mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-17 00:01:14 +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,41 @@
|
||||
# Matchmaking integration harnesses
|
||||
|
||||
Two harnesses for the matchmaking client integration (the `matchmaking-modal`
|
||||
close-code/reconnect contract from the matchmaking API handoff). Neither runs
|
||||
as part of `npm test` — they drive a real headless browser.
|
||||
|
||||
## Contained — `npm run test:matchmaking`
|
||||
|
||||
Integration test against a **fake matchmaking server** (`fakeServer.mjs`, an
|
||||
in-process `ws` server speaking the documented protocol). The browser's
|
||||
`WebSocket` for `/matchmaking/join` is redirected to it, so real close-code
|
||||
semantics apply. Covers:
|
||||
|
||||
| Scenario | Expected client behavior |
|
||||
| ----------------------------------- | ------------------------------- |
|
||||
| join after connect | `{type:"join", jwt}` sent |
|
||||
| abrupt drop (deploy/restart, 1006) | reconnect + rejoin (backoff) |
|
||||
| 1008 `Invalid session` | reconnect + rejoin, fresh token |
|
||||
| `match-assignment` | gameId recorded, socket done |
|
||||
| 1000 `Replaced by newer connection` | message shown, **no** retry |
|
||||
| intentional close (user backs out) | no retry, no message |
|
||||
|
||||
Prerequisite: `npm run dev` (app on :9000). No API worker needed.
|
||||
|
||||
## E2E — `npm run test:matchmaking:e2e`
|
||||
|
||||
Real integration against the **API worker on `localhost:8787`**. Two browser
|
||||
players join the real queue through the real modal; the dev game server's
|
||||
`/matchmaking/checkin` long-poll receives the assignment and creates the
|
||||
game; the test asserts both players get the same `gameId` and dispatch
|
||||
`join-lobby` once the game exists.
|
||||
|
||||
Prerequisites:
|
||||
|
||||
- `npm run dev` (app + game server on :9000; the game server polls checkin
|
||||
on :8787 out of the box in dev)
|
||||
- the API worker running locally: `wrangler dev` in the API repo (port 8787)
|
||||
|
||||
On failure the harness dumps both players' browser consoles — close code
|
||||
1008 means the worker rejected the play token; no assignment usually means
|
||||
the worker rejected the game server's `x-api-key` checkin.
|
||||
@@ -0,0 +1,139 @@
|
||||
// Contained matchmaking integration test: drives the real matchmaking modal
|
||||
// in the real app against a fake matchmaking server (fakeServer.mjs) that
|
||||
// speaks the documented protocol. Covers the close-code contract:
|
||||
// - unexpected close (deploy) -> reconnect + rejoin
|
||||
// - 1008 Invalid session -> reconnect + rejoin (fresh token)
|
||||
// - 1000 Replaced by newer connection-> message shown, NO retry
|
||||
// - intentional close (user backs out or assignment received) -> no retry
|
||||
//
|
||||
// Prerequisite: the dev app must be running (`npm run dev`, port 9000).
|
||||
// Run: npm run test:matchmaking
|
||||
|
||||
import {
|
||||
gotoHome,
|
||||
launch,
|
||||
} from "../../.claude/skills/run-openfront/driver.mjs";
|
||||
import { startFakeMatchmakingServer } from "./fakeServer.mjs";
|
||||
import { isUp, makeChecker, waitFor } from "./util.mjs";
|
||||
|
||||
if (!(await isUp("http://localhost:9000"))) {
|
||||
console.error(
|
||||
"Dev app is not running on :9000 — start it with `npm run dev`.",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const fake = await startFakeMatchmakingServer();
|
||||
const control = async (path, body) => {
|
||||
const res = await fetch(`${fake.controlUrl}/${path}`, {
|
||||
method: "POST",
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
return res.json();
|
||||
};
|
||||
const joins = async () => {
|
||||
const res = await fetch(`${fake.controlUrl}/state`);
|
||||
return (await res.json()).joins;
|
||||
};
|
||||
const joinCountReaches = (n, timeoutMs) =>
|
||||
waitFor(async () => (await joins()).length >= n, {
|
||||
timeoutMs,
|
||||
label: `join #${n} to reach the fake server`,
|
||||
});
|
||||
|
||||
const { browser, page } = await launch();
|
||||
const c = makeChecker();
|
||||
try {
|
||||
await gotoHome(page);
|
||||
|
||||
// Redirect the modal's /matchmaking/join socket to the fake server while
|
||||
// keeping real browser WebSocket (and close-code) semantics.
|
||||
await page.evaluate((wsUrl) => {
|
||||
const Real = window.WebSocket;
|
||||
window.WebSocket = class extends Real {
|
||||
constructor(url, protocols) {
|
||||
const s = String(url);
|
||||
if (s.includes("/matchmaking/join")) {
|
||||
super(
|
||||
`${wsUrl}/matchmaking/join?${s.split("?")[1] ?? ""}`,
|
||||
protocols,
|
||||
);
|
||||
} else {
|
||||
super(url, protocols);
|
||||
}
|
||||
}
|
||||
};
|
||||
window.__mmMessages = [];
|
||||
window.addEventListener("show-message", (e) =>
|
||||
window.__mmMessages.push(e.detail?.message),
|
||||
);
|
||||
}, fake.wsUrl);
|
||||
|
||||
const modal = (body) =>
|
||||
page.evaluate(`(() => {
|
||||
const el = document.querySelector("matchmaking-modal");
|
||||
${body}
|
||||
})()`);
|
||||
const resetAndConnect = () =>
|
||||
modal(`el.gameID = null;
|
||||
el.intentionalClose = false;
|
||||
el.reconnectAttempts = 0;
|
||||
el.connect();`);
|
||||
|
||||
// 1. Joining the queue: connect -> join arrives (after the modal's 2s delay)
|
||||
await resetAndConnect();
|
||||
await joinCountReaches(1, 8000);
|
||||
c.check("join sent after connect", true);
|
||||
|
||||
// 2. Deploy/restart: server drops the socket abruptly -> reconnect + rejoin
|
||||
await control("kill");
|
||||
await joinCountReaches(2, 10000);
|
||||
c.check("unexpected close -> reconnected and rejoined", true);
|
||||
|
||||
// 3. Invalid session: next join is closed 1008 -> client retries and rejoins
|
||||
await control("reject-next");
|
||||
await control("kill"); // forces the reconnect whose join gets 1008
|
||||
await joinCountReaches(4, 20000); // join 3 rejected, join 4 accepted
|
||||
c.check("1008 -> reconnected and rejoined with fresh token", true);
|
||||
|
||||
// 4. Assignment: modal records the gameId
|
||||
await control("assign", { gameId: "FakeGame1" });
|
||||
await waitFor(() => modal(`return el.gameID === "FakeGame1";`), {
|
||||
timeoutMs: 5000,
|
||||
label: "modal to receive match-assignment",
|
||||
});
|
||||
c.check("match-assignment received", true);
|
||||
await modal(`el.onClose();`); // stop the game-exists polling
|
||||
|
||||
// 5. Replaced by newer connection: message shown, no retry
|
||||
await resetAndConnect();
|
||||
await joinCountReaches(5, 8000);
|
||||
await control("replace");
|
||||
await waitFor(() => page.evaluate(() => window.__mmMessages.length > 0), {
|
||||
timeoutMs: 5000,
|
||||
label: "replaced message",
|
||||
});
|
||||
const msg = await page.evaluate(() => window.__mmMessages.at(-1));
|
||||
c.check(
|
||||
`replaced -> message shown ("${msg}")`,
|
||||
typeof msg === "string" && !msg.includes("matchmaking_modal."),
|
||||
);
|
||||
await new Promise((r) => setTimeout(r, 3500));
|
||||
c.check("replaced -> no retry", (await joins()).length === 5);
|
||||
|
||||
// 6. Intentional close (user backs out): no retry, no message
|
||||
await resetAndConnect();
|
||||
await joinCountReaches(6, 8000);
|
||||
const msgsBefore = await page.evaluate(() => window.__mmMessages.length);
|
||||
await modal(`el.onClose();`);
|
||||
await new Promise((r) => setTimeout(r, 3500));
|
||||
c.check("intentional close -> no retry", (await joins()).length === 6);
|
||||
c.check(
|
||||
"intentional close -> no message",
|
||||
(await page.evaluate(() => window.__mmMessages.length)) === msgsBefore,
|
||||
);
|
||||
} finally {
|
||||
await browser.close();
|
||||
await fake.close();
|
||||
}
|
||||
c.finish();
|
||||
@@ -0,0 +1,116 @@
|
||||
// End-to-end matchmaking integration test. Requires the REAL stack:
|
||||
// - the API worker running on localhost:8787 (wrangler dev in the API repo)
|
||||
// - the dev app running on localhost:9000 (`npm run dev` — its game server
|
||||
// long-polls the worker's /matchmaking/checkin out of the box in dev)
|
||||
//
|
||||
// Drives two real browser players through the real matchmaking modal:
|
||||
// both join the queue on the worker, the matcher pairs them, the local game
|
||||
// server receives the checkin assignment and creates the game, and both
|
||||
// clients see the game exist and dispatch join-lobby.
|
||||
//
|
||||
// Run: npm run test:matchmaking:e2e
|
||||
|
||||
import {
|
||||
gotoHome,
|
||||
launch,
|
||||
} from "../../.claude/skills/run-openfront/driver.mjs";
|
||||
import { isUp, makeChecker, waitFor } from "./util.mjs";
|
||||
|
||||
if (!(await isUp("http://localhost:9000"))) {
|
||||
console.error(
|
||||
"Dev app is not running on :9000 — start it with `npm run dev`.",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
if (!(await isUp("http://localhost:8787"))) {
|
||||
console.error(
|
||||
"API worker is not running on :8787 — start it with `wrangler dev` in the API repo.",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const { browser, page: page1 } = await launch();
|
||||
const context2 = await browser.newContext({
|
||||
viewport: { width: 1400, height: 1000 },
|
||||
});
|
||||
const page2 = await context2.newPage();
|
||||
|
||||
const pages = [page1, page2];
|
||||
const consoles = pages.map(() => []);
|
||||
pages.forEach((p, i) => p.on("console", (msg) => consoles[i].push(msg.text())));
|
||||
|
||||
const dumpConsoles = () => {
|
||||
pages.forEach((_, i) => {
|
||||
console.error(`\n--- player ${i + 1} console (last 25 lines) ---`);
|
||||
for (const line of consoles[i].slice(-25)) console.error(line);
|
||||
});
|
||||
console.error(
|
||||
"\nHints: close code 1008 means the worker rejected the play token; " +
|
||||
"no assignment at all usually means the worker isn't accepting the " +
|
||||
"game server's checkin (x-api-key) or the matcher isn't running.",
|
||||
);
|
||||
};
|
||||
|
||||
const c = makeChecker();
|
||||
try {
|
||||
// Two contexts = separate localStorage = two distinct players.
|
||||
for (const p of pages) {
|
||||
await gotoHome(p);
|
||||
await p.evaluate(() => {
|
||||
window.__joinLobby = null;
|
||||
document.addEventListener(
|
||||
"join-lobby",
|
||||
(e) => (window.__joinLobby = e.detail ?? {}),
|
||||
);
|
||||
document.querySelector("matchmaking-modal").connect();
|
||||
});
|
||||
}
|
||||
|
||||
const gameIdOf = (p) =>
|
||||
p.evaluate(() => document.querySelector("matchmaking-modal").gameID);
|
||||
|
||||
let gameIds;
|
||||
try {
|
||||
gameIds = await waitFor(
|
||||
async () => {
|
||||
const ids = await Promise.all(pages.map(gameIdOf));
|
||||
return ids.every(Boolean) ? ids : null;
|
||||
},
|
||||
{
|
||||
timeoutMs: 90000,
|
||||
intervalMs: 1000,
|
||||
label: "both players to receive a match-assignment",
|
||||
},
|
||||
);
|
||||
} catch (err) {
|
||||
dumpConsoles();
|
||||
throw err;
|
||||
}
|
||||
c.check(`both players received an assignment (${gameIds[0]})`, true);
|
||||
c.check("both players got the same gameId", gameIds[0] === gameIds[1]);
|
||||
|
||||
// The modal polls the game server until the assigned game exists, then
|
||||
// dispatches join-lobby — this proves the checkin/creation side worked.
|
||||
try {
|
||||
await waitFor(
|
||||
async () => {
|
||||
const details = await Promise.all(
|
||||
pages.map((p) => p.evaluate(() => window.__joinLobby)),
|
||||
);
|
||||
return details.every((d) => d?.gameID === gameIds[0]);
|
||||
},
|
||||
{
|
||||
timeoutMs: 45000,
|
||||
intervalMs: 1000,
|
||||
label: "both players to dispatch join-lobby for the created game",
|
||||
},
|
||||
);
|
||||
c.check("game created on the game server and joinable by both", true);
|
||||
} catch (err) {
|
||||
dumpConsoles();
|
||||
throw err;
|
||||
}
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
c.finish();
|
||||
@@ -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)),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
// Shared helpers for the matchmaking harnesses (contained + e2e).
|
||||
|
||||
export async function waitFor(
|
||||
fn,
|
||||
{ timeoutMs = 10000, intervalMs = 200, label = "condition" } = {},
|
||||
) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
for (;;) {
|
||||
const value = await fn();
|
||||
if (value) return value;
|
||||
if (Date.now() > deadline) {
|
||||
throw new Error(`Timed out after ${timeoutMs}ms waiting for: ${label}`);
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, intervalMs));
|
||||
}
|
||||
}
|
||||
|
||||
// Any HTTP response (including 404) counts as "up"; only a network error
|
||||
// (nothing listening) counts as down.
|
||||
export async function isUp(url) {
|
||||
try {
|
||||
await fetch(url, { signal: AbortSignal.timeout(2000) });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function makeChecker() {
|
||||
const results = [];
|
||||
return {
|
||||
check(name, cond) {
|
||||
results.push({ name, pass: !!cond });
|
||||
console.log(`${cond ? "PASS" : "FAIL"} ${name}`);
|
||||
},
|
||||
finish() {
|
||||
const failed = results.filter((r) => !r.pass);
|
||||
console.log(
|
||||
`\n${results.length - failed.length}/${results.length} checks passed`,
|
||||
);
|
||||
process.exit(failed.length > 0 ? 1 : 0);
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user