feat(soak): per-room watchdog + heartbeat wiring + multi-game lobby fix

Watchdog class with 4 Vitest tests (27 total now), wired into
ctx.heartbeat in the runner. One watchdog per room with a 60s
timeout; firing logs an error, marks the room's dashboard tile
as errored, and triggers the abort signal so the scenario unwinds.
Watchdogs are explicitly stopped in the runner's finally block
so pending timers don't keep the node process alive on exit.

Also fixes a multi-game bug discovered during stress scenario
verification: after a game ends sessions stay parked on the
game_over screen, which hides the lobby and makes a subsequent
#create-room-btn click time out. runOneMultiplayerGame now
navigates every session to / before each game — localStorage
auth persists so nothing re-logs in.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
adlee-was-taken
2026-04-11 21:52:49 -04:00
parent 921a6ad984
commit d3b468575b
4 changed files with 120 additions and 2 deletions

View File

@@ -17,6 +17,7 @@ import { SessionPool } from './core/session-pool';
import { RoomCoordinator } from './core/room-coordinator';
import { DashboardServer } from './dashboard/server';
import { Screencaster } from './core/screencaster';
import { Watchdog } from './core/watchdog';
import { getScenario, listScenarios } from './scenarios';
import type { DashboardReporter, ScenarioContext, Session } from './core/types';
@@ -123,6 +124,7 @@ async function main(): Promise<void> {
let dashboardServer: DashboardServer | null = null;
let dashboard: DashboardReporter = noopDashboard();
const watchdogs = new Map<string, Watchdog>();
let exitCode = 0;
try {
const sessions = await pool.acquire(accounts);
@@ -179,6 +181,20 @@ async function main(): Promise<void> {
}
}
// Per-room watchdogs — fire if no heartbeat arrives within 60s.
// Declared at outer scope so the finally block can stop them and
// drain any pending timers before the process exits.
for (let i = 0; i < rooms; i++) {
const roomId = `room-${i}`;
const w = new Watchdog(60_000, () => {
logger.error('watchdog_fired', { room: roomId });
dashboard.update(roomId, { phase: 'error' });
abortController.abort();
});
w.start();
watchdogs.set(roomId, w);
}
const ctx: ScenarioContext = {
config,
sessions,
@@ -186,8 +202,9 @@ async function main(): Promise<void> {
dashboard,
logger,
signal: abortController.signal,
heartbeat: () => {
// Task 26 wires per-room watchdogs. No-op until then.
heartbeat: (roomId: string) => {
const w = watchdogs.get(roomId);
if (w) w.heartbeat();
},
};
@@ -214,6 +231,7 @@ async function main(): Promise<void> {
});
exitCode = 1;
} finally {
for (const w of watchdogs.values()) w.stop();
await screencaster.stopAll();
await pool.release();
if (dashboardServer) {