Skip to content

fix(ext/node): rewrite Windows TTY reading to match libuv (console mode, encoding, raw + line mode)#32999

Merged
bartlomieju merged 16 commits intodenoland:mainfrom
bartlomieju:fix/windows-tty-regression
Mar 26, 2026
Merged

fix(ext/node): rewrite Windows TTY reading to match libuv (console mode, encoding, raw + line mode)#32999
bartlomieju merged 16 commits intodenoland:mainfrom
bartlomieju:fix/windows-tty-regression

Conversation

@bartlomieju
Copy link
Copy Markdown
Member

@bartlomieju bartlomieju commented Mar 25, 2026

Summary

Rewrites the Windows TTY read/write paths to match libuv's behavior, fixing regressions introduced in the node:tty rewrite (#32777, landed in v2.7.6).

Console mode restoration

uv_tty_set_mode(NORMAL) was replacing the console mode with a hardcoded subset of flags (ECHO|LINE|PROCESSED = 0x0007), losing ENABLE_QUICK_EDIT_MODE, ENABLE_INSERT_MODE, and ENABLE_EXTENDED_FLAGS that Windows sets by default. This broke interactive input after the first raw-mode cycle (e.g. vite create's multi-step prompts). Now restores the original mode saved at init time, matching libuv's behavior.

TTY output encoding

tty_try_write used WriteFile which writes raw bytes interpreted according to the console's active code page. On non-UTF-8 code pages (common on Windows), this garbled Unicode output -- box-drawing characters, accented text, CJK, etc. Now converts UTF-8 to UTF-16 and uses WriteConsoleW, matching libuv's approach.

Raw mode reading rewrite

Replaced ReadFile-based console reading with ReadConsoleInputW for raw mode, matching libuv's uv_process_tty_read_raw_req approach. ReadFile on a console handle only consumes KEY_DOWN events that produce characters, blocking on KEY_UP/FOCUS/MOUSE events and freezing the event loop. The new implementation:

  • Reads individual INPUT_RECORD structs via ReadConsoleInputW
  • Filters non-key events, KEY_UP events (except Alt composition), and numpad keys during Alt-code entry
  • Encodes Unicode characters to UTF-8 with surrogate pair support
  • Maps function keys (arrows, F1-F12, Home/End/etc.) to VT100/xterm escape sequences (ported from libuv's get_vt100_fn_key)
  • Handles key repeat counts and Alt-prefix for escape sequences

Line mode reading rewrite

Replaced blocking ReadFile with ReadConsoleW on a worker thread, matching libuv's uv_tty_line_read_thread + QueueUserWorkItem approach. ReadFile/ReadConsoleW block until Enter is pressed, so they must run off the event loop thread. The worker thread reads UTF-16 via ReadConsoleW, converts to UTF-8, and wakes the event loop when the line is complete.

Async console input notification

Uses RegisterWaitForSingleObject (matching libuv's uv__tty_queue_read_raw) to register a one-shot thread pool wait on the console input handle. When input becomes available, the callback wakes the tokio event loop. Without this, tokio would park after the first keystroke and never re-poll the TTY.

Input gating

Only allocates buffers and calls read_cb when there is actually data to process, preventing spurious read_cb(nread=0) calls on every poll cycle.

Fixes #32996
Fixes #32997
Fixes #32639
Fixes #33002
Fixes #32992

Test plan

  • cargo check passes
  • tools/format.js and tools/lint.js pass
  • New PTY test tty_raw_mode_toggle_pty -- raw mode toggling across 3 consecutive prompts
  • New PTY test tty_unicode_output_pty -- box-drawing and Unicode characters render correctly
  • New PTY test tty_line_mode_read_pty -- threaded ReadConsoleW line-mode with consecutive prompts
  • New PTY test tty_raw_mode_arrow_keys_pty -- arrow keys produce correct VT100 escape sequences
  • New PTY test tty_ctrl_c_raw_mode_pty -- Ctrl+C (0x03) delivered as data in raw mode
  • Existing readline_multi_prompt_pty and readline_muted_multi_prompt_pty tests pass
  • Manual Windows testing: deno create npm:vite@latest multi-step prompts work correctly
  • Manual Windows testing: @inquirer/prompts work correctly

🤖 Generated with Claude Code

bartlomieju and others added 9 commits March 25, 2026 20:37
…ing, and read blocking

Three fixes for Windows TTY regressions introduced in the node:tty
rewrite (denoland#32777):

1. Restore saved console mode for NORMAL: uv_tty_set_mode(NORMAL) was
   setting a hardcoded subset of flags (ECHO|LINE|PROCESSED = 0x0007),
   losing ENABLE_QUICK_EDIT_MODE, ENABLE_INSERT_MODE, and
   ENABLE_EXTENDED_FLAGS that Windows sets by default. Now restores the
   original mode saved at init time, matching libuv behavior.

2. Use WriteConsoleW for TTY output: WriteFile writes raw bytes
   interpreted by the console's active code page, garbling non-ASCII
   output (box-drawing chars, Unicode) when the code page isn't UTF-8.
   Now converts UTF-8 to UTF-16 and calls WriteConsoleW, matching libuv.

3. Re-check events before each read: the Windows read loop evaluated
   GetNumberOfConsoleInputEvents once and looped with a stale boolean,
   so ReadFile could block the event loop after consuming all available
   events. Now re-checks before each ReadFile call.

Fixes denoland#32996
Fixes denoland#32997

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The loop that re-checked GetNumberOfConsoleInputEvents before each
ReadFile call caused the event loop to block. The Windows console
input buffer contains ALL event types (KEY_UP, FOCUS, MOUSE, etc.)
but ReadFile only consumes KEY_DOWN events that produce characters.
After reading a character, non-character events remain in the buffer,
num_events > 0 still holds, and the next ReadFile blocks waiting for
input that will never come -- freezing the prompt after the first
keystroke and preventing Ctrl+C from working.

Revert to a single-shot read per poll cycle: check once, read once,
return to the event loop. The event loop re-enters poll_tty_handle
on the next tick when more input arrives.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…n-character events

ReadFile on a console handle only consumes KEY_DOWN events that
produce characters. Other events in the input buffer (KEY_UP, FOCUS,
MOUSE, WINDOW_BUFFER_SIZE) are ignored by ReadFile but not removed,
so it blocks waiting for the next character-producing event. This
caused the prompt to freeze after the first keystroke and prevented
Ctrl+C from working.

Now uses PeekConsoleInputW to inspect the front of the input queue
and drains non-character events (anything that is not a KEY_DOWN with
a non-zero UnicodeChar) via ReadConsoleInputW before calling ReadFile.
This ensures ReadFile is only called when there is actually a
character to read, preventing event loop blocking.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…putW

Replace ReadFile-based console reading with ReadConsoleInputW for raw
mode, matching libuv's uv_process_tty_read_raw_req approach. ReadFile
on a console handle only consumes KEY_DOWN events that produce
characters, blocking on KEY_UP/FOCUS/MOUSE events and freezing the
event loop. ReadConsoleInputW reads individual INPUT_RECORD structs,
allowing us to filter and process events manually.

Changes:
- Add Win32 constants: virtual key codes, control key state flags
- Add state fields to uv_tty_t for key buffering and UTF-16 surrogates
- Port libuv's VT100 function key mapping (get_vt100_fn_key)
- New tty_try_read_raw: processes INPUT_RECORD structs, filters
  KEY_UP/non-key events, handles Alt-code composition, encodes UTF-16
  to UTF-8, maps function keys to VT100 escape sequences, handles
  repeat counts and surrogate pairs
- Rename old tty_try_read to tty_try_read_line for line mode
- Mode-aware poll_tty_handle: raw mode uses tty_try_read_raw,
  line mode uses tty_try_read_line gated by event count

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Only allocate a buffer and call read_cb when there is actually
something to read. Without this gate, poll_tty_handle would call
alloc_cb + read_cb(nread=0) on every event loop tick even when no
input was available, causing the JS readline layer to malfunction
after processing the first character.

On Unix, poll_read_ready(cx) serves this gating role via
tokio's AsyncFd integration. On Windows there is no equivalent
async notification mechanism, so we check explicitly:
- Raw mode: pending decoded bytes, repeat count, or console events
- Line mode: console events via GetNumberOfConsoleInputEvents

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The Windows event loop had no mechanism to wake up when new console
input arrived. After delivering the first character, tokio would park
and never re-poll the TTY handle, freezing the prompt.

Now uses RegisterWaitForSingleObject (matching libuv's
uv__tty_queue_read_raw) to register a one-shot thread pool wait on
the console input handle. When the handle is signaled (input
available), the callback wakes the tokio event loop via the stored
waker. The wait is re-registered after each signal (one-shot) and
unregistered when reading stops.

This eliminates the need for busy-polling while ensuring the event
loop promptly processes new console input.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@bartlomieju
Copy link
Copy Markdown
Member Author

Verified the fix on Windows machine - unicode renders properly, prompt works fine, arrow key selector works correctly too 👍

@bartlomieju bartlomieju changed the title fix(ext/node): fix Windows TTY console mode, output encoding, and read blocking fix(ext/node): fix Windows TTY console mode, output encoding, and raw mode reading Mar 26, 2026
Copy link
Copy Markdown
Member

@littledivy littledivy left a comment

Choose a reason for hiding this comment

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

Nice!

bartlomieju and others added 5 commits March 26, 2026 14:24
When a write is queued to a TTY handle on Windows, the deferred write
callback was not processed until something else woke the event loop
(e.g. console input). This caused inquirer-style prompts to not
render until the user pressed a key.

Add UvLoopInner::wake() and call it from ensure_tty_registered on
Windows so the event loop re-polls promptly after a write is queued,
flushing the write callback and allowing the JS layer to render the
prompt UI.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The wake() from ensure_tty_registered only fires for queued writes.
If read_start_tty is called after run_io() has already finished
iterating tty_handles (e.g. from a microtask), the stdin handle
won't be processed until the next event loop tick. But nothing wakes
the loop to trigger that tick.

Wake from read_start_tty so poll_tty_handle runs promptly, which
both registers RegisterWaitForSingleObject for input notifications
and drains any pending stdout write callbacks.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
ReadConsoleW blocks until the user presses Enter, so calling it from
the event loop thread freezes everything. This matches libuv's
uv_tty_line_read_thread which uses QueueUserWorkItem to run
ReadConsoleW on a dedicated worker thread.

The implementation:
- Spawns a "tty-line-read" thread that calls ReadConsoleW (UTF-16)
- Converts the result to UTF-8 via String::from_utf16_lossy
- Stores the result in a shared Arc<Mutex<>> and wakes the event loop
- poll_tty_handle picks up the result on the next tick and delivers
  it to the read callback
- Only one line-read thread is in flight at a time

This fixes vite create and other programs that use line-mode console
input on Windows (the default mode before setRawMode is called).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…move dead code

Two fixes:

1. Drain pending write callbacks before spawning the ReadConsoleW
   worker thread. The JS stream layer may have prompt text buffered
   that only reaches the native WriteConsoleW call after a write
   completion callback fires. Without this drain, the line-read
   thread starts (and blocks on ReadConsoleW) before the prompt
   renders, requiring the user to press a key before seeing output.

2. Remove unused tty_try_read_line (replaced by the threaded
   ReadConsoleW approach) and fix unnecessary unsafe block warning.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Add tty_line_mode_read_pty: tests threaded ReadConsoleW line-mode
  reading with consecutive prompts
- Add tty_raw_mode_arrow_keys_pty: tests VT100 escape sequence mapping
  for arrow keys via ReadConsoleInputW
- Add tty_ctrl_c_raw_mode_pty: tests that Ctrl+C (0x03) is delivered
  as data in raw mode (not as SIGINT, since raw mode disables terminal
  signal processing)
- Remove the write-queue drain before spawning the line-read thread
  (confirmed unnecessary -- inquirer works without it as of 2eee47e)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@bartlomieju bartlomieju changed the title fix(ext/node): fix Windows TTY console mode, output encoding, and raw mode reading fix(ext/node): rewrite Windows TTY reading to match libuv (console mode, encoding, raw + line mode) Mar 26, 2026
…xity

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@kajukitli
Copy link
Copy Markdown
Contributor

i found one blocking issue here.

uv_close() goes through UvLoopInner::stop_tty(), and that path still closes the Windows console handle directly without tearing down the new async read machinery first. In this PR that means:

• a RegisterWaitForSingleObject callback can still be armed, but stop_tty() never calls tty_unregister_wait() before CloseHandle / _close (libs/core/uv_compat.rs:531-585, new wait registration in libs/core/uv_compat/tty.rs:2033-2082)
• a line-mode ReadConsoleW worker can still be blocked on the same handle when stop_tty() closes it (libs/core/uv_compat/tty.rs:2330-2361)

that opens up two bad races:

• the wait callback keeps a raw pointer to internal_wait_waker; if the handle is closed and the uv_tty_t is later freed before UnregisterWaitEx, the callback can wake through freed memory
• the detached line-read thread is still using the HANDLE while close is happening

so i don’t think this is safe to merge as-is. stop_tty() needs to tear down the Windows wait registration too, and the close path probably needs to account for an in-flight line-mode read before closing the HANDLE.

otherwise the direction looks right, but this one needs fixing first.

…g handle

stop_tty() was closing the console handle without first unregistering
the RegisterWaitForSingleObject callback or detaching the line-mode
reader thread. This opened two races:

1. The wait callback holds a raw pointer to internal_wait_waker; if
   the handle is closed and the uv_tty_t freed before
   UnregisterWaitEx, the callback can wake through freed memory.

2. The detached line-read thread could still be using the HANDLE
   while close was happening.

Add close_tty_read() that tears down the wait registration (blocking
until any in-flight callback completes via INVALID_HANDLE_VALUE),
drops the waker, and detaches from any in-flight line-mode read.
Call it from stop_tty() before CloseHandle/_close.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown
Member

@littledivy littledivy left a comment

Choose a reason for hiding this comment

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

LGTM

@kajukitli
Copy link
Copy Markdown
Contributor

re-reviewed after acdc89d51 and my previous blocker looks fixed.

stop_tty() now tears down the windows async read machinery before closing the console handle:

• calls close_tty_read(handle) first
close_tty_read() unregisters the RegisterWaitForSingleObject wait with UnregisterWaitEx(..., INVALID_HANDLE_VALUE) so any in-flight callback finishes before teardown
• then drops internal_wait_waker and detaches from pending line-read state

that addresses the use-after-free / close-race i flagged earlier. i don't see any new follow-up issues from this pass.

@bartlomieju bartlomieju merged commit eb43657 into denoland:main Mar 26, 2026
113 checks passed
@bartlomieju bartlomieju deleted the fix/windows-tty-regression branch March 26, 2026 15:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

3 participants