#1405·opentui

renderer.destroy() leaves the process.stdin handle it created registered on the event loop, wedging the legacy Windows console host

Author: Sandstorm831Created Aug 22, 2026Updated Sep 6, 2026

Summary

createCliRenderer() touches process.stdin, which lazily instantiates the TTY read stream and registers its handle on the event loop. destroy() removes the data listener, clears raw mode and calls pause() — but never releases the handle. On the legacy Windows console host (conhost.exe), if a keypress was delivered during the session and the event loop turns between destroy() and process exit, the console host wedges and then dies, taking the window with it.

pause() stops the read; it does not remove the handle from the loop. That distinction is the whole bug.

Environment

  • @opentui/core 0.4.5 (measured) and 0.5.6 (confirmed), Windows 11, conhost.exe powershell.exe
  • Reproduced on both Node 26.7.0 and Bun 1.3.14 — it is not runtime-specific
  • Windows Terminal is not affected; this is legacy conhost only

The relevant code is unchanged in 0.5.6. cleanupBeforeDestroy() still does exactly stdin.removeListener("data", …)setRawMode(false)stdin.pause(), and neither unref() nor destroy() is ever called on the stdin stream on either version (unref() appears in 0.5.6 only on an internal poll timer). 0.5.6 additionally calls stopTerminalKeepAlive() and drains buffered input, but neither touches the handle registration.

Reproduction

Run in a real conhost.exe powershell.exe window (not Windows Terminal):

javascript
// repro.mjs
import { createCliRenderer } from "@opentui/core";

const renderer = await createCliRenderer({});

process.stderr.write("press a key, then wait\n");
await new Promise((resolve) => process.stdin.once("data", resolve));

renderer.destroy();

// let the event loop turn. no console I/O of any kind happens here.
await new Promise((resolve) => setTimeout(resolve, 3000));
process.exit(0);

The console window wedges roughly 0.6–0.8s after destroy() and closes itself shortly after. Remove either condition and it survives:

  • don't press a key → survives
  • replace the 3s wait with an immediate process.exit(0) → survives

Two notes for anyone re-running this on 0.5.6. On Node the native library loads through node:ffi, so it needs --experimental-ffi or createCliRenderer throws OpenTUI native FFI is not available for this runtime yet. And the once("data") above resolves on OpenTUI's own startup capability replies (CPR, XTVERSION, OSC 10/11), which conhost answers within ~50ms — my instrumented version ignores chunks beginning with ESC so that the keypress arm is genuinely exercised. Notably the capability reply alone is enough to arm the defect, which suggests the trigger is that a read completed at all, rather than that a human was involved.

What the conditions are, precisely

Both are necessary, neither is sufficient. Twenty-plus controlled runs in a real conhost window:

Condition Result
no keypress, loop turns 3s survives
no keypress, renderer held open 10s survives
keypress, main thread blocked 3s with Atomics.wait, then exit survives
keypress, loop allowed to turn dies
keypress, loop turns, no console I/O at all after destroy() dies

The blocked-thread case is the informative one: 3 seconds of wall clock pass and the console lives, so the trigger is JS-scheduled work on the main loop, not elapsed time, not a worker, and not anything native.

The handle

process.stdin does not exist before the OpenTUI import; createCliRenderer brings it into being.

Phase stdin
before import absent
after createCliRenderer present, isRaw=true
after destroy() present, isRaw=false, still registered

Right after destroy(), active handles are {"TTYWrap":3,"Timeout":1} on 0.4.5, and {"WriteStream":2,"TTY":3,"ReadStream":1} on 0.5.6 under Node 26.7.0.

Deciding control

Releasing the handle before destroy() fixes it; releasing it after does not.

stdin handling On the loop at destroy() Console
setRawMode(false) + pause() before destroy() yes — TTYWrap:3 dies
full release after destroy() yes dies
full release before destroy() no — TTYWrap:2 survives

Where "full release" is: remove listeners → setRawMode(false)pause()unref()destroy().

The same control reproduces on 0.5.6 under Node, with the census reading {"WriteStream":2,"TTY":3,"ReadStream":1} in the runs that die and {"WriteStream":2,"TTY":2}ReadStream gone — in the run that survives.

The workaround does not hold under Bun, and that is the part I would most like your eyes on. On Bun 1.3.14 with 0.5.6, a run applying the full release before destroy() and then turning the loop for 3s still wedges the console, indistinguishably from the run that does nothing. Both runs took a genuine keypress (a 1-byte 0x71 chunk ~1.7s in, distinct from the ~50ms capability reply).

The release is not being ignored. Sampling process.stdin immediately before and after it:

before release after release
isRaw true false
paused false true
destroyed false true
readable true false
data listeners 1 0

The stream is fully destroyed at the JS level and the console still dies. So on Bun, destroying process.stdin does not appear to detach whatever is actually reading the console — process._getActiveHandles() is stubbed on Bun and returns {}, so I can't see the handle directly to say more than that. Since Bun is the primary runtime here, this is probably the more consequential half of this report: the Node-side fix below is straightforward, and the Bun side may not be.

With the handle off the loop, the surviving run turns the loop for a full 3 seconds after destroy() — the exact configuration that kills the console in every other run — exits cleanly, and holds console mode 503 and code page 850 through a 15-second watch.

By the time destroy() has returned, intervening is already too late: the console is unresponsive ~0.1s in, and a setRawMode(false) issued there blocks for 0.7s. A parent process's GetConsoleMode on the same window blocks and then fails with 233 ERROR_PIPE_NOT_CONNECTED.

Things this is not

Ruled out by direct measurement, in case they look like candidates:

  • Not the console output code page. A live sampler saw outputCP go 850 → 65001 → 850 across destroy() in runs where the console survived.
  • Not the shutdown VT blob. Every surviving keypress run executes the same performShutdownSequence().
  • Not raw mode. A script that loads no OpenTUI at all reproduces console input mode 520 and survives.
  • Not a timer leak. The one 5000ms one-shot OpenTUI creates is cleared by destroy(); a control clearing every remaining timer afterwards reported cleared 0 timer(s) and died anyway.
  • Not FreeConsole or a closed handle. The shipped @opentui/[email protected] opentui.dll imports no FreeConsole, AllocConsole, AttachConsole, GetStdHandle or SetStdHandle, never opens CONOUT$/CONIN$, and has no native stdin path.

Suggested fix

AGENTS.md already states the invariant this breaks — "make native ownership explicit; clean up handles, callbacks, buffers, and listeners on every exit path." destroy() cleans up the listener but not the handle, and process.stdin is a handle the renderer itself brought into existence.

So: destroy() should release it rather than only pausing it — unref() at minimum, and only when the renderer instantiated it (i.e. this.stdin === process.stdin and no caller-supplied config.stdin). Releasing a handle the caller passed in would be wrong.

That is sufficient on Node. It is evidently not sufficient on Bun — a fully destroyed stdin stream still leaves the console to die there — so the Bun path likely needs something below the stream abstraction. You will know Bun's stdin internals far better than I do.

Workaround for anyone hitting this

On Node, release stdin yourself, before calling destroy():

javascript
process.stdin.removeAllListeners("data");
if (process.stdin.setRawMode) process.stdin.setRawMode(false);
process.stdin.pause();
process.stdin.unref();
process.stdin.destroy();

renderer.destroy();

Ordering is load-bearing: the same five lines after destroy() do not help. I have no working workaround for Bun.

On process, since CONTRIBUTING asks for PRs on bug fixes

I have a fix I am confident in for the Node path and none for Bun, so this is a report rather than a half-finished PR. If the Node-side change is worth having on its own I am glad to open it — say the word and I will.

AGENTS.md also asks for a focused regression test first. I do not think this one is automatable: it needs a real conhost.exe window, and both a pty master and ConPTY misreport it — the earlier investigation got false readings in both directions from ConPTY before switching to file-logged evidence inside a real console, which is why every number above comes from a log file rather than from scraping a terminal. Per the same guide's allowance for platforms that cannot be automated, the evidence and the manual verification steps are recorded above instead.

Both runtimes measured are the supported ones: Bun 1.3.14, and Node 26 with --experimental-ffi matching scripts/node26.mjs.

Possibly the same root cause

Several downstream reports describe a terminal or parent shell dying on TUI exit on Windows. They pin @opentui/[email protected]:

Happy to run further controls on the same Windows setup if any of this would be more useful in a different form.