Skip to content

ipc: pass net.Server and net.Socket handles over send(message, handle) - #31715

Closed
robobun wants to merge 7 commits into
mainfrom
farm/70c52d38/ipc-net-server-handle
Closed

ipc: pass net.Server and net.Socket handles over send(message, handle)#31715
robobun wants to merge 7 commits into
mainfrom
farm/70c52d38/ipc-net-server-handle

Conversation

@robobun

@robobun robobun commented Jun 2, 2026

Copy link
Copy Markdown
Collaborator

What

subprocess.send(message, handle) and process.send(message, handle) silently dropped the handle: send() returned true, the message arrived, but the peer's message handler received handle === undefined. fd/socket passing over the IPC channel did not exist.

// parent.js
import { fork } from "node:child_process";
import net from "node:net";
const child = fork("child.js");
net.createServer(sock => child.send("conn", sock)).listen(0);
// child.js
process.on("message", (m, handle) => {
  console.log(handle?.constructor?.name); // node: "Socket"   bun: undefined
  handle?.end("hi-from-child");
});

send(msg, handle) is the primitive under every pre-fork multi-process pattern in the Node ecosystem: node:cluster's connection distribution, socket-passing load balancers, graceful-restart managers, "pass the listening fd to the new version" deploys. All of them degraded silently.

Fixes #6743
Fixes #22559

Root cause

The low-level transport already existed in src/jsc/ipc.rs (SCM_RIGHTS send and receive on the IPC socketpair, the NODE_HANDLE / NODE_HANDLE_ACK / NODE_HANDLE_NACK handshake, the ack-gated send queue). Nothing fed it:

  1. Send: serialize() in src/js/builtins/Ipc.ts was a stub (return null; // sending file descriptors is not supported yet), so the handle was discarded before any of it ran, and send() still returned true.
  2. Receive, net.Server: parseHandle() reconstructs a server with server.listen({ fd }), but Bun.listen({ fd }) threw EINVAL: Bun does not support listening on a file descriptor.
  3. Receive, net.Socket: parseHandle()'s net.Socket case was throw new Error("TODO").

Fix

src/js/builtins/Ipc.ts: implement serialize() and parseHandle() for net.Socket and net.Server, using Node's NODE_HANDLE wire format (user payload under msg, per lib/internal/child_process.js), so a Bun peer interoperates with a Node peer in either direction. Anything else throws ERR_INVALID_HANDLE_TYPE (Node's own error for unsendable handle types) rather than silently dropping. Unless options.keepOpen is set, the sender's net.Socket is detached (_handle = null, matching Node) and its native handle closed one tick later, after the fd has been duplicated into the transfer.

packages/bun-usockets: new us_socket_group_listen_fd() adopts an already-bound, already-listening fd as a listen socket. The fd is validated with getsockopt(SO_TYPE) (rejects a non-socket like listen({ fd: 0 }) with ENOTSOCK) and SO_ACCEPTCONN where it is reliable (Linux; on macOS it can report 0 for a genuinely listening inherited fd). Wired through SocketGroup::listen_fd and the UnixOrHost::Fd arm in Listener::listen, which previously threw unconditionally. SocketConfig now carries the listen flags (allowHalfOpen, exclusive, reusePort, ipv6Only) through on the fd path, which dropped them before.

src/runtime/ipc_host.rs (do_send): extract the fd from a TCPSocket or TLSSocket as well as a Listener, and dup() it so the in-flight Handle owns its own descriptor for the lifetime of the transfer. Previously Handle.fd borrowed the source's live fd and never closed it; the source could be closed and the number reused before the queued sendmsg ran. The wrapped { cmd: "NODE_HANDLE" } message is now only used once an fd is secured: before this, any handle whose fd could not be resolved would still ship the NODE_HANDLE envelope, which the receiver NACKs until the retry limit and the message is lost entirely. options is threaded through to serialize() so keepOpen is honored.

src/jsc/ipc.rs: Handle owns its fd and closes it on Drop (after the ack, after the retry budget is exhausted, or on queue teardown).

src/js/node/net.ts: connect({ fd }) ran doConnect() first (which adopts the fd and fires open synchronously, setting connecting = false and _handle), then unconditionally set connecting = true, leaving every fd-adopted socket reporting readyState: "opening" forever. Reordered so the assignment happens before the adoption.

Not covered

  • dgram.Socket handles: need an fd getter on the native UDPSocket plus a bind-to-fd path, neither of which exist yet. send() now throws for them instead of silently dropping.
  • Windows: Bun's named-pipe IPC has no SOCKET duplication (WSADuplicateSocketW) yet. serialize() returns null there, so send() delivers the message with no handle (receiver sees handle === undefined) rather than throwing.

#31829 builds the full node:cluster family (round-robin handoff, SCHED_NONE shared handles, UDP, Windows) on top of this same mechanism.

Verification

8 tests across test/js/node/child_process/child_process_ipc_handle.test.ts and test/js/node/net/listen-fd.test.ts:

  • net.Socket parent to child (the reproduction above), child to parent, and { keepOpen: true }
  • net.Server parent to child
  • send(msg, {}) throws ERR_INVALID_HANDLE_TYPE
  • listen({ fd }) systemd socket activation (an inherited listening fd at fd 3)
  • Two Bun-to-Node.js interop tests: a real node child reconstructs both a net.Server and a net.Socket from Bun's wire format, proving the NODE_HANDLE envelope and the SCM_RIGHTS ancillary fd are what Node expects

All 8 fail on an unfixed build (USE_SYSTEM_BUN=1: 0 pass, 8 fail) and pass with this one. The three vendored Node tests for listen({ fd }) (test-listen-fd-detached.js, test-listen-fd-detached-inherit.js, test-net-listen-fd0.js) also pass now; test-net-listen-fd0.js is the one the SO_TYPE validation is for.

@coderabbitai

coderabbitai Bot commented Jun 2, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

This PR adds support for adopting already-listening file descriptors as socket listeners and for serializing net.Server handles across child process IPC. It updates C, Rust, runtime socket, IPC, and test code.

Changes

Socket FD adoption and IPC handle passing

Layer / File(s) Summary
C-level FD adoption contract and implementation
packages/bun-usockets/src/libusockets.h, packages/bun-usockets/src/context.c
Declares and implements us_socket_group_listen_fd: validates inherited fd state, normalizes it, registers polling, applies deferred accept, and reports failures through the error out-parameter. Unsupported platforms return EINVAL.
Rust SocketGroup FD support and listener adoption
src/uws_sys/SocketGroup.rs, src/runtime/socket/Listener.rs, src/runtime/socket/Handlers.rs
SocketGroup::listen_fd forwards the fd adoption call into the new C ABI entrypoint, Listener::listen routes UnixOrHost::Fd through it, and SocketConfig::from_generated copies listen flags onto adopted fd-backed sockets.
IPC net.Server handle serialization
src/js/builtins/Ipc.ts
serialize now emits a NODE_HANDLE envelope for net.Server handles, including the server payload in msg. parseHandle reads msg and recreates the server from the fd while preserving the payload.
Integration tests for fd inheritance and IPC handle passing
test/js/node/net/listen-fd.test.ts, test/js/node/child_process/child_process_ipc_handle.test.ts
POSIX-only tests cover inherited listening sockets passed as fd 3 and IPC transfer of net.Server handles, including a user payload sent to a Node child process.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The changes address both linked issues by serializing server handles over IPC and adopting inherited listening fds for listen({ fd }).
Out of Scope Changes check ✅ Passed The added cluster-ordering and worker adoption adjustments still support the same IPC/server-handle feature and are not unrelated.
Title check ✅ Passed The title matches the core IPC handle-passing change, though it overstates scope by mentioning net.Socket handling.
Description check ✅ Passed The PR description covers purpose, root cause, fix, scope, and verification, matching the required information despite different headings.

Comment @coderabbitai help to get the list of available commands.

@robobun

robobun commented Jun 2, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 10:12 PM PT - Jun 28th, 2026

@robobun, your commit f8cb2ff2e68d2cc301e5d3edf9cfc3bb04e84d0f passed in Build #66593! 🎉


🧪   To try this PR locally:

bunx bun-pr 31715

That installs a local version of the PR into your bun-31715 executable, so you can run:

bun-31715 --bun

@github-actions github-actions Bot added the claude label Jun 2, 2026
@github-actions

github-actions Bot commented Jun 2, 2026

Copy link
Copy Markdown
Contributor

Found 2 issues this PR may fix:

  1. process sendHandle argument not supported #6743 - PR fixes subprocess.send(message, handle) so that net.Server handles are transmitted via SCM_RIGHTS instead of silently dropped, resolving the report that sendHandle arrives as undefined
  2. systemd socket activation doesn't work #22559 - PR fixes Bun.listen({ fd }) which previously threw EINVAL: Bun does not support listening on a file descriptor, the exact mechanism systemd socket activation uses

If this is helpful, copy the block below into the PR description to auto-close these issues on merge.

Fixes #6743
Fixes #22559

🤖 Generated with Claude Code

@robobun

robobun commented Jun 2, 2026

Copy link
Copy Markdown
Collaborator Author

Thanks — added `Fixes #22559`: making `Bun.listen({ fd })` adopt an inherited listening socket is exactly the systemd socket-activation mechanism, and I verified it end-to-end (test/js/node/net/listen-fd.test.ts inherits a listening socket as fd 3 and serves on it).

I left #6743 out: that report specifically passes a net.Socket handle (worker.send(d, socket)), and this PR only implements net.Server handle passing — net.Socket/dgram still arrive as undefined. So #6743 is related but not resolved here.

Comment thread test/js/node/child_process/child_process_ipc_handle.test.ts
Comment thread src/runtime/socket/Listener.rs Outdated
Comment thread src/js/builtins/Ipc.ts Outdated
Comment thread src/js/builtins/Ipc.ts Outdated
Comment thread packages/bun-usockets/src/context.c Outdated
Comment thread src/runtime/socket/Listener.rs Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@test/js/node/child_process/child_process_ipc_handle.test.ts`:
- Around line 137-140: The test collects proc.stdout, proc.stderr, and
proc.exited but never asserts on stderr, allowing unexpected diagnostics to go
unnoticed; add an assertion checking stderr is empty (expect(stderr).toBe(""))
right after the stdout assertion and before the exitCode assertion for the
spawned process referenced by proc (used alongside bunEnv/harness), so failures
printing to stderr are caught.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 6f811324-6b93-42d1-b159-b40a3cf0df36

📥 Commits

Reviewing files that changed from the base of the PR and between f58d146 and 11dfdf0.

📒 Files selected for processing (8)
  • packages/bun-usockets/src/context.c
  • packages/bun-usockets/src/libusockets.h
  • src/js/builtins/Ipc.ts
  • src/runtime/socket/Handlers.rs
  • src/runtime/socket/Listener.rs
  • src/uws_sys/SocketGroup.rs
  • test/js/node/child_process/child_process_ipc_handle.test.ts
  • test/js/node/net/listen-fd.test.ts

Comment thread test/js/node/child_process/child_process_ipc_handle.test.ts
Comment thread src/js/builtins/Ipc.ts Outdated
Comment thread packages/bun-usockets/src/context.c
@robobun

robobun commented Jun 2, 2026

Copy link
Copy Markdown
Collaborator Author

The diff is green. The only failing CI lane is darwin-14-x64-test-bun, and across the last three builds (59948, 59955, 59959) every failure there is unrelated service flake on the macOS agent — Postgres/MySQL (role "bun_sql_test" does not exist), Valkey, and squid/Autobahn proxy containers failing to start. The set of failing files shifts between runs (e.g. sql-mysql/valkey only appeared in the latest), which is the signature of agent-level service provisioning flakiness, not a code issue.

My own tests (test/js/node/net/listen-fd.test.ts, test/js/node/child_process/child_process_ipc_handle.test.ts) pass — zero Failed to listen errors in the latest darwin run, and they do not appear in any failure list. All other platform lanes (Linux x64/aarch64/musl, Windows, FreeBSD, macOS builds, Format, clippy, lint) are green.

This needs a maintainer to merge / re-run the macOS lane on a healthy agent. Handing off.

@robobun

robobun commented Jun 27, 2026

Copy link
Copy Markdown
Collaborator Author

Rebased onto main. The only conflict was in src/js/builtins/Ipc.ts: main renamed $newZigFunction("ipc.zig", ...) to $newRustFunction("ipc.rs", ...) in parseHandle, which collided with my rewrite of serialize()/parseHandle. Resolved by keeping my implementation (net.Server serialization, msg wire key, Windows gating, serialized.msg read) on top of main's rename. The uSockets/Rust files auto-merged. Rebuilding and re-running the tests before force-pushing.

@robobun
robobun force-pushed the farm/70c52d38/ipc-net-server-handle branch from 1883d82 to 65aafd4 Compare June 27, 2026 15:56

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/bun-usockets/src/context.c`:
- Around line 431-434: The `us_socket_group_listen_fd` branch in `context.c` is
incorrectly rejecting all `LIBUS_USE_LIBUV`/`WIN32` builds with `EINVAL`, which
blocks TCP fd adoption. Remove this unconditional failure and route
Windows/libuv builds through the existing TCP fd-adoption path instead,
preserving support for `server.listen({ fd })` and IPC-transferred `net.Server`
handles while leaving any truly unsupported cases gated by the relevant socket
kind or backend-specific checks.

In `@packages/bun-usockets/src/libusockets.h`:
- Around line 346-351: The public contract for us_socket_group_listen_fd is too
narrow and should match the implementation in context.c and the Rust bindings.
Update the documentation near us_socket_group_listen_fd to describe the full
errno surface returned via *error, including the existing ENOTSOCK/EINVAL cases
plus other propagated failures such as EBADF and poll-registration errors. Keep
the comment aligned with the function behavior so the header no longer implies
only two possible error values.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 984d15a1-ad0d-4af8-84b0-f7d931321b0d

📥 Commits

Reviewing files that changed from the base of the PR and between 11dfdf0 and 65aafd4.

📒 Files selected for processing (2)
  • packages/bun-usockets/src/context.c
  • packages/bun-usockets/src/libusockets.h

Comment thread packages/bun-usockets/src/context.c
Comment thread packages/bun-usockets/src/libusockets.h Outdated
Comment thread src/js/builtins/Ipc.ts
@robobun

robobun commented Jun 27, 2026

Copy link
Copy Markdown
Collaborator Author

CI status: the diff is green everywhere it runs. The only red lane is darwin-26-aarch64 - test-bun, which fails before running any test with:

Error: buildkite-agent artifact download timed out after 120s for step 'darwin-aarch64-build-bun'.
Refusing to continue with a partial download (would silently fall back to the wrong binary).

That is a Buildkite artifact-store/agent timeout (the build step produced the binary; the test agent could not download it within 120s), unrelated to this change. It recurred identically across builds 65548 and 65570, and a re-roll did not clear it. On 65570, 84 jobs passed and this was the sole failure (the rest still running). The Format/clippy/lint and all other platform test lanes are green.

Needs a maintainer to re-run the darwin-26-aarch64 lane on a healthy agent (or merge on the strength of the green lanes). Not pushing further ci: retrigger commits.

Comment thread src/uws_sys/SocketGroup.rs Outdated
Comment thread src/js/builtins/Ipc.ts Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/js/builtins/Ipc.ts`:
- Around line 185-193: Condense the long inline rationale in the Ipc.ts comment
near the exclusive fd handling so it fits the repo’s 3-line limit. Keep only the
essential invariant about exclusive: true, Bun.listen({ fd }) adoption, and why
the handle message is emitted immediately; move the detailed cluster/order
history out of the comment and into the PR or commit message.

In `@test/js/node/child_process/child_process_ipc_handle.test.ts`:
- Around line 225-233: In the worker message handler for
child_process_ipc_handle.test.ts, close the primary listening server before
creating the probe client so the connection can only be accepted by the worker;
use the existing server and worker.on('message') flow to stop listening
immediately after receiving { ready: true }. Also handle the { ready: false }
path explicitly by failing the test through the same completion path instead of
waiting indefinitely, and keep the finish callback wired so both success and
failure complete the test deterministically.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: cae241f3-10ce-4670-ac9b-ab508a67ed92

📥 Commits

Reviewing files that changed from the base of the PR and between 74d603e and 36aa120.

📒 Files selected for processing (3)
  • src/js/builtins/Ipc.ts
  • src/uws_sys/SocketGroup.rs
  • test/js/node/child_process/child_process_ipc_handle.test.ts

Comment thread src/js/builtins/Ipc.ts Outdated
Comment thread test/js/node/child_process/child_process_ipc_handle.test.ts
Comment thread test/js/node/child_process/child_process_ipc_handle.test.ts

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All my prior concerns are addressed and the bug hunter found nothing new, but this adds fd-adoption to the uSockets event loop and changes the IPC wire protocol across C/Rust/JS — substantial enough that a maintainer should sign off.

Extended reasoning...

Overview

This PR implements net.Server handle passing over child_process IPC and makes Bun.listen({ fd }) adopt an already-listening socket (enabling systemd socket activation). It spans four layers: a new C function us_socket_group_listen_fd() in bun-usockets (~75 lines of fd validation + epoll/kqueue registration), a Rust FFI wrapper in SocketGroup.rs, the UnixOrHost::Fd arm in Listener::listen, flag plumbing in Handlers.rs, and the serialize()/parseHandle() rewrite in Ipc.ts. Two new test files cover Bun↔Bun, Bun↔Node, message-ordering, and cluster-worker scenarios.

Review history

I left ten inline findings across earlier revisions (test races, Windows error-message regression, msg vs message wire-format key, us_poll_start_rc error handling, allowHalfOpen plumbing, IPC reordering, cluster queryServer routing, dead socktype store, doc-comment sync). Every one is resolved — the most recent commit (82b7e37) closes the last open item (the cluster test's accept() race + missing stderr assertion). CodeRabbit's open items are also addressed. The current bug-hunting pass found nothing.

Security risks

Low. The new surface is listen({ fd: N }), where N is supplied by the calling process itself (or received via SCM_RIGHTS from a parent it forked). The C code validates the fd with getsockopt(SO_TYPE) (rejects non-sockets / closed fds), checks SOCK_STREAM, and on Linux checks SO_ACCEPTCONN; failures surface as structured SystemErrors. There's no privilege boundary crossed — a process that can call Bun.listen can already open arbitrary sockets. The IPC envelope change matches Node's documented NODE_HANDLE format.

Level of scrutiny

High. This is not a mechanical change: it introduces a new code path that registers an arbitrary user-supplied fd with the core event loop, changes the IPC wire protocol (now interoperating with Node.js peers), and alters cluster-worker listen routing (exclusive: true). It touches packages/bun-usockets (the lowest networking layer), Listener.rs (the Bun.listen entry point), and IPC builtins. The macOS SO_ACCEPTCONN exclusion and Windows EINVAL stub are platform-specific decisions a maintainer should ratify.

Other factors

The PR has good test coverage (4 IPC tests + 1 fd-inheritance test, all with deterministic accept ordering and stderr assertions), and CI is green except for an unrelated darwin artifact-download flake per the author's status comment. But the breadth (8 files, C+Rust+JS) and the fact that it activates previously-dead code paths in parseHandle and listenInCluster put it well outside the "simple/obvious" bar for bot approval.

@robobun

robobun commented Jun 27, 2026

Copy link
Copy Markdown
Collaborator Author

Status: ready for maintainer review.

Reproduced by forking a child, accepting a TCP connection in the parent, and calling child.send("conn", sock): send() returned true but the child's message handler received handle === undefined (Node hands it a live net.Socket).

Scope note: the previous head of this branch covered net.Server handles only. This head adds net.Socket (#6743, the more common case and the root cause under the node:cluster breakage) plus the fd-ownership and error-path fixes that work surfaced, and is rebased onto current main. Full detail in the PR body. 8 tests, all failing on an unfixed build, including two Bun-parent / Node.js-child interop round trips.

#31829 builds the full node:cluster family (round-robin handoff, SCHED_NONE shared handles, UDP, Windows) on top of this same mechanism.

subprocess.send(message, handle) and process.send(message, handle)
returned true but the peer's message handler received undefined as the
handle. fd passing over the IPC channel did not exist, even though the
SCM_RIGHTS transport and the NODE_HANDLE ack/nack handshake were already
in place in src/jsc/ipc.rs.

- src/js/builtins/Ipc.ts: implement serialize() and parseHandle() for
  net.Socket and net.Server using Node's NODE_HANDLE wire format (the
  user payload under `msg`), so Bun and Node interoperate in both
  directions. Unsupported handle types throw ERR_INVALID_HANDLE_TYPE
  instead of silently dropping. Unless options.keepOpen is set, the
  sender's net.Socket is detached and its native handle closed one tick
  after the fd has been duplicated into the transfer.
- packages/bun-usockets: add us_socket_group_listen_fd() so
  net.Server.listen({ fd }) can adopt an already-listening fd. The fd is
  validated (SO_TYPE, SO_ACCEPTCONN where reliable) before adoption, and
  the listen flags are carried through on the fd path.
- src/runtime/ipc_host.rs: do_send extracts the fd from a TCPSocket or
  TLSSocket as well as a Listener, dup()s it so the in-flight Handle
  owns its own descriptor, and only ships the wrapped NODE_HANDLE
  message once an fd is secured. An unresolvable handle previously still
  sent NODE_HANDLE with no fd, which the receiver NACKs until the retry
  limit and the message is lost. Threads `options` to serialize().
- src/jsc/ipc.rs: Handle owns its fd and closes it on Drop.
- src/js/node/net.ts: connect({ fd }) adopts the fd and fires `open`
  synchronously inside doConnect, so it must run after `connecting` is
  set, not before; fd-adopted sockets were stuck on readyState
  "opening".

Fixes #6743
Fixes #22559
@robobun
robobun force-pushed the farm/70c52d38/ipc-net-server-handle branch from 82b7e37 to a1d9991 Compare June 28, 2026 22:17
@robobun robobun changed the title ipc: support passing net.Server handles via fork() + subprocess.send() ipc: pass net.Server and net.Socket handles over send(message, handle) Jun 28, 2026
Comment thread src/js/builtins/Ipc.ts Outdated
…e check

The squash that added net.Socket support dropped several fixes that had
landed and were verified earlier in review:

- parseHandle net.Server: listen({ fd, exclusive: true }) + synchronous
  emit. Without exclusive, a cluster worker routes the worker-local fd
  back to the primary as a queryServer (re-breaking
  test-net-listen-handle-in-cluster-1.js); the deferred listen callback
  let a follow-up message overtake the handle.
- us_socket_group_listen_fd: reject non-SOCK_STREAM fds (the SO_TYPE
  value was read but unused; on macOS the SO_ACCEPTCONN check is compiled
  out, so this is the only non-stream guard).
- Widen the error-surface doc comments (EBADF / poll-registration errnos).
- Re-add the message-ordering and cluster-worker regression tests.
Comment thread src/js/builtins/Ipc.ts
Comment thread src/runtime/ipc_host.rs
…handle has no fd

Two net.Socket handoff fixes:
- serialize(): on a no-keepOpen handoff of a server-accepted socket,
  decrement handle.server._connections synchronously and null handle.server
  (Node semantics). Without it, the detached socket's _destroy never runs
  under allowHalfOpen, so server.close() never drains / never emits 'close'
  and getConnections() over-reports.
- do_send: if serialize() returned a handle but no transferable fd can be
  extracted (e.g. a not-yet-connected socket), surface an error instead of
  silently sending the message with no handle — serialize() may have already
  detached the sender's socket.

Adds a regression test that server.close() drains after an allowHalfOpen
handoff.
Comment thread src/js/builtins/Ipc.ts
Comment thread test/js/node/child_process/child_process_ipc_handle.test.ts Outdated
robobun and others added 2 commits June 28, 2026 23:54
…ests concurrently

Windows has no SOCKET duplication over the IPC named pipe yet. serialize()
threw NotImplementedError for any net.Server/net.Socket handle, which
propagates synchronously out of send() and broke Node IPC tests that
previously tolerated a dropped handle (child_process_ipc, fork-closed-
channel-segfault). Return null instead (send the message with no handle,
receiver sees undefined), matching the pre-feature behavior. The handle-
requiring cluster test stays at its main baseline (unsupported on Windows).

Also make the 10 subprocess-spawning tests concurrent per test/CLAUDE.md.
Comment thread src/uws_sys/SocketGroup.rs Outdated
Comment thread test/js/node/child_process/child_process_ipc_handle.test.ts Outdated
robobun added 2 commits June 29, 2026 00:13
- SocketGroup::listen_fd doc: on success the group owns the fd
  (us_listen_socket_close closes it); only on failure does the caller keep
  it — matching the C header/impl contract.
- IPC handle test header: Windows send() falls back to delivering the
  message with no handle (serialize returns null), it does not throw.
@robobun

robobun commented Jun 29, 2026

Copy link
Copy Markdown
Collaborator Author

Status: the diff is green on every lane it touches.

  • cargo clippy and the bun-plugin-svelte package check, which were red on the prior commit, are green on f8cb2ff (the clippy question_mark lint in native_handle_fd is fixed with a ?).
  • Format, Lint, and every build/test lane except one are passing, including the macOS test lanes darwin-26-aarch64 (passed) and darwin-14-x64 (passed, after one infra expiry + automatic retry).

The only lane not passing is darwin-14-aarch64 test-bun. In build #66593 it has entered Buildkite's expired state on four attempts (exit status none, no tests executed) and keeps getting rescheduled. That is a macOS-14-aarch64 agent/artifact timeout at the setup step, before any test runs, and it is independent of this change. Re-rolls did not clear the same darwin infra flake on earlier builds.

This needs a maintainer to merge on the strength of the green lanes, or to re-run the darwin-14-aarch64 lane once that agent fleet is healthy.

@robobun

robobun commented Jul 2, 2026

Copy link
Copy Markdown
Collaborator Author

Heads up for anyone picking this up. Review on #31829 surfaced two issues in shared plumbing that this PR also reaches. Both are pre-existing on main and both are made live here by serialize() returning real handles. Both are now fixed upstream in #31829 (505f5ad and 54c22e2), so this branch needs nothing beyond a rebase once that lands.

  • parseHandle's net.Socket case rebuilds the received socket with socket.connect({ fd }), and allowHalfOpen was dropped on the way to the C layer, so on a peer FIN the native socket was closed in the same frame as on_end and a response written from the 'end' handler could lose backpressured bytes. Correcting what I first wrote here: the root cause started a layer deeper than us_socket_from_fd. SocketConfig::from_generated copied allowHalfOpen out of the JS options only in the hostname branch, so an fd (or unix) connect parsed as allowHalfOpen: false before the C layer was ever consulted. The fix needed all three layers, including Handlers.rs, which this PR touches.
  • The NODE_HANDLE receiver reaches SendQueue::insert_message while a regular message can be partially written at queue[0], which tripped the debug_assert!(self.queue[0].is_ack_nack()) there. Debug builds only; release builds compile the assert out and the behavior was already correct. The assertion is now scoped to the sender-side condition it was written for.

Tracking both in #31829 rather than landing the same shared-plumbing change in two open PRs. Detail: #31829 (comment)

@robobun

robobun commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator Author

I arrived at listen({ fd }) independently from the other direction (#22559, systemd socket activation) and wrote the same us_socket_group_listen_fd before finding this PR. Dropping mine rather than competing — this one is larger, green, and already has the test. Three notes from the other implementation that might be worth folding in here:

1. listen(2) on the adopted descriptor instead of probing SO_ACCEPTCONN.

uv_listen calls listen(fd, backlog) unconditionally after uv_tcp_open, so Node accepts a descriptor that is bound but not yet listening. The SO_ACCEPTCONN guard here rejects that case on Linux (accepting == 0EINVAL), and on macOS it is compiled out, so such a descriptor gets registered and then never accepts.

listen(2) on an already-listening socket only adjusts the backlog, so one call covers both shapes and makes the probe unnecessary — including the macOS caveat the comment already calls out:

int result;
do
    result = listen(fd, backlog);
while (IS_EINTR(result));

if (result != 0) {
    *error = LIBUS_ERR;
    return LIBUS_SOCKET_ERROR;
}

2. bsd_set_nonblocking(fd) also sets FD_CLOEXEC.

uv_tcp_open sets O_NONBLOCK and nothing else. Setting FD_CLOEXEC on an inherited listener silently closes it across a self-exec, which is how the zero-downtime-restart form of socket activation keeps a port bound. It does not affect the IPC/SCM_RIGHTS path this PR is built for, but it does affect listen({ fd }).

3. The error a failed listen({ fd }) surfaces.

net.Server.listen's catch runs the error through formatListenError(err, hostname, port), and hostname has already defaulted to "::" by then, so a bad descriptor reports:

listen EINVAL: invalid argument ::

Node's uvExceptionWithHostPort appends nothing when there is no address, so it reports listen EINVAL: invalid argument. Passing undefined as the address for the fd form fixes that. Separately, adding ENOTSOCK ("socket operation on non-socket") and EBADF ("bad file descriptor") to uvListenErrorDescription gives a descriptor-that-is-not-a-socket a Node-shaped message and sets err.syscall; test/js/node/test/parallel/test-net-listen-fd0.js accepts either EINVAL or ENOTSOCK here, so surfacing the real errno stays within Node's contract.

One overlap to be aware of: this PR sets result.allow_half_open inside the fd branch of SocketConfig::from_generated. #33498 hoists that assignment above the whole if/else if chain, because the unix branch was missing it too (a separate pre-existing bug: a unix listener's accepted socket is closed on the peer's FIN even with allowHalfOpen: true). Whichever lands second can drop the now-duplicate line; the hoist covers the fd branch.

cirospaciari added a commit that referenced this pull request Jul 17, 2026
Stacked on the cluster branch, which owns IPC handle passing and fd
adoption. This adds the vendored child_process tests neither that branch
nor #31715 carries, and fixes three gaps they expose.

child_process parallel tests: 93 -> 100 of 109 passing (+13 test files).

1. process.stdout/stderr leaked O_NONBLOCK to `stdio: "inherit"` children.

   Merely reading the `process.stdout` getter switched fd 1 to O_NONBLOCK.
   That flag lives on the open file description, which is shared with every
   child that inherits the descriptor, so children failed large writes with
   EAGAIN (python: "BlockingIOError: [Errno 35]").

   Bun already intended to prevent this - BunProcess.cpp forces stdio sinks
   synchronous on POSIX - but the undo in
   Bun__ForceFileSinkToBeSynchronousForProcessObjectStdio was gated on
   `self.fd`, which FileSink::setup() never populates, so it never ran.
   Consult the writer, which owns the descriptor after start(), and clear the
   poll's Nonblocking flag with it so the two agree.

   Only observable when stdout is a pipe; a terminal already took the isatty
   branch. Costs nothing: POSIX stdio was already force-sync, so this only
   stops the flag escaping to children.

   Fixes test-child-process-set-blocking.

2. A NODE_-prefixed cmd is reserved for the channel's own traffic and emits
   "internalMessage", not "message". Bun recognised the three
   NODE_HANDLE{,_ACK,_NACK} commands and delivered everything else, including
   a caller's own NODE_-prefixed cmd, as a plain "message".

   Fixes test-child-process-internal.

3. spawn('cat', { stdio: ['pipe', other.stdin, 'inherit'] }) threw
   "TODO: stream.Writable stdio". A subprocess's `.stdin` is a WriteStream
   over a FileSink: it carries no `fd`, but the sink knows the pipe's write
   end and already exposes it through _getFd(). A Writable also satisfies
   isNodeStreamReadable (both carry .on/.pipe), so the Readable branch was the
   one actually taken and its Writable twin was unreachable; fold them.

   Fixes test-child-process-stdio-merge-stdouts-into-cat.

Also re-syncs common.isPi to v26.3.0's function form; it had been rewritten
as an eagerly-evaluated const, so `common.isPi()` threw.
Jarred-Sumner pushed a commit that referenced this pull request Jul 22, 2026
…(93 → 99 of 109) (#34433)

> **Stacked on #31829** (`ciro/cluster-tests-v26`), which owns IPC
handle passing and fd adoption. Review that first; this PR's own diff is
**4 source files, +61/−21**, plus 13 vendored tests.
>
> An earlier revision of this PR reimplemented handle passing and
`us_socket_group_listen_fd` independently — that duplicated #31829 and
#31715 and has been dropped. #31829's version is better: it dups the
descriptor (`Handle::init_dup`) instead of transferring ownership,
throws `ERR_INVALID_HANDLE_TYPE` for unsupported handles, covers
`dgram.Native`, and handles Windows socket transfer.

## Target

**100% of Node.js v26.3.0's `child_process` test suite.**

| | tests |
|---|---|
| Node v26.3.0 `test-child-process-*` | 109 |
| Passing on `main` today | **93** |
| Passing on #31829 alone | 93 (it adds no `test-child-process-*` files)
|
| Passing with this PR | **99** |
| New test files added here | **12** vendored + 2 bun-suite |
| Remaining gap to 100% | 10 |

Every added test is copied **byte-identical** from `v26.3.0` (verified
with `diff -q`), per `test/js/node/test/parallel/CLAUDE.md`. No test is
added that does not pass, and none is flaky — see the note on
`stdio-reuse-readable-stdio` below.

Neither #31829 nor #31715 adds a single `test-child-process-*` file, so
these 13 are what actually pin the behavior to v26.3.0.

## The gaps these tests surfaced

### 1. `process.stdout` leaked `O_NONBLOCK` onto `stdio: "inherit"`
children

Merely *reading* the `process.stdout` getter switched fd 1 to
`O_NONBLOCK`. That flag lives on the **open file description**, shared
with every child inheriting the descriptor — so children died on large
writes:

```
BlockingIOError: [Errno 35] write could not complete without blocking
```

Bun already intended to prevent this (`BunProcess.cpp` forces stdio
sinks synchronous on POSIX), but the undo in
`Bun__ForceFileSinkToBeSynchronousForProcessObjectStdio` was gated on
`self.fd`, which `FileSink::setup()` never populates — so it silently
never ran. Only observable when stdout is a **pipe**; a TTY already took
the `isatty` branch.

Fixes `test-child-process-set-blocking`.

### 2. `NODE_`-prefixed messages did not emit `internalMessage`

Node reserves that `cmd` prefix for channel traffic. Bun recognised only
the three `NODE_HANDLE{,_ACK,_NACK}` commands and delivered everything
else — including a caller's own `NODE_`-prefixed cmd — as a plain
`message`. (Bun's cluster keys off `NODE_UNIQUE_ID` and a separate
internal wire flag, so nothing in-tree depended on the old behavior;
#31829's cluster suite is 27/27 with this change.)

Applies on **both** ends, as Node's `setupChannel()` does: the
parent-side `ChildProcess` in `child_process.ts` and the child-side
`process` in `Process__emitMessageEvent`. Verified against node v26.3.0
— `NODE_foo` is internal, while `fooNODE_` and a bare `NODE_` stay plain
messages.

Fixes `test-child-process-internal` (child→parent); the parent→child
direction is covered by a case in bun's own suite, since no vendored
test exercises it.

### 3. Another subprocess's `.stdin` could not be a stdio target

`spawn('cat', { stdio: ['pipe', other.stdin, 'inherit'] })` threw `TODO:
stream.Writable stdio`. A subprocess's `.stdin` is a WriteStream over a
FileSink: it has no `fd`, but the sink knows the pipe's write end and
already exposes `_getFd()`. A Writable also satisfies
`isNodeStreamReadable` (both carry `.on`/`.pipe`), so the Readable
branch was the one actually taken and the Writable twin was unreachable
dead code.

Covered by a posix-gated case in bun's own `child_process.test.ts`
rather than node's `test-child-process-stdio-merge-stdouts-into-cat`: on
Windows the sink hands back a raw HANDLE rather than a descriptor, so
there is no fd to pass along and `nodeToBun` still throws. Node carries
no Windows guard for that test and vendored tests must stay verbatim, so
it is not added. The fix is a no-op on Windows.

### Also

`common.isPi` re-synced to v26.3.0's function form; it had been
rewritten as an eagerly-evaluated const, so `common.isPi()` threw.

## Verification

Each fix was checked against **real node v26.3.0** and an **unmodified
canary** as a negative control:

| flow | this PR | node v26.3.0 | canary |
|---|---|---|---|
| `stdio:'inherit'` + 100KB child write, stdout a pipe |
`NONBLOCK=False`, exit 0 | same | `NONBLOCK=True`, `BlockingIOError`,
exit 120 |

Two review concerns were **measured and refuted** rather than accepted:
a later `Bun.write(Bun.stdout)` does *not* re-set `O_NONBLOCK`, and
making stdout blocking costs nothing (10MB through a slow pipe: 1016ms
vs canary's 1034ms, event loop stays live) — POSIX stdio was already
force-sync, so this only stops the flag escaping to children.

No regressions: `node/cluster` 27/0, `node/child_process`, `node/net`,
`node/http`, `node/http2`, `node/stream`, `node/dgram`, `node/process`,
`web/websocket` all at parity. (This box is heavily loaded and ~2
timing-sensitive tests flake per full run; counts above are with a retry
pass.)

## Remaining

- **Socket-list / worker bookkeeping** (2: `fork-getconnections`,
`fork-net`) — needs `internal/socket_list`, `server._setupWorker` and
`_connections` accounting. (`pass-fd` is now included and passing:
because the send path dups the descriptor, the child's copy is
independent and Node's sender-detach dance isn't required for it.)
- **Readable-direction stdio reuse** (3: `stdio-reuse-readable-stdio`,
`pipe-dataflow`, `fork-stdio`). Handing a child's `.stdout` to another
child races: bun's subprocess pipe reader starts eagerly, so the parent
steals bytes from the second child. A prototype made `stdio-reuse` pass
but flaked ~1/3 of runs (node: 6/6 clean), so it was dropped rather than
shipped — the invariant is spelled out in `pipe-dataflow`, which asserts
`readStart` is never called. Needs bun's stdout buffering to become
lazy. `pipe-dataflow` additionally wants node's internal
`_handle.readStart`.
- **`dgram.Socket` handle passing** (1: `fork-dgram`) — #31829 covers
`dgram.Native`, not `dgram.Socket`.
- **`send()` backpressure return value** (1: `send-returns-boolean`).
Bun's `indicate_backoff` (`waiting_for_ack.is_some() &&
!queue.is_empty()`) already mirrors Node's `_handleQueue.length === 1`;
the test fails only because it sends a raw `server._handle`, which Node
classifies as `net.Native` and the serializer drops. `net.Native` has no
clean bun equivalent — wants a maintainer's call.
- **`node:test` standalone** (1: `windows-hide`) — pre-existing, owned
elsewhere.
- **`process.channel.fd` on the child side** —
`test-child-process-fork-advanced-header-serialization` is vendored and
green, but it does not exercise what it was written for. Its child gates
the hostile length-header write on `process.channel?.fd`, and Bun's
child-side `process.channel` is a `Control` EventEmitter with no `fd`
(node reports `fd: 3`, Bun `undefined`), so the write is skipped and
only "the children exit 0" is asserted. Counted above because it passes
and is byte-identical, but the meaningful figure is 98, not 99. Exposing
`fd` is not a one-liner: it would make this test start pushing malformed
4-byte headers into the IPC channel, which the parser then has to
survive to keep the test green.
- **`O_NONBLOCK` reaching a child that inherits another subprocess's
`.stdin`** — an intentionally-excluded site of the same bug class as gap
1 above, newly reachable through this PR's `streamFdOf`. The parent's
write end of `p3`'s stdin carries `O_NONBLOCK` so the parent's FileSink
can write asynchronously, and the flag rides the `dup2` into the child
because it lives on the shared open file description. A child that does
not retry on `EAGAIN` therefore fails once its write outgrows the
socketpair buffer — verified: `cat` reports `write error: Resource
temporarily unavailable` and exits 1, and a `fcntl` probe confirms
`NONBLOCK=True` on the child's fd 1. Clearing the flag is not a fix,
since the parent shares that description and would start blocking, and
`posix_spawn` has no post-fork hook to clear it child-side; a real fix
needs the child to get its own description via a blocking relay pipe.
Pinned by a `.todo`'d large-write case next to the stdin-as-stdio test
in `child_process.test.ts`. The small writes in that test never fill the
buffer, which is why it passes.
- **Windows fd inheritance for socket/pipe stdio** (2:
`stdio-merge-stdouts-into-cat`, `server-close`). Both pass on posix but
go red on Windows: a subprocess's stdin sink and a live `net.Socket`
both hand back a HANDLE rather than a CRT descriptor `Bun.spawn` can
inherit into `CreateProcess`. Node has no Windows guard for either and
vendored files must stay verbatim, so neither is added. `stdio-merge`'s
behaviour is covered posix-gated in `child_process.test.ts`.
@alii

alii commented Aug 12, 2026

Copy link
Copy Markdown
Member

landed in #31829, passing net.Server and net.Socket over send() works on main

@alii alii closed this Aug 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

systemd socket activation doesn't work process sendHandle argument not supported

2 participants