## 概述 PR #50(commit `ce5a217`)引入的上游闪断重试里,**下游断连监听器的注册点被放在「拿到上游响应头之后」**: ```js // proxy.mjs:1433(ce5a217) // 下游断连检测:打断 CC 上游 + 记录日志(只在首次尝试注册,重试不重复挂载监听器) if (attempt === 1) res.on('close', () => { ... }); ``` 如果**第 1 次尝试在拿到上游响应头之前就闪断**(正是本特性要处理的场景),这行永远执行不到 —— 之后所有尝试都不再有断连监听。 ## 影响 1. 客户端已经断连,代理仍会继续重试上游(白烧一次额度 / 放大上游 QPS); 2. 更麻烦的是记账与日志被污染:响应其实只写给了空气,日志却打出 `[info] Upstream retry recovered {"path":"/v1/chat/completions","attempt":2,...}`, 线上复盘会据此得出「重试救回了请求」的错误结论(`upstreamRetryStats.recovered` 同理)。 ## 最小复现(Windows / Node 26.x,ce5a217 实测) mock 上游:第 1 次 `/alpha/generate` 在 `writeHead` **之前** `res.socket.destroy()`(真实制造 `terminated`),第 2 次正常吐字;客户端在第 2 次尝试进行中 abort。 ``` PROBE generateCalls = 3 PROBE has "Client disconnected" = false PROBE retry logs = 2 AssertionError: 断连后不应再打上游;若为 3 说明第 2 次尝试没有断连监听 (3 !== 2) ``` 对照:所有既有用例的第 1 次上游调用都会先 `writeHead(200) + flushHeaders()`(`test/upstream-retry.test.mjs:45-46`),所以 `res.on('close')` 总能注册上 —— 现有 13 个用例覆盖不到这个分支。 ## 建议修法 把注册提到 `attemptLoop` 之前无条件注册一次,或用标志位保证「无论第几次尝试、无论失败在哪个阶段,都恰好注册一次」: ```js let closeListenerBound = false; const bindDownstreamClose = () => { if (closeListenerBound) return; closeListenerBound = true; res.on('close', () => { /* 原回调体不动 */ }); }; bindDownstreamClose(); // 进入重试循环之前 ``` (我用这个方式在本机打了本地补丁,上述探针由 FAIL(3!==2) 转为 PASS,`npm test` 41/41 仍全绿。)
Activity
xelr233 commentedon Oct 2, 2026
已确认并在 #58 提交修复 —— 你的根因定位、探针设计和「对照用例盖不到该分支」的说明全部准确,修复就是按你建议的方案落的。
复核补充两点:
成因确认:
res.on('close')挂在attempt === 1且forwardToCC(响应头)成功之后;第 1 次尝试在响应头前闪断走的是外层 catch →rewindAttempt→continue attemptLoop,该分支永远执行不到,而第 2 次起attempt === 1又为假 —— 于是断连监听全程缺席。后果与你的实测一致:已断开的客户端还会吃到后续尝试,对着空气交付成功时还会触发attempt > 1 && delivered→upstreamRetryStats.recovered+++Upstream retry recovered日志,误导线上复盘。修复口径(fix: 上游闪断重试的断连监听器挂错了位置 —— 首次尝试响应头前闪断后,客户端断连全程失明(#55,#50 回归) #58):注册提到
attemptLoop之前无条件执行、恰好一次,回调体一字未动。有一个比标志位更省的细节:回调里读的abortController是每次尝试在循环顶重绑的let,闭包取到的永远是当前尝试的控制器,所以不需要closeListenerBound标志位 —— 单一注册点本身就保证幂等。你提到的另一处也顺带成立:ensureInitialized/forwardToCC进行期间的客户端断连(feat: 上游闪断透明重试 —— 未吐字前的连接层闪断由代理内部消化 #50 之前就存在的盲区)现在同样能及时 abort 上游。验证(与你的探针同构):
destroy-before-headers/destroy-before-headers-delayed动作(writeHead之前 RST,fetch直接terminated)—— 既有 13 个用例全都先flushHeaders,恰好绕开这个缺口,这也是它一直没被发现的原因;generateCalls停在 2、有Client disconnected、无retry recovered、重试日志恰好 1 条。修复前跑它如预期红(打满 3 次、误记 recovered),修复后绿;recovered(确认没有误伤正常重试语义);upstream-retry15/15、相邻套件 32/32 全绿;node 22 / node 24 / bun 全量矩阵在 fix: 上游闪断重试的断连监听器挂错了位置 —— 首次尝试响应头前闪断后,客户端断连全程失明(#55,#50 回归) #58 的 CI 已通过。你本机的补丁和 #58 行为一致,可以直接沿用,也可以等合并后同步。
fix: 上游闪断重试的断连监听器改挂重试循环前(#55,#50 回归)
Merge upstream/master into master(同步上游 MAXeaglet#29/MAXeaglet#31/MAXe…
chore: 同步上游 35 个提交(协议层修复)+ 收窄面板测试哈希范围