Test Failure Debugging Runbook
When tests fail, follow this exact sequence. Do NOT skip steps or guess at root causes:
make test-summary: readsummary.json. Identify the FIRST failure — sortfailures[]byfailedAt(later ones are usually cancellation cascade from StopOnFail). Note thefailureCategory(assertion/timeout/infrastructure/crash) andreproCommand, and checkinfrastructureErrors— ahost_disconnectedentry there names a host outage's cause (including the SSH master's own death line).summary.jsonis always written, even on aborted runs ("aborted": true+abortReason).make test-events TEST=Class.Method: filterinfrastructure.jsonlfor events attributed to the failing test (jq filter ontest.displayName). Look at timing between events, whichphasefailed (connect, test body, artifacts, cleanup), and any error details.make test-container-log CONTAINER=server-0(orclient-0,steam-auth-shared,steam-auth-per-N): full lifecycle log for the container. Use the test window timestamps frommake test-eventsoutput to slice context around the failure.make test-infra-log: if the failure is infrastructure-related (server poisoned, capacity deadlock, timeout waiting for server), checkdiagnostics/infrastructure.jsonlfor resource lifecycle events (server create/evict/poison, capacity acquire/release, HTTP requests, session lifecycle) around the failure timestamp.- Read the actual test code and mod code before proposing a fix. Cross-reference the error with source.
make test-flaky: check if this test has failed before across runs. Flaky tests need different fixes than consistent failures.- Remote-host / SSH tunnel failures (only when
failureCategoryisinfrastructureAND a host is remote — seehost_idin events): the SSH tunnel is a silent failure domain, so a dead tunnel surfaces downstream as a generic timeout/host_disconnected. To find the SSH-level cause:- Grep
diagnostics/infrastructure.jsonlforhost_disconnected,ssh_master_log,ssh_master_exited,tunnel_forward_failed(make test-infra-log, or grep the file directly —ssh_master_logis emitted at teardown on the coordinator, so it is not attributed to a single test and won't appear undermake test-events). Also grepinfrastructure.parent.jsonlforssh_master_unhealthy_owner— a mid-run hit (itscausenames what tripped: a failed mux check, a data-path wedge caught by the canary, or in-place forward reopens failing repeatedly) means the shared ControlMaster was replaced; the follow-upssh_master_respawn_attempt(termination,exitCode,killOutcomeof the old master — see the tunnel-stall section below) andtunnel_forward_reopened/tunnel_forward_reopen_failedevents tell whether the daemon-socket forward came back on its original port. A teardown-window hit (aftersummary.json's timestamp) is a benign shutdown race, not a mid-run outage. - A
host_disconnectedwhosereasonnames a transport fault and which carries ansshMasterLogTailis the smoking gun for a mid-run drop; the tail holds ssh's own death line (e.g.Timeout, server not responding.). - Read
diagnostics/ssh-master-{host}.logfor the master's full-Eerror log. An empty master log on a poison is expected for an abrupt RST drop (the reset is caught by the exception classifier instead, not the log) — not itself a bug. - Healthy sequence:
ssh_preflight→ssh_master_ready→tunnel_forward_opened(×N) → … →ssh_master_exited(cleanexitCode 0). A non-zeroexitCode/stderronssh_master_exitedortunnel_forward_closedmeans a teardown step itself failed.
- Grep
Output locations: TestResults/latest.txt points to the current run directory. All artifacts are under TestResults/runs/{timestamp}_{sha}/.
Tunnel stalls (every stream through one remote host stops at once)
A Wi‑Fi or ssh-mux stall shows as every container.log of a host ending within the same second, Docker stats stopping, and an in-flight API request timing out or ending prematurely. The evidence lives on the coordinator only — a macOS sshd logs no session open/close and the Mac's unified log holds no session-level record, so do not look there. Read, in order:
diagnostics/infrastructure.parent.jsonl(runner-side events, not attributed to a test):ssh_master_canary_stall— first wedged/_pingcanary through the host's daemon-socket forward;canarycarries the connect/write/read timings and which deadline hit.ssh_master_canary_recovered— the stall ended by itself;stallMsis its measured length andwedgedPollshow many 10 s polls it spanned. A run full of these and nossh_master_wedge_observedwas a stall the harness rode out.ssh_master_wedge_observed— the streak reached the action threshold. Captured before any action:canaries(every poll of the streak),muxCheck(ssh -O checkexit + stderr),tcp(coordinator TCP state toward the host:Get-NetTCPConnection+netstat -sretransmit counters on Windows,ss -tion Linux;errornames why sampling failed),reachability(a fresh TCP connect to the host's SSH port —port, resolved viassh -G— with its latency) andmasterLogTail.reachability.result: connectedwith wedged canaries points at the mux; atimeoutthere points at the path.ssh_master_respawn_attempt— the action:incidentId,cause,termination(exit_ok/killed/socket_gone/unconfirmed),exitCode,exitStderr,killOutcome,elapsedMs,terminatedAtUtc, andmasterLogArchivePath— the old master's-Elog, archived per pid so the replacement master's log starts empty. Thenssh_master_respawned(alive) orssh_master_respawn_failed.
diagnostics/transport-state.{hostId}.json— the runner's latest action on that host as one document (incidentId,cause,termination,outcome,actionStartedAtUtc…windowEndUtc). A test failure betweenactionStartedAtUtcandwindowEndUtcon that host was caused by the respawn, not by the test.diagnostics/infrastructure.jsonl(child-side):container_log_stream_gap/container_stats_stream_gap— a stream delivered again after a silent gap (gapStartUtc,gapEndUtc,gapMs). Sort bygapStartUtc: every stream of the host starting a gap at the same instant is the stall's start; one stream alone is a quiet container. A stream with no gap-end event never resumed.container_log_stream_reconnected— a container log reader re-opened its stream after a transport loss (outageStartUtc,reconnectedAtUtc,gapMs,openFailures,lastLineTimestamp,incidentIdwhen a runner action covered the outage). One per container after a master respawn is the healthy shape.container_log_stream_ended— why a container log reader stopped, emitted on every exit:reasoniscontainer_exited(inspect confirmed not running or gone),open_failures_exhausted(no re-open before the budget deadline;detailnames the budget source andincidentIdthe covering action),cancelled(drain/dispose/shutdown),docker_down(daemon 500) orline_handler_faulted(the per-line callback threw — a sink/forwarding fault, not a transport loss;faultType/faultMessagename it). CarriesfaultType/faultMessage/faultChain,lastLineTimestamp,linesEmitted,reconnects,outageMs. Acontainer.logending before the run did with nocontainer_log_stream_endedfor it is a reader bug.forward_heal_attempt— one per heal cycle of a request'sForwardHealingHandler(attempt,port,faultChain,classification,healMs,outcome). Alternating ports across consecutive attempts means two handlers re-opened the same forward in turns.
diagnostics/ssh-master-{host}.log(current master) andssh-master-{host}.pid{N}-{hhmmss}.log(archived masters): ssh's own death line (Timeout, server not responding.) when the master saw the stall as a keepalive timeout; empty when it did not.diagnostics/test-process-stderr.log— everyTestLogline of the xUnit child ([Server]/[Client]/[Test]prefixes), including the lease/reuse and "marking dead" lines that exist nowhere else.
Stalled / wedged runs (aborted: true, tests "Not executed", 0 failed)
A run aborted by the stall watchdog (abortReason: "child-stall-watchdog", notDispatched > 0) has no failing test to start from — something is blocked while holding a lease. Diagnose the waiter, not a test:
summary.json: notenotDispatchedand which tests never ran — their shared server config is usually the blocked resource.diagnostics/infrastructure.jsonl: findwaitevents with"phase":"started"and no matching"phase":"completed"— those are the hung waiters. Therun_stall_watchdog_trippedevent carriesoutstandingLeases.- Pool-accounting tells:
steam_account_pool_insufficientwithkind:"server"means a second steam server config forked and its prestart is starving (see.claude/rules/test-broker-invariants.md);steam_pool_lease_wait_startedwithavailableInBag:0means steam-client scarcity;client_returned/client_acquiredevents reconstruct who held which client when. diagnostics/test-process-stderr.log: the lease-request/reuseTestLoglines (Lease requested,client-N reused (steam=…),… marking dead) are only there and on the console —client_returnedevents land ininfrastructure.jsonl, the requests/reuses do not.- A dead Steam client is self-healing:
client-N disconnect failed, marking dead: A task was canceled.retires the pool's only Steam-bearing client, but dead containers don't count toward the cap and the next Steam lease recreates one. A run that still starves on Steam leases after that line means the discount/recreate path regressed.
Known benign log lines
Ignore these when triaging — they are not signals of test failure:
Timer: time has moved backwards!in anycontainer.log. Emitted by TigerVNC (common/rfb/Timer.cxx,Timer::getNextTimeout) when the container's wall clock jumps backwards by more than 1 second. Common on virtualized hosts (Docker Desktop on Mac/Windows, WSL2) where the VM clock is periodically resynced from the host. TigerVNC self-corrects (dueTime = now); the game, the mod, and the test infrastructure are unaffected. Cross-worker event correlation usesrun_ms, so wall-clock jumps inside a single container do not affect ordering either. Do not add in-container time-sync daemons to suppress this.