From ba6c9184232e52533efdde64ae24f413b8184cee Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 20 Jul 2026 08:06:26 +0000 Subject: [PATCH 1/6] tty(windows): make non-stdin ReadStream#setRawMode use VT raw mode On Windows, process.stdin.setRawMode already requests UV_TTY_MODE_RAW_VT (ENABLE_VIRTUAL_TERMINAL_INPUT) so the terminal supplies VT input such as bracketed paste. A tty.ReadStream on any other console fd, e.g. new tty.ReadStream(fs.openSync("CONIN$", "r")), took the $bunNativePtr branch, which fs.ReadStream never populates, so it always emitted "setRawMode failed because it was called on something that is not a TTY" and never reached Source::set_raw_mode (which itself would have used UV_TTY_MODE_RAW, not RAW_VT). Unify on the VT raw mode: * Source::set_raw_mode now requests TtyMode::Vt. * Source__setRawModeStdin is generalised to Source__setRawModeTty(fd). fd 0 keeps the stdin uv_tty_t singleton so the mode change is coordinated with any in-flight libuv console read. For other console fds the caller's fd is checked with GetConsoleMode and the same RAW_VT / NORMAL console-mode masks libuv uses are written on a fresh CONIN$ handle (SetConsoleMode needs GENERIC_READ|GENERIC_WRITE, which an O_RDONLY handle lacks, and a transient uv_tty_t would _close() the caller's fd on uv_close). * jsTTYSetMode on Windows now takes (fd, flag) and tty.ts calls it for every fd, dropping the dead $bunNativePtr branch. Console input mode is per input buffer, so after setRawMode(true) on either path, GetConsoleMode on any console input handle reports the same ENABLE_WINDOW_INPUT | ENABLE_VIRTUAL_TERMINAL_INPUT mask. --- src/io/source.rs | 122 +++++++++++++++++---- src/js/node/tty.ts | 34 ++---- src/jsc/bindings/ProcessBindingTTYWrap.cpp | 10 +- test/js/node/tty.test.ts | 120 +++++++++++++++++++- 4 files changed, 231 insertions(+), 55 deletions(-) diff --git a/src/io/source.rs b/src/io/source.rs index a8e91b06884b..fa2f8d46a621 100644 --- a/src/io/source.rs +++ b/src/io/source.rs @@ -427,9 +427,12 @@ impl Source { pub fn set_raw_mode(&mut self, value: bool) -> bun_sys::Result<()> { match self { Source::Tty(tty) => { + // Match `Source__setRawModeTty`: UV_TTY_MODE_RAW_VT so the + // terminal supplies VT input (bracketed paste etc.) regardless + // of whether raw mode was entered via fd 0 or a CONIN$ handle. if let Some(err) = Self::tty_mut(tty) .set_mode(if value { - uv::TtyMode::Raw + uv::TtyMode::Vt } else { uv::TtyMode::Normal }) @@ -472,7 +475,7 @@ pub mod stdin_tty { pub(super) fn get_stdin_tty(loop_: *mut uv::Loop) -> bun_sys::Result> { // bun_threading::Mutex::lock() returns `()` — must use lock_guard() for RAII // unlock-on-drop, otherwise the mutex is held forever and the next call - // (e.g. Source__setRawModeStdin → open_tty(stdin)) deadlocks/UB-relocks. + // (e.g. Source__setRawModeTty → open_tty(stdin)) deadlocks/UB-relocks. let _guard = LOCK.lock_guard(); if !INITIALIZED.swap(true, Ordering::Relaxed) { @@ -491,33 +494,108 @@ pub mod stdin_tty { } } -/// The uv loop is taken as a parameter (reading it from the VM directly would -/// be a T6 dependency); the C++ caller +/// `node:tty` `setRawMode` for Windows. The uv loop is taken as a parameter +/// (reading it from the VM directly would be a T6 dependency); the C++ caller /// (`ProcessBindingTTYWrap.cpp`) supplies `defaultGlobalObject()->uvLoop()`. +/// +/// fd 0 resolves to the process-static `stdin_tty` singleton, so the mode +/// change goes through `uv_tty_set_mode` and is coordinated with any +/// in-flight libuv console read on that handle. +/// +/// Other console fds have no libuv reader (fs.ReadStream reads via +/// `uv_fs_read`), and a transient `uv_tty_t` on them is unsafe: +/// `uv__tty_close` `_close()`s the caller's fd, and `SetConsoleMode` needs +/// `GENERIC_READ | GENERIC_WRITE` which an `O_RDONLY` CONIN$ handle lacks +/// (and `DuplicateHandle` cannot add access the source does not have). So the +/// fd is checked to be a console input handle and the same console-mode masks +/// `uv_tty_set_mode` would apply for `UV_TTY_MODE_RAW_VT` / +/// `UV_TTY_MODE_NORMAL` are written on a fresh CONIN$ handle. Console input +/// mode is a property of the input buffer, not the handle, so the mode +/// observed on fd 0 and on any CONIN$ handle is the same afterwards. #[unsafe(no_mangle)] -pub(crate) extern "C" fn Source__setRawModeStdin(uv_loop: *mut uv::Loop, raw: bool) -> c_int { - let mut tty = match Source::open_tty(uv_loop, Fd::stdin()) { - bun_sys::Result::Ok(tty) => tty, - bun_sys::Result::Err(e) => return e.errno as c_int, - }; +pub(crate) extern "C" fn Source__setRawModeTty( + uv_loop: *mut uv::Loop, + fd: c_int, + raw: bool, +) -> c_int { // UV_TTY_MODE_RAW_VT is a variant of UV_TTY_MODE_RAW that enables control // sequence processing on the TTY implementer side, rather than having libuv // translate keypress events into control sequences, aligning behavior more // closely with POSIX platforms. This is also required to support some // control sequences at all on Windows, such as bracketed paste mode. The // Node.js readline implementation handles differences between these modes. - // `tty` is the static stdin tty (fd 0 → `get_stdin_tty`), live for the - // process — same invariant the `Source::Tty` arm relies on, so reuse the - // shared `tty_mut` accessor. - if let Some(err) = Source::tty_mut(&mut tty) - .set_mode(if raw { - uv::TtyMode::Vt - } else { - uv::TtyMode::Normal - }) - .to_error(bun_sys::Tag::uv_tty_set_mode) + let mode = if raw { + uv::TtyMode::Vt + } else { + uv::TtyMode::Normal + }; + + if fd == 0 { + let mut tty = match Source::open_tty(uv_loop, Fd::stdin()) { + bun_sys::Result::Ok(tty) => tty, + bun_sys::Result::Err(e) => return e.errno as c_int, + }; + return match Source::tty_mut(&mut tty) + .set_mode(mode) + .to_error(bun_sys::Tag::uv_tty_set_mode) + { + Some(err) => err.errno as c_int, + None => 0, + }; + } + + use bun_sys::windows as w; + const ENABLE_WINDOW_INPUT: u32 = 0x0008; + const CONIN_W: [u16; 7] = [b'C' as _, b'O' as _, b'N' as _, b'I' as _, b'N' as _, b'$' as _, 0]; + + let src = Fd::from_uv(fd).native(); + if src == w::INVALID_HANDLE_VALUE { + return bun_sys::E::BADF as c_int; + } + // Reject non-console-input fds with ENOTTY before touching the process + // console. `GetConsoleMode` only needs `GENERIC_READ`, which an O_RDONLY + // CONIN$ handle has. + let mut unused: u32 = 0; + // SAFETY: `src` is a live handle (`uv_get_osfhandle(fd)` for an open fd). + if unsafe { w::GetConsoleMode(src, &mut unused) } == 0 { + return bun_sys::E::NOTTY as c_int; + } + // `SetConsoleMode` on an input handle requires `GENERIC_READ|GENERIC_WRITE` + // and `DuplicateHandle` cannot add access the source lacks, so open a + // fresh CONIN$ handle. Console input mode is a property of the buffer, so + // the caller's read-only handle observes the new mode for its reads. + // SAFETY: `CONIN_W` is a NUL-terminated static wide string. + let conin = unsafe { + w::CreateFileW( + CONIN_W.as_ptr(), + w::GENERIC_READ | w::GENERIC_WRITE, + w::FILE_SHARE_READ | w::FILE_SHARE_WRITE, + core::ptr::null_mut(), + w::OPEN_EXISTING, + 0, + core::ptr::null_mut(), + ) + }; + if conin == w::INVALID_HANDLE_VALUE { + return w::get_last_errno() as c_int; + } + // Same masks and fallback as libuv `uv_tty_set_mode` (src/win/tty.c). + let (flags, try_flags) = match mode { + uv::TtyMode::Vt => (ENABLE_WINDOW_INPUT, w::ENABLE_VIRTUAL_TERMINAL_INPUT), + _ => ( + w::ENABLE_ECHO_INPUT | w::ENABLE_LINE_INPUT | w::ENABLE_PROCESSED_INPUT, + 0, + ), + }; + // SAFETY: `conin` is a valid console handle we own for this block. + let rc = if unsafe { w::SetConsoleMode(conin, flags | try_flags) } != 0 + || (try_flags != 0 && unsafe { w::SetConsoleMode(conin, flags) } != 0) { - return err.errno as c_int; - } - 0 + 0 + } else { + w::get_last_errno() as c_int + }; + // SAFETY: `conin` was returned by CreateFileW; closed exactly once here. + unsafe { w::CloseHandle(conin) }; + rc } diff --git a/src/js/node/tty.ts b/src/js/node/tty.ts index cad1915116f7..f79a058540fc 100644 --- a/src/js/node/tty.ts +++ b/src/js/node/tty.ts @@ -56,37 +56,17 @@ Object.defineProperty(ReadStream, "prototype", { Prototype.setRawMode = function (flag) { flag = !!flag; - // On windows, this goes through the stream handle itself, as it must call - // uv_tty_set_mode on the uv_tty_t. + // On Windows this calls uv_tty_set_mode (UV_TTY_MODE_RAW_VT) via + // Source__setRawModeTty; fd 0 resolves to the shared stdin uv_tty_t so + // the mode change is coordinated with any in-flight console read. // // On POSIX, I tried to use the same approach, but it didn't work reliably, // so we just use the file descriptor and use termios APIs directly. if (process.platform === "win32") { - // Special case for stdin, as it has a shared uv_tty handle - // and it's stream is constructed differently - if (this.fd === 0) { - const err = ttySetMode(flag); - if (err) { - this.emit("error", new Error("setRawMode failed with errno: " + err)); - return this; - } - } else { - const handle = this.$bunNativePtr; - if (!handle) { - this.emit("error", new Error("setRawMode failed because it was called on something that is not a TTY")); - return this; - } - - // If you call setRawMode before you call on('data'), the stream will - // not be constructed, leading to EBADF - // This corresponds to the `ensureConstructed` function in `native-readable.ts` - this.$start(); - - const err = handle.setRawMode(flag); - if (err) { - this.emit("error", err); - return this; - } + const err = ttySetMode(this.fd, flag); + if (err) { + this.emit("error", new Error("setRawMode failed with errno: " + err)); + return this; } } else { const state = (this[kRawModeState] ??= new Uint8Array(rawModeStateSize)); diff --git a/src/jsc/bindings/ProcessBindingTTYWrap.cpp b/src/jsc/bindings/ProcessBindingTTYWrap.cpp index 73b896d732db..c1aada235f2d 100644 --- a/src/jsc/bindings/ProcessBindingTTYWrap.cpp +++ b/src/jsc/bindings/ProcessBindingTTYWrap.cpp @@ -31,7 +31,7 @@ #if OS(WINDOWS) -extern "C" int Source__setRawModeStdin(uv_loop_t* uv_loop, bool raw); +extern "C" int Source__setRawModeTty(uv_loop_t* uv_loop, int fd, bool raw); namespace UV { @@ -195,13 +195,13 @@ JSC::EncodedJSValue Process_functionInternalGetWindowSize(JSC::JSGlobalObject* g JSC_DEFINE_HOST_FUNCTION(jsTTYSetMode, (JSC::JSGlobalObject * globalObject, CallFrame* callFrame)) { #if OS(WINDOWS) - ASSERT(callFrame->argumentCount() == 1); - auto flag = callFrame->argument(0); - bool raw = flag.asBoolean(); + ASSERT(callFrame->argumentCount() == 2); + int fd = callFrame->argument(0).asInt32(); + bool raw = callFrame->argument(1).asBoolean(); Zig::GlobalObject* global = uncheckedDowncast(globalObject); - return JSValue::encode(jsNumber(Source__setRawModeStdin(global->uvLoop(), raw))); + return JSValue::encode(jsNumber(Source__setRawModeTty(global->uvLoop(), fd, raw))); #else auto& vm = JSC::getVM(globalObject); auto scope = DECLARE_THROW_SCOPE(vm); diff --git a/test/js/node/tty.test.ts b/test/js/node/tty.test.ts index 3eca2321a0fa..0693f48758c1 100644 --- a/test/js/node/tty.test.ts +++ b/test/js/node/tty.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, test } from "bun:test"; -import { bunEnv, bunExe, isWindows } from "harness"; +import { bunEnv, bunExe, isArm64, isWindows } from "harness"; import { WriteStream } from "node:tty"; describe("ReadStream.prototype.setRawMode", () => { @@ -190,6 +190,124 @@ describe("ReadStream.prototype.setRawMode", () => { }); expect(await proc.exited).toBe(0); }); + + // On Windows, setRawMode on process.stdin requests UV_TTY_MODE_RAW_VT so + // the terminal supplies VT input sequences, but a tty.ReadStream on any + // other console fd (e.g. CONIN$) either failed outright ("not a TTY") or, + // via Source::set_raw_mode, would have used plain UV_TTY_MODE_RAW, which + // leaves ENABLE_VIRTUAL_TERMINAL_INPUT off and routes input through libuv's + // own INPUT_RECORD translator. The console input mode must be the same + // whichever fd the program raw-moded. bun:ffi has no Windows/arm64 backend. + test.skipIf(!isWindows || isArm64)( + "uses the same VT raw console mode for CONIN$ as for stdin on Windows", + async () => { + const ENABLE_VIRTUAL_TERMINAL_INPUT = 0x0200; + + let output = ""; + const decoder = new TextDecoder(); + const done = Promise.withResolvers(); + const eof = Promise.withResolvers(); + + const proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + ` + const fs = require("node:fs"); + const tty = require("node:tty"); + const { dlopen, ptr } = require("bun:ffi"); + + const k32 = dlopen("kernel32.dll", { + GetConsoleMode: { args: ["ptr", "ptr"], returns: "i32" }, + GetStdHandle: { args: ["u32"], returns: "ptr" }, + GetLastError: { args: [], returns: "u32" }, + }); + + // Console input mode is per input buffer (not per handle), so the + // stdin handle observes the same mode whichever console handle + // uv_tty_set_mode was called on. + const STD_INPUT_HANDLE = 0xfffffff6; // (DWORD)-10 + const probe = k32.symbols.GetStdHandle(STD_INPUT_HANDLE); + if (!probe) throw new Error("GetStdHandle failed"); + const out = new Uint32Array(1); + const mode = () => { + if (!k32.symbols.GetConsoleMode(probe, ptr(out))) + throw new Error("GetConsoleMode failed: " + k32.symbols.GetLastError()); + return out[0]; + }; + + let err; + process.stdin.on("error", e => (err = String(e))); + process.stdin.setRawMode(true); + const stdinRawMode = mode(); + process.stdin.setRawMode(false); + const stdinNormalMode = mode(); + + const fd = fs.openSync("CONIN$", "r"); + const stream = new tty.ReadStream(fd); + stream.on("error", e => (err = String(e))); + stream.setRawMode(true); + const coninRawMode = mode(); + const coninIsRaw = stream.isRaw; + stream.setRawMode(false); + const coninNormalMode = mode(); + + process.stdout.write( + "RESULT " + + JSON.stringify({ + fd, + isTTY: stream.isTTY, + stdinRawMode, + coninRawMode, + stdinNormalMode, + coninNormalMode, + coninIsRaw, + ...(err ? { err } : {}), + }), + ); + process.exit(0); + `, + ], + env: bunEnv, + terminal: { + cols: 200, + rows: 24, + data(_t, chunk: Uint8Array) { + output += decoder.decode(chunk, { stream: true }); + if (output.includes("RESULT ") && output.includes("}")) done.resolve(); + }, + exit() { + eof.resolve(); + }, + }, + }); + + await Promise.race([done.promise, eof.promise]); + proc.kill(); + await proc.exited; + proc.terminal?.close(); + output += decoder.decode(); + + const stripped = Bun.stripANSI(output).replace(/[\r\n]/g, ""); + const match = stripped.match(/RESULT (\{[^}]*\})/); + if (!match) { + throw new Error("child did not emit RESULT; terminal output was: " + JSON.stringify(output)); + } + const result = JSON.parse(match[1]); + expect(result).toEqual({ + fd: expect.any(Number), + isTTY: true, + stdinRawMode: result.stdinRawMode, + coninRawMode: result.stdinRawMode, + stdinNormalMode: result.stdinNormalMode, + coninNormalMode: result.stdinNormalMode, + coninIsRaw: true, + }); + expect(result.fd).not.toBe(0); + expect(result.stdinRawMode & ENABLE_VIRTUAL_TERMINAL_INPUT).toBe(ENABLE_VIRTUAL_TERMINAL_INPUT); + expect(result.coninRawMode & ENABLE_VIRTUAL_TERMINAL_INPUT).toBe(ENABLE_VIRTUAL_TERMINAL_INPUT); + }, + ); }); describe("WriteStream.prototype.getColorDepth", () => { From 519f75787711330be78bbc50358dcc3a41169feb Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Mon, 20 Jul 2026 08:08:50 +0000 Subject: [PATCH 2/6] [autofix.ci] apply automated fixes --- src/io/source.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/io/source.rs b/src/io/source.rs index fa2f8d46a621..77230c7a0478 100644 --- a/src/io/source.rs +++ b/src/io/source.rs @@ -546,7 +546,9 @@ pub(crate) extern "C" fn Source__setRawModeTty( use bun_sys::windows as w; const ENABLE_WINDOW_INPUT: u32 = 0x0008; - const CONIN_W: [u16; 7] = [b'C' as _, b'O' as _, b'N' as _, b'I' as _, b'N' as _, b'$' as _, 0]; + const CONIN_W: [u16; 7] = [ + b'C' as _, b'O' as _, b'N' as _, b'I' as _, b'N' as _, b'$' as _, 0, + ]; let src = Fd::from_uv(fd).native(); if src == w::INVALID_HANDLE_VALUE { From e0f7920c128e7e51db98e6664dcae39719c84a4a Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 20 Jul 2026 08:17:48 +0000 Subject: [PATCH 3/6] review: trim comments to three lines --- src/io/source.rs | 14 ++++---------- src/js/node/tty.ts | 8 ++------ test/js/node/tty.test.ts | 10 +++------- 3 files changed, 9 insertions(+), 23 deletions(-) diff --git a/src/io/source.rs b/src/io/source.rs index 77230c7a0478..e92386850e5d 100644 --- a/src/io/source.rs +++ b/src/io/source.rs @@ -518,12 +518,8 @@ pub(crate) extern "C" fn Source__setRawModeTty( fd: c_int, raw: bool, ) -> c_int { - // UV_TTY_MODE_RAW_VT is a variant of UV_TTY_MODE_RAW that enables control - // sequence processing on the TTY implementer side, rather than having libuv - // translate keypress events into control sequences, aligning behavior more - // closely with POSIX platforms. This is also required to support some - // control sequences at all on Windows, such as bracketed paste mode. The - // Node.js readline implementation handles differences between these modes. + // UV_TTY_MODE_RAW_VT: the terminal emits VT input sequences (bracketed + // paste etc.) instead of libuv translating INPUT_RECORDs. Matches POSIX. let mode = if raw { uv::TtyMode::Vt } else { @@ -562,10 +558,8 @@ pub(crate) extern "C" fn Source__setRawModeTty( if unsafe { w::GetConsoleMode(src, &mut unused) } == 0 { return bun_sys::E::NOTTY as c_int; } - // `SetConsoleMode` on an input handle requires `GENERIC_READ|GENERIC_WRITE` - // and `DuplicateHandle` cannot add access the source lacks, so open a - // fresh CONIN$ handle. Console input mode is a property of the buffer, so - // the caller's read-only handle observes the new mode for its reads. + // `SetConsoleMode` needs `GENERIC_READ|GENERIC_WRITE`; `DuplicateHandle` + // can't add access, so open CONIN$ fresh. Mode is per buffer, not handle. // SAFETY: `CONIN_W` is a NUL-terminated static wide string. let conin = unsafe { w::CreateFileW( diff --git a/src/js/node/tty.ts b/src/js/node/tty.ts index f79a058540fc..c560bad5df76 100644 --- a/src/js/node/tty.ts +++ b/src/js/node/tty.ts @@ -56,12 +56,8 @@ Object.defineProperty(ReadStream, "prototype", { Prototype.setRawMode = function (flag) { flag = !!flag; - // On Windows this calls uv_tty_set_mode (UV_TTY_MODE_RAW_VT) via - // Source__setRawModeTty; fd 0 resolves to the shared stdin uv_tty_t so - // the mode change is coordinated with any in-flight console read. - // - // On POSIX, I tried to use the same approach, but it didn't work reliably, - // so we just use the file descriptor and use termios APIs directly. + // Windows: Source__setRawModeTty applies UV_TTY_MODE_RAW_VT for every + // console fd. POSIX: termios on the fd with per-stream saved state. if (process.platform === "win32") { const err = ttySetMode(this.fd, flag); if (err) { diff --git a/test/js/node/tty.test.ts b/test/js/node/tty.test.ts index 0693f48758c1..880f37e8a71d 100644 --- a/test/js/node/tty.test.ts +++ b/test/js/node/tty.test.ts @@ -191,13 +191,9 @@ describe("ReadStream.prototype.setRawMode", () => { expect(await proc.exited).toBe(0); }); - // On Windows, setRawMode on process.stdin requests UV_TTY_MODE_RAW_VT so - // the terminal supplies VT input sequences, but a tty.ReadStream on any - // other console fd (e.g. CONIN$) either failed outright ("not a TTY") or, - // via Source::set_raw_mode, would have used plain UV_TTY_MODE_RAW, which - // leaves ENABLE_VIRTUAL_TERMINAL_INPUT off and routes input through libuv's - // own INPUT_RECORD translator. The console input mode must be the same - // whichever fd the program raw-moded. bun:ffi has no Windows/arm64 backend. + // setRawMode on a CONIN$ tty.ReadStream must leave the console in the same + // UV_TTY_MODE_RAW_VT state (ENABLE_VIRTUAL_TERMINAL_INPUT) as process.stdin + // does. bun:ffi has no Windows/arm64 backend, hence the isArm64 skip. test.skipIf(!isWindows || isArm64)( "uses the same VT raw console mode for CONIN$ as for stdin on Windows", async () => { From f8ba2a81958708c9c76f5041644293a41e68ea05 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 20 Jul 2026 08:38:35 +0000 Subject: [PATCH 4/6] review: route every console-input fd through the stdin uv_tty_t Address claude[bot] findings on #34788: * uv_tty_set_mode short-circuits when the requested mode equals the cached tty.rd.mode.mode on the stdin singleton, so writing the console mode via a side channel for fd != 0 could leave that cache stale and make a later process.stdin.setRawMode(true) no-op. Route every console-input fd through the singleton; fall back to direct SetConsoleMode on CONIN$ only when fd 0 itself is not a console (no cache to desynchronise then). * Gate fd != 0 with GetNumberOfConsoleInputEvents so console output handles (CONOUT$, fd 1/2) are rejected with ENOTTY instead of silently raw-moding the input buffer. * jsTTYSetMode on Windows now validates fd with isNumber() + toInt32 under a throw scope; this.fd is a public user-mutable property that fs.ReadStream sets to null on close. The test is extended to cover both the cache-desync sequence and the output-handle rejection. --- src/io/source.rs | 68 +++++++++++----------- src/jsc/bindings/ProcessBindingTTYWrap.cpp | 17 ++++-- src/sys/windows/mod.rs | 1 + src/windows_sys/externs.rs | 3 + test/js/node/tty.test.ts | 15 +++++ 5 files changed, 67 insertions(+), 37 deletions(-) diff --git a/src/io/source.rs b/src/io/source.rs index e92386850e5d..6bee9d1e533b 100644 --- a/src/io/source.rs +++ b/src/io/source.rs @@ -498,26 +498,29 @@ pub mod stdin_tty { /// (reading it from the VM directly would be a T6 dependency); the C++ caller /// (`ProcessBindingTTYWrap.cpp`) supplies `defaultGlobalObject()->uvLoop()`. /// -/// fd 0 resolves to the process-static `stdin_tty` singleton, so the mode -/// change goes through `uv_tty_set_mode` and is coordinated with any -/// in-flight libuv console read on that handle. +/// Console input mode is a property of the input buffer, not the handle, so +/// every console-input fd routes the actual mode change through +/// `uv_tty_set_mode` on the process-static `stdin_tty` singleton. That keeps +/// libuv's cached `tty.rd.mode.mode` (which `uv_tty_set_mode` short-circuits +/// on) coherent with the real console state and coordinates the mode flip +/// with any in-flight libuv console read on stdin. /// -/// Other console fds have no libuv reader (fs.ReadStream reads via -/// `uv_fs_read`), and a transient `uv_tty_t` on them is unsafe: -/// `uv__tty_close` `_close()`s the caller's fd, and `SetConsoleMode` needs +/// A transient `uv_tty_t` on the caller's fd instead is unsafe: +/// `uv__tty_close` `_close()`s it, and `SetConsoleMode` needs /// `GENERIC_READ | GENERIC_WRITE` which an `O_RDONLY` CONIN$ handle lacks -/// (and `DuplicateHandle` cannot add access the source does not have). So the -/// fd is checked to be a console input handle and the same console-mode masks -/// `uv_tty_set_mode` would apply for `UV_TTY_MODE_RAW_VT` / -/// `UV_TTY_MODE_NORMAL` are written on a fresh CONIN$ handle. Console input -/// mode is a property of the input buffer, not the handle, so the mode -/// observed on fd 0 and on any CONIN$ handle is the same afterwards. +/// (and `DuplicateHandle` cannot add access the source does not have). So for +/// fd != 0 the fd is only used to gate that it is a console-input handle, and +/// when fd 0 itself is not a console the same masks `uv_tty_set_mode` would +/// apply are written on a fresh RW CONIN$ handle, which has no libuv cache to +/// desynchronise. #[unsafe(no_mangle)] pub(crate) extern "C" fn Source__setRawModeTty( uv_loop: *mut uv::Loop, fd: c_int, raw: bool, ) -> c_int { + use bun_sys::windows as w; + // UV_TTY_MODE_RAW_VT: the terminal emits VT input sequences (bracketed // paste etc.) instead of libuv translating INPUT_RECORDs. Matches POSIX. let mode = if raw { @@ -526,11 +529,21 @@ pub(crate) extern "C" fn Source__setRawModeTty( uv::TtyMode::Normal }; - if fd == 0 { - let mut tty = match Source::open_tty(uv_loop, Fd::stdin()) { - bun_sys::Result::Ok(tty) => tty, - bun_sys::Result::Err(e) => return e.errno as c_int, - }; + if fd != 0 { + let src = Fd::from_uv(fd).native(); + if src == w::INVALID_HANDLE_VALUE { + return bun_sys::E::BADF as c_int; + } + // `GetNumberOfConsoleInputEvents` only succeeds on console input + // handles; `GetConsoleMode` alone would also accept screen buffers. + let mut unused: u32 = 0; + // SAFETY: `src` is a live handle (`uv_get_osfhandle(fd)` for an open fd). + if unsafe { w::GetNumberOfConsoleInputEvents(src, &mut unused) } == 0 { + return bun_sys::E::NOTTY as c_int; + } + } + + if let bun_sys::Result::Ok(mut tty) = Source::open_tty(uv_loop, Fd::stdin()) { return match Source::tty_mut(&mut tty) .set_mode(mode) .to_error(bun_sys::Tag::uv_tty_set_mode) @@ -539,27 +552,16 @@ pub(crate) extern "C" fn Source__setRawModeTty( None => 0, }; } + if fd == 0 { + return bun_sys::E::NOTTY as c_int; + } - use bun_sys::windows as w; + // fd 0 is not a console but `fd` is (piped stdin + CONIN$ reopen). There + // is no libuv mode cache on the input buffer in this case. const ENABLE_WINDOW_INPUT: u32 = 0x0008; const CONIN_W: [u16; 7] = [ b'C' as _, b'O' as _, b'N' as _, b'I' as _, b'N' as _, b'$' as _, 0, ]; - - let src = Fd::from_uv(fd).native(); - if src == w::INVALID_HANDLE_VALUE { - return bun_sys::E::BADF as c_int; - } - // Reject non-console-input fds with ENOTTY before touching the process - // console. `GetConsoleMode` only needs `GENERIC_READ`, which an O_RDONLY - // CONIN$ handle has. - let mut unused: u32 = 0; - // SAFETY: `src` is a live handle (`uv_get_osfhandle(fd)` for an open fd). - if unsafe { w::GetConsoleMode(src, &mut unused) } == 0 { - return bun_sys::E::NOTTY as c_int; - } - // `SetConsoleMode` needs `GENERIC_READ|GENERIC_WRITE`; `DuplicateHandle` - // can't add access, so open CONIN$ fresh. Mode is per buffer, not handle. // SAFETY: `CONIN_W` is a NUL-terminated static wide string. let conin = unsafe { w::CreateFileW( diff --git a/src/jsc/bindings/ProcessBindingTTYWrap.cpp b/src/jsc/bindings/ProcessBindingTTYWrap.cpp index c1aada235f2d..06dec631f642 100644 --- a/src/jsc/bindings/ProcessBindingTTYWrap.cpp +++ b/src/jsc/bindings/ProcessBindingTTYWrap.cpp @@ -195,13 +195,22 @@ JSC::EncodedJSValue Process_functionInternalGetWindowSize(JSC::JSGlobalObject* g JSC_DEFINE_HOST_FUNCTION(jsTTYSetMode, (JSC::JSGlobalObject * globalObject, CallFrame* callFrame)) { #if OS(WINDOWS) - ASSERT(callFrame->argumentCount() == 2); - int fd = callFrame->argument(0).asInt32(); - bool raw = callFrame->argument(1).asBoolean(); + auto& vm = JSC::getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + + JSValue fd = callFrame->argument(0); + if (!fd.isNumber()) { + throwTypeError(globalObject, scope, "fd must be a number"_s); + return {}; + } + int fdToUse = fd.toInt32(globalObject); + RETURN_IF_EXCEPTION(scope, {}); + bool raw = callFrame->argument(1).toBoolean(globalObject); + RETURN_IF_EXCEPTION(scope, {}); Zig::GlobalObject* global = uncheckedDowncast(globalObject); - return JSValue::encode(jsNumber(Source__setRawModeTty(global->uvLoop(), fd, raw))); + return JSValue::encode(jsNumber(Source__setRawModeTty(global->uvLoop(), fdToUse, raw))); #else auto& vm = JSC::getVM(globalObject); auto scope = DECLARE_THROW_SCOPE(vm); diff --git a/src/sys/windows/mod.rs b/src/sys/windows/mod.rs index 122c17f71d82..5edb0c86d0fd 100644 --- a/src/sys/windows/mod.rs +++ b/src/sys/windows/mod.rs @@ -4267,6 +4267,7 @@ pub fn GetProcessMemoryInfo(process: HANDLE) -> Result BOOL; + pub fn GetNumberOfConsoleInputEvents(hConsoleInput: HANDLE, lpcNumberOfEvents: *mut DWORD) + -> BOOL; + pub fn InitializeProcThreadAttributeList( lpAttributeList: *mut u8, dwAttributeCount: DWORD, diff --git a/test/js/node/tty.test.ts b/test/js/node/tty.test.ts index 880f37e8a71d..10842158d76f 100644 --- a/test/js/node/tty.test.ts +++ b/test/js/node/tty.test.ts @@ -248,6 +248,17 @@ describe("ReadStream.prototype.setRawMode", () => { stream.setRawMode(false); const coninNormalMode = mode(); + // libuv caches the last-requested mode on the stdin uv_tty_t and + // short-circuits on a repeat; the CONIN$ path must keep it in sync. + process.stdin.setRawMode(true); + stream.setRawMode(false); + process.stdin.setRawMode(true); + const stdinReRawMode = mode(); + process.stdin.setRawMode(false); + + let outErr; + new tty.ReadStream(1).on("error", e => (outErr = String(e))).setRawMode(true); + process.stdout.write( "RESULT " + JSON.stringify({ @@ -258,6 +269,8 @@ describe("ReadStream.prototype.setRawMode", () => { stdinNormalMode, coninNormalMode, coninIsRaw, + stdinReRawMode, + outErr, ...(err ? { err } : {}), }), ); @@ -298,6 +311,8 @@ describe("ReadStream.prototype.setRawMode", () => { stdinNormalMode: result.stdinNormalMode, coninNormalMode: result.stdinNormalMode, coninIsRaw: true, + stdinReRawMode: result.stdinRawMode, + outErr: expect.stringContaining("setRawMode failed with errno:"), }); expect(result.fd).not.toBe(0); expect(result.stdinRawMode & ENABLE_VIRTUAL_TERMINAL_INPUT).toBe(ENABLE_VIRTUAL_TERMINAL_INPUT); From fc353db5aad270c9af41586bb9158ffa7f938cf7 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Mon, 20 Jul 2026 08:40:44 +0000 Subject: [PATCH 5/6] [autofix.ci] apply automated fixes --- src/windows_sys/externs.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/windows_sys/externs.rs b/src/windows_sys/externs.rs index dd391b87a093..87b66fd08a1e 100644 --- a/src/windows_sys/externs.rs +++ b/src/windows_sys/externs.rs @@ -1797,8 +1797,10 @@ unsafe extern "system" { pub fn SetConsoleMode(hConsoleHandle: HANDLE, dwMode: DWORD) -> BOOL; - pub fn GetNumberOfConsoleInputEvents(hConsoleInput: HANDLE, lpcNumberOfEvents: *mut DWORD) - -> BOOL; + pub fn GetNumberOfConsoleInputEvents( + hConsoleInput: HANDLE, + lpcNumberOfEvents: *mut DWORD, + ) -> BOOL; pub fn InitializeProcThreadAttributeList( lpAttributeList: *mut u8, From 7165d46fec0a5623146b0e3f9b0e45d20a573475 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 20 Jul 2026 09:03:40 +0000 Subject: [PATCH 6/6] review: hoist ENABLE_WINDOW_INPUT next to its sibling flags --- src/io/source.rs | 3 +-- src/sys/windows/mod.rs | 1 + 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/io/source.rs b/src/io/source.rs index 6bee9d1e533b..9c16b07e464a 100644 --- a/src/io/source.rs +++ b/src/io/source.rs @@ -558,7 +558,6 @@ pub(crate) extern "C" fn Source__setRawModeTty( // fd 0 is not a console but `fd` is (piped stdin + CONIN$ reopen). There // is no libuv mode cache on the input buffer in this case. - const ENABLE_WINDOW_INPUT: u32 = 0x0008; const CONIN_W: [u16; 7] = [ b'C' as _, b'O' as _, b'N' as _, b'I' as _, b'N' as _, b'$' as _, 0, ]; @@ -579,7 +578,7 @@ pub(crate) extern "C" fn Source__setRawModeTty( } // Same masks and fallback as libuv `uv_tty_set_mode` (src/win/tty.c). let (flags, try_flags) = match mode { - uv::TtyMode::Vt => (ENABLE_WINDOW_INPUT, w::ENABLE_VIRTUAL_TERMINAL_INPUT), + uv::TtyMode::Vt => (w::ENABLE_WINDOW_INPUT, w::ENABLE_VIRTUAL_TERMINAL_INPUT), _ => ( w::ENABLE_ECHO_INPUT | w::ENABLE_LINE_INPUT | w::ENABLE_PROCESSED_INPUT, 0, diff --git a/src/sys/windows/mod.rs b/src/sys/windows/mod.rs index 5edb0c86d0fd..862ee3e5142f 100644 --- a/src/sys/windows/mod.rs +++ b/src/sys/windows/mod.rs @@ -3749,6 +3749,7 @@ pub const ENABLE_ECHO_INPUT: DWORD = 0x004; pub const ENABLE_LINE_INPUT: DWORD = 0x002; pub const ENABLE_PROCESSED_INPUT: DWORD = 0x001; pub const ENABLE_VIRTUAL_TERMINAL_INPUT: DWORD = 0x200; +pub const ENABLE_WINDOW_INPUT: DWORD = 0x008; pub const ENABLE_WRAP_AT_EOL_OUTPUT: DWORD = 0x0002; pub const ENABLE_PROCESSED_OUTPUT: DWORD = 0x0001;