node:child_process: subprocess.channel and process.channel count refs like Node's Control

DY
21d ago
+1003-122

Not ready to merge. A review found regressions. The changes under "Callbacks that were called from a promise reaction" caused most of them and have been taken out again. The text and the numbers below are from the head before that and are out of date.

What

subprocess.channel (parent) and process.channel (child) now have the four methods of Node's Control: ref(), unref(), refCounted(), unrefCounted(). They count as Node's do, but for what Sends says below.

On main, subprocess.channel is an EventEmitter with no methods at all, and process.channel has only ref() and unref(). execa 9's execaNode() calls channel.refCounted(), catches the TypeError, and reports the child as failed although it ran to completion.

Fixes #33593

Messages that arrive before a listener

All four methods open the child's end of the channel, which Bun otherwise opens on the first 'message' or 'disconnect' listener or the first process.send(). So a child that calls process.channel.ref() at start and adds its listener later reads messages that nobody listens for yet. They have to be kept, which is why the commits of #45678 are part of this branch.

An IPC message that arrives while 'message' has no listener was lost, in the child (process) and in the parent (ChildProcess). Node keeps it and emits it on the tick after a listener is added.

// parent.mjs
import { fork } from "node:child_process";
const child = fork("./child.mjs");
child.once("message", () => child.send("hello")); // the child said "ready"

// child.mjs
process.send("ready");
await somethingAsync();
process.on("message", message => console.log(message)); // Node: "hello". Bun: never called.

A child reads its channel as soon as anything opens it: process.send(), process.on("disconnect"), or a 'message' listener that has since been removed. A parent always reads.

Five bugs on main

mainnowNode 26.7
1the open channel of a fork()ed child, after child.unref()does not hold the parentholds itholds it
2any of the four methods on subprocess.channelthrows TypeErrorworksworks
3process.channel.refCounted(), unrefCounted()throws TypeErrorworksworks
4a child held only by process.channel.ref(), when the parent disconnectshangsexitsexits
5NODE_CHANNEL_FD that cannot be opened, then process.on("message") or process.channel.ref()hangsexitsexits

Bug 1 is a behaviour change to look at. A parent that calls child.unref() on a forked child and nothing else now waits for the channel, as in Node. To let it exit, call child.channel.unref() or child.disconnect() too. The same holds for a cluster primary that unrefs its workers' processes, until it first sends to a worker, which it does when the worker listens on a port. Such a parent now leaves when the channel closes, which is when the child dies, so it can still hear of that: see non-bug -> non-bug.

Cause and fix

What holds the event loop for a channel was spread over the VM (channel_ref, channel_ref_overridden) for the child and did not exist for the parent. It is now one struct, Control, on the SendQueue that both sides already have, with Node's rules written once, in src/runtime/ipc.rs.

  • Fields. Three are Node's: refs, ref_explicitly_set, and has_ref for hasRef() of Node's handle. Two say which part of refs is whose: listeners and sending.
  • should_ref() has one more condition: an open socket whose channel has a ref keeps the loop alive. A socket that is closed, or never opened, does not, which is what fixes bugs 4 and 5.
  • Opening. The four methods open the child's end with get_ipc_instance_quietly: where it cannot be opened they say nothing, as in Node. process.on("message"), send() and disconnect() warn.
  • Listeners (child). onDidChangeListeners passes the total of 'message' and 'disconnect' listeners, not +1 or -1, because removeAllListeners() reports once per event name.
  • Start. Node's Pipe has a ref from the start, which only the child drops. So a channel made by node:child_process starts with one in the parent (startChannelWithRef). One made by Bun.spawn({ ipc }) does not.
  • JS. One Control class, src/js/internal/process/channel_control.ts, for both sides. Its only state is the subprocess, and it calls setChannelRef(enabled, counted, subprocess?), which like startChannelWithRef(subprocess) is in node_cluster_binding.bind.ts.
  • Deleted: vm.channel_ref, vm.channel_ref_overridden, Bun__refChannelUnlessOverridden, Bun__unrefChannelUnlessOverridden, Bun__ensureProcessIPCInitialized.

Sends. Node counts a write that did not finish at once as one ref until it does. For a parent, whose channel starts with a ref but with refs at 0, that has a side effect: when the write finishes, refs goes from 1 to 0 and the ref from the start is gone. Here that differs from Node in two ways:

  • Every send() counts, not only one whose write did not finish at once. Whether a write finishes at once is a race with the reader. After child.unref(), a Node parent stays for one send of 140,000 bytes and exits for one of 160,000. It stays for 100 sends of 10 bytes and exits for 200. So the hold of bug 1 ends with the parent's first send, whatever its size. In Node it ends with the first send that does not finish at once.
  • The version packet is not a send. With serialization: "advanced" Bun writes one first, which Node does not have. On Windows its write ends after the channel was started, and would end the hold of bug 1.

As in Node, what is sent after a handle is written, and counted, only once the handle is acknowledged (Node's _handleQueue). An ACK or NACK is not a send() and is not counted, so a parent that only receives handles keeps its hold.

Messages without a listener. The child emitted only if there was a listener, and the parent emitted to nobody. Both now do what Node's emit and newListener handler do: with no 'message' listener the message and its handle go on a list, and the tick after any listener is added the list is emitted and then emptied. A listener that throws ends that loop before the list is emptied, so the next listener that is added gets the same messages again.

  • Parent, src/js/node/child_process.ts: Node's two functions. #emitIpcMessage pushes to #pendingMessages, and a 'newListener' listener queues #emitPendingMessages.
  • Child, src/jsc/bindings/BunProcess.cpp: emitOrHoldMessage pushes to Process::m_pendingMessages while the channel is connected. installPendingMessagesListener adds a 'newListener' listener to process when there is a channel. As in Node it is listed and counted, and off() and removeAllListeners() take it away. It queues the tick only while messages are on the list.
  • That listener is native, NativeEventListener in webcore/EventEmitter.h: it has a function for listeners() to show and off() to take, and the emitter runs its C++ callback in place of calling that function. A call would enter the VM in every process.on() of such a child. Near the stack limit, or when a vm timeout ends the script, that call fails, and the emitter reports the failure as uncaught where the caller of process.on() should get it.
  • Whether there is a listener, hasMessageListener: Node asks target.listenerCount('message'), which a program can replace. The child reads the native list of process first, which the listenerCount() of node:events does not see, and with no listener there it calls process.listenerCount().
  • A listener that throws. In the parent, what Node does follows from the order of the statements. process is a native EventEmitter that reports a listener's exception itself and goes on, so EventEmitter::emit got an optional bool* listenerThrew, with which it stops there.

A tick in Bun reaches further than one in Node in four places. Each has a row in ipc-pending-messages-ending-fixture.cjs:

  • An exception that nobody handles. Node exits at once. Bun first ends the turn, ticks included. Both loops now ask Bun__VirtualMachine__hasUnhandledError and emit nothing. That is unhandled_error_counter, which ends the event loop of a script, or an entry point whose promise is rejected and not reported yet.
  • The first tick of a process. If a microtask queues it, Bun runs it before the microtasks that follow, so before the exception at the top level of an ES module has reached the entry point's promise. emitOrHoldMessage builds the tick queue when it keeps the first message, and from then on a tick that a microtask queues comes after the microtasks, as in Node. The next section is about what else saw that first tick.
  • process.on() at the stack limit, where the queue is still to be built. The lazy process.nextTick property cannot throw: where building it fails, it reports the error as uncaught and is undefined from then on. Process::queueNextTick now builds it itself while that property is untouched, so the caller of process.on() gets the RangeError, and m_nextTickQueue is set once the build has succeeded.
  • The end of the channel. At the peer's EOF, SendQueue queued a task that told its owner. The loop polled once more before that task ran, so the parent could emit 'exit' before 'disconnect', and the tick of a listener added in 'exit' still found a channel. SendQueue::closed_by_peer in src/runtime/ipc.rs tells the owner in the loop iteration that reads the EOF, as Node does, and #onDisconnect sets channel to null, as Node's does.

The callback of Bun.spawn({ ipc }) in the parent is not a listener and is not touched. A Bun child of such a parent uses process.on("message"), so it gets the fix.

Callbacks that were called from a promise reaction

That early first tick hid a bug. Where Bun calls a callback or emits an event from the reaction of a native promise, the listener runs in a microtask, so the microtasks it queues run before its ticks. Node calls all of these from the event loop, where ticks come first. Only the first tick of a process came out right, and a kept message uses that one up.

fs.ts sends its callbacks through process.nextTick for this reason (settleCallback). Now these do too:

  • src/js/node/dns.ts: every callback, in wrapCallback. 'lookup' and 'connectionAttempt' of a net.Socket are emitted in one.
  • src/js/node/dgram.ts: 'listening' and 'error' of bind().
  • src/js/internal/fs/watch.ts: 'close'.
  • src/js/internal/streams/native-readable.ts: a read that had to wait, so 'data' of child.stdout and child.stderr.
  • src/js/internal/fs/streams.ts: a write that had to wait, so the callback, 'drain' and 'error' of child.stdin and of a piped process.stdout or process.stderr. A write that ends at once already goes through process.nextTick.
  • src/js/node/inspector.ts: post() calls its callback before it returns, as Node's does.

They were found by printing that order in 1,179 programs, one callback or event of a node:* module each, on Node, on main, and on main after an earlier tick.

Behaviour

"held" means that the process's event loop is still running after it did what the row says. "bug" means different from Node 26.7, but for one kind of case where Node ends in an error of its own: after disconnect() in the listener of a kept message its loop throws TypeError: Cannot set properties of null (setting 'Symbol(kPendingMessages)'). There, right is Node's result without that error. The rows in code are calls in order. Those under bug -> non-bug are lines that test/js/node/child_process/fixtures/ipc-channel-ref-fixture.mjs prints, which also runs on Node. The rows of who listens late are lines that ipc-pending-messages-fixture.mjs and ipc-pending-messages-ending-fixture.cjs print, next to it, but for the two with disconnect() in the listener. The others were run by hand.

Of the 75 cases about messages, 59 changed.

cases
bug -> bug12
bug -> non-bug78
non-bug -> bug0
non-bug -> non-bug1
bug -> bug
processoperationsmainnowNode 26.7
parentchild.unref send(small) unrefCountedthrows TypeErrornot heldheld
childon(message,a) on(message,a) unrefCounted, the same function twicethrows TypeErrornot heldheld
in a child, before a late listenermainnowNode 26.7
process.emit is a wrapper that logs["end"]["first","second","end"]["emit(first)","first","emit(second)","second","emit(end)","end"]
the listener calls process.emit("inner"), whose listener throws["end"]["first","uncaught:inner","after the inner emit","second","end"]["first","uncaught:inner","first","uncaught:inner","end"]
the listener calls process.emit("inner") in a try, and its listener throws["end"]["first","uncaught:inner","not thrown","second","end"]["first","caught","second","end"]
a 'newListener' listener of the program throws at process.on("message")["uncaught:newListener","on() returned","end"]["uncaught:newListener","on() returned","first","second","end"]["on() threw","first","second","end"]
process.listenerCount = () => 0 at the tick, with a listener["restored","end"]["first","second","restored","end"]["restored","first","second","end"]
process.nextTick = () => {} while the listener is added["restored","end"]["first","second","restored","end"]["restored","first","second","end"]
with a channel, and no listener of the programmainnowNode 26.7
process.listenerCount("newListener")013
child.eventNames()[]["newListener"]["internalMessage","newListener"]
process.eventNames()["warning"]["warning","newListener"]["newListener","removeListener","warning","internalMessage"]
alsomainnowNode 26.7
a late listener from another vm context throws, and there is an 'uncaughtException' listenernever endsexit code 1["uncaught thrown at first"]
bug -> non-bug
processwhat it doesmainnowNode 26.7
parentchild.unrefnot heldheldheld
parentchild.unref unrefthrows TypeErrornot heldnot held
parentchild.unref unref refthrows TypeErrorheldheld
parentchild.unref refCounted unrefCountedthrows TypeErrornot heldnot held
parentchild.unref disconnect refthrows TypeErrornot heldnot held
parentchild.unref ref send(big)throws TypeErrorheldheld
parentchild.unref unrefCounted send(big)x2throws TypeErrornot heldnot held
parentchild.unref unrefCounted unrefCounted send(big)x3throws TypeErrornot heldnot held
parentchild.unref unrefCounted send(handle+small)throws TypeErrorheldheld
parentchild.unref receive(handle)not heldheldheld
parentcluster child.unrefnot heldheldheld
parentcluster child.unref unrefthrows TypeErrornot heldnot held
childon(message,a) unrefCountedthrows TypeErrornot heldnot held
childon(message,a) on(disconnect,a) unrefCountedthrows TypeErrorheldheld
childon(message,a) on(message,b) unrefCounted removeAll(message) on(message,a)throws TypeErrornot heldnot held
childrefCountedthrows TypeErrorheldheld
childrefCounted unrefCountedthrows TypeErrornot heldnot held
childrefCounted refCounted unrefCountedthrows TypeErrorheldheld
childunrefCounted refCountedthrows TypeErrornot heldnot held
childunrefCounted on(message,a)throws TypeErrornot heldnot held
childref unrefCountedthrows TypeErrorheldheld
childon(message,a) unref refCountedthrows TypeErrornot heldnot held
childheld only by process.channel.ref(), the parent disconnectshangsexitsexits
childNODE_CHANNEL_FD that cannot be opened, then process.on("message")hangsexitsexits
childNODE_CHANNEL_FD that cannot be opened, then process.channel.ref()hangsexitsexits
parentchild.unref, with serialization: "advanced"not heldheldheld
parentchild.unref, and the child exits by itself: what the parent emits before it leavesnothing'disconnect', and in 8 of 60 runs 'exit' too'disconnect'
childonly process.channel.ref(): the callback of the parent's send() of 1,000,000 bytes, after 1 snot calledcalledcalled
childonly process.channel.unref(): the callback of the parent's send() of 1,000,000 bytes, after 1 snot calledcalledcalled

In the table of who listens late, a value is what a 'message' listener received that was added after "first", an internal message and "second" had arrived. In the row with two more on(), it is what the two processes printed.

who listens latemainnowNode 26.7
child, a late listener["end"]["first","second","end"]["first","second","end"]
child, a handle["end"][["first","Server"],"second","end"][["first","Server"],"second","end"]
child, a listener that throws["end"]["first","first","end"]["first","first","end"]
child, a listener that throws, then a listener for another event["end"]["first","first","first","end"]["first","first","first","end"]
child, once["end","end"]["first","end"]["first","end"]
child, added and removed in one tick["end"]["first","second","end"]["first","second","end"]
child, on() down to the stack limit["RangeError","end"]["RangeError","first","second","end"]["RangeError","first","second","end"]
child, the listenerCount() of node:events["end"]["first","second","end"]["first","second","end"]
child, nobody listens and listenerCount() throws["end"]["RangeError","RangeError","end"]["RangeError","RangeError","end"]
child, nobody listens and listenerCount is 5["end"]["TypeError","TypeError","end"]["TypeError","TypeError","end"]
parent, a late listener["end"]["first","second","end"]["first","second","end"]
parent, a handle["end"][["first","Server"],"second","end"][["first","Server"],"second","end"]
parent, a listener that throws["end"]["first","first","end"]["first","first","end"]
parent, a listener that throws, then a listener for another event["end"]["first","first","first","end"]["first","first","first","end"]
parent, once["end","end"]["first","end"]["first","end"]
parent, added and removed in one tick["end"]["first","second","end"]["first","second","end"]
parent, on() down to the stack limit["RangeError","end"]["RangeError","first","second","end"]["RangeError","first","second","end"]
parent, the listenerCount() of node:events["end"]["first","second","end"]["first","second","end"]
parent, nobody listens and listenerCount() throws["end"]["RangeError","RangeError","end"]["RangeError","RangeError","end"]
parent, nobody listens and listenerCount is 5["end"]["TypeError","TypeError","end"]["TypeError","TypeError","end"]
child, disconnect() in the listener of "first"never ends["first","second"]["first","second"]
parent, disconnect() in the listener of "first"never ends["first","second","<disconnect event>"]["first","second","<disconnect event>","TypeError: Cannot set properties of null (setting 'Symbol(kPendingMessages)')"]
child, a listener that throws and two more on() in one turnnever ends["late first","exit listener 1","child exit code 1"]["late first","exit listener 1","child exit code 1"]
parent, a listener added in 'exit'["exit Control { ... }","disconnect Control { ... }","close"]["disconnect null","exit null","close"]["disconnect null","exit null","close"]
in a child, before a late listenermainnowNode 26.7
three async functions each add a listener after an await[]["0 first","1 first","2 first"]["0 first","1 first","2 first"]
with a channel, and no listener of the programmainnowNode 26.7
child.listenerCount("newListener")011
child.emit("newListener", "x", fn)falsetruetrue
process.emit("newListener", "x", fn)falsetruetrue
a tick and a microtask are queued in, after an earlier tickmainnowNode 26.7
the dns.lookup() callbackmicro,ticktick,microtick,micro
the dns.lookupService() callbackmicro,ticktick,microtick,micro
the callback of resolve*() and reverse(), of node:dns and of a Resolvermicro,ticktick,microtick,micro
'lookup' of a net.Socketmicro,ticktick,microtick,micro
'listening' of a dgram.Socket, and the bind() callbackmicro,ticktick,microtick,micro
the inspector.Session#post() callbackmicro,ticktick,microtick,micro
'close' of an fs.FSWatchermicro,ticktick,microtick,micro
'data' of child.stdout, for output that the parent had to wait formicro,ticktick,microtick,micro
the callback of an 8 MiB child.stdin.write()micro,ticktick,microtick,micro
'drain' of child.stdinmicro,ticktick,microtick,micro
the callback of an 8 MiB process.stdout.write() to a pipemicro,ticktick,microtick,micro
'drain' of process.stdout, a pipemicro,ticktick,microtick,micro
'error' of process.stdout, a pipe that the reader closedmicro,ticktick,microtick,micro
alsomainnowNode 26.7
a message is kept and nobody listens: the first process.nextTick() of the process, from the first of three microtasks["micro0","tick","micro1","micro2"]["micro0","micro1","micro2","tick"]["micro0","micro1","micro2","tick"]
child.channel in 'exit'"object"nullnull
an --import preload with a top-level await keeps "first", and a CommonJS entry point listens, then queues a tick and a microtask["main","tick","micro"]["main","micro","first","tick"]["main","micro","first","tick"]
an --import preload with a top-level await keeps "first", and an ES module entry point listens, then queues a tick and a microtask["main","tick","micro"]["main","micro","first","tick"]["main","micro","first","tick"]
session.post("Runtime.enable", callback): the callback, and post() returningreturned,callbackcallback,returnedcallback,returned
child.channel in 'disconnect'"object"nullnull
require("node:dns"), then the first process.nextTick() of the process from a microtask, and a microtasktick,micromicro,tickmicro,tick
require("node:dgram"), then the first process.nextTick() of the process from a microtask, and a microtasktick,micromicro,tickmicro,tick
non-bug -> bug

None.

non-bug -> non-bug
processwhat it doesmainnowNode 26.7
parentchild.unref, then it kills the child: what it emits before it leaves, in 300 runs with SIGTERM and 300 with SIGKILLnothing'exit' and 'close' in 9 and in 24, nothing in the othersnothing

Worth knowing

  • Whether two sends overlap is a race with the reader, in Node too: one write() of 1,000,000 bytes can finish at once while the other side is reading. After child.unref unrefCounted send(big), two more big sends at once left the parent held in 0 of 80 runs with a Node child, whichever runtime the parent was, and in some with a Bun child.
  • The row under non-bug -> non-bug: neither is a bug. The parent waits for the channel and not for the child, and the two end together. Bun watches a child through a pidfd in the poll that watches the channel. Of 250 traced runs, 4 emitted 'exit', and in those the one epoll_pwait2 that reported the end of the channel reported the pidfd too. In the other 246 it reported the channel alone, and the parent left without another poll. Node hears of a child through SIGCHLD and a pipe, which takes one more poll. On main the parent does not wait for the channel.
  • The two rows of operations under bug -> bug: the first is the small send of Sends. The second is process, which keeps a function that is added twice for one event once, so it counts once.
  • A process that never adds a 'message' listener keeps every message it receives until the channel disconnects, as in Node. main dropped them.
  • The child's code is native. It emits and queues its tick itself, without calling process.emit, which a program can replace, or a process.nextTick that was replaced after the first tick, and with a listener in its own list it does not call process.listenerCount. Three cases under bug -> bug come from that.
  • Three more under bug -> bug come from process.emit(), which reports what a listener throws and goes on where Node's throws it. In those three and in the one with the wrapper, the kept message gets what main gives a message that finds a listener.
  • The one with the vm context ends as a message does on main that finds such a listener.
  • dns.ts, dgram.ts, native-readable.ts and fs/streams.ts read process.nextTick when they load, as fs.ts does, so that fake timers do not hold back what native code ends. Reading it builds the tick queue, which is the two rows with require().
  • process.stdin, a writev to child.stdin and the second of two waiting writes also run microtasks first. They do so for a first tick too, so a kept message changes nothing for them, and they are not touched.
  • listenerCount("newListener") and the two eventNames(): of Node's three 'newListener' listeners in such a child, one is this one. The other two start a signal's handler and count the channel's refs, which onDidChangeListeners does natively here, as it does what Node's 'removeListener' listener does. 'internalMessage' has no listener in Bun on either side.

Tests

In child_process_ipc.test.js:

  • "which operations on subprocess.channel hold the event loop" and "which operations on process.channel hold the event loop" run the fixture. On 1.4.3 both finish, and fail on 13 and 10 rows.
  • "a child held only by process.channel.ref() leaves when the parent disconnects" and the three "a channel that could not be opened does not hold the event loop after ..." are bugs 4 and 5. The one with process.channel.unref() passes on 1.4.3, and fails if the methods warn. The other three are hangs, so on 1.4.3 they time out.

The fixture does not use time to tell whether a loop is held. The driver answers each line the process prints on its stdin, which the process reads through an unref'd handle. A loop that nothing holds turns a few more times at most and the process exits with 0. One that still answers after 10 round trips is held.

child_process_ipc.test.js, "a message that arrives before there is a 'message' listener is kept for it", runs ipc-pending-messages-fixture.mjs, which also runs on Node and prints the same there. It fails on main and on 1.4.3.

"kept messages where a process ends, or is at the stack limit" runs ipc-pending-messages-ending-fixture.cjs, one process per row, which also prints the same on Node. On main and on 1.4.3 two of its six rows fail, and the other four pass, where nothing is ever emitted late. With kept messages and without the four pieces above, all six fail.

Five of the fixture's variants pass on main too, on both sides, where nothing is ever emitted late: a listener that throws, added before another, removeAllListeners('newListener'), disconnect() in the tick that adds it and the two with listenerCount() returns 1. In the child, the first fails if the emit goes on after a throw, the second if what queues the loop is not a listener, the third if the tick does not ask whether the channel is still connected, and the last two if process.listenerCount() is not called. on() down to the stack limit fails in the child if the listener is not native.

"a tick queued in ... runs before a microtask queued in it", in node-dns.test.js (six callbacks), node-dgram.test.js, fs.watch.test.ts, child-process-stdio.test.js ('data' of child.stdout, and a write to child.stdin) and process-stdio.test.ts (a piped process.stdout and process.stderr), and "post() calls its callback before it returns" in inspector.test.ts. All fail on main and on 1.4.3. Alone in a new process, the first of the six in node-dns.test.js passes there: it gets the first tick.