mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-23 13:15:37 +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:
@@ -15,6 +15,9 @@ import { translateText } from "./Utils";
|
||||
export class MatchmakingModal extends BaseModal {
|
||||
private gameCheckInterval: ReturnType<typeof setInterval> | null = null;
|
||||
private connectTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
private reconnectTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
private reconnectAttempts = 0;
|
||||
private intentionalClose = false;
|
||||
@state() private connected = false;
|
||||
@state() private socket: WebSocket | null = null;
|
||||
@state() private gameID: string | null = null;
|
||||
@@ -71,6 +74,11 @@ export class MatchmakingModal extends BaseModal {
|
||||
}
|
||||
|
||||
private async connect() {
|
||||
// A pending join timer from a previous socket must not fire on this one.
|
||||
if (this.connectTimeout) {
|
||||
clearTimeout(this.connectTimeout);
|
||||
this.connectTimeout = null;
|
||||
}
|
||||
this.socket = new WebSocket(
|
||||
`${ClientEnv.jwtIssuer()}/matchmaking/join?instance_id=${encodeURIComponent(ClientEnv.instanceId())}`,
|
||||
);
|
||||
@@ -99,6 +107,7 @@ export class MatchmakingModal extends BaseModal {
|
||||
console.log(event.data);
|
||||
const data = JSON.parse(event.data);
|
||||
if (data.type === "match-assignment") {
|
||||
this.intentionalClose = true;
|
||||
this.socket?.close();
|
||||
console.log(`matchmaking: got game ID: ${data.gameId}`);
|
||||
this.gameID = data.gameId;
|
||||
@@ -108,8 +117,35 @@ export class MatchmakingModal extends BaseModal {
|
||||
this.socket.onerror = (event: Event) => {
|
||||
console.error("WebSocket error occurred:", event);
|
||||
};
|
||||
this.socket.onclose = () => {
|
||||
console.log("Matchmaking server closed connection");
|
||||
this.socket.onclose = (event: CloseEvent) => {
|
||||
console.log(
|
||||
`Matchmaking server closed connection: code=${event.code} reason=${event.reason}`,
|
||||
);
|
||||
if (this.intentionalClose || this.gameID !== null) {
|
||||
return;
|
||||
}
|
||||
if (event.code === 1000) {
|
||||
// A newer connection for this account (e.g. a second tab) took the
|
||||
// queue slot; this socket was replaced. Do not retry.
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("show-message", {
|
||||
detail: {
|
||||
message: translateText("matchmaking_modal.replaced"),
|
||||
color: "red",
|
||||
duration: 5000,
|
||||
},
|
||||
}),
|
||||
);
|
||||
this.close();
|
||||
return;
|
||||
}
|
||||
// 1008: the jwt was rejected — getPlayToken() refreshes expired tokens,
|
||||
// so rejoining sends a fresh one. Anything else is a server
|
||||
// restart/deploy; the queue is in-memory only, so rejoin. Back off in
|
||||
// case the failure repeats.
|
||||
this.connected = false;
|
||||
const delay = Math.min(1000 * 2 ** this.reconnectAttempts++, 15000);
|
||||
this.reconnectTimeout = setTimeout(() => this.connect(), delay);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -154,16 +190,23 @@ export class MatchmakingModal extends BaseModal {
|
||||
|
||||
this.connected = false;
|
||||
this.gameID = null;
|
||||
this.intentionalClose = false;
|
||||
this.reconnectAttempts = 0;
|
||||
this.connect();
|
||||
}
|
||||
|
||||
protected onClose(): void {
|
||||
this.connected = false;
|
||||
this.intentionalClose = true;
|
||||
this.socket?.close();
|
||||
if (this.connectTimeout) {
|
||||
clearTimeout(this.connectTimeout);
|
||||
this.connectTimeout = null;
|
||||
}
|
||||
if (this.reconnectTimeout) {
|
||||
clearTimeout(this.reconnectTimeout);
|
||||
this.reconnectTimeout = null;
|
||||
}
|
||||
if (this.gameCheckInterval) {
|
||||
clearInterval(this.gameCheckInterval);
|
||||
this.gameCheckInterval = null;
|
||||
|
||||
Reference in New Issue
Block a user