nodejs / nodejs/node

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

未关闭
#64,869 2 条评论 0 个 reaction 已指派 0 人 在 GitHub 查看

还没有人认领这个 Issue。

主要语言
JavaScript
星标
122k
派生
37.3k
平均合并
4 天 2 小时
30 天内合并 PR
283

描述

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.

贡献指南

打开贡献指南

从这里开始

  1. 先读完整个 Issue,再读项目的贡献指南。
  2. 在 Issue 下留言说明你要接手 —— 这能避免两个人做同样的事。
  3. Fork 仓库,在一个分支上完成修改。
  4. 提交 Pull Request,并在描述里引用这个 Issue 编号。

调研方向

先复现 Windows 情况,然后检查 lib/internal/cluster/primary.js 和 lib/internal/cluster/child.js,尤其是 queryServer()、shared() 以及 handle-key 的构造。跟踪重复 listen 如何返回给 worker,并比较固定端口和端口 0 的路径。完成的标准是:重复 listen 产生可捕获的 EADDRINUSE,而不是 ERR_INTERNAL_ASSERTION,并且为 Windows 特有行为提供回归测试覆盖。

由索引模型根据 Issue 内容生成。

评估

技术栈
javascript, node.js
领域
backend, networking, operating-systems
Issue 类型
缺陷
难度
4/5
预计耗时
3-5 天
活跃度
冷清
描述清晰度
基本清楚
新手友好度
45/100

把新 issue 发到你的邮箱

精选适合新手参与的 GitHub issue 摘要。