Skip to content

Commit fe9e0db

Browse files
guybedfordaduh95
authored andcommitted
net: support AF_UNIX paths in net.BoundSocket
Signed-off-by: Guy Bedford <guybedford@gmail.com> PR-URL: #64399 Reviewed-By: Ethan Arrowood <ethan@arrowood.dev> Reviewed-By: James M Snell <jasnell@gmail.com> Reviewed-By: Matteo Collina <matteo.collina@gmail.com>
1 parent c25b8e3 commit fe9e0db

3 files changed

Lines changed: 323 additions & 13 deletions

File tree

doc/api/net.md

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

1702+
A `BoundSocket` binds either a TCP endpoint (`host` or `port`) or a
1703+
Unix domain/named-pipe endpoint (`path`); the two are mutually exclusive. For a
1704+
`path`, the file system entry is reserved in the constructor, so conflicts such
1705+
as `EADDRINUSE` throw synchronously exactly as a TCP bind does. On Linux a
1706+
leading `'\0'` in `path` selects the abstract namespace (no file system entry);
1707+
an abstract path on any other platform throws [`ERR_INVALID_ARG_VALUE`][].
1708+
17021709
Adoption transfers ownership of the socket; afterwards `address()` and `close()`
17031710
throw [`ERR_SOCKET_HANDLE_ADOPTED`][]. A handle that is never adopted must be
1704-
closed to avoid leaking the socket.
1711+
closed to avoid leaking the socket. Closing a pipe `BoundSocket` removes its
1712+
file system entry; abstract and TCP binds have none to remove.
1713+
1714+
When a pipe `BoundSocket` bound to a source `path` is adopted as a client, that
1715+
path is reported as the socket's `localAddress` once it connects.
17051716

17061717
When an adopted `BoundSocket` connects to a numeric IP literal, `connect(2)` is
17071718
issued synchronously, so [`socket.localAddress`][] is resolved once
@@ -1723,6 +1734,10 @@ server.listen(bound); // Adopt as a server, or pass to new net.Socket() instead.
17231734

17241735
<!-- YAML
17251736
added: v26.4.0
1737+
changes:
1738+
- version: REPLACEME
1739+
pr-url: https://github.com/nodejs/node/pull/64399
1740+
description: The `path` option is supported.
17261741
-->
17271742

17281743
* `options` {Object}
@@ -1737,19 +1752,41 @@ added: v26.4.0
17371752
* `reusePort` {boolean} Sets `SO_REUSEPORT`, allowing multiple sockets to bind
17381753
the same address and port for kernel-level load balancing. Support is
17391754
platform-dependent. **Default:** `false`.
1755+
* `path` {string} Binds a Unix domain socket (or Windows named pipe) at the
1756+
given path instead of a TCP endpoint. A leading `'\0'` selects the Linux
1757+
abstract namespace. Mutually exclusive with `host`, `port`, `ipv6Only`, and
1758+
`reusePort`; combining them throws [`ERR_INVALID_ARG_VALUE`][].
17401759

17411760
### `boundSocket.address()`
17421761

17431762
<!-- YAML
17441763
added: v26.4.0
1764+
changes:
1765+
- version: REPLACEME
1766+
pr-url: https://github.com/nodejs/node/pull/64399
1767+
description: The bound path is returned for a pipe bind.
17451768
-->
17461769

1747-
* Returns: {Object} An object with `address`, `family`, and `port` properties,
1748-
as [`server.address()`][] returns.
1770+
* Returns: {Object|string} For a TCP bind, an object with `address`, `family`,
1771+
and `port` properties, as [`server.address()`][] returns. For a pipe bind, the
1772+
bound path string, as [`server.address()`][] returns for a pipe server.
17491773

17501774
Returns the bound local address. When bound with `port: 0`, `port` is the
17511775
OS-assigned ephemeral port.
17521776

1777+
### `boundSocket.isPipe`
1778+
1779+
<!-- YAML
1780+
added: REPLACEME
1781+
-->
1782+
1783+
* {boolean}
1784+
1785+
`true` when the socket was bound with a `path` (a Unix domain socket or Windows
1786+
named pipe), `false` for a TCP bind. The getter's presence on
1787+
`net.BoundSocket.prototype` also serves as a capability probe for `path`
1788+
support.
1789+
17531790
### `boundSocket.fd()`
17541791

17551792
<!-- YAML
@@ -2256,6 +2293,7 @@ net.isIPv6('fhqwhgads'); // returns false
22562293
[`'listening'`]: #event-listening
22572294
[`'timeout'`]: #event-timeout
22582295
[`BoundSocket`]: #class-netboundsocket
2296+
[`ERR_INVALID_ARG_VALUE`]: errors.md#err_invalid_arg_value
22592297
[`ERR_SOCKET_HANDLE_ADOPTED`]: errors.md#err_socket_handle_adopted
22602298
[`EventEmitter`]: events.md#class-eventemitter
22612299
[`child_process.fork()`]: child_process.md#child_processforkmodulepath-args-options

lib/net.js

Lines changed: 98 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -380,19 +380,35 @@ const kBoundSource = Symbol('kBoundSource');
380380
// Server/Socket.
381381
const kBoundSocketConsume = Symbol('kBoundSocketConsume');
382382

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

393404
constructor(options = kEmptyObject) {
394405
validateObject(options, 'options');
395406

407+
if (options.path !== undefined) {
408+
this.#bindPipe(options);
409+
return;
410+
}
411+
396412
const port = validatePort(options.port ?? 0, 'options.port');
397413

398414
const ipv6Only = options.ipv6Only ?? false;
@@ -443,13 +459,47 @@ class BoundSocket {
443459
this.#handle = handle;
444460
}
445461

446-
// The kernel-assigned local address, resolved at construction; reflects the
447-
// OS-assigned ephemeral port when the bind requested port 0.
462+
// Bind a named unix-domain socket (or Windows named pipe). A leading '\0'
463+
// selects the Linux abstract namespace. path is mutually exclusive with the
464+
// TCP options; uv_pipe_bind is synchronous so conflicts throw here.
465+
#bindPipe(options) {
466+
const { path, host, port, ipv6Only, reusePort } = options;
467+
if (host !== undefined || port !== undefined ||
468+
ipv6Only !== undefined || reusePort !== undefined) {
469+
throw new ERR_INVALID_ARG_VALUE(
470+
'options', options,
471+
'path is mutually exclusive with host, port, ipv6Only, and reusePort');
472+
}
473+
validateString(path, 'options.path');
474+
if (path[0] === '\0' && !isLinux) {
475+
throw new ERR_INVALID_ARG_VALUE(
476+
'options.path', path,
477+
'abstract socket paths are only supported on Linux');
478+
}
479+
480+
const handle = new Pipe(PipeConstants.SOCKET);
481+
const err = handle.bind(path);
482+
if (err) {
483+
handle.close();
484+
throw new ErrnoException(err, 'bind');
485+
}
486+
487+
this.#handle = handle;
488+
this.#path = path;
489+
}
490+
491+
// The bound local endpoint: an { address, family, port } object for TCP, or
492+
// the path string for a pipe (matching net.Server.address()). Resolved at
493+
// construction; a TCP bind of port 0 reflects the OS-assigned port.
448494
address() {
449495
if (this.#handle === null) {
450496
throw new ERR_SOCKET_HANDLE_ADOPTED();
451497
}
452-
return this.#address;
498+
return this.#path ?? this.#address;
499+
}
500+
501+
get [kBoundSocketPath]() {
502+
return this.#path;
453503
}
454504

455505
// The underlying OS file descriptor, or -1 where sockets have none (Windows).
@@ -488,6 +538,13 @@ class BoundSocket {
488538
this.#handle = null;
489539
return handle;
490540
}
541+
542+
// Reports whether this is a pipe (unix-domain) bind rather than TCP. Its mere
543+
// presence on the prototype ('isPipe' in net.BoundSocket.prototype) is the
544+
// capability signal that this build honors { path } instead of a TCP port.
545+
get isPipe() {
546+
return this.#path !== undefined;
547+
}
491548
}
492549

493550
function Socket(options) {
@@ -555,9 +612,24 @@ function Socket(options) {
555612
let boundNotConnected = false;
556613
if (options.handle) {
557614
if (options.handle instanceof BoundSocket) {
615+
const boundPath = options.handle[kBoundSocketPath];
558616
this._handle = options.handle[kBoundSocketConsume]();
559617
this[kBoundSource] = true;
560618
boundNotConnected = true;
619+
// A bound client pipe owns a source path; surface it as localAddress.
620+
if (boundPath !== undefined) {
621+
this[kBoundPath] = boundPath;
622+
// uv_pipe_bind() only assigns the fd; it does not open the stream, so a
623+
// later connect() would leave the handle without its readable/writable
624+
// flags and unusable. Re-open the already-bound fd (idempotent, sets the
625+
// flags) so the adopted client pipe connects to a working stream.
626+
if (this._handle.fd >= 0) {
627+
const err = this._handle.open(this._handle.fd);
628+
if (err) {
629+
throw new ErrnoException(err, 'open');
630+
}
631+
}
632+
}
561633
} else {
562634
this._handle = options.handle; // private
563635
}
@@ -1120,6 +1192,11 @@ protoGetter('remotePort', function remotePort() {
11201192

11211193

11221194
Socket.prototype._getsockname = function() {
1195+
// An adopted bound client pipe has no handle getsockname; its source path was
1196+
// captured at adoption and stays authoritative across the connect reset.
1197+
if (this[kBoundPath] !== undefined) {
1198+
return { address: this[kBoundPath] };
1199+
}
11231200
if (!this._handle || !this._handle.getsockname) {
11241201
return {};
11251202
} else if (!this._sockname) {
@@ -1478,7 +1555,13 @@ Socket.prototype.connect = function(...args) {
14781555
}
14791556

14801557
const { path } = options;
1481-
const pipe = !!path;
1558+
// An adopted BoundSocket handle already fixes the transport; trust its type
1559+
// rather than inferring pipe-ness from a path option on the connect call.
1560+
// Once destroyed the adopted handle is gone (its reservation released), so
1561+
// fall back to the path option, as for other pre-existing handles (e.g. a
1562+
// TLSWrap) that are not transport handles.
1563+
const pipe = this[kBoundSource] && this._handle ?
1564+
this._handle instanceof Pipe : !!path;
14821565
debug('pipe', pipe, path);
14831566

14841567
if (!this._handle) {
@@ -2425,7 +2508,12 @@ Server.prototype.listen = function(...args) {
24252508
boundSocket = options.handle;
24262509
}
24272510
if (boundSocket !== null) {
2511+
const boundPath = boundSocket[kBoundSocketPath];
24282512
this._handle = boundSocket[kBoundSocketConsume]();
2513+
// A pipe-backed handle reports its path via Server.address().
2514+
if (boundPath !== undefined) {
2515+
this._pipeName = boundPath;
2516+
}
24292517
this[async_id_symbol] = this._handle.getAsyncId();
24302518
this._listeningId++;
24312519
listenInCluster(this, null, -1, -1, backlogFromArgs, undefined, true);

0 commit comments

Comments
 (0)