Skip to content

Commit e3cdb14

Browse files
committed
net: early TCP binding via synchronous net.BoundSocket
Signed-off-by: Guy Bedford <guybedford@gmail.com> PR-URL: #63951 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 c27921b commit e3cdb14

5 files changed

Lines changed: 513 additions & 5 deletions

File tree

doc/api/errors.md

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

29872987
A call was made and the UDP subsystem was not running.
29882988

2989+
<a id="ERR_SOCKET_HANDLE_ADOPTED"></a>
2990+
2991+
### `ERR_SOCKET_HANDLE_ADOPTED`
2992+
2993+
An operation was attempted on a [`BoundSocket`][] that had already been adopted
2994+
by a [`net.Server`][] or [`net.Socket`][]. Once a bound socket is adopted, its
2995+
`address()` and `close()` methods can no longer be used.
2996+
29892997
<a id="ERR_SOURCE_MAP_CORRUPT"></a>
29902998

29912999
### `ERR_SOURCE_MAP_CORRUPT`
@@ -4566,6 +4574,7 @@ An error occurred trying to allocate memory. This should never happen.
45664574
[`--force-fips`]: cli.md#--force-fips
45674575
[`--no-addons`]: cli.md#--no-addons
45684576
[`--unhandled-rejections`]: cli.md#--unhandled-rejectionsmode
4577+
[`BoundSocket`]: net.md#class-netboundsocket
45694578
[`Class: assert.AssertionError`]: assert.md#class-assertassertionerror
45704579
[`ERR_INCOMPATIBLE_OPTION_PAIR`]: #err_incompatible_option_pair
45714580
[`ERR_INVALID_ARG_TYPE`]: #err_invalid_arg_type
@@ -4609,7 +4618,9 @@ An error occurred trying to allocate memory. This should never happen.
46094618
[`http`]: http.md
46104619
[`https`]: https.md
46114620
[`libuv Error handling`]: https://docs.libuv.org/en/v1.x/errors.html
4621+
[`net.Server`]: net.md#class-netserver
46124622
[`net.Socket.write()`]: net.md#socketwritedata-encoding-callback
4623+
[`net.Socket`]: net.md#class-netsocket
46134624
[`net`]: net.md
46144625
[`new URL(input)`]: url.md#new-urlinput-base
46154626
[`new URLPattern(input)`]: url.md#new-urlpatternstring-baseurl-options

doc/api/net.md

Lines changed: 112 additions & 3 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 [`BoundSocket`][], or an object with an `fd`
527+
member that is a valid file descriptor.
528+
529+
When `handle` is a [`BoundSocket`][], the server adopts the already-bound
530+
socket and starts listening on it. Adoption consumes the bound socket (see
531+
[ownership transfer][`BoundSocket`]).
528532

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

@@ -550,6 +554,10 @@ changes:
550554
* `backlog` {number} Common parameter of [`server.listen()`][]
551555
functions.
552556
* `exclusive` {boolean} **Default:** `false`
557+
* `handle` {net.BoundSocket} A pre-bound [`BoundSocket`][]. The server adopts
558+
the already-bound socket and listens on it, ignoring `host`, `port`, and
559+
`path`. Adoption consumes the bound socket (see
560+
[ownership transfer][`BoundSocket`]).
553561
* `host` {string}
554562
* `ipv6Only` {boolean} For TCP servers, setting `ipv6Only` to `true` will
555563
disable dual-stack support, i.e., binding to host `::` won't make
@@ -573,7 +581,8 @@ changes:
573581
functions.
574582
* Returns: {net.Server}
575583

576-
If `port` is specified, it behaves the same as
584+
If `handle` is specified, the server adopts that pre-bound socket. Otherwise, if
585+
`port` is specified, it behaves the same as
577586
[`server.listen([port[, host[, backlog]]][, callback])`][`server.listen(port)`].
578587
Otherwise, if `path` is specified, it behaves the same as
579588
[`server.listen(path[, backlog][, callback])`][`server.listen(path)`].
@@ -769,6 +778,12 @@ changes:
769778
access to specific IP addresses, IP ranges, or IP subnets.
770779
* `fd` {number} If specified, wrap around an existing socket with
771780
the given file descriptor, otherwise a new socket will be created.
781+
* `handle` {net.BoundSocket} If specified, wrap around the bound socket from a
782+
[`BoundSocket`][]. A subsequent
783+
[`socket.connect()`][`socket.connect()`] uses the bound socket as the
784+
connection's source binding (honoring the bound local address and port).
785+
Adoption consumes the bound socket (see
786+
[ownership transfer][`BoundSocket`]).
772787
* `keepAlive` {boolean} If set to `true`, it enables keep-alive functionality on
773788
the socket immediately after the connection is established, similarly on what
774789
is done in [`socket.setKeepAlive()`][]. **Default:** `false`.
@@ -1627,6 +1642,94 @@ This property represents the state of the connection as a string.
16271642
* If the stream is readable and not writable, it is `readOnly`.
16281643
* If the stream is not readable and writable, it is `writeOnly`.
16291644

1645+
## Class: `net.BoundSocket`
1646+
1647+
<!-- YAML
1648+
added: REPLACEME
1649+
-->
1650+
1651+
Allows for the synchronous creation of a pre-bound socket, that can be passed
1652+
to `listen()` or `new net.Socket()` later on. For `listen()` this enables
1653+
synchronous port reservation, while for `new net.Socket()`, it allows control
1654+
over the local egress port/IP, via `bind(2)` semantics.
1655+
1656+
Adoption transfers ownership of the socket; afterwards `address()` and `close()`
1657+
throw [`ERR_SOCKET_HANDLE_ADOPTED`][]. A handle that is never adopted must be
1658+
closed to avoid leaking the socket.
1659+
1660+
```mjs
1661+
import net from 'node:net';
1662+
1663+
const bound = new net.BoundSocket();
1664+
const { port } = bound.address();
1665+
console.log(`Reserved port ${port} for server`);
1666+
1667+
const server = net.createServer();
1668+
server.listen(bound); // Adopt as a server, or pass to new net.Socket() instead.
1669+
```
1670+
1671+
### `new net.BoundSocket([options])`
1672+
1673+
<!-- YAML
1674+
added: REPLACEME
1675+
-->
1676+
1677+
* `options` {Object}
1678+
* `host` {string} Local address to bind. Must be a numeric IP literal; no DNS
1679+
resolution is performed. **Default:** `'0.0.0.0'`, or `'::'` when
1680+
`ipv6Only` is `true`.
1681+
* `port` {number} Local port. `0` requests an OS-assigned ephemeral port.
1682+
**Default:** `0`.
1683+
* `ipv6Only` {boolean} Sets `IPV6_V6ONLY`, disabling dual-stack support so the
1684+
socket binds IPv6 only. Only meaningful for IPv6 binds. **Default:**
1685+
`false`.
1686+
* `reusePort` {boolean} Sets `SO_REUSEPORT`, allowing multiple sockets to bind
1687+
the same address and port for kernel-level load balancing. Support is
1688+
platform-dependent. **Default:** `false`.
1689+
1690+
### `boundSocket.address()`
1691+
1692+
<!-- YAML
1693+
added: REPLACEME
1694+
-->
1695+
1696+
* Returns: {Object} An object with `address`, `family`, and `port` properties,
1697+
as [`server.address()`][] returns.
1698+
1699+
Returns the bound local address. When bound with `port: 0`, `port` is the
1700+
OS-assigned ephemeral port.
1701+
1702+
### `boundSocket.fd()`
1703+
1704+
<!-- YAML
1705+
added: REPLACEME
1706+
-->
1707+
1708+
* Returns: {integer} The underlying OS file descriptor, or `-1` on platforms
1709+
that do not expose one for sockets (such as Windows).
1710+
1711+
Returns the file descriptor of the bound socket. Ownership remains with the
1712+
`BoundSocket`, so the descriptor must not be closed by the caller. The
1713+
descriptor is only available before the handle is adopted; afterwards it belongs
1714+
to the adopting [`net.Server`][] or [`net.Socket`][] and `fd()` throws
1715+
[`ERR_SOCKET_HANDLE_ADOPTED`][].
1716+
1717+
### `boundSocket.close()`
1718+
1719+
<!-- YAML
1720+
added: REPLACEME
1721+
-->
1722+
1723+
Releases the bound socket. Only needed when the handle is never adopted.
1724+
1725+
### `boundSocket[Symbol.dispose]()`
1726+
1727+
<!-- YAML
1728+
added: REPLACEME
1729+
-->
1730+
1731+
Closes the handle if it has not been adopted or closed; otherwise a no-op.
1732+
16301733
## `net.connect()`
16311734

16321735
Aliases to
@@ -1721,6 +1824,9 @@ and [`socket.connect(options[, connectListener])`][`socket.connect(options)`].
17211824

17221825
Additional options:
17231826

1827+
* `handle` {net.BoundSocket} A pre-bound [`BoundSocket`][] used as the
1828+
connection's source binding, honoring its local address and port. Adoption
1829+
consumes the bound socket (see [ownership transfer][`BoundSocket`]).
17241830
* `timeout` {number} If set, will be used to call
17251831
[`socket.setTimeout(timeout)`][] after the socket is created, but before
17261832
it starts the connection.
@@ -2097,6 +2203,8 @@ net.isIPv6('fhqwhgads'); // returns false
20972203
[`'error'`]: #event-error_1
20982204
[`'listening'`]: #event-listening
20992205
[`'timeout'`]: #event-timeout
2206+
[`BoundSocket`]: #class-netboundsocket
2207+
[`ERR_SOCKET_HANDLE_ADOPTED`]: errors.md#err_socket_handle_adopted
21002208
[`EventEmitter`]: events.md#class-eventemitter
21012209
[`child_process.fork()`]: child_process.md#child_processforkmodulepath-args-options
21022210
[`dns.lookup()`]: dns.md#dnslookuphostname-options-callback
@@ -2116,6 +2224,7 @@ net.isIPv6('fhqwhgads'); // returns false
21162224
[`net.getDefaultAutoSelectFamilyAttemptTimeout()`]: #netgetdefaultautoselectfamilyattempttimeout
21172225
[`new net.Socket(options)`]: #new-netsocketoptions
21182226
[`readable.setEncoding()`]: stream.md#readablesetencodingencoding
2227+
[`server.address()`]: #serveraddress
21192228
[`server.close()`]: #serverclosecallback
21202229
[`server.dropMaxConnection`]: #serverdropmaxconnection
21212230
[`server.listen()`]: #serverlisten

lib/internal/errors.js

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

0 commit comments

Comments
 (0)