Skip to content

Commit 1fefa08

Browse files
committed
net: support transferring net.BoundSocket to other threads
Signed-off-by: Guy Bedford <guybedford@gmail.com>
1 parent fdcb1de commit 1fefa08

6 files changed

Lines changed: 185 additions & 10 deletions

File tree

doc/api/errors.md

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3004,8 +3004,9 @@ A call was made and the UDP subsystem was not running.
30043004
### `ERR_SOCKET_HANDLE_ADOPTED`
30053005

30063006
An operation was attempted on a [`BoundSocket`][] that had already been adopted
3007-
by a [`net.Server`][] or [`net.Socket`][]. Once a bound socket is adopted, its
3008-
`address()` and `close()` methods can no longer be used.
3007+
by a [`net.Server`][] or [`net.Socket`][], or transferred to another thread.
3008+
Once a bound socket is adopted or transferred, its `address()` and `close()`
3009+
methods can no longer be used.
30093010

30103011
<a id="ERR_SOURCE_MAP_CORRUPT"></a>
30113012

@@ -3573,9 +3574,10 @@ The `Response` that has been passed to `WebAssembly.compileStreaming` or to
35733574

35743575
### `ERR_WORKER_HANDLE_NOT_TRANSFERABLE`
35753576

3576-
An attempt was made to transfer a `net.Socket` or `net.Server` to another thread
3577-
via a `worker_threads` `postMessage()` call while it was not in a transferable
3578-
state, for example because it had already started reading or had buffered data.
3577+
An attempt was made to transfer a `net.Socket`, `net.Server` or
3578+
`net.BoundSocket` to another thread via a `worker_threads` `postMessage()` call
3579+
while it was not in a transferable state, for example because it had already
3580+
started reading, had buffered data, or had already been adopted.
35793581

35803582
<a id="ERR_WORKER_INIT_FAILED"></a>
35813583

doc/api/net.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -789,6 +789,13 @@ server.listen(8000);
789789
A listening [`net.Server`][] can be transferred the same way, which moves the
790790
listening socket itself (and its pending accept queue) to the receiving thread.
791791

792+
An un-adopted TCP [`BoundSocket`][] can also be transferred, which moves the
793+
bound (but not yet listening or connected) socket. This allows a port to be
794+
reserved synchronously on one thread and adopted by a server or outgoing
795+
connection on another. Pipe binds are not transferable. After the transfer, the
796+
source `BoundSocket` behaves as if it had been adopted: `address()`, `fd()` and
797+
`close()` throw [`ERR_SOCKET_HANDLE_ADOPTED`][].
798+
792799
### `new net.Socket([options])`
793800

794801
<!-- YAML
@@ -1718,6 +1725,10 @@ file system entry; abstract and TCP binds have none to remove.
17181725
When a pipe `BoundSocket` bound to a source `path` is adopted as a client, that
17191726
path is reported as the socket's `localAddress` once it connects.
17201727

1728+
An un-adopted TCP `BoundSocket` can be moved to another thread by listing it in
1729+
the `transferList` of a [`worker_threads`][] `postMessage()` call, see
1730+
[Transferring TCP handles to other threads][]. Pipe binds are not transferable.
1731+
17211732
When an adopted `BoundSocket` connects to a numeric IP literal, `connect(2)` is
17221733
issued synchronously, so [`socket.localAddress`][] is resolved once
17231734
[`socket.connect()`][] returns. Connection failures are still reported via a

doc/api/worker_threads.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1238,7 +1238,7 @@ port2.postMessage(circularData);
12381238
```
12391239
12401240
`transferList` may be a list of {ArrayBuffer}, [`MessagePort`][],
1241-
[`FileHandle`][], {net.Server}, and {net.Socket} objects.
1241+
[`FileHandle`][], {net.Server}, {net.Socket}, and {net.BoundSocket} objects.
12421242
After transferring, they are not usable on the sending side of the channel
12431243
anymore (even if they are not contained in `value`).
12441244
@@ -1249,6 +1249,8 @@ freshly accepted or created TCP connection that has not yet started reading and
12491249
has no buffered data, otherwise `postMessage()` throws
12501250
`ERR_WORKER_HANDLE_NOT_TRANSFERABLE`. This makes it possible to accept
12511251
connections on one thread and distribute them across a pool of worker threads.
1252+
Transferring a {net.BoundSocket} moves an un-adopted pre-bound socket, so a
1253+
port can be reserved synchronously on one thread and adopted on another.
12521254
Only TCP handles are supported.
12531255
12541256
If `value` contains {SharedArrayBuffer} instances, those are accessible

lib/net.js

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -388,6 +388,10 @@ const kBoundPath = Symbol('kBoundPath');
388388

389389
const isLinux = process.platform === 'linux';
390390

391+
// Internal: construct an empty BoundSocket shell during postMessage()
392+
// deserialization; the transferred handle is installed by [kDeserialize].
393+
const kBoundSocketDeserialize = Symbol('kBoundSocketDeserialize');
394+
391395
// A role-neutral wrapper over a synchronously bound libuv handle: bound to a
392396
// local address (a numeric IP literal for TCP, or a filesystem/abstract path
393397
// for a unix-domain socket via { path }) but neither listening nor connecting
@@ -396,11 +400,20 @@ const isLinux = process.platform === 'linux';
396400
// handle must be closed by the caller. bind(2) is non-blocking, so binding
397401
// happens inline and errors throw synchronously. No DNS is performed.
398402
class BoundSocket {
399-
#handle;
403+
#handle = null;
400404
#address = {};
401405
#path;
402406

403407
constructor(options = kEmptyObject) {
408+
// An un-adopted BoundSocket can be moved to another thread by listing it
409+
// in the transferList of a worker_threads postMessage() call. See
410+
// [kTransfer]().
411+
markTransferMode(this, false, true);
412+
413+
if (options === kBoundSocketDeserialize) {
414+
return;
415+
}
416+
404417
validateObject(options, 'options');
405418

406419
if (options.path !== undefined) {
@@ -544,7 +557,56 @@ class BoundSocket {
544557
get isPipe() {
545558
return this.#path !== undefined;
546559
}
560+
561+
// A BoundSocket can be transferred only while it still owns its handle,
562+
// i.e. before it has been adopted, closed or already transferred. Only TCP
563+
// binds are transferable; pipe handles cannot move between event loops.
564+
#assertTransferable() {
565+
if (this.#handle === null || !(this.#handle instanceof TCP)) {
566+
throw new ERR_WORKER_HANDLE_NOT_TRANSFERABLE('net.BoundSocket');
567+
}
568+
}
569+
570+
[kTransferList]() {
571+
this.#assertTransferable();
572+
return [this.#handle];
573+
}
574+
575+
[kTransfer]() {
576+
this.#assertTransferable();
577+
const handle = this.#handle;
578+
// Detach the handle; the messaging layer takes ownership of it via
579+
// TCPWrap::TransferForMessaging(). Further use on the sending side throws
580+
// ERR_SOCKET_HANDLE_ADOPTED, as after adoption.
581+
this.#handle = null;
582+
return {
583+
data: { handle },
584+
deserializeInfo: 'net:_TransferredBoundSocket',
585+
};
586+
}
587+
588+
[kDeserialize](data) {
589+
const handle = data?.handle;
590+
if (handle == null || !(handle instanceof TCP)) {
591+
throw new ERR_WORKER_HANDLE_NOT_TRANSFERABLE('net.BoundSocket');
592+
}
593+
// Re-derive the bound address from the transferred handle rather than
594+
// trusting serialized state.
595+
const err = handle.getsockname(this.#address);
596+
if (err) {
597+
handle.close();
598+
throw new ERR_WORKER_HANDLE_NOT_TRANSFERABLE('net.BoundSocket');
599+
}
600+
this.#handle = handle;
601+
}
602+
}
603+
604+
// Deserialization target for a transferred BoundSocket: constructs an empty
605+
// shell without binding a new socket. Internal, not part of the public API.
606+
function _TransferredBoundSocket() {
607+
return new BoundSocket(kBoundSocketDeserialize);
547608
}
609+
_TransferredBoundSocket.prototype = BoundSocket.prototype;
548610

549611
function Socket(options) {
550612
if (!(this instanceof Socket)) return new Socket(options);
@@ -2933,6 +2995,7 @@ Server.prototype.unref = function() {
29332995
module.exports = {
29342996
_createServerHandle: createServerHandle,
29352997
_normalizeArgs: normalizeArgs,
2998+
_TransferredBoundSocket,
29362999
get BlockList() {
29373000
BlockList ??= require('internal/blocklist').BlockList;
29383001
return BlockList;
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
'use strict';
2+
3+
// This test verifies that an un-adopted net.BoundSocket can be transferred to
4+
// a worker thread via worker_threads postMessage()'s transferList. The parent
5+
// thread binds synchronously to reserve the port, then hands the bound socket
6+
// off; the worker adopts it via server.listen() and accepts connections.
7+
8+
const common = require('../common');
9+
10+
const assert = require('assert');
11+
const net = require('net');
12+
const {
13+
Worker,
14+
parentPort,
15+
threadId,
16+
workerData,
17+
} = require('worker_threads');
18+
19+
if (workerData?.role === 'server') {
20+
parentPort.on('message', common.mustCall(({ bound, port }) => {
21+
assert.ok(bound instanceof net.BoundSocket);
22+
// The bound address survives the transfer.
23+
assert.strictEqual(bound.address().port, port);
24+
25+
const server = net.createServer((socket) => {
26+
socket.end(`served-by:${threadId}`);
27+
});
28+
server.listen(bound, common.mustCall(() => {
29+
assert.strictEqual(server.address().port, port);
30+
// Adoption consumed the bound socket.
31+
assert.throws(() => bound.address(), {
32+
code: 'ERR_SOCKET_HANDLE_ADOPTED',
33+
});
34+
parentPort.postMessage('listening');
35+
}));
36+
}));
37+
return;
38+
}
39+
40+
const worker = new Worker(__filename, { workerData: { role: 'server' } });
41+
42+
const bound = new net.BoundSocket({ host: '127.0.0.1', port: 0 });
43+
const { port } = bound.address();
44+
45+
// Move the bound socket to the worker thread.
46+
worker.postMessage({ bound, port }, [bound]);
47+
48+
// The source is left in the adopted state; further use throws.
49+
assert.throws(() => bound.address(), { code: 'ERR_SOCKET_HANDLE_ADOPTED' });
50+
assert.throws(() => bound.fd(), { code: 'ERR_SOCKET_HANDLE_ADOPTED' });
51+
assert.throws(() => bound.close(), { code: 'ERR_SOCKET_HANDLE_ADOPTED' });
52+
53+
worker.on('message', common.mustCall((msg) => {
54+
assert.strictEqual(msg, 'listening');
55+
const client = net.connect(port, '127.0.0.1');
56+
client.setEncoding('utf8');
57+
let response = '';
58+
client.on('data', (chunk) => { response += chunk; });
59+
client.on('end', common.mustCall(() => {
60+
assert.match(response, /^served-by:\d+$/);
61+
assert.notStrictEqual(response, `served-by:${threadId}`);
62+
worker.terminate();
63+
}));
64+
}));

test/parallel/test-net-transfer-guards.js

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,31 @@
11
'use strict';
22

3-
// This test verifies the guards that reject transferring a net.Socket or
4-
// net.Server that is not in a clean, movable state. Serialization is triggered
5-
// with a MessageChannel, so no worker thread is required.
3+
// This test verifies the guards that reject transferring a net.Socket,
4+
// net.Server or net.BoundSocket that is not in a clean, movable state.
5+
// Serialization is triggered with a MessageChannel, so no worker thread is
6+
// required.
67

78
const common = require('../common');
89

910
const assert = require('assert');
1011
const net = require('net');
1112
const { MessageChannel } = require('worker_threads');
13+
const tmpdir = require('../common/tmpdir');
14+
15+
tmpdir.refresh();
1216

1317
const { port1 } = new MessageChannel();
1418

19+
// A pipe BoundSocket is not transferable; only TCP binds can move between
20+
// event loops.
21+
{
22+
const bound = new net.BoundSocket({ path: common.PIPE });
23+
assert.throws(() => port1.postMessage({ bound }, [bound]), {
24+
code: 'ERR_WORKER_HANDLE_NOT_TRANSFERABLE',
25+
});
26+
bound.close();
27+
}
28+
1529
// A Server that is not listening (no handle) cannot be transferred.
1630
{
1731
const server = net.createServer();
@@ -20,6 +34,25 @@ const { port1 } = new MessageChannel();
2034
});
2135
}
2236

37+
// A closed BoundSocket no longer owns a handle and cannot be transferred.
38+
{
39+
const bound = new net.BoundSocket({ host: '127.0.0.1', port: 0 });
40+
bound.close();
41+
assert.throws(() => port1.postMessage({ bound }, [bound]), {
42+
code: 'ERR_WORKER_HANDLE_NOT_TRANSFERABLE',
43+
});
44+
}
45+
46+
// An adopted BoundSocket no longer owns a handle and cannot be transferred.
47+
{
48+
const bound = new net.BoundSocket({ host: '127.0.0.1', port: 0 });
49+
const socket = new net.Socket({ handle: bound });
50+
assert.throws(() => port1.postMessage({ bound }, [bound]), {
51+
code: 'ERR_WORKER_HANDLE_NOT_TRANSFERABLE',
52+
});
53+
socket.destroy();
54+
}
55+
2356
// A Socket that has already consumed data cannot be transferred, because that
2457
// buffered data would be lost on the sending side.
2558
{

0 commit comments

Comments
 (0)