From 14083ede5f8964ee02c93850080fb7ecef55aee2 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 11 Sep 2026 00:55:48 +0000 Subject: [PATCH 1/3] spawn: count output against maxBuffer when lazy is set `lazy: true` leaves the stdout and stderr readers paused until JS first pulls. `maxBuffer` is charged only from the read path. With both options, a child that wrote past the limit was never counted and never killed. It blocked on a full pipe and `exited` never settled. Start the readers eagerly whenever `maxBuffer` is set. The budget bounds what they buffer: `maxBuffer` plus one 64 KiB read for each pipe. --- packages/bun-types/bun.d.ts | 3 ++ src/runtime/api/bun/js_bun_spawn_bindings.rs | 13 +++++--- test/js/bun/spawn/spawn-maxbuf.test.ts | 31 ++++++++++++++++++++ 3 files changed, 43 insertions(+), 4 deletions(-) diff --git a/packages/bun-types/bun.d.ts b/packages/bun-types/bun.d.ts index a8633057bc40..838726622895 100644 --- a/packages/bun-types/bun.d.ts +++ b/packages/bun-types/bun.d.ts @@ -7797,6 +7797,9 @@ declare module "bun" { * This can improve performance when you don't need to read output * immediately. * + * Has no effect when `maxBuffer` is set. Output is counted against + * `maxBuffer` as it is read, so reading starts right away. + * * @default false * * @example diff --git a/src/runtime/api/bun/js_bun_spawn_bindings.rs b/src/runtime/api/bun/js_bun_spawn_bindings.rs index 03728c67b2eb..cf223e388c51 100644 --- a/src/runtime/api/bun/js_bun_spawn_bindings.rs +++ b/src/runtime/api/bun/js_bun_spawn_bindings.rs @@ -1696,6 +1696,11 @@ fn spawn_maybe_sync( } } + // `maxBuffer` is charged only as bytes are read. A reader that waits for the + // first JS pull never counts the child's output, so it never kills the + // child. The budget itself bounds what an eager reader can buffer. + let lazy = !is_sync && lazy && max_buffer.is_none(); + // Start the readers before the Writable::Buffer stdin writer so that if // the writer's start() throws below, both PipeReaders have taken their // start() ref and on_process_exit's later drain is refcount-balanced. @@ -1703,8 +1708,8 @@ fn spawn_maybe_sync( // Note: pass `subprocess_nn` (the `NonNull>` // captured above) instead of the live `&mut subprocess`, which would // alias with the `&mut subprocess.stdout` borrow held by `pipe`. - Readable::pipe_reader_mut(pipe).start(subprocess_nn, event_loop_nn, !is_sync && lazy); - if (is_sync || !lazy) && matches!(subprocess.stdout.get(), Readable::Pipe(_)) { + Readable::pipe_reader_mut(pipe).start(subprocess_nn, event_loop_nn, lazy); + if !lazy && matches!(subprocess.stdout.get(), Readable::Pipe(_)) { if let Readable::Pipe(pipe) = subprocess.stdout.get() { Readable::pipe_reader_mut(pipe).read_all(); } @@ -1713,9 +1718,9 @@ fn spawn_maybe_sync( if let Readable::Pipe(pipe) = subprocess.stderr.get() { // Note: see stdout arm above — avoid aliased &mut. - Readable::pipe_reader_mut(pipe).start(subprocess_nn, event_loop_nn, !is_sync && lazy); + Readable::pipe_reader_mut(pipe).start(subprocess_nn, event_loop_nn, lazy); - if (is_sync || !lazy) && matches!(subprocess.stderr.get(), Readable::Pipe(_)) { + if !lazy && matches!(subprocess.stderr.get(), Readable::Pipe(_)) { if let Readable::Pipe(pipe) = subprocess.stderr.get() { Readable::pipe_reader_mut(pipe).read_all(); } diff --git a/test/js/bun/spawn/spawn-maxbuf.test.ts b/test/js/bun/spawn/spawn-maxbuf.test.ts index c17f20a47383..7e877bc7da32 100644 --- a/test/js/bun/spawn/spawn-maxbuf.test.ts +++ b/test/js/bun/spawn/spawn-maxbuf.test.ts @@ -148,6 +148,37 @@ describe.each(["stdout", "stderr"] as const)("maxBuffer kills the process after }); }); +// `lazy: true` defers the pipe reads until JS first pulls, and `maxBuffer` is +// only charged as bytes are read. `maxBuffer` must still count the output of a +// child whose pipes nothing has read yet. +describe.each(["stdout", "stderr"] as const)("maxBuffer kills the process with lazy: true and .%s unread", fd => { + // The child writes well past `maxBuffer` and then blocks forever. Without the + // kill, `proc.exited` never resolves and the test times out. + const firehose = `process.${fd}.write(Buffer.alloc(300000, 65).toString()); setInterval(() => {}, 1e9);`; + const killSignal = isWindows ? "SIGKILL" : "SIGHUP"; + + test.concurrent("Bun.spawn", async () => { + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", firehose], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + lazy: true, + maxBuffer: 1000, + killSignal, + }); + await proc.exited; + expect({ exitCode: proc.exitCode, signalCode: proc.signalCode }).toEqual({ + exitCode: null, + signalCode: killSignal, + }); + // A late reader still gets what was read up to the limit. + const bytes = await proc[fd].bytes(); + expect(bytes.length).toBeGreaterThan(1000); + expect(bytes.length).toBeLessThanOrEqual(1000 + 64 * 1024); + }); +}); + describe("maxBuffer infinity does not limit the number of bytes", () => { const sample = "this is a long example string\n"; const sample_repeat_count = 10000; From 46cfe7a3009672eecd0041d0747d4a6dc55120f6 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 11 Sep 2026 01:13:58 +0000 Subject: [PATCH 2/3] test(spawn): assert the buffered output before the exit status --- test/js/bun/spawn/spawn-maxbuf.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/test/js/bun/spawn/spawn-maxbuf.test.ts b/test/js/bun/spawn/spawn-maxbuf.test.ts index 7e877bc7da32..36cb00731a90 100644 --- a/test/js/bun/spawn/spawn-maxbuf.test.ts +++ b/test/js/bun/spawn/spawn-maxbuf.test.ts @@ -168,14 +168,14 @@ describe.each(["stdout", "stderr"] as const)("maxBuffer kills the process with l killSignal, }); await proc.exited; - expect({ exitCode: proc.exitCode, signalCode: proc.signalCode }).toEqual({ - exitCode: null, - signalCode: killSignal, - }); // A late reader still gets what was read up to the limit. const bytes = await proc[fd].bytes(); expect(bytes.length).toBeGreaterThan(1000); expect(bytes.length).toBeLessThanOrEqual(1000 + 64 * 1024); + expect({ exitCode: proc.exitCode, signalCode: proc.signalCode }).toEqual({ + exitCode: null, + signalCode: killSignal, + }); }); }); From d750cf3b3aa156ef6a5e8a97eb0bd07a5043d2aa Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 11 Sep 2026 01:24:11 +0000 Subject: [PATCH 3/3] spawn: shorten the comment on the lazy override --- src/runtime/api/bun/js_bun_spawn_bindings.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/runtime/api/bun/js_bun_spawn_bindings.rs b/src/runtime/api/bun/js_bun_spawn_bindings.rs index cf223e388c51..b0885c9a8fd5 100644 --- a/src/runtime/api/bun/js_bun_spawn_bindings.rs +++ b/src/runtime/api/bun/js_bun_spawn_bindings.rs @@ -1696,9 +1696,7 @@ fn spawn_maybe_sync( } } - // `maxBuffer` is charged only as bytes are read. A reader that waits for the - // first JS pull never counts the child's output, so it never kills the - // child. The budget itself bounds what an eager reader can buffer. + // `maxBuffer` is charged as bytes are read, so a paused reader would never enforce it. let lazy = !is_sync && lazy && max_buffer.is_none(); // Start the readers before the Writable::Buffer stdin writer so that if