Skip to content

fix: settle ink input handlers after frame waits so TUI coverage runs stop flaking on Windows - #1996

Merged
cliffhall merged 3 commits into
v2/mainfrom
v2/fix/1942-tui-coverage-flake
Aug 16, 2026
Merged

fix: settle ink input handlers after frame waits so TUI coverage runs stop flaking on Windows#1996
cliffhall merged 3 commits into
v2/mainfrom
v2/fix/1942-tui-coverage-flake

Conversation

@olaservo

Copy link
Copy Markdown
Member

Closes #1942

Test-only. In ink 6 a visible frame doesn't imply armed useInput handlers (they re-subscribe in the passive-effect flush), so a keypress right after a frame wait could be silently swallowed — v8 instrumentation widens the window, hence the coverage-only flake. waitUntil now awaits one setImmediate turn after its predicate passes; check-queue FIFO guarantees the effect flush runs first. Verified: race made deterministic via a 1ms-poll amplifier (3/3 failing before, 3/3 green after) plus 3 consecutive full green test:coverage runs. Full root-cause in the issue comment.

🤖 Generated with Claude Code

…I App tests

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@olaservo olaservo added the v2 Issues and PRs for v2 label Aug 12, 2026
@cliffhall

Copy link
Copy Markdown
Member

@olaservo — could you try this branch on your Windows machine?

I worked #1942 independently before finding this PR, and landed on the same root cause you did: in ink 6 a visible frame does not imply armed input handlers. The frame is written during React's commit, but useInput re-subscribes in the passive-effect flush, which React schedules on a setImmediate. waitUntil polls on setTimeout, and Node runs the timers phase before the check phase — so a poll can observe the new frame in the same loop iteration that still has React's flush queued. A key pressed right there dispatches into the previous commit's stale closures and is silently swallowed, with no retry, so the success frame never renders. v8 instrumentation just widens the window, which is why it is coverage-only.

Two additions are being pushed onto this branch on top of your settleInputHandlers fix:

  1. The same settle before each stdin.write in press() and mount() — your version closes the window after frame waits, but the same race exists at the other keypress sites.
  2. A guard test that pins the invariant: it schedules a setImmediate sentinel and asserts it ran before waitUntil resolves. Verified it fails when the yield is removed.

The reason I am asking rather than just calling it done: clients/tui npm run test:coverage passes 3/3 here and root npm run ci is green, but this machine is macOS and has never reproduced the flake — so a green run here is not evidence. Windows is the only place the failure has ever been observed, which makes your machine the only real test. If you could run npm run test:coverage in clients/tui a handful of times there, that would confirm it (or not).

…he contract

Extends the previous commit's fix rather than changing it. `waitUntil` now
settles, but the same window is open wherever a key is written without having
just polled: `press()` reaches its `stdin.write` on a turn that may still have
React's passive-effect flush queued (straight after a plain `tick`, or after an
earlier key committed a render), and `mount()`'s absorbing keypress lands one
tick after the mount commit. Both now `await settleInputHandlers()` before
writing, so "frame visible" implies "input handlers armed" at every dispatch
point, not only after a frame wait.

Adds a `test helpers` case that pins the contract: a `setImmediate` sentinel
stands in for the pending effect flush and must have run by the time
`waitUntil` resolves. Verified it fails when the yield is removed, so a future
regression surfaces here instead of as a differently-named flaky OAuth test
under coverage instrumentation.

Signed-off-by: cliffhall <cliff@futurescale.com>
@cliffhall

Copy link
Copy Markdown
Member

Pushed one additive commit on top of your fix (ae0e9d53) — nothing of yours rewritten, and it reuses settleInputHandlers rather than introducing a parallel helper. Two things:

1. The same settle at the keypress sites. Your version closes the window after a frame wait, which is where #1942 bit. But the hazard is "a key is written on a turn that still has React's passive-effect flush queued", and a poll is not the only way to arrive there:

  • press() writes its key straight after the caller's own tick, or after a previous key in the same loop committed a render — either can leave the flush pending, so the key routes to the prior commit's useInput closures. The existing "two ticks per key" comment is about the same failure mode, arrived at empirically; await settleInputHandlers() before each write makes it structural.
  • mount()'s "x" — the key that absorbs ink-testing-library's dropped first keypress — lands one tick after the mount commit, same story.

So after this, "frame visible ⇒ input handlers armed" holds at every dispatch point rather than only after a poll.

2. A guard test for the contract. New describe("test helpers") case: schedule a setImmediate sentinel standing in for React's pending effect flush, await waitUntil(() => true), assert the sentinel already ran. I verified it fails when the yield is removed from waitUntil and passes with it — so if the yield is ever dropped in a refactor, it fails there by name instead of resurfacing months later as a differently-named flaky OAuth test that only reproduces under v8 instrumentation.

Verification: clients/tui && npm run test:coverage ×3 all green (294/294; statements 96.2 / branches 93.07 / functions 96.53 / lines 96.6, no per-file gate misses), plus a full root npm run format + npm run ci green end to end (validate → coverage → verify:build-gate → smoke incl. local smoke:tui → Storybook).

Happy to drop either half if you'd rather keep the change minimal.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Stabilizes TUI tests by waiting for Ink input handlers to re-arm after frame updates.

Changes:

  • Adds a setImmediate-based input-settling helper.
  • Applies settling before simulated keypresses.
  • Adds a regression test for the synchronization contract.

💡 Add a code-review agent skill for context-aware, tailored reviews. Learn more in the docs.

@olaservo

Copy link
Copy Markdown
Member Author

@cliffhall Ran this branch (ae0e9d5) on the Windows machine:

  • npm run test:coverage in clients/tui: 5/5 green (26 files, 294 tests each).
  • This machine never reproduced the flake as filed even before the fix, so plain green runs are weak evidence here too. The stronger check from the original investigation is shrinking tick from 25ms to 1ms, which made the three step-up tests fail 3/3 before the settle. With your additions: 3/3 green (81/81 in App.test.tsx).

The press()/mount() settles and the sentinel test both look right to me.

@cliffhall
cliffhall marked this pull request as ready for review August 16, 2026 21:40
@cliffhall
cliffhall merged commit a03c7d9 into v2/main Aug 16, 2026
1 check passed
@cliffhall
cliffhall deleted the v2/fix/1942-tui-coverage-flake branch August 16, 2026 21:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

v2 Issues and PRs for v2

Projects

None yet

Development

Successfully merging this pull request may close these issues.

TUI coverage gate flakes: App.test.tsx OAuth step-up tests fail under v8 instrumentation only

3 participants