child_process: keep 'close' of stdout and stderr when their 'error' emit throws
Problem
- Since 1.4.3 (#41456), an error on
child.stdoutorchild.stderrwith no'error'listener loses the pipe's'close'. Ways in:destroy(err), a read error, a refused epoll registration (ENOMEM: not enough memory, epoll_ctl). - The
ChildProcessthen emits no'close', and noexecFilecallback runs. - Cause:
emitErrorCloseNT(src/js/internal/streams/destroy.ts:177) emits both events in one tick. A throw from'error'skips'close'.
Fix
destroyHandle(src/js/internal/streams/native-readable.ts:279) emits the pipe's'error'one tick earlier. The stream layer's tick then emits only'close'.reportUncaughtExceptiontakes a throw from that emit and now says if something handled it. If not, the pipe emits no'close'and the process exits 1, as on main.- Verified:
test/js/bun/spawn/spawn-stdio-syscall-error.test.ts(3 of 21 fail on main),test/js/node/child_process/child-process-stdio.test.js(5 of 17).
Background
- In Node these pipes are
net.Sockets, which emit'close'from the handle. ChildProcessemits'close'after'exit'and each pipe's'close'.execFilecalls back from it.- Considered a
'close'tick of the pipe's own. Its listeners ran after a fatal error and could turn exit code 1 into 0. - Source conflict with #46528: one hunk in
webstreams_adapters.ts, one silent break innative-readable.ts. Wanted order: #46528 first (see Notes).
Downsides
- Such a fatal error prints the error's own stack, not the
throwinsidenode:events. - Not fixed:
child.stdinand a throwingdestroy(err, callback)callback still lose'close'. - Cost: one call and branch per
destroy()of these pipes, one closure and tick more with an error. Built-in JS grows 673 bytes.
Notes
Source conflict with #46528, and the wanted order. #46528 (http: consolidate the open fetch, Bun.serve, node:http, node:http2 and HTTP/3 pull requests) moves the Readable.fromWeb() path of a native stream. git merge-tree of 990a8418cf with its head 2613145a7b (committed 2026-10-29 07:39Z) stops in one file, src/js/internal/webstreams_adapters.ts, in one hunk. #46528 deletes tryTransferToNativeReadable, and this PR adds the argument "webstream" to the call inside it. The merge takes the deletion.
Six more files change in both PRs and merge as text: src/codegen/builtins/host/operand-changes.builtins.ts, src/js/internal/shared.ts, src/js/internal/streams/native-readable.ts, src/js/node/child_process.ts, src/jsc/bindings/ZigGlobalObject.h and src/jsc/virtual_machine_exports.rs. One of them does not work as merged. #46528 moves the body of constructNativeReadable into a new fromNativeHandle(bunNativePtr, options, signal). The line stream._destroy = nodeSource === "libuv-handle" ? destroyHandle : destroy of this PR moves with it, and nodeSource is not a parameter there. The merge has to pass it on: constructNativeReadable hands nodeSource to fromNativeHandle, and tryConstructNativeReadable (the Readable.fromWeb() path of #46528) passes "webstream".
Wanted order: #46528 first. It is the larger change (808 files), and this side then takes both points as above. The two were not built together.
Repro
const { spawn } = require("node:child_process");
process.on("uncaughtException", e => console.log("uncaughtException", e.message));
const child = spawn("sh", ["-c", "printf aaaa"], { stdio: ["ignore", "pipe", "ignore"] });
child.stdout.on("close", () => console.log("stdout close"));
child.on("exit", () => console.log("child exit"));
child.on("close", () => console.log("child close"));
child.stdout.destroy(new Error("boom"));
| events | |
|---|---|
| Node v26.3.0 | uncaughtException boom, child exit, child close, stdout close |
| Bun 1.4.2 | stdout close, child exit, child close (the error is never reported) |
| Bun 1.4.3, 1.4.4, main 6c3d1bf956 | uncaughtException boom, child exit |
| this branch | uncaughtException boom, stdout close, child exit, child close |
Node emits the 'close' of the pipe in a later phase of the event loop, so its order differs. Bun keeps the order that it has with an 'error' listener.
A refused epoll registration
The reader of a pipe reports a refused EPOLL_CTL_ADD the way it reports a failed read (register_poll, src/io/PipeReader.rs:499). strace -e inject=epoll_ctl:error=ENOMEM:when=3 refuses the registration of the stdout pipe of the child, and when=4 that of its stderr pipe, around execFile("sh", ["-c", "sleep 0.3; echo hi"], callback). Both give the same result:
| no handler | uncaughtException handler | 'error' listeners on both pipes | |
|---|---|---|---|
| Bun 1.4.2 | callback | callback | callback |
| Bun 1.4.3, 1.4.4, main 6c3d1bf956 | no callback, exit 1, ENOMEM: not enough memory, epoll_ctl | the handler runs, no callback, exit 0 | callback |
| this branch | no callback, exit 1, ENOMEM: not enough memory, epoll_ctl | the handler runs, callback | callback |
- 1.4.2 drops the error, and the next pull registers the pipe again. With every
epoll_ctlrefused it exits 0 with no callback and no error. - 1.3.14 closes the pipe and reports nothing. The callback comes, and a child that writes to that pipe dies of
SIGPIPE. - main and this branch are debug builds. They start slower, so the child sleeps 4 s there.
- With no handler the result is the same as on main: an
'error'that nothing handles is fatal. Node v26.3.0 aborts at the same refusal (uv__io_poll: Assertion 'errno == EEXIST' failed, exit 134). Measured withio_uring_setuprefused, so that libuv registers throughepoll_ctl.
The change in detail
emitErrorsetserrorEmittedbefore it emits, soemitErrorNTin the tick of the stream layer returns early. The order (destroy()callback,'error','close') and the position of'close'in the tick queue are the same as on main.constructNativeReadabletakes a third argument,"libuv-handle" | "webstream". Onlychild_process.tspasses"libuv-handle". A plainReadableloses'close'in Node too, soReadable.fromWeb()of a native stream keeps the old path.Bun__reportUnhandledErrorreturns the result ofVirtualMachine::uncaught_exceptionand no longer drops it. Its other callers ignore the result.- The builtins table that
bun build --compile --aotcompiles built-in code against declaredundefinedas the result ofjsFunctionReportUncaughtException. It now declares a boolean (src/codegen/builtins/host/operand-changes.builtins.ts). A script of the shape above, and one with no handler, print the same when compiled with--compile --aotas under the interpreter. - 673 bytes is the growth of the codegen output of the four modules in a debug build. The change adds no host function, no module and no binding.
native-readable.tsrequiresinternal/shared, which both of its callers load first.
Not changed
- The
'close'of the pipe still has nohadErrorargument. Node passes one. child.stdinloses its'close'in the same situation. It is a different stream class, and theChildProcessdoes not wait for it.- A callback passed to
destroy(err, callback)that throws still loses'close'. The'error'of the pipe is now still emitted in that case. - After a fatal error Bun still runs what other code queued behind it (#34661).
Tests
child-process-stdio.test.js: one fixture process runs six rows under anuncaughtExceptionlistener. They are: no'error'listener on both pipes, an'error'listener that throws, afinished()callback that throws,execFile(),destroy(err)inside the first'data', andReadable.fromWeb(). Node v26.3.0 prints the same JSON for that fixture. Two more tests run with no listener and expect exit code 1 and no'close'listener. On main the first five rows fail.spawn-stdio-syscall-error.test.ts: anLD_PRELOADshim fails arecv()withEIO, or refuses theEPOLL_CTL_ADDof the pipe withENOMEM. Three tests fail on main: theEIOunder a handler, and the refused registration of stdout and of stderr under a handler. A fourth test expects exit code 1 for anEIOwith no handler. It passes on main too.- Each clause of the fix breaks tests when it is removed. Of the 38 tests in the two files: 3 fail when the verdict is ignored, 1 without the
nodeSourcecheck, 12 withouterrorEmitted = true, and 13 whenemitErroris queued behind the tick of the stream layer. - Also ran on the debug build:
child_process.test.ts,child-process-exec.test.tsandchild_process-node.test.jsintest/js/node/child_process/, andtest/js/bun/spawn/spawn-pipe-start-error.test.ts. The results are the same with main's built-in JS. - The shim tests are for Linux. Nothing ran on macOS or Windows outside CI.
- Measured on 10-21 at 0ebe670b88 and not repeated since: the same output as Node for the error in a worker, and for the
AsyncLocalStoragestore that theuncaughtExceptionhandler and the'close'listener see.