Skip to content

Commit 25cfe2b

Browse files
divybotlittledivy
andauthored
fix(ext/node): support ChildProcess.send with net.Server handles (#34948)
## Summary `subprocess.send('server', server)` from `node:child_process`, where `server` is an unlistened `net.createServer()`, threw: ``` error: Uncaught (in promise) Error: Not implemented: ChildProcess.send with non-TCP net.Server handle ``` An unlistened `net.Server` has a `null` underlying handle, and `getIpcHandleInfo` only accepted TCP handles. This makes `ChildProcess.send` with `net.Server` / `net.Socket` handles work in the cases Node supports. ## Changes - **Null handle → plain message.** An unlistened `net.Server` (or detached `net.Socket`) has a null underlying handle. `getIpcHandleInfo` now returns `null` in that case and the message is delivered without a handle, matching Node's `if (!handle) message = message.msg`. This is the exact case in the issue's repro. - **Non-TCP (Pipe / unix-socket) handles.** `net.Server` and `net.Socket` handles backed by a `Pipe` are now transferable. The IPC message records `nativeKind` (`"tcp"` | `"pipe"`) so the receiver reconstructs the correct wrap type. - **`net.Server.prototype.listen` Pipe branch.** A `Pipe` wrap exposes an `fd` getter, so without an explicit handle branch `listen(pipe)` fell into the `options.fd` path and re-opened the already-owned fd, failing with `EEXIST`. - **`uv_pipe_open_listener`** (mirrors the existing `uv_tcp_open_listener`). A pipe opened from an inherited *listening* fd must not eagerly create an `AsyncFd`; otherwise `uv_pipe_listen`'s `UnixListener` registration hits `epoll_ctl(EPOLL_CTL_ADD)` `EEXIST` on the same fd. `PipeWrap::open` dispatches to it for server pipes. ## Tests Adds `tests/specs/node/child_process_ipc_handle/`: - `unlistened_server_main.mjs` — the issue's repro (send an unlistened server; child receives the message with no handle). - `pipe_server_main.mjs` — transfer a listening unix-socket server; child reconstructs a working `net.Server`. Both run under `json` and `advanced` serialization. Full suite (10 tests) passes: `cargo test --test specs -- node::child_process_ipc_handle`. Note: a full connect round-trip over a *transferred unix-socket* server isn't asserted because `closeAfterSend` unlinks the socket file on the parent's `server.close()`, which is inherent to unix sockets (TCP keeps working because the bound port stays valid while the child's dup'd fd listens). Closes #34921 Closes denoland/divybot#502 Co-authored-by: divybot <divybot@users.noreply.github.com> Co-authored-by: Divy Srivastava <me@littledivy.com>
1 parent 0444172 commit 25cfe2b

9 files changed

Lines changed: 257 additions & 17 deletions

File tree

ext/node/ops/pipe_wrap.rs

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -325,8 +325,18 @@ impl PipeWrap {
325325
if pipe.is_null() {
326326
return uv_compat::UV_EBADF;
327327
}
328-
// SAFETY: pipe is valid (null-checked above).
329-
let ret = unsafe { uv_compat::uv_pipe_open(pipe, fd) };
328+
// A wrap constructed with `new Pipe(SERVER)` opens the fd as a listening
329+
// socket. Like the TCP split (see TCPWrap::open), the listener path must
330+
// not pre-register an AsyncFd, or uv_pipe_listen's reactor registration
331+
// would fail with EEXIST. This is hit when a unix-socket net.Server is
332+
// transferred over IPC (ChildProcess.send).
333+
let ret = if self.pipe_type.get() == PipeType::Server {
334+
// SAFETY: pipe is valid (null-checked above).
335+
unsafe { uv_compat::uv_pipe_open_listener(pipe, fd) }
336+
} else {
337+
// SAFETY: pipe is valid (null-checked above).
338+
unsafe { uv_compat::uv_pipe_open(pipe, fd) }
339+
};
330340
if ret == 0 {
331341
// Register as UvOwned - the native handle owns the fd.
332342
state.borrow_mut::<deno_io::FdTable>().register_uv_owned(fd);

ext/node/polyfills/internal/child_process.ts

Lines changed: 46 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2026,14 +2026,26 @@ function getIpcHandleInfo(handle, options) {
20262026
const { Server: NetServer } = lazyNet();
20272027
const { Socket: DgramSocket } = lazyDgram();
20282028
if (ObjectPrototypeIsPrototypeOf(Socket.prototype, handle)) {
2029-
if (!ObjectPrototypeIsPrototypeOf(TCP.prototype, handle._handle)) {
2029+
const inner = handle._handle;
2030+
// Match Node's handleConversion["net.Socket"].send, which returns the
2031+
// socket's native handle. A socket without an underlying handle (e.g.
2032+
// already destroyed) yields null; Node then strips the handle and sends
2033+
// the message alone instead of throwing.
2034+
if (!inner) {
2035+
return null;
2036+
}
2037+
const isTcp = ObjectPrototypeIsPrototypeOf(TCP.prototype, inner);
2038+
const isPipe = ObjectPrototypeIsPrototypeOf(Pipe.prototype, inner);
2039+
if (!isTcp && !isPipe) {
20302040
notImplemented("ChildProcess.send with non-TCP net.Socket handle");
20312041
}
20322042
return {
2033-
rawFd: rawFdFromTcpHandle(handle._handle),
2043+
rawFd: rawFdFromTcpHandle(inner),
20342044
message: {
20352045
cmd: "NODE_HANDLE",
20362046
type: IPC_HANDLE_NET_SOCKET,
2047+
// Distinguishes the wrap type the receiver should reconstruct.
2048+
nativeKind: isTcp ? "tcp" : "pipe",
20372049
msg: undefined,
20382050
},
20392051
closeAfterSend: options.keepOpen !== true,
@@ -2046,14 +2058,26 @@ function getIpcHandleInfo(handle, options) {
20462058
}
20472059

20482060
if (ObjectPrototypeIsPrototypeOf(NetServer.prototype, handle)) {
2049-
if (!ObjectPrototypeIsPrototypeOf(TCP.prototype, handle._handle)) {
2061+
const inner = handle._handle;
2062+
// Match Node's handleConversion["net.Server"].send, which returns
2063+
// server._handle. A server that hasn't started listening (or was
2064+
// already closed) has a null handle; Node then strips the handle and
2065+
// sends the message alone instead of throwing.
2066+
if (!inner) {
2067+
return null;
2068+
}
2069+
const isTcp = ObjectPrototypeIsPrototypeOf(TCP.prototype, inner);
2070+
const isPipe = ObjectPrototypeIsPrototypeOf(Pipe.prototype, inner);
2071+
if (!isTcp && !isPipe) {
20502072
notImplemented("ChildProcess.send with non-TCP net.Server handle");
20512073
}
20522074
return {
2053-
rawFd: rawFdFromTcpHandle(handle._handle),
2075+
rawFd: rawFdFromTcpHandle(inner),
20542076
message: {
20552077
cmd: "NODE_HANDLE",
20562078
type: IPC_HANDLE_NET_SERVER,
2079+
// Distinguishes the wrap type the receiver should reconstruct.
2080+
nativeKind: isTcp ? "tcp" : "pipe",
20572081
msg: undefined,
20582082
},
20592083
// Match Node's handleConversion["net.Server"].postSend, which calls
@@ -2128,25 +2152,29 @@ function createIpcHandle(message, rawFd) {
21282152
const { Server: NetServer } = lazyNet();
21292153
const { Socket: DgramSocket } = lazyDgram();
21302154
if (message.type === IPC_HANDLE_NET_SOCKET) {
2131-
const tcp = new TCP(tcpSocketType.SOCKET);
2132-
const err = tcp.open(rawFd);
2155+
const inner = message.nativeKind === "pipe"
2156+
? new Pipe(socketType.SOCKET)
2157+
: new TCP(tcpSocketType.SOCKET);
2158+
const err = inner.open(rawFd);
21332159
if (err !== 0) {
21342160
throw errnoException(codeMap.get(err), "open");
21352161
}
21362162
try {
21372163
return new Socket({
2138-
handle: tcp,
2164+
handle: inner,
21392165
readable: true,
21402166
writable: true,
21412167
});
21422168
} catch (err) {
2143-
tcp.close();
2169+
inner.close();
21442170
throw err;
21452171
}
21462172
}
21472173
if (message.type === IPC_HANDLE_NET_SERVER) {
2148-
const tcp = new TCP(tcpSocketType.SERVER);
2149-
const err = tcp.open(rawFd);
2174+
const inner = message.nativeKind === "pipe"
2175+
? new Pipe(socketType.SERVER)
2176+
: new TCP(tcpSocketType.SERVER);
2177+
const err = inner.open(rawFd);
21502178
if (err !== 0) {
21512179
throw errnoException(codeMap.get(err), "open");
21522180
}
@@ -2156,10 +2184,10 @@ function createIpcHandle(message, rawFd) {
21562184
// detects an already-listening fd and skips the bind/listen syscalls.
21572185
const server = new NetServer();
21582186
try {
2159-
server.listen(tcp);
2187+
server.listen(inner);
21602188
return server;
21612189
} catch (err) {
2162-
tcp.close();
2190+
inner.close();
21632191
throw err;
21642192
}
21652193
}
@@ -2561,7 +2589,12 @@ function setupChannel(
25612589
let handleInfo = null;
25622590
if (handle !== undefined) {
25632591
handleInfo = getIpcHandleInfo(handle, options);
2564-
handleInfo.message.msg = message;
2592+
// `getIpcHandleInfo` returns null when the handle has no underlying
2593+
// native handle (e.g. a server that never started listening). Match
2594+
// Node, which strips the handle and sends the plain message instead.
2595+
if (handleInfo !== null) {
2596+
handleInfo.message.msg = message;
2597+
}
25652598
}
25662599

25672600
return enqueueOrDispatch(message, handleInfo, callback);

ext/node/polyfills/net.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2847,8 +2847,15 @@ Server.prototype.listen = function (...args: unknown[]) {
28472847
options = (options as any)._handle || (options as any).handle || options;
28482848
const flags = _getFlags(options.ipv6Only, options.reusePort);
28492849

2850-
// (handle[, backlog][, cb]) where handle is an object with a handle
2851-
if (ObjectPrototypeIsPrototypeOf(TCP.prototype, options)) {
2850+
// (handle[, backlog][, cb]) where handle is an object with a handle.
2851+
// A Pipe wrap exposes an `fd` getter, so it must be matched here before
2852+
// the `options.fd` branch below -- otherwise the already-opened fd would
2853+
// be re-opened and rejected (EEXIST). This is the path taken when a
2854+
// unix-socket server is transferred over IPC (ChildProcess.send).
2855+
if (
2856+
ObjectPrototypeIsPrototypeOf(TCP.prototype, options) ||
2857+
ObjectPrototypeIsPrototypeOf(Pipe.prototype, options)
2858+
) {
28522859
this._handle = options;
28532860
this[asyncIdSymbol] = this._handle.getAsyncId();
28542861

libs/core/uv_compat/pipe.rs

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -482,6 +482,50 @@ pub unsafe fn uv_pipe_open(pipe: *mut uv_pipe_t, fd: c_int) -> c_int {
482482
0
483483
}
484484

485+
/// Open `pipe` from an fd that should be used as a *listening* socket
486+
/// (typically a unix-socket `net.Server` received via IPC handle passing).
487+
///
488+
/// Unlike [`uv_pipe_open`], this does NOT eagerly create an `AsyncFd`. The
489+
/// tokio `UnixListener` that [`uv_pipe_listen`] registers is the sole reactor
490+
/// registration for the fd; pre-registering an `AsyncFd` here would make
491+
/// `uv_pipe_listen`'s `epoll_ctl(EPOLL_CTL_ADD)` fail with `EEXIST`. This
492+
/// mirrors the TCP split between [`uv_tcp_open`] and [`uv_tcp_open_listener`].
493+
///
494+
/// # Safety
495+
/// `pipe` must be a valid pointer to an initialized `uv_pipe_t`. `fd` must be
496+
/// a valid socket fd whose ownership the caller is transferring.
497+
#[cfg(unix)]
498+
pub unsafe fn uv_pipe_open_listener(pipe: *mut uv_pipe_t, fd: c_int) -> c_int {
499+
if fd < 0 {
500+
return UV_EBADF;
501+
}
502+
unsafe {
503+
// Set non-blocking mode (tokio's `from_std` requires it).
504+
let flags = libc::fcntl(fd, libc::F_GETFL);
505+
if flags == -1 {
506+
return UV_EBADF;
507+
}
508+
if libc::fcntl(fd, libc::F_SETFL, flags | libc::O_NONBLOCK) == -1 {
509+
return UV_EBADF;
510+
}
511+
// Record the fd so uv_pipe_listen can adopt it. Crucially, do not create
512+
// an AsyncFd here (see the doc comment above).
513+
(*pipe).internal_fd = Some(fd);
514+
}
515+
0
516+
}
517+
518+
/// Windows has no SCM_RIGHTS-style fd passing for unix sockets, so the IPC
519+
/// listener-transfer path is never exercised there. Fall back to the regular
520+
/// open to keep the API uniform across platforms.
521+
///
522+
/// # Safety
523+
/// See [`uv_pipe_open`].
524+
#[cfg(windows)]
525+
pub unsafe fn uv_pipe_open_listener(pipe: *mut uv_pipe_t, fd: c_int) -> c_int {
526+
unsafe { uv_pipe_open(pipe, fd) }
527+
}
528+
485529
/// Build a `sockaddr_un` from a path, handling both abstract sockets
486530
/// (path starts with `\0`, Linux-only) and filesystem sockets.
487531
///

tests/specs/node/child_process_ipc_handle/__test__.jsonc

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,30 @@
3535
"args": "run -A server_main.mjs advanced",
3636
"output": "server_main.out",
3737
"exitCode": 0
38+
},
39+
"unlistened_server_json": {
40+
"if": "unix",
41+
"args": "run -A unlistened_server_main.mjs json",
42+
"output": "unlistened_server_main.out",
43+
"exitCode": 0
44+
},
45+
"unlistened_server_advanced": {
46+
"if": "unix",
47+
"args": "run -A unlistened_server_main.mjs advanced",
48+
"output": "unlistened_server_main.out",
49+
"exitCode": 0
50+
},
51+
"pipe_server_json": {
52+
"if": "unix",
53+
"args": "run -A pipe_server_main.mjs json",
54+
"output": "pipe_server_main.out",
55+
"exitCode": 0
56+
},
57+
"pipe_server_advanced": {
58+
"if": "unix",
59+
"args": "run -A pipe_server_main.mjs advanced",
60+
"output": "pipe_server_main.out",
61+
"exitCode": 0
3862
}
3963
}
4064
}
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import { fork } from "node:child_process";
2+
import net from "node:net";
3+
import process from "node:process";
4+
import os from "node:os";
5+
import path from "node:path";
6+
7+
const serialization = process.argv[2] === "advanced" ? "advanced" : "json";
8+
9+
if (process.argv[3] === "child") {
10+
// Child receives a listening unix-socket (Pipe) net.Server from the parent.
11+
// Before this fix sending it threw "ChildProcess.send with non-TCP
12+
// net.Server handle"; we now reconstruct a working net.Server around the
13+
// inherited fd. We don't do a connect round-trip here: closing the parent's
14+
// server unlinks the socket path (so the path stops routing), which is an
15+
// inherent property of transferring unix-socket servers, not a Deno bug.
16+
process.on("message", (msg, server) => {
17+
const ok = server instanceof net.Server &&
18+
typeof server.address === "function";
19+
if (ok) {
20+
// The inherited fd is a real listening socket we can accept on.
21+
server.on("connection", (socket) => socket.destroy());
22+
console.log("child got server");
23+
server.close();
24+
} else {
25+
console.log("child got non-server");
26+
}
27+
process.send({ done: true });
28+
});
29+
process.on("disconnect", () => process.exit(0));
30+
} else {
31+
const sockPath = path.join(
32+
os.tmpdir(),
33+
`deno-ipc-pipe-${process.pid}.sock`,
34+
);
35+
36+
const server = net.createServer();
37+
server.listen(sockPath, () => {
38+
const child = fork(
39+
new URL("./pipe_server_main.mjs", import.meta.url).pathname,
40+
[serialization, "child"],
41+
{ serialization },
42+
);
43+
44+
child.on("error", (e) => {
45+
console.error("child error:", e);
46+
process.exit(1);
47+
});
48+
child.on("message", (msg) => {
49+
if (msg && msg.done) {
50+
child.disconnect();
51+
console.log("ok");
52+
process.exit(0);
53+
}
54+
});
55+
56+
child.send({ kind: "pipe-server" }, server, (err) => {
57+
if (err) {
58+
console.error("send err:", err);
59+
process.exit(1);
60+
}
61+
});
62+
});
63+
64+
setTimeout(() => {
65+
console.error("timeout");
66+
process.exit(2);
67+
}, 5000);
68+
}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
child got server
2+
ok
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { fork } from "node:child_process";
2+
import net from "node:net";
3+
import process from "node:process";
4+
5+
const serialization = process.argv[2] === "advanced" ? "advanced" : "json";
6+
7+
if (process.argv[3] === "child") {
8+
// Mirrors the issue repro: the child only inspects the message, not the
9+
// (absent) handle.
10+
process.on("message", (msg, handle) => {
11+
if (msg === "server") {
12+
console.log(`child got message, handle=${handle === undefined}`);
13+
}
14+
process.send({ done: true });
15+
});
16+
process.on("disconnect", () => process.exit(0));
17+
} else {
18+
const child = fork(
19+
new URL("./unlistened_server_main.mjs", import.meta.url).pathname,
20+
[serialization, "child"],
21+
{ serialization },
22+
);
23+
24+
child.on("error", (e) => {
25+
console.error("child error:", e);
26+
process.exit(1);
27+
});
28+
child.on("message", (msg) => {
29+
if (msg && msg.done) {
30+
child.disconnect();
31+
console.log("ok");
32+
process.exit(0);
33+
}
34+
});
35+
36+
// The server has never started listening, so it has no underlying handle.
37+
// Node strips the handle and delivers the plain message; we must match.
38+
const server = net.createServer();
39+
child.send("server", server, (err) => {
40+
if (err) {
41+
console.error("send err:", err);
42+
process.exit(1);
43+
}
44+
});
45+
46+
setTimeout(() => {
47+
console.error("timeout");
48+
process.exit(2);
49+
}, 5000);
50+
}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
child got message, handle=true
2+
ok

0 commit comments

Comments
 (0)