Skip to content

fix(core): deactivate idle TTY write handles - #35886

Merged
nathanwhit merged 4 commits into
denoland:mainfrom
nathanwhit:codex/node-stdio-tty-unref
Jul 9, 2026
Merged

nathanwhit merged 4 commits into
denoland:mainfrom
nathanwhit:codex/node-stdio-tty-unref

Conversation

@nathanwhit

@nathanwhit nathanwhit commented Jul 8, 2026 •

Copy link
Copy Markdown
Member

Summary

Allow Node-compatible TTY stdio writes to drain and then stop keeping Deno alive, without changing the observable ref state of the underlying stdio handle where that handle is exposed.

Adds a PTY integration regression for a large process.stderr.write() that verifies the write callback runs and the process exits. On platforms that expose process.stderr._handle.hasRef(), it also verifies the handle remains ref'ed before and inside the callback.

Root Cause

uv_tty_init() creates TTY handles with UV_HANDLE_REF, matching Node's observable process.stderr._handle.hasRef() === true behavior for stdio TTYs where _handle is exposed.

Deno's uv_compat write path also marks TTY handles UV_HANDLE_ACTIVE via ensure_tty_registered(). Plain TTY writes drained their callbacks, but the normal write-drain path did not clear UV_HANDLE_ACTIVE; only the shutdown path deactivated the handle. Since loop liveness checks count handles that are both active and ref'ed, an idle stdio TTY write handle could keep Deno alive forever after all JS and children had finished.

The fix deactivates TTY handles once there is no pending read, write, or shutdown work. It leaves UV_HANDLE_REF untouched, so _handle.hasRef() remains Node-compatible while idle inactive handles no longer keep the loop alive.

Redirected output avoids the TTY stream path, which is why the same script exited with stdout/stderr redirected.

Validation

  • ./tools/format.js ext/node/polyfills/process.ts libs/core/uv_compat/tty.rs tests/integration/run_tests.rs tests/testdata/run/process_stderr_tty_write_exit.js
  • cargo test --test integration process_stderr_tty_write_exit
  • cargo test -p deno_core uv_compat
  • ./tools/lint.js
  • Manual PTY reduction: large process.stderr.write() exits and reports _handle.hasRef() as true before and inside the callback on macOS
  • Manual PTY validation in ../v8x: target/debug/deno run -A tests/harness/run.mjs rusty_v8 quickjs --rescue exits cleanly with pass=267 fail=0 skip=0
  • CI: test integration (1/2) debug windows-x86_64 passed on head 3a9f366bf34b16ad3b0c12117233460c5320e2a0

@nathanwhit
nathanwhit marked this pull request as ready for review July 8, 2026 20:18
@nathanwhit nathanwhit changed the title fix(node): unref stdio TTY streams fix(node): let idle stdio TTY streams exit Jul 8, 2026
@nathanwhit nathanwhit changed the title fix(node): let idle stdio TTY streams exit fix(core): deactivate idle TTY write handles Jul 8, 2026
@nathanwhit
nathanwhit merged commit bc58c2a into denoland:main Jul 9, 2026
138 checks passed
bartlomieju pushed a commit that referenced this pull request Jul 15, 2026
## Summary

Allow Node-compatible TTY stdio writes to drain and then stop keeping
Deno alive, without changing the observable ref state of the underlying
stdio handle where that handle is exposed.

Adds a PTY integration regression for a large `process.stderr.write()`
that verifies the write callback runs and the process exits. On
platforms that expose `process.stderr._handle.hasRef()`, it also
verifies the handle remains ref'ed before and inside the callback.

## Root Cause

`uv_tty_init()` creates TTY handles with `UV_HANDLE_REF`, matching
Node's observable `process.stderr._handle.hasRef() === true` behavior
for stdio TTYs where `_handle` is exposed.

Deno's `uv_compat` write path also marks TTY handles `UV_HANDLE_ACTIVE`
via `ensure_tty_registered()`. Plain TTY writes drained their callbacks,
but the normal write-drain path did not clear `UV_HANDLE_ACTIVE`; only
the shutdown path deactivated the handle. Since loop liveness checks
count handles that are both active and ref'ed, an idle stdio TTY write
handle could keep Deno alive forever after all JS and children had
finished.

The fix deactivates TTY handles once there is no pending read, write, or
shutdown work. It leaves `UV_HANDLE_REF` untouched, so
`_handle.hasRef()` remains Node-compatible while idle inactive handles
no longer keep the loop alive.

Redirected output avoids the TTY stream path, which is why the same
script exited with stdout/stderr redirected.

## Validation

- `./tools/format.js ext/node/polyfills/process.ts
libs/core/uv_compat/tty.rs tests/integration/run_tests.rs
tests/testdata/run/process_stderr_tty_write_exit.js`
- `cargo test --test integration process_stderr_tty_write_exit`
- `cargo test -p deno_core uv_compat`
- `./tools/lint.js`
- Manual PTY reduction: large `process.stderr.write()` exits and reports
`_handle.hasRef()` as `true` before and inside the callback on macOS
- Manual PTY validation in `../v8x`: `target/debug/deno run -A
tests/harness/run.mjs rusty_v8 quickjs --rescue` exits cleanly with
`pass=267 fail=0 skip=0`
- CI: `test integration (1/2) debug windows-x86_64` passed on head
`3a9f366bf34b16ad3b0c12117233460c5320e2a0`
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