diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml
index b989dd1faaef45..48b3f1d6125975 100644
--- a/.github/workflows/benchmark.yml
+++ b/.github/workflows/benchmark.yml
@@ -25,11 +25,43 @@ on:
type: number
default: 30
description: How many times to repeat each benchmark
+ post-comment:
+ type: boolean
+ description: Post a comment linking to the run, with the aggregated result once known.
+ token:
+ type: string
+ description: A GitHub token to post comments cross repository (not recommended)
permissions:
contents: read
jobs:
+ post-comment:
+ if: inputs.post-comment
+ outputs:
+ body: ${{ steps.comment.outputs.COMMENT_BODY }}
+ url: ${{ steps.comment.outputs.COMMENT_URL }}
+ runs-on: ubuntu-slim
+ permissions:
+ pull-requests: write
+ steps:
+ - name: Mark token input as sensitive
+ if: inputs.token != ''
+ run: echo "::add-mask::${{ inputs.token }}"
+ - name: Add link to the current run
+ id: comment
+ run: |
+ FILTER_IF_SET=
+ [ -z "$FILTER" ] || FILTER_IF_SET=" / $FILTER"
+ COMMENT_BODY="Benchmark GHA (${CATEGORIES}${FILTER_IF_SET}): ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"
+ echo "COMMENT_BODY=$COMMENT_BODY" >> "$GITHUB_OUTPUT"
+ echo "COMMENT_URL=$(gh pr comment -R "${REPO:-$GITHUB_REPOSITORY}" "$PR_ID" --body "$COMMENT_BODY")" >> "$GITHUB_OUTPUT"
+ env:
+ CATEGORIES: ${{ inputs.category }}
+ FILTER: ${{ inputs.filter }}
+ GH_TOKEN: ${{ inputs.token || github.token }}
+ REPO: ${{ inputs.repo }}
+ PR_ID: ${{ inputs.pr_id }}
build:
strategy:
fail-fast: true
@@ -155,9 +187,13 @@ jobs:
path: ${{ matrix.system }}.csv
aggregate-results:
- needs: build
+ needs: [build, post-comment]
+ if: ${{ always() && needs.build.result == 'success' && needs.post-comment.result == (inputs.post-comment && 'success' || 'skipped') }}
name: Aggregate benchmark results
runs-on: ubuntu-slim
+ permissions:
+ contents: read
+ pull-requests: write
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
@@ -200,4 +236,30 @@ jobs:
echo "> [!WARNING] "
echo "> Do not take GHA benchmark results as face value, always confirm them"
echo "> using a dedicated machine, e.g. Jenkins CI."
- ' | tee /dev/stderr >> "$GITHUB_STEP_SUMMARY"
+ ' | tee /dev/stderr ${{ inputs.post-comment && 'body.txt' || '' }} >> "$GITHUB_STEP_SUMMARY"
+ - name: Mark token input as sensitive
+ if: inputs.token != ''
+ run: echo "::add-mask::${{ inputs.token }}"
+ - name: Edit comment
+ if: inputs.post-comment
+ run: |
+ {
+ echo "$COMMENT_BODY"
+ echo
+ echo 'Results'
+ echo
+ cat body.txt
+ echo
+ echo ''
+ } | jq -Rrcs '{ body: . }' | gh api \
+ --method PATCH \
+ -H "Accept: application/vnd.github+json" \
+ -H "X-GitHub-Api-Version: 2026-03-10" \
+ "/repos/${REPO:-$GITHUB_REPOSITORY}/issues/comments/${COMMENT_URL##*-}" \
+ --input -
+ env:
+ GH_TOKEN: ${{ inputs.token || github.token }}
+ REPO: ${{ inputs.repo }}
+ COMMENT_BODY: ${{ needs.post-comment.outputs.body }}
+ COMMENT_URL: ${{ needs.post-comment.outputs.url }}
+ PR_ID: ${{ inputs.pr_id }}
diff --git a/doc/api/errors.md b/doc/api/errors.md
index db3eb81eff6b90..29cd9e8a4937d1 100644
--- a/doc/api/errors.md
+++ b/doc/api/errors.md
@@ -3582,6 +3582,22 @@ added: v18.1.0
The `Response` that has been passed to `WebAssembly.compileStreaming` or to
`WebAssembly.instantiateStreaming` is not a valid WebAssembly response.
+
+
+### `ERR_WORKER_HANDLE_NOT_TRANSFERABLE`
+
+An attempt was made to transfer a `net.Socket` or `net.Server` to another thread
+via a `worker_threads` `postMessage()` call while it was not in a transferable
+state, for example because it had already started reading or had buffered data.
+
+
+
+### `ERR_WORKER_HANDLE_TRANSFER_UNSUPPORTED`
+
+An attempt was made to transfer a `net.Socket` or `net.Server` to another thread
+on a platform where moving the underlying handle between event loops is not
+supported (currently Windows).
+
### `ERR_WORKER_INIT_FAILED`
diff --git a/doc/api/net.md b/doc/api/net.md
index e2f06776409898..81863d9b0a13c5 100644
--- a/doc/api/net.md
+++ b/doc/api/net.md
@@ -305,6 +305,11 @@ added: v0.1.90
This class is used to create a TCP or [IPC][] server.
+A listening TCP `net.Server` can be transferred to a worker thread by listing it
+in the `transferList` of a [`worker_threads`][] `postMessage()` call. This moves
+the underlying listening socket to the receiving thread, where it resumes
+accepting connections. See [Transferring TCP handles to other threads][].
+
### `new net.Server([options][, connectionListener])`
* `options` {Object} See
@@ -751,6 +756,41 @@ is received. For example, it is passed to the listeners of a
[`'connection'`][] event emitted on a [`net.Server`][], so the user can use
it to interact with the client.
+### Transferring TCP handles to other threads
+
+A connected TCP `net.Socket` can be moved to another thread by listing it in the
+`transferList` of a [`worker_threads`][] `postMessage()` call. After the
+transfer, the source socket is destroyed on the sending thread (further use
+fails with `ERR_STREAM_DESTROYED` rather than silently dropping data), and the
+socket continues to work on the receiving thread. This makes it possible to
+accept connections on one thread and distribute them across a pool of worker
+threads, for example to build a `node:cluster`-like model on top of worker
+threads.
+
+The socket must be a freshly accepted or created TCP connection: it must still
+be attached to a live handle, must not be connecting or destroyed, and must not
+have started reading or have buffered data. Otherwise `postMessage()` throws
+`ERR_WORKER_HANDLE_NOT_TRANSFERABLE`. Only TCP sockets are supported, and only
+on Unix-like platforms; on Windows `postMessage()` throws
+`ERR_WORKER_HANDLE_TRANSFER_UNSUPPORTED`.
+
+```cjs
+const net = require('node:net');
+const { Worker } = require('node:worker_threads');
+
+// worker.js receives `{ socket }` messages and handles each connection.
+const worker = new Worker('./worker.js');
+
+const server = net.createServer((socket) => {
+ // Hand the freshly accepted connection off to the worker thread.
+ worker.postMessage({ socket }, [socket]);
+});
+server.listen(8000);
+```
+
+A listening [`net.Server`][] can be transferred the same way, which moves the
+listening socket itself (and its pending accept queue) to the receiving thread.
+
### `new net.Socket([options])`
+
+* `data` {Object}
+ * `column` {number|undefined} The column number where the test is defined, or
+ `undefined` if the test was run through the REPL.
+ * `data` {any} The structured payload passed to [`context.log`][], or
+ `undefined` if none was provided. The test runner does not interpret this
+ value.
+ * `entryFile` {string|undefined} The path of the test file that was
+ executed as the entry point of the child process that emitted this event.
+ Only present when tests run with process isolation. May differ from
+ `file` when the test is defined in a module imported by the entry file.
+ * `file` {string|undefined} The path of the test file,
+ `undefined` if test was run through the REPL.
+ * `line` {number|undefined} The line number where the test is defined, or
+ `undefined` if the test was run through the REPL.
+ * `message` {string} The log message.
+ * `name` {string} The test name.
+ * `nesting` {number} The nesting level of the test.
+ * `parentId` {number|undefined} The `testId` of the enclosing test, or
+ `undefined` for top-level tests.
+ * `testId` {number} A numeric identifier for the test instance that emitted
+ the log message.
+
+Emitted when [`context.log`][] is called. Unlike [`'test:diagnostic'`][],
+this event is emitted immediately, in the order that the tests execute,
+making it suitable for reporters that render test output unbuffered.
+
### Event: `'test:pass'`
* `data` {Object}
@@ -4272,6 +4308,29 @@ test('top level test', (t) => {
});
```
+### `context.log(message[, data])`
+
+
+
+* `message` {string} Message to be reported.
+* `data` {any} Optional structured payload attached to the message. The test
+ runner passes it through untouched. When tests run with process isolation,
+ this value must be compatible with the [HTML structured clone algorithm][].
+
+This function is used to write a log message to the output. Unlike
+[`context.diagnostic`][], the resulting [`'test:log'`][] event is emitted
+immediately, in the order that the tests execute, rather than being buffered
+until the test reports its results. This function does not return a value.
+
+```js
+test('top level test', (t) => {
+ t.log('fetched user', { userId: 42 });
+ t.log('retrying flaky endpoint', { attempt: 3 });
+});
+```
+
### `context.filePath`
+
+* `message` {string} Message to be reported.
+* `data` {any} Optional structured payload attached to the message. The test
+ runner passes it through untouched.
+
+Write a log message to the output. The resulting [`'test:log'`][] event is
+emitted immediately, in the order that the tests execute.
+
+```js
+test.describe('my suite', (suite) => {
+ suite.log('Suite log message');
+});
+```
+
+[HTML structured clone algorithm]: https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Structured_clone_algorithm
[TAP]: https://testanything.org/
[Test tags]: #test-tags
[`'test:complete'`]: #event-testcomplete
@@ -4770,6 +4849,7 @@ test.describe('my suite', (suite) => {
[`'test:enqueue'`]: #event-testenqueue
[`'test:fail'`]: #event-testfail
[`'test:interrupted'`]: #event-testinterrupted
+[`'test:log'`]: #event-testlog
[`'test:pass'`]: #event-testpass
[`'test:plan'`]: #event-testplan
[`'test:start'`]: #event-teststart
@@ -4805,6 +4885,7 @@ test.describe('my suite', (suite) => {
[`TracingChannel`]: diagnostics_channel.md#class-tracingchannel
[`assert.throws`]: assert.md#assertthrowsfn-error-message
[`context.diagnostic`]: #contextdiagnosticmessage
+[`context.log`]: #contextlogmessage-data
[`context.skip`]: #contextskipmessage
[`context.tags`]: #contexttags
[`context.todo`]: #contexttodomessage
diff --git a/doc/api/worker_threads.md b/doc/api/worker_threads.md
index 525825ade82621..d0b4f0a830b03e 100644
--- a/doc/api/worker_threads.md
+++ b/doc/api/worker_threads.md
@@ -1208,6 +1208,8 @@ In particular, the significant differences to `JSON` are:
* {KeyObject}s,
* {MessagePort}s,
* {net.BlockList}s,
+ * {net.Server}s (TCP only, when listed in `transferList`),
+ * {net.Socket}s (TCP only, when listed in `transferList`),
* {net.SocketAddress}es,
* {X509Certificate}s.
@@ -1235,12 +1237,20 @@ circularData.foo = circularData;
port2.postMessage(circularData);
```
-`transferList` may be a list of {ArrayBuffer}, [`MessagePort`][], and
-[`FileHandle`][] objects.
+`transferList` may be a list of {ArrayBuffer}, [`MessagePort`][],
+[`FileHandle`][], {net.Server}, and {net.Socket} objects.
After transferring, they are not usable on the sending side of the channel
-anymore (even if they are not contained in `value`). Unlike with
-[child processes][], transferring handles such as network sockets is currently
-not supported.
+anymore (even if they are not contained in `value`).
+
+Transferring a {net.Server} moves its listening socket — together with any
+pending connections in the accept queue — to the receiving thread's event loop.
+Transferring a {net.Socket} moves a single connection; the socket must be a
+freshly accepted or created TCP connection that has not yet started reading and
+has no buffered data, otherwise `postMessage()` throws
+`ERR_WORKER_HANDLE_NOT_TRANSFERABLE`. This makes it possible to accept
+connections on one thread and distribute them across a pool of worker threads.
+Only TCP handles are supported, and only on Unix-like platforms; on Windows
+`postMessage()` throws `ERR_WORKER_HANDLE_TRANSFER_UNSUPPORTED`.
If `value` contains {SharedArrayBuffer} instances, those are accessible
from either thread. They cannot be listed in `transferList`.
@@ -2276,7 +2286,6 @@ thread spawned will spawn another until the application crashes.
[async-resource-worker-pool]: async_context.md#using-asyncresource-for-a-worker-thread-pool
[browser `LockManager`]: https://developer.mozilla.org/en-US/docs/Web/API/LockManager
[browser `MessagePort`]: https://developer.mozilla.org/en-US/docs/Web/API/MessagePort
-[child processes]: child_process.md
[contextified]: vm.md#what-does-it-mean-to-contextify-an-object
[locks.request()]: #locksrequestname-options-callback
[v8.serdes]: v8.md#serialization-api
diff --git a/lib/_http_client.js b/lib/_http_client.js
index 891da4e3f9a984..d202a28f03502f 100644
--- a/lib/_http_client.js
+++ b/lib/_http_client.js
@@ -121,6 +121,8 @@ let debug = require('internal/util/debuglog').debuglog('http', (fn) => {
const INVALID_PATH_REGEX = /[^\u0021-\u00ff]/;
const kError = Symbol('kError');
const kPath = Symbol('kPath');
+const kAuthority = Symbol('kAuthority');
+const kProxyRewrittenToAbsolute = Symbol('kProxyRewrittenToAbsolute');
const HTTP_CLIENT_TRACE_EVENT_NAME = 'http.client.request';
@@ -318,6 +320,7 @@ function rewriteForProxiedHttp(req, reqOptions, proxyAuthority, userHostHeader,
if (requestURL !== undefined) {
// Convert the path to absolute-form. The authority is built from options.
req.path = requestURL.href;
+ req[kProxyRewrittenToAbsolute] = true;
}
debug(`updated request for HTTP proxy ${href} with ${req.path} `, req[kOutHeaders]);
return true;
@@ -479,6 +482,7 @@ function ClientRequest(input, options, cb) {
this.reusedSocket = false;
this.host = host;
this.protocol = protocol;
+ this[kProxyRewrittenToAbsolute] = false;
if (this.agent) {
// If there is an agent we should default to Connection:keep-alive,
@@ -509,6 +513,9 @@ function ClientRequest(input, options, cb) {
if (port && +port !== defaultPort) {
hostHeaderFromOptions += ':' + port;
}
+ // Preserve the request authority (with the port when non-default) so that
+ // the perf_hooks entry can report a faithful URL.
+ this[kAuthority] = hostHeaderFromOptions;
const headersArray = ArrayIsArray(options.headers);
if (!headersArray) {
if (options.headers) {
@@ -627,7 +634,11 @@ ClientRequest.prototype._finish = function _finish() {
detail: {
req: {
method: this.method,
- url: `${this.protocol}//${this.host}${this.path}`,
+ // If the path has been rewritten to absolute-form for proxying,
+ // it is already a full URL.
+ url: this[kProxyRewrittenToAbsolute] ?
+ this.path :
+ `${this.protocol}//${this[kAuthority]}${this.path}`,
headers: typeof this.getHeaders === 'function' ? this.getHeaders() : {},
},
},
@@ -741,10 +752,24 @@ function socketErrorListener(err) {
debug('SOCKET ERROR:', err.message, err.stack);
if (req) {
+ const res = req.res;
+ const isUserDestroyError = err === req[kError] || err === res?.errored;
+
// For Safety. Some additional errors might fire later on
// and we need to make sure we don't double-fire the error event.
socket._hadError = true;
- emitErrorEvent(req, err);
+ // Before a response exists, the request itself failed. Once a response
+ // exists, socket teardown belongs to the IncomingMessage and is finalized
+ // by socketCloseListener. Preserve errors explicitly used to destroy the
+ // request or response, which have historically been emitted on the request.
+ if (!res || isUserDestroyError) {
+ emitErrorEvent(req, err);
+ }
+
+ if (res) {
+ socket.destroy();
+ return;
+ }
}
const parser = socket.parser;
diff --git a/lib/internal/errors.js b/lib/internal/errors.js
index 03d27332c894d8..5f4cba63a64499 100644
--- a/lib/internal/errors.js
+++ b/lib/internal/errors.js
@@ -1976,6 +1976,12 @@ E('ERR_WEBASSEMBLY_NOT_SUPPORTED',
'WebAssembly is not supported in this environment, but is required for %s',
Error);
E('ERR_WEBASSEMBLY_RESPONSE', 'WebAssembly response %s', TypeError);
+E('ERR_WORKER_HANDLE_NOT_TRANSFERABLE',
+ '%s cannot be transferred in its current state; it must be a freshly ' +
+ 'created or accepted handle that has not started reading and has no ' +
+ 'pending writes', Error);
+E('ERR_WORKER_HANDLE_TRANSFER_UNSUPPORTED',
+ 'Transferring a %s to another thread is not supported on this platform', Error);
E('ERR_WORKER_INIT_FAILED', 'Worker initialization failure: %s', Error);
E('ERR_WORKER_INVALID_EXEC_ARGV', (errors, msg = 'invalid execArgv flags') =>
`Initiated Worker with ${msg}: ${ArrayPrototypeJoin(errors, ', ')}`,
diff --git a/lib/internal/test_runner/reporter/junit.js b/lib/internal/test_runner/reporter/junit.js
index 130ff60af405ad..ed25a4bd5fbd7c 100644
--- a/lib/internal/test_runner/reporter/junit.js
+++ b/lib/internal/test_runner/reporter/junit.js
@@ -154,7 +154,8 @@ module.exports = async function* junitReporter(source) {
}
break;
}
- case 'test:diagnostic': {
+ case 'test:diagnostic':
+ case 'test:log': {
const parent = currentSuite?.children ?? roots;
ArrayPrototypePush(parent, {
__proto__: null, nesting: event.data.nesting, comment: event.data.message,
diff --git a/lib/internal/test_runner/reporter/spec.js b/lib/internal/test_runner/reporter/spec.js
index fce0754e25061a..7f5920c7b38a30 100644
--- a/lib/internal/test_runner/reporter/spec.js
+++ b/lib/internal/test_runner/reporter/spec.js
@@ -91,8 +91,9 @@ class SpecReporter extends Transform {
case 'test:stderr':
case 'test:stdout':
return data.message;
- case 'test:diagnostic':{
- const diagnosticColor = reporterColorMap[data.level] || reporterColorMap['test:diagnostic'];
+ case 'test:diagnostic':
+ case 'test:log': {
+ const diagnosticColor = reporterColorMap[data.level] || reporterColorMap[type];
return `${diagnosticColor}${indent(data.nesting)}${reporterUnicodeSymbolMap[type]}${data.message}${colors.white}\n`;
}
case 'test:coverage':
diff --git a/lib/internal/test_runner/reporter/tap.js b/lib/internal/test_runner/reporter/tap.js
index c3bbd1d144eb35..2bacba167570a8 100644
--- a/lib/internal/test_runner/reporter/tap.js
+++ b/lib/internal/test_runner/reporter/tap.js
@@ -56,6 +56,7 @@ async function * tapReporter(source) {
}
break;
} case 'test:diagnostic':
+ case 'test:log':
yield `${indent(data.nesting)}# ${tapEscape(data.message)}\n`;
break;
case 'test:coverage':
diff --git a/lib/internal/test_runner/reporter/utils.js b/lib/internal/test_runner/reporter/utils.js
index 67030680019838..995155c533c51d 100644
--- a/lib/internal/test_runner/reporter/utils.js
+++ b/lib/internal/test_runner/reporter/utils.js
@@ -21,6 +21,7 @@ const reporterUnicodeSymbolMap = {
'test:fail': '\u2716 ',
'test:pass': '\u2714 ',
'test:diagnostic': '\u2139 ',
+ 'test:log': '\u2139 ',
'test:coverage': '\u2139 ',
'arrow:right': '\u25B6 ',
'hyphen:minus': '\uFE63 ',
@@ -38,6 +39,9 @@ const reporterColorMap = {
get 'test:diagnostic'() {
return colors.blue;
},
+ get 'test:log'() {
+ return colors.blue;
+ },
get 'info'() {
return colors.blue;
},
diff --git a/lib/internal/test_runner/runner.js b/lib/internal/test_runner/runner.js
index 7f50cc8521bcda..dc6529c220539a 100644
--- a/lib/internal/test_runner/runner.js
+++ b/lib/internal/test_runner/runner.js
@@ -130,7 +130,7 @@ const kCanceledTests = new SafeSet()
// Execution-ordered events are forwarded immediately, bypassing the
// per-file declaration-order buffer.
const kExecutionOrderedEvents = new SafeSet()
- .add('test:enqueue').add('test:dequeue').add('test:complete');
+ .add('test:enqueue').add('test:dequeue').add('test:complete').add('test:log');
let kResistStopPropagation;
diff --git a/lib/internal/test_runner/test.js b/lib/internal/test_runner/test.js
index d19d6349f104bf..1b9faad58a68b5 100644
--- a/lib/internal/test_runner/test.js
+++ b/lib/internal/test_runner/test.js
@@ -80,6 +80,7 @@ const {
validateNumber,
validateObject,
validateOneOf,
+ validateString,
validateUint32,
} = require('internal/validators');
const {
@@ -315,6 +316,10 @@ class TestContext {
this.#test.diagnostic(message);
}
+ log(message, data) {
+ this.#test.log(message, data);
+ }
+
plan(count, options = kEmptyObject) {
if (this.#test.plan !== null) {
throw new ERR_TEST_FAILURE(
@@ -523,6 +528,10 @@ class SuiteContext {
diagnostic(message) {
this.#suite.diagnostic(message);
}
+
+ log(message, data) {
+ this.#suite.log(message, data);
+ }
}
function parseExpectFailure(expectFailure) {
@@ -1198,6 +1207,12 @@ class Test extends AsyncResource {
ArrayPrototypePush(this.diagnostics, message);
}
+ log(message, data) {
+ validateString(message, 'message');
+ this.reporter.log(this.nesting, this.loc, message, data,
+ this.name, this.testId, this.parent?.testId);
+ }
+
start() {
this.applyFilters();
diff --git a/lib/internal/test_runner/tests_stream.js b/lib/internal/test_runner/tests_stream.js
index 8be33f90dfa0f2..7fb514fb99e2f5 100644
--- a/lib/internal/test_runner/tests_stream.js
+++ b/lib/internal/test_runner/tests_stream.js
@@ -139,6 +139,19 @@ class TestsStream extends Readable {
});
}
+ log(nesting, loc, message, data, name, testId, parentId) {
+ this[kEmitMessage]('test:log', {
+ __proto__: null,
+ name,
+ nesting,
+ testId,
+ parentId,
+ message,
+ data,
+ ...loc,
+ });
+ }
+
diagnostic(nesting, loc, message, level = 'info') {
this[kEmitMessage]('test:diagnostic', {
__proto__: null,
diff --git a/lib/net.js b/lib/net.js
index fa685769422186..d0620449c31e7b 100644
--- a/lib/net.js
+++ b/lib/net.js
@@ -119,9 +119,17 @@ const {
ERR_SOCKET_CLOSED_BEFORE_CONNECTION,
ERR_SOCKET_CONNECTION_TIMEOUT,
ERR_SOCKET_HANDLE_ADOPTED,
+ ERR_WORKER_HANDLE_NOT_TRANSFERABLE,
+ ERR_WORKER_HANDLE_TRANSFER_UNSUPPORTED,
},
genericNodeError,
} = require('internal/errors');
+const {
+ markTransferMode,
+ kDeserialize,
+ kTransfer,
+ kTransferList,
+} = require('internal/worker/js_transferable');
const { isUint8Array } = require('internal/util/types');
const { queueMicrotask } = require('internal/process/task_queues');
const {
@@ -484,6 +492,9 @@ class BoundSocket {
function Socket(options) {
if (!(this instanceof Socket)) return new Socket(options);
+ // A connected TCP Socket can be moved to another thread by listing it in the
+ // transferList of a worker_threads postMessage() call. See [kTransfer]().
+ markTransferMode(this, false, true);
if (options?.objectMode) {
throw new ERR_INVALID_ARG_VALUE(
'options.objectMode',
@@ -1501,6 +1512,58 @@ Socket.prototype[kReinitializeHandle] = function reinitializeHandle(handle) {
initSocketHandle(this);
};
+// A Socket can be transferred to another thread only while it is a freshly
+// accepted/created TCP connection: still attached to a live handle, not
+// connecting or destroyed, and with no data already buffered in either
+// direction (which would otherwise be lost on the sending side).
+function assertTransferableSocket(socket) {
+ if (isWindows) {
+ throw new ERR_WORKER_HANDLE_TRANSFER_UNSUPPORTED('net.Socket');
+ }
+ const handle = socket._handle;
+ if (handle == null || !(handle instanceof TCP) ||
+ socket.destroyed || socket.connecting ||
+ socket.bytesRead > 0 || socket.bytesWritten > 0 ||
+ socket.readableLength > 0 || socket.writableLength > 0 ||
+ socket.readableEncoding != null) {
+ throw new ERR_WORKER_HANDLE_NOT_TRANSFERABLE('net.Socket');
+ }
+}
+
+Socket.prototype[kTransferList] = function() {
+ assertTransferableSocket(this);
+ return [this._handle];
+};
+
+Socket.prototype[kTransfer] = function() {
+ assertTransferableSocket(this);
+ const handle = this._handle;
+ const data = {
+ handle,
+ allowHalfOpen: this.allowHalfOpen,
+ };
+ // Detach the handle from this source socket; the messaging layer takes
+ // ownership of it via TCPWrap::TransferForMessaging(). Destroy the source so
+ // any further use on the sending side fails cleanly (the socket is now owned
+ // by the receiving thread) instead of silently dropping data.
+ this._handle = null;
+ this.destroy();
+ return {
+ data,
+ deserializeInfo: 'net:Socket',
+ };
+};
+
+Socket.prototype[kDeserialize] = function(data) {
+ const handle = data?.handle;
+ if (handle == null || !(handle instanceof TCP)) {
+ throw new ERR_WORKER_HANDLE_NOT_TRANSFERABLE('net.Socket');
+ }
+ this.allowHalfOpen = Boolean(data.allowHalfOpen);
+ this[kReinitializeHandle](handle);
+ this.readable = this.writable = true;
+};
+
function socketToDnsFamily(family) {
switch (family) {
case 'IPv4':
@@ -1993,6 +2056,10 @@ function Server(options, connectionListener) {
EventEmitter.call(this);
+ // A listening TCP Server can be moved to another thread by listing it in the
+ // transferList of a worker_threads postMessage() call. See [kTransfer]().
+ markTransferMode(this, false, true);
+
if (typeof options === 'function') {
connectionListener = options;
options = kEmptyObject;
@@ -2199,6 +2266,61 @@ function setupListenHandle(address, port, addressType, backlog, fd, flags) {
Server.prototype._listen2 = setupListenHandle; // legacy alias
+// A listening TCP Server can be transferred to another thread, which moves the
+// underlying listening socket (and its pending accept queue) to that thread's
+// event loop. Only a server bound to a live TCP handle can be transferred.
+function assertTransferableServer(server) {
+ if (isWindows) {
+ throw new ERR_WORKER_HANDLE_TRANSFER_UNSUPPORTED('net.Server');
+ }
+ if (server._handle == null || !(server._handle instanceof TCP)) {
+ throw new ERR_WORKER_HANDLE_NOT_TRANSFERABLE('net.Server');
+ }
+}
+
+Server.prototype[kTransferList] = function() {
+ assertTransferableServer(this);
+ return [this._handle];
+};
+
+Server.prototype[kTransfer] = function() {
+ assertTransferableServer(this);
+ const handle = this._handle;
+ const data = {
+ handle,
+ // Construction-time options that govern accepted sockets, so the receiving
+ // server reproduces the same behaviour.
+ allowHalfOpen: this.allowHalfOpen,
+ pauseOnConnect: this.pauseOnConnect,
+ noDelay: this.noDelay,
+ keepAlive: this.keepAlive,
+ keepAliveInitialDelay: this.keepAliveInitialDelay * 1000,
+ highWaterMark: this.highWaterMark,
+ };
+ // Detach so the source server no longer references the handle being moved.
+ this._handle = null;
+ return {
+ data,
+ deserializeInfo: 'net:Server',
+ };
+};
+
+Server.prototype[kDeserialize] = function(data) {
+ const { handle, ...options } = data ?? {};
+ if (handle == null || !(handle instanceof TCP)) {
+ throw new ERR_WORKER_HANDLE_NOT_TRANSFERABLE('net.Server');
+ }
+ this.allowHalfOpen = Boolean(options.allowHalfOpen);
+ this.pauseOnConnect = Boolean(options.pauseOnConnect);
+ this.noDelay = Boolean(options.noDelay);
+ this.keepAlive = Boolean(options.keepAlive);
+ this.keepAliveInitialDelay = ~~(options.keepAliveInitialDelay / 1000);
+ this.highWaterMark = options.highWaterMark ?? getDefaultHighWaterMark();
+ // Adopt the transferred listening handle (mirrors the child_process server
+ // hand-off path), which re-arms accept() on this thread's event loop.
+ this.listen(handle);
+};
+
function emitErrorNT(self, err) {
self.emit('error', err);
}
diff --git a/src/tcp_wrap.cc b/src/tcp_wrap.cc
index 1abf0d6d83e349..dc3cd6553d4f9b 100644
--- a/src/tcp_wrap.cc
+++ b/src/tcp_wrap.cc
@@ -43,6 +43,7 @@
#include
#include
#include
+#include // dup(), close()
#endif
namespace node {
@@ -379,6 +380,87 @@ void TCPWrap::Open(const FunctionCallbackInfo& args) {
args.GetReturnValue().Set(err);
}
+BaseObject::TransferMode TCPWrap::GetTransferMode() const {
+#ifdef _WIN32
+ // Re-adopting a socket into another thread's event loop requires
+ // re-associating it with that loop's IOCP, which needs same-process
+ // WSADuplicateSocket support that is not wired up yet. The JS net layer
+ // throws a clearer error before reaching here; this is the backstop for the
+ // low-level `socket._handle` transfer path.
+ return TransferMode::kDisallowCloneAndTransfer;
+#else
+ // Only a live handle that is not already being torn down can be transferred.
+ // Higher-level guards (no buffered reads, no pending writes) are enforced by
+ // the JS net.Socket/net.Server layer before a handle reaches here.
+ if (!HandleWrap::IsAlive(this) || IsHandleClosing())
+ return TransferMode::kDisallowCloneAndTransfer;
+ return TransferMode::kTransferable;
+#endif
+}
+
+std::unique_ptr TCPWrap::TransferForMessaging() {
+#ifdef _WIN32
+ return {};
+#else
+ CHECK_NE(GetTransferMode(), TransferMode::kDisallowCloneAndTransfer);
+
+ uv_os_fd_t fd;
+ if (uv_fileno(reinterpret_cast(&handle_), &fd) != 0) return {};
+
+ // dup() the descriptor so the receiving event loop owns an independent
+ // reference to the same socket. We then close the source handle, which
+ // renders it unusable on this side (true transfer semantics) while the dup
+ // keeps the underlying socket alive for the destination thread.
+ int dup_fd = dup(fd);
+ if (dup_fd < 0) return {};
+
+ SocketType type =
+ provider_type() == ProviderType::PROVIDER_TCPSERVERWRAP ? SERVER : SOCKET;
+
+ // Stop watching the fd and tear down the source handle.
+ Close();
+
+ return std::make_unique(dup_fd, type);
+#endif
+}
+
+TCPWrap::TransferData::~TransferData() {
+ // Only reached if the message was never delivered (e.g. the destination port
+ // closed in flight); close the dup'd fd so it is not leaked.
+ if (fd_ >= 0) {
+ uv_fs_t req;
+ CHECK_EQ(0, uv_fs_close(nullptr, &req, fd_, nullptr));
+ uv_fs_req_cleanup(&req);
+ }
+}
+
+BaseObjectPtr TCPWrap::TransferData::Deserialize(
+ Environment* env,
+ Local context,
+ std::unique_ptr self) {
+ // Construct a fresh TCPWrap in the receiving Environment. We cannot use
+ // TCPWrap::Instantiate() here because it requires a parent AsyncWrap to
+ // establish the async_hooks trigger id, and a deserialized handle has none.
+ if (env->tcp_constructor_template().IsEmpty()) return {};
+ Local constructor;
+ if (!env->tcp_constructor_template()->GetFunction(context).ToLocal(
+ &constructor)) {
+ return {};
+ }
+ Local type_arg = Int32::New(env->isolate(), type_);
+ Local