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

Merged
CI
2mo ago
+6881-621

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 suitebeforeafter
test-cluster-* (parallel + sequential)54 files85 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, idempotent close(), getsockname from the primary's cached sockname — instead of binding their own socket. newconn/accepted replies, redistribution when a worker declines, and the maxConnections accept 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's act:close leaves 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 (new us_socket_group_listen_fd, which validates before touching the descriptor's flags). Bind errnos are captured once and replayed to every worker (EADDRINUSE arrives as an 'error' event with Node's ExceptionWithHostPort shape). 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-supplied listen({ fd }) is validated the way Node's createServerHandle does — anything that cannot serve (non-sockets, connected stream sockets such as a spawned process's stdio, datagram sockets) is EINVAL, 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 a UDPSocket.fd getter back it. A worker that fails to adopt the shared fd releases it.
  • cluster._getServer query tuples match Node's listenInCluster: pipes query as (path, -1, -1), fds as (null, null, null), listen(port, host) carries the real address/family, and reusePort: true forces exclusive (net and dgram) so each worker binds its own SO_REUSEPORT socket. Primary handle keys only get the per-listen :index suffix for port-0 listens, so fixed ports/pipes/fds share one handle across workers. http.Server.listen() in a worker reports listening to the primary (and stays quiet in a child that merely inherited NODE_UNIQUE_ID without a channel).
  • IPC handle passing (child.send / process.send / worker.send with a handle): net.Socket, net.Server and dgram.Socket handles 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 follow lib/internal/child_process.js: a sent net.Socket is detached from the sender (no 'end'/'close' there, server connection count decremented) unless keepOpen; TLS sockets are rejected with ERR_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 (later send() fails, a second disconnect() 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 to Bun.spawn({ ipc }) children too.
  • Windows handle transfer: sockets cross processes with WSADuplicateSocketW — the exported WSAPROTOCOL_INFOW rides along with the IPC message and the receiver imports it. Pipe and UDP shared binds report ENOTSUP on Windows rather than pretending to work.
  • fork() from a parent whose stdio slot is closed gives the child /dev/null for 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 saw disconnect().
  • child_process stdio accepts fs write streams (their fd is taken from the stream's sink), and passing a stream with no descriptor gives a clear error instead of a TODO.
  • internal/cluster/round_robin_handle and internal/test/binding (a udp_wrap.UDP bind/fd shim) resolve for the vendored --expose-internals tests, gated exactly like bun:internal-for-testing.
  • Stale-listen protection (listeningId) so a listen() superseded before the primary replies closes the late handle instead of adopting it. server.blockList rejections under round-robin are closed silently ('drop' is for maxConnections), 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/https server connection sockets have no transferable handle in Bun (they are native, like TLS sockets), so child.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-patches process.send breaks the worker↔primary protocol (works in Node).
  • Under round-robin the 'drop' event for maxConnections is emitted without Node's address payload; server.listen({ fd }) on an fd that is already listening resolves rather than failing with EEXIST.
  • Pipe and UDP shared binds are not supported on Windows (ENOTSUP); the cluster tests covering them are skipped there. cluster.schedulingPolicy still defaults to SCHED_RR on Windows (Node defaults to SCHED_NONE there); 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.
  • spawnSync with a closed inherited stdio slot still fails with EBADF (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