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