From 1e5822c737d10b00e31bc98563fa330b235235e9 Mon Sep 17 00:00:00 2001 From: Guy Bedford Date: Thu, 9 Jul 2026 17:04:13 -0700 Subject: [PATCH 1/5] net: support AF_UNIX paths in net.BoundSocket Signed-off-by: Guy Bedford --- doc/api/net.md | 41 ++++++- lib/net.js | 104 ++++++++++++++++-- test/parallel/test-net-boundsocket.js | 149 ++++++++++++++++++++++++++ 3 files changed, 281 insertions(+), 13 deletions(-) diff --git a/doc/api/net.md b/doc/api/net.md index e90a90738f1178..6b90a517426576 100644 --- a/doc/api/net.md +++ b/doc/api/net.md @@ -1705,9 +1705,24 @@ to `listen()` or `new net.Socket()` later on. For `listen()` this enables synchronous port reservation, while for `new net.Socket()`, it allows control over the local egress port/IP, via `bind(2)` semantics. +A `BoundSocket` binds either a TCP endpoint (`host`/`port`) or a +Unix domain/named-pipe endpoint (`path`); the two are mutually exclusive. For a +`path`, the file system entry is reserved in the constructor, so conflicts such +as `EADDRINUSE` throw synchronously exactly as a TCP bind does. On Linux a +leading `'\0'` in `path` selects the abstract namespace (no file system entry); +an abstract path on any other platform throws [`ERR_INVALID_ARG_VALUE`][]. + Adoption transfers ownership of the socket; afterwards `address()` and `close()` throw [`ERR_SOCKET_HANDLE_ADOPTED`][]. A handle that is never adopted must be -closed to avoid leaking the socket. +closed to avoid leaking the socket. Closing a pipe `BoundSocket` removes its +file system entry; abstract and TCP binds have none to remove. + +The presence of the [`boundSocket.isPipe`][] getter on +`net.BoundSocket.prototype` is a capability signal that a build honors the +`path` option rather than silently binding a TCP ephemeral port. + +When a pipe `BoundSocket` bound to a source `path` is adopted as a client, that +path is reported as the socket's `localAddress` once it connects. When an adopted `BoundSocket` connects to a numeric IP literal, `connect(2)` is issued synchronously, so [`socket.localAddress`][] is resolved once @@ -1743,6 +1758,10 @@ added: v26.4.0 * `reusePort` {boolean} Sets `SO_REUSEPORT`, allowing multiple sockets to bind the same address and port for kernel-level load balancing. Support is platform-dependent. **Default:** `false`. + * `path` {string} Binds a Unix domain socket (or Windows named pipe) at the + given path instead of a TCP endpoint. A leading `'\0'` selects the Linux + abstract namespace. Mutually exclusive with `host`, `port`, `ipv6Only`, and + `reusePort`; combining them throws [`ERR_INVALID_ARG_VALUE`][]. ### `boundSocket.address()` @@ -1750,12 +1769,26 @@ added: v26.4.0 added: v26.4.0 --> -* Returns: {Object} An object with `address`, `family`, and `port` properties, - as [`server.address()`][] returns. +* Returns: {Object|string} For a TCP bind, an object with `address`, `family`, + and `port` properties, as [`server.address()`][] returns. For a pipe bind, the + bound path string, as [`server.address()`][] returns for a pipe server. Returns the bound local address. When bound with `port: 0`, `port` is the OS-assigned ephemeral port. +### `boundSocket.isPipe` + + + +* {boolean} + +`true` when the socket was bound with a `path` (a Unix domain socket or Windows +named pipe), `false` for a TCP bind. The getter's presence on +`net.BoundSocket.prototype` also serves as a capability probe for `path` +support. + ### `boundSocket.fd()` * `options` {Object} @@ -1767,6 +1767,10 @@ added: v26.4.0 * Returns: {Object|string} For a TCP bind, an object with `address`, `family`, @@ -2298,7 +2302,6 @@ net.isIPv6('fhqwhgads'); // returns false [`ERR_INVALID_ARG_VALUE`]: errors.md#err_invalid_arg_value [`ERR_SOCKET_HANDLE_ADOPTED`]: errors.md#err_socket_handle_adopted [`EventEmitter`]: events.md#class-eventemitter -[`boundSocket.isPipe`]: #boundsocketispipe [`child_process.fork()`]: child_process.md#child_processforkmodulepath-args-options [`dns.lookup()`]: dns.md#dnslookuphostname-options-callback [`dns.lookup()` hints]: dns.md#supported-getaddrinfo-flags diff --git a/lib/net.js b/lib/net.js index 061590f672c6ae..0941cc10d255c7 100644 --- a/lib/net.js +++ b/lib/net.js @@ -1557,9 +1557,11 @@ Socket.prototype.connect = function(...args) { const { path } = options; // An adopted BoundSocket handle already fixes the transport; trust its type // rather than inferring pipe-ness from a path option on the connect call. - // Other pre-existing handles (e.g. a TLSWrap) are not transport handles, so - // fall back to the path option in that case. - const pipe = this[kBoundSource] ? this._handle instanceof Pipe : !!path; + // Once destroyed the adopted handle is gone (its reservation released), so + // fall back to the path option, as for other pre-existing handles (e.g. a + // TLSWrap) that are not transport handles. + const pipe = this[kBoundSource] && this._handle ? + this._handle instanceof Pipe : !!path; debug('pipe', pipe, path); if (!this._handle) { diff --git a/test/parallel/test-net-boundsocket.js b/test/parallel/test-net-boundsocket.js index 8b4f4e44e07111..0c2785344f8702 100644 --- a/test/parallel/test-net-boundsocket.js +++ b/test/parallel/test-net-boundsocket.js @@ -341,6 +341,40 @@ if (!common.isWindows) { })); } +// Reconnecting an adopted pipe BoundSocket after destroy: the adopted handle is +// gone, so pipe-ness must come from the path option rather than the (now null) +// handle, otherwise connect() would wrongly attempt a TCP connect. +if (!common.isWindows) { + const path = `${common.PIPE}-reconnect`; + + const server = net.createServer(common.mustCall((socket) => { + socket.on('data', (data) => socket.end(data)); + }, 2)); + + const bound = new net.BoundSocket({ path: `${path}-src` }); + server.listen(path, common.mustCall(() => { + const client = new net.Socket({ handle: bound }); + client.connect({ path }); + client.once('connect', common.mustCall(() => { + client.end('ping'); + client.once('data', common.mustCall((data) => { + assert.strictEqual(data.toString(), 'ping'); + })); + client.once('close', common.mustCall(() => { + // The adopted handle is gone; reconnect must still be a pipe. + client.connect({ path }); + client.once('connect', common.mustCall(() => { + client.end('pong'); + client.once('data', common.mustCall((data) => { + assert.strictEqual(data.toString(), 'pong'); + })); + client.once('close', common.mustCall(() => server.close())); + })); + })); + })); + })); +} + // Linux abstract namespace: a leading '\0' binds without creating a filesystem // entry, and still listens/connects. if (isLinux) { From 2b4839fcb96515ed4b44f194b3495814edf62045 Mon Sep 17 00:00:00 2001 From: Guy Bedford Date: Mon, 20 Jul 2026 13:34:45 -0700 Subject: [PATCH 4/5] pr feedback --- doc/api/net.md | 2 +- lib/net.js | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/doc/api/net.md b/doc/api/net.md index 35412abf259e57..95c65fd9a9a304 100644 --- a/doc/api/net.md +++ b/doc/api/net.md @@ -1705,7 +1705,7 @@ to `listen()` or `new net.Socket()` later on. For `listen()` this enables synchronous port reservation, while for `new net.Socket()`, it allows control over the local egress port/IP, via `bind(2)` semantics. -A `BoundSocket` binds either a TCP endpoint (`host`/`port`) or a +A `BoundSocket` binds either a TCP endpoint (`host` or `port`) or a Unix domain/named-pipe endpoint (`path`); the two are mutually exclusive. For a `path`, the file system entry is reserved in the constructor, so conflicts such as `EADDRINUSE` throw synchronously exactly as a TCP bind does. On Linux a diff --git a/lib/net.js b/lib/net.js index 0941cc10d255c7..b12bce8cba56c6 100644 --- a/lib/net.js +++ b/lib/net.js @@ -463,9 +463,9 @@ class BoundSocket { // selects the Linux abstract namespace. path is mutually exclusive with the // TCP options; uv_pipe_bind is synchronous so conflicts throw here. #bindPipe(options) { - const { path } = options; - if (options.host !== undefined || options.port !== undefined || - options.ipv6Only !== undefined || options.reusePort !== undefined) { + const { path, host, port, ipv6Only, reusePort } = options; + if (host !== undefined || port !== undefined || + ipv6Only !== undefined || reusePort !== undefined) { throw new ERR_INVALID_ARG_VALUE( 'options', options, 'path is mutually exclusive with host, port, ipv6Only, and reusePort'); From f30b9ca5a37480fbf0a6666953185e043b9df05f Mon Sep 17 00:00:00 2001 From: Guy Bedford Date: Tue, 21 Jul 2026 11:11:43 -0700 Subject: [PATCH 5/5] fix aix --- test/parallel/test-net-boundsocket.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/test/parallel/test-net-boundsocket.js b/test/parallel/test-net-boundsocket.js index 0c2785344f8702..2d11d0d4ba83f0 100644 --- a/test/parallel/test-net-boundsocket.js +++ b/test/parallel/test-net-boundsocket.js @@ -412,14 +412,15 @@ if (!isLinux) { // Filesystem bind errors surface synchronously in the constructor. A missing // parent directory yields EACCES (libuv maps the kernel's ENOENT to EACCES for // cross-platform parity), and an over-long path yields EINVAL (uv_pipe_bind is -// called with UV_PIPE_NO_TRUNCATE). +// called with UV_PIPE_NO_TRUNCATE). The path must exceed sun_path on every +// platform, which is 1023 bytes on AIX. if (!common.isWindows) { assert.throws( () => new net.BoundSocket({ path: `${common.PIPE}-nope/child.sock` }), { code: 'EACCES', syscall: 'bind' }); assert.throws( - () => new net.BoundSocket({ path: `${common.PIPE}-${'x'.repeat(200)}` }), + () => new net.BoundSocket({ path: `${common.PIPE}-${'x'.repeat(2000)}` }), { code: 'EINVAL', syscall: 'bind' }); }