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..b0885c9a8fd5 100644 --- a/src/runtime/api/bun/js_bun_spawn_bindings.rs +++ b/src/runtime/api/bun/js_bun_spawn_bindings.rs @@ -1696,6 +1696,9 @@ fn spawn_maybe_sync( } } + // `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 // 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 +1706,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 +1716,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..36cb00731a90 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; + // 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, + }); + }); +}); + describe("maxBuffer infinity does not limit the number of bytes", () => { const sample = "this is a long example string\n"; const sample_repeat_count = 10000;