From 9802957bc4f552018bfa3d9a138deb88317995e5 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 28 Jul 2026 02:41:14 +0000 Subject: [PATCH 1/2] child_process: latch exec/execFile maxBuffer overflow so truncated output never exceeds the cap exec/execFile's stdout/stderr data handler computes truncatedLen = maxBuffer - (totalLen - length) when the cap is exceeded and slices the chunk to that length. After 027716a143 the pipe FileReader delivers ~64 KiB chunks with the next one already queued, so a second data event can fire after kill()/destroy(). On that second event totalLen - length is already past maxBuffer, so truncatedLen is negative and chunk.slice(0, -k) keeps chunk.length - k bytes, appending past the cap (observed up to ~3x maxBuffer) and calling kill() again. Add a per-stream latch so the handler drops every chunk after the first overflow, and clamp truncatedLen to zero. The callback now receives exactly maxBuffer bytes with ERR_CHILD_PROCESS_STDIO_MAXBUFFER, matching Node. --- src/js/node/child_process.ts | 12 ++++++-- .../child_process/child-process-exec.test.ts | 29 +++++++++++++++++-- 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/src/js/node/child_process.ts b/src/js/node/child_process.ts index 5902816c49ec..badb294f941d 100644 --- a/src/js/node/child_process.ts +++ b/src/js/node/child_process.ts @@ -36,6 +36,7 @@ const ArrayPrototypeSplice = Array.prototype.splice; var ArrayBufferIsView = ArrayBuffer.isView; var NumberIsInteger = Number.isInteger; +var MathMax = Math.max; var StringPrototypeIncludes = String.prototype.includes; var Uint8ArrayPrototypeIncludes = Uint8Array.prototype.includes; @@ -346,6 +347,7 @@ function execFile(file, args, options, callback) { if (encoding) child_buffer.setEncoding(encoding); let totalLen = 0; + let maxBufferTripped = false; if (maxBuffer === Infinity) { child_buffer.on("data", function onDataNoMaxBuf(chunk) { $arrayPush(_buffer, chunk); @@ -353,13 +355,18 @@ function execFile(file, args, options, callback) { return; } child_buffer.on("data", function onData(chunk) { + // A chunk that was already queued in the pipe may be delivered after + // kill()/destroy(). Once the limit has tripped, drop late chunks so the + // callback's stdout/stderr never exceeds maxBuffer and kill() runs once. + if (maxBufferTripped) return; const encoding = child_buffer.readableEncoding; if (encoding) { const length = Buffer.byteLength(chunk, encoding); totalLen += length; if (totalLen > maxBuffer) { - const truncatedLen = maxBuffer - (totalLen - length); + maxBufferTripped = true; + const truncatedLen = MathMax(0, maxBuffer - (totalLen - length)); $arrayPush(_buffer, String.prototype.slice.$call(chunk, 0, truncatedLen)); ex = $ERR_CHILD_PROCESS_STDIO_MAXBUFFER(kind); @@ -372,7 +379,8 @@ function execFile(file, args, options, callback) { totalLen += length; if (totalLen > maxBuffer) { - const truncatedLen = maxBuffer - (totalLen - length); + maxBufferTripped = true; + const truncatedLen = MathMax(0, maxBuffer - (totalLen - length)); $arrayPush(_buffer, chunk.slice(0, truncatedLen)); ex = $ERR_CHILD_PROCESS_STDIO_MAXBUFFER(kind); diff --git a/test/js/node/child_process/child-process-exec.test.ts b/test/js/node/child_process/child-process-exec.test.ts index 31eede1263fa..f79e3d0cf60a 100644 --- a/test/js/node/child_process/child-process-exec.test.ts +++ b/test/js/node/child_process/child-process-exec.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; -import { bunExe, isWindows } from "harness"; -import { exec } from "node:child_process"; +import { bunEnv, bunExe, isWindows } from "harness"; +import { exec, execFile } from "node:child_process"; const SIZE = 262145; @@ -104,6 +104,31 @@ describe.concurrent("child_process.exec", () => { }); }); +// Regression: a chunk already queued in the pipe when kill()/destroy() fires +// was being appended past maxBuffer because slice(0, negative) keeps a tail. +// Needs a writer that fills the pipe faster than the reader drains it; a +// debug-build Bun child starts too slowly to trigger it, so use head(1). +describe.concurrent.each(["buffer", "utf8"] as const)("maxBuffer cap with fast writer (%s)", enc => { + test.skipIf(isWindows)("stdout never exceeds maxBuffer", async () => { + const maxBuffer = 64 * 1024; + const results = await Promise.all( + Array.from({ length: 10 }, () => { + const { promise, resolve } = Promise.withResolvers<{ code: unknown; len: number }>(); + execFile( + "head", + ["-c", String(4 * 1024 * 1024), "/dev/zero"], + { maxBuffer, encoding: enc, env: bunEnv }, + (err, stdout) => resolve({ code: (err as NodeJS.ErrnoException | null)?.code, len: stdout.length }), + ); + return promise; + }), + ); + expect(results).toEqual( + Array.from({ length: 10 }, () => ({ code: "ERR_CHILD_PROCESS_STDIO_MAXBUFFER", len: maxBuffer })), + ); + }); +}); + test.concurrent("exec with verbatim arguments", async () => { const { resolve, reject, promise } = Promise.withResolvers(); From 323ce62ed76f827eaa5ddbb1a275aaa3cb9b54d0 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 28 Jul 2026 22:02:14 +0000 Subject: [PATCH 2/2] drop explanatory comment; maxBufferTripped is self-documenting --- src/js/node/child_process.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/js/node/child_process.ts b/src/js/node/child_process.ts index badb294f941d..3dd611d01531 100644 --- a/src/js/node/child_process.ts +++ b/src/js/node/child_process.ts @@ -355,9 +355,6 @@ function execFile(file, args, options, callback) { return; } child_buffer.on("data", function onData(chunk) { - // A chunk that was already queued in the pipe may be delivered after - // kill()/destroy(). Once the limit has tripped, drop late chunks so the - // callback's stdout/stderr never exceeds maxBuffer and kill() runs once. if (maxBufferTripped) return; const encoding = child_buffer.readableEncoding; if (encoding) {