Skip to content

cluster: ERR_INTERNAL_ASSERTION when a worker listens twice on the same host:port (Windows, regression from #60141) #64869

Description

@viyaha

Version

v26.5.0 (also reproduced on v26.3.0). By source inspection the same key computation is still on main.

Platform

Windows 11 Pro 10.0.26200 x64 (also expected on any Windows build; see analysis for why this is platform-specific).

Subsystem

cluster

What steps will reproduce the bug?

import cluster from 'node:cluster';
import net from 'node:net';

if (cluster.isPrimary) {
    cluster.fork().on('exit', (code) => console.log('worker exit', code));
} else {
    const listen = () =>
        new Promise((resolve, reject) => {
            const s = net.createServer();
            s.once('error', reject);
            s.once('listening', () => resolve(s));
            s.listen({host: '127.0.0.1', port: 51888});
        });

    await listen(); // ok
    await listen().catch((err) => console.log('caught:', err.code)); // expected: EADDRINUSE
    console.log('worker survived');
}

Output:

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:

[fixed port] listening 127.0.0.1 51999
[fixed port] CRASH: ERR_INTERNAL_ASSERTION      → worker exit 1
[port 0]     listening 127.0.0.1 61468
[port 0]     listening 127.0.0.1 61469
[port 0]     survived                           → worker exit 0

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:

const key = `${message.address}:${message.port}:${message.addressType}:${message.fd}:${message.index}`;

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:

const index = indexSet.nextIndex++;
indexSet.set.add(index);

and shared() asserts uniqueness on whatever key the primary sends back:

assert(handles.has(key) === false);
handles.set(key, handle);

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:

const key = `${message.address}:${message.port}:${message.addressType}:` +
            `${message.fd}` + (message.port === 0 ? `:${message.index}` : '');

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:

const cachedHandle = handles.get(key);
let handle;
if (cachedHandle && !cachedHandle.has(worker)) {
    handle = cachedHandle;   // not taken: this worker is already registered
}
if (handle === undefined) {
    handle = new SharedHandle(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():

> node -e "const net=require('net');
           const h1=net._createServerHandle('127.0.0.1',51777,4);
           const h2=net._createServerHandle('127.0.0.1',51777,4);
           console.log(typeof h1, typeof h2, h2.listen(511));"
object object -4091          // -4091 = UV_EADDRINUSE

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.
  • Or keep the index in the key and address Not all processes rebind when switching from :: to 0.0.0.0 or vice versa #60086's port-reuse problem another way (e.g. release the key on close rather than omitting the index).
  • 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions