Fix bugs from code audit (server respawn, leaks, hardening)

Verified findings from a code audit and fixed the legitimate, well-scoped
ones. Notable: crashed cluster workers were never respawned because
worker.process.env is undefined in the exit handler, so WORKER_ID was always
undefined (confirmed empirically).

Fixes:
- Master: respawn crashed workers via a worker.id -> WORKER_ID registry
  instead of the always-undefined worker.process.env.WORKER_ID.
- GameServer: report Finished when _hasEnded so abandoned private lobbies are
  cleaned up instead of lingering until max duration; remove sockets from the
  websockets Set on close/rejoin; reset hasReachedMaxPlayerCount when a player
  leaves before start (avoids premature under-capacity starts, e.g. ranked 1v1).
- Worker: reap WebSocket connections that upgrade but never authenticate
  (30s join timeout) to prevent Slowloris-style FD exhaustion.
- ServerEnv: add a 1h TTL to the cached JWKS public key so IdP key rotation
  doesn't break all logins until a restart.
- SpawnExecution: ignore a client-supplied tile in random-spawn mode so a
  forged spawn intent can't pick its own coordinates (+ test).
- GameRenderer / MultiTabDetector: remove window listeners on teardown
  (bind once so add/removeEventListener references match).
- LocalPersistantStats: guard localStorage access with try/catch so sandboxed
  iframes / disabled-storage contexts don't crash game start.
- StructurePass: reuse per-frame uniform arrays instead of allocating each draw.

Also documents a known, intentionally-unfixed auth gap on
/api/archive_singleplayer_game (pending a product decision on logged-out users).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Evan Pelle
2026-06-27 17:49:59 +00:00
parent 23e05f0115
commit 6e23c26723
11 changed files with 163 additions and 30 deletions
+45
View File
@@ -0,0 +1,45 @@
import { SpawnExecution } from "../src/core/execution/SpawnExecution";
import { Game, PlayerInfo, PlayerType } from "../src/core/game/Game";
import { TileRef } from "../src/core/game/GameMap";
import { GameID } from "../src/core/Schemas";
import { setup } from "./util/Setup";
const GAME_ID: GameID = "game_id";
const PLAYER_ID = "p_id";
async function spawnWith(
randomSpawn: boolean,
x: number,
y: number,
): Promise<{ game: Game; injected: TileRef; spawnTile: TileRef | undefined }> {
const game = await setup("plains", { randomSpawn });
game.addPlayer(new PlayerInfo("p", PlayerType.Human, null, PLAYER_ID));
const injected = game.map().ref(x, y);
game.addExecution(
new SpawnExecution(GAME_ID, game.player(PLAYER_ID).info(), injected),
);
game.executeNextTick(); // init the execution
game.executeNextTick(); // run the execution
return { game, injected, spawnTile: game.player(PLAYER_ID).spawnTile() };
}
describe("Random spawn cannot be overridden by a client-supplied tile", () => {
test("non-random mode honors the requested tile", async () => {
const { injected, spawnTile } = await spawnWith(false, 50, 50);
expect(spawnTile).toBe(injected);
});
test("random mode ignores the injected tile", async () => {
const a = await spawnWith(true, 50, 50);
const b = await spawnWith(true, 60, 60);
// The player still spawns on a valid land tile.
expect(a.spawnTile).toBeDefined();
expect(a.game.isLand(a.spawnTile!)).toBe(true);
// If the injected tile were honored, the two runs would spawn at the two
// distinct injected tiles. Because random spawn ignores it and uses the
// deterministic per-player seed instead, both runs land on the same tile.
expect(a.spawnTile).toBe(b.spawnTile);
});
});