Skip to content

[P0] Replace the single-sample watcher-status assertion in the lease-contention test with a bounded settled-state wait #700

Description

@mohanagy

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:

  1. 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.
  2. 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

  1. The failing assertion is replaced with a bounded wait that still requires idle.
  2. waitFor's timeout error names the condition and the last observed value.
  3. 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.
  4. A six-lane exact-head matrix is green on the correction branch, with the raw logs inspected rather than the step names trusted.
  5. 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions