cluster: ERR_INTERNAL_ASSERTION when a worker listens twice on the same host:port (Windows, regression from #60141)
Nessuno ha ancora preso questa issue.
- Lingua principale
- JavaScript
- Stelle
- 122k
- Fork
- 37.3k
- Merge medio
- 4g 2h
- PR unite (30g)
- 283
Descrizione
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, 903f6479), 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 #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.
Guida per i contributori
Apri la guida per i contributori
Come iniziare
- Leggi tutta la issue e poi la guida ai contributi del progetto.
- Commenta sulla issue per dire che te ne occupi tu — evita che due persone facciano lo stesso lavoro.
- Fai un fork del repository e lavora su un branch.
- Apri una pull request che faccia riferimento al numero della issue.
Direzione di ricerca
Riproduci prima il caso Windows, quindi ispeziona lib/internal/cluster/primary.js e lib/internal/cluster/child.js, in particolare queryServer(), shared() e la costruzione della chiave dell’handle. Traccia il modo in cui i listen duplicati vengono restituiti al worker e confronta i percorsi della porta fissa e della porta 0. Il lavoro è completato quando il listen duplicato produce un EADDRINUSE intercettabile anziché ERR_INTERNAL_ASSERTION, con una copertura di regressione per il comportamento specifico di Windows.
Scritto dal modello di indicizzazione a partire dal testo della issue.
Valutazione
- Stack tecnologico
- javascript, node.js
- Ambito
- backend, networking, operating-systems
- Tipo di issue
- Bug
- Difficoltà
- 4/5
- Tempo stimato
- 3-5 giorni
- Stato di attività
- Tranquilla
- Chiarezza
- Abbastanza chiara
- Idoneità per principianti
- 45/100