feat(soak): artifacts, graceful shutdown, health probes, smoke script, v3.3.4

Batched remaining harness tasks (27-30, 33):

Task 27 — Artifact capture on failure: screenshots, HTML snapshots,
game state JSON, and console error tails are captured into
tests/soak/artifacts/<run-id>/ when a scenario throws. Successful
runs get a summary.json. Old runs (>7d) are pruned on startup.

Task 28 — Graceful shutdown: first SIGINT/SIGTERM flips the abort
signal (scenarios finish current turn then unwind). 10s after, a
hard-kill fires if cleanup hangs. Double Ctrl-C = immediate exit.
Exit codes: 0 success, 1 errors, 2 interrupted.

Task 29 — Periodic health probes: every 30s GET /health against the
target server. Three consecutive failures abort the run with
health_fatal, preventing staging outages from being misattributed
to harness bugs. Corrected endpoint from /api/health to /health
per server/routers/health.py.

Task 30 — Smoke test script: tests/soak/scripts/smoke.sh, a 60s
end-to-end canary that health-probes the target, seeds if needed,
and runs one minimal populate game.

Task 33 — Version bump to v3.3.4: both index.html footers (was
v3.1.6), new footer added to admin.html (had none), pyproject.toml.

Also fixes discovered during stress testing:
- SessionPool sets baseURL on all contexts so relative goto('/')
  resolves correctly between games (was "invalid URL" error)
- RoomCoordinator key is now unique per game-start (Date.now
  suffix) so Deferred promises don't carry stale room codes from
  previous games

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
adlee-was-taken
2026-04-11 22:57:15 -04:00
parent d3b468575b
commit b8bc432175
8 changed files with 242 additions and 5 deletions

View File

@@ -55,9 +55,15 @@ export async function runOneMultiplayerGame(
// goto('/') bounces them back; localStorage-cached auth persists.
await Promise.all(sessions.map((s) => s.bot.goto('/')));
// Use a unique coordinator key per game-start so Deferreds don't
// carry stale room codes from previous games. The coordinator's
// Promises only resolve once — reusing `opts.roomId` across games
// would make joiners receive the first game's code on every game.
const coordKey = `${opts.roomId}-${Date.now()}`;
// Host creates game and announces the code
const code = await host.bot.createGame(host.account.username);
ctx.coordinator.announce(opts.roomId, code);
ctx.coordinator.announce(coordKey, code);
ctx.heartbeat(opts.roomId);
ctx.dashboard.update(opts.roomId, { phase: 'lobby' });
ctx.logger.info('room_created', { room: opts.roomId, code });
@@ -65,7 +71,7 @@ export async function runOneMultiplayerGame(
// Joiners join concurrently
await Promise.all(
joiners.map(async (joiner) => {
const awaited = await ctx.coordinator.await(opts.roomId);
const awaited = await ctx.coordinator.await(coordKey);
await joiner.bot.joinGame(awaited, joiner.account.username);
}),
);