net: early TCP binding via synchronous net.BoundSocket

Signed-off-by: Guy Bedford <guybedford@gmail.com>
PR-URL: https://github.com/nodejs/node/pull/63951
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 committed 2026-06-20 14:40:48 -07:00
1 parent c27921ba06
commit e3cdb14678
5 files changed
+513 -5

No files matched your search

+11
View File
@@ -2986,6 +2986,14 @@ disconnected socket.
A call was made and the UDP subsystem was not running.
<a id="ERR_SOCKET_HANDLE_ADOPTED"></a>
### `ERR_SOCKET_HANDLE_ADOPTED`
An operation was attempted on a [`BoundSocket`][] that had already been adopted
by a [`net.Server`][] or [`net.Socket`][]. Once a bound socket is adopted, its
`address()` and `close()` methods can no longer be used.
<a id="ERR_SOURCE_MAP_CORRUPT"></a>
### `ERR_SOURCE_MAP_CORRUPT`
@@ -4566,6 +4574,7 @@ An error occurred trying to allocate memory. This should never happen.
[`--force-fips`]: cli.md#--force-fips
[`--no-addons`]: cli.md#--no-addons
[`--unhandled-rejections`]: cli.md#--unhandled-rejectionsmode
[`BoundSocket`]: net.md#class-netboundsocket
[`Class: assert.AssertionError`]: assert.md#class-assertassertionerror
[`ERR_INCOMPATIBLE_OPTION_PAIR`]: #err_incompatible_option_pair
[`ERR_INVALID_ARG_TYPE`]: #err_invalid_arg_type
@@ -4609,7 +4618,9 @@ An error occurred trying to allocate memory. This should never happen.
[`http`]: http.md
[`https`]: https.md
[`libuv Error handling`]: https://docs.libuv.org/en/v1.x/errors.html
[`net.Server`]: net.md#class-netserver
[`net.Socket.write()`]: net.md#socketwritedata-encoding-callback
[`net.Socket`]: net.md#class-netsocket
[`net`]: net.md
[`new URL(input)`]: url.md#new-urlinput-base
[`new URLPattern(input)`]: url.md#new-urlpatternstring-baseurl-options
+112 -3
View File
@@ -523,8 +523,12 @@ Start a server listening for connections on a given `handle` that has
already been bound to a port, a Unix domain socket, or a Windows named pipe.
The `handle` object can be either a server, a socket (anything with an
underlying `_handle` member), or an object with an `fd` member that is a
valid file descriptor.
underlying `_handle` member), a [`BoundSocket`][], or an object with an `fd`
member that is a valid file descriptor.
When `handle` is a [`BoundSocket`][], the server adopts the already-bound
socket and starts listening on it. Adoption consumes the bound socket (see
[ownership transfer][`BoundSocket`]).
Listening on a file descriptor is not supported on Windows.
@@ -550,6 +554,10 @@ changes:
* `backlog` {number} Common parameter of [`server.listen()`][]
functions.
* `exclusive` {boolean} **Default:** `false`
* `handle` {net.BoundSocket} A pre-bound [`BoundSocket`][]. The server adopts
the already-bound socket and listens on it, ignoring `host`, `port`, and
`path`. Adoption consumes the bound socket (see
[ownership transfer][`BoundSocket`]).
* `host` {string}
* `ipv6Only` {boolean} For TCP servers, setting `ipv6Only` to `true` will
disable dual-stack support, i.e., binding to host `::` won't make
@@ -573,7 +581,8 @@ changes:
functions.
* Returns: {net.Server}
If `port` is specified, it behaves the same as
If `handle` is specified, the server adopts that pre-bound socket. Otherwise, if
`port` is specified, it behaves the same as
[`server.listen([port[, host[, backlog]]][, callback])`][`server.listen(port)`].
Otherwise, if `path` is specified, it behaves the same as
[`server.listen(path[, backlog][, callback])`][`server.listen(path)`].
@@ -769,6 +778,12 @@ changes:
access to specific IP addresses, IP ranges, or IP subnets.
* `fd` {number} If specified, wrap around an existing socket with
the given file descriptor, otherwise a new socket will be created.
* `handle` {net.BoundSocket} If specified, wrap around the bound socket from a
[`BoundSocket`][]. A subsequent
[`socket.connect()`][`socket.connect()`] uses the bound socket as the
connection's source binding (honoring the bound local address and port).
Adoption consumes the bound socket (see
[ownership transfer][`BoundSocket`]).
* `keepAlive` {boolean} If set to `true`, it enables keep-alive functionality on
the socket immediately after the connection is established, similarly on what
is done in [`socket.setKeepAlive()`][]. **Default:** `false`.
@@ -1627,6 +1642,94 @@ This property represents the state of the connection as a string.
* If the stream is readable and not writable, it is `readOnly`.
* If the stream is not readable and writable, it is `writeOnly`.
## Class: `net.BoundSocket`
<!-- YAML
added: REPLACEME
-->
Allows for the synchronous creation of a pre-bound socket, that can be passed
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.
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.
```mjs
import net from 'node:net';
const bound = new net.BoundSocket();
const { port } = bound.address();
console.log(`Reserved port ${port} for server`);
const server = net.createServer();
server.listen(bound); // Adopt as a server, or pass to new net.Socket() instead.
```
### `new net.BoundSocket([options])`
<!-- YAML
added: REPLACEME
-->
* `options` {Object}
* `host` {string} Local address to bind. Must be a numeric IP literal; no DNS
resolution is performed. **Default:** `'0.0.0.0'`, or `'::'` when
`ipv6Only` is `true`.
* `port` {number} Local port. `0` requests an OS-assigned ephemeral port.
**Default:** `0`.
* `ipv6Only` {boolean} Sets `IPV6_V6ONLY`, disabling dual-stack support so the
socket binds IPv6 only. Only meaningful for IPv6 binds. **Default:**
`false`.
* `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`.
### `boundSocket.address()`
<!-- YAML
added: REPLACEME
-->
* Returns: {Object} An object with `address`, `family`, and `port` properties,
as [`server.address()`][] returns.
Returns the bound local address. When bound with `port: 0`, `port` is the
OS-assigned ephemeral port.
### `boundSocket.fd()`
<!-- YAML
added: REPLACEME
-->
* Returns: {integer} The underlying OS file descriptor, or `-1` on platforms
that do not expose one for sockets (such as Windows).
Returns the file descriptor of the bound socket. Ownership remains with the
`BoundSocket`, so the descriptor must not be closed by the caller. The
descriptor is only available before the handle is adopted; afterwards it belongs
to the adopting [`net.Server`][] or [`net.Socket`][] and `fd()` throws
[`ERR_SOCKET_HANDLE_ADOPTED`][].
### `boundSocket.close()`
<!-- YAML
added: REPLACEME
-->
Releases the bound socket. Only needed when the handle is never adopted.
### `boundSocket[Symbol.dispose]()`
<!-- YAML
added: REPLACEME
-->
Closes the handle if it has not been adopted or closed; otherwise a no-op.
## `net.connect()`
Aliases to
@@ -1721,6 +1824,9 @@ and [`socket.connect(options[, connectListener])`][`socket.connect(options)`].
Additional options:
* `handle` {net.BoundSocket} A pre-bound [`BoundSocket`][] used as the
connection's source binding, honoring its local address and port. Adoption
consumes the bound socket (see [ownership transfer][`BoundSocket`]).
* `timeout` {number} If set, will be used to call
[`socket.setTimeout(timeout)`][] after the socket is created, but before
it starts the connection.
@@ -2097,6 +2203,8 @@ net.isIPv6('fhqwhgads'); // returns false
[`'error'`]: #event-error_1
[`'listening'`]: #event-listening
[`'timeout'`]: #event-timeout
[`BoundSocket`]: #class-netboundsocket
[`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
[`dns.lookup()`]: dns.md#dnslookuphostname-options-callback
@@ -2116,6 +2224,7 @@ net.isIPv6('fhqwhgads'); // returns false
[`net.getDefaultAutoSelectFamilyAttemptTimeout()`]: #netgetdefaultautoselectfamilyattempttimeout
[`new net.Socket(options)`]: #new-netsocketoptions
[`readable.setEncoding()`]: stream.md#readablesetencodingencoding
[`server.address()`]: #serveraddress
[`server.close()`]: #serverclosecallback
[`server.dropMaxConnection`]: #serverdropmaxconnection
[`server.listen()`]: #serverlisten
+2
View File
@@ -1790,6 +1790,8 @@ E('ERR_SOCKET_CONNECTION_TIMEOUT',
E('ERR_SOCKET_DGRAM_IS_CONNECTED', 'Already connected', Error);
E('ERR_SOCKET_DGRAM_NOT_CONNECTED', 'Not connected', Error);
E('ERR_SOCKET_DGRAM_NOT_RUNNING', 'Not running', Error);
E('ERR_SOCKET_HANDLE_ADOPTED',
'The bound socket has already been adopted by a server or socket', Error);
E('ERR_SOURCE_MAP_CORRUPT', `The source map for '%s' does not exist or is corrupt.`, Error);
E('ERR_SOURCE_MAP_MISSING_SOURCE', `Cannot find '%s' imported from the source map for '%s'`, Error);
E('ERR_SRI_PARSE',
+156 -2
View File
@@ -118,6 +118,7 @@ const {
ERR_SOCKET_CLOSED,
ERR_SOCKET_CLOSED_BEFORE_CONNECTION,
ERR_SOCKET_CONNECTION_TIMEOUT,
ERR_SOCKET_HANDLE_ADOPTED,
},
genericNodeError,
} = require('internal/errors');
@@ -135,6 +136,7 @@ const {
validateFunction,
validateInt32,
validateNumber,
validateObject,
validatePort,
validateString,
validateStringWithoutNullBytes,
@@ -363,6 +365,122 @@ function closeSocketHandle(self, isException, isCleanupPending = false) {
const kBytesRead = Symbol('kBytesRead');
const kBytesWritten = Symbol('kBytesWritten');
const kSetTOS = Symbol('kSetTOS');
// Marks a Socket whose handle is an adopted, already-bound BoundSocket.
const kBoundSource = Symbol('kBoundSource');
// Internal: adopt the underlying handle, transferring ownership to a
// 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.
class BoundSocket {
#handle;
#address = {};
constructor(options = kEmptyObject) {
validateObject(options, 'options');
const port = validatePort(options.port ?? 0, 'options.port');
const ipv6Only = options.ipv6Only ?? false;
validateBoolean(ipv6Only, 'options.ipv6Only');
const reusePort = options.reusePort ?? false;
validateBoolean(reusePort, 'options.reusePort');
let { host } = options;
let addressType;
if (host === undefined || host === null) {
host = ipv6Only ? DEFAULT_IPV6_ADDR : DEFAULT_IPV4_ADDR;
addressType = ipv6Only ? 6 : 4;
} else {
validateString(host, 'options.host');
addressType = isIP(host);
if (addressType === 0) {
throw new ERR_INVALID_ARG_VALUE(
'options.host', host,
'must be a numeric IP address; net.BoundSocket does not perform DNS resolution');
}
}
let flags = 0;
if (ipv6Only) {
flags |= TCPConstants.UV_TCP_IPV6ONLY;
}
if (reusePort) {
flags |= TCPConstants.UV_TCP_REUSEPORT;
}
const handle = new TCP(TCPConstants.SOCKET);
let err = addressType === 6 ?
handle.bind6(host, port, flags) :
handle.bind(host, port, flags);
// EADDRINUSE is deferred by libuv's uv_tcp_bind(), which returns 0 and
// surfaces it on the next getsockname/listen/connect. Force getsockname()
// now so conflicts throw synchronously; the address is cached since the
// handle is immutable.
if (err === 0) {
err = handle.getsockname(this.#address);
}
if (err) {
handle.close();
throw new ExceptionWithHostPort(err, 'bind', host, port);
}
this.#handle = handle;
}
// The kernel-assigned local address, resolved at construction; reflects the
// OS-assigned ephemeral port when the bind requested port 0.
address() {
if (this.#handle === null) {
throw new ERR_SOCKET_HANDLE_ADOPTED();
}
return this.#address;
}
// The underlying OS file descriptor, or -1 where sockets have none (Windows).
// Ownership stays with the BoundSocket until it is adopted, so the caller must
// not close it.
fd() {
if (this.#handle === null) {
throw new ERR_SOCKET_HANDLE_ADOPTED();
}
return this.#handle.fd;
}
// Release the socket if it is never adopted, preventing an fd/handle leak.
close() {
if (this.#handle === null) {
throw new ERR_SOCKET_HANDLE_ADOPTED();
}
this.#handle.close();
this.#handle = null;
}
// Enables `using bound = new net.BoundSocket(...)`: closes an un-adopted
// handle and is a no-op once the handle has been adopted or closed.
[SymbolDispose]() {
if (this.#handle !== null) {
this.#handle.close();
this.#handle = null;
}
}
[kBoundSocketConsume]() {
if (this.#handle === null) {
throw new ERR_SOCKET_HANDLE_ADOPTED();
}
const handle = this.#handle;
this.#handle = null;
return handle;
}
}
function Socket(options) {
if (!(this instanceof Socket)) return new Socket(options);
@@ -421,8 +539,17 @@ function Socket(options) {
options.decodeStrings = false;
stream.Duplex.call(this, options);
// An adopted BoundSocket is bound but not connected: defer the read flow
// until connect() completes.
let boundNotConnected = false;
if (options.handle) {
this._handle = options.handle; // private
if (options.handle instanceof BoundSocket) {
this._handle = options.handle[kBoundSocketConsume]();
this[kBoundSource] = true;
boundNotConnected = true;
} else {
this._handle = options.handle; // private
}
this[async_id_symbol] = getNewAsyncId(this._handle);
} else if (options.fd !== undefined) {
const { fd } = options;
@@ -493,7 +620,7 @@ function Socket(options) {
// If we have a handle, then start the flow of data into the
// buffer. if not, then this will happen when we connect
if (this._handle && options.readable !== false) {
if (this._handle && options.readable !== false && !boundNotConnected) {
if (options.pauseOnCreate) {
// Stop the handle from reading and pause the stream
this._handle.reading = false;
@@ -1392,6 +1519,17 @@ function lookupAndConnect(self, options) {
validateStringWithoutNullBytes(host, 'options.host');
// An adopted BoundSocket already owns the local endpoint and address family.
if (self[kBoundSource]) {
if (localAddress !== undefined || localPort !== undefined) {
throw new ERR_INVALID_ARG_VALUE(
'options',
options,
'localAddress and localPort cannot be used with an adopted bound socket');
}
autoSelectFamily = false;
}
if (localAddress && !isIP(localAddress)) {
throw new ERR_INVALID_IP_ADDRESS(localAddress);
}
@@ -2145,6 +2283,21 @@ Server.prototype.listen = function(...args) {
toNumber(args.length > 1 && args[1]) ||
toNumber(args.length > 2 && args[2]); // (port, host, backlog)
// (boundSocket[, ...]) or ({ handle: boundSocket }[, ...]): adopt the bound
// handle, transferring ownership so the BoundSocket can no longer close it.
let boundSocket = null;
if (options instanceof BoundSocket) {
boundSocket = options;
} else if (options.handle instanceof BoundSocket) {
boundSocket = options.handle;
}
if (boundSocket !== null) {
this._handle = boundSocket[kBoundSocketConsume]();
this[async_id_symbol] = this._handle.getAsyncId();
this._listeningId++;
listenInCluster(this, null, -1, -1, backlogFromArgs, undefined, true);
return this;
}
options = options._handle || options.handle || options;
const flags = getFlags(options);
// Refresh the id to make the previous call invalid
@@ -2574,6 +2727,7 @@ module.exports = {
SocketAddress ??= require('internal/socketaddress').SocketAddress;
return SocketAddress;
},
BoundSocket,
connect,
createConnection: connect,
createServer,
+232
View File
@@ -0,0 +1,232 @@
'use strict';
const common = require('../common');
const assert = require('assert');
const net = require('net');
// Constructing a BoundSocket binds synchronously and address() reports the
// resolved address, including the OS-assigned ephemeral port when port is 0.
{
const bound = new net.BoundSocket({ host: '127.0.0.1', port: 0 });
const addr = bound.address();
assert.strictEqual(addr.address, '127.0.0.1');
assert.strictEqual(addr.family, 'IPv4');
assert.strictEqual(typeof addr.port, 'number');
assert.ok(addr.port > 0);
// fd() exposes the underlying descriptor (a real fd on POSIX, -1 on Windows).
const fd = bound.fd();
assert.strictEqual(typeof fd, 'number');
if (!common.isWindows) {
assert.ok(fd >= 0);
}
bound.close();
}
// Defaults the host to the IPv4 wildcard when omitted.
{
const bound = new net.BoundSocket({ port: 0 });
const addr = bound.address();
assert.strictEqual(addr.address, '0.0.0.0');
assert.strictEqual(addr.family, 'IPv4');
assert.ok(addr.port > 0);
bound.close();
}
// No arguments reserves an OS-assigned ephemeral port on the IPv4 wildcard.
{
const bound = new net.BoundSocket();
const addr = bound.address();
assert.strictEqual(addr.address, '0.0.0.0');
assert.strictEqual(addr.family, 'IPv4');
assert.ok(addr.port > 0);
bound.close();
}
// Binding to a port held by a live listener throws EADDRINUSE synchronously.
// libuv defers this error from uv_tcp_bind(), so the constructor forces a
// getsockname() to surface it eagerly. (Two role-neutral, not-yet-listening
// binds to the same port instead coexist, since libuv sets SO_REUSEADDR.)
{
const server = net.createServer();
server.listen(0, '127.0.0.1', common.mustCall(() => {
const { port } = server.address();
assert.throws(() => {
new net.BoundSocket({ host: '127.0.0.1', port });
}, {
code: 'EADDRINUSE',
syscall: 'bind',
});
server.close();
}));
}
// Throws synchronously on a non-numeric host (no DNS resolution).
{
assert.throws(() => new net.BoundSocket({ host: 'localhost', port: 0 }), {
code: 'ERR_INVALID_ARG_VALUE',
name: 'TypeError',
});
}
// Rejects a non-string host and a non-object options argument.
{
assert.throws(() => new net.BoundSocket({ host: 1234 }),
{ code: 'ERR_INVALID_ARG_TYPE' });
assert.throws(() => new net.BoundSocket(0), { code: 'ERR_INVALID_ARG_TYPE' });
}
// Throws synchronously on a non-local address (EADDRNOTAVAIL). 192.0.2.0/24 is
// TEST-NET-1 (RFC 5737) and is never assigned to a local interface.
{
assert.throws(() => new net.BoundSocket({ host: '192.0.2.1', port: 0 }), {
code: 'EADDRNOTAVAIL',
syscall: 'bind',
});
}
// Binding a privileged port without privilege throws EACCES synchronously.
if (!common.isWindows && process.getuid() !== 0) {
assert.throws(() => new net.BoundSocket({ host: '127.0.0.1', port: 1 }), {
code: 'EACCES',
syscall: 'bind',
});
}
// An un-adopted handle releases its socket cleanly on close(): the port becomes
// immediately re-bindable.
{
const bound = new net.BoundSocket({ host: '127.0.0.1', port: 0 });
const { port } = bound.address();
bound.close();
const again = new net.BoundSocket({ host: '127.0.0.1', port });
assert.strictEqual(again.address().port, port);
again.close();
}
// Server adoption: server.listen(boundSocket), then a client round-trips.
{
const bound = new net.BoundSocket({ host: '127.0.0.1', port: 0 });
const { port } = bound.address();
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(() => {
assert.strictEqual(server.address().port, port);
// The bound socket has been adopted: address()/fd()/close() now throw.
assert.throws(() => bound.address(), { code: 'ERR_SOCKET_HANDLE_ADOPTED' });
assert.throws(() => bound.fd(), { code: 'ERR_SOCKET_HANDLE_ADOPTED' });
assert.throws(() => bound.close(), { code: 'ERR_SOCKET_HANDLE_ADOPTED' });
const client = net.connect({ host: '127.0.0.1', port }, () => {
client.end('ping');
});
client.on('data', common.mustCall((data) => {
assert.strictEqual(data.toString(), 'pong');
}));
client.on('close', common.mustCall(() => server.close()));
}));
}
// Client adoption: new net.Socket({ handle: boundSocket }).connect(...) honors
// the bound source port and round-trips.
{
const server = net.createServer(common.mustCall((socket) => {
socket.on('data', common.mustCall((data) => {
assert.strictEqual(data.toString(), 'ping');
socket.end('pong');
}));
}));
server.listen(0, '127.0.0.1', common.mustCall(() => {
const serverPort = server.address().port;
const bound = new net.BoundSocket({ host: '127.0.0.1', port: 0 });
const localPort = bound.address().port;
const client = new net.Socket({ handle: bound });
// Adoption consumed the bound socket.
assert.throws(() => bound.address(), { code: 'ERR_SOCKET_HANDLE_ADOPTED' });
client.connect({ host: '127.0.0.1', port: serverPort }, common.mustCall(() => {
assert.strictEqual(client.localPort, localPort);
client.end('ping');
}));
client.on('data', common.mustCall((data) => {
assert.strictEqual(data.toString(), 'pong');
}));
client.on('close', common.mustCall(() => server.close()));
}));
}
// connect() rejects localAddress/localPort when adopting a bound socket: the
// handle already owns the local endpoint.
{
const bound = new net.BoundSocket({ host: '127.0.0.1', port: 0 });
const client = new net.Socket({ handle: bound });
assert.throws(() => {
client.connect({ host: '127.0.0.1', port: 1, localPort: 0 });
}, { code: 'ERR_INVALID_ARG_VALUE' });
client.destroy();
}
// reusePort: SO_REUSEPORT permits multiple listeners on the same port. Support
// is platform-dependent, so probe first.
{
let first;
try {
first = new net.BoundSocket({ host: '127.0.0.1', port: 0, reusePort: true });
} catch {
first = null; // SO_REUSEPORT unsupported on this platform.
}
if (first) {
const { port } = first.address();
const second = new net.BoundSocket({ host: '127.0.0.1', port, reusePort: true });
const s1 = net.createServer();
const s2 = net.createServer();
s1.listen(first, common.mustCall(() => {
s2.listen(second, common.mustCall(() => {
assert.strictEqual(s1.address().port, port);
assert.strictEqual(s2.address().port, port);
s1.close();
s2.close();
}));
}));
}
}
// IPv6 binds: loopback, and ipv6Only dual-stack control.
if (common.hasIPv6) {
{
const bound = new net.BoundSocket({ host: '::1', port: 0 });
const addr = bound.address();
assert.strictEqual(addr.address, '::1');
assert.strictEqual(addr.family, 'IPv6');
assert.ok(addr.port > 0);
bound.close();
}
{
const bound = new net.BoundSocket({ ipv6Only: true, port: 0 });
const addr = bound.address();
assert.strictEqual(addr.address, '::');
assert.strictEqual(addr.family, 'IPv6');
bound.close();
}
// ipv6Only: false (default) binds the IPv6 wildcard as dual-stack.
{
const bound = new net.BoundSocket({ host: '::', port: 0 });
assert.strictEqual(bound.address().family, 'IPv6');
bound.close();
}
}