Parent: #649
Milestone: v0.33.0-beta.1 — Trust Foundation
Family: #689, #692, #695 — the deterministic-test-evidence stream that closed #654
Currently blocks: a clean six-lane matrix on PR #681, and therefore every roadmap merge into next
Outcome
tests/unit/watch.test.ts > watch > keeps startup unsettled during live lease contention and recovers after release samples a persisted watcher status exactly once, at a moment when two independent and platform-dependent inputs can legitimately have set it to pending. The assertion therefore fails nondeterministically on macOS while the production behaviour it observes is correct.
Make the assertion deterministic without weakening it.
Observed failure
run : https://github.com/mohanagy/madar/actions/runs/31767671776
job : validate (macos-latest, Node 20) (id 94666770382)
head : 1a1c5030f9481be25a5f0dc6a21641f988870d44 (PR #681)
lanes : 5 of 6 green; macos-latest / Node 20 alone failed
FAIL tests/unit/watch.test.ts > watch > keeps startup unsettled during live lease contention and recovers after release
AssertionError: expected 'pending' to be 'idle' // Object.is equality
Expected: "idle"
Received: "pending"
❯ tests/unit/watch.test.ts:370:71
Test Files 1 failed | 210 passed (211)
Tests 1 failed | 2990 passed | 1 expected fail | 2 skipped (2994)
The three assertions immediately before it passed:
await refresh.startupSettled
expect(refresh.startupComplete?.()).toBe(true) // passed
expect(refresh.initialRebuilt).toBe(true) // passed
expect(readWatcherStateForGraph(generated.graphPath)?.status).toBe('idle') // line 370 — failed
So startup completed and the initial rebuild ran. Only the persisted status disagreed.
Mechanism
src/infrastructure/watch.ts:1038, at the end of the startup reconcile:
state.status = eventDirty || pending ? 'pending' : 'idle'
Two independent inputs can each set pending, and neither is synchronised with startupSettled:
eventDirty — a filesystem event delivered during startup. The test writes main.ts twice and starts the watcher immediately after the second write. On macOS, FSEvents can surface a write that happened just before the watcher attached, so the event may or may not arrive inside the startup window purely by platform timing.
postBuildChanges (watch.ts:1030-1036) — the post-rebuild snapshot diff. When it is non-empty, pending is set to true on the same path.
In both cases pending is the correct persisted status: a change really was observed during startup. The defect is that the test treats one of two legitimate settled outcomes as the only acceptable one, sampled once with no synchronisation.
startupSettled is a promise about startup, not about the watcher having quiesced. The test uses it as if it were both.
Why this surfaced now rather than during #654
The same test passed on all 18 lanes across the three consecutive acceptance matrices 31748654971, 31749252628, 31749780064 at 6b6b3d84. This is a latent race, not a regression introduced on next.
It is not caused by PR #681's diff. That branch touches only docs/qualification/, .github/, package.json, .gitattributes and tests/unit/qualification-contract.test.ts, and nothing in the watcher or its tests. Adding a test file does change how work distributes across the four Vitest workers, which plausibly shifted the timing that exposed the race — a trigger, not a cause. Recorded here rather than on #681 so that the correction is not smuggled into an unrelated diff.
This is material to #654's outcome. The protected complete-suite merge gate is real, but this failure shows the suite still contains at least one single-sample assertion over a value that a live watcher keeps mutating, of the same family #689, #692 and #695 corrected. #654 remains closed; this issue carries the remaining instance.
Required correction
Wait for the settled state instead of sampling it, keeping idle as the required outcome:
await refresh.startupSettled
expect(refresh.startupComplete?.()).toBe(true)
expect(refresh.initialRebuilt).toBe(true)
// A change observed during startup legitimately persists 'pending'. The invariant
// under test is that lease contention resolves to a settled 'idle', not that the
// status is already idle the instant startup settles.
await waitFor(() => readWatcherStateForGraph(generated.graphPath)?.status === 'idle')
waitFor is already the idiom two lines above in the same test, which waits for 'reconciling'.
Improve the helper's failure evidence at the same time: waitFor currently throws the generic Timed out waiting for graph refresh, which names neither the condition nor the value actually observed. A timeout here should report the last observed status.
Non-goals
- Do not relax the assertion to accept
pending as a final state. Reaching idle is the point of the test.
- Do not add
retry, .skip, .todo, or quarantine.
- Do not change
maxWorkers or testTimeout.
- Do not change watcher production semantics.
pending after an observed startup change is correct behaviour.
Acceptance criteria
- The failing assertion is replaced with a bounded wait that still requires
idle.
waitFor's timeout error names the condition and the last observed value.
- Every other single-sample assertion over
readWatcherStateForGraph(...)?.status in tests/unit/watch.test.ts is audited for the same pattern, and each one is either shown to be synchronised or corrected.
- A six-lane exact-head matrix is green on the correction branch, with the raw logs inspected rather than the step names trusted.
- No test is weakened, skipped, retried or quarantined.
Rollback
Single test-only commit; revert restores the previous assertion and the previous nondeterminism.
Refs #654. Refs #689. Refs #692. Refs #695. Related pull request: #681.
Parent: #649
Milestone:
v0.33.0-beta.1 — Trust FoundationFamily: #689, #692, #695 — the deterministic-test-evidence stream that closed #654
Currently blocks: a clean six-lane matrix on PR #681, and therefore every roadmap merge into
nextOutcome
tests/unit/watch.test.ts > watch > keeps startup unsettled during live lease contention and recovers after releasesamples a persisted watcher status exactly once, at a moment when two independent and platform-dependent inputs can legitimately have set it topending. The assertion therefore fails nondeterministically on macOS while the production behaviour it observes is correct.Make the assertion deterministic without weakening it.
Observed failure
The three assertions immediately before it passed:
So startup completed and the initial rebuild ran. Only the persisted status disagreed.
Mechanism
src/infrastructure/watch.ts:1038, at the end of the startup reconcile:Two independent inputs can each set
pending, and neither is synchronised withstartupSettled:eventDirty— a filesystem event delivered during startup. The test writesmain.tstwice and starts the watcher immediately after the second write. On macOS, FSEvents can surface a write that happened just before the watcher attached, so the event may or may not arrive inside the startup window purely by platform timing.postBuildChanges(watch.ts:1030-1036) — the post-rebuild snapshot diff. When it is non-empty,pendingis set totrueon the same path.In both cases
pendingis the correct persisted status: a change really was observed during startup. The defect is that the test treats one of two legitimate settled outcomes as the only acceptable one, sampled once with no synchronisation.startupSettledis a promise about startup, not about the watcher having quiesced. The test uses it as if it were both.Why this surfaced now rather than during #654
The same test passed on all 18 lanes across the three consecutive acceptance matrices
31748654971,31749252628,31749780064at6b6b3d84. This is a latent race, not a regression introduced onnext.It is not caused by PR #681's diff. That branch touches only
docs/qualification/,.github/,package.json,.gitattributesandtests/unit/qualification-contract.test.ts, and nothing in the watcher or its tests. Adding a test file does change how work distributes across the four Vitest workers, which plausibly shifted the timing that exposed the race — a trigger, not a cause. Recorded here rather than on #681 so that the correction is not smuggled into an unrelated diff.This is material to #654's outcome. The protected complete-suite merge gate is real, but this failure shows the suite still contains at least one single-sample assertion over a value that a live watcher keeps mutating, of the same family #689, #692 and #695 corrected. #654 remains closed; this issue carries the remaining instance.
Required correction
Wait for the settled state instead of sampling it, keeping
idleas the required outcome:waitForis already the idiom two lines above in the same test, which waits for'reconciling'.Improve the helper's failure evidence at the same time:
waitForcurrently throws the genericTimed out waiting for graph refresh, which names neither the condition nor the value actually observed. A timeout here should report the last observed status.Non-goals
pendingas a final state. Reachingidleis the point of the test.retry,.skip,.todo, or quarantine.maxWorkersortestTimeout.pendingafter an observed startup change is correct behaviour.Acceptance criteria
idle.waitFor's timeout error names the condition and the last observed value.readWatcherStateForGraph(...)?.statusintests/unit/watch.test.tsis audited for the same pattern, and each one is either shown to be synchronised or corrected.Rollback
Single test-only commit; revert restores the previous assertion and the previous nondeterminism.
Refs #654. Refs #689. Refs #692. Refs #695. Related pull request: #681.