From 7f0da3a82d4927020f18070ab267aec877ca26f2 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 27 Jul 2026 06:48:08 +0000 Subject: [PATCH 1/6] console/worker_threads: keep fd 1/2 blocking across worker start; poll on EAGAIN in the console writer Starting a node:worker_threads Worker was silently making the main thread's console.log lossy on a pipe: the worker's stdio rebind called Object.defineProperty(process, "stdout"/"stderr", {...}), and JSC's defineOwnProperty reifies a static PropertyCallback slot before replacing it. Reification ran the fd-backed stream constructor (Bun.file(1).writer()), which dup()s fd 1 and sets O_NONBLOCK on the dup. O_NONBLOCK lives on the open file description, which the dup shares with the process-wide fd 1, so every thread's fd 1 was now nonblocking for the rest of the process. The main thread's native console writer (fd_write_all_quiet) treated the resulting EAGAIN as a terminal error and discarded the unwritten tail, so a burst into a slow pipe reader dropped most of its lines with exit 0. Three independent fixes, each closing one seam of that chain: - worker_threads: rebind stdio with plain assignment instead of Object.defineProperty. put() replaces a PropertyCallback slot without reifying it and yields the same {writable,enumerable,configurable} descriptor, so the fd-backed constructor is never run in a worker. - sys: fd_write_all_quiet polls for POLLOUT on EAGAIN instead of giving up. Anything sharing the open file description (a worker, a parent shell, libuv in a co-process) can flip O_NONBLOCK at any time; blocking on writability is what Node's writer does. - FileSink: record the fd returned by open_for_writing in self.fd. setup() handed the fd to the writer but left self.fd at INVALID, so Bun__ForceFileSinkToBeSynchronousForProcessObjectStdio's update_nonblocking(self.fd, false) was a no-op and get_fd() returned -1 on the Bun.file(fd).writer() path. --- src/js/node/worker_threads.ts | 38 ++---- src/runtime/webcore/FileSink.rs | 3 + src/sys/lib.rs | 15 +++ test/js/node/process/process-stdio.test.ts | 147 ++++++++++++++++++++- 4 files changed, 178 insertions(+), 25 deletions(-) diff --git a/src/js/node/worker_threads.ts b/src/js/node/worker_threads.ts index 921df1a621d2..682286b3c76b 100644 --- a/src/js/node/worker_threads.ts +++ b/src/js/node/worker_threads.ts @@ -446,37 +446,27 @@ function makePortWritable(port) { function setupWorkerStdio(stdio) { const { stdin, stdout, stderr } = stdio; + // Plain assignment, not Object.defineProperty: process.stdout/stderr/stdin + // are static PropertyCallback slots, and JSC's defineOwnProperty reifies the + // lazy value first (running the fd-backed constructor on the shared fd 1/2) + // before replacing it. put() replaces the slot without reifying and yields + // the same {writable,enumerable,configurable}=true descriptor. if (stdout) { - Object.defineProperty(process, "stdout", { - value: makePortWritable(stdout), - writable: true, - configurable: true, - enumerable: true, - }); + process.stdout = makePortWritable(stdout); } if (stderr) { - Object.defineProperty(process, "stderr", { - value: makePortWritable(stderr), - writable: true, - configurable: true, - enumerable: true, - }); + process.stderr = makePortWritable(stderr); } // node always replaces a worker's process.stdin: port-backed when { stdin: true }, // otherwise an immediately-EOF'd stream — never the process-wide fd 0, which // would race the main thread (and hang on a TTY). - Object.defineProperty(process, "stdin", { - value: stdin - ? makePortReadable(stdin, true) - : new Readable({ - read() { - this.push(null); - }, - }), - writable: true, - configurable: true, - enumerable: true, - }); + process.stdin = stdin + ? makePortReadable(stdin, true) + : new Readable({ + read() { + this.push(null); + }, + }); // node routes console.log through process.stdout/stderr; Bun's global console // writes the fd directly, so rebind it to the captured streams when present. if (stdout || stderr) { diff --git a/src/runtime/webcore/FileSink.rs b/src/runtime/webcore/FileSink.rs index f0a207de17bf..1bf487f4b208 100644 --- a/src/runtime/webcore/FileSink.rs +++ b/src/runtime/webcore/FileSink.rs @@ -643,6 +643,9 @@ impl FileSink { } sys::Result::Ok(fd) => fd, }; + // Record the dup'd/opened fd so `get_fd()` and the process.stdout + // force-sync hook (which clears O_NONBLOCK on it) see a real fd. + self.fd.set(fd); #[cfg(windows)] { diff --git a/src/sys/lib.rs b/src/sys/lib.rs index 90d4cdbac33f..494d10a96edb 100644 --- a/src/sys/lib.rs +++ b/src/sys/lib.rs @@ -9405,11 +9405,26 @@ fn qw_set_fd(qw: &mut bun_core::output::QuietWriter, fd: Fd) { /// Best-effort write-all loop. Returns `false` on I/O error / zero-write so /// `ScopedLogger::log` can disable the scope; "quiet" callers discard the bool. +/// EAGAIN is not an error: the open file description backing a stdio fd can be +/// flipped to O_NONBLOCK by any co-process or thread sharing it (workers, +/// parent shells, libuv), so block on writability and retry instead of +/// discarding the unwritten tail. fn fd_write_all_quiet(fd: Fd, mut bytes: &[u8]) -> bool { while !bytes.is_empty() { match write(fd, bytes) { Ok(0) => return false, // short write → give up Ok(n) => bytes = &bytes[n..], + #[cfg(unix)] + Err(e) if e.is_retry() => { + let mut pfd = [posix::PollFd { + fd: fd.native(), + events: posix::POLL_OUT, + revents: 0, + }]; + if posix::poll(&mut pfd, -1).is_err() { + return false; + } + } Err(_) => return false, } } diff --git a/test/js/node/process/process-stdio.test.ts b/test/js/node/process/process-stdio.test.ts index ba35bc4f0484..25fa8c9dc2c9 100644 --- a/test/js/node/process/process-stdio.test.ts +++ b/test/js/node/process/process-stdio.test.ts @@ -1,6 +1,8 @@ import { spawn, spawnSync } from "bun"; +import { dlopen, FFIType, ptr } from "bun:ffi"; import { describe, expect, test } from "bun:test"; -import { bunEnv, bunExe } from "harness"; +import { bunEnv, bunExe, isPosix, libcPathForDlopen } from "harness"; +import { closeSync, readSync } from "node:fs"; import path from "path"; import { isatty } from "tty"; describe.concurrent("process-stdio", () => { @@ -158,4 +160,147 @@ describe.concurrent("process-stdio", () => { `hello worldhello again|😋 Get Emoji — All Emojis to ✂️ Copy and 📋 Paste 👌`.repeat(9999), ); }); + + // O_NONBLOCK is an open-file-description flag: any co-process or thread + // sharing the description (worker threads, a parent shell, libuv) can flip it + // on the process-wide fd 1/2. Bun must (a) not flip it from the worker stdio + // path and (b) not drop output when something else has. + describe.skipIf(!isPosix)("stdout/stderr vs O_NONBLOCK on a pipe", () => { + // F_GETFL/F_SETFL are 3/4 on Linux and Darwin; O_NONBLOCK differs (2048 vs 4). + // describe.skipIf still evaluates this body on Windows, so guard the libc + // lookup (which throws there); the skipped tests never read the value. + const libc = isPosix ? libcPathForDlopen() : ""; + const fcntlPrelude = ` +const { dlopen, FFIType } = require("bun:ffi"); +const { O_NONBLOCK } = require("node:constants"); +const { fcntl } = dlopen(${JSON.stringify(libc)}, { + fcntl: { args: [FFIType.int, FFIType.int, FFIType.int], returns: FFIType.int }, +}).symbols; +const nonblock = fd => (fcntl(fd, 3, 0) & O_NONBLOCK) !== 0; +`; + + test("reading process.stdout / process.stderr leaves fd 1/2 blocking", async () => { + await using proc = spawn({ + cmd: [ + bunExe(), + "-e", + fcntlPrelude + + ` +const before = [nonblock(1), nonblock(2)]; +void process.stdout; +void process.stderr; +const after = [nonblock(1), nonblock(2)]; +process.stderr.write(JSON.stringify({ before, after })); +`, + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stdout).toBe(""); + expect(JSON.parse(stderr)).toEqual({ before: [false, false], after: [false, false] }); + expect(exitCode).toBe(0); + }); + + test("starting a node:worker_threads Worker leaves fd 1/2 blocking", async () => { + // The worker's stdio rebind must not reify the fd-backed stream + // (JSC defineOwnProperty on a lazy PropertyCallback would run it). + await using proc = spawn({ + cmd: [ + bunExe(), + "-e", + fcntlPrelude + + ` +const { Worker } = require("node:worker_threads"); +const before = [nonblock(1), nonblock(2)]; +const w = new Worker("setTimeout(() => {}, 0)", { eval: true }); +w.on("online", () => { + const after = [nonblock(1), nonblock(2)]; + process.stderr.write(JSON.stringify({ before, after })); + w.on("exit", () => {}); +}); +`, + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stdout).toBe(""); + expect(JSON.parse(stderr)).toEqual({ before: [false, false], after: [false, false] }); + expect(exitCode).toBe(0); + }); + + test("console.log delivers every byte when fd 1 is O_NONBLOCK and the pipe is full", async () => { + // The open file description can be flipped by anything sharing it; the + // native console writer must poll for writability on EAGAIN, not discard + // the unwritten tail. + // stdout: "pipe" pre-drains into the parent, so use a raw pipe(2) whose + // read end only this test drains: the child fills it to EAGAIN, signals + // the byte count on stderr, then console.log()s the markers into the + // still-full pipe; the parent starts draining only after the signal. + const { pipe } = dlopen(libc, { + pipe: { args: [FFIType.ptr], returns: FFIType.int }, + }).symbols; + const fds = new Int32Array(2); + expect(pipe(ptr(fds))).toBe(0); + const [r, w] = fds; + try { + await using proc = spawn({ + cmd: [ + bunExe(), + "-e", + fcntlPrelude + + ` +const fs = require("node:fs"); +fcntl(1, 4, fcntl(1, 3, 0) | O_NONBLOCK); +const fill = Buffer.alloc(4096, 120); +let filled = 0; +for (let i = 0; i < 1000; i++) { + try { filled += fs.writeSync(1, fill); } catch { break; } +} +fs.writeSync(2, String(filled) + "\\n"); +for (let i = 0; i < 10; i++) console.log("marker " + i); +`, + ], + env: bunEnv, + stdio: ["ignore", w, "pipe"], + }); + closeSync(w); + const reader = proc.stderr.getReader(); + const first = await reader.read(); + const filled = Number(Buffer.from(first.value).toString().trim()); + expect(filled).toBeGreaterThan(0); + const buf = Buffer.alloc(65536); + let total = Buffer.alloc(0); + for (;;) { + const n = readSync(r, buf); + if (n === 0) break; + total = Buffer.concat([total, buf.subarray(0, n)]); + } + let stderrRest = ""; + for (;;) { + const { value, done } = await reader.read(); + if (done) break; + stderrRest += Buffer.from(value).toString(); + } + const exitCode = await proc.exited; + expect({ + stderrRest, + filledOK: total.subarray(0, filled).equals(Buffer.alloc(filled, 120)), + payload: total.subarray(filled).toString(), + }).toEqual({ + stderrRest: "", + filledOK: true, + payload: Array.from({ length: 10 }, (_, i) => `marker ${i}\n`).join(""), + }); + expect(exitCode).toBe(0); + } finally { + try { + closeSync(r); + } catch {} + } + }); + }); }); From 2aac1fc5e5cb183cf804dc89f6fa4448b744a8cd Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 27 Jul 2026 07:15:03 +0000 Subject: [PATCH 2/6] FileSink: set self.fd only after writer.start() succeeds Avoids leaving a closed fd number in self.fd when start()/start_sync() fails (the error arms close the fd and return). The sink is deref'd immediately on that path today so it was not observable, but this keeps the pre-existing invariant that self.fd is INVALID whenever setup() returns Err. --- src/runtime/webcore/FileSink.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/runtime/webcore/FileSink.rs b/src/runtime/webcore/FileSink.rs index 1bf487f4b208..171a27148d92 100644 --- a/src/runtime/webcore/FileSink.rs +++ b/src/runtime/webcore/FileSink.rs @@ -643,9 +643,6 @@ impl FileSink { } sys::Result::Ok(fd) => fd, }; - // Record the dup'd/opened fd so `get_fd()` and the process.stdout - // force-sync hook (which clears O_NONBLOCK on it) see a real fd. - self.fd.set(fd); #[cfg(windows)] { @@ -660,6 +657,7 @@ impl FileSink { return sys::Result::Err(err); } sys::Result::Ok(()) => { + self.fd.set(fd); self.writer .with_mut(|w| w.update_ref(self.io_evtloop(), false)); } @@ -675,6 +673,9 @@ impl FileSink { return sys::Result::Err(err); } sys::Result::Ok(()) => { + // Record the dup'd/opened fd so `get_fd()` and the process.stdout + // force-sync hook (which clears O_NONBLOCK on it) see a real fd. + self.fd.set(fd); // Only keep the event loop ref'd while there's a pending write in progress. // If there's no pending write, no need to keep the event loop ref'd. self.writer From 2c86c87f29aea5f4f2203209983c5af858734b90 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 27 Jul 2026 07:17:29 +0000 Subject: [PATCH 3/6] tighten comments --- src/js/node/worker_threads.ts | 7 ++----- src/runtime/webcore/FileSink.rs | 3 +-- src/sys/lib.rs | 5 +---- 3 files changed, 4 insertions(+), 11 deletions(-) diff --git a/src/js/node/worker_threads.ts b/src/js/node/worker_threads.ts index 682286b3c76b..609bc0f791a4 100644 --- a/src/js/node/worker_threads.ts +++ b/src/js/node/worker_threads.ts @@ -446,11 +446,8 @@ function makePortWritable(port) { function setupWorkerStdio(stdio) { const { stdin, stdout, stderr } = stdio; - // Plain assignment, not Object.defineProperty: process.stdout/stderr/stdin - // are static PropertyCallback slots, and JSC's defineOwnProperty reifies the - // lazy value first (running the fd-backed constructor on the shared fd 1/2) - // before replacing it. put() replaces the slot without reifying and yields - // the same {writable,enumerable,configurable}=true descriptor. + // Plain assignment: defineProperty would reify the lazy fd-backed stdio + // (JSC reifies a static PropertyCallback before defining over it). if (stdout) { process.stdout = makePortWritable(stdout); } diff --git a/src/runtime/webcore/FileSink.rs b/src/runtime/webcore/FileSink.rs index 171a27148d92..3e25c9ed364a 100644 --- a/src/runtime/webcore/FileSink.rs +++ b/src/runtime/webcore/FileSink.rs @@ -673,8 +673,7 @@ impl FileSink { return sys::Result::Err(err); } sys::Result::Ok(()) => { - // Record the dup'd/opened fd so `get_fd()` and the process.stdout - // force-sync hook (which clears O_NONBLOCK on it) see a real fd. + // `get_fd()` and the stdio force-sync O_NONBLOCK undo read this. self.fd.set(fd); // Only keep the event loop ref'd while there's a pending write in progress. // If there's no pending write, no need to keep the event loop ref'd. diff --git a/src/sys/lib.rs b/src/sys/lib.rs index 494d10a96edb..8cddf32b8208 100644 --- a/src/sys/lib.rs +++ b/src/sys/lib.rs @@ -9405,10 +9405,7 @@ fn qw_set_fd(qw: &mut bun_core::output::QuietWriter, fd: Fd) { /// Best-effort write-all loop. Returns `false` on I/O error / zero-write so /// `ScopedLogger::log` can disable the scope; "quiet" callers discard the bool. -/// EAGAIN is not an error: the open file description backing a stdio fd can be -/// flipped to O_NONBLOCK by any co-process or thread sharing it (workers, -/// parent shells, libuv), so block on writability and retry instead of -/// discarding the unwritten tail. +/// EAGAIN polls: anything sharing the open file description can flip O_NONBLOCK. fn fd_write_all_quiet(fd: Fd, mut bytes: &[u8]) -> bool { while !bytes.is_empty() { match write(fd, bytes) { From b5d22cf1cc72a9ad64415d2027d50a5b221deaeb Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 27 Jul 2026 07:19:12 +0000 Subject: [PATCH 4/6] tighten worker_threads comment to one line --- src/js/node/worker_threads.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/js/node/worker_threads.ts b/src/js/node/worker_threads.ts index 609bc0f791a4..c517077da809 100644 --- a/src/js/node/worker_threads.ts +++ b/src/js/node/worker_threads.ts @@ -446,8 +446,7 @@ function makePortWritable(port) { function setupWorkerStdio(stdio) { const { stdin, stdout, stderr } = stdio; - // Plain assignment: defineProperty would reify the lazy fd-backed stdio - // (JSC reifies a static PropertyCallback before defining over it). + // Not defineProperty: that reifies the lazy fd-backed stdio before replacing it. if (stdout) { process.stdout = makePortWritable(stdout); } From 2231b09e4303584fe90e72efb58c6ea9a5f8d781 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 27 Jul 2026 07:38:23 +0000 Subject: [PATCH 5/6] address review: cc-compiled fcntl wrapper (Apple arm64 variadic ABI), EINTR retry on macOS, gate on linux|darwin, frame stderr header, close w on spawn failure --- src/sys/lib.rs | 3 + test/js/node/process/process-stdio.test.ts | 72 +++++++++++++++------- 2 files changed, 52 insertions(+), 23 deletions(-) diff --git a/src/sys/lib.rs b/src/sys/lib.rs index 8cddf32b8208..a5e405d98bf4 100644 --- a/src/sys/lib.rs +++ b/src/sys/lib.rs @@ -9411,6 +9411,9 @@ fn fd_write_all_quiet(fd: Fd, mut bytes: &[u8]) -> bool { match write(fd, bytes) { Ok(0) => return false, // short write → give up Ok(n) => bytes = &bytes[n..], + // Darwin's write$NOCANCEL is single-shot (no EINTR retry in `write()`). + #[cfg(unix)] + Err(e) if e.get_errno() == E::EINTR => continue, #[cfg(unix)] Err(e) if e.is_retry() => { let mut pfd = [posix::PollFd { diff --git a/test/js/node/process/process-stdio.test.ts b/test/js/node/process/process-stdio.test.ts index 25fa8c9dc2c9..09339c0fd442 100644 --- a/test/js/node/process/process-stdio.test.ts +++ b/test/js/node/process/process-stdio.test.ts @@ -1,7 +1,7 @@ import { spawn, spawnSync } from "bun"; -import { dlopen, FFIType, ptr } from "bun:ffi"; +import { cc, ptr } from "bun:ffi"; import { describe, expect, test } from "bun:test"; -import { bunEnv, bunExe, isPosix, libcPathForDlopen } from "harness"; +import { bunEnv, bunExe, isLinux, isMacOS, tempDirWithFiles } from "harness"; import { closeSync, readSync } from "node:fs"; import path from "path"; import { isatty } from "tty"; @@ -165,18 +165,30 @@ describe.concurrent("process-stdio", () => { // sharing the description (worker threads, a parent shell, libuv) can flip it // on the process-wide fd 1/2. Bun must (a) not flip it from the worker stdio // path and (b) not drop output when something else has. - describe.skipIf(!isPosix)("stdout/stderr vs O_NONBLOCK on a pipe", () => { - // F_GETFL/F_SETFL are 3/4 on Linux and Darwin; O_NONBLOCK differs (2048 vs 4). - // describe.skipIf still evaluates this body on Windows, so guard the libc - // lookup (which throws there); the skipped tests never read the value. - const libc = isPosix ? libcPathForDlopen() : ""; - const fcntlPrelude = ` -const { dlopen, FFIType } = require("bun:ffi"); -const { O_NONBLOCK } = require("node:constants"); -const { fcntl } = dlopen(${JSON.stringify(libc)}, { - fcntl: { args: [FFIType.int, FFIType.int, FFIType.int], returns: FFIType.int }, + describe.skipIf(!(isLinux || isMacOS))("stdout/stderr vs O_NONBLOCK on a pipe", () => { + // fcntl is variadic; Apple's arm64 ABI puts variadic args on the stack, so a + // fixed-arg dlopen binding gets F_SETFL wrong there. Compile non-variadic + // wrappers instead (fdutil.c is shared with the spawned children). + const dir = tempDirWithFiles("stdio-nonblock", { + "fdutil.c": ` +#include +#include +int fd_is_nonblock(int fd) { int fl = fcntl(fd, F_GETFL); return fl >= 0 && (fl & O_NONBLOCK) != 0; } +int fd_set_nonblock(int fd) { int fl = fcntl(fd, F_GETFL); return fl < 0 ? fl : fcntl(fd, F_SETFL, fl | O_NONBLOCK); } +int fd_pipe(int* fds) { return pipe(fds); } +`, + }); + const fdutil = path.join(dir, "fdutil.c"); + const prelude = ` +const { cc } = require("bun:ffi"); +const { fd_is_nonblock, fd_set_nonblock } = cc({ + source: ${JSON.stringify(fdutil)}, + symbols: { + fd_is_nonblock: { args: ["int"], returns: "int" }, + fd_set_nonblock: { args: ["int"], returns: "int" }, + }, }).symbols; -const nonblock = fd => (fcntl(fd, 3, 0) & O_NONBLOCK) !== 0; +const nonblock = fd => fd_is_nonblock(fd) !== 0; `; test("reading process.stdout / process.stderr leaves fd 1/2 blocking", async () => { @@ -184,7 +196,7 @@ const nonblock = fd => (fcntl(fd, 3, 0) & O_NONBLOCK) !== 0; cmd: [ bunExe(), "-e", - fcntlPrelude + + prelude + ` const before = [nonblock(1), nonblock(2)]; void process.stdout; @@ -210,7 +222,7 @@ process.stderr.write(JSON.stringify({ before, after })); cmd: [ bunExe(), "-e", - fcntlPrelude + + prelude + ` const { Worker } = require("node:worker_threads"); const before = [nonblock(1), nonblock(2)]; @@ -240,21 +252,23 @@ w.on("online", () => { // read end only this test drains: the child fills it to EAGAIN, signals // the byte count on stderr, then console.log()s the markers into the // still-full pipe; the parent starts draining only after the signal. - const { pipe } = dlopen(libc, { - pipe: { args: [FFIType.ptr], returns: FFIType.int }, + const { fd_pipe } = cc({ + source: fdutil, + symbols: { fd_pipe: { args: ["ptr"], returns: "int" } }, }).symbols; const fds = new Int32Array(2); - expect(pipe(ptr(fds))).toBe(0); + expect(fd_pipe(ptr(fds))).toBe(0); const [r, w] = fds; + let wClosed = false; try { await using proc = spawn({ cmd: [ bunExe(), "-e", - fcntlPrelude + + prelude + ` const fs = require("node:fs"); -fcntl(1, 4, fcntl(1, 3, 0) | O_NONBLOCK); +fd_set_nonblock(1); const fill = Buffer.alloc(4096, 120); let filled = 0; for (let i = 0; i < 1000; i++) { @@ -268,9 +282,17 @@ for (let i = 0; i < 10; i++) console.log("marker " + i); stdio: ["ignore", w, "pipe"], }); closeSync(w); + wClosed = true; const reader = proc.stderr.getReader(); - const first = await reader.read(); - const filled = Number(Buffer.from(first.value).toString().trim()); + let header = ""; + while (!header.includes("\n")) { + const { value, done } = await reader.read(); + if (done) break; + header += Buffer.from(value).toString(); + } + const nl = header.indexOf("\n"); + const filled = Number(header.slice(0, nl >= 0 ? nl : header.length)); + let stderrRest = nl >= 0 ? header.slice(nl + 1) : ""; expect(filled).toBeGreaterThan(0); const buf = Buffer.alloc(65536); let total = Buffer.alloc(0); @@ -279,7 +301,6 @@ for (let i = 0; i < 10; i++) console.log("marker " + i); if (n === 0) break; total = Buffer.concat([total, buf.subarray(0, n)]); } - let stderrRest = ""; for (;;) { const { value, done } = await reader.read(); if (done) break; @@ -297,6 +318,11 @@ for (let i = 0; i < 10; i++) console.log("marker " + i); }); expect(exitCode).toBe(0); } finally { + if (!wClosed) { + try { + closeSync(w); + } catch {} + } try { closeSync(r); } catch {} From f49470f0d8b079e3645fd0a3a8e0fb056b6f204d Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 27 Jul 2026 07:57:45 +0000 Subject: [PATCH 6/6] test: dlopen pipe(2) in the parent instead of cc() to avoid the ffi cc symbol-map ASAN leak pipe(2) is not variadic so a fixed-arg dlopen binding is ABI-correct on every target; only the spawned children need the cc-compiled fcntl wrapper (and they exit, so ASAN does not report their allocations). --- test/js/node/process/process-stdio.test.ts | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/test/js/node/process/process-stdio.test.ts b/test/js/node/process/process-stdio.test.ts index 09339c0fd442..f608b68cebd3 100644 --- a/test/js/node/process/process-stdio.test.ts +++ b/test/js/node/process/process-stdio.test.ts @@ -1,7 +1,7 @@ import { spawn, spawnSync } from "bun"; -import { cc, ptr } from "bun:ffi"; +import { dlopen, ptr } from "bun:ffi"; import { describe, expect, test } from "bun:test"; -import { bunEnv, bunExe, isLinux, isMacOS, tempDirWithFiles } from "harness"; +import { bunEnv, bunExe, isLinux, isMacOS, libcPathForDlopen, tempDirWithFiles } from "harness"; import { closeSync, readSync } from "node:fs"; import path from "path"; import { isatty } from "tty"; @@ -172,10 +172,8 @@ describe.concurrent("process-stdio", () => { const dir = tempDirWithFiles("stdio-nonblock", { "fdutil.c": ` #include -#include int fd_is_nonblock(int fd) { int fl = fcntl(fd, F_GETFL); return fl >= 0 && (fl & O_NONBLOCK) != 0; } int fd_set_nonblock(int fd) { int fl = fcntl(fd, F_GETFL); return fl < 0 ? fl : fcntl(fd, F_SETFL, fl | O_NONBLOCK); } -int fd_pipe(int* fds) { return pipe(fds); } `, }); const fdutil = path.join(dir, "fdutil.c"); @@ -252,12 +250,12 @@ w.on("online", () => { // read end only this test drains: the child fills it to EAGAIN, signals // the byte count on stderr, then console.log()s the markers into the // still-full pipe; the parent starts draining only after the signal. - const { fd_pipe } = cc({ - source: fdutil, - symbols: { fd_pipe: { args: ["ptr"], returns: "int" } }, + // pipe(2) is not variadic, so a dlopen binding is ABI-correct everywhere. + const { pipe } = dlopen(libcPathForDlopen(), { + pipe: { args: ["ptr"], returns: "int" }, }).symbols; const fds = new Int32Array(2); - expect(fd_pipe(ptr(fds))).toBe(0); + expect(pipe(ptr(fds))).toBe(0); const [r, w] = fds; let wClosed = false; try {