Skip to content

Commit dcb691a

Browse files
committed
net: add synchronous, role-neutral net.BoundHandle
Add net.BoundHandle, a synchronous TCP bind primitive that mirrors POSIX bind(2): the socket is bound to a local address but stays role-agnostic until it is adopted as a server (server.listen()) or a client (new net.Socket({ handle }) followed by connect()). Constructing a net.BoundHandle binds inline via the existing uv_tcp_bind() path, so the kernel-assigned address (including the ephemeral port when port is 0) is available immediately via boundHandle.address(), and bind errors throw synchronously. Adoption transfers ownership of the underlying handle; afterwards address() and close() throw ERR_SOCKET_HANDLE_ADOPTED. An un-adopted handle is released with close() or via Symbol.dispose (using). The handle is passed in-process rather than as a file descriptor, so it also works on Windows.
1 parent b09155d commit dcb691a

5 files changed

Lines changed: 418 additions & 4 deletions

File tree

doc/api/errors.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2972,6 +2972,14 @@ disconnected socket.
29722972

29732973
A call was made and the UDP subsystem was not running.
29742974

2975+
<a id="ERR_SOCKET_HANDLE_ADOPTED"></a>
2976+
2977+
### `ERR_SOCKET_HANDLE_ADOPTED`
2978+
2979+
An operation was attempted on a [`BoundHandle`][] that had already been adopted
2980+
by a [`net.Server`][] or [`net.Socket`][]. Once a bound handle is adopted, its
2981+
`address()` and `close()` methods can no longer be used.
2982+
29752983
<a id="ERR_SOURCE_MAP_CORRUPT"></a>
29762984

29772985
### `ERR_SOURCE_MAP_CORRUPT`
@@ -4552,6 +4560,7 @@ An error occurred trying to allocate memory. This should never happen.
45524560
[`--force-fips`]: cli.md#--force-fips
45534561
[`--no-addons`]: cli.md#--no-addons
45544562
[`--unhandled-rejections`]: cli.md#--unhandled-rejectionsmode
4563+
[`BoundHandle`]: net.md#class-netboundhandle
45554564
[`Class: assert.AssertionError`]: assert.md#class-assertassertionerror
45564565
[`ERR_INCOMPATIBLE_OPTION_PAIR`]: #err_incompatible_option_pair
45574566
[`ERR_INVALID_ARG_TYPE`]: #err_invalid_arg_type
@@ -4595,7 +4604,9 @@ An error occurred trying to allocate memory. This should never happen.
45954604
[`http`]: http.md
45964605
[`https`]: https.md
45974606
[`libuv Error handling`]: https://docs.libuv.org/en/v1.x/errors.html
4607+
[`net.Server`]: net.md#class-netserver
45984608
[`net.Socket.write()`]: net.md#socketwritedata-encoding-callback
4609+
[`net.Socket`]: net.md#class-netsocket
45994610
[`net`]: net.md
46004611
[`new URL(input)`]: url.md#new-urlinput-base
46014612
[`new URLPattern(input)`]: url.md#new-urlpatternstring-baseurl-options

doc/api/net.md

Lines changed: 96 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -523,8 +523,12 @@ Start a server listening for connections on a given `handle` that has
523523
already been bound to a port, a Unix domain socket, or a Windows named pipe.
524524

525525
The `handle` object can be either a server, a socket (anything with an
526-
underlying `_handle` member), or an object with an `fd` member that is a
527-
valid file descriptor.
526+
underlying `_handle` member), a [`BoundHandle`][], or an object with an `fd`
527+
member that is a valid file descriptor.
528+
529+
When `handle` is a [`BoundHandle`][], the server adopts the already-bound
530+
socket and starts listening on it. Adoption consumes the bound handle (see
531+
[ownership transfer][`BoundHandle`]).
528532

529533
Listening on a file descriptor is not supported on Windows.
530534

@@ -769,6 +773,12 @@ changes:
769773
access to specific IP addresses, IP ranges, or IP subnets.
770774
* `fd` {number} If specified, wrap around an existing socket with
771775
the given file descriptor, otherwise a new socket will be created.
776+
* `handle` {net.BoundHandle} If specified, wrap around the bound socket from a
777+
[`BoundHandle`][]. A subsequent
778+
[`socket.connect()`][`socket.connect()`] uses the bound handle as the
779+
connection's source binding (honoring the bound local address and port).
780+
Adoption consumes the bound handle (see
781+
[ownership transfer][`BoundHandle`]).
772782
* `keepAlive` {boolean} If set to `true`, it enables keep-alive functionality on
773783
the socket immediately after the connection is established, similarly on what
774784
is done in [`socket.setKeepAlive()`][]. **Default:** `false`.
@@ -1627,6 +1637,85 @@ This property represents the state of the connection as a string.
16271637
* If the stream is readable and not writable, it is `readOnly`.
16281638
* If the stream is not readable and writable, it is `writeOnly`.
16291639

1640+
## Class: `net.BoundHandle`
1641+
1642+
<!-- YAML
1643+
added: REPLACEME
1644+
-->
1645+
1646+
A role-neutral wrapper over a synchronously bound TCP socket, mirroring POSIX
1647+
`bind(2)`, which is role-agnostic until `listen()` or `connect()`. It is adopted
1648+
by exactly one server (via [`server.listen()`][]) or socket (via the `handle`
1649+
option of [`new net.Socket()`][`new net.Socket(options)`]). Adoption transfers
1650+
ownership of the socket; afterwards `address()` and `close()` throw
1651+
[`ERR_SOCKET_HANDLE_ADOPTED`][]. A handle that is never adopted must be closed
1652+
to avoid leaking the socket.
1653+
1654+
```mjs
1655+
import net from 'node:net';
1656+
1657+
const bound = new net.BoundHandle({ host: '127.0.0.1', port: 0 });
1658+
const { port } = bound.address();
1659+
1660+
const server = net.createServer();
1661+
server.listen(bound); // Adopt as a server, or pass to new net.Socket() instead.
1662+
```
1663+
1664+
### `new net.BoundHandle([options])`
1665+
1666+
<!-- YAML
1667+
added: REPLACEME
1668+
-->
1669+
1670+
* `options` {Object}
1671+
* `host` {string} Local address to bind. Must be a numeric IP literal; no DNS
1672+
resolution is performed. **Default:** `'0.0.0.0'`, or `'::'` when
1673+
`ipv6Only` is `true`.
1674+
* `port` {number} Local port. `0` requests an OS-assigned ephemeral port.
1675+
**Default:** `0`.
1676+
* `ipv6Only` {boolean} Sets `IPV6_V6ONLY`, disabling dual-stack support so the
1677+
socket binds IPv6 only. Only meaningful for IPv6 binds. **Default:**
1678+
`false`.
1679+
1680+
Synchronously binds a TCP socket. Because `bind(2)` is a local, non-blocking
1681+
system call, the bind happens inline and errors (such as `EADDRINUSE`,
1682+
`EADDRNOTAVAIL`, `EACCES`, or `EINVAL`) are thrown synchronously. The
1683+
kernel-assigned address, including the ephemeral port chosen when `port` is `0`,
1684+
is available immediately via
1685+
[`boundHandle.address()`][`net.BoundHandle.address()`].
1686+
1687+
This is the synchronous, role-neutral counterpart to the bind performed
1688+
internally by [`server.listen()`][] and [`socket.connect()`][], analogous to
1689+
[`dgram` `socket.bindSync()`][].
1690+
1691+
### `boundHandle.address()`
1692+
1693+
<!-- YAML
1694+
added: REPLACEME
1695+
-->
1696+
1697+
* Returns: {Object} An object with `address`, `family`, and `port` properties,
1698+
as [`server.address()`][] returns.
1699+
1700+
Returns the bound local address. When bound with `port: 0`, `port` is the
1701+
OS-assigned ephemeral port.
1702+
1703+
### `boundHandle.close()`
1704+
1705+
<!-- YAML
1706+
added: REPLACEME
1707+
-->
1708+
1709+
Releases the bound socket. Only needed when the handle is never adopted.
1710+
1711+
### `boundHandle[Symbol.dispose]()`
1712+
1713+
<!-- YAML
1714+
added: REPLACEME
1715+
-->
1716+
1717+
Closes the handle if it has not been adopted or closed; otherwise a no-op.
1718+
16301719
## `net.connect()`
16311720

16321721
Aliases to
@@ -2097,10 +2186,14 @@ net.isIPv6('fhqwhgads'); // returns false
20972186
[`'error'`]: #event-error_1
20982187
[`'listening'`]: #event-listening
20992188
[`'timeout'`]: #event-timeout
2189+
[`BoundHandle`]: #class-netboundhandle
2190+
[`ERR_SOCKET_HANDLE_ADOPTED`]: errors.md#err_socket_handle_adopted
21002191
[`EventEmitter`]: events.md#class-eventemitter
21012192
[`child_process.fork()`]: child_process.md#child_processforkmodulepath-args-options
2193+
[`dgram` `socket.bindSync()`]: dgram.md#socketbindsyncoptions
21022194
[`dns.lookup()`]: dns.md#dnslookuphostname-options-callback
21032195
[`dns.lookup()` hints]: dns.md#supported-getaddrinfo-flags
2196+
[`net.BoundHandle.address()`]: #boundhandleaddress
21042197
[`net.Server`]: #class-netserver
21052198
[`net.Socket`]: #class-netsocket
21062199
[`net.connect()`]: #netconnect
@@ -2116,6 +2209,7 @@ net.isIPv6('fhqwhgads'); // returns false
21162209
[`net.getDefaultAutoSelectFamilyAttemptTimeout()`]: #netgetdefaultautoselectfamilyattempttimeout
21172210
[`new net.Socket(options)`]: #new-netsocketoptions
21182211
[`readable.setEncoding()`]: stream.md#readablesetencodingencoding
2212+
[`server.address()`]: #serveraddress
21192213
[`server.close()`]: #serverclosecallback
21202214
[`server.dropMaxConnection`]: #serverdropmaxconnection
21212215
[`server.listen()`]: #serverlisten

lib/internal/errors.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1788,6 +1788,8 @@ E('ERR_SOCKET_CONNECTION_TIMEOUT',
17881788
E('ERR_SOCKET_DGRAM_IS_CONNECTED', 'Already connected', Error);
17891789
E('ERR_SOCKET_DGRAM_NOT_CONNECTED', 'Not connected', Error);
17901790
E('ERR_SOCKET_DGRAM_NOT_RUNNING', 'Not running', Error);
1791+
E('ERR_SOCKET_HANDLE_ADOPTED',
1792+
'The bound handle has already been adopted by a server or socket', Error);
17911793
E('ERR_SOURCE_MAP_CORRUPT', `The source map for '%s' does not exist or is corrupt.`, Error);
17921794
E('ERR_SOURCE_MAP_MISSING_SOURCE', `Cannot find '%s' imported from the source map for '%s'`, Error);
17931795
E('ERR_SRI_PARSE',

lib/net.js

Lines changed: 131 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,7 @@ const {
118118
ERR_SOCKET_CLOSED,
119119
ERR_SOCKET_CLOSED_BEFORE_CONNECTION,
120120
ERR_SOCKET_CONNECTION_TIMEOUT,
121+
ERR_SOCKET_HANDLE_ADOPTED,
121122
},
122123
genericNodeError,
123124
} = require('internal/errors');
@@ -135,6 +136,7 @@ const {
135136
validateFunction,
136137
validateInt32,
137138
validateNumber,
139+
validateObject,
138140
validatePort,
139141
validateString,
140142
} = require('internal/validators');
@@ -363,6 +365,105 @@ const kBytesRead = Symbol('kBytesRead');
363365
const kBytesWritten = Symbol('kBytesWritten');
364366
const kSetTOS = Symbol('kSetTOS');
365367

368+
// Internal: adopt the underlying handle, transferring ownership to a
369+
// Server/Socket. Used by server.listen() and the Socket constructor.
370+
const kBoundHandleConsume = Symbol('kBoundHandleConsume');
371+
372+
// A thin, role-neutral wrapper over a synchronously bound libuv TCP handle,
373+
// mirroring POSIX bind(2): the socket is bound to a local address but has not
374+
// chosen a role. It neither listens nor connects until it is adopted by exactly
375+
// one Server (via server.listen()) or Socket (via new net.Socket({ handle })).
376+
// Adoption transfers ownership of the underlying handle; an un-adopted
377+
// BoundHandle must be closed by the caller.
378+
//
379+
// bind(2) is a local, non-blocking system call, so binding happens inline in
380+
// the constructor and errors throw synchronously. The host must be a numeric IP
381+
// literal: no DNS resolution is performed.
382+
class BoundHandle {
383+
#handle;
384+
385+
constructor(options = kEmptyObject) {
386+
validateObject(options, 'options');
387+
388+
const port = validatePort(options.port ?? 0, 'options.port');
389+
390+
const ipv6Only = options.ipv6Only ?? false;
391+
validateBoolean(ipv6Only, 'options.ipv6Only');
392+
393+
let { host } = options;
394+
let addressType;
395+
if (host === undefined || host === null) {
396+
host = ipv6Only ? DEFAULT_IPV6_ADDR : DEFAULT_IPV4_ADDR;
397+
addressType = ipv6Only ? 6 : 4;
398+
} else {
399+
validateString(host, 'options.host');
400+
addressType = isIP(host);
401+
if (addressType === 0) {
402+
throw new ERR_INVALID_ARG_VALUE(
403+
'options.host', host,
404+
'must be a numeric IP address; net.BoundHandle does not perform DNS resolution');
405+
}
406+
}
407+
408+
let flags = 0;
409+
if (ipv6Only) {
410+
flags |= TCPConstants.UV_TCP_IPV6ONLY;
411+
}
412+
413+
const handle = new TCP(TCPConstants.SOCKET);
414+
const err = addressType === 6 ?
415+
handle.bind6(host, port, flags) :
416+
handle.bind(host, port, flags);
417+
if (err) {
418+
handle.close();
419+
throw new ExceptionWithHostPort(err, 'bind', host, port);
420+
}
421+
422+
this.#handle = handle;
423+
}
424+
425+
// The kernel-assigned local address; reflects the OS-assigned ephemeral port
426+
// when the bind requested port 0.
427+
address() {
428+
if (this.#handle === null) {
429+
throw new ERR_SOCKET_HANDLE_ADOPTED();
430+
}
431+
const out = {};
432+
const err = this.#handle.getsockname(out);
433+
if (err) {
434+
throw new ErrnoException(err, 'address');
435+
}
436+
return out;
437+
}
438+
439+
// Release the socket if it is never adopted, preventing an fd/handle leak.
440+
close() {
441+
if (this.#handle === null) {
442+
throw new ERR_SOCKET_HANDLE_ADOPTED();
443+
}
444+
this.#handle.close();
445+
this.#handle = null;
446+
}
447+
448+
// Enables `using bound = net.bindSync(...)`: closes an un-adopted handle and
449+
// is a no-op once the handle has been adopted or closed.
450+
[SymbolDispose]() {
451+
if (this.#handle !== null) {
452+
this.#handle.close();
453+
this.#handle = null;
454+
}
455+
}
456+
457+
[kBoundHandleConsume]() {
458+
if (this.#handle === null) {
459+
throw new ERR_SOCKET_HANDLE_ADOPTED();
460+
}
461+
const handle = this.#handle;
462+
this.#handle = null;
463+
return handle;
464+
}
465+
}
466+
366467
function Socket(options) {
367468
if (!(this instanceof Socket)) return new Socket(options);
368469
if (options?.objectMode) {
@@ -420,8 +521,19 @@ function Socket(options) {
420521
options.decodeStrings = false;
421522
stream.Duplex.call(this, options);
422523

524+
// A BoundHandle from net.bindSync() is bound but not connected, so the read
525+
// flow must not start until a later connect() completes.
526+
let boundNotConnected = false;
423527
if (options.handle) {
424-
this._handle = options.handle; // private
528+
// A BoundHandle is adopted here, transferring ownership of its underlying
529+
// handle so a subsequent connect() drives it as the connection's source
530+
// binding.
531+
if (options.handle instanceof BoundHandle) {
532+
this._handle = options.handle[kBoundHandleConsume]();
533+
boundNotConnected = true;
534+
} else {
535+
this._handle = options.handle; // private
536+
}
425537
this[async_id_symbol] = getNewAsyncId(this._handle);
426538
} else if (options.fd !== undefined) {
427539
const { fd } = options;
@@ -492,7 +604,7 @@ function Socket(options) {
492604

493605
// If we have a handle, then start the flow of data into the
494606
// buffer. if not, then this will happen when we connect
495-
if (this._handle && options.readable !== false) {
607+
if (this._handle && options.readable !== false && !boundNotConnected) {
496608
if (options.pauseOnCreate) {
497609
// Stop the handle from reading and pause the stream
498610
this._handle.reading = false;
@@ -2144,6 +2256,22 @@ Server.prototype.listen = function(...args) {
21442256
toNumber(args.length > 1 && args[1]) ||
21452257
toNumber(args.length > 2 && args[2]); // (port, host, backlog)
21462258

2259+
// (boundHandle[, ...]) or ({ handle: boundHandle }[, ...]) from
2260+
// net.bindSync(): adopt the bound handle, transferring ownership so the
2261+
// BoundHandle can no longer close it.
2262+
let boundHandle = null;
2263+
if (options instanceof BoundHandle) {
2264+
boundHandle = options;
2265+
} else if (options.handle instanceof BoundHandle) {
2266+
boundHandle = options.handle;
2267+
}
2268+
if (boundHandle !== null) {
2269+
this._handle = boundHandle[kBoundHandleConsume]();
2270+
this[async_id_symbol] = this._handle.getAsyncId();
2271+
this._listeningId++;
2272+
listenInCluster(this, null, -1, -1, backlogFromArgs, undefined, true);
2273+
return this;
2274+
}
21472275
options = options._handle || options.handle || options;
21482276
const flags = getFlags(options);
21492277
// Refresh the id to make the previous call invalid
@@ -2573,6 +2701,7 @@ module.exports = {
25732701
SocketAddress ??= require('internal/socketaddress').SocketAddress;
25742702
return SocketAddress;
25752703
},
2704+
BoundHandle,
25762705
connect,
25772706
createConnection: connect,
25782707
createServer,

0 commit comments

Comments
 (0)