Files
dsh_ai1net_server/dsh-server-docs/04-调整方案/146-实例子域503-中继per-port额度被连接泄漏占满.md
T
admin e6207aa691
build / build-and-scan (push) Canceled after 0s
chore(仓库对齐): 文档库结构治理 + IM/插件线落地
文档库:目录改为编号制(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/;交接单不入库(政策)。
2026-09-24 07:25:16 +08:00

16 KiB
Raw Blame History

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 被成功建立)

判据(可复用的三条)

  1. refused: busY + capacity.used/max 远未满(实测 2/7515)⇒ 不是中继容量问题,是per-port 计数被打满。
  2. 跨机用户的 503 而 本机用户正常 ⇒ 直接指向 via=relay 那条链路上的 per-port 门限。
  3. 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):

  1. npm run build(Node 22,tsc -p tsconfig.json,RC=0)
  2. node scripts/verify-inject.cjs lib/supervisor/proxy.js ⇒ 全部合格 ✅(注入契约未被破坏)
  3. 传前 diff:与线上产物统一行尾后比对 ⇒ 46 行差异,全部是本次改动(无夹带)
  4. 备份 → 传输 → md5 双向核对一致(17816ed815cca1a8a473c23648589a1e)
  5. 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 build RC=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'

八、遗留(本次未做,需另行处理)

  1. 🔴 106 的 relay 未同步本次兜底,且其产物基线本身落后:106 = 94,292 B / 09-19 00:26 vs 47 改前 = 100,970 B / 09-20 17:43。⇒ ⛔ 不能把 47 的新产物直接覆盖到 106 —— 那会夹带 09-20 那批改动(其中含序47 在途的内容,未经验证)。必须先按覆盖网络线纪律把 106 对齐到同一源码基线,再连同本次兜底一起部署。 ⚠️ 本次故障链路为 manager → 47 relay → w-106,必经 47 ⇒ 47 修好即已覆盖当前路径; 106 的兜底只在 worker 漂移到 106 时才成为必经之路。
  2. 🔴 两台 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 兜底(它仍在线上) ⇒ 必须重新落地 §三 的正确版本(已写入接续包,列为最高优先)。

三条教训(已并入纪律):

  1. 验收必须打在"被改的那条代码路径"上:平台在早期返回的 401 不能证明 proxyHttp 健康。 ✅ 强制口径 = 带有效 sid 走一次真实子域请求,核对 200 + 响应字节数 > 0;或部署后看 access log 出现 200。
  2. 改连接生命周期这类代码,curl 只看"状态码对不对"远远不够 ⇒ 必须连同响应体字节数与耗时一起看。
  3. 高水位会话不做这种改动 —— 本轮回归正是赶工的产物(304k 水位、收口状态下追加修改)。