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:
Evan
2026-07-13 10:55:15 -07:00
committed by GitHub
co-authored by Claude Fable 5
parent 558f2e20db
commit e76b34be22
9 changed files with 490 additions and 2 deletions
+139
View File
@@ -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();