node:child_process: subprocess.channel and process.channel count refs like Node's Control
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
| main | now | Node 26.7 | ||
|---|---|---|---|---|
| 1 | the open channel of a fork()ed child, after child.unref() | does not hold the parent | holds it | holds it |
| 2 | any of the four methods on subprocess.channel | throws TypeError | works | works |
| 3 | process.channel.refCounted(), unrefCounted() | throws TypeError | works | works |
| 4 | a child held only by process.channel.ref(), when the parent disconnects | hangs | exits | exits |
| 5 | NODE_CHANNEL_FD that cannot be opened, then process.on("message") or process.channel.ref() | hangs | exits | exits |
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, andhas_refforhasRef()of Node's handle. Two say which part ofrefsis whose:listenersandsending. 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()anddisconnect()warn. - Listeners (child).
onDidChangeListenerspasses the total of'message'and'disconnect'listeners, not +1 or -1, becauseremoveAllListeners()reports once per event name. - Start. Node's
Pipehas a ref from the start, which only the child drops. So a channel made bynode:child_processstarts with one in the parent (startChannelWithRef). One made byBun.spawn({ ipc })does not. - JS. One
Controlclass,src/js/internal/process/channel_control.ts, for both sides. Its only state is the subprocess, and it callssetChannelRef(enabled, counted, subprocess?), which likestartChannelWithRef(subprocess)is innode_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. Afterchild.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.#emitIpcMessagepushes to#pendingMessages, and a'newListener'listener queues#emitPendingMessages. - Child,
src/jsc/bindings/BunProcess.cpp:emitOrHoldMessagepushes toProcess::m_pendingMessageswhile the channel is connected.installPendingMessagesListeneradds a'newListener'listener toprocesswhen there is a channel. As in Node it is listed and counted, andoff()andremoveAllListeners()take it away. It queues the tick only while messages are on the list. - That listener is native,
NativeEventListenerinwebcore/EventEmitter.h: it has a function forlisteners()to show andoff()to take, and the emitter runs its C++ callback in place of calling that function. A call would enter the VM in everyprocess.on()of such a child. Near the stack limit, or when avmtimeout ends the script, that call fails, and the emitter reports the failure as uncaught where the caller ofprocess.on()should get it. - Whether there is a listener,
hasMessageListener: Node askstarget.listenerCount('message'), which a program can replace. The child reads the native list ofprocessfirst, which thelistenerCount()ofnode:eventsdoes not see, and with no listener there it callsprocess.listenerCount(). - A listener that throws. In the parent, what Node does follows from the order of the statements.
processis a nativeEventEmitterthat reports a listener's exception itself and goes on, soEventEmitter::emitgot an optionalbool* 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__hasUnhandledErrorand emit nothing. That isunhandled_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.
emitOrHoldMessagebuilds 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 lazyprocess.nextTickproperty cannot throw: where building it fails, it reports the error as uncaught and isundefinedfrom then on.Process::queueNextTicknow builds it itself while that property is untouched, so the caller ofprocess.on()gets theRangeError, andm_nextTickQueueis set once the build has succeeded.- The end of the channel. At the peer's EOF,
SendQueuequeued 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_peerinsrc/runtime/ipc.rstells the owner in the loop iteration that reads the EOF, as Node does, and#onDisconnectsetschanneltonull, 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, inwrapCallback.'lookup'and'connectionAttempt'of anet.Socketare emitted in one.src/js/node/dgram.ts:'listening'and'error'ofbind().src/js/internal/fs/watch.ts:'close'.src/js/internal/streams/native-readable.ts: a read that had to wait, so'data'ofchild.stdoutandchild.stderr.src/js/internal/fs/streams.ts: a write that had to wait, so the callback,'drain'and'error'ofchild.stdinand of a pipedprocess.stdoutorprocess.stderr. A write that ends at once already goes throughprocess.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 -> bug | 12 |
| bug -> non-bug | 78 |
| non-bug -> bug | 0 |
| non-bug -> non-bug | 1 |
bug -> bug
| process | operations | main | now | Node 26.7 |
|---|---|---|---|---|
| parent | child.unref send(small) unrefCounted | throws TypeError | not held | held |
| child | on(message,a) on(message,a) unrefCounted, the same function twice | throws TypeError | not held | held |
| in a child, before a late listener | main | now | Node 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 program | main | now | Node 26.7 |
|---|---|---|---|
process.listenerCount("newListener") | 0 | 1 | 3 |
child.eventNames() | [] | ["newListener"] | ["internalMessage","newListener"] |
process.eventNames() | ["warning"] | ["warning","newListener"] | ["newListener","removeListener","warning","internalMessage"] |
| also | main | now | Node 26.7 |
|---|---|---|---|
a late listener from another vm context throws, and there is an 'uncaughtException' listener | never ends | exit code 1 | ["uncaught thrown at first"] |
bug -> non-bug
| process | what it does | main | now | Node 26.7 |
|---|---|---|---|---|
| parent | child.unref | not held | held | held |
| parent | child.unref unref | throws TypeError | not held | not held |
| parent | child.unref unref ref | throws TypeError | held | held |
| parent | child.unref refCounted unrefCounted | throws TypeError | not held | not held |
| parent | child.unref disconnect ref | throws TypeError | not held | not held |
| parent | child.unref ref send(big) | throws TypeError | held | held |
| parent | child.unref unrefCounted send(big)x2 | throws TypeError | not held | not held |
| parent | child.unref unrefCounted unrefCounted send(big)x3 | throws TypeError | not held | not held |
| parent | child.unref unrefCounted send(handle+small) | throws TypeError | held | held |
| parent | child.unref receive(handle) | not held | held | held |
| parent | cluster child.unref | not held | held | held |
| parent | cluster child.unref unref | throws TypeError | not held | not held |
| child | on(message,a) unrefCounted | throws TypeError | not held | not held |
| child | on(message,a) on(disconnect,a) unrefCounted | throws TypeError | held | held |
| child | on(message,a) on(message,b) unrefCounted removeAll(message) on(message,a) | throws TypeError | not held | not held |
| child | refCounted | throws TypeError | held | held |
| child | refCounted unrefCounted | throws TypeError | not held | not held |
| child | refCounted refCounted unrefCounted | throws TypeError | held | held |
| child | unrefCounted refCounted | throws TypeError | not held | not held |
| child | unrefCounted on(message,a) | throws TypeError | not held | not held |
| child | ref unrefCounted | throws TypeError | held | held |
| child | on(message,a) unref refCounted | throws TypeError | not held | not held |
| child | held only by process.channel.ref(), the parent disconnects | hangs | exits | exits |
| child | NODE_CHANNEL_FD that cannot be opened, then process.on("message") | hangs | exits | exits |
| child | NODE_CHANNEL_FD that cannot be opened, then process.channel.ref() | hangs | exits | exits |
| parent | child.unref, with serialization: "advanced" | not held | held | held |
| parent | child.unref, and the child exits by itself: what the parent emits before it leaves | nothing | 'disconnect', and in 8 of 60 runs 'exit' too | 'disconnect' |
| child | only process.channel.ref(): the callback of the parent's send() of 1,000,000 bytes, after 1 s | not called | called | called |
| child | only process.channel.unref(): the callback of the parent's send() of 1,000,000 bytes, after 1 s | not called | called | called |
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 late | main | now | Node 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 turn | never 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 listener | main | now | Node 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 program | main | now | Node 26.7 |
|---|---|---|---|
child.listenerCount("newListener") | 0 | 1 | 1 |
child.emit("newListener", "x", fn) | false | true | true |
process.emit("newListener", "x", fn) | false | true | true |
| a tick and a microtask are queued in, after an earlier tick | main | now | Node 26.7 |
|---|---|---|---|
the dns.lookup() callback | micro,tick | tick,micro | tick,micro |
the dns.lookupService() callback | micro,tick | tick,micro | tick,micro |
the callback of resolve*() and reverse(), of node:dns and of a Resolver | micro,tick | tick,micro | tick,micro |
'lookup' of a net.Socket | micro,tick | tick,micro | tick,micro |
'listening' of a dgram.Socket, and the bind() callback | micro,tick | tick,micro | tick,micro |
the inspector.Session#post() callback | micro,tick | tick,micro | tick,micro |
'close' of an fs.FSWatcher | micro,tick | tick,micro | tick,micro |
'data' of child.stdout, for output that the parent had to wait for | micro,tick | tick,micro | tick,micro |
the callback of an 8 MiB child.stdin.write() | micro,tick | tick,micro | tick,micro |
'drain' of child.stdin | micro,tick | tick,micro | tick,micro |
the callback of an 8 MiB process.stdout.write() to a pipe | micro,tick | tick,micro | tick,micro |
'drain' of process.stdout, a pipe | micro,tick | tick,micro | tick,micro |
'error' of process.stdout, a pipe that the reader closed | micro,tick | tick,micro | tick,micro |
| also | main | now | Node 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" | null | null |
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() returning | returned,callback | callback,returned | callback,returned |
child.channel in 'disconnect' | "object" | null | null |
require("node:dns"), then the first process.nextTick() of the process from a microtask, and a microtask | tick,micro | micro,tick | micro,tick |
require("node:dgram"), then the first process.nextTick() of the process from a microtask, and a microtask | tick,micro | micro,tick | micro,tick |
non-bug -> bug
None.
non-bug -> non-bug
| process | what it does | main | now | Node 26.7 |
|---|---|---|---|---|
| parent | child.unref, then it kills the child: what it emits before it leaves, in 300 runs with SIGTERM and 300 with SIGKILL | nothing | 'exit' and 'close' in 9 and in 24, nothing in the others | nothing |
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. Afterchild.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 oneepoll_pwait2that 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 throughSIGCHLDand 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 aprocess.nextTickthat was replaced after the first tick, and with a listener in its own list it does not callprocess.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
vmcontext ends as a message does on main that finds such a listener. dns.ts,dgram.ts,native-readable.tsandfs/streams.tsreadprocess.nextTickwhen they load, asfs.tsdoes, so that fake timers do not hold back what native code ends. Reading it builds the tick queue, which is the two rows withrequire().process.stdin, awritevtochild.stdinand 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 twoeventNames(): 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, whichonDidChangeListenersdoes 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.