From ba1ee93d0fef086763ce07cb96030b5a9a7c2c81 Mon Sep 17 00:00:00 2001 From: Matt Evans Date: Thu, 18 Jun 2026 13:43:24 -0700 Subject: [PATCH] fix(lobby): share protocol version with worker --- client/package.json | 5 ++- crates/lobby-broker/src/lib.rs | 2 +- crates/lobby-broker/src/protocol.rs | 16 ++++++++ crates/server-core/src/protocol.rs | 27 +------------- lobby-worker/broker-wasm/Cargo.lock | 21 ++++++++++- lobby-worker/broker-wasm/src/lib.rs | 48 +++++++++++++++++++----- lobby-worker/src/hello-gate.ts | 13 +++---- lobby-worker/src/lobby-do.ts | 6 +-- lobby-worker/src/protocol.ts | 53 --------------------------- lobby-worker/test/hello-gate.test.mjs | 37 +++++++++++++++++++ scripts/check-protocol-version.mjs | 40 ++++++++++++++++++++ 11 files changed, 165 insertions(+), 103 deletions(-) delete mode 100644 lobby-worker/src/protocol.ts create mode 100644 lobby-worker/test/hello-gate.test.mjs create mode 100644 scripts/check-protocol-version.mjs diff --git a/client/package.json b/client/package.json index ca52157623..95b2921061 100644 --- a/client/package.json +++ b/client/package.json @@ -5,9 +5,10 @@ "type": "module", "scripts": { "dev": "vite", - "build": "tsc -b && vite build", + "build": "pnpm run protocol:check && tsc -b && vite build", "lint": "eslint .", - "type-check": "tsc -b --noEmit", + "protocol:check": "node ../scripts/check-protocol-version.mjs", + "type-check": "pnpm run protocol:check && tsc -b --noEmit", "preview": "vite preview", "test": "vitest", "test:integration": "vitest run --config vitest.integration.config.ts", diff --git a/crates/lobby-broker/src/lib.rs b/crates/lobby-broker/src/lib.rs index 9175872fbe..6a4b4a35ac 100644 --- a/crates/lobby-broker/src/lib.rs +++ b/crates/lobby-broker/src/lib.rs @@ -31,7 +31,7 @@ pub use lobby::{ }; pub use protocol::{ parse_lobby_client_message, DraftLobbyMetadata, LobbyClientMessage, LobbyGame, - LobbyServerMessage, ParsedFrame, ServerMode, + LobbyServerMessage, ParsedFrame, ServerMode, MIN_SUPPORTED_PROTOCOL, PROTOCOL_VERSION, }; pub use reservation_auth::{ conn_holds_reservation, consume_owned_reservation, release_owned_reservation, diff --git a/crates/lobby-broker/src/protocol.rs b/crates/lobby-broker/src/protocol.rs index e4862536a6..2394e193b0 100644 --- a/crates/lobby-broker/src/protocol.rs +++ b/crates/lobby-broker/src/protocol.rs @@ -21,6 +21,22 @@ use engine::types::format::{FormatConfig, GameFormat}; use engine::types::match_config::MatchConfig; use serde::{Deserialize, Serialize}; +/// Wire-protocol version shared by the native server, client, and Cloudflare +/// lobby Worker. Bump when any `ClientMessage` or `ServerMessage` variant is +/// added, removed, renamed, or has a field type changed. Adding a new optional +/// field with `#[serde(default)]` does not require a bump. +/// +/// Note: renaming or removing a variant silently fails at JSON parse time +/// (clients see "Invalid message: unknown variant") rather than at the +/// handshake. When making such changes, plan a deprecation window where +/// both the old and new variants coexist, then bump and remove the old. +pub const PROTOCOL_VERSION: u32 = 8; + +/// Minimum protocol version accepted at the hello handshake. The window is +/// "current and previous" by policy, so a release-vs-preview deployment can +/// coexist in the same lobby server during rollout. +pub const MIN_SUPPORTED_PROTOCOL: u32 = PROTOCOL_VERSION.saturating_sub(1); + /// Public-lobby view of a single registered game. Populated by the server, /// never by clients. Field shape mirrors the pre-extraction /// `server_core::protocol::LobbyGame` exactly for wire compatibility. diff --git a/crates/server-core/src/protocol.rs b/crates/server-core/src/protocol.rs index 9d8f9b91ad..74e89613b9 100644 --- a/crates/server-core/src/protocol.rs +++ b/crates/server-core/src/protocol.rs @@ -12,32 +12,7 @@ use engine::types::player::PlayerId; use phase_ai::config::AiDifficulty; use serde::{Deserialize, Serialize}; -/// Wire-protocol version. Bump when any `ClientMessage` or `ServerMessage` -/// variant is added, removed, renamed, or has a field type changed. Adding a -/// new optional field with `#[serde(default)]` does not require a bump. -/// -/// Note: renaming or removing a variant silently fails at JSON parse time -/// (clients see "Invalid message: unknown variant") rather than at the -/// handshake. When making such changes, plan a deprecation window where -/// both the old and new variants coexist, then bump and remove the old. -pub const PROTOCOL_VERSION: u32 = 8; - -/// Minimum protocol version the server will accept at the hello handshake. -/// Clients on `MIN_SUPPORTED_PROTOCOL..=PROTOCOL_VERSION` are admitted to the -/// lobby; older clients are rejected. The window is "current and previous" by -/// policy — each bump deprecates exactly one version behind, so a release-vs- -/// preview deployment can coexist in the same lobby server during the rollout. -/// -/// Derived from `PROTOCOL_VERSION` so a bump automatically rolls the floor. -/// Use `saturating_sub` so the constant is well-defined when `PROTOCOL_VERSION` -/// is 0 (range collapses to `0..=0`, no underflow). -/// -/// Note: admission to the lobby does not guarantee that every game wire -/// operation is bidirectionally compatible across versions. Per-game cross- -/// version filtering is a follow-up; until it lands, browsing succeeds but a -/// v6 client clicking "join" on a v7-hosted game will fail at the seat-message -/// boundary with an opaque deserialize error. -pub const MIN_SUPPORTED_PROTOCOL: u32 = PROTOCOL_VERSION.saturating_sub(1); +pub use lobby_broker::{MIN_SUPPORTED_PROTOCOL, PROTOCOL_VERSION}; /// Git short-hash of the build. Emitted by `build.rs`; falls back to `"dev"` /// when git isn't available (containers, source tarballs). diff --git a/lobby-worker/broker-wasm/Cargo.lock b/lobby-worker/broker-wasm/Cargo.lock index f002dce207..69c5f4cda9 100644 --- a/lobby-worker/broker-wasm/Cargo.lock +++ b/lobby-worker/broker-wasm/Cargo.lock @@ -25,7 +25,7 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "engine" -version = "0.1.37" +version = "0.2.0" dependencies = [ "im", "indexmap", @@ -33,8 +33,10 @@ dependencies = [ "petgraph", "rand", "rand_chacha", + "rustc-hash", "serde", "serde_json", + "smallvec", "strum", "thiserror", "toml", @@ -157,7 +159,7 @@ checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" [[package]] name = "lobby-broker" -version = "0.1.37" +version = "0.2.0" dependencies = [ "engine", "serde", @@ -291,6 +293,12 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + [[package]] name = "rustversion" version = "1.0.22" @@ -365,6 +373,15 @@ version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +dependencies = [ + "serde", +] + [[package]] name = "strum" version = "0.27.2" diff --git a/lobby-worker/broker-wasm/src/lib.rs b/lobby-worker/broker-wasm/src/lib.rs index 0c1f66aa41..5f8e060bed 100644 --- a/lobby-worker/broker-wasm/src/lib.rs +++ b/lobby-worker/broker-wasm/src/lib.rs @@ -14,7 +14,7 @@ use lobby_broker::{ parse_lobby_client_message, Broker, BrokerEnv, ConnState, LobbyClientMessage, - LobbyServerMessage, Outbound, ParsedFrame, + LobbyServerMessage, Outbound, ParsedFrame, PROTOCOL_VERSION, }; use rand::Rng; use serde::Serialize; @@ -36,13 +36,17 @@ impl BrokerEnv for WorkerEnv { fn new_token(&self) -> String { let mut rng = rand::rng(); - (0..32).map(|_| format!("{:x}", rng.random_range(0u8..16))).collect() + (0..32) + .map(|_| format!("{:x}", rng.random_range(0u8..16))) + .collect() } fn new_game_code(&self) -> String { let mut rng = rand::rng(); let chars: Vec = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789".chars().collect(); - (0..6).map(|_| chars[rng.random_range(0..chars.len())]).collect() + (0..6) + .map(|_| chars[rng.random_range(0..chars.len())]) + .collect() } } @@ -136,7 +140,9 @@ impl WasmBroker { /// Fresh empty broker — cold start with no stored snapshot. #[wasm_bindgen(constructor)] pub fn new() -> WasmBroker { - WasmBroker { inner: Broker::new() } + WasmBroker { + inner: Broker::new(), + } } /// Restore from a DO-storage snapshot. Falls back to an empty broker if the @@ -145,7 +151,9 @@ impl WasmBroker { pub fn from_snapshot(json: &str) -> WasmBroker { match serde_json::from_str::(json) { Ok(inner) => WasmBroker { inner }, - Err(_) => WasmBroker { inner: Broker::new() }, + Err(_) => WasmBroker { + inner: Broker::new(), + }, } } @@ -167,7 +175,9 @@ impl WasmBroker { /// attachment, `now_ms` is JS `Date.now()`. Returns a [`CallResult`] as JSON. pub fn handle(&mut self, conn_json: &str, raw_frame: &str, now_ms: f64) -> String { let mut conn: ConnState = serde_json::from_str(conn_json).unwrap_or_default(); - let env = WorkerEnv { now_ms: now_ms as u64 }; + let env = WorkerEnv { + now_ms: now_ms as u64, + }; let (outbounds, dirty, reject) = match parse_lobby_client_message(raw_frame) { ParsedFrame::Message(msg) => { @@ -189,7 +199,12 @@ impl WasmBroker { } }; - result_json(CallResult { conn, outbounds: to_dtos(outbounds), dirty, reject }) + result_json(CallResult { + conn, + outbounds: to_dtos(outbounds), + dirty, + reject, + }) } /// Socket-close teardown: release the connection's seat reservations and @@ -199,14 +214,21 @@ impl WasmBroker { let outbounds = self.inner.on_disconnect(&mut conn); // A close releases reservations / removes a hosted entry — treat as a // mutation so the shell snapshots (cheap: close is low-frequency). - result_json(CallResult { conn, outbounds: to_dtos(outbounds), dirty: true, reject: None }) + result_json(CallResult { + conn, + outbounds: to_dtos(outbounds), + dirty: true, + reject: None, + }) } /// Staleness reaper, driven by a DO alarm (a hibernated DO has no tokio /// interval). Returns the ordered `Outbound`s (a `LobbyGameRemoved` per /// reaped entry) as a JSON array — there is no connection scope here. pub fn reap_expired(&mut self, timeout_secs: f64, now_ms: f64) -> String { - let env = WorkerEnv { now_ms: now_ms as u64 }; + let env = WorkerEnv { + now_ms: now_ms as u64, + }; let outbounds = self.inner.reap_expired(timeout_secs as u64, &env); serde_json::to_string(&to_dtos(outbounds)).expect("outbounds always serialize") } @@ -218,6 +240,14 @@ impl Default for WasmBroker { } } +/// The shared phase.rs wire-protocol version. The Cloudflare Worker shell uses +/// this for `ServerHello` and its pre-broker handshake gate, so it cannot drift +/// from the Rust protocol constant. +#[wasm_bindgen] +pub fn protocol_version() -> u32 { + PROTOCOL_VERSION +} + fn result_json(r: CallResult) -> String { serde_json::to_string(&r).expect("call result always serializes") } diff --git a/lobby-worker/src/hello-gate.ts b/lobby-worker/src/hello-gate.ts index d7aac14232..12f7a555e6 100644 --- a/lobby-worker/src/hello-gate.ts +++ b/lobby-worker/src/hello-gate.ts @@ -1,9 +1,5 @@ // Mirrors phase-server `classify_hello_gate` for the Cloudflare DO shell. -import { PROTOCOL_VERSION } from "./protocol"; - -export const MIN_SUPPORTED_PROTOCOL = PROTOCOL_VERSION - 1; - export type HelloGateOutcome = | { kind: "accept" } | { kind: "reject_handshake" } @@ -21,18 +17,21 @@ export interface ConnAttachment { export function classifyHelloGate( helloReceived: boolean, frame: { type?: string; data?: Record }, + serverProtocolVersion: number, ): HelloGateOutcome { + const minSupportedProtocol = Math.max(0, serverProtocolVersion - 1); if (frame.type === "ClientHello") { if (!helloReceived) { const protocolVersion = Number(frame.data?.protocol_version ?? 0); if ( - protocolVersion < MIN_SUPPORTED_PROTOCOL || - protocolVersion > PROTOCOL_VERSION + Number.isNaN(protocolVersion) || + protocolVersion < minSupportedProtocol || + protocolVersion > serverProtocolVersion ) { return { kind: "reject_protocol", client: protocolVersion, - server: PROTOCOL_VERSION, + server: serverProtocolVersion, }; } return { kind: "accept" }; diff --git a/lobby-worker/src/lobby-do.ts b/lobby-worker/src/lobby-do.ts index 342bf7a244..8ba1f970ff 100644 --- a/lobby-worker/src/lobby-do.ts +++ b/lobby-worker/src/lobby-do.ts @@ -18,20 +18,20 @@ // logic, the host language is a serialization boundary with zero game logic. import wasmModule from "./broker-wasm-pkg/broker_bg.wasm"; -import { initSync, WasmBroker } from "./broker-wasm-pkg/broker.js"; +import { initSync, protocol_version, WasmBroker } from "./broker-wasm-pkg/broker.js"; import { classifyHelloGate, helloGateErrorMessage, type ConnAttachment, } from "./hello-gate"; import { moderationErrorForLobbyFrame } from "./name-filter"; -import { PROTOCOL_VERSION } from "./protocol"; // Instantiate the broker WASM once per isolate, at top level (CF imports `.wasm` // as a WebAssembly.Module; `initSync` wires the wasm-bindgen imports // synchronously). Doing this here — not per request — avoids re-instantiation. initSync({ module: wasmModule }); +const PROTOCOL_VERSION = protocol_version(); const SERVER_VERSION = "lobby-rs"; // build_commit is cosmetic for a LobbyOnly broker — the gameplay-relevant gate // is each room's host_build_commit (enforced inside the Rust core), not the @@ -139,7 +139,7 @@ export class LobbyDO { } const attachment = conn as ConnAttachment; - const gate = classifyHelloGate(attachment.client_hello != null, frame); + const gate = classifyHelloGate(attachment.client_hello != null, frame, PROTOCOL_VERSION); const gateError = helloGateErrorMessage(gate); if (gateError) { ws.send(JSON.stringify({ type: "Error", data: { message: gateError } })); diff --git a/lobby-worker/src/protocol.ts b/lobby-worker/src/protocol.ts deleted file mode 100644 index 257ea35ba2..0000000000 --- a/lobby-worker/src/protocol.ts +++ /dev/null @@ -1,53 +0,0 @@ -// Hand-written mirror of the LobbyOnly subset of -// crates/server-core/src/protocol.rs. -// -// ⚠️ STUB ONLY. This mirror is exactly the cross-adapter duplication / -// drift hazard that the WASM-shared-crate approach exists to eliminate -// (.planning/lobby-failover-federation-plan.md §4a, §6c). It lives here only -// to validate the Cloudflare plumbing. When the real `lobby-broker` crate is -// compiled to WASM and loaded into the DO, delete this file. - -/** MUST equal `PROTOCOL_VERSION` in crates/server-core/src/protocol.rs. - * The client gate accepts [PROTOCOL_VERSION - 1, PROTOCOL_VERSION] so a - * deprecation window of one minor exists, but the broker must still report - * the current version so up-to-date clients are not stranded on a stale - * server. */ -export const PROTOCOL_VERSION = 7; - -/** Wire shape of a lobby row (snake_case — protocol.rs has no rename_all on - * `LobbyGame`). Only the fields the lobby listing needs are populated. */ -export interface LobbyGame { - game_code: string; - host_name: string; - created_at: number; - has_password: boolean; - host_version: string; - host_build_commit: string; - current_players: number; - max_players: number; - format: string | null; - room_name: string | null; - is_p2p: boolean; - is_sandbox: boolean; -} - -/** Per-socket state, persisted via `ws.serializeAttachment` so it survives - * Durable Object hibernation (the attachment is restored on wake). */ -export interface SocketState { - /** True once the socket has sent `SubscribeLobby`. */ - subscribed: boolean; - /** The host's build commit from its `ClientHello` — stamped onto - * `LobbyGame.host_build_commit` so guest/host build-compat gating works. */ - buildCommit: string; - /** game_code this socket registered, if it is a host. Removed on close. */ - ownedGameCode?: string; -} - -/** Secret/per-room data never broadcast in `LobbyGame` (peer id, password, - * and the configs echoed back to a joining guest). */ -export interface RoomSecret { - hostPeerId: string; - password: string | null; - formatConfig: unknown | null; - matchConfig: unknown; -} diff --git a/lobby-worker/test/hello-gate.test.mjs b/lobby-worker/test/hello-gate.test.mjs new file mode 100644 index 0000000000..282569d071 --- /dev/null +++ b/lobby-worker/test/hello-gate.test.mjs @@ -0,0 +1,37 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { classifyHelloGate } from "../src/hello-gate.ts"; + +test("rejects malformed protocol versions", () => { + assert.deepEqual( + classifyHelloGate( + false, + { type: "ClientHello", data: { protocol_version: "invalid" } }, + 8, + ), + { kind: "reject_protocol", client: Number.NaN, server: 8 }, + ); +}); + +test("accepts current and previous protocol versions", () => { + assert.deepEqual( + classifyHelloGate(false, { type: "ClientHello", data: { protocol_version: 7 } }, 8), + { kind: "accept" }, + ); + assert.deepEqual( + classifyHelloGate(false, { type: "ClientHello", data: { protocol_version: 8 } }, 8), + { kind: "accept" }, + ); +}); + +test("rejects versions outside the supported range", () => { + assert.deepEqual( + classifyHelloGate(false, { type: "ClientHello", data: { protocol_version: 6 } }, 8), + { kind: "reject_protocol", client: 6, server: 8 }, + ); + assert.deepEqual( + classifyHelloGate(false, { type: "ClientHello", data: { protocol_version: 9 } }, 8), + { kind: "reject_protocol", client: 9, server: 8 }, + ); +}); diff --git a/scripts/check-protocol-version.mjs b/scripts/check-protocol-version.mjs new file mode 100644 index 0000000000..d92c0663a9 --- /dev/null +++ b/scripts/check-protocol-version.mjs @@ -0,0 +1,40 @@ +import { readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); + +function extractVersion(source, pattern, label) { + const match = source.match(pattern); + if (!match) { + throw new Error(`Could not find PROTOCOL_VERSION in ${label}`); + } + return Number(match[1]); +} + +const rustSource = readFileSync( + resolve(root, "crates/lobby-broker/src/protocol.rs"), + "utf8", +); +const clientSource = readFileSync( + resolve(root, "client/src/adapter/ws-adapter.ts"), + "utf8", +); + +const rustVersion = extractVersion( + rustSource, + /pub\s+const\s+PROTOCOL_VERSION\s*:\s*u32\s*=\s*(\d+)\s*;/, + "crates/lobby-broker/src/protocol.rs", +); +const clientVersion = extractVersion( + clientSource, + /export\s+const\s+PROTOCOL_VERSION\s*=\s*(\d+)\s*;/, + "client/src/adapter/ws-adapter.ts", +); + +if (rustVersion !== clientVersion) { + console.error( + `Protocol version mismatch: Rust=${rustVersion}, client=${clientVersion}`, + ); + process.exit(1); +}