fix(test): make the exec-timeout tests deterministic under load - #27
Merged
Conversation
`task check` was non-deterministic on main: `internal/provider` TestExecDigestPin/match_allows_exec and the `hack/spikes/provider` exec tests failed with `signal: killed` under full-suite `-race` load. Diagnosis (measured, not assumed): the tests spawn a real child provider binary under a hardcoded 1s (5s in the isolation/contract tests) ExecOpts deadline. Instrumenting the call sites with a 120s deadline and logging elapsed time under `go test -race -count=1 ./...` with 36 competing CPU burners shows the child takes 1.2s-5.3s wall clock (~200ms unloaded) and completes successfully. The first exec of a just-written binary pays page-in plus macOS code-signature validation while sibling package binaries compile and run. Every failure landed exactly on the deadline (1.00s / 5.00s / 5.01s) with elapsed well under 120s, so it is deadline-under-load, not a deadlock, leak, or race. Fix: a named `execTestTimeout = 60 * time.Second` constant per package, with a comment recording the measurement and why it must stay generous. Nothing in these tests asserts timeout behaviour -- they pin digest verification and argv/env scrubbing -- so the deadline is incidental plumbing. The dedicated timeout assertions (TestTransportTimeoutUnavailable, spike TestStates) keep their short, deliberately-blocking deadlines and are untouched. Failure messages now name the deadline, so a recurrence reads "matching pin must allow exec (timeout 1m0s): signal: killed" instead of a bare `signal: killed` that reads like a crash. Before: 6/6 full-suite runs failed under 36 burners. After: 0/6. Assertions verified still live by mutation at both polarities: VerifyExecDigest fail-open reds the refusal subtests; inverting the digest comparison reds match_allows_exec and both isolation tests; breaking the child's stdout contract reds the dump assertion; leaking the spike ScrubEnv reds spike TestIsolation.
…ew F1/F2)
The isolation tests detect a scrubber regression by inspecting the child's env
dump -- and then printed the offending `NAME=VALUE` line in full. That branch
fires exactly when `ScrubEnv` has regressed, which is exactly when the line holds
a real host credential: the test that catches the leak also amplified it into
whatever log captured the failure. Demonstrated during this lane's mutation
testing, which dumped live operator tokens to stdout.
F1: the per-line assertion now prints only the variable name. It still says which
variable leaked -- the assertion is unchanged -- only the value is dropped.
Present in BOTH isolation tests (internal/provider and hack/spikes/provider).
F2: the five `dump:\n%s` sanity messages now print through redactDumpValues,
which masks the value half of every NAME=VALUE line while leaving the dump's
`=== SECTION ===` banners and the stdin JSON readable. Assertions always run
against the raw dump -- only the printed form changes -- so no check is weakened.
Verified with a deliberately safe mutation shape that leaks only the tests' own
configured canaries and never touches os.Environ():
- `secretName` filter disabled for configured env: 4 leaked-canary errors plus
2 credential-name errors, in both packages, with the credential lines now
reading `UPSTREAM_TOKEN` / `LDAP_SECRET` and no values.
- configured env dropped entirely: the sanity Fatalf still fires in both
packages and prints a redacted-but-readable dump (`PATH=<redacted>`, banners
intact).
This was referenced Aug 8, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
task check— the gate AGENTS.md rule 4 requires green before every commit — wasnon-deterministic on
main(7247e6d). Under full-suite-raceload:internal/providerTestExecDigestPin/match_allows_exec→signal: killedhack/spikes/providerTestContractandTestIsolation→signal: killedDiagnosis (verified, not assumed)
The reported root cause is correct, and I measured it rather than taking it on trust.
Repro. 36 CPU burners (18-core machine) +
go test -race -count=1 ./..., 6 runs.-count=1matters: without it the Go test cache serves runs 2..n and the flake looksintermittent when it is actually deterministic under load.
Result: 6/6 runs failed. Every failure landed exactly on its deadline —
TestExecDigestPin/match_allows_exec (1.00s),TestContract (5.01s),TestIsolation (5.00s).Instrumentation. I temporarily raised each call site's deadline to 120s and logged
elapsed wall-clock, then re-ran the same loaded full suite:
internal/providermatch_allows_execTestContractexecTestIsolationexecTestContractHTTPEvery one of those completed with
err=<nil>at the 120s deadline. That rules out thealternatives explicitly: a deadlock would have shown elapsed ≈ 120s, a leaked process or
unbuffered channel would not resolve at all, and a data race would have tripped
-race.It is a healthy child process losing a race against a short wall-clock deadline. Confirmed:
timeout-under-load.
A secondary probe explains the magnitude. Running the affected package alone under the same
36 burners, the child costs ~200ms on its first exec and ~12ms on every subsequent one.
The first exec of a just-written binary pays page-in plus macOS code-signature validation; in
the full-suite run 18 sibling package binaries are compiling and executing at the same time,
stretching that first spawn 6–25x. Consistent with the observation that the failing test is
always the first spawn of each fixture binary in its package.
Fix
A named constant per package with the measurement written into the comment:
Applied to every call site that spawns the real child (
internal/provider/exec_test.go,internal/provider/isolation_test.go,hack/spikes/provider/{contract,isolation}_test.go).Why a generous named constant rather than deriving it from
t.Deadline(). The derivedvariant needs branches for "no deadline set" (
-timeout 0) and "budget already exhausted"that no run in this repo ever executes. A repo that has been bitten repeatedly by
tests-that-cannot-fail should not gain unexercised logic in its test helpers. 60s is ~11x the
worst measured spawn (5.31s) and costs nothing when things are healthy — the call returns in
milliseconds. The comment records the measurement so nobody optimises it back down.
What is not weakened. Nothing in these tests asserts timeout behaviour; they pin digest
verification (REQ-E5-S03-02) and argv/env scrubbing (REQ-E5-S03-01/03), for which the deadline
is incidental plumbing. The dedicated timeout assertions are untouched and already robust:
TestTransportTimeoutUnavailable(50ms) and spikeTestStates(100ms) both block theirhandler until the test releases it, so a slow machine can only make them slower, never wrong.
Failure messages now name the deadline. A recurrence reads
matching pin must allow exec (timeout 1m0s): signal: killedrather than a baresignal: killedthat reads like a crash — the ambiguity that cost four agents a session.Proof the assertions still bite (mutation, both polarities)
The risk with a timeout fix is neutering the assertion. Four mutations, each grepped in the
file to confirm it landed before believing the result, each reverted after:
VerifyExecDigest→return nil(fail-open)match_allows_execstill greenmissing_pin_refuses,mismatch_refuses.match_allows_execpassed — the pin matched anyway.!strings.EqualFold(got, pin)match_allows_execREDmatch_allows_exec,TestIsolationNoWriteToken,TestIsolationNoCredentialInArgv=== ARGV DUMP ===bannermatch_allows_execon the stdout-content assertionScrubEnv→append(os.Environ(), configured...)TestIsolationREDPolarity B is the load-bearing one: it is the only mutation that reds the exact subtest whose
timeout changed, and its output shows the new diagnostic (
(timeout 1m0s)) working. A alonewould have proven nothing about this change.
git statuswas verified clean after each revert —internal/provider/transport.gois PR #26'sfile and no mutation hunk is in this diff.
Before / after
go test -race -count=1 ./...× 6, 36 burnerstask check× 2go test -race -count=5 ./internal/provider/...go test -race -count=5 ./hack/spikes/provider/...Note on the two
task checkruns:task testisgo test -race ./...with no-count=1, sothe second run is partly cache-served and mostly proves the gate is satisfied. The load claim
rests on the 0/6 re-run of the verbatim before-script.
Files touched
internal/provider/exec_test.go— constant + 6 call sites + diagnostic messageinternal/provider/isolation_test.go— 2 call sites (5s → constant) + diagnostic messageshack/spikes/provider/main_test.go— constant next to the fixture-binary varshack/spikes/provider/contract_test.go— exec + HTTP call siteshack/spikes/provider/isolation_test.go— exec call siteCHANGELOG.md— regenerated (task changelog-write)No production code changed. Nothing outside this lane's ownership:
transport.go,transport_test.go,internal/forge/**,internal/compare/**,schemas/**,docs/architecture/**,.github/workflows/**,Taskfile.yml,cliff.tomlare all untouched.Residual risks
TestStatesuses one 100ms deadline for all threehandlers, including the non-blocking
garbage/stalecases where the deadline isincidental. In principle that can flake under load. It did not flake in 6/6 loaded runs,
the 100ms is load-bearing for the
timeouthandler's assertion, and ifgarbage/staleever exceed it the test fails loud and correct (
unavailable≠invalid), never fail-open.Restructuring a table-driven test for an unreproduced hypothesis is scope creep in a lane
billed as surgical. Logged here so the next reader knows it was considered.
contended than this measurement, the constant is the single place to raise — and the comment
says so.
Note on the base
origin/mainadvanced from7247e6dtoa2a2b17(PRs #23/#24) while this lane ran, producinga
CHANGELOG.md-only conflict. Resolved by mergingorigin/maininto the lane — the pattern4d8f577already established in this repo — and regenerating withtask changelog-write. Noforce-push, no history rewrite.
git diff origin/main...HEADis exactly the six files listedabove. Both
task checkruns and the 0/6 loaded re-run above were performed on the mergedtree.
Review round 2 (F1, F2, F5)
F1 — leaked credential names, never values (both files). The per-line assertion in
internal/provider/isolation_test.goandhack/spikes/provider/isolation_test.goprintedthe full
NAME=VALUE. That branch fires exactly whenScrubEnvhas regressed, which is exactlywhen the line holds a real host credential — so the test that detects a leak also amplified
it into whatever log captured the failure. Both now print only the name. The assertion is
untouched: it still says which variable leaked.
F2 — redacted sanity dumps (taken, optional). The five
dump:\n%smessages now printthrough
redactDumpValues, masking the value half of everyNAME=VALUEline while leaving the=== SECTION ===banners and the stdin JSON readable. Assertions always run against the rawdump — only the printed form changes — so no check is weakened.
Safe-mutation evidence (reviewer's shape: leaks only the tests' own configured canaries,
never touches
os.Environ(), so no real credential is exposed):secretNamefilter disabled for configured env onlyUPSTREAM_TOKEN/LDAP_SECRETwith no valuesFatalfRED=== ENV DUMP ===/PATH=<redacted>/ intact stdin JSONThe second mutation also caught a defect in my first cut of
redactDumpValues: it redacted the=== SECTION ===banners themselves (they contain=), gutting the diagnostic. Fixed with aname != ""guard and re-verified.F5 — base refreshed to
9e50e17. Mergedorigin/mainin (no force-push), merge subject:wrench: chore(release): …socliff.toml's first parser skips it, andtask changelog-writecommitted last.
task changelog-verifygreen standalone (verify-changelog: ok).internal/provider/transport.goandhack/spikes/provider/transport.goare byte-identical toorigin/main— every mutation was reverted and verified.Gates re-run on the merged head, graded on stage banners, not exit code: both
task checkruns reached all 13 stages (
fmt, vet, lint, test, coverage, build, dogfood-comparison, compare-exitgate-test, changelog-verify, release-changelog-gate-test, release-verify-tag-gate-test, docs-gates, lint-depguard-test), exit 0, zero failure lines.Loaded repro re-run on this head: 0/6.
Correction to the original report: the flake also reds
task coverage(
go test -coverprofile ./internal/...), not onlytask test—mainis genuinely broken attwo stages, not one.
Containment re-verified on the final tree: 0 hits for
glpat-,github_pat_,ics_v1_,ATATT, and the two hex token prefixes across the pushed range, the tracked tree, both gatelogs, the commit messages, and this PR body.
Base refreshed again:
origin/mainadvanced to669d805(PR #25) while round 2 wasverifying. Merged in the same way,
task changelog-writecommitted last, gates re-run — thatPR adds a 14th stage, so both runs now reach all 14 banners (
… lint-depguard-test, lint-workflow-pins-test), exit 0, zero failure lines.git diff origin/main...HEADremainsexactly the six files.