node:child_process: keep the timeout kill timer ref'd and arm one per call
Maintainer request, no issue.
Problem
- After
child.unref(), atimeoutdoes not keep the parent alive: it can exit and never sendkillSignal. Node waits and signals. exec/execFilesignal twice at a deadline.- Cause: both kill timers are unref'd where armed (
src/js/node/child_process.ts:181,:372).execFile()also passestimeouttospawn()(:266).
Fix
- Keep both timers ref'd while the child runs.
execFile()no longer passestimeouttospawn(): one timer, one signal (as #37186). - Timers use native functions, not the globals: a replaced
clearTimeoutcannot leave one armed.unseen-code.builtins.tsclassifies 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 Nodetest-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
timeoutis a JS timer that callschild.kill(): one inspawn()(spawn, fork), one inexecFile()(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 nativeBun.spawntimeout (child.killedstays false).
Downsides
- A parent stays alive until its
timeoutchild exits or the deadline. - After an early 'error',
exec/execFilesend 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
timeoutforward and thekillSignalforward fromexecFile(). This PR has the first of those deletions, and the first commit credits its author. ThekillSignalline 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:
| case | main | this PR | Node v26.12.1 |
|---|---|---|---|
| spawn, stdio ignore | 2 ms, alive | 505 ms, killed | 508 ms, killed |
| spawn, default pipes, nothing reads them | 20 ms, alive | 516 ms, killed | 514 ms, killed |
| spawn, child closes stdout and stderr | 13 ms, alive | 517 ms, killed | 507 ms, killed |
| execFile, child keeps its pipes open | 517 ms, killed | 516 ms, killed | 508 ms, killed |
| execFile, child closes stdout and stderr | 29 ms, alive | 513 ms, killed | 506 ms, killed |
| exec, child keeps its pipes open | 517 ms, killed | 514 ms, killed | 506 ms, killed |
| exec, child closes stdout and stderr | 16 ms, alive | 513 ms, killed | 508 ms, killed |
| fork, stdio ignore | 10 ms, alive | 505 ms, killed | 520 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'sshloop 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
timeoutfromexecFile()tospawn(): 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$newCppFunctionbindings of the two host functions behind the globals. #37987 does the same for every built-in module throughinternal/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 andtimeout: 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.
- Shell exits at once, a grandchild holds the pipes 3 s,
What earlier versions of this branch got wrong (all three reproduced before the change)
- The first version took
setTimeout/clearTimeoutfrom the globals when the module loaded. If the module first loaded while a fake held the globals,timeoutnever 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
functionSetTimeoutandfunctionClearTimeoutinnode:child_process.seenCodeEntriesinsrc/codegen/builtins/host/unseen-code.builtins.tslists 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.
functionSetTimeoutrefuses a callback that is not callable (src/jsc/bindings/node/NodeTimers.cpp:49), andfunctionClearTimeoutonly callsBun__Timer__clearTimeout. No feature that--trimcan 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 --aotprints the same as underbun run, withtimeout: 300and withtimeout: 2 ** 31:spawnandexecFilekill the child with SIGTERM, and the second printsTimeoutOverflowWarningand runs the program's 'warning' listener. - 16 files of
test/aotthat read the table or load built-in modules, on a release build of this head with--timeout=90000: 284 pass, 0 fail.--trimdid not run: a local build has no trim table. - Release builds of 11c367163a and of this head: 13 of the 18
options.timeoutcases fail on main, all 18 pass here. The repro printsparent exiting at 4 mson main, andparent exiting at 509 msthenTERMEDhere.
Tests
options.timeoutis 18 cases, each a process of its own that runsfixtures/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. Thetimeoutforward, 4. spawn()'s 'exit' clear removed, 1. execFile()'s unref at 'exit' removed, 1. ThesetTimeoutbinding removed, 2. TheclearTimeoutbinding 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
clearTimeoutbinding 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.
- The deadline was 60 s. With the
- On Windows the
execcase runs a child that ends by itself after 600 ms. cmd.exe cannotexec, 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.jsis 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-stdiousesnode:test: underbun testits 3 tests pass, here and on main. - Also
child_process-node.test.js(30 pass, 4 todo) andchild-process-exec.test.ts(11 pass). - All of
child_process.test.tson 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 notimeoutalso reached the 5 s timeout (the twodestroy()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 oneNumber.isIntegercall inspawn(). 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:jscheapStats, 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
Timeoutevents 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()andchild.stderr.unref(): the parent leaves at 'exit', 20 ms (main 9 ms). Node waits for the deadline, 2009 ms, and callskill()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
clearTimeoutreplaced afterspawn(), a missing file, and execFile + abort + unref with a child that ignores SIGTERM (111 to 125 ms). - With both timers ref'd and the
timeoutforward kept, that last order waited for the deadline and calledkill()twice (2143 to 2437 ms on a debug build). This is why the forward goes. - With 'error' at 100 ms,
timeout: 1000and a 2.5 s child underexecFile: 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
setTimeoutthat returns a number (a fake-timers library): on mainspawn()with atimeoutthrew a TypeError after the child had started. Now it returns the child, as Node does. node:testmock.timersno 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:testfake timers still reach it (#37987).- A child that a
Bun.ModuleGraphspawned with a timeout does not keep the host aftergraph.dispose(): 11 ms with a 4000 ms timeout.
Not changed here
exec/execFilewith{ signal, killSignal: "SIGKILL" }: on abort Node sends SIGTERM, Bun sends SIGKILL, becauseexecFile()still passeskillSignaltospawn(). #37186 removes that line.src/js/node/net.ts:3554unrefs 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