You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
node:internal/assert:11
throw new ERR_INTERNAL_ASSERTION(message);
^
Error [ERR_INTERNAL_ASSERTION]: This is caused by either a bug in Node.js or incorrect usage of Node.js internals.
Please open an issue with this stack trace at https://github.com/nodejs/node/issues
at assert (node:internal/assert:11:11)
at shared (node:internal/cluster/child:156:3)
at Worker.<anonymous> (node:internal/cluster/child:111:7)
at process.onInternalMessage (node:internal/cluster/utils:49:5)
at process.emit (node:events:521:24)
at emit (node:internal/child_process:973:14)
at process.processTicksAndRejections (node:internal/process/task_queues:91:21)
code: 'ERR_INTERNAL_ASSERTION'
worker exit 1
How often does it reproduce? Is there a required condition?
100% deterministic. Required conditions:
Windows (cluster.schedulingPolicy === SCHED_NONE, so the SharedHandle path is taken).
A concrete, non-zero port. With port: 0 the same code runs fine — both listens succeed on different ports — because the primary still appends the index to the key in that case.
The two listen() calls come from the same worker.
Both variants side by side, same machine, same binary:
What is the expected behavior? Why is that the expected behavior?
The second listen() should emit EADDRINUSE on the server, which is the documented behavior of net.Server#listen for an address already in use, and is what a non-cluster process does. An ERR_INTERNAL_ASSERTION should not be reachable from public API usage at all — the error text itself says as much, and there is no way for user code to catch it: it is thrown out of the cluster IPC message handler, not out of listen(), so server.on('error') never sees it and the worker dies.
This also worked before #60141. In v22.11.0 queryServer() composed the key as:
so the second listen() got a distinct key, the primary genuinely attempted a second bind, and the resulting EADDRINUSE was reported back to the worker as an ordinary listen error. (Established by source inspection of the v22.11.0 tag — I did not run a v22.11.0 binary.)
What do you see instead?
An uncaught ERR_INTERNAL_ASSERTION that terminates the worker.
Additional information
The primary and the child disagree about what the handle key identifies.
lib/internal/cluster/child.js still keys its handles map per (address, port, addressType, fd, index). It allocates a fresh index on every _getServer() call:
Since #60141 ("cluster: fix port reuse between cluster", merged 2026-01-14, 903f647), lib/internal/cluster/primary.js drops the index from that key for non-zero ports:
So two listens from one worker on the same host:port now come back under the same key, and the child's uniqueness invariant — which the child still enforces — is no longer maintained by the primary. The child side was not adjusted.
Why the primary doesn't just return EADDRINUSE.
In queryServer() the same-worker case deliberately falls through to constructing a second handle:
constcachedHandle=handles.get(key);lethandle;if(cachedHandle&&!cachedHandle.has(worker)){handle=cachedHandle;// not taken: this worker is already registered}if(handle===undefined){handle=newSharedHandle(key,address,message);// binds again
...
}
SharedHandle only binds (net._createServerHandle); listen() happens later, in the child, on the shared handle. On Windows two binds of the same address:port both succeed — libuv sets SO_REUSEADDR — and the conflict only materialises at listen():
The primary therefore sees errno === 0, sends the handle, and the child asserts before anything ever calls listen(). On POSIX the second bind() fails outright, so the primary returns an errno, the child takes the rr() path (if (message.errno) return cb(message.errno, null)) and a clean EADDRINUSE surfaces — which is presumably why this has not been noticed. I have not verified the POSIX behavior on a Linux machine; that part is from reading the code.
Possible directions for a fix (deferring to the cluster maintainers):
In queryServer(), when cachedHandle exists and already has this worker, reply with UV_EADDRINUSE instead of binding a second handle under a key the child has already registered.
Either way, shared()'s assertion is currently reachable from public API on Windows and should probably become a real, catchable error.
Real-world impact. We hit this in a server that binds each configured address and then unconditionally also binds 127.0.0.1 and ::1 so loopback always works. When the configuration also lists 127.0.0.1, the loopback bind is a duplicate. On v22.11.0 that duplicate was absorbed as a catchable EADDRINUSE; from v26.3.0 the worker dies during startup with the assertion above, taking the whole server with it. The duplicate listen is arguably our bug, but the failure mode went from "catchable error" to "uncatchable internal assertion".
Affected release lines. Reproduced on v26.3.0 and v26.5.0. #60141 shipped in v25.5.0 (Current) and was backported to v22.22.1 (LTS), so the v22.x line changed behavior mid-line. Not verified whether v24.x carries the backport.
Version
v26.5.0 (also reproduced on v26.3.0). By source inspection the same key computation is still on main.
Platform
Subsystem
cluster
What steps will reproduce the bug?
Output:
How often does it reproduce? Is there a required condition?
100% deterministic. Required conditions:
Windows (cluster.schedulingPolicy === SCHED_NONE, so the SharedHandle path is taken).
A concrete, non-zero port. With port: 0 the same code runs fine — both listens succeed on different ports — because the primary still appends the index to the key in that case.
The two listen() calls come from the same worker.
Both variants side by side, same machine, same binary:
What is the expected behavior? Why is that the expected behavior?
The second listen() should emit EADDRINUSE on the server, which is the documented behavior of net.Server#listen for an address already in use, and is what a non-cluster process does. An ERR_INTERNAL_ASSERTION should not be reachable from public API usage at all — the error text itself says as much, and there is no way for user code to catch it: it is thrown out of the cluster IPC message handler, not out of listen(), so server.on('error') never sees it and the worker dies.
This also worked before #60141. In v22.11.0 queryServer() composed the key as:
so the second listen() got a distinct key, the primary genuinely attempted a second bind, and the resulting EADDRINUSE was reported back to the worker as an ordinary listen error. (Established by source inspection of the v22.11.0 tag — I did not run a v22.11.0 binary.)
What do you see instead?
An uncaught ERR_INTERNAL_ASSERTION that terminates the worker.
Additional information
The primary and the child disagree about what the handle key identifies.
lib/internal/cluster/child.js still keys its handles map per (address, port, addressType, fd, index). It allocates a fresh index on every _getServer() call:
and shared() asserts uniqueness on whatever key the primary sends back:
Since #60141 ("cluster: fix port reuse between cluster", merged 2026-01-14, 903f647), lib/internal/cluster/primary.js drops the index from that key for non-zero ports:
So two listens from one worker on the same host:port now come back under the same key, and the child's uniqueness invariant — which the child still enforces — is no longer maintained by the primary. The child side was not adjusted.
Why the primary doesn't just return EADDRINUSE.
In queryServer() the same-worker case deliberately falls through to constructing a second handle:
SharedHandle only binds (net._createServerHandle); listen() happens later, in the child, on the shared handle. On Windows two binds of the same address:port both succeed — libuv sets SO_REUSEADDR — and the conflict only materialises at listen():
The primary therefore sees errno === 0, sends the handle, and the child asserts before anything ever calls listen(). On POSIX the second bind() fails outright, so the primary returns an errno, the child takes the rr() path (if (message.errno) return cb(message.errno, null)) and a clean EADDRINUSE surfaces — which is presumably why this has not been noticed. I have not verified the POSIX behavior on a Linux machine; that part is from reading the code.
Possible directions for a fix (deferring to the cluster maintainers):
Real-world impact. We hit this in a server that binds each configured address and then unconditionally also binds 127.0.0.1 and ::1 so loopback always works. When the configuration also lists 127.0.0.1, the loopback bind is a duplicate. On v22.11.0 that duplicate was absorbed as a catchable EADDRINUSE; from v26.3.0 the worker dies during startup with the assertion above, taking the whole server with it. The duplicate listen is arguably our bug, but the failure mode went from "catchable error" to "uncatchable internal assertion".
Affected release lines. Reproduced on v26.3.0 and v26.5.0. #60141 shipped in v25.5.0 (Current) and was backported to v22.22.1 (LTS), so the v22.x line changed behavior mid-line. Not verified whether v24.x carries the backport.