Skip to content

child_process: match node's option validation for uid, gid, timeout, maxBuffer and windowsHide - #44967

Open
robobun wants to merge 1 commit into
mainfrom
robobun/49922e3f/child-process-validators
Open

robobun wants to merge 1 commit into
mainfrom
robobun/49922e3f/child-process-validators

Conversation

@robobun

@robobun robobun commented Oct 11, 2026 •

Copy link
Copy Markdown
Collaborator

Supersedes #33716.

Problem

  • node:child_process throws the wrong error for a bad uid, gid, timeout or maxBuffer. spawnSync(cmd, { uid: NaN }) throws TypeError [ERR_INVALID_ARG_TYPE]: The "options.uid" property must be of type int32. Node 26.3.0 throws RangeError [ERR_OUT_OF_RANGE].
  • timeout: "1" and maxBuffer: "1" throw ERR_OUT_OF_RANGE. Node throws ERR_INVALID_ARG_TYPE. execFile(f, { windowsHide: 1 }) throws. Node runs the child.
  • The cause is hand-written checks in src/js/node/child_process.ts (:989, :1935, :1953) that node replaced in lib: use validators for argument validation nodejs/node#59416 (v25.0.0). No user reported this.

Fix

  • The checks now call validateInt32, validateInteger and validateNumber, as node's lib/child_process.js does. Class and code now match node.
  • execFile passes !!options.windowsHide and !!options.windowsVerbatimArguments to spawn, as node does. A null byte in cwd gives node's message.
  • Verified: 32 new cases in test/js/node/child_process/child_process.test.ts fail on 1.4.3-canary and pass here. The four node tests for these errors are now node's v26.3.0 files.
  • Self-reviewed: 14 concerns raised, 14 addressed, but I opened no tracking issue (Numeric options accept NaN and silently use a placeholder (Bun.spawn maxBuffer, validate_integer_range callers) #44407 reserves issues for user reports).

Background

  • All seven spawn functions validate options in child_process.ts, before a process exists.
  • internal/validators holds Bun's native ports of node's validators (src/jsc/bindings/NodeValidator.cpp).
  • Considered uid and gid only: timeout and maxBuffer stay wrong, and one node test matches no node version.

Downsides

  • Code that matches on the old error takes a different branch, as in node v25.
  • An integer timeout above 2^53 - 1 throws. Before, a timer killed the child at once, or spawnSync returned result.error.
  • Valid options: exec and execFile make 3 native validator calls (2 Number.isInteger calls before), the sync functions 1 (0 before). Allocations: 0.
Notes

Errors, node 26.3.0 and Bun. Each of the seven functions (spawn, spawnSync, exec, execSync, execFile, execFileSync, fork) gives the same result unless a row says otherwise. gid behaves like uid. "Before" is bun 1.4.3-canary (367d939). After this change Bun prints the node column.

option       value                        node 26.3.0                                                              bun before
uid          NaN, 1.5, ±Infinity          RangeError ERR_OUT_OF_RANGE     It must be an integer.                   TypeError ERR_INVALID_ARG_TYPE  must be of type int32
uid          2**31, 2**32, 2**63          RangeError ERR_OUT_OF_RANGE     It must be >= -2147483648 && <= 2147483647.   TypeError ERR_INVALID_ARG_TYPE  must be of type int32
uid          "0", true, {}, [], function  TypeError ERR_INVALID_ARG_TYPE  must be of type number.                  TypeError ERR_INVALID_ARG_TYPE  must be of type int32
uid          0n, Symbol()                 TypeError ERR_INVALID_ARG_TYPE  must be of type number.                  TypeError with no code, thrown by `value | 0`
timeout      "0", true, {}, [], 0n        TypeError ERR_INVALID_ARG_TYPE  must be of type number.                  RangeError ERR_OUT_OF_RANGE  It must be an unsigned integer.
timeout      NaN, 1.5, ±Infinity          RangeError ERR_OUT_OF_RANGE     It must be an integer.                   RangeError ERR_OUT_OF_RANGE  It must be an unsigned integer.
timeout      -1                           RangeError ERR_OUT_OF_RANGE     It must be >= 0 && <= 9007199254740991.  RangeError ERR_OUT_OF_RANGE  It must be an unsigned integer.
timeout      2**53                        RangeError ERR_OUT_OF_RANGE     It must be >= 0 && <= 9007199254740991.  async: child starts, killed after about 100 ms. spawnSync: result.error
maxBuffer    "0", true, {}, [], 0n        TypeError ERR_INVALID_ARG_TYPE  must be of type number.                  RangeError ERR_OUT_OF_RANGE  It must be a positive number.
maxBuffer    NaN, -1, -Infinity           RangeError ERR_OUT_OF_RANGE     It must be >= 0.                         RangeError ERR_OUT_OF_RANGE  It must be a positive number.
windowsHide  "yes", 1 (exec, execFile)    accepted, the child runs                                                 TypeError ERR_INVALID_ARG_TYPE  must be of type boolean
cwd          "a\0b"                       ... must be a string, Uint8Array, or URL without null bytes.             ... must be a string or Uint8Array without null bytes.

maxBuffer is an option of exec, execFile and the three sync functions only. The windowsHide row is the same for windowsVerbatimArguments. The five other functions still reject a non-boolean there, as node does. The cwd text is also the text for the module path of fork.

Scope of "matches node". The error class and code match node for every value I ran. The message text matches for numbers and for the values in the table. The Received ... part comes from the shared formatter (determineSpecificType, src/jsc/bindings/ErrorCode.cpp). It still differs from node for a few unusual values: a string that has ' and a control character, a long string with a late ', an object whose constructor has no name, a bound function. #38642 and #38473 change that formatter.

Measurements (Linux x64, debug build of this branch, node 26.3.0 as the reference):

  • A matrix of 436 rows that node rejects, for uid, gid, timeout and maxBuffer (7 functions x 19 uid values, the same for gid, 7 x 15 timeout values, 5 x 11 maxBuffer values, 4 rows with a counting valueOf, 5 rows with two bad options). 436 rows differ from node before. 0 rows differ after.
  • A second table of 616 rows that also has accepted values. Exactly 4 accept or reject verdicts change: timeout: 2 ** 53 on spawn, execFile, exec and fork. No other accepted value is rejected now. The new tests pin the accepted limits: -(2 ** 31) and 2 ** 31 - 1 for the ids, 2 ** 53 - 1 for timeout.
  • The four node tests: exit 0 with the fix as root and as uid 65534 (8 runs), exit 0 on node 26.3.0 (8 runs), exit 1 on bun 1.4.3-canary (4 runs). Before this change the four files were byte-identical to node v24.3.0. Now their git blobs equal node v26.3.0: 2353e7594f35, f7554e4484b4, b777390156be, 27df4e678570. The two *-timeout-kill-signal.js files also take node's switch to assert. and events.listenerCount.
  • bun run lint: 0 errors. tsc --noEmit -p src/js/tsconfig.json: exit 0. The 32 new cases also pass with BUN_JSC_validateExceptionChecks=1.
  • test/js/node/child_process/child_process.test.ts with --timeout 120000: 115 pass, 1 fail. The failure is spawn still works with more than 10240 fds open. It fails on the unfixed release build here too, because the hard fd limit of my container is 4096. With the default timeout, some older cases that start several debug-build children exceed 5 s on my machine.
  • I ran nothing on macOS or Windows. The new cases have no platform gate, so CI runs them there.

Cost for callers with valid options.

  • Native validator calls per call, counted from the source. spawn and fork with no uid, gid or timeout: 0 before, 0 after. exec and execFile (defaults timeout: 0, maxBuffer: 1048576): 2 validateInteger and 1 validateNumber, where they made 2 Number.isInteger calls. spawnSync, execFileSync and execSync: 1 validateNumber, 0 before. A valid uid or gid: 1 validateInt32 each, which returns at the int32 tag check.
  • Allocations: none. BUN_JSC_logGC=1 logs 0 collections during 3,000,000 accepted calls of each validator with these argument shapes (reached through EventEmitter#setMaxListeners, TLSSocket#setMaxSendFragment and stream.setDefaultHighWaterMark on the release build). A control that allocates one object per call logs 49.
  • Binary: the embedded source of node:child_process shrinks by 284 bytes (40610 to 40326, production output of src/codegen/bundle-modules.ts). No native code changes, and no host function is added.
  • perf, valgrind and strace are not installed where I run, so I have no instruction or syscall counts.
  • Also weighed: an inline accept test in front of each validator, so that valid options make no native call. It keeps a second copy of each rule, and node has no such test.

Still different from node, not changed here.

  • uid: -0 and gid: -0 are accepted and mean id 0. Node's validateInt32 accepts -0 too. Node then rejects it in C++: spawnSync throws options.uid must be a 32-bit signed integer (src/spawn_sync.cc:860), and spawn aborts the process (CHECK(uid_v->IsInt32()), src/process_wrap.cc:220). A test pins that -0 behaves like 0.
  • A timeout from 2 ** 31 to 2 ** 53 - 1 passes the validator, as in node. The sync functions then report ETIMEDOUT at once, because the native parser of Bun.spawnSync keeps only 31 bits of the value (src/runtime/api/bun/js_bun_spawn_bindings.rs:764). Node runs the child. spawn: honor timeouts of 2**31 ms or more instead of truncating them to 31 bits #37604 changes that parser.
  • spawnSync(cmd, { maxBuffer: 0.5 }) sets no limit. Node reports ENOBUFS. The same native parser truncates the value to 0 (js_bun_spawn_bindings.rs:785).
  • new ChildProcess().spawn(options) skips these checks, as in node. Bun.spawn: validate uid and gid as node does instead of treating NaN as id 0 #39682 covers its uid and gid.
  • A bad stdio value throws ERR_INVALID_OPT_VALUE. Node throws ERR_INVALID_ARG_VALUE (child_process: throw ERR_INVALID_ARG_VALUE for invalid stdio values #35226).
  • On Windows, spawn and fork of a .bat or .cmd file without shell throw EINVAL before they validate timeout (read from child_process.ts:159, not run). Node validates first. child_process: match Node when spawnSync rejects a Windows batch file #43706 changes the spawnSync side.
  • execFile passes timeout and killSignal to spawn, so validateTimeout runs twice and two kill timers exist. Node's execFile passes neither.
  • Other differences from node's lib/child_process.js that are not option validation: exec("exit 3") gives the message Command failed: exit 3 with no trailing newline, env: { A: Symbol() } does not throw, exec does not call module.exports.execFile, the promisified exec has no name, and shell: true with arguments prints no DEP0190 warning.

Related pull requests.


no test proof · iteration 0 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/js/node/child_process/child_process.test.ts

…maxBuffer and windowsHide

normalizeSpawnArguments, validateTimeout and validateMaxBuffer carried the
checks that node removed in nodejs/node#59416 (v25.0.0). Each check built
one error for every bad value, so the error did not match node 26:

- uid and gid: NaN, a fraction, Infinity or a value outside int32 threw
  ERR_INVALID_ARG_TYPE. A BigInt or a Symbol threw a TypeError with no code.
- timeout and maxBuffer: a value that is not a number threw
  ERR_OUT_OF_RANGE.

The three checks now call validateInt32, validateInteger and validateNumber
from internal/validators, as node does. The error class and code now match
node. The message matches too, except for the "Received ..." text that the
shared error formatter prints for some unusual values.

Two more differences on the same path:

- execFile() now passes `!!options.windowsHide` and
  `!!options.windowsVerbatimArguments` to spawn(), as node does. exec() and
  execFile() threw for a value that is not a boolean. Node accepts it.
- A null byte in `cwd` or in the module path of fork() now gives node's
  text: "must be a string, Uint8Array, or URL without null bytes".

The four node tests that pin the uid, gid, timeout and maxBuffer errors are
replaced with their v26.3.0 versions.
@robobun

robobun commented Oct 11, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 2:28 AM PT - Oct 11th, 2026

✅ @robobun, your commit 16b6b4ba0a2152215de6a04f572ab5540345395b passed in Build #124500! 🎉


🧪   To try this PR locally:

bunx bun-pr 44967

That installs a local version of the PR into your bun-44967 executable, so you can run:

bun-44967 --bun

@robobun

robobun commented Oct 11, 2026

Copy link
Copy Markdown
Collaborator Author

Status

Reproduced on bun 1.4.3-canary (367d939), Linux x64, against node 26.3.0:

const cp = require("child_process");
for (const options of [{ uid: NaN }, { gid: 1.5 }, { timeout: "1" }, { maxBuffer: "1" }]) {
  try {
    cp.spawnSync(process.execPath, ["-e", "0"], options);
  } catch (e) {
    console.log(e.name, e.code);
  }
}

Node prints RangeError ERR_OUT_OF_RANGE two times, then TypeError ERR_INVALID_ARG_TYPE two times. Bun 1.4.3-canary prints the two pairs in the opposite order. A build of this pull request (#44967) prints what node prints.

@coderabbitai

coderabbitai Bot commented Oct 11, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration
  • Configuration used: Repository: oven-sh/bun/.coderabbit.yaml
  • Review profile: ASSERTIVE
  • Plan: Essentials
  • Run ID: 06c8d8f3-5b4e-49f8-8b3c-f32a6cab9f39



📥 Commits

Reviewing files that changed from the base of the PR and between 6c1eb06 and 16b6b4b.




📒 Files selected for processing (6)
  • src/js/node/child_process.ts
  • test/js/node/child_process/child_process.test.ts
  • test/js/node/test/parallel/test-child-process-fork-timeout-kill-signal.js
  • test/js/node/test/parallel/test-child-process-spawn-timeout-kill-signal.js
  • test/js/node/test/parallel/test-child-process-spawn-typeerror.js
  • test/js/node/test/parallel/test-child-process-spawnsync-validation-errors.js



Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 7 remain after this review.





Walkthrough

Child-process option validation now uses shared validators for UID, GID, timeout, and maxBuffer. Exec option coercion and null-byte error text changed. Tests cover validation results, error types, accepted values, and validation order.

Changes

Child process validation

Layer / File(s) Summary
UID and GID validation
src/js/node/child_process.ts, test/js/node/child_process/child_process.test.ts, test/js/node/test/parallel/test-child-process-spawn-typeerror.js, test/js/node/test/parallel/test-child-process-spawnsync-validation-errors.js
uid and gid now use the shared int32 validator. Tests check valid int32 values, null and undefined, and errors for invalid values across child-process APIs.
Timeout and maxBuffer validation
src/js/node/child_process.ts, test/js/node/child_process/child_process.test.ts, test/js/node/test/parallel/test-child-process-*-timeout-kill-signal.js, test/js/node/test/parallel/test-child-process-spawnsync-validation-errors.js
timeout and maxBuffer now use shared validators. Tests check value types, limits, error precedence, kill signals, and abort-listener counts.
Exec options and path errors
src/js/node/child_process.ts, test/js/node/child_process/child_process.test.ts
execFile boolean-coerces windowsHide and windowsVerbatimArguments. The null-byte error description includes URL. Tests check option coercion and null-byte errors.

Priority: ➖ Normal

Merge Risk: ⚪ Minimal · up to 16b6b

No concrete issue remains that would block merging after normal checks.

Pre-merge checks | Passed 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check Passed Check skipped because no linked issues were found for this pull request.
Title check Passed The title clearly summarizes the main change: aligning child_process option validation with Node for uid, gid, timeout, maxBuffer, and windowsHide.
Description check Passed The description is detailed and directly addresses the change, implementation, verification results, limitations, and related pull requests. It does not use the exact template headings, but it provide…

  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Comment @coderabbitai help to get the list of available commands.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

One part of this review's analysis that stopped early was run once more. It finished.

Additional findings (outside the current diff — GitHub can't attach inline comments there):

  • 🟣 src/js/node/child_process.ts — Windows callers running a .bat or .cmd file with an invalid timeout or maxBuffer still get a silent result.error EINVAL instead of node's thrown TypeError, so the error contract this PR advertises is not delivered to them. spawnSync returns early on options.windowsBatchFileError at src/js/node/child_process.ts:531-549 before validateTimeout at :557 and validateMaxBuffer at :560 run. Node validates timeout and maxBuffer in JS before the batch-file EINVAL is produced, so node throws. Fix: run validateTimeout and validateMaxBuffer before the windowsBatchFileError early return in spawnSync, and check the same ordering in the other sync entry points.

    Why this was flagged

    On Windows, spawnSync("x.bat", { timeout: "x" }) or { maxBuffer: "x" } without shell. normalizeSpawnArguments sets options.windowsBatchFileError and spawnSync returns the EINVAL SystemError at src/js/node/child_process.ts:531-549 before validateTimeout (:557) and validateMaxBuffer (:560) are reached, so the caller gets { error: EINVAL, status: null } rather than a thrown ERR_INVALID_ARG_TYPE. In node the JS validators run before the spawn_sync binding returns EINVAL for a batch file, so node throws. The async spawn path does throw windowsBatchFileError at :162 before validateTimeout at :163 as well. The ordering predates the diff, but the PR's stated goal is to match node's error contract for timeout and maxBuffer on all seven functions, and this Windows population does not get it; the new tests use bunExe() (.exe) so they never exercise it. Remedy: validate before the batch-file early return.

    Verification: pre-existing. Triggering condition: on Windows, spawnSync() called with a .bat/.cmd file, no shell, and an invalid timeout or maxBuffer. In src/js/node/child_process.ts, spawnSync returns the EINVAL result at :531-549 before validateTimeout at :557 and validateMaxBuffer at :560 execute. The diff has no hunk touching :531-560, so the base already masks the validation error by the same route.

it("exec() and execFile() coerce windowsHide and windowsVerbatimArguments, the other functions validate them", async () => {
const options: any = { windowsHide: "yes", windowsVerbatimArguments: 1 };
// node's execFile() passes `!!options.windowsHide` to spawn(), so these two run the child.
expect(await promisify(execFile)(bunExe(), ["-e", "0"], options)).toEqual({ stdout: "", stderr: "" });

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 nit (optional): maintainers get a test that can fail on CI agents where ad-hoc JSC or BUN_DEBUG_* env vars are set, instead of a reliable assertion. The new test at test/js/node/child_process/child_process.test.ts:1587 runs a Bun child through execFile(bunExe(), ...) without env: bunEnv and asserts stderr is exactly "". Fix: spread bunEnv into the options of every bunExe() child in the new block (lines 1351-1354 and 1587), as test/CLAUDE.md requires, so stderr stays empty regardless of the agent's environment.

Why this was flagged

The test at test/js/node/child_process/child_process.test.ts:1587 calls promisify(execFile)(bunExe(), ["-e", "0"], { windowsHide: "yes", windowsVerbatimArguments: 1 }) with no env, so the child inherits the runner's process.env. test/harness.ts:66-72 documents that bunEnv strips JSC_useJIT and sets BUN_DEBUG_QUIET_LOGS because those leak a "WARNING: failed to parse" or debug line to stderr on CI agents. With the inherited env the child can print to stderr, and the assertion toEqual({ stdout: "", stderr: "" }) fails. test/CLAUDE.md says to use bunExe and bunEnv together when spawning Bun; the sibling table at lines 1351-1354 has the same omission, though those children are killed or throw before output is asserted. The base branch has no such test, so this is a new flake source rather than a runtime defect.

Verification: nit — triggers when the runner's environment lacks BUN_DEBUG_QUIET_LOGS=1 or carries an ad-hoc BUN_DEBUG_/JSC_* variable. The new test at test/js/node/child_process/child_process.test.ts:1587 asserts stderr: "" on an execFile(bunExe()) child with no env, so it inherits the runner's environment and visible debug-build loggers print. The CI runner does NOT strip BUN_DEBUG_/JSC_ keys.

if (timeout != null && !(NumberIsInteger(timeout) && timeout >= 0)) {
throw $ERR_OUT_OF_RANGE("timeout", "an unsigned integer", timeout);
if (timeout != null) {
validateInteger(timeout, "timeout", 0);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟣 pre-existing, not blocking: Callers who pass a timeout between 231 and 253-1 get a child killed at once or after the wrong delay, and the PR now certifies those values as valid. validateInteger at src/js/node/child_process.ts:1956 admits them, and spawnSync forwards them unchanged at :611. Bun.spawnSync then truncates with (timeout_int as u32) & 0x7FFF_FFFF (src/runtime/api/bun/js_bun_spawn_bindings.rs:764), so 231 becomes 0 and the child is killed immediately. Fix: either cap the accepted range at the value the backend honours or widen the binding to a 64-bit timer so every value the validator accepts is honoured; the new test pins 231 and 2**53-1 as accepted without checking the timer.
A small fix can ride a push you are already making; otherwise a short reply is enough.

Why this was flagged

spawnSync(cmd, { timeout: 2 ** 31 }) or any value in 231..253-1 from a user script. validateInteger(timeout, "timeout", 0) at src/js/node/child_process.ts:1956 accepts it (max defaults to maxSafeInteger, NodeValidator.cpp:52). spawnSync passes it to Bun.spawnSync at src/js/node/child_process.ts:611. The binding does validate_integer_range::<u64> then (timeout_int as u32) & 0x7FFF_FFFF at src/runtime/api/bun/js_bun_spawn_bindings.rs:762-766, so 231 -> Some(0) -> user_timespec = now (js_bun_spawn_bindings.rs:1797) -> the child is killed immediately; 232+5000 silently becomes 5000 ms. Node's spawnSync honours an int64 timeout. Async spawn hits setTimeout at :174 with the same values. The truncation predates the diff, but this PR is the one that defines the accepted range as 0..2**53-1 and adds a test (child_process.test.ts, expectAccepted("timeout", [..., 2 ** 31, 2 ** 53 - 1, ...])) that only checks ENOENT, so the broken range is now pinned as correct. The remedy is to make the accepted range and the honoured range the same.

Verification: pre-existing — triggered by spawnSync with timeout in [231, 253-1]. src/js/node/child_process.ts:1956 admits it; src/runtime/api/bun/js_bun_spawn_bindings.rs:764 masks & 0x7FFF_FFFF, so 2**31 becomes 0 and the child is killed at once. The base validator had no upper bound and fails identically. The new test at test/js/node/child_process/child_process.test.ts:1570 pins these as accepted.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant