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";