net: support AF_UNIX paths in net.BoundSocket

Signed-off-by: Guy Bedford <guybedford@gmail.com>
PR-URL: https://github.com/nodejs/node/pull/64399
Reviewed-By: Ethan Arrowood <ethan@arrowood.dev>
Reviewed-By: James M Snell <jasnell@gmail.com>
Reviewed-By: Matteo Collina <matteo.collina@gmail.com>
This commit is contained in:
Guy Bedford authored and Guy Bedford committed 2026-07-24 16:11:39 -07:00
1 parent e8be132e31
commit b420cb649d
3 files changed
+323 -13

No files matched your search

+41 -3
View File
@@ -1705,9 +1705,20 @@ 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` 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
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.
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
@@ -1729,6 +1740,10 @@ server.listen(bound); // Adopt as a server, or pass to new net.Socket() instead.
<!-- YAML
added: v26.4.0
changes:
- version: REPLACEME
pr-url: https://github.com/nodejs/node/pull/64399
description: The `path` option is supported.
-->
* `options` {Object}
@@ -1743,19 +1758,41 @@ 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()`
<!-- YAML
added: v26.4.0
changes:
- version: REPLACEME
pr-url: https://github.com/nodejs/node/pull/64399
description: The bound path is returned for a pipe bind.
-->
* 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`
<!-- YAML
added: REPLACEME
-->
* {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()`
<!-- YAML
@@ -2262,6 +2299,7 @@ net.isIPv6('fhqwhgads'); // returns false
[`'listening'`]: #event-listening
[`'timeout'`]: #event-timeout
[`BoundSocket`]: #class-netboundsocket
[`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
[`child_process.fork()`]: child_process.md#child_processforkmodulepath-args-options
+98 -10
View File
@@ -380,19 +380,35 @@ const kBoundSource = Symbol('kBoundSource');
// Server/Socket.
const kBoundSocketConsume = Symbol('kBoundSocketConsume');
// A role-neutral wrapper over a synchronously bound libuv TCP handle: bound to
// a local address but neither listening nor connecting until adopted by exactly
// one Server (server.listen) or Socket (new net.Socket({ handle })). Adoption
// transfers ownership; an un-adopted handle must be closed by the caller.
// bind(2) is non-blocking, so binding happens inline and errors throw
// synchronously. host must be a numeric IP literal; no DNS is performed.
// Internal: read a pipe BoundSocket's bound path before adoption (undefined for
// a TCP BoundSocket).
const kBoundSocketPath = Symbol('kBoundSocketPath');
// The source path of an adopted, bound client pipe, surfaced as localAddress.
const kBoundPath = Symbol('kBoundPath');
const isLinux = process.platform === 'linux';
// A role-neutral wrapper over a synchronously bound libuv handle: bound to a
// local address (a numeric IP literal for TCP, or a filesystem/abstract path
// for a unix-domain socket via { path }) but neither listening nor connecting
// until adopted by exactly one Server (server.listen) or Socket
// (new net.Socket({ handle })). Adoption transfers ownership; an un-adopted
// handle must be closed by the caller. bind(2) is non-blocking, so binding
// happens inline and errors throw synchronously. No DNS is performed.
class BoundSocket {
#handle;
#address = {};
#path;
constructor(options = kEmptyObject) {
validateObject(options, 'options');
if (options.path !== undefined) {
this.#bindPipe(options);
return;
}
const port = validatePort(options.port ?? 0, 'options.port');
const ipv6Only = options.ipv6Only ?? false;
@@ -443,13 +459,47 @@ class BoundSocket {
this.#handle = handle;
}
// The kernel-assigned local address, resolved at construction; reflects the
// OS-assigned ephemeral port when the bind requested port 0.
// Bind a named unix-domain socket (or Windows named pipe). A leading '\0'
// 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, 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');
}
validateString(path, 'options.path');
if (path[0] === '\0' && !isLinux) {
throw new ERR_INVALID_ARG_VALUE(
'options.path', path,
'abstract socket paths are only supported on Linux');
}
const handle = new Pipe(PipeConstants.SOCKET);
const err = handle.bind(path);
if (err) {
handle.close();
throw new ErrnoException(err, 'bind');
}
this.#handle = handle;
this.#path = path;
}
// The bound local endpoint: an { address, family, port } object for TCP, or
// the path string for a pipe (matching net.Server.address()). Resolved at
// construction; a TCP bind of port 0 reflects the OS-assigned port.
address() {
if (this.#handle === null) {
throw new ERR_SOCKET_HANDLE_ADOPTED();
}
return this.#address;
return this.#path ?? this.#address;
}
get [kBoundSocketPath]() {
return this.#path;
}
// The underlying OS file descriptor, or -1 where sockets have none (Windows).
@@ -488,6 +538,13 @@ class BoundSocket {
this.#handle = null;
return handle;
}
// Reports whether this is a pipe (unix-domain) bind rather than TCP. Its mere
// presence on the prototype ('isPipe' in net.BoundSocket.prototype) is the
// capability signal that this build honors { path } instead of a TCP port.
get isPipe() {
return this.#path !== undefined;
}
}
function Socket(options) {
@@ -555,9 +612,24 @@ function Socket(options) {
let boundNotConnected = false;
if (options.handle) {
if (options.handle instanceof BoundSocket) {
const boundPath = options.handle[kBoundSocketPath];
this._handle = options.handle[kBoundSocketConsume]();
this[kBoundSource] = true;
boundNotConnected = true;
// A bound client pipe owns a source path; surface it as localAddress.
if (boundPath !== undefined) {
this[kBoundPath] = boundPath;
// uv_pipe_bind() only assigns the fd; it does not open the stream, so a
// later connect() would leave the handle without its readable/writable
// flags and unusable. Re-open the already-bound fd (idempotent, sets the
// flags) so the adopted client pipe connects to a working stream.
if (this._handle.fd >= 0) {
const err = this._handle.open(this._handle.fd);
if (err) {
throw new ErrnoException(err, 'open');
}
}
}
} else {
this._handle = options.handle; // private
}
@@ -1120,6 +1192,11 @@ protoGetter('remotePort', function remotePort() {
Socket.prototype._getsockname = function() {
// An adopted bound client pipe has no handle getsockname; its source path was
// captured at adoption and stays authoritative across the connect reset.
if (this[kBoundPath] !== undefined) {
return { address: this[kBoundPath] };
}
if (!this._handle || !this._handle.getsockname) {
return {};
} else if (!this._sockname) {
@@ -1478,7 +1555,13 @@ Socket.prototype.connect = function(...args) {
}
const { path } = options;
const pipe = !!path;
// An adopted BoundSocket handle already fixes the transport; trust its type
// rather than inferring pipe-ness from a path option on the connect call.
// 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) {
@@ -2425,7 +2508,12 @@ Server.prototype.listen = function(...args) {
boundSocket = options.handle;
}
if (boundSocket !== null) {
const boundPath = boundSocket[kBoundSocketPath];
this._handle = boundSocket[kBoundSocketConsume]();
// A pipe-backed handle reports its path via Server.address().
if (boundPath !== undefined) {
this._pipeName = boundPath;
}
this[async_id_symbol] = this._handle.getAsyncId();
this._listeningId++;
listenInCluster(this, null, -1, -1, backlogFromArgs, undefined, true);
+184
View File
@@ -1,7 +1,12 @@
'use strict';
const common = require('../common');
const assert = require('assert');
const fs = require('fs');
const net = require('net');
const tmpdir = require('../common/tmpdir');
tmpdir.refresh();
const isLinux = process.platform === 'linux';
// Constructing a BoundSocket binds synchronously and address() reports the
// resolved address, including the OS-assigned ephemeral port when port is 0.
@@ -240,6 +245,185 @@ if (!common.isWindows && process.getuid() !== 0) {
}
}
// The isPipe getter's presence on the prototype is the capability signal that
// this build honors { path }; the value discriminates pipe from TCP binds.
assert.ok('isPipe' in net.BoundSocket.prototype);
{
const tcpBound = new net.BoundSocket({ port: 0 });
assert.strictEqual(tcpBound.isPipe, false);
tcpBound.close();
}
// The path option is mutually exclusive with the TCP options.
for (const extra of [{ host: '127.0.0.1' }, { port: 0 }, { ipv6Only: true },
{ reusePort: true }]) {
assert.throws(
() => new net.BoundSocket({ path: common.PIPE, ...extra }),
{ code: 'ERR_INVALID_ARG_VALUE' });
}
// A non-string path is rejected.
assert.throws(() => new net.BoundSocket({ path: 1234 }),
{ code: 'ERR_INVALID_ARG_TYPE' });
// Pipe bind is synchronous: address() returns the bound path, and a second
// bind to the same path throws EADDRINUSE in the constructor.
{
const path = `${common.PIPE}-addr`;
const bound = new net.BoundSocket({ path });
assert.strictEqual(bound.isPipe, true);
assert.strictEqual(bound.address(), path);
assert.throws(() => new net.BoundSocket({ path }),
{ code: 'EADDRINUSE', syscall: 'bind' });
bound.close();
}
// Server adoption: server.listen(boundSocket) over a path, client
// net.connect({ path }) round-trips, and the connection is a pipe.
{
const path = `${common.PIPE}-server`;
const bound = new net.BoundSocket({ path });
const server = net.createServer(common.mustCall((socket) => {
assert.strictEqual(socket.remoteFamily, undefined);
socket.on('data', common.mustCall((data) => {
assert.strictEqual(data.toString(), 'ping');
socket.end('pong');
}));
}));
server.listen(bound, common.mustCall(() => {
assert.strictEqual(server.address(), path);
assert.throws(() => bound.address(), { code: 'ERR_SOCKET_HANDLE_ADOPTED' });
const client = net.connect({ path }, () => {
client.end('ping');
});
client.on('data', common.mustCall((data) => {
assert.strictEqual(data.toString(), 'pong');
}));
client.on('close', common.mustCall(() => server.close()));
}));
}
// Client adoption of a bound source pipe: after connect(), localAddress
// reflects the bound source path. Binding a client to a source path is a
// POSIX unix-domain concept.
if (!common.isWindows) {
const srcPath = `${common.PIPE}-src`;
const dstPath = `${common.PIPE}-dst`;
const server = net.createServer(common.mustCall((socket) => {
socket.on('data', common.mustCall((data) => {
assert.strictEqual(data.toString(), 'ping');
socket.end('pong');
}));
}));
const dst = new net.BoundSocket({ path: dstPath });
server.listen(dst, common.mustCall(() => {
const src = new net.BoundSocket({ path: srcPath });
assert.strictEqual(src.address(), srcPath);
const client = new net.Socket({ handle: src });
client.connect({ path: dstPath });
assert.strictEqual(client.localAddress, srcPath);
client.on('connect', common.mustCall(() => {
assert.strictEqual(client.localAddress, srcPath);
client.end('ping');
}));
client.on('data', common.mustCall((data) => {
assert.strictEqual(data.toString(), 'pong');
}));
client.on('close', common.mustCall(() => server.close()));
}));
}
// 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) {
const abstractPath = `\0node-test-boundsocket.${process.pid}`;
const bound = new net.BoundSocket({ path: abstractPath });
assert.strictEqual(bound.address(), abstractPath);
const server = net.createServer(common.mustCall((socket) => {
socket.on('data', common.mustCall((data) => {
assert.strictEqual(data.toString(), 'ping');
socket.end('pong');
}));
}));
server.listen(bound, common.mustCall(() => {
// No filesystem entry is created for an abstract socket.
assert.ok(!fs.existsSync(abstractPath.slice(1)));
const client = net.connect({ path: abstractPath }, () => {
client.end('ping');
});
client.on('data', common.mustCall((data) => {
assert.strictEqual(data.toString(), 'pong');
}));
client.on('close', common.mustCall(() => server.close()));
}));
}
// An abstract path on a non-Linux platform is rejected synchronously.
if (!isLinux) {
assert.throws(() => new net.BoundSocket({ path: '\0abstract' }),
{ code: 'ERR_INVALID_ARG_VALUE' });
}
// 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). 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(2000)}` }),
{ code: 'EINVAL', syscall: 'bind' });
}
// IPv6 binds: loopback, and ipv6Only dual-stack control.
if (common.hasIPv6) {
{