From c1584b8a356cf0344eade96a2184550827ae6fb5 Mon Sep 17 00:00:00 2001 From: robobun Date: Tue, 2 Sep 2025 03:26:25 -0700 Subject: [PATCH] Fix spawnSync crash when stdio is set to process.stderr (#22329) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Fixes #20321 - spawnSync crashes with RangeError when stdio is set to process.stderr - Handles file descriptors in stdio array correctly by treating them as non-captured output ## Problem When `spawnSync` is called with `process.stderr` or `process.stdout` in the stdio array, Bun.spawnSync returns the file descriptor number (e.g., 2 for stderr) instead of a buffer or null. This causes a RangeError when the code tries to call `toString(encoding)` on the number, since `Number.prototype.toString()` expects a radix between 2 and 36, not an encoding string. This was blocking AWS CDK usage with Bun, as CDK internally uses `spawnSync` with `stdio: ['ignore', process.stderr, 'inherit']`. ## Solution Check if stdout/stderr from Bun.spawnSync are numbers (file descriptors) and treat them as null (no captured output) instead of trying to convert them to strings. This aligns with Node.js's behavior where in `lib/internal/child_process.js` (lines 1051-1055), when a stdio option is a number or has an `fd` property, it's treated as a file descriptor: ```javascript } else if (typeof stdio === 'number' || typeof stdio.fd === 'number') { ArrayPrototypePush(acc, { type: 'fd', fd: typeof stdio === 'number' ? stdio : stdio.fd, }); ``` And when stdio is a stream object (like process.stderr), Node.js extracts the fd from it (lines 1056-1067) and uses it as a file descriptor, which means the output isn't captured in the result. ## Test plan Added comprehensive regression tests in `test/regression/issue/20321.test.ts` that cover: - process.stderr as stdout - process.stdout as stderr - All process streams in stdio array - Mixed stdio options - Direct file descriptor numbers - The exact AWS CDK use case All tests pass with the fix. 🤖 Generated with [Claude Code](https://claude.ai/code) --------- Co-authored-by: Claude Bot Co-authored-by: Claude Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- src/js/node/child_process.ts | 11 +++- test/regression/issue/20321.test.ts | 95 +++++++++++++++++++++++++++++ 2 files changed, 103 insertions(+), 3 deletions(-) create mode 100644 test/regression/issue/20321.test.ts diff --git a/src/js/node/child_process.ts b/src/js/node/child_process.ts index aa892ffe8c..510e4aca7b 100644 --- a/src/js/node/child_process.ts +++ b/src/js/node/child_process.ts @@ -550,11 +550,16 @@ function spawnSync(file, args, options) { stderr = null; } + // When stdio is redirected to a file descriptor, Bun.spawnSync returns the fd number + // instead of the actual output. We should treat this as no output available. + const outputStdout = typeof stdout === "number" ? null : stdout; + const outputStderr = typeof stderr === "number" ? null : stderr; + const result = { signal: signalCode ?? null, status: exitCode, // TODO: Need to expose extra pipes from Bun.spawnSync to child_process - output: [null, stdout, stderr], + output: [null, outputStdout, outputStderr], pid, }; @@ -562,11 +567,11 @@ function spawnSync(file, args, options) { result.error = error; } - if (stdout && encoding && encoding !== "buffer") { + if (outputStdout && encoding && encoding !== "buffer") { result.output[1] = result.output[1]?.toString(encoding); } - if (stderr && encoding && encoding !== "buffer") { + if (outputStderr && encoding && encoding !== "buffer") { result.output[2] = result.output[2]?.toString(encoding); } diff --git a/test/regression/issue/20321.test.ts b/test/regression/issue/20321.test.ts new file mode 100644 index 0000000000..6d7a500e30 --- /dev/null +++ b/test/regression/issue/20321.test.ts @@ -0,0 +1,95 @@ +import { expect, test } from "bun:test"; +import { spawnSync } from "child_process"; +import { bunEnv, bunExe, tempDirWithFiles } from "harness"; + +test("spawnSync should not crash when stdout is set to process.stderr (issue #20321)", () => { + // Test with process.stderr as stdout + const proc1 = spawnSync(bunExe(), ["-e", 'console.log("hello")'], { + encoding: "utf-8", + stdio: ["ignore", process.stderr, "inherit"], + env: bunEnv, + }); + + expect(proc1.error).toBeUndefined(); + expect(proc1.status).toBe(0); + // When redirecting to a file descriptor, we don't capture the output + expect(proc1.stdout).toBeNull(); +}); + +test("spawnSync should not crash when stderr is set to process.stdout", () => { + // Test with process.stdout as stderr + const proc2 = spawnSync(bunExe(), ["-e", 'console.log("hello")'], { + encoding: "utf-8", + stdio: ["ignore", "pipe", process.stdout], + env: bunEnv, + }); + + expect(proc2.error).toBeUndefined(); + expect(proc2.status).toBe(0); + expect(proc2.stdout).toBe("hello\n"); + // When redirecting to a file descriptor, we don't capture the output + expect(proc2.stderr).toBeNull(); +}); + +test("spawnSync should handle process.stdin/stdout/stderr in stdio array", () => { + // Test with all process streams + const proc3 = spawnSync(bunExe(), ["-e", 'console.log("test")'], { + encoding: "utf-8", + stdio: [process.stdin, process.stdout, process.stderr], + env: bunEnv, + }); + + expect(proc3.error).toBeUndefined(); + expect(proc3.status).toBe(0); + // When redirecting to file descriptors, we don't capture the output + expect(proc3.stdout).toBeNull(); + expect(proc3.stderr).toBeNull(); +}); + +test("spawnSync with mixed stdio options including process streams", () => { + // Mix of different stdio options + const proc4 = spawnSync(bunExe(), ["-e", 'console.log("mixed")'], { + encoding: "utf-8", + stdio: ["pipe", process.stderr, "pipe"], + env: bunEnv, + }); + + expect(proc4.error).toBeUndefined(); + expect(proc4.status).toBe(0); + // stdout redirected to stderr fd, so no capture + expect(proc4.stdout).toBeNull(); + // stderr is piped, should be empty for echo + expect(proc4.stderr).toBe(""); +}); + +test("spawnSync should work with file descriptors directly", () => { + // Test with raw file descriptors (same as what process.stderr resolves to) + const proc5 = spawnSync(bunExe(), ["-e", 'console.log("fd-test")'], { + encoding: "utf-8", + stdio: ["ignore", 2, "inherit"], // 2 is stderr fd + env: bunEnv, + }); + + expect(proc5.error).toBeUndefined(); + expect(proc5.status).toBe(0); + expect(proc5.stdout).toBeNull(); +}); + +test("spawnSync should handle the AWS CDK use case", () => { + // This is the exact use case from AWS CDK that was failing + const dir = tempDirWithFiles("spawnsync-cdk", { + "test.js": `console.log("CDK output");`, + }); + + const proc = spawnSync(bunExe(), ["test.js"], { + encoding: "utf-8", + stdio: ["ignore", process.stderr, "inherit"], + cwd: dir, + env: bunEnv, + }); + + expect(proc.error).toBeUndefined(); + expect(proc.status).toBe(0); + // Output goes to stderr, not captured + expect(proc.stdout).toBeNull(); +});