cluster: port Node's cluster and child_process handle-passing suites (+43 upstream tests; cluster 54 → 85) and implement what they expose — round-robin fd handoff, SCHED_NONE shared handles, UDP clustering, IPC handle passing
Brings node:cluster in line with Node v26.3.0 by porting the upstream cluster suite verbatim and implementing the runtime machinery the tests expose as missing — until now workers silently bound their own sockets, so nothing was actually shared. IPC handle passing (process.send(msg, handle)) is implemented on the way, since round-robin scheduling is built on it, and the upstream child_process tests that exercise it are ported too.
| vendored suite | before | after |
|---|---|---|
test-cluster-* (parallel + sequential) | 54 files | 85 files, 85/85 passing (31 added, 14 re-synced to v26.3.0) |
test-child-process-* handle passing / fork | — | 12 added (fork-net-server, fork-net-socket, send-keep-open, pass-fd, internal, advanced-serialization framing, …) |
In total 43 upstream tests are added (31 cluster + 12 child_process) and 15 existing ones re-synced, alongside ~41 new Bun-side test cases (cluster.test.ts, the new child_process_ipc_handle.test.ts, child_process.test.ts, node-net.test.ts, spawn.ipc.bun-node.test.ts). Numbers are local debug-build sweeps (Linux and macOS). Every vendored file is byte-identical to upstream; test/expectations.txt is untouched.
Behavior changes
- Real round-robin scheduling (SCHED_RR): the primary owns the listen socket, accepts with reads paused (bytes stay in the kernel buffer, so data that arrives before the worker attaches is not lost), and hands each accepted fd to a worker over the IPC channel as SCM_RIGHTS ancillary data. Workers adopt Node's faux listen handle —
ref()/unref()via timer, idempotentclose(),getsocknamefrom the primary's cached sockname — instead of binding their own socket.newconn/acceptedreplies, redistribution when a worker declines, and themaxConnectionsaccept check match Node. Beyond Node: a connection that was in flight to a worker that crashed is handed to another worker instead of being leaked; after a graceful disconnect the primary just drops its copy; a live worker'sact:closeleaves the connection to its ack exactly as Node does. A peer that resets a queued connection no longer crashes the primary. - SCHED_NONE / shared handles: the primary binds (never listens) via a new native bind-only path, ships the fd to every worker that asks, and each worker does its own
listen(2)on the duplicated fd (newus_socket_group_listen_fd, which validates before touching the descriptor's flags). Bind errnos are captured once and replayed to every worker (EADDRINUSEarrives as an'error'event with Node'sExceptionWithHostPortshape).server.close()in a worker releases its share immediately, so the same worker can re-listen;server.address()reports the shared unix path; abstract-namespace names bind with the exact length. A user-suppliedlisten({ fd })is validated the way Node'screateServerHandledoes — anything that cannot serve (non-sockets, connected stream sockets such as a spawned process's stdio, datagram sockets) isEINVAL, and the primary's own descriptor is left alone. - UDP clustering: dgram sockets in workers route non-exclusive binds through the primary's shared handle (UDP is never round-robin), including
bind({ fd }); new native UDP-from-fd adoption (us_create_udp_socket_from_fd) and aUDPSocket.fdgetter back it. A worker that fails to adopt the shared fd releases it. cluster._getServerquery tuples match Node'slistenInCluster: pipes query as(path, -1, -1), fds as(null, null, null),listen(port, host)carries the real address/family, andreusePort: trueforcesexclusive(net and dgram) so each worker binds its own SO_REUSEPORT socket. Primary handle keys only get the per-listen:indexsuffix for port-0 listens, so fixed ports/pipes/fds share one handle across workers.http.Server.listen()in a worker reportslisteningto the primary (and stays quiet in a child that merely inheritedNODE_UNIQUE_IDwithout a channel).- IPC handle passing (
child.send/process.send/worker.sendwith a handle):net.Socket,net.Serveranddgram.Sockethandles cross the channel (fd via SCM_RIGHTS, the NODE_HANDLE ack protocol that already existed in the wire layer; the JS serialize/parse halves were stubs). Semantics followlib/internal/child_process.js: a sentnet.Socketis detached from the sender (no'end'/'close'there, server connection count decremented) unlesskeepOpen; TLS sockets are rejected withERR_INVALID_HANDLE_TYPE; a received handle that lands on fd 0 is adopted; a handle that arrives right before the channel's EOF is still delivered; a send that fails after detaching closes the native socket; owned descriptors are released even when they were allocated at 0–2.disconnect()reports disconnected at once (latersend()fails, a seconddisconnect()errors) but, as in Node, the close waits for the messages queued behind a handle that is still awaiting its ack — only user-initiated disconnects wait; a dead peer or exit closes immediately. NODE_-prefixed messages ({ cmd: "NODE_…" }) are routed to'internalMessage'on both ends, as Node documents; this applies toBun.spawn({ ipc })children too.- Windows handle transfer: sockets cross processes with
WSADuplicateSocketW— the exportedWSAPROTOCOL_INFOWrides along with the IPC message and the receiver imports it. Pipe and UDP shared binds reportENOTSUPon Windows rather than pretending to work. fork()from a parent whose stdio slot is closed gives the child/dev/nullfor that slot (libuv's behaviour). The plain inherit action captured whatever descriptor was created next — the IPC socketpair's parent end — so the child held its own channel open and never sawdisconnect().child_processstdio acceptsfswrite streams (their fd is taken from the stream's sink), and passing a stream with no descriptor gives a clear error instead of aTODO.internal/cluster/round_robin_handleandinternal/test/binding(audp_wrap.UDPbind/fd shim) resolve for the vendored--expose-internalstests, gated exactly likebun:internal-for-testing.- Stale-listen protection (
listeningId) so alisten()superseded before the primary replies closes the late handle instead of adopting it.server.blockListrejections under round-robin are closed silently ('drop'is formaxConnections), as in Node.
Bun-side tests
test/js/node/cluster.test.ts (+~1000 lines: scheduling, shared handles, unix/abstract paths, fd validation, dgram release, mid-handoff crash, early bytes, blockList, …), new test/js/node/child_process/child_process_ipc_handle.test.ts (handle passing semantics: detach, keepOpen, TLS rejection, fd 0, queued handles on channel close, disconnect ordering), plus additions to child_process.test.ts, node-net.test.ts and spawn.ipc.bun-node.test.ts. The latter previously asserted that a received net.Socket handle is rejected — that only held while parseHandle threw; it now asserts that net.Socket and dgram.Socket handles are delivered and that the descriptors are released.
Known limitations / follow-ups
http/httpsserver connection sockets have no transferable handle in Bun (they are native, like TLS sockets), sochild.send(msg, httpSocket)sends the message without the handle — the same path Node takes for a handle-less socket, but Node's http sockets do have one; documenting or throwing like TLS is a follow-up.- A socket is converted (and detached) when
send()is called, not when the message is actually written as in Node, so a socket queued behind an un-acked handle is not reusable if the channel dies first. A late-adopted server/dgram handle is emitted after'disconnect'where Node emits it before. - Cluster-internal messages are marked by wire type rather than recognised by the
NODE_prefix on receipt, so a worker that monkey-patchesprocess.sendbreaks the worker↔primary protocol (works in Node). - Under round-robin the
'drop'event formaxConnectionsis emitted without Node's address payload;server.listen({ fd })on an fd that is already listening resolves rather than failing withEEXIST. - Pipe and UDP shared binds are not supported on Windows (
ENOTSUP); the cluster tests covering them are skipped there.cluster.schedulingPolicystill defaults toSCHED_RRon Windows (Node defaults toSCHED_NONEthere); flipping it deserves its own PR now that shared handles work. - The primary's accepted-socket pause relies on the accept callback running before the first read poll; a uSockets-level accept-paused option would make that structural.
spawnSyncwith a closed inherited stdio slot still fails withEBADF(pre-existing; Node aborts on the same input).
no test proof · iteration 71 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/js/bun/net/named-pipe-listen-error.test.ts test/js/bun/spawn/spawn.ipc.bun-node.test.ts test/js/node/child_process/child_process_ipc_handle.test.ts test/js/node/cluster.test.ts