From b716adb7e2f1396e8b5ae80730ac052e6f5638ce Mon Sep 17 00:00:00 2001 From: evanpelle Date: Sat, 18 Jul 2026 14:05:32 -0700 Subject: [PATCH] fix(server): keep matchmaking games out of lobby reports; salvage invalid entries Ranked matchmaking games have a Public gameType but no publicGameType, so the worker's lobbyList report included them with the field dropped over IPC, failing the master's schema parse. The master then discarded the entire report, freezing its view of that worker's lobbies for the match's lobby window (~7s): stale player counts and countdowns for all clients, repeated countdown resets, and duplicate scheduled lobbies. - Worker: filter games without a publicGameType out of the report; matchmaking games are invite-only and must never be advertised. - Master: validate lobby entries individually and drop only the bad ones (logged), so one malformed entry can't invalidate the whole report. Co-Authored-By: Claude Fable 5 --- src/server/IPCBridgeSchema.ts | 9 ++++--- src/server/MasterLobbyService.ts | 26 ++++++++++++++++-- src/server/WorkerLobbyService.ts | 4 +++ tests/server/MasterLobbyServiceHealth.test.ts | 27 +++++++++++++++++++ 4 files changed, 61 insertions(+), 5 deletions(-) diff --git a/src/server/IPCBridgeSchema.ts b/src/server/IPCBridgeSchema.ts index 48293614d..4ac8c1531 100644 --- a/src/server/IPCBridgeSchema.ts +++ b/src/server/IPCBridgeSchema.ts @@ -1,7 +1,6 @@ import { z } from "zod"; import { GameConfigSchema, - PublicGameInfoSchema, PublicGamesSchema, PublicGameTypeSchema, } from "../core/Schemas"; @@ -19,10 +18,14 @@ export type MasterMessage = z.infer; // --- Worker Messages --- -// Worker tells the master about its lobbies. +// Worker tells the master about its lobbies. Entries are deliberately not +// validated here: the master checks each against PublicGameInfoSchema and +// drops bad ones (MasterLobbyService.validLobbies), so a single malformed +// lobby can't invalidate the whole report and freeze the master's view of +// this worker's lobbies. const WorkerLobbyListSchema = z.object({ type: z.literal("lobbyList"), - lobbies: z.array(PublicGameInfoSchema), + lobbies: z.array(z.unknown()), }); const WorkerReadySchema = z.object({ diff --git a/src/server/MasterLobbyService.ts b/src/server/MasterLobbyService.ts index 25e4d23df..603b76cd9 100644 --- a/src/server/MasterLobbyService.ts +++ b/src/server/MasterLobbyService.ts @@ -1,6 +1,10 @@ import { Worker } from "cluster"; import winston from "winston"; -import { PublicGameInfo, PublicGameType } from "../core/Schemas"; +import { + PublicGameInfo, + PublicGameInfoSchema, + PublicGameType, +} from "../core/Schemas"; import { generateID } from "../core/Util"; import { MasterCreateGame, @@ -46,12 +50,30 @@ export class MasterLobbyService { this.handleWorkerReady(msg.workerId); break; case "lobbyList": - this.workerLobbies.set(workerId, msg.lobbies); + this.workerLobbies.set(workerId, this.validLobbies(msg.lobbies)); break; } }); } + // Lobby entries are validated individually so one malformed entry only + // drops itself. Rejecting the whole report would freeze this worker's + // lobbies in the master's view for as long as the bad entry exists — + // stale broadcasts to every client, countdown resets, and duplicate + // scheduling. + private validLobbies(lobbies: unknown[]): PublicGameInfo[] { + const valid: PublicGameInfo[] = []; + for (const lobby of lobbies) { + const result = PublicGameInfoSchema.safeParse(lobby); + if (result.success) { + valid.push(result.data); + } else { + this.log.error("Dropping invalid lobby in worker report:", lobby); + } + } + return valid; + } + removeWorker(workerId: number) { this.workers.delete(workerId); this.workerLobbies.delete(workerId); diff --git a/src/server/WorkerLobbyService.ts b/src/server/WorkerLobbyService.ts index 6e7f405a1..c84776269 100644 --- a/src/server/WorkerLobbyService.ts +++ b/src/server/WorkerLobbyService.ts @@ -98,9 +98,13 @@ export class WorkerLobbyService { } private sendMyLobbiesToMaster() { + // Matchmaking games have a Public gameType (so they appear in + // publicLobbies()) but no publicGameType: they are invite-only and must + // never be advertised, and the master rejects entries without one. const lobbies = this.gm .publicLobbies() .map((g) => g.gameInfo()) + .filter((gi) => gi.publicGameType !== undefined) .map((gi) => { return { gameID: gi.gameID, diff --git a/tests/server/MasterLobbyServiceHealth.test.ts b/tests/server/MasterLobbyServiceHealth.test.ts index d29fcce75..386f32959 100644 --- a/tests/server/MasterLobbyServiceHealth.test.ts +++ b/tests/server/MasterLobbyServiceHealth.test.ts @@ -48,6 +48,33 @@ function startAllWorkers( return workers; } +describe("MasterLobbyService lobby report validation", () => { + it("keeps the valid lobbies when a report contains a malformed entry", () => { + const service = createService(1); + const [{ w }] = startAllWorkers(service, 1); + + w.emit("message", { + type: "lobbyList", + lobbies: [ + // Missing publicGameType, like a matchmaking game reported by + // mistake. It must be dropped without discarding the whole report. + { gameID: "bad", numClients: 0 }, + { gameID: "good", numClients: 1, publicGameType: "ffa" }, + ], + }); + + (service as any).broadcastLobbies(); + + const broadcast = ((w as any).send as ReturnType).mock.calls + .map((c) => c[0]) + .find((m) => m.type === "lobbiesBroadcast"); + expect(broadcast).toBeDefined(); + expect( + broadcast.publicGames.games.ffa.map((l: { gameID: string }) => l.gameID), + ).toEqual(["good"]); + }); +}); + describe("MasterLobbyService.isHealthy", () => { it("unhealthy before any workers register", () => { const service = createService(4);