node:child_process: keep the timeout kill timer ref'd and arm one per call

RO
12d ago
+279-3

Maintainer request, no issue.

Problem

  • After child.unref(), a timeout does not keep the parent alive: it can exit and never send killSignal. Node waits and signals.
  • exec/execFile signal twice at a deadline.
  • Cause: both kill timers are unref'd where armed (src/js/node/child_process.ts:181, :372). execFile() also passes timeout to spawn() (:266).

Fix

  • Keep both timers ref'd while the child runs. execFile() no longer passes timeout to spawn(): one timer, one signal (as #37186).
  • Timers use native functions, not the globals: a replaced clearTimeout cannot leave one armed. unseen-code.builtins.ts classifies both for --aot.
  • Verified: test/js/node/child_process/child_process.test.ts (options.timeout: 13 of 18 cases fail on main, each reverted clause fails one). Also 18 Node test-child-process-* scripts.
  • Self-reviewed: 8 concerns raised, 6 addressed. Rejected: a fork-and-disconnect case (moot), todo cases for #41632, #45662 (their PRs).

Background

  • timeout is a JS timer that calls child.kill(): one in spawn() (spawn, fork), one in execFile() (execFile, exec). Unref'd timers do not hold the loop.
  • Node keeps both ref'd until 'close'. Bun can lose 'close': at 'exit' spawn() clears its timer, execFile() unrefs its own.
  • Considered: the .unref() deletions alone (#35805), the native Bun.spawn timeout (child.killed stays false).

Downsides

  • A parent stays alive until its timeout child exits or the deadline.
  • After an early 'error', exec/execFile send no signal at the deadline (main sent one, Node none).
  • trace_events sees 0 events for this timer (main 2). First require: +518 heap bytes. Binary: +4,096 bytes.
Notes

Where this comes from

  • A maintainer asked for it on 2026-07-25. No user has reported it and there is no issue.
  • #35805 had the two .unref() deletions. The stale sweep closed it after 91 days with no review. #35808 had the same source diff.
  • #37186 (open) removes the timeout forward and the killSignal forward from execFile(). This PR has the first of those deletions, and the first commit credits its author. The killSignal line stays here, see "Not changed here".

Repro (the one from the request)

import { spawn } from "node:child_process";
const t0 = Date.now();
const c = spawn("sh", ["-c", "trap 'echo TERMED; exit' TERM; echo READY $$; while :; do sleep 0.1; done"],
                { stdio: ["ignore", "inherit", "inherit"], timeout: 500 });
c.unref();
process.on("exit", () => console.log("parent exiting at", Date.now() - t0, "ms"));
  • Node v26.12.1: READY 22676, TERMED, parent exiting at 506 ms.
  • main (179664ea89, release build): READY 22685, parent exiting at 5 ms. The child still runs.
  • This PR (release build): READY 14975, parent exiting at 506 ms, TERMED.

Every entry point, { timeout: 500 } then child.unref(), the child is an sh loop that dies of SIGTERM. Parent lifetime, and the child 1.2 s later:

casemainthis PRNode v26.12.1
spawn, stdio ignore2 ms, alive505 ms, killed508 ms, killed
spawn, default pipes, nothing reads them20 ms, alive516 ms, killed514 ms, killed
spawn, child closes stdout and stderr13 ms, alive517 ms, killed507 ms, killed
execFile, child keeps its pipes open517 ms, killed516 ms, killed508 ms, killed
execFile, child closes stdout and stderr29 ms, alive513 ms, killed506 ms, killed
exec, child keeps its pipes open517 ms, killed514 ms, killed506 ms, killed
exec, child closes stdout and stderr16 ms, alive513 ms, killed508 ms, killed
fork, stdio ignore10 ms, alive505 ms, killed520 ms, killed
  • On main, exec and execFile deliver the signal only while the child's pipes keep the parent's event loop alive.
  • For exec, the process that gets the signal is the shell, here and in Node. The table's sh loop is that shell.
  • The rows with default pipes and with fork equal Node because the child dies of the signal. When it survives, Node keeps waiting on the pipes or the channel and Bun does not. That is #41632 (pipes, since #34971) and #23686 / #45662 (channel), not this timer.

The double signal. A child that exits 0 at 300 ms after its first SIGTERM, under execFile(..., { timeout: 600 }): on main it died of a second SIGTERM in 2 of 6 runs. With this PR and on Node, 0 of 6. kill(2) calls to the child at one deadline: main 2, this PR 1, Node 1 (exec and execFile, 5 of 5 runs, counted with an LD_PRELOAD shim because strace is not on this machine).

The edits and the Node source (lib/child_process.js at v26.12.1)

  • Ref'd timers: spawn() L863-L872, execFile() L458-L463. Both .unref() calls came from #8456.
  • No timeout from execFile() to spawn(): L352-L361. The forward came from #10193.
  • Timer functions that are not the globals: Node takes them from require('timers') at L76. Here they are $newCppFunction bindings of the two host functions behind the globals. #37987 does the same for every built-in module through internal/timers, and its hunk for this file uses the same two names.

The one difference from Node, kept on purpose: what happens at 'exit'

  • Node keeps both timers ref'd until 'close' (spawn L874-L880, execFile in its exithandler). In every order I ran, Node emitted 'close' after 'exit'.
  • Bun can emit 'exit' with no 'close' after it, for example when an 'exit' listener leaves a pipe paused. #45033 and #44849 work on that. A timer that stays ref'd until 'close' then holds the process until the deadline.
  • spawn() keeps main's clear on 'exit' and on 'close'. With stdout paused in an 'exit' listener and timeout: 2000, a 'close'-only clear kept the parent 2021 ms. Main 33 ms, this PR 21 ms, Node 5 ms with 'close'.
  • execFile() keeps its timer armed after 'exit', because the deadline also ends the wait for 'close' when a grandchild holds the pipes. It only unrefs the timer at 'exit'.
    • Shell exits at once, a grandchild holds the pipes 3 s, timeout: 500: the callback runs at 514 ms (main 516 ms, Node 507 ms). A clear on 'exit' would move it to 3 s.
    • Shell exits at once, a grandchild holds the pipes 0.3 s, an 'exit' listener pauses both pipes, timeout: 2000: the parent exits at 305 ms with no callback, as on main (305 ms). Node calls back at 307 ms. With the timer still ref'd after 'exit' the parent waited 2013 to 2033 ms.

What earlier versions of this branch got wrong (all three reproduced before the change)

  • The first version took setTimeout/clearTimeout from the globals when the module loaded. If the module first loaded while a fake held the globals, timeout never fired again: the child ran its full 2 s (main 314 ms, Node 305 ms, now 306 ms). Found in self-review.
  • It also cleared spawn()'s timer on 'close' only. Found in self-review.
  • The first pushed commit left execFile()'s timer ref'd after 'exit'. Found in review of the PR. The second commit is the fix.

After the merge of main (11c367163a)

  • Main now has bun build --compile --aot (#45631). Its build step asks of each native function that a built-in module binds whether it can run code that the compiler has not seen.
  • This PR binds functionSetTimeout and functionClearTimeout in node:child_process. seenCodeEntries in src/codegen/builtins/host/unseen-code.builtins.ts lists both. Without the two rows the step stops: src/js: native:NewCppFunction:node/NodeTimers.cpp:functionSetTimeout runs native code nobody has classified: declare it, or list its holder or file in host/unseen-code.builtins.ts.
  • Neither function runs such code. functionSetTimeout refuses a callback that is not callable (src/jsc/bindings/node/NodeTimers.cpp:49), and functionClearTimeout only calls Bun__Timer__clearTimeout. No feature that --trim can remove holds timers or warnings (src/trim/features/*.def).
  • The rows give no signature and no feature, and the step asks for none: it passes with the two rows alone. The same file classifies the native functions of 31 other files in bulk (nativeFiles).
  • A program built with --compile --aot prints the same as under bun run, with timeout: 300 and with timeout: 2 ** 31: spawn and execFile kill the child with SIGTERM, and the second prints TimeoutOverflowWarning and runs the program's 'warning' listener.
  • 16 files of test/aot that read the table or load built-in modules, on a release build of this head with --timeout=90000: 284 pass, 0 fail. --trim did not run: a local build has no trim table.
  • Release builds of 11c367163a and of this head: 13 of the 18 options.timeout cases fail on main, all 18 pass here. The repro prints parent exiting at 4 ms on main, and parent exiting at 509 ms then TERMED here.

Tests

  • options.timeout is 18 cases, each a process of its own that runs fixtures/child-process-timeout-fixture.js.
  • Release build of main: 13 of 18 fail. The 5 that pass are guards: a missing file, the two 'exit' listener cases, no timeout, and a disposed Bun.ModuleGraph.
  • Release build of this PR: 18 pass in 3.3 s. Node v26.12.1 gives the expected value for each of the 17 cases it can run.
  • One clause reverted at a time, then bun bd test -t options.timeout: .unref() on spawn()'s timer, 6 cases fail. .unref() on execFile()'s timer, 4. The timeout forward, 4. spawn()'s 'exit' clear removed, 1. execFile()'s unref at 'exit' removed, 1. The setTimeout binding removed, 2. The clearTimeout binding removed, 2. Each of the 4 timer call sites switched to the global, 1 each.
  • Five of those fail by the test timeout: the parent waits for a deadline that it had to clear. That deadline is 600 s, longer than the test timeout of every CI lane (90 s, and 270 s on ASAN, scripts/runner.node.ts).
  • Two gaps in the first four commits, both closed by the fifth:
    • The deadline was 60 s. With the clearTimeout binding removed and a 90 s test timeout, "spawn, timers replaced after the module loaded" passed after 60.8 s. Now it fails at 90 s.
    • No case failed when only execFile()'s clear used the global, though these notes said that one did. The unref at 'exit' from the second commit hid that clear. "execFile of a missing file, timers replaced after the module loaded" is the case for it: no 'exit' comes there.
  • On Windows the exec case runs a child that ends by itself after 600 ms. cmd.exe cannot exec, so the signal ends the shell only.
  • The cases run one at a time. Each is two processes, and a debug build that started all of them together took 4988 ms for the slowest, against a 5000 ms test timeout.
  • test-child-process-spawn-timeout-clear-on-error.js is copied from Node v26.12.1 with no edit. It passes on main and with this PR.
  • Node scripts that exit 0 with this PR: test-child-process- spawn-timeout-kill-signal, fork-timeout-kill-signal, exec-timeout-expire, exec-timeout-kill, exec-timeout-not-expired, spawn-timeout-clear-on-error, exec-kill-throws, detached, fork-detached, exec-abortcontroller-promisified, execfile, promisified, spawn-controller, fork-abort-signal, exec-maxbuf, exec-any-shells-windows, kill. bad-stdio uses node:test: under bun test its 3 tests pass, here and on main.
  • Also child_process-node.test.js (30 pass, 4 todo) and child-process-exec.test.ts (11 pass).
  • All of child_process.test.ts on a release build of this head: 102 pass, 1 fail (the fd limit case below, which fails on main too). On a debug build before the merge: 101 pass, 2 fail. The same 2 fail without this change. "spawn still works with more than 10240 fds open" fails on this machine, whose fd limit is 4096. "extra stdio pipes are not double-closed on GC" reaches the 5 s timeout. In one of two runs, three concurrent cases that pass no timeout also reached the 5 s timeout (the two destroy() inside 'data' cases and one maxBuffer case). They passed in the other run.
  • I ran Linux x64 only. I cannot run the macOS and Windows lanes here.

Measurements (main is 179664ea89, the base before the merge, release builds unless a line says debug)

  • No-timeout path: spawn() +0 bytecode ops, execFile() -3 ops per call (BUN_JSC_dumpGeneratedBytecodes, debug). execFile() also skips one Number.isInteger call in spawn().
  • kill() calls per call with a timeout: spawn 1, fork 1, execFile 1, exec 1 (main 1, 1, 2, 2).
  • Cells kept alive per call with a timeout (bun:jsc heapStats, slope from 20 to 40 live children): execFile Timeout 1, Function 18, Array 15 (main 2, 20, 16). spawn unchanged (Timeout 1, Function 6). Without a timeout: unchanged.
  • Listeners exit/close/error left on the child: spawn 1/1/0 (main 1/1/0, Node 0/1/0), execFile 1/1/1 (main 1/2/1, Node 0/1/1).
  • Event loop ref and unref transitions per process (BUN_DEBUG_Loop=1, debug): execFile with a timeout 12 (main 14). spawn with a timeout 9 (main 9). Without a timeout: 7 and 10 on both.
  • First require of node:child_process: +4 cells and +518 bytes of heap, for the two function objects.
  • Bundled module 40,674 bytes (main 40,624). Binary text 107,556,836 bytes (main 107,556,528). Host functions +0, generated js2native entries +2.
  • After the merge (main is 11c367163a): the binary is 4,096 bytes larger, 138,197,144 against 138,193,048. .rodata +4,096 (one page), .bun_builtins +52, .text -64. Rows in the table of built-ins: +2.
  • trace_events Timeout events for one kill timer: 0 (main 2), whether tracing starts before or after the module loads. trace_events counts timers by wrapping the globals.

What changes for a caller, beyond the kill

  • Child exits first, a grandchild keeps its stdout open 6 s, timeout: 2000, child.unref(): the parent hears 'exit' and 'close' and lives 6005 ms (main 13 ms and no event, Node 6005 ms).
  • The same with child.stdout.unref() and child.stderr.unref(): the parent leaves at 'exit', 20 ms (main 9 ms). Node waits for the deadline, 2009 ms, and calls kill() on the dead child.
  • The user removes every 'close' listener, timeout: 1500: 5 to 15 ms (main 5 to 15 ms). Node waits 1504 to 1517 ms.
  • Not held, 10 runs each, same as Node: a global clearTimeout replaced after spawn(), a missing file, and execFile + abort + unref with a child that ignores SIGTERM (111 to 125 ms).
  • With both timers ref'd and the timeout forward kept, that last order waited for the deadline and called kill() twice (2143 to 2437 ms on a debug build). This is why the forward goes.
  • With 'error' at 100 ms, timeout: 1000 and a 2.5 s child under execFile: main signalled at 1014 ms through the forwarded timer. This PR and Node do not, and the child exits 0 at 2504 ms and 2505 ms.
  • A global setTimeout that returns a number (a fake-timers library): on main spawn() with a timeout threw a TypeError after the child had started. Now it returns the child, as Node does.
  • node:test mock.timers no longer reaches the kill timer: with the mock on, the kill lands at 305 ms (Node 309 ms). On main the child ran its full 2 s. bun:test fake timers still reach it (#37987).
  • A child that a Bun.ModuleGraph spawned with a timeout does not keep the host after graph.dispose(): 11 ms with a 4000 ms timeout.

Not changed here

  • exec/execFile with { signal, killSignal: "SIGKILL" }: on abort Node sends SIGTERM, Bun sends SIGKILL, because execFile() still passes killSignal to spawn(). #37186 removes that line.
  • src/js/node/net.ts:3554 unrefs the autoSelectFamily attempt timer. Node keeps it ref'd (lib/net.js#L1576). I found no case where that changes behaviour.

no test proof · iteration 3 · 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