diff --git a/src/io/source.rs b/src/io/source.rs index a8e91b06884b..9c16b07e464a 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,105 @@ 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()`. +/// +/// 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. +/// +/// 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 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__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 { + 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 { + uv::TtyMode::Vt + } else { + uv::TtyMode::Normal }; - // 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) - { - return err.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; + } } - 0 + + 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) + { + Some(err) => err.errno as c_int, + None => 0, + }; + } + if fd == 0 { + return bun_sys::E::NOTTY as c_int; + } + + // 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 CONIN_W: [u16; 7] = [ + b'C' as _, b'O' as _, b'N' as _, b'I' as _, b'N' as _, b'$' as _, 0, + ]; + // 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 => (w::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) + { + 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..c560bad5df76 100644 --- a/src/js/node/tty.ts +++ b/src/js/node/tty.ts @@ -56,37 +56,13 @@ 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 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") { - // 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..06dec631f642 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,22 @@ 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(); + 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__setRawModeStdin(global->uvLoop(), 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..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; @@ -4267,6 +4268,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 3eca2321a0fa..10842158d76f 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,135 @@ describe("ReadStream.prototype.setRawMode", () => { }); expect(await proc.exited).toBe(0); }); + + // 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 () => { + 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(); + + // 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({ + fd, + isTTY: stream.isTTY, + stdinRawMode, + coninRawMode, + stdinNormalMode, + coninNormalMode, + coninIsRaw, + stdinReRawMode, + outErr, + ...(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, + 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); + expect(result.coninRawMode & ENABLE_VIRTUAL_TERMINAL_INPUT).toBe(ENABLE_VIRTUAL_TERMINAL_INPUT); + }, + ); }); describe("WriteStream.prototype.getColorDepth", () => {