Skip to content

Commit 6a4fee8

Browse files
committed
net: support AF_UNIX paths in net.BoundSocket
Signed-off-by: Guy Bedford <guybedford@gmail.com>
1 parent 8fec65d commit 6a4fee8

3 files changed

Lines changed: 281 additions & 13 deletions

File tree

doc/api/net.md

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1653,9 +1653,24 @@ to `listen()` or `new net.Socket()` later on. For `listen()` this enables
16531653
synchronous port reservation, while for `new net.Socket()`, it allows control
16541654
over the local egress port/IP, via `bind(2)` semantics.
16551655

1656+
A `BoundSocket` binds either a TCP endpoint (`host`/`port`) or a
1657+
Unix domain/named-pipe endpoint (`path`); the two are mutually exclusive. For a
1658+
`path`, the file system entry is reserved in the constructor, so conflicts such
1659+
as `EADDRINUSE` throw synchronously exactly as a TCP bind does. On Linux a
1660+
leading `'\0'` in `path` selects the abstract namespace (no file system entry);
1661+
an abstract path on any other platform throws [`ERR_INVALID_ARG_VALUE`][].
1662+
16561663
Adoption transfers ownership of the socket; afterwards `address()` and `close()`
16571664
throw [`ERR_SOCKET_HANDLE_ADOPTED`][]. A handle that is never adopted must be
1658-
closed to avoid leaking the socket.
1665+
closed to avoid leaking the socket. Closing a pipe `BoundSocket` removes its
1666+
file system entry; abstract and TCP binds have none to remove.
1667+
1668+
The presence of the [`boundSocket.isPipe`][] getter on
1669+
`net.BoundSocket.prototype` is a capability signal that a build honors the
1670+
`path` option rather than silently binding a TCP ephemeral port.
1671+
1672+
When a pipe `BoundSocket` bound to a source `path` is adopted as a client, that
1673+
path is reported as the socket's `localAddress` once it connects.
16591674

16601675
```mjs
16611676
import net from 'node:net';
@@ -1686,19 +1701,37 @@ added: v26.4.0
16861701
* `reusePort` {boolean} Sets `SO_REUSEPORT`, allowing multiple sockets to bind
16871702
the same address and port for kernel-level load balancing. Support is
16881703
platform-dependent. **Default:** `false`.
1704+
* `path` {string} Binds a Unix domain socket (or Windows named pipe) at the
1705+
given path instead of a TCP endpoint. A leading `'\0'` selects the Linux
1706+
abstract namespace. Mutually exclusive with `host`, `port`, `ipv6Only`, and
1707+
`reusePort`; combining them throws [`ERR_INVALID_ARG_VALUE`][].
16891708

16901709
### `boundSocket.address()`
16911710

16921711
<!-- YAML
16931712
added: v26.4.0
16941713
-->
16951714

1696-
* Returns: {Object} An object with `address`, `family`, and `port` properties,
1697-
as [`server.address()`][] returns.
1715+
* Returns: {Object|string} For a TCP bind, an object with `address`, `family`,
1716+
and `port` properties, as [`server.address()`][] returns. For a pipe bind, the
1717+
bound path string, as [`server.address()`][] returns for a pipe server.
16981718

16991719
Returns the bound local address. When bound with `port: 0`, `port` is the
17001720
OS-assigned ephemeral port.
17011721

1722+
### `boundSocket.isPipe`
1723+
1724+
<!-- YAML
1725+
added: REPLACEME
1726+
-->
1727+
1728+
* {boolean}
1729+
1730+
`true` when the socket was bound with a `path` (a Unix domain socket or Windows
1731+
named pipe), `false` for a TCP bind. The getter's presence on
1732+
`net.BoundSocket.prototype` also serves as a capability probe for `path`
1733+
support.
1734+
17021735
### `boundSocket.fd()`
17031736

17041737
<!-- YAML
@@ -2204,8 +2237,10 @@ net.isIPv6('fhqwhgads'); // returns false
22042237
[`'listening'`]: #event-listening
22052238
[`'timeout'`]: #event-timeout
22062239
[`BoundSocket`]: #class-netboundsocket
2240+
[`ERR_INVALID_ARG_VALUE`]: errors.md#err_invalid_arg_value
22072241
[`ERR_SOCKET_HANDLE_ADOPTED`]: errors.md#err_socket_handle_adopted
22082242
[`EventEmitter`]: events.md#class-eventemitter
2243+
[`boundSocket.isPipe`]: #boundsocketispipe
22092244
[`child_process.fork()`]: child_process.md#child_processforkmodulepath-args-options
22102245
[`dns.lookup()`]: dns.md#dnslookuphostname-options-callback
22112246
[`dns.lookup()` hints]: dns.md#supported-getaddrinfo-flags

lib/net.js

Lines changed: 94 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -372,19 +372,35 @@ const kBoundSource = Symbol('kBoundSource');
372372
// Server/Socket.
373373
const kBoundSocketConsume = Symbol('kBoundSocketConsume');
374374

375-
// A role-neutral wrapper over a synchronously bound libuv TCP handle: bound to
376-
// a local address but neither listening nor connecting until adopted by exactly
377-
// one Server (server.listen) or Socket (new net.Socket({ handle })). Adoption
378-
// transfers ownership; an un-adopted handle must be closed by the caller.
379-
// bind(2) is non-blocking, so binding happens inline and errors throw
380-
// synchronously. host must be a numeric IP literal; no DNS is performed.
375+
// Internal: read a pipe BoundSocket's bound path before adoption (undefined for
376+
// a TCP BoundSocket).
377+
const kBoundSocketPath = Symbol('kBoundSocketPath');
378+
379+
// The source path of an adopted, bound client pipe, surfaced as localAddress.
380+
const kBoundPath = Symbol('kBoundPath');
381+
382+
const isLinux = process.platform === 'linux';
383+
384+
// A role-neutral wrapper over a synchronously bound libuv handle: bound to a
385+
// local address (a numeric IP literal for TCP, or a filesystem/abstract path
386+
// for a unix-domain socket via { path }) but neither listening nor connecting
387+
// until adopted by exactly one Server (server.listen) or Socket
388+
// (new net.Socket({ handle })). Adoption transfers ownership; an un-adopted
389+
// handle must be closed by the caller. bind(2) is non-blocking, so binding
390+
// happens inline and errors throw synchronously. No DNS is performed.
381391
class BoundSocket {
382392
#handle;
383393
#address = {};
394+
#path;
384395

385396
constructor(options = kEmptyObject) {
386397
validateObject(options, 'options');
387398

399+
if (options.path !== undefined) {
400+
this.#bindPipe(options);
401+
return;
402+
}
403+
388404
const port = validatePort(options.port ?? 0, 'options.port');
389405

390406
const ipv6Only = options.ipv6Only ?? false;
@@ -435,13 +451,47 @@ class BoundSocket {
435451
this.#handle = handle;
436452
}
437453

438-
// The kernel-assigned local address, resolved at construction; reflects the
439-
// OS-assigned ephemeral port when the bind requested port 0.
454+
// Bind a named unix-domain socket (or Windows named pipe). A leading '\0'
455+
// selects the Linux abstract namespace. path is mutually exclusive with the
456+
// TCP options; uv_pipe_bind is synchronous so conflicts throw here.
457+
#bindPipe(options) {
458+
const { path } = options;
459+
if (options.host !== undefined || options.port !== undefined ||
460+
options.ipv6Only !== undefined || options.reusePort !== undefined) {
461+
throw new ERR_INVALID_ARG_VALUE(
462+
'options', options,
463+
'path is mutually exclusive with host, port, ipv6Only, and reusePort');
464+
}
465+
validateString(path, 'options.path');
466+
if (path[0] === '\0' && !isLinux) {
467+
throw new ERR_INVALID_ARG_VALUE(
468+
'options.path', path,
469+
'abstract socket paths are only supported on Linux');
470+
}
471+
472+
const handle = new Pipe(PipeConstants.SOCKET);
473+
const err = handle.bind(path);
474+
if (err) {
475+
handle.close();
476+
throw new ErrnoException(err, 'bind');
477+
}
478+
479+
this.#handle = handle;
480+
this.#path = path;
481+
}
482+
483+
// The bound local endpoint: an { address, family, port } object for TCP, or
484+
// the path string for a pipe (matching net.Server.address()). Resolved at
485+
// construction; a TCP bind of port 0 reflects the OS-assigned port.
440486
address() {
441487
if (this.#handle === null) {
442488
throw new ERR_SOCKET_HANDLE_ADOPTED();
443489
}
444-
return this.#address;
490+
return this.#path ?? this.#address;
491+
}
492+
493+
get [kBoundSocketPath]() {
494+
return this.#path;
445495
}
446496

447497
// The underlying OS file descriptor, or -1 where sockets have none (Windows).
@@ -480,6 +530,13 @@ class BoundSocket {
480530
this.#handle = null;
481531
return handle;
482532
}
533+
534+
// Reports whether this is a pipe (unix-domain) bind rather than TCP. Its mere
535+
// presence on the prototype ('isPipe' in net.BoundSocket.prototype) is the
536+
// capability signal that this build honors { path } instead of a TCP port.
537+
get isPipe() {
538+
return this.#path !== undefined;
539+
}
483540
}
484541

485542
function Socket(options) {
@@ -544,9 +601,24 @@ function Socket(options) {
544601
let boundNotConnected = false;
545602
if (options.handle) {
546603
if (options.handle instanceof BoundSocket) {
604+
const boundPath = options.handle[kBoundSocketPath];
547605
this._handle = options.handle[kBoundSocketConsume]();
548606
this[kBoundSource] = true;
549607
boundNotConnected = true;
608+
// A bound client pipe owns a source path; surface it as localAddress.
609+
if (boundPath !== undefined) {
610+
this[kBoundPath] = boundPath;
611+
// uv_pipe_bind() only assigns the fd; it does not open the stream, so a
612+
// later connect() would leave the handle without its readable/writable
613+
// flags and unusable. Re-open the already-bound fd (idempotent, sets the
614+
// flags) so the adopted client pipe connects to a working stream.
615+
if (this._handle.fd >= 0) {
616+
const err = this._handle.open(this._handle.fd);
617+
if (err) {
618+
throw new ErrnoException(err, 'open');
619+
}
620+
}
621+
}
550622
} else {
551623
this._handle = options.handle; // private
552624
}
@@ -1109,6 +1181,11 @@ protoGetter('remotePort', function remotePort() {
11091181

11101182

11111183
Socket.prototype._getsockname = function() {
1184+
// An adopted bound client pipe has no handle getsockname; its source path was
1185+
// captured at adoption and stays authoritative across the connect reset.
1186+
if (this[kBoundPath] !== undefined) {
1187+
return { address: this[kBoundPath] };
1188+
}
11121189
if (!this._handle || !this._handle.getsockname) {
11131190
return {};
11141191
} else if (!this._sockname) {
@@ -1467,7 +1544,9 @@ Socket.prototype.connect = function(...args) {
14671544
}
14681545

14691546
const { path } = options;
1470-
const pipe = !!path;
1547+
// An adopted handle already fixes the transport; trust its type rather than
1548+
// inferring pipe-ness from a path option on the connect call.
1549+
const pipe = this._handle ? this._handle instanceof Pipe : !!path;
14711550
debug('pipe', pipe, path);
14721551

14731552
if (!this._handle) {
@@ -2292,7 +2371,12 @@ Server.prototype.listen = function(...args) {
22922371
boundSocket = options.handle;
22932372
}
22942373
if (boundSocket !== null) {
2374+
const boundPath = boundSocket[kBoundSocketPath];
22952375
this._handle = boundSocket[kBoundSocketConsume]();
2376+
// A pipe-backed handle reports its path via Server.address().
2377+
if (boundPath !== undefined) {
2378+
this._pipeName = boundPath;
2379+
}
22962380
this[async_id_symbol] = this._handle.getAsyncId();
22972381
this._listeningId++;
22982382
listenInCluster(this, null, -1, -1, backlogFromArgs, undefined, true);

0 commit comments

Comments
 (0)