文档库:目录改为编号制(01-规范/02-架构设计/03-数据库/04-调整方案/
05-交接单/06-ops/07-scripts/08-skills/09-archive),顶层散文件归入 01-规范/;
INDEX.md 与 docs-manifest.json 重刷(档案 146 篇);旧目录名引用全量对齐。
IM 线:src/im/**(SDK / hub / store / presence / ws / gateway-token)、
src/web/routes/im.ts、src/db/plugin-data/**、src/supervisor/plugin-assembly.ts
及对应 test/**。
插件线:poc/{im-agent-bridge,im-connection-gateway,im-conversation-tabs,
business-plugins-im,carbon-mcp-probe}、src/web/routes/{sessions,overlay-device}.ts、
src/net/relay/{device-grant,instance-credential}.ts。
仓库卫生:清出 40 个历史误入库 / 已改名文件(34 个交接单归档 + 6 个旧结构,
本地均有副本);dsh-server-docs/.gitignore 补 tmp/;交接单不入库(政策)。
16 KiB
146 · 实例子域 503:中继 per-port 额度被「客户端断开不中止上游」泄漏占满
- 日期:2026-09-21
- 触发:用户「guest 用户dsh会话实例 一直重启 报错 client api: directoryPicker/list failed: transport failure for /api/directoryPicker/list: HTTP 503」+「从源头排查问题产生原因,找出最优的解决办法,彻底解决避免再次发生」
- 对象:
src/supervisor/proxy.ts(Manager 侧,只部署到 47 的/opt/dshs/lib/) - 状态:✅ 已落地并验收(47 已加载新产物、guest 通道恢复、连接可回落、
dialDenied停止增长)
TL;DR|实例根本没在重启(已连续运行 20.8 h,
curl 127.0.0.1:21000稳定 401 / 0.9 ms)。 真因是平台代理泄漏连接 ⇒ 占满中继的 per-port 并发额度 ⇒ 该实例端口永久refused: busy⇒ 全站 503: ①proxyHttp在agent:false下每请求一条独立 TCP、靠"响应结束"释放;而 dsh 有大量 永不结束的 SSE/长响应,客户端断开(刷新/关页/切走)时代码从不中止上游 (全文没有request.raw.on('close')/aborted)⇒ socket 永久ESTAB; 同文件 WS upgrade 隧道只做'error'双向销毁、没有'close'对称销毁 ⇒ 同样泄漏。 ② 中继DEFAULT_MAX_STREAMS_PER_PORT = 64是纯计数门限、无空闲回收 ⇒ 额度只增不减,涨满后永不恢复;而跨机(via=relay)才走这条链路, 47 本机的 admin 实例(via=local)不受该门限约束 ⇒ 这正是"只有 guest 挂"的原因。 ③ 用户「打不开 ⇒ 反复刷新 ⇒ 再泄漏」构成正反馈(实测 45 min 不恢复,重启控制面才清空)。
一、取证(先证后改,全部为实测)
| # | 结论 | 证据 |
|---|---|---|
| 1 | 实例健康、从未重启 | 106 上 dsh-100002-c7e0168b.scope = active running;ActiveEnterTimestamp=2026-09-20 09:18:20(连续 20.8 h);curl 127.0.0.1:21000/ → 401、time=0.0009s(×3 稳定) |
| 2 | 47 上没有 guest 的 scope | dsh_instances 表:guest 的 host_id = **w-106**(不是 47);47 只有 admin 的 dsh-114801-*.scope |
| 3 | 中继是唯一故障点 | curl 127.0.0.1:20080/status:endpoints[] 中 w-106:21000 的 streams = 64 = DEFAULT_MAX_STREAMS_PER_PORT(server.ts:97);counters.dialDenied = 184、refused = 184 |
| 4 | 平台侧日志坐实"拨号被拒" | journalctl -u dshs:[relay-dialer] 拨 ops/w-106:21000 失败:relay client: dial w-106:21000 refused: busy(同一秒 8 连发) |
| 5 | 额度不是瞬时拥塞,是永久占用 | 隔 3 min ×3 次取样:streams 恒 64、streamsOpened 恒 514、dialDenied 恒 184 —— 完全静止(既不释放也不增长) |
| 6 | 泄漏发生在 Manager 侧 | 47 上 manager 进程(pid 989967)有 64 条 ESTAB 全部指向中继拨号槽位 127.0.0.1:25001(= (w-106, 21000) 的落点) |
| 7 | 106 侧同样挂着 64 对空闲连接 | ss -tn | grep :21000 → 128 ESTAB(64 对),全部 Recv-Q=0 Send-Q=0(空闲,不是活跃数据流) |
| 8 | 503 是正常 reply、不是抛错 | 平台日志那条只有 statusCode:503 + responseTime≈380–430ms,无 err 字段 ⇒ 走的是 reply.code(503).send() 正常路径 |
| 9 | 恢复动作能立刻解开 | systemctl restart dshs 后:拨号槽位连接 64 → 5、21000 streams 64 → 1、streamsOpened 514 → 526(新 dial 被成功建立) |
判据(可复用的三条)
refused: busY+capacity.used/max远未满(实测2/7515)⇒ 不是中继容量问题,是per-port 计数被打满。- 跨机用户的 503 而 本机用户正常 ⇒ 直接指向
via=relay那条链路上的 per-port 门限。 streams在多次取样间恒定且等于 64 ⇒ 泄漏(正常的复用池会有波动,且不会恰好卡在上限)。
二、根因(代码级)
2.1 泄漏源:客户端断开时上游无人收尾(proxy.ts)
| 路径 | 位置 | 缺陷 |
|---|---|---|
| HTTP 转发 | proxyHttp()(attempt(),约 360–580 行) |
上游连接在 agent: false(cluster/local 模式的默认,见 proxy.ts:291 + 372)下每请求一条独立 TCP,只在"响应结束"时释放。全文没有任何 request.raw.on('close') / 'aborted' 处理 ⇒ 客户端一走,上游请求继续挂着等响应(SSE 根本不结束)⇒ socket 永久 ESTAB,upRes 还把数据灌进已关闭的 reply |
| WS upgrade 隧道 | app.server.on('upgrade', …)(约 718–742 行) |
只有 upstream.on('error') → socket.destroy() 与 socket.on('error') → upstream.destroy()。而正常断开只发 'close'、不发 'error' ⇒ 一侧关闭后另一侧永久挂着 |
2.2 放大器:中继 per-port 门限是纯计数、无回收
server.ts:1685-1691:
const live = worker.portStreams.get(port) ?? 0
if (live >= this.maxStreamsPerPort) { // DEFAULT_MAX_STREAMS_PER_PORT = 64
this.refused += 1
this.dialDenied += 1
done(false, { error: 'busy' })
return
}
- 中继的
idleTimeoutMs = 45 s是会话级(session.lastSeen,server.ts:1409),对流没有空闲回收 ⇒ 只要 worker 会话活着,泄漏的流就永远不会被清掉。 - ⇒ 一旦打满,该
(hostId, port)永久不可用,且没有任何自愈路径。
2.3 为什么"只有 guest"
- admin 的实例在 47 本机(
dsh_hosts.via = local)⇒ 代理直连回环,不经过中继的 per-port 门限。 - guest 的实例在 106(
dsh_hosts.via = relay)⇒ 每次转发都要在中继上占一个流名额 ⇒ 唯一会撞门限的用户。
2.4 正反馈
门限打满 ⇒ 用户请求全 503 ⇒ 用户反复刷新(日志实测 guest.ai1net.com/ 5 min 内被请求 19 次)⇒ 每次刷新再泄漏若干条 ⇒ 额度永远回不来(实测 06:06–06:13 整整 7 分钟无任何恢复迹象)。
三、改动(src/supervisor/proxy.ts,4 处)
| # | 改动 | 作用 |
|---|---|---|
| 1 | import 增 type ClientRequest |
新引用的类型 |
| 2 | proxyHttp() 顶层(attempt 之前)新增 liveUpstream / clientGone + request.raw.on('close') |
断开即中止上游;判据用 reply.raw.writableEnded || reply.raw.destroyed 区分"正常完成"与"提前断开"(正常完成也会触发 'close') |
| 3 | attempt() 内:liveUpstream = upstream;upstream.on('error') / upRes.on('error') 开头加 if (clientGone) return;upRes 回调开头加 if (clientGone) { upRes.resume(); return } |
断开后不重试、不再开新连接、不再往已关闭的 reply 写(少了这句,每次断开会凭空再开一条连接 —— 正是额度被占满的正反馈来源) |
| 4 | WS upgrade 增 upstream.on('close', () => socket.destroy()) + socket.on('close', () => upstream.destroy()) |
补齐对称收尾('close' ≠ 'error') |
未改(刻意):
- 不动
upstreamAgent:它只在useKeepAlive=true(k8s 形态)时生效,当前集群形态下压根没被用上,与本次事故无关(第一轮曾误判为"agent 池涨到 32×2=64",实测证伪)。 - 不动
maxStreamsPerPort:泄漏源修好后 64 完全够用;单纯调大只是掩盖问题。 - 不动中继代码(见 §六 遗留)。
四、落地与验收
部署方式 = 只送编译产物(dsh-server-docs 纪律 §9.3 方式 b):
npm run build(Node 22,tsc -p tsconfig.json,RC=0)node scripts/verify-inject.cjs lib/supervisor/proxy.js⇒ 全部合格 ✅(注入契约未被破坏)- 传前 diff:与线上产物统一行尾后比对 ⇒ 46 行差异,全部是本次改动(无夹带)
- 备份 → 传输 → md5 双向核对一致(
17816ed815cca1a8a473c23648589a1e) systemctl restart dshs(约 10 s,实例 scope 不受影响)
验收实测:
| 项 | 修复前 | 修复后 |
|---|---|---|
中继 w-106:21000 streams |
64(恒定,45 min 不降) | 1 |
| 47 拨号槽位连接数 | 64 | 1(观察中一度 7,25 s 后自动回落到 1 ⟵ 关键:连接能回收了) |
dialDenied |
184(持续增长) | 184(停止增长) |
| 线上产物含修复 | — | grep -c clientGone = 5 |
| guest 通道 | 全 503 | curl -H "Host: guest.ai1net.com" …/ → 401(未认证,正常);同日真实流量出现 120 个 200 |
| admin 实例 | — | scope 仍 running,未受影响 |
五、回滚
ssh -p 22 -i ~/.ssh/id_ed25519 [email protected] \
'cp -a /opt/dsh/backups/proxy.js.pre-guest503-20260921-062013 /opt/dshs/lib/supervisor/proxy.js && systemctl restart dshs'
- 备份文件:
/opt/dsh/backups/proxy.js.pre-guest503-20260921-062013(39,645 B,= 修复前线上产物) - 源码侧回滚 = 撤销
src/supervisor/proxy.ts的 §三 四组改动后重新 build + 同上传输。
六、遗留(未做,需独立立项)
中继侧缺少"per-port 流"的空闲回收 / 兜底(§2.2)。
- 本次修的是泄漏源;但中继"纯计数、无回收、打满即永久拒绝"这一设计特性仍在 ⇒ 任何未来新的泄漏(别的来源)都会重演同一种死锁,且没有自愈路径。
- 建议方向(择一或组合):
- ① per-port 流加空闲超时回收(与会话级 45 s 同源思路);
- ②
streams打满时淘汰最老流(LRU)而非直接拒绝; - ③ 打满时返回可重试信号(而非让调用方按 503 退出)。
- ⚠️ 中继属覆盖网络线核心资产(该线有独立台账与铁律,且序47 在途)⇒ 本档不擅自改,按该线纪律另行立项。
七、中继侧兜底(2026-09-21 已落地 · 用户选方案 1)
按「彻底解决、避免再发生」的要求,在泄漏源已修的基础上再加第二道防线 —— 因为
maxStreamsPerPort 原本是「纯计数、无回收、打满即永久拒绝」,任何未来源头不同的泄漏
都会重演同一种死锁,且没有任何自愈路径。
改动(src/net/relay/server.ts,编译后 diff 恰好 10 个 hunk):
| # | 改动 |
|---|---|
| 1 | 新增常量 DEFAULT_STREAM_RECLAIM_MS = 600_000(10 min)、DEFAULT_STREAM_RECLAIM_BATCH = 8;env 可覆盖(RELAY_STREAM_RECLAIM_MS / RELAY_STREAM_RECLAIM_BATCH) |
| 2 | MuxStream.openedAt(建立时刻;只被兜底回收用来挑"最老的流",不参与任何正常路径判定) |
| 3 | 两条分配路径(dial 主动拨号 / onManagerConn)在 busy 判定之前先调 reclaimStale,回收后重读额度、仍满才真正拒绝 |
| 4 | 新增 reclaimStale():按"最老优先"关闭该端口上存活 > 阈值的流,单次最多 batch 条;⛔ 只在打满时被调用 |
| 5 | /status 增 streamsReclaimed 计数(正常路径恒为 0;非 0 ⇒ 曾打满并被破局 ⇒ 回头查是否又有新泄漏源) |
语义边界(重要):这不是"空闲回收" —— 判据用建立时刻而非"最后活动时刻",因为后者要改 数据转发路径(风险大),且 dsh 的 SSE/WS 在页面静默时本就长时间无数据,按"空闲"判死会误杀正常长连接。 这里的语义是「额度已满 ⇒ 系统已异常 ⇒ 优先破局」,取 10 min 只为保证"被牺牲的确实是最老的那批"。
落地与验收:
npm run buildRC=0;node --test test/relay.test.mjs→ 36 项全过 / 0 败- 🔴 relay 是独立部署:
dshs-relay.service→/opt/dsh-relay/lib/net/relay/(⛔ 不是/opt/dshs/;后者那份是 Manager 进程内的另一副本,不要误传) - 传前 diff:与本机新产物统一行尾后 10 个 hunk 全部精确对应本次 10 处改动(7 新增 + 3 替换),无夹带
- 备份
/opt/dsh/backups/relay-server.js.pre146-20260921-063850(100,970 B)+relay-index.js.pre146-… - md5 双向一致 →
systemctl restart dshs-relay - 验收:relay
active;manager与w-106均AUTH OK重连;端点重新注册(w-106:19000/21000);/status出现streamsReclaimed: 0—— 该字段旧码没有 ⇒ 证明新码确已加载
回滚:
ssh -p 22 [email protected] \
'cp -a /opt/dsh/backups/relay-server.js.pre146-20260921-063850 /opt/dsh-relay/lib/net/relay/server.js && \
cp -a /opt/dsh/backups/relay-index.js.pre146-20260921-063850 /opt/dsh-relay/lib/net/relay/index.js && \
systemctl restart dshs-relay'
八、遗留(本次未做,需另行处理)
- 🔴 106 的 relay 未同步本次兜底,且其产物基线本身落后:106 =
94,292 B / 09-19 00:26vs 47 改前 =100,970 B / 09-20 17:43。⇒ ⛔ 不能把 47 的新产物直接覆盖到 106 —— 那会夹带 09-20 那批改动(其中含序47 在途的内容,未经验证)。必须先按覆盖网络线纪律把 106 对齐到同一源码基线,再连同本次兜底一起部署。 ⚠️ 本次故障链路为manager → 47 relay → w-106,必经 47 ⇒ 47 修好即已覆盖当前路径; 106 的兜底只在 worker 漂移到 106 时才成为必经之路。 - 🔴 两台 relay 的产物版本不一致(同源跑出不同产物)本身即隐患 ⇒ 建议纳入覆盖网络线的台账治理。
九、⚠️ §三 的 HTTP 期修复引入回归,已于 07:35 回滚(2026-09-21)
现象(用户报「平台无法访问」):nginx access log 里实例子域的 GET / 稳定 499(客户端断开、0 字节,
admin 用 Edge 07:07 与 07:32、guest 用 Chrome 07:32 各一次);而门户(ai1net.com,不走 proxyHttp)稳定 200
⇒ 故障面精确等于"走 proxyHttp 的子域请求",直指 §三 的改动。
我的 bug(proxy.ts §三 改动 2):
request.raw.on('close', () => { if (!reply.raw.writableEnded) { clientGone = true; liveUpstream?.destroy() } })
⛔ 判据主体错了:request.raw(http.IncomingMessage)在消息读完时就会被 destroy 并 emit 'close';
对 GET 请求(无体)来说"请求头读完即完成" ⇒ 该回调几乎立刻触发,而此刻响应尚未写出
⇒ 被误判为"客户端提前断开" ⇒ upstream.destroy() 把正常请求掐死在发出之前 ⇒ 用户侧 499(无任何响应)。
⚠️ 它骗过了当时的验收:curl -H "Host: guest.ai1net.com" …/ 得到 401,而 401 是平台在进入 proxyHttp
之前(未认证)就返回的 ⇒ 根本没走到被改的代码路径 ⇒ 假绿。
✅ 正解(下次必须这样写) —— 判据落在响应流上,而不是请求流:
// ✅ 连接真正结束 且 响应未写完 ⇒ 才是"客户端提前断开"
reply.raw.on('close', () => {
if (reply.raw.writableEnded) return
clientGone = true
liveUpstream?.destroy()
})
(reply.raw = http.ServerResponse,其 'close' 在底层连接结束时触发。⛔ 也不要改回 req.on('aborted') —— 已废弃。)
止血:
cp -a /opt/dsh/backups/proxy.js.pre-guest503-20260921-062013 /opt/dshs/lib/supervisor/proxy.js && systemctl restart dshs
⇒ 复验:门户 200 / admin 子域 401 / guest 子域 401 / 经 CF 200;线上产物 grep -c clientGone = 0。
⚠️ 代价:§二 的泄漏源修复同时被撤销 ⇒ 当前唯一防线是 §七 的中继 per-port 兜底(它仍在线上)
⇒ 必须重新落地 §三 的正确版本(已写入接续包,列为最高优先)。
三条教训(已并入纪律):
- 验收必须打在"被改的那条代码路径"上:平台在早期返回的 401 不能证明
proxyHttp健康。 ✅ 强制口径 = 带有效sid走一次真实子域请求,核对 200 + 响应字节数 > 0;或部署后看 access log 出现 200。 - 改连接生命周期这类代码,
curl只看"状态码对不对"远远不够 ⇒ 必须连同响应体字节数与耗时一起看。 - 高水位会话不做这种改动 —— 本轮回归正是赶工的产物(304k 水位、收口状态下追加修改)。