Skip to content

test(cli): stop asserting the winner of a 1ms race in the Vale timeout test - #320

Open
thecodedrift wants to merge 1 commit into
fix/rule-guard-json-envelopefrom
fix/deterministic-vale-timeout-test
Open

test(cli): stop asserting the winner of a 1ms race in the Vale timeout test#320
thecodedrift wants to merge 1 commit into
fix/rule-guard-json-envelopefrom
fix/deterministic-vale-timeout-test

Conversation

@thecodedrift

@thecodedrift thecodedrift commented Sep 8, 2026

Copy link
Copy Markdown
Member

Stack (root → tip):

Found while investigating #262. This is not either of the two tests that issue names — those passed 9 of 9 full-suite runs here. It is a separate flake, and it entered with 54cd0c0 (the #300 fix), inside the 0.11.2 range.

The captured failure

#262's main gap is that the assertion output was never recorded. This one now is:

FAIL test/vale-run.test.ts > ValeRunOutcome.blocking > marks a timeout blocking
AssertionError: expected { status: 'ok', blocking: false }
                to match  { status: 'timeout', blocking: true }
   await runVale({ cwd, paths: ["doc.md"], timeoutMs: 1 })

Reproduced once across four concurrent full-suite runs. Idle it passes 15/15, and under light contention 6/6 — it needs real load.

Why it raced

The test gave a 1ms budget to a one-line document. That asserts the winner of a race: the timer must fire before a child that runs in its own process and does not care whether our event loop is free. Measured, that document takes Vale ~46ms, so 1ms normally wins. Under load the timer's callback is delayed while the child keeps going, and it loses.

The production timeout is correct and is unchanged — a settled guard, a cleared timer, and an unref so it never holds the loop open. Only the test's budget was wrong.

The fix

Make the work outlast the budget by a margin nothing plausible closes. Vale is quadratic in the size of a single file, measured on the pinned binary:

size time
80KB 0.30s
160KB 0.91s
320KB 3.50s
640KB 14.3s
1MB 48.5s

So ~320KB of prose against a 100ms budget is a 33x margin the right way round, where the old test had 46x the wrong way. The run is killed at 100ms, so the test costs about that, not 3.5s.

Verified

  • Mutation check: disabling the timer lets the same run finish in 3290ms and report ok, failing the test — which both proves the test exercises the timeout path and independently confirms the 320KB sizing.
  • Eight concurrent copies pass, which is the contention shape that broke the old one.
  • Full suite 1370 passing, lint clean.

Related, and worse — filed separately

That quadratic has a product consequence beyond this test. VALE_TIMEOUT_MS is 60s, and a timeout returns blocking: true immediately rather than being excluded-and-retried the way an unparseable file now is. So one markdown file over roughly 1.1MB blocks the whole check run and every other file's findings are lost — the exact shape #300 fixed, on a path #300 did not cover.

Refs #262

…t test

The test gave a 1ms budget to a one-line document and demanded a timeout. That
asserts the winner of a race: the timer has to fire before a child running in
its OWN process, which does not care whether our event loop is free. Measured,
that document takes Vale about 46ms, so 1ms normally wins — and it was seen
losing once, across four concurrent full-suite runs, reporting a clean "ok"
where the test demanded a "timeout".

Captured assertion, which issue #262 had never managed to record for its own
two tests:

  FAIL test/vale-run.test.ts > marks a timeout blocking
  expected { status: 'ok', blocking: false } to match
           { status: 'timeout', blocking: true }

The production timeout is correct and is not changed: a settled guard, a
cleared timer, and an unref so it never holds the loop open. Only the test's
budget was wrong.

The race is removed by making the work outlast the budget by a margin nothing
plausible closes. Vale is QUADRATIC in the size of one file, measured on the
pinned binary:

  80KB 0.3s | 160KB 0.9s | 320KB 3.5s | 640KB 14s | 1MB 48s

So ~320KB of prose against a 100ms budget is a 33x margin the right way round,
confirmed by the mutation check: disabling the timer lets the same run finish
in 3290ms and report "ok". Eight concurrent copies of the test now pass, which
is the contention that broke the old one. The run is killed at 100ms, so the
test costs about that rather than 3.5s.

This is not either of the two tests #262 names — those passed 9 of 9 runs here.
It is a separate flake that entered with 54cd0c0, inside the 0.11.2 range.

Refs #262
@thecodedrift

Copy link
Copy Markdown
Member Author

@claude /review

@claude

claude Bot commented Sep 8, 2026

Copy link
Copy Markdown

Claude finished @thecodedrift's task in 2m 8s —— View job


Review complete

Review mode: incremental — read 0 prior review thread(s) before reviewing. .prior-review.json contained no reviews, review threads, or top-level comments, so this is the first review of this PR — the whole diff was assessed fresh, nothing carried forward as already-addressed.

  • Read .prior-review.json (empty — first review)
  • Fetch PR diff and understand the change (gh pr diff 320)
  • Read packages/cli/src/rules/vale/run.ts (the spawnVale/runVale implementation) and the full vale-run.test.ts test for context
  • Post review findings

Summary

The diff is a single test file change (+20/-2), replacing the racy 1ms-timeout / one-line-document setup in ValeRunOutcome.blocking > marks a timeout blocking with a 320KB document ("Just simply do it. ".repeat(17_000)) against a 100ms budget, plus a comment explaining why.

I verified the underlying mechanics in src/rules/vale/run.ts:

  • The timeout path (spawnVale) does call child.kill("SIGKILL") on expiry and uses a settled guard so the late close event can't override the timeout result — the production behavior described in the PR body checks out.
  • The settle/clearTimeout/unref handling is unchanged, exactly as claimed.
  • The new fixture size (~323KB, matching the ~320KB the PR describes) and the arithmetic in the added comment (19-byte unit × 17,000 ≈ 323,000 bytes) check out.

This is a solid fix for the race: instead of asserting a 1ms timer beats a ~46ms child process (a race the timer can lose under load), it now asserts a 100ms timer beats a ~3.5s child process — a race that's essentially decided before it starts, and one that fails safe if the timer itself gets delayed. I didn't find any correctness bug, removed test coverage, or convention violation in the diff.

One inline note (informational, not blocking): left on the timeoutMs: 100 assertion — the ~35x margin is measured on one pinned binary/host, and if that margin ever narrows (faster CI hardware, a future Vale build with a better constant factor, sandbox overhead eating into the 100ms), the same race could reappear in the other direction. The PR's own verification (mutation test + 8 concurrent copies) already covers this well; flagging only so it's visible if the constants ever drift.

No other findings. Nothing from the prior review to carry forward since there wasn't one.
· Branch


expect(
await runVale({ cwd, paths: ["doc.md"], timeoutMs: 1 })
await runVale({ cwd, paths: ["doc.md"], timeoutMs: 100 })

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[New] Minor, not blocking: the fix's margin (100ms budget vs. ~3.5s measured real runtime) is what makes this deterministic, but that margin is measured on one pinned binary/host. It only has to survive down to roughly 3x for the timeout to still win reliably; anything that shrinks the ~35x margin toward that — a materially faster CI runner, a future Vale binary bump that improves the quadratic constant, or running this in a lower-resource sandbox where process startup itself eats a chunk of the 100ms — narrows the safety margin rather than eliminating the race outright. Given the PR's own verification (mutation check + 8 concurrent copies) already stress-tested this, I'd treat this as informational rather than something to act on now — just flagging it as the one axis that could reintroduce flakiness later if the constants drift.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant