diff --git a/doc/api/errors.md b/doc/api/errors.md index 4714df8e8244b9..c61c92cc52e251 100644 --- a/doc/api/errors.md +++ b/doc/api/errors.md @@ -2972,6 +2972,14 @@ disconnected socket. A call was made and the UDP subsystem was not running. + + +### `ERR_SOCKET_HANDLE_ADOPTED` + +An operation was attempted on a [`BoundHandle`][] that had already been adopted +by a [`net.Server`][] or [`net.Socket`][]. Once a bound handle is adopted, its +`address()` and `close()` methods can no longer be used. + ### `ERR_SOURCE_MAP_CORRUPT` @@ -4552,6 +4560,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 +[`BoundHandle`]: net.md#class-netboundhandle [`Class: assert.AssertionError`]: assert.md#class-assertassertionerror [`ERR_INCOMPATIBLE_OPTION_PAIR`]: #err_incompatible_option_pair [`ERR_INVALID_ARG_TYPE`]: #err_invalid_arg_type @@ -4595,7 +4604,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 diff --git a/doc/api/net.md b/doc/api/net.md index 9ee3c1497397da..a5e939e8819bae 100644 --- a/doc/api/net.md +++ b/doc/api/net.md @@ -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 [`BoundHandle`][], or an object with an `fd` +member that is a valid file descriptor. + +When `handle` is a [`BoundHandle`][], the server adopts the already-bound +socket and starts listening on it. Adoption consumes the bound handle (see +[ownership transfer][`BoundHandle`]). Listening on a file descriptor is not supported on Windows. @@ -769,6 +773,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.BoundHandle} If specified, wrap around the bound socket from a + [`BoundHandle`][]. A subsequent + [`socket.connect()`][`socket.connect()`] uses the bound handle as the + connection's source binding (honoring the bound local address and port). + Adoption consumes the bound handle (see + [ownership transfer][`BoundHandle`]). * `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 +1637,103 @@ 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.BoundHandle` + + + +A role-neutral wrapper over a synchronously bound TCP socket, mirroring POSIX +`bind(2)`, which is role-agnostic until `listen()` or `connect()`. It is adopted +by exactly one server (via [`server.listen()`][]) or socket (via the `handle` +option of [`new net.Socket()`][`new net.Socket(options)`]). 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.BoundHandle({ host: '127.0.0.1', port: 0 }); +const { port } = bound.address(); + +const server = net.createServer(); +server.listen(bound); // Adopt as a server, or pass to new net.Socket() instead. +``` + +### `new net.BoundHandle([options])` + + + +* `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`. + +Synchronously binds a TCP socket. Because `bind(2)` is a local, non-blocking +system call, the bind happens inline and errors (such as `EADDRINUSE`, +`EADDRNOTAVAIL`, `EACCES`, or `EINVAL`) are thrown synchronously. The +kernel-assigned address, including the ephemeral port chosen when `port` is `0`, +is available immediately via +[`boundHandle.address()`][`net.BoundHandle.address()`]. + +This is the synchronous, role-neutral counterpart to the bind performed +internally by [`server.listen()`][] and [`socket.connect()`][], analogous to +[`dgram` `socket.bindSync()`][]. + +### `boundHandle.address()` + + + +* 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. + +### `boundHandle.fd()` + + + +* 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 +`BoundHandle`, 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`][]. + +### `boundHandle.close()` + + + +Releases the bound socket. Only needed when the handle is never adopted. + +### `boundHandle[Symbol.dispose]()` + + + +Closes the handle if it has not been adopted or closed; otherwise a no-op. + ## `net.connect()` Aliases to @@ -2097,10 +2204,14 @@ net.isIPv6('fhqwhgads'); // returns false [`'error'`]: #event-error_1 [`'listening'`]: #event-listening [`'timeout'`]: #event-timeout +[`BoundHandle`]: #class-netboundhandle +[`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 +[`dgram` `socket.bindSync()`]: dgram.md#socketbindsyncoptions [`dns.lookup()`]: dns.md#dnslookuphostname-options-callback [`dns.lookup()` hints]: dns.md#supported-getaddrinfo-flags +[`net.BoundHandle.address()`]: #boundhandleaddress [`net.Server`]: #class-netserver [`net.Socket`]: #class-netsocket [`net.connect()`]: #netconnect @@ -2116,6 +2227,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 diff --git a/lib/internal/errors.js b/lib/internal/errors.js index f09788538ce8f5..b8b73d74e3c4ce 100644 --- a/lib/internal/errors.js +++ b/lib/internal/errors.js @@ -1788,6 +1788,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 handle 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', diff --git a/lib/net.js b/lib/net.js index ee4bc9943e4d52..ecd68f3935d303 100644 --- a/lib/net.js +++ b/lib/net.js @@ -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, } = require('internal/validators'); @@ -362,6 +364,127 @@ 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 BoundHandle. +const kBoundSource = Symbol('kBoundSource'); + +// Internal: adopt the underlying handle, transferring ownership to a +// Server/Socket. Used by server.listen() and the Socket constructor. +const kBoundHandleConsume = Symbol('kBoundHandleConsume'); + +// A thin, role-neutral wrapper over a synchronously bound libuv TCP handle, +// mirroring POSIX bind(2): the socket is bound to a local address but has not +// chosen a role. It neither listens nor connects until it is adopted by exactly +// one Server (via server.listen()) or Socket (via new net.Socket({ handle })). +// Adoption transfers ownership of the underlying handle; an un-adopted +// BoundHandle must be closed by the caller. +// +// bind(2) is a local, non-blocking system call, so binding happens inline in +// the constructor and errors throw synchronously. The host must be a numeric IP +// literal: no DNS resolution is performed. +class BoundHandle { + #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.BoundHandle 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(): it stashes the error and + // returns 0, surfacing it only on the next getsockname/listen/connect. + // Resolve the address now so bind conflicts throw synchronously here; the + // result is also cached (a BoundHandle is immutable, so it cannot change). + 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 for the bound socket, or -1 on platforms + // that do not expose one for sockets (e.g. Windows). Ownership stays with the + // BoundHandle, so the caller must not close the descriptor; once the handle is + // adopted, the descriptor belongs to the adopting Server/Socket instead. + 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.BoundHandle(...)`: 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; + } + } + + [kBoundHandleConsume]() { + 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); @@ -420,8 +543,17 @@ function Socket(options) { options.decodeStrings = false; stream.Duplex.call(this, options); + // An adopted BoundHandle 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 BoundHandle) { + this._handle = options.handle[kBoundHandleConsume](); + 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; @@ -492,7 +624,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; @@ -1391,6 +1523,17 @@ function lookupAndConnect(self, options) { validateString(host, 'options.host'); + // An adopted BoundHandle 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 handle'); + } + autoSelectFamily = false; + } + if (localAddress && !isIP(localAddress)) { throw new ERR_INVALID_IP_ADDRESS(localAddress); } @@ -2144,6 +2287,22 @@ Server.prototype.listen = function(...args) { toNumber(args.length > 1 && args[1]) || toNumber(args.length > 2 && args[2]); // (port, host, backlog) + // (boundHandle[, ...]) or ({ handle: boundHandle }[, ...]) from + // net.bindSync(): adopt the bound handle, transferring ownership so the + // BoundHandle can no longer close it. + let boundHandle = null; + if (options instanceof BoundHandle) { + boundHandle = options; + } else if (options.handle instanceof BoundHandle) { + boundHandle = options.handle; + } + if (boundHandle !== null) { + this._handle = boundHandle[kBoundHandleConsume](); + 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 @@ -2573,6 +2732,7 @@ module.exports = { SocketAddress ??= require('internal/socketaddress').SocketAddress; return SocketAddress; }, + BoundHandle, connect, createConnection: connect, createServer, diff --git a/test/parallel/test-net-boundhandle.js b/test/parallel/test-net-boundhandle.js new file mode 100644 index 00000000000000..15579fb890aa80 --- /dev/null +++ b/test/parallel/test-net-boundhandle.js @@ -0,0 +1,222 @@ +'use strict'; +const common = require('../common'); +const assert = require('assert'); +const net = require('net'); + +// Constructing a BoundHandle binds synchronously and address() reports the +// resolved address, including the OS-assigned ephemeral port when port is 0. +{ + const bound = new net.BoundHandle({ 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.BoundHandle({ 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(); +} + +// 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.BoundHandle({ 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.BoundHandle({ 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.BoundHandle({ host: 1234 }), + { code: 'ERR_INVALID_ARG_TYPE' }); + assert.throws(() => new net.BoundHandle(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.BoundHandle({ 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.BoundHandle({ 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.BoundHandle({ host: '127.0.0.1', port: 0 }); + const { port } = bound.address(); + bound.close(); + const again = new net.BoundHandle({ host: '127.0.0.1', port }); + assert.strictEqual(again.address().port, port); + again.close(); +} + +// Server adoption: server.listen(boundHandle), then a client round-trips. +{ + const bound = new net.BoundHandle({ 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 handle 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: boundHandle }).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.BoundHandle({ host: '127.0.0.1', port: 0 }); + const localPort = bound.address().port; + + const client = new net.Socket({ handle: bound }); + + // Adoption consumed the bound handle. + 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 handle: the +// handle already owns the local endpoint. +{ + const bound = new net.BoundHandle({ 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.BoundHandle({ 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.BoundHandle({ 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.BoundHandle({ 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.BoundHandle({ 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.BoundHandle({ host: '::', port: 0 }); + assert.strictEqual(bound.address().family, 'IPv6'); + bound.close(); + } +}