From 1306a2d20066e1181b42e85fb4ed393935733657 Mon Sep 17 00:00:00 2001 From: evanpelle Date: Wed, 22 Jul 2026 10:43:42 -0700 Subject: [PATCH] Reject version-mismatched joins, prompt refresh on homepage after deploys The sim runs on each client and is only deterministic when every client in a game executes identical code. After a deploy, tabs opened before the LB flip still run the old bundle but connect (per-connection routing) to the new deployment, join fresh games alongside new-bundle clients, and desync. Two mechanisms close this: - Join gate: join/rejoin messages carry the client bundle's gitCommit; the worker rejects mismatches (missing = pre-feature = stale) with a typed version_mismatch error before token verification. The client answers the error with an update alert and reloads to pull the new bundle. - Homepage drain: the lobby feed's full snapshot advertises the server's commit. The homepage lobby socket (only active outside games) fires once on mismatch, shows the update alert, and reloads, so stale tabs migrate to the new version between games instead of at their next failed join. Dev is unaffected (both sides use GIT_COMMIT=DEV). An e2e test spawns the real server and drives the actual join handler and lobby feed over real WebSockets (npm run test:e2e; opt-in since it binds ports 3000/3001). Co-Authored-By: Claude Fable 5 --- package.json | 1 + resources/lang/en.json | 3 + src/client/ClientGameRunner.ts | 6 + src/client/GameModeSelector.ts | 14 ++- src/client/LobbySocket.ts | 18 +++ src/client/Transport.ts | 2 + src/core/Schemas.ts | 11 ++ src/server/Worker.ts | 20 +++ src/server/WorkerLobbyService.ts | 3 + tests/ClientVersionSchemas.test.ts | 71 +++++++++++ tests/e2e/VersionCheck.e2e.test.ts | 192 +++++++++++++++++++++++++++++ tests/setup.ts | 4 + 12 files changed, 343 insertions(+), 2 deletions(-) create mode 100644 tests/ClientVersionSchemas.test.ts create mode 100644 tests/e2e/VersionCheck.e2e.test.ts diff --git a/package.json b/package.json index 0ff687aa34..ad372c3373 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "docs:map-generator": "cd map-generator && go doc -cmd -u -all", "tunnel": "npm run build-prod && npm run start:server", "test": "vitest run && vitest run tests/server", + "test:e2e": "cross-env E2E=1 vitest run tests/e2e", "test:matchmaking": "node tests/matchmaking/contained.mjs", "test:matchmaking:e2e": "node tests/matchmaking/e2e.mjs", "perf": "npx tsx tests/perf/run-all.ts", diff --git a/resources/lang/en.json b/resources/lang/en.json index bf2eb1cf6d..66150e10ec 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -1504,6 +1504,9 @@ "sam_launcher": "SAM Launcher", "warship": "Warship" }, + "update_available": { + "message": "A new version of OpenFront is available. The page will now refresh to update." + }, "user_setting": { "alert_frame_desc": "Toggle the alert frame. When enabled, the frame will be displayed when you are betrayed or attacked over land.", "alert_frame_label": "Alert Frame", diff --git a/src/client/ClientGameRunner.ts b/src/client/ClientGameRunner.ts index 9f99bd91ca..fd82ece3ab 100644 --- a/src/client/ClientGameRunner.ts +++ b/src/client/ClientGameRunner.ts @@ -229,6 +229,12 @@ export function joinLobby( }), ); }); + } else if (message.error === "version_mismatch") { + // The server runs a newer build than this bundle (tab left open + // across a deploy). Reload to pick up the new version. + showInGameAlert(translateText("update_available.message")).then(() => { + window.location.reload(); + }); } else { showErrorModal( message.error, diff --git a/src/client/GameModeSelector.ts b/src/client/GameModeSelector.ts index fe77425027..56b5c78a92 100644 --- a/src/client/GameModeSelector.ts +++ b/src/client/GameModeSelector.ts @@ -12,6 +12,7 @@ import { import { PublicGameInfo, PublicGames } from "../core/Schemas"; import "./components/IOSAddToHomeScreenBanner"; import { HostLobbyModal } from "./HostLobbyModal"; +import { showInGameAlert } from "./InGameModal"; import { JoinLobbyModal } from "./JoinLobbyModal"; import { PublicLobbySocket } from "./LobbySocket"; import { JoinLobbyEvent } from "./Main"; @@ -37,10 +38,19 @@ export class GameModeSelector extends LitElement { private serverTimeOffset: number = 0; private defaultLobbyTime: number = 0; - private lobbySocket = new PublicLobbySocket((lobbies) => - this.handleLobbiesUpdate(lobbies), + private lobbySocket = new PublicLobbySocket( + (lobbies) => this.handleLobbiesUpdate(lobbies), + // This socket only runs while the player is on the homepage (Main.ts + // stops it when a game is joined), so it's always safe to reload here. + { onUpdateAvailable: () => this.handleUpdateAvailable() }, ); + private handleUpdateAvailable() { + showInGameAlert(translateText("update_available.message")).then(() => { + window.location.reload(); + }); + } + createRenderRoot() { return this; } diff --git a/src/client/LobbySocket.ts b/src/client/LobbySocket.ts index dbd114e04c..3b4e3dda00 100644 --- a/src/client/LobbySocket.ts +++ b/src/client/LobbySocket.ts @@ -5,6 +5,9 @@ interface LobbySocketOptions { reconnectDelay?: number; maxWsAttempts?: number; pollIntervalMs?: number; + // Fired at most once, when the server advertises a different build commit + // than this bundle — i.e. a new version deployed while this tab was open. + onUpdateAvailable?: () => void; } function getRandomWorkerPath(numWorkers: number): string { @@ -24,6 +27,8 @@ export class PublicLobbySocket { private readonly reconnectDelay: number; private readonly maxWsAttempts: number; + private readonly onUpdateAvailable?: () => void; + private updateAvailableFired = false; constructor( private onLobbiesUpdate: (data: PublicGames) => void, @@ -31,6 +36,7 @@ export class PublicLobbySocket { ) { this.reconnectDelay = options?.reconnectDelay ?? 3000; this.maxWsAttempts = options?.maxWsAttempts ?? 3; + this.onUpdateAvailable = options?.onUpdateAvailable; } async start() { @@ -88,6 +94,7 @@ export class PublicLobbySocket { JSON.parse(event.data as string), ); if (message.type === "full") { + this.checkServerCommit(message.gitCommit); this.lastFull = { serverTime: message.serverTime, games: message.games, @@ -134,6 +141,17 @@ export class PublicLobbySocket { } } + private checkServerCommit(serverCommit: string | undefined) { + if (this.updateAvailableFired || this.onUpdateAvailable === undefined) { + return; + } + if (serverCommit === undefined) return; + const ownCommit = ClientEnv.gitCommit(); + if (ownCommit === "DEV" || serverCommit === ownCommit) return; + this.updateAvailableFired = true; + this.onUpdateAvailable(); + } + private handleClose() { if (this.stopped) return; console.log("WebSocket disconnected, attempting to reconnect..."); diff --git a/src/client/Transport.ts b/src/client/Transport.ts index f57e761c9f..5056077b1e 100644 --- a/src/client/Transport.ts +++ b/src/client/Transport.ts @@ -427,6 +427,7 @@ export class Transport { cosmetics: this.lobbyConfig.cosmetics, turnstileToken: this.lobbyConfig.turnstileToken, token: await getPlayToken(), + gitCommit: ClientEnv.gitCommit(), } satisfies ClientJoinMessage); } @@ -437,6 +438,7 @@ export class Transport { // Note: clientID is not sent - server looks it up from persistentID in token lastTurn: lastTurn, token: await getPlayToken(), + gitCommit: ClientEnv.gitCommit(), } satisfies ClientRejoinMessage); } diff --git a/src/core/Schemas.ts b/src/core/Schemas.ts index f47dfa1961..c79c0a367a 100644 --- a/src/core/Schemas.ts +++ b/src/core/Schemas.ts @@ -250,6 +250,10 @@ export const PublicLobbyFullSchema = z.object({ type: z.literal("full"), serverTime: z.number(), games: z.partialRecord(PublicGameTypeSchema, z.array(PublicGameInfoSchema)), + // Build commit of the serving deployment. Clients on the homepage compare + // it to their own bundle's commit to detect that a new version deployed + // and prompt a refresh. Optional so clients tolerate older servers. + gitCommit: z.string().max(64).optional(), }); export const PublicLobbyCountsSchema = z.object({ @@ -879,6 +883,11 @@ export const ClientJoinMessageSchema = z.object({ // Server replaces the refs with the actual cosmetic data. cosmetics: PlayerCosmeticRefsSchema.optional(), turnstileToken: z.string().nullable(), + // Build commit of the client bundle. The sim only stays deterministic when + // every client in a game runs identical code, so the server rejects joins + // whose commit doesn't match its own (missing counts as a mismatch — + // pre-feature bundles are by definition stale). + gitCommit: z.string().max(64).optional(), }); export const ClientRejoinMessageSchema = z.object({ @@ -887,6 +896,8 @@ export const ClientRejoinMessageSchema = z.object({ // Note: clientID is NOT sent - server looks it up from persistentID in token lastTurn: z.number(), token: TokenSchema, + // See ClientJoinMessageSchema.gitCommit. + gitCommit: z.string().max(64).optional(), }); export const ClientMessageSchema = z.discriminatedUnion("type", [ diff --git a/src/server/Worker.ts b/src/server/Worker.ts index 8e7c4e7fdb..ad7c96ec4b 100644 --- a/src/server/Worker.ts +++ b/src/server/Worker.ts @@ -451,6 +451,26 @@ export async function startWorker() { return; } + // The sim is deterministic only when every client in a game runs + // identical code, so a client built from a different commit (e.g. a + // tab left open across a deploy) would desync the game. Reject it + // with a typed error the client answers by refreshing. A missing + // commit means a pre-feature bundle, which is stale by definition. + if (clientMsg.gitCommit !== ServerEnv.gitCommit()) { + log.info("rejecting version-mismatched client", { + gameID: clientMsg.gameID, + clientCommit: clientMsg.gitCommit, + }); + ws.send( + JSON.stringify({ + type: "error", + error: "version_mismatch", + } satisfies ServerErrorMessage), + ); + ws.close(1002, "Version mismatch"); + return; + } + // Verify token signature const result = await verifyClientToken(clientMsg.token); if (result.type === "error") { diff --git a/src/server/WorkerLobbyService.ts b/src/server/WorkerLobbyService.ts index 01e34572dd..d77eba46a6 100644 --- a/src/server/WorkerLobbyService.ts +++ b/src/server/WorkerLobbyService.ts @@ -15,6 +15,7 @@ import { WorkerReady, } from "./IPCBridgeSchema"; import { logger } from "./Logger"; +import { ServerEnv } from "./ServerEnv"; // The game config advertised for a listed private lobby: everything the // host configured minus host-only fields. The server already rejects @@ -256,6 +257,7 @@ export class WorkerLobbyService { type: "full", serverTime: this.lastPublicGames.serverTime, games: this.sanitizeGames(this.lastPublicGames.games), + gitCommit: ServerEnv.gitCommit(), } satisfies PublicLobbyMessage); ws.send(fullJson); } @@ -307,6 +309,7 @@ export class WorkerLobbyService { type: "full", serverTime: publicGames.serverTime, games: sanitizedGames, + gitCommit: ServerEnv.gitCommit(), }; this.lastFullGameIds = fingerprint; } else { diff --git a/tests/ClientVersionSchemas.test.ts b/tests/ClientVersionSchemas.test.ts new file mode 100644 index 0000000000..418dd5635a --- /dev/null +++ b/tests/ClientVersionSchemas.test.ts @@ -0,0 +1,71 @@ +import { + ClientJoinMessageSchema, + ClientRejoinMessageSchema, + PublicLobbyMessageSchema, +} from "../src/core/Schemas"; + +const COMMIT = "a".repeat(40); + +const baseJoin = { + type: "join", + token: "123e4567-e89b-12d3-a456-426614174000", + gameID: "abcd1234", + username: "TestPlayer", + clanTag: null, + turnstileToken: null, +}; + +const baseRejoin = { + type: "rejoin", + gameID: "abcd1234", + lastTurn: 10, + token: "123e4567-e89b-12d3-a456-426614174000", +}; + +describe("gitCommit on join/rejoin messages", () => { + test("join parses with gitCommit", () => { + const result = ClientJoinMessageSchema.safeParse({ + ...baseJoin, + gitCommit: COMMIT, + }); + expect(result.success).toBe(true); + expect(result.data?.gitCommit).toBe(COMMIT); + }); + + test("join parses without gitCommit (pre-feature clients)", () => { + const result = ClientJoinMessageSchema.safeParse(baseJoin); + expect(result.success).toBe(true); + expect(result.data?.gitCommit).toBeUndefined(); + }); + + test("join rejects an oversized gitCommit", () => { + const result = ClientJoinMessageSchema.safeParse({ + ...baseJoin, + gitCommit: "a".repeat(65), + }); + expect(result.success).toBe(false); + }); + + test("rejoin parses with and without gitCommit", () => { + expect( + ClientRejoinMessageSchema.safeParse({ ...baseRejoin, gitCommit: COMMIT }) + .success, + ).toBe(true); + expect(ClientRejoinMessageSchema.safeParse(baseRejoin).success).toBe(true); + }); +}); + +describe("gitCommit on the public lobby feed", () => { + test("full message parses with and without gitCommit", () => { + const full = { type: "full", serverTime: 123, games: {} }; + expect(PublicLobbyMessageSchema.safeParse(full).success).toBe(true); + const withCommit = PublicLobbyMessageSchema.safeParse({ + ...full, + gitCommit: COMMIT, + }); + expect(withCommit.success).toBe(true); + if (withCommit.success && withCommit.data.type === "full") { + expect(withCommit.data.gitCommit).toBe(COMMIT); + } + }); +}); diff --git a/tests/e2e/VersionCheck.e2e.test.ts b/tests/e2e/VersionCheck.e2e.test.ts new file mode 100644 index 0000000000..ac7deb8b64 --- /dev/null +++ b/tests/e2e/VersionCheck.e2e.test.ts @@ -0,0 +1,192 @@ +import { spawn, type ChildProcess } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import path from "node:path"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { WebSocket } from "ws"; + +// End-to-end test of the deploy version gate: spawns the real server +// (master + 1 worker) as a child process and exercises the actual +// WebSocket join handler and lobby feed. +// +// Opt-in (npm run test:e2e) because it binds the real dev ports 3000/3001 +// and would collide with a running `npm run dev`. + +const SERVER_COMMIT = "a".repeat(40); +const WRONG_COMMIT = "b".repeat(40); +const WORKER_HTTP = "http://127.0.0.1:3001"; +const WORKER_WS = "ws://127.0.0.1:3001"; + +interface JoinResult { + messages: any[]; + closeCode: number | undefined; +} + +describe.skipIf(process.env.E2E !== "1")("deploy version gate (e2e)", () => { + let server: ChildProcess; + let gameID: string; + const serverLogs: string[] = []; + + // Doubles as the readiness probe: retried until the worker accepts it. + async function createGame(): Promise { + const deadline = Date.now() + 45_000; + let lastError: unknown; + while (Date.now() < deadline) { + try { + const res = await fetch(`${WORKER_HTTP}/w0/api/create_game`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${randomUUID()}`, + }, + body: JSON.stringify({}), + }); + if (res.ok) { + const info = (await res.json()) as { gameID: string }; + return info.gameID; + } + lastError = new Error(`create_game returned ${res.status}`); + } catch (e) { + lastError = e; + } + await new Promise((r) => setTimeout(r, 500)); + } + throw new Error( + `server not ready: ${lastError}\n--- server logs ---\n${serverLogs.join("")}`, + ); + } + + beforeAll(async () => { + const root = path.resolve(__dirname, "..", ".."); + server = spawn( + path.join(root, "node_modules", ".bin", "tsx"), + ["src/server/Server.ts"], + { + cwd: root, + detached: true, + env: { + ...process.env, + GAME_ENV: "dev", + NUM_WORKERS: "1", + GIT_COMMIT: SERVER_COMMIT, + DOMAIN: "localhost", + TURNSTILE_SITE_KEY: "1x00000000000000000000AA", + API_KEY: "WARNING_DEV_API_KEY_DO_NOT_USE_IN_PRODUCTION", + ADMIN_BOT_API_KEY: + "WARNING_DEV_ADMIN_BOT_KEY_DO_NOT_USE_IN_PRODUCTION", + }, + }, + ); + server.stdout?.on("data", (d) => serverLogs.push(String(d))); + server.stderr?.on("data", (d) => serverLogs.push(String(d))); + gameID = await createGame(); + }, 60_000); + + afterAll(() => { + if (server?.pid !== undefined) { + // detached spawn = own process group; negative pid kills the master + // and the cluster workers it forked. + try { + process.kill(-server.pid, "SIGTERM"); + } catch { + server.kill("SIGKILL"); + } + } + }); + + // Opens a game socket, sends a join, and collects messages until the + // server closes the socket or admits us (lobby_info). + function join(gitCommit: string | undefined): Promise { + return new Promise((resolve, reject) => { + const ws = new WebSocket(WORKER_WS); + const messages: any[] = []; + const timeout = setTimeout(() => { + ws.terminate(); + reject( + new Error(`no join verdict in 15s: ${JSON.stringify(messages)}`), + ); + }, 15_000); + const finish = (closeCode?: number) => { + clearTimeout(timeout); + resolve({ messages, closeCode }); + }; + ws.on("open", () => { + ws.send( + JSON.stringify({ + type: "join", + gameID, + token: randomUUID(), // dev servers accept a bare persistent ID + username: "TestPlayer", + clanTag: null, + turnstileToken: null, + ...(gitCommit === undefined ? {} : { gitCommit }), + }), + ); + }); + ws.on("message", (data) => { + try { + messages.push(JSON.parse(String(data))); + } catch { + return; + } + if (messages[messages.length - 1].type === "lobby_info") { + ws.close(); + finish(undefined); + } + }); + ws.on("close", (code) => finish(code)); + ws.on("error", (e) => { + clearTimeout(timeout); + reject(e); + }); + }); + } + + it("rejects a join whose gitCommit differs from the server's", async () => { + const result = await join(WRONG_COMMIT); + expect(result.messages).toContainEqual({ + type: "error", + error: "version_mismatch", + }); + expect(result.closeCode).toBe(1002); + }, 30_000); + + it("rejects a join with no gitCommit (pre-feature client)", async () => { + const result = await join(undefined); + expect(result.messages).toContainEqual({ + type: "error", + error: "version_mismatch", + }); + expect(result.closeCode).toBe(1002); + }, 30_000); + + it("admits a join with the matching gitCommit", async () => { + const result = await join(SERVER_COMMIT); + expect(result.messages.some((m) => m.type === "lobby_info")).toBe(true); + expect(result.messages.every((m) => m.type !== "error")).toBe(true); + }, 30_000); + + it("advertises the server commit on the lobby feed", async () => { + // The feed is primed by the master's public-lobby schedule (every ~5s in + // dev), so the first full snapshot can take a few seconds to arrive. + const full = await new Promise((resolve, reject) => { + const ws = new WebSocket(`${WORKER_WS}/lobbies`); + const timeout = setTimeout(() => { + ws.terminate(); + reject(new Error("no full lobby message in 30s")); + }, 30_000); + ws.on("message", (data) => { + const msg = JSON.parse(String(data)); + if (msg.type === "full") { + clearTimeout(timeout); + ws.close(); + resolve(msg); + } + }); + ws.on("error", (e) => { + clearTimeout(timeout); + reject(e); + }); + }); + expect(full.gitCommit).toBe(SERVER_COMMIT); + }, 45_000); +}); diff --git a/tests/setup.ts b/tests/setup.ts index a4b1368ce2..f83e9927f5 100644 --- a/tests/setup.ts +++ b/tests/setup.ts @@ -1,2 +1,6 @@ // Add global mocks or configuration here if needed import "vitest-canvas-mock"; + +// ServerEnv.gitCommit() throws when unset; the dev server sets GIT_COMMIT=DEV, +// so tests exercising server code (e.g. the lobby feed) mirror that. +process.env.GIT_COMMIT ??= "DEV";