diff --git a/src/js/node/tty.ts b/src/js/node/tty.ts index caada4eb14c..04637f221e7 100644 --- a/src/js/node/tty.ts +++ b/src/js/node/tty.ts @@ -8,6 +8,7 @@ const { isatty, getWindowSize: _getWindowSize, } = $cpp("ProcessBindingTTYWrap.cpp", "createBunTTYFunctions"); +const { TTY } = process.platform === "win32" ? process.binding("tty_wrap") : { TTY: undefined }; const { validateInteger } = require("internal/validators"); const fs = require("internal/fs/streams"); @@ -20,6 +21,14 @@ function ReadStream(fd): void { this.isRaw = false; // Only set isTTY to true if the fd is actually a TTY this.isTTY = isatty(fd); + if (process.platform === "win32" && fd !== 0 && this.isTTY) { + // Windows raw mode for non-stdin tty.ReadStream instances must go through + // uv_tty_set_mode, which needs a uv_tty_t wrapper for this fd. fd 0 uses + // Bun's shared stdin source and Source__setRawModeStdin instead, while + // non-TTY fds should keep the existing construction behavior and only fail + // if setRawMode() is later requested. + this.$bunNativePtr = new TTY(fd); + } } $toClass(ReadStream, "ReadStream", fs.ReadStream); @@ -71,12 +80,17 @@ Object.defineProperty(ReadStream, "prototype", { 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 you call setRawMode before you call on('data'), native-readable + // streams need to be constructed before uv_tty_set_mode to avoid EBADF. + // This corresponds to the `ensureConstructed` function in + // `native-readable.ts`. Plain fd-backed tty.ReadStream instances, such + // as reopened CONIN$, do not have $start and only use the TTYWrap for + // console mode changes. + this.$start?.(); + + // Native-readable handles expose setRawMode(bool), but the TTYWrap + // binding mirrors libuv and expects numeric uv_tty_mode_t values. + const err = handle.setRawMode(handle instanceof TTY ? (flag ? 1 : 0) : flag); if (err) { this.emit("error", err); return this; diff --git a/test/js/node/tty.test.ts b/test/js/node/tty.test.ts index 814b520ef7c..b1926f82693 100644 --- a/test/js/node/tty.test.ts +++ b/test/js/node/tty.test.ts @@ -81,6 +81,77 @@ describe("ReadStream.prototype.setRawMode", () => { returnsThis: true, }); }); + + test.skipIf(!isWindows)("supports raw mode on reopened CONIN$ streams", async () => { + 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"); + let err; + const fd = fs.openSync("\\\\\\\\.\\\\CONIN$", "r+"); + const input = new tty.ReadStream(fd); + input.on("error", e => (err = e.message)); + const before = input.isRaw; + const ret = input.setRawMode(true); + const afterTrue = input.isRaw; + input.setRawMode(false); + const afterFalse = input.isRaw; + input.destroy(); + process.stdout.write( + "RESULT " + + JSON.stringify({ + isTTY: input.isTTY, + before, + afterTrue, + afterFalse, + returnsThis: ret === input, + ...(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)); + } + expect(JSON.parse(match[1])).toEqual({ + isTTY: true, + before: false, + afterTrue: true, + afterFalse: false, + returnsThis: true, + }); + }); }); describe("WriteStream.prototype.getColorDepth", () => {