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 <noreply@anthropic.com>
This commit is contained in:
evanpelle
2026-07-18 14:12:29 -07:00
co-authored by Claude Fable 5
parent fe5d7708e0
commit b716adb7e2
4 changed files with 61 additions and 5 deletions
+6 -3
View File
@@ -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<typeof MasterMessageSchema>;
// --- 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({
+24 -2
View File
@@ -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);
+4
View File
@@ -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,
@@ -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<typeof vi.fn>).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);