mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-23 16:05:34 +00:00
Handle matchmaking socket close codes per API contract (#4595)
## What Implements the close-code contract from the matchmaking API handoff (API PR #419) in the matchmaking modal, plus two runnable integration harnesses. Previously `onclose` only logged, so an API deploy while queued left the player on the "searching" spinner forever (the queue is in-memory on the API worker). | Close | Behavior | | --- | --- | | 1008 `Invalid session` | Reconnect and re-send `join` — `getPlayToken()` refreshes an expired token internally, so the rejoin carries a fresh JWT | | 1000 `Replaced by newer connection` | Another tab/window took the queue slot: show a message (new `matchmaking_modal.replaced` string) and stop. No retry | | Any other close before assignment | Server restart/deploy: reconnect and re-send `join`, with exponential backoff (1s doubling to a 15s cap) | Intentional closes (user backs out of the modal, assignment received) don't reconnect. A pending 2s join timer from a previous socket is cleared before each reconnect so it can't fire on the new socket. ## Test harnesses (`tests/matchmaking/`) - **`npm run test:matchmaking`** (contained): drives the real modal in a headless browser against a fake in-process matchmaking server speaking the documented protocol; covers the whole close-code table over real WebSockets. Needs only `npm run dev`. - **`npm run test:matchmaking:e2e`**: real integration against the API worker on `localhost:8787` — two browser players join the real queue, the dev game server receives the checkin assignment, and both clients end up in the same created game. ## Why now This is required by the matchmaking API's client contract independent of the upcoming 2v2 work ("clients must already handle unexpected close → reconnect and rejoin"). The rest of the 2v2 integration is planned for the next version. ## Verification - Contained harness: 8/8 checks pass (join, 1006 reconnect+rejoin, 1008 rejoin with fresh token, assignment, replaced → message + no retry, intentional close → no retry/no message). - E2E harness against a local worker: 3/3 — both players matched into the same game, game created via checkin and joinable by both. - `npm test` (all 2,046 pass), `npx tsc --noEmit`, ESLint clean. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,101 @@
|
||||
// Fake matchmaking server implementing the API worker's documented protocol
|
||||
// (see the matchmaking integration handoff): WS /matchmaking/join, the
|
||||
// {type:"join", jwt} message, {type:"match-assignment", gameId}, and the
|
||||
// close-code contract (1008 invalid session, 1000 replaced by newer
|
||||
// connection). A control API lets tests trigger each server-side behavior.
|
||||
|
||||
import http from "node:http";
|
||||
import { WebSocketServer } from "ws";
|
||||
|
||||
export async function startFakeMatchmakingServer() {
|
||||
const state = {
|
||||
joins: [], // every {type:"join"} ever received: { jwt, at }
|
||||
sockets: new Map(), // jwt -> ws holding the queue slot
|
||||
rejectNextJoin: false,
|
||||
};
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
let body = "";
|
||||
req.on("data", (c) => (body += c));
|
||||
req.on("end", () => {
|
||||
const send = (obj) => {
|
||||
res.setHeader("content-type", "application/json");
|
||||
res.end(JSON.stringify(obj));
|
||||
};
|
||||
switch (new URL(req.url, "http://localhost").pathname) {
|
||||
case "/control/state":
|
||||
return send({
|
||||
joins: state.joins,
|
||||
queued: [...state.sockets.keys()],
|
||||
});
|
||||
case "/control/reject-next": // next join gets 1008 Invalid session
|
||||
state.rejectNextJoin = true;
|
||||
return send({ ok: true });
|
||||
case "/control/kill": // abrupt drop (deploy/restart) -> client sees 1006
|
||||
for (const ws of state.sockets.values()) ws.terminate();
|
||||
state.sockets.clear();
|
||||
return send({ ok: true });
|
||||
case "/control/replace": // queue slot taken by a newer connection
|
||||
for (const ws of state.sockets.values()) {
|
||||
ws.close(1000, "Replaced by newer connection");
|
||||
}
|
||||
state.sockets.clear();
|
||||
return send({ ok: true });
|
||||
case "/control/assign": {
|
||||
const { gameId } = JSON.parse(body || "{}");
|
||||
for (const ws of state.sockets.values()) {
|
||||
ws.send(JSON.stringify({ type: "match-assignment", gameId }));
|
||||
}
|
||||
return send({ ok: true });
|
||||
}
|
||||
default:
|
||||
res.statusCode = 404;
|
||||
return res.end();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const wss = new WebSocketServer({
|
||||
server,
|
||||
// Real worker rejects a missing instance_id with HTTP 400 pre-upgrade.
|
||||
verifyClient: ({ req }) =>
|
||||
new URL(req.url, "http://localhost").searchParams.has("instance_id"),
|
||||
});
|
||||
|
||||
wss.on("connection", (ws) => {
|
||||
ws.on("message", (raw) => {
|
||||
let msg;
|
||||
try {
|
||||
msg = JSON.parse(raw.toString());
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (msg.type !== "join") return;
|
||||
state.joins.push({ jwt: msg.jwt, at: Date.now() });
|
||||
if (state.rejectNextJoin) {
|
||||
state.rejectNextJoin = false;
|
||||
ws.close(1008, "Invalid session");
|
||||
return;
|
||||
}
|
||||
const prev = state.sockets.get(msg.jwt);
|
||||
if (prev && prev !== ws) {
|
||||
prev.close(1000, "Replaced by newer connection");
|
||||
}
|
||||
state.sockets.set(msg.jwt, ws);
|
||||
});
|
||||
ws.on("close", () => {
|
||||
for (const [jwt, s] of state.sockets) {
|
||||
if (s === ws) state.sockets.delete(jwt);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
await new Promise((r) => server.listen(0, "127.0.0.1", r));
|
||||
const port = server.address().port;
|
||||
return {
|
||||
port,
|
||||
wsUrl: `ws://127.0.0.1:${port}`,
|
||||
controlUrl: `http://127.0.0.1:${port}/control`,
|
||||
close: () => new Promise((r) => server.close(r)),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user