child_process: keep 'close' of stdout and stderr when their 'error' emit throws

RO
10d ago
+280-15

Problem

  • Since 1.4.3 (#41456), an error on child.stdout or child.stderr with 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 ChildProcess then emits no 'close', and no execFile callback 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'.
  • reportUncaughtException takes 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.
  • ChildProcess emits 'close' after 'exit' and each pipe's 'close'. execFile calls 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 in native-readable.ts. Wanted order: #46528 first (see Notes).

Downsides

  • Such a fatal error prints the error's own stack, not the throw inside node:events.
  • Not fixed: child.stdin and a throwing destroy(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.0uncaughtException boom, child exit, child close, stdout close
Bun 1.4.2stdout close, child exit, child close (the error is never reported)
Bun 1.4.3, 1.4.4, main 6c3d1bf956uncaughtException boom, child exit
this branchuncaughtException 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 handleruncaughtException handler'error' listeners on both pipes
Bun 1.4.2callbackcallbackcallback
Bun 1.4.3, 1.4.4, main 6c3d1bf956no callback, exit 1, ENOMEM: not enough memory, epoll_ctlthe handler runs, no callback, exit 0callback
this branchno callback, exit 1, ENOMEM: not enough memory, epoll_ctlthe handler runs, callbackcallback
  • 1.4.2 drops the error, and the next pull registers the pipe again. With every epoll_ctl refused 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 with io_uring_setup refused, so that libuv registers through epoll_ctl.

The change in detail

  • emitError sets errorEmitted before it emits, so emitErrorNT in 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.
  • constructNativeReadable takes a third argument, "libuv-handle" | "webstream". Only child_process.ts passes "libuv-handle". A plain Readable loses 'close' in Node too, so Readable.fromWeb() of a native stream keeps the old path.
  • Bun__reportUnhandledError returns the result of VirtualMachine::uncaught_exception and no longer drops it. Its other callers ignore the result.
  • The builtins table that bun build --compile --aot compiles built-in code against declared undefined as the result of jsFunctionReportUncaughtException. 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 --aot as 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.ts requires internal/shared, which both of its callers load first.

Not changed

  • The 'close' of the pipe still has no hadError argument. Node passes one.
  • child.stdin loses its 'close' in the same situation. It is a different stream class, and the ChildProcess does 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 an uncaughtException listener. They are: no 'error' listener on both pipes, an 'error' listener that throws, a finished() callback that throws, execFile(), destroy(err) inside the first 'data', and Readable.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: an LD_PRELOAD shim fails a recv() with EIO, or refuses the EPOLL_CTL_ADD of the pipe with ENOMEM. Three tests fail on main: the EIO under a handler, and the refused registration of stdout and of stderr under a handler. A fourth test expects exit code 1 for an EIO with 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 nodeSource check, 12 without errorEmitted = true, and 13 when emitError is queued behind the tick of the stream layer.
  • Also ran on the debug build: child_process.test.ts, child-process-exec.test.ts and child_process-node.test.js in test/js/node/child_process/, and test/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 AsyncLocalStorage store that the uncaughtException handler and the 'close' listener see.