Fix: Root-cause the flake cluster that made "rerun once" standard advice - #618
Conversation
…uster
- MarkdownStylesheetTests: drive FileWatcher's debounce from an injected
TestClock (the FileWatcherTests idiom) so no wall-clock bound covers the
150ms window; remaining polls wait only on real legs with hang guards
- ThemeStoreTests: re-derive the FSEvents reload hang guard to the
population-sized 90s figure; timeout now throws a diagnostic that
discriminates watcher-never-fired / decode-failed / file-never-landed
- ProviderEventsSupervisorTests: re-derive tier-3 bounded-poll guards for
saturated local parallel runs, pin a 6-minute suite hang limit, and stop
asserting the transient {live-a, run-1} snapshot that the supervisor's
own 5s-silence watchdog legitimately replaces mid-test
|
✅ Looks good Both review lenses — correctness and CLAUDE.md conventions — came back clean on this PR. Correctness. The diff is confined to three test files ( Conventions. None of the flag/TUI-scraping/private-context/migration rules apply to a test-only PR, and that scope was verified rather than assumed. The claimed anchor doctrine in 0 findings were filtered out as invalid during the merge (both specialists returned empty findings arrays). Finding dispositionsNo specialist findings were reported by either lens — nothing to dispose of. Review diagnosticsNo tool calls failed or were denied for either specialist or the orchestrator. One environmental note: Posted by the claude-review check — the review of this PR's diff at patch-id |
Under-load soak results (the failure condition from the PR description)Three consecutive full-suite runs (5411 tests in one process) on the loaded dev box, load averages 15–44 on 12 cores at run starts:
Also for the record: this PR's first CI run flaked on |
What's broken
A recurring cluster of tests fails under machine load and passes on targeted rerun: both
ProviderEventsSupervisorTests, theMarkdownStylesheetTestsdirectory-watch test, theThemeStoreTestswatcher-reload test, and (analyzed but not changed here, see below) theAppearanceDebounceTests/SearchQueryDebouncerTestsburst tests. Across four full runs on a loaded dev machine, every run had at least one of these red; each went green on rerun. The standing advice calcified into "rerun once", which taxes every branch and trains people to wave past red suites.Why it happens
Swift Testing runs every non-serialized test in one process with no concurrency cap, so per-test scheduling latency scales with the total population (now 5417 tests) plus external machine load — mined runs put p50 per-test reported duration at ~1/3 of total wall time (
Tests/CLAUDE.md, "Population is the scheduler"). Each flaking test waited on that real scheduling behind a wall-clock bound sized for a smaller population or a quieter environment. Per-test root causes, each traced through the production pipeline (none is a race in the code under test):ProviderEventsSupervisorTests(tier 3) — 15s bounded-poll deadlines were sized for CI's quiet serial pass; a loaded parallel local run starves the supervision task past them (observed: pid file never read in 15s; mirror rows still empty at 15s). On top of that, one assertion had a genuine logic race:snapshotAppliedAndRestartResyncsdemanded the exact transient set{live-a, run-1}, but the stub goes silent after its snapshot andsilenceLimitis 5, so the supervisor's own watchdog legitimately kills and respawns it every few seconds, replacingrun-1withrun-2— an observer starved past the first cycle could never match, at any deadline.MarkdownStylesheetTestswatch test (tier 2) — waited out a real 150msFileWatcherdebounce plusAsyncStreamdelivery under one 8s bound.FileWatcheralready takes an injected clock, and the siblingFileWatcherTestsalready proves the real-dispatch-source +TestClockpattern; this test predated it.ThemeStoreTestswatcher test (tier 2) — FSEvents cannot be virtualized (the 0.1s is a stream latency parameter, not a sleep; events are journaled by fseventsd, so none can be lost) and bounded polling is the sanctioned shape; the 30s bound predated the population re-derivation that took the shared guards to 45s/90s. Its wait rides the single MainActor executor that every@MainActorsuite queues on.When it broke
Not one regression commit: each bound was correct for the population it was derived against (the ThemeStore 30s in #441 against ~3000 tests; the supervisor 15s with #514 assuming the quiet tier-3 pass). Population growth to 5417 plus multi-agent load on the dev box moved the environment out from under them. The
run-1transient-set race has been latent since #514 — it needs a >5s observation delay to bite, so only load exposed it.What this PR does
MarkdownStylesheetTests: rewrites the watch test onto theFileWatcherTestsidiom — real dispatch source delivers the event, the debounce timer is virtual (FileWatcher(clock: TestClock)via an arm-counting delegating clock). No wall bound covers the debounce any more; the remaining bounded polls guard only genuinely real-time legs (kqueue registration/arming,AsyncStreamdelivery) and throw named diagnostics.ProviderEventsSupervisorTests: rewritesrunAssertionsagainst the contract instead of run indices — a snapshot applied (live-a+ anyrun-N), invocation count strictly above its value at the kill, then somerun-MwithM > maxRunAtKill(a snapshot only a post-kill connection can produce). Re-derives deadlines with the reasoning inline: bounded-poll guards 15→90s (anchoring to theciSafeDeadlinefigure for the same contention class), zombie-reap guards 5→30s, thestop()promptness bound 10→60s, and pins a 6-minute suite hang limit so a regressedstop()cannot wedge a whole local run.ThemeStoreTests: re-derives the reload hang guard 30→90s (same anchor), and upgrades the timeout to a thrown diagnostic reportinguserThemes,loadErrors, and the actual directory listing — discriminating "watcher never fired" from "reload ran but decode failed" from "file never landed".On the raised numbers, explicitly rather than quietly: every one is a positive wait that breaks on its first satisfying probe — healthy runs pay ~0.1s, only a genuinely failing run pays the deadline — and each carries its derivation in a comment so the next re-derivation has an anchor. This is the repo's established hang-guard doctrine (
Tests/CLAUDE.md), not tolerance-window inflation; the transient-state fix above is the part no deadline could have fixed.Deliberately not changed: the
AppearanceDebounceTests/SearchQueryDebouncerTestsburst tests. They are already fully virtual-time and.serialized; their failure is the arming handshake starving inside shared machinery (TestClock.checkSuspension's background-QoS megaYield under process-global saturation), and every suite-local remedy is already measured-and-refuted inTests/CLAUDE.md. The real fix is the megaYield-free virtual clock thatClockTestSupportnames as a deliberate shared-contract non-goal — that reshapes shared test infrastructure, so it needs a human decision first rather than being bundled here. Also checked per the burn-down list: all five production types in the cluster already carry the clock seam; nono_raw_task_sleepsuppressions are involved.Evidence & verification
Mutation checks (each applied to
Sources/, observed red with its named diagnostic, restored, re-observed green):FileWatchereventMask minus.write/.extend→a FileWatcher on a directory armed no debounce timer for an entry being created — observed armed=0 after 4 attempts of 2.0 seconds eachThemeStore.startWatching()withoutw.start(directory:)→ thrown diagnostic at 90s whose directory listing showsext.jsonpresent, pinning "watcher never fired"supervisor did not restart the events process within 90.0s; observed invocation count=1, was 1 at the killkillTree→ red via the leak check at the 30s reap guard (stop()returned promptly; the child survived)Targeted suites green before and after: 32 tests across the three files (the rewritten markdown watch test now runs in ~0.1s instead of riding a real debounce).
scripts/swift-safe buildclean,swiftlint --strictclean. Full-suite under-load soak runs are executing on the loaded dev box now; results will follow as a PR comment.🔗 open in tbd