Skip to content

Conversation

@costajohnt
Copy link
Contributor

@costajohnt costajohnt commented Jan 26, 2026

Summary

  • Makes unmount() flush pending throttled renders and log writes before the final render, so the last frame is always displayed
  • Makes waitUntilExit() await the stdout drain callback, so the promise only resolves once buffered writes are actually flushed to the terminal
  • Users can use process.exitCode + await waitUntilExit() instead of process.exit() for guaranteed final output
  • No new public API surface area

Changes

  • unmount() now calls this.throttledOnRender.flush() and this.throttledLog.flush() before the final render
  • Exit promise resolution is deferred until stdout.writableNeedDrain is false (or the drain event fires)
  • Removed flush() public method, src/flush.ts, and flush export
  • Added test verifying unmount forces pending throttled render

Test plan

  • New test: "unmount forces pending throttled render"
  • All existing tests pass
  • Linting passes

Closes #796

@sindresorhus
Copy link
Collaborator

I do not think we need a public flush API. The real fix can live inside existing behavior: make unmount force the final render without throttling, and have waitUntilExit await the last stdout write callback so buffered writes are actually flushed. Then recommend using process.exitCode and await waitUntilExit instead of process.exit. This keeps the surface area unchanged and still guarantees the last frame is written.

Instead of adding a public flush() API, this modifies the existing
unmount() to flush throttled renders/log writes before the final render,
and waitUntilExit() to await stdout drain before resolving. Users can
use `process.exitCode` + `await waitUntilExit()` for guaranteed output.

Closes vadimdemedes#796
@costajohnt costajohnt force-pushed the feat/flush-pending-renders branch from 26eb6c2 to edab70d Compare February 5, 2026 06:33
@costajohnt costajohnt changed the title feat: add flush() method to force pending renders fix: make unmount flush pending renders and await stdout drain Feb 5, 2026
@costajohnt
Copy link
Contributor Author

Great feedback, agreed! I've revised this PR to avoid adding a public flush() API. Instead:

  1. unmount() now flushes pending throttled renders and log writes before the final render, so the last frame is always displayed
  2. waitUntilExit() now awaits the stdout drain callback, so the promise only resolves once buffered writes are actually flushed to the terminal

Users can use process.exitCode + await waitUntilExit() instead of process.exit() and get guaranteed final output. No new surface area.

@sindresorhus
Copy link
Collaborator

  1. [P1] waitUntilExit() can resolve before the last write callback runs
    File: ink/src/ink.tsx:463
    Current code only waits when stdout.writableNeedDrain is true. That misses streams where writes are async but never exceed highWaterMark (writableNeedDrain stays false).
    I reproduced this with a delayed custom Writable: waitUntilExit resolved in ~1ms while the stream callback fired ~150-200ms later.

    What should change:

    • Don’t gate on writableNeedDrain.
    • Resolve/reject via a write callback barrier (for example stdout.write('', callback)), so resolution happens after queued writes are processed.
  2. [P2] Missing regression test for that path
    File: ink/test/render.tsx:406 (new test area)
    The new test covers throttled render flushing, but not the new waitUntilExit flush guarantee. Add a test with a delayed custom Writable where writableNeedDrain remains false, and assert waitUntilExit() does not resolve early.

Replace the writableNeedDrain check with a stdout.write('', callback)
barrier so waitUntilExit() only resolves after all queued write
callbacks have completed, regardless of stream backpressure state.

Add regression test with a delayed custom Writable.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@costajohnt
Copy link
Contributor Author

Thanks @sindresorhus, great catch on both points.

P1 – write callback barrier: You're right, gating on writableNeedDrain is insufficient since it only reflects backpressure, not whether queued write callbacks have actually fired. Replaced the writableNeedDrain check with a stdout.write('', callback) barrier so resolution is always sequenced after all prior writes are processed.

P2 – regression test: Added a test using a custom Writable with a delayed _write() that never exceeds highWaterMark, asserting that waitUntilExit() only resolves after the write callback fires.

The empty write barrier (stdout.write('', callback)) records an
observable call on sinon spy-based test stdouts, breaking tests that
check lastCall. Detect real writable streams via _writableState or
writableLength and only use the write barrier for those. For non-stream
objects (test spies), resolve via setImmediate instead.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@costajohnt
Copy link
Contributor Author

Follow-up fix: the empty write barrier (stdout.write('', callback)) was recording an observable call on sinon spy-based test stdouts, breaking tests that check lastCall. Now detects real writable streams via _writableState/writableLength and only uses the write barrier for those. For non-stream objects (test spies), resolves via setImmediate instead.

When signal-exit calls unmount() during process shutdown, the event
loop is draining and async callbacks (stdout.write barrier, setImmediate)
may never fire. Detect this case by checking if the error parameter is
a number or null (signal-exit signature) rather than undefined/Error
(manual unmount), and resolve the exit promise synchronously.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@costajohnt
Copy link
Contributor Author

Pushed an additional fix (6d537d2): when unmount() is called from signal-exit during process shutdown, the exit promise now resolves synchronously instead of using the write barrier. During process shutdown the event loop is draining, so async callbacks (stdout.write barrier or setImmediate) may never fire.

The detection works by checking the error parameter — signal-exit passes (exitCode: number, signal: string | null) while manual calls pass undefined or an Error.

Regarding the 2 remaining CI failures (exit > exit normally and hooks > useStdout): both fail identically on master with exit code 13 — they're pre-existing failures unrelated to this PR (likely a node-pty compatibility issue with the CI Node.js version).

@sindresorhus sindresorhus merged commit 5e35d73 into vadimdemedes:master Feb 7, 2026
1 check passed
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.

Wait for Ink to finish any pending renders before process exit

2 participants