Skip to content

fix: 上游闪断重试的断连监听器挂错了位置 —— 首次尝试响应头前闪断后,客户端断连全程失明(#55,#50 回归) - #58

Merged
MAXeaglet merged 24 commits into
MAXeaglet:masterfrom
xelr233:fix/issue-55-retry-disconnect-listener
Oct 9, 2026
Merged

MAXeaglet merged 24 commits into
MAXeaglet:masterfrom
xelr233:fix/issue-55-retry-disconnect-listener

Conversation

@xelr233

@xelr233 xelr233 commented Oct 2, 2026

Copy link
Copy Markdown
Contributor

Fixes #55

根因(与 issue 作者的排查一致,已在我们 fork 上复核)

#50(ce5a217)把下游断连监听器挂在「attempt === 1 且已拿到上游响应头」的分支里:

if (attempt === 1) res.on('close', () => { ... });   // forwardToCC 之后

而第 1 次尝试恰恰可能在响应头之前就闪断(forwardToCC 抛 terminated → 外层 catch → rewindAttempt → 重试)—— 这正是重试特性要处理的场景,此时该分支永远执行不到;第 2 次起 attempt === 1 又为假。于是之后所有尝试都没有断连监听:

  • 客户端已断开,代理仍对着空气继续打上游(白烧额度 / 放大 QPS);
  • 第 2 次成功交付给已死的连接时,还会触发 attempt > 1 && delivered → upstreamRetryStats.recovered++ + 日志 Upstream retry recovered,线上复盘会得出「重试救回了请求」的错误结论。

既有 13 个用例盖不到:mock 全部先 writeHead(200) + flushHeaders(),监听器总能注册上。

修复

  • 把 res.on('close', ...) 的注册移到 attemptLoop 之前、无条件执行、恰好一次;回调体一字未动。闭包里读的 abortController 是每次尝试在循环顶重绑的 let,回调取到的永远是当前尝试的控制器,所以不需要重复挂载;
  • 副作用只有正面的:ensureInitialized / forwardToCC 进行期间(feat: 上游闪断透明重试 —— 未吐字前的连接层闪断由代理内部消化 #50 之前也存在的盲区)客户端断连现在同样能及时 abort 上游。

验证

  • test/upstream-retry.test.mjs 的 mock 新增两个动作 destroy-before-headers / destroy-before-headers-delayed(在 writeHead 之前 RST,fetch 直接 terminated),补两个用例:
    1. [Bug] 上游闪断重试:首次尝试在「拿到上游响应头之前」失败时,下游断连监听器永不注册(客户端已断连仍会再打一次上游,并误记 retry recovered) #55 探针:第 1 次响应头前闪断 → 第 2 次尝试进行中客户端断开(第 2 次也是响应头前 RST)→ 断言 generateCalls 停在 2、日志有 Client disconnected、无 Upstream retry recovered、重试日志恰好 1 条。修复前跑它如预期红(打满 3 次上游、误记 recovered),修复后绿 —— 与 issue 里的探针完全同构;
    2. 对照:响应头前闪断 + 客户端一直在线 → 照常重试成功并如实记 recovered(守住「不误伤正常重试语义」)。
  • 本地相邻套件全绿:upstream-retry 15/15、connection-lifecycle + stream-end + regressions 共 32/32。
  • 全量矩阵(node 22 / node 24 / bun)随本 PR 的 CI 跑。

顺带说明:upstreamRetryStats 的 rewinds/recovered 与 README 中「重试相关行为」的描述不受影响 —— 修复只是让断连事实能被看见。

xelr233 and others added 24 commits September 13, 2026 03:05
仅两处改动,对齐官方 cmd CLI 1.53.0 的线上行为(源码 + 抓包双重确认):

1. /alpha/generate 请求补上 User-Agent: cli
   CLI buildCommandAuthHeaders 里该头取自常量 vy = "cli"。
   MAXeaglet 此前完全不发 User-Agent。

2. 信封顶层补上真实 threadId,取值与 x-session-id 恒等
   CLI createModelClient 实际发送 body.threadId === x-session-id。
   MAXeaglet 原有的 newThreadId() 是死代码(495 行算完从未进入请求体)。
   - 客户端提供 session 头   → threadId 取该值
   - 客户端未提供 session 头 → 仍走原有 per-key 12h 会话,threadId 取同一值(该路径行为不变)
   - sessionId 非合法 UUID   → 省略该字段,对应 CLI toWireThreadId 语义
                               (uuid.safeParse 失败即返回 undefined,键被 JSON.stringify 丢弃)

threadId 在信封中占位于 permissionMode 与 params 之间,保持与 CLI 一致的键序。

已实测(本地 mock 上游):
- 两个端点(/v1/chat/completions、/v1/messages)UA 均为 cli,threadId 均等于 x-session-id
- 客户端未发 session 头时,threadId 与生成的 x-session-id 相等
- 客户端发非 UUID 时,threadId 键整体消失
说明:本分支原在此提交一并实现了 CC_MAX_INFLIGHT,rebase 到上游 c443889
后该项已由上游实现且更完整 —— 上游版在 server 入口统一准入、/health 与 /
豁免、finish+close 双事件幂等释放。本提交已把本地那套全部撤下,只保留上游
实现;本分支也不再提供 config.json 的 maxInflight(与 CC_MAX_BODY_MB /
CC_STREAM_IDLE_MS 等调参项一致,只用环境变量)。

Finding 1(响应背压)已由上游 88a1872 修复;Finding 2(请求树副本)由本提交
补齐;全局在途上限由上游 c443889 补齐。

- buildCcRequest 之后释放原始请求树
  - /v1/chat/completions:取出 prompt_cache_key 后置空 openaiReq
  - /v1/messages:置空 openaiReq 与 anthropicReq
  原先这两棵树会和 ccBody 一起活到整段请求结束。

新增 upstreamProxy / CC_UPSTREAM_PROXY,让发往 CC 上游的请求走本地 HTTP
代理(出口地区调整 / 风控 403 的 IP 维度对照)。

覆盖 /alpha/generate、/alpha/fingerprint/record、/alpha/lifecycle-events、
/provider/v1/models;不影响本地监听、/health 与 npm 版本检查。

实现为零依赖:自建 CONNECT 隧道(代理只做裸字节转发),再用 node:https
复用同一 socket,TLS 端到端、证书按目标主机名校验。因此不需要 undici /
https-proxy-agent,engines >=18 即可用(Node 原生 fetch 不读 HTTPS_PROXY;
官方环境变量路线需 Node >= 22.21/24.5 + NODE_USE_ENV_PROXY=1)。

预请求刻意也走代理:若它们直连而上游生成走代理,同一账号会从两个不同 IP
注册,正是该 issue 想消除的矛盾。

- 默认路径:UA=cli、threadId===x-session-id、恰好 1 条 generate
- 配代理:CONNECT 隧道建立、generate 与两个预请求均经隧道、/health 不经
  代理、UA 仍为 cli
- 两个端点(OpenAI/Anthropic)无回归,Anthropic 端点经代理亦正常
上一版把默认值降到 8MB 是错的。核过 MAXeaglet#7 与其修复提交 573e260 后确认:MAXeaglet#7 的
真实触发场景是多模态长会话(21 张 base64 图片累积约 10.11 MiB 的合法请求),
任何 4~8MB 的默认值都会把这类请求整体挡在门外。

需要分清的是:MAXeaglet#7 的「客户端只看到 Connection error」症状由「超限返回 413 +
排空连接」这条路径解决,与阈值取值无关;但阈值决定的是功能边界,而真实的
多模态上下文确实会到 10MB 量级,所以默认值必须留足。

结论:既然 body 阈值必须够大,要约束的就是并发侧 —— 这个角色由上游 c443889
的 CC_MAX_INFLIGHT 承担(本分支不再自带实现,只保留上游那套)。本提交因此
只做两件事:还原默认值,并在两个 README 的「在途上限」章节补一段「为什么
body 默认值不能降」的依据,顺带修掉「内存与部署」里那句已过时的「proxy 自身
没有在途限流」。

复测:
- 默认(100MB) 放行 9MB 请求 -> 200(MAXeaglet#7 的多模态场景不再被挡)
- CC_MAX_BODY_MB=8 时同一请求 -> 413,拒绝后小请求仍正常
- 其余验证项(UA/threadId、上游代理)全部通过
修三个问题:指纹漂移(进程重启 / 多实例 / 每 12h 被动重置)、
以及 thumbmark 与 hashSignal 的构造与官方 CLI 不一致。

指纹由 HMAC-SHA256(CC_FP_SALT, apiKey) 确定性派生,一个 key 恒定一台设备:

  进程重启        Map 清空 → 换一台机器   →  同一台机器
  第二个实例      同一 key = 两台机器     →  同一台机器
  session 过期 12h keyStateStore.delete → 每 12h 换一台机器  →  同一台机器

第三条是原实现最明显的破绽:真实用户不会一天换两次电脑,而上游
device_fingerprints 表按 (userId, thumbmark) 建唯一索引。

刻意**不是**"按 key 取哈希桶选设备":固定池的熵上限就是池大小,key 数
一旦超过池容量就必然出现多 key 共用指纹(模拟:50 key / 1000 池 → 约 2 个
碰撞;50 key / 100 池 → 约 20 个),而共用 thumbmark 正是"多账号同机"的
直接证据。派生方案每个 key 仍是独立设备,碰撞概率 2^-256。

CC_FP_MODE=random 保留原「每进程随机」行为作为回退。

原 keyStateStore.delete 写在 session 清理循环里,本意是内存回收,
实际效果是每 12h 重置指纹。现在指纹状态有自己的「按空闲淘汰」(24h),
且因为指纹是派生的,淘汰后重新派生得到的仍是同一台设备,不构成漂移;
活跃 key 的 nextInitAt 也不再被重置,避免重复发送预请求。

  源码(buildMachineFingerprint / hashSignal):
    ib = "command-code:device-fingerprint:v1"
    hashSignal(v) = sha256(ib + "\0" + v.trim().toLowerCase())     // v 是原始值
    thumbmark     = sha256(ib + "\0machine\0" + [machineId, macs.join(",")].join("|"))
                    machineId 非空时 hostname / cpuModel 不参与

  原实现:直接对随机 hex 求 sha256(缺 ib 前缀),且 thumbmark 由各
  component 的**哈希**拼成、另加 platform/osRelease/cpuModel 等字段。

上游拿不到原始 machineId、无法重算,所以这处不一致本来就检测不到;
既然要动就一次对齐。components 的字段集原本就是对的(runtime: "cli"、
collectorVersion: 1 都对),本次只改哈希构造。

指纹套件(本地 mock 上游,起停真实进程):
- components 字段集与 CLI 完全一致(15 个),runtime/collectorVersion 正确
- 重启后同一 key -> 同一 thumbmark;CC_FP_MODE=random 下则不同
- 40 个 key -> 40 个不同 thumbmark,零碰撞;CPU 14 种、时区 14 种分布
- 不同 CC_FP_SALT -> 不同设备;同一盐 -> 稳定

白盒交叉验证(关键):
- 独立复刻 CLI 算法,对同一组原始值算期望值,与运行中代理实际上报的
  5 个 key × 15 个字段逐字节比对 -> 全部一致

回归:MAXeaglet#20 内存、MAXeaglet#18 上游代理、UA=cli、threadId===x-session-id 全部通过。
原文档写「把 timezone 绑定到出口 IP 是自然的下一步」,这是错的。

command-code CLI 的 readTimezone() 是
  Intl.DateTimeFormat().resolvedOptions().timeZone
即**客户端本机操作系统的时区**,与流量从哪个 IP 出去无关。

中国大陆用户通过代理访问本服务时,本机时区与出口地不一致是常态。
真正有意义的性质是时区在**自身用户群**里的分布,而不是与出口 IP 是否一致:
单一地区用户群若从 15 个全球时区里均匀取,会让每个账号看起来来自不同大洲。
正确做法是让池子匹配实际使用该部署的人群(收窄或加权 FINGERPRINT_TZS),
这是运维决策,不是「对齐 IP」。
上游有测试之前,fork 的这些改动只能靠手动脚本验证。现在把它们纳入 CI,
避免在上游同步时被静默覆盖。

新增 test/fork.test.mjs(13 条):

逆向对齐(对应 e3e267e)
- 上游请求 User-Agent 为 cli(CLI 源码常量 vy = "cli")
- fingerprint/lifecycle 预请求同样是 cli
- threadId 与 x-session-id 同值
- session 非 UUID 时省略 threadId(CLI toWireThreadId 行为)

上游代理(对应 MAXeaglet#18)
- CC_UPSTREAM_PROXY 配置后经 CONNECT 隧道
- 预请求也走代理(否则同账号会从两个 IP 注册)
- 未配置时不建立任何 CONNECT
- /health 不经过上游代理

指纹派生(对应 c153a76)
- 同 key 同盐重启后 thumbmark 稳定
- 不同 key 得到不同 thumbmark(不退化成哈希桶)
- 不同盐得到不同部署指纹
- CC_FP_MODE=random 可回退到原行为

测试用录制型 CONNECT 代理(只转发裸字节),不需要真出口代理。
全部走 loopback,无凭据、不访问外部服务。
fork.test.mjs 里 8 处 startMockUpstream() 漏了 await(我用 sed 替换动态
import 时引入)。mock.port 因此是 Promise,代理连不上、mock 又永不关闭,
事件循环被挂住 —— CI 三个矩阵 job 全部空转满 10 分钟后被取消。

两处修复:
- 补回 await(8 处)
- 让"挂起"有界,避免同类问题再次吃掉整个 job:
  · helpers 新增 closeServer():先 closeAllConnections 再 close,
    并叠加 3s 兜底超时。server.close() 只停止接受新连接,遇到
    keep-alive / 未关闭的 socket 会永远等下去。
  · npm test 加 --test-timeout=30000,单条测试超 30s 即失败。
CI 抓到的真实 fidelity 缺口:CLI 侧指纹预请求与生成请求共用同一个 header
常量表(lb = vy = "cli"),两者 UA 一致。而代理只在 forwardToCC 设了
User-Agent: cli,ensureInitialized 里的 fingerprint/lifecycle 两条预请求
走的是 Node fetch 默认的 "node"。

后果是同一账号的「设备指纹注册」与「生成请求」来自两种 User-Agent ——
这是服务端可直接观测的破绽,且恰好出现在设备识别入口上。

同时修正 fork.test.mjs 里一条断言错误的契约:
getSessionId 接受任意 >=8 字符的 session 头(不限 UUID),此时 threadId
必须等于实际发出的 x-session-id。原断言假设非 UUID 会被丢弃,与实现不符。
改为断言真正的不变量(threadId === x-session-id),并补一条无 session 头
时的回落路径。
CI 暴露我把两个不同的契约混为一谈:

  x-session-id header —— getSessionId 接受任意 >=8 字符,原样透传
  body.threadId       —— 仅当 sessionId 是合法 UUID 时才写(isWireUuid),
                         否则省略该字段,对齐 CLI 的 toWireThreadId

上一版断言「非 UUID 时 threadId 仍等于 header」,与实现不符。现在按实现
的真实契约分别断言,并补一条无 session 头时的回落路径(回落值是合法的
per-key UUID,此时 threadId 必须与之同值)。
CI 的 Node 18 job 报 "node: bad option: --test-timeout=30000" ——
该选项 20.11 才加入,而 engines 声明 >=18。等于我用一个防挂起的措施
把最低支持版本打挂了。这正是矩阵存在的意义:本地只有 Node 24,
不加矩阵就会直接发出去。

改为在 helpers 里起一个 unref 的定时器:进程若因泄漏的 socket /
未 await 的句柄无法退出,到点强制 exit(1) 并打印排查提示。
正常结束时定时器被 unref,不阻止退出。Node 18 同样可用。

保留上一版的 closeServer()(先 closeAllConnections 再 close + 兜底超时)——
那才是真正修掉「不退出」的手段,看门狗只是兜底。
上游 9 个 commit 合入,冲突 8 处(proxy.mjs 6 + README ×2)全部解决。
原则:协议形态一律取上游(它以 CLI 源码 + 真机为准),fork 独有特性保留。

取上游的部分:
- params.system 改为块数组(CLI 的 toWireSystem)、缓存断点落 system 末块
- 信封 9 键 + CLI 键序、skills=null、mode、threadId 仅 UUID 时下发
- 请求头按 buildCommandAuthHeaders 排序、删除 x-co-flag、UA=cli
- tools 总是下发且去掉自创 type;tool 输出 \n 拼接;image 补 mimeType
- x-project-slug = slugify(workingDir),不再随 session 变化
- 指纹信号值改为按 apiKey 派生(MachineGuid 形状 / MAC / DESKTOP- 主机名 / 可读邮箱),
  候选池用「打分取最大」而非取模;协议版本改为常量 + 漂移告警
- DEVICE_PROFILE 单一真源:指纹 / environment / workingDir / slug / lifecycle.os 自洽,
  不再泄露宿主 platform / Node 版本 / cwd
- 流式 stop_reason:先过 mapFinishReason 再进 mapAnthropicStopReason
  (上游 finishReason 是 'tool-calls' 连字符,此前 Anthropic 流式路径会误报 end_turn)
- 上游 error.code 透出(OpenAI 路径)

保留 fork 的部分:
- upstreamProxy / CC_UPSTREAM_PROXY 的 CONNECT 隧道(issue MAXeaglet#18)
- 预请求同样带 User-Agent: cli(上游仍只有 forwardToCC 设了它)
- 请求树提前释放(issue MAXeaglet#20)、CC_MAX_BODY_MB 默认 100MB、空闲淘汰 keyState

移除 fork 的部分(已被上游更好的实现取代):
- fpMode / CC_FP_MODE / fpSalt / CC_FP_SALT → fingerprintSalt / CC_FINGERPRINT_SALT
- fakeProjectSlug(盘符被错误剥掉且有自造后缀,与 CLI 的 slugify(cwd) 不符)
- 重复的 body.threadId 赋值(上游的键序重排已覆盖)

测试:58 → 69,全绿。
- 修正 2 条编码了旧认知的断言(params.system 曾断言「必须是字符串」)
- 修正 fork 指纹用例的配置项名;用「信号形状」用例替代已删除的 random 回退用例
- 新增 test/envelope.test.mjs(11 条):信封键序、非 UUID 键序、skills=null、
  header 集合无 x-co-flag、slug=slugify(workingDir) 且与 workingDir 同源、
  workingDir 不泄露宿主 cwd、tools 总是下发、tools 无 type 字段、
  image 带 mimeType、信封 mode 与 lifecycle mode 是两个枚举
上游 d063b47 引入的 TOOL_NAME_ALIASES 取错了来源,作用方向也反了。
对照 command-code@1.54.0 dist/cli.mjs:

  nw="search_tools", rw="tool_search"
  function toWireToolName(e){return e===rw?nw:e}          ← 线上只有这一项
  function toWireTools(e){return e.map(e=>({name:e.name,  ← 声明不做重写
      description:e.description,input_schema:e.input_schema}))}
  // toWireMessages 里:const r=toWireToolName(t.name); n.set(t.id,r);
  //   tool-call 用 r,tool-result 用 n.get(id) —— 两边都是重写后的名字

另外三项(bash_output / task_output / read_multiple_files)来自 ow 表,消费者是
resolveToolNameAlias:模型调了退役工具名时本地按新名字执行,并回一句给模型看的
自然语言 note,还会补 defaults(task_output 补 wait:"exit")。那是执行语义,
不是 wire 变换。

原实现:把四项全用在 params.tools[].name(CLI 不改的地方改了),
tool-call / tool-result 反而用原名(CLI 该改的地方没改)。下游按自己声明的
bash_output 找不到工具 —— 即 issue MAXeaglet#36 的现象。

修复:
- WIRE_TOOL_ALIASES = { tool_search: 'search_tools' },附注释说明 ow 表为何不能照搬
- params.tools[].name 原样下发
- toolNameMap 存重写后的名字(对齐 CLI 的 n.set(t.id, r))
- assistant 的 tool-call 与 tool-result 都用 toWireToolName,保证两边一致

已向上游提 issue MAXeaglet#37(含源码证据与建议修法)。

测试:73 全绿,新增 4 条 wire 契约:
- tools 声明不做名字重写
- tool_search 在 tool-call 里被重写为 search_tools
- tool-result 的 toolName 与 tool-call 一致
- 别名表外的名字在声明与 messages 里都不动
383aed8 只做到一半:MAXeaglet#37 里我建议「照搬 CLI,声明不改、消息改」,保留了一项
tool_search→search_tools。继续深挖 CLI 源码后发现那个方案本身也不成立。

CLI 里两个改名函数的真实定位(command-code@1.54.0 dist/cli.mjs):

  createSearchToolsTool        → schema.name = nw = "search_tools"
  createRetiredToolSearchTool  → schema.name = rw = "tool_search", visible:()=>false
      description: "Retired: use search_tools instead. Calls to the tool_search
                    NAME are routed to search_tools by the runner automatically."

  createToolRunner: o = () => [...tools, search_tools, retired]
      catalog 的 eligible() = n.filter(isVisible),发起请求那次 getSchemas({mode})
      不带 includeHidden(只有本地 resolveToolSchema 才开)
      → params.tools 里永远没有 tool_search

  toWireToolName(e){return e===rw?nw:e}   只作用在 toWireMessages
  toWireTools(e){return e.map(e=>({name,description,input_schema}))}   原样
  resolveToolNameAlias(ow)  被工具执行器调用,产出给模型看的 Repair note + 补 defaults

即 toWireToolName 不是「工具重命名设施」,而是「把自家 catalog 里那一个退役名字的
历史归一化」—— 前提是 CLI 自己退役过工具名、且可能重放旧会话。

反代没有这个前提:params.tools 由下游给出,没有 catalog、没有退役名。
而且只改消息不改声明本身就是不自洽的:客户端一旦声明了名为 tool_search 的工具,
就是「声明 tool_search、消息 search_tools」,复现 MAXeaglet#36 的同一个 bug。

修复:删除 WIRE_TOOL_ALIASES 与 toWireToolName,三处调用点退回原名,
原处留一段墓志铭注释说明两个 CLI 函数的真实定位与「将来要支持旧会话该做成入站」。
测试同步改为断言「不重写」。

结论已发到 issue MAXeaglet#37(含 cross-ref MAXeaglet#36)。
68664c2 修了 stop_reason 的一个成员('tool-calls' 连字符),方向正确,
但同一族里还有四个成员没处理,后果都比它更严重:**上游明明截断了,
下游收到的是「正常结束」**。对照 command-code@1.54.0 dist/cli.mjs 逐条对齐。

四种情形(mapFinishReason 只认 tool-calls/length/stop,其余原样放行):

1) max_output_tokens / model_context_window_exceeded
   CLI 的 normalizeStopReason2 把这两个都算 max_tokens。原实现走 default:
   OpenAI 侧透出非法枚举,Anthropic 侧 mapAnthropicStopReason 兜底成 end_turn
   —— 上下文撑爆被报成正常结束。
2) pause_turn
   Anthropic 原生枚举,表示「这一轮被暂停,后面还有」。CLI 靠自动续写循环
   (Ph=5)把它吸收掉,代理不续写就必须如实上报,不能吞。
   Anthropic 侧原样透出 pause_turn;OpenAI 没有对应枚举,折成 length
   (表达「输出不完整」)而不是折成 stop(那是谎报完成)。
3) network-error / connection-error / upstream-error
   CLI 的 isNetworkFailureFinish → 502 可重试。
4) 流里根本没有 finish 事件
   CLI:"Stream ended unexpectedly before completion (no finish event) —
   response was truncated" → 502 可重试。
   原实现 Anthropic 侧 `stopReason || 'end_turn'` 无条件兜底,OpenAI 侧
   连 finish_reason 块都不发直接 [DONE]。

修法:
- mapFinishReason 全量归一化(length 家族 / upstream_error),未知值原样返回,
  不再静默折成 stop
- mapAnthropicStopReason 增加 pause_turn / refusal 原样透出
- 新增 toOpenAIFinishReason:pause_turn → length
- 新增 incompleteUpstreamDetail(sawFinish, finishReason) / incompleteUpstreamError(),
  三条协议共用一个判定
- 六处补 sawFinish 跟踪(OpenAI 流式/非流式、Anthropic 流式/非流式、Responses 流式/非流式),
  没走完 finish 时:非流式报 502 可重试,流式发 error 事件而不是补一个假的结束标志
- Responses 流式原先直接比对原始 finishReason === 'length',改为用归一化后的值
- 流式路径把「没有正常结束」判定排在「零输出」之前 —— 上游压根没发 finish 时,
  「no finish event」才是根因,按 429 报会掩盖它

sawFinish 的口径是「上游给过任何完成信号」:finish 与代理一直在处理的 finish-step
都算。(finish-step 不在 CLI 的事件集里,但既然代理认它,就不能让它变成「没完成」,
否则会把原本正常的响应误判成 502。真正要拦的是「一个完成信号都没有就断了」。)

测试:新增 test/stream-end.test.mjs 15 条(三种协议 × 四种情形 + 正常结束不受影响的回归),
全套 73 → 88 全绿。
CLI 的 gatherRawSignals 里 cpuCount 取的是 os.cpus().length,即**逻辑处理器(线程)数**,
而 FINGERPRINT_CPUS 填的全是物理核心数 —— 15 项逐项核对,无一正确。

为什么这条不只是「不够真实」:
components 里只有 machineIdHash / macHashes / osUserHash / hostnameHash / gitEmailHash
走哈希,**cpuModel 与 cpuCount 是明文上传的**,服务端可以把这一对交叉核对。
「i7-12650H + 10 线程」等价于「这台机器关掉了超线程」;原表 100% 都落在
这个罕见表述上,是群体分布层面的特征,不是单请求能看出来的那种。

改法:表里补 threads 字段,cpuCount 改用 threads。
数字逐项复核过,其中 Intel Core Ultra 9 285H 是个反直觉项:
Arrow Lake 取消了超线程,6P+8E+2LPE = 16 核 == 16 线程,
所以它和 Ultra 7 155H(Meteor Lake 有超线程,22 线程)不能套同一个公式。

实现参考 @jinyu2022 的 PR MAXeaglet#35(那份 PR 还包含 MAC OUI 真实化,本次未采纳:
MAC 走的是哈希、明文不上网,收益只是外观;而它会改变 thumbmark 的输入,
等于让所有已部署的 key 换一台设备,代价大于收益)。

新增测试锁定「cpuCount == 该型号的逻辑处理器数」这一对不变量。
全套 89 项全绿。
线上排查时发现两条,一真一噪:

【噪音】Unknown CC event type {"type":"text-start"}
上游每个响应都会发一串不携带内容的事件(text-start / text-end / start / start-step /
reasoning-start / reasoning-end / provider-metadata / tool-input-* / tool-error)。
这些在三条**流式**翻译器里都有 case,但三条非流式路径要么缺静默列表、要么根本没有 ——
Responses 非流式那条压根没有静默列表,于是每个响应都刷十来个 warn,
把真正的错误淹掉。

修法:四条路径统一成同一份静默列表(只列"无用户可见内容"的事件),
default 仍然保留警告,真正没见过的类型照旧留痕。

【真问题】mapCcEventError 丢掉了上游自带的状态
CLI 的 readStreamErrorEvent 读的是 error.statusCode / error.isRetryable,
取值链:parseEmbeddedErrorJSON(message)?.status ?? error.statusCode ?? null
原实现只看 message 里的 "<NNN>" 前缀,statusCode 一律被丢掉,
于是一律塌成 502 upstream_error。

后果:429/503 这类「该退避重试」的信号在代理这一层被抹平成「服务端错误」——
客户端不再按限流退避,监控也把它错误归类成后端故障。
实测线上那条 "The request limited providers for this model and they are currently
at capacity"(不含 CLI 的任何 terminal 标记:premium_credits_exhausted /
model_not_in_plan / insufficient credits,即按 CLI 口径它是可重试的)
就可能因此被记成 502 而不是 429。

修法:
- mapCcEventError 采纳 error.statusCode(<NNN> 前缀仍优先,与 CLI 一致),
  返回值增加 reportedStatus 便于区分「上游报的」与「我们映射后的」
- 四个 CC error 日志点改为先映射再记日志,并打出 upstreamStatus / upstreamRetryable /
  code / mappedTo —— 与作者 78353d9 对 mapCcError 的处理保持一致

测试:20 条(MAXeaglet#38 家族)→ 全套 94 条全绿。
新增 4 条 statusCode 映射断言 + 1 条「标准序列不产生 Unknown CC 警告」。
线上排障定位到:流空闲超时后走的是

    res.write(`data: ${error}\n\n`);
    res.destroy();

res.write 是异步的,紧接着 destroy 会把尚未刷出的缓冲丢掉并发 RST。
反向代理侧看到的就是 "upstream prematurely closed connection":
响应头还没转发给客户端时回 502,已经转发了就是客户端看到 connection error /
截断的流。**两个症状同源。**

线上证据(1c2g VPS / OpenResty + systemd,资源指标全部健康:
NRestarts=0、MemoryCurrent=215MB、LimitNOFILE=524288、CPU 1.5%、无 OOM):

    Stream idle timeout {elapsedMs:62448,  bytesReceived:642575, lastCcEvent:"reasoning-delta"}
    Stream idle timeout {elapsedMs:87201,  bytesReceived:688653, lastCcEvent:"text-delta"}
    Stream idle timeout {elapsedMs:139706, bytesReceived:815758, lastCcEvent:"reasoning-delta"}
    Stream idle timeout {elapsedMs:147005, bytesReceived:833893, lastCcEvent:"reasoning-delta"}

833KB / 147s ≈ 5.5KB/s —— 上游确实慢(推理模型 + 容量受限),30s 空闲阈值
(CC_STREAM_IDLE_MS 默认值)在这种流上会频繁误杀。超时本身也许合理,
但收尾方式不对,把"代理主动截断"变成了"代理把客户端连接搞断"。

修法:三处流式超时收尾(OpenAI / Anthropic / Responses)改为
res.end(errEvent) —— 把错误事件正常写进 SSE 流再发 FIN,客户端 SDK 能按
可重试错误处理。下游若已僵死(不读也不断),仍由 CLIENT_DRAIN_TIMEOUT_MS
那条路径负责强制断开,职责不变。

测试:新增 1 条,且**验证过有区分度** ——
把 end() 换回 destroy() 时该用例失败,客户端拿到 "TypeError: terminated"
(连接被重置);换回 end() 通过。这正是线上 connection error 的复现。
test/helpers.mjs 增加 onRequest 返回 true 即"接管响应"的能力,
用来模拟"上游发了一半就长时间没新数据"。

全套 95 项全绿。
线上 nginx error log 的主要错误(8/10 条):

    sendfile() failed (32: Broken pipe) while sending request to upstream
    request: "POST /v1/chat/completions HTTP/1.1"
    upstream: "http://127.0.0.1:3050/..."

含义很具体:nginx 正在**把请求体写给后端**时,后端把连接关了。
注意是 sendfile() 而不是 writev() —— 这些请求体大到被 nginx 缓冲落盘。
而 POST 是非幂等,nginx 默认不会重试已发出的请求 → 客户端直接吃 502。

根因是 keep-alive 的时序:反代的 upstream keepalive_timeout 必须**小于**
后端的 keepAliveTimeout,否则反代会从缓存里取出一条后端已经关掉的连接。
Node 默认 keepAliveTimeout 是 5s,反代常见的 4s 只留了 1 秒余量;两边的
计时基准还不一样(反代从"读完响应放回缓存"起算,后端从"写完响应"起算),
大响应体下这点余量随时会被吃掉。线上恰恰全是 600~830KB 的流式响应。

此外 proxy 之前**没有设置过** server.keepAliveTimeout,等于把这件事完全交给
Node 默认值与反代配置的巧合 —— 部署形态(反面代理)是已知的,不该靠巧合。

改法:显式 server.keepAliveTimeout = 65s、headersTimeout = 66s
(CC_KEEPALIVE_TIMEOUT_MS 可覆盖),与 Node 官方"部署在反向代理之后"的建议一致
(keepAliveTimeout > 前端 idle timeout)。启动横幅打出该值,便于与反代对齐。

反代侧仍建议把 upstream keepalive_timeout 设成 60s 以内(不是 4s)——
现在两侧都是分钟级,余量从 1 秒变成几十秒,不再取决于抖动。

测试:新增 1 条锁定"启动横幅必须打出 keepAliveTimeout 并提示反代对应项",
全套 96 项全绿。
只报上限等于让人去猜自己超了多少:客户端要据此决定拆请求还是申请提额,
运维要据此决定 CC_MAX_BODY_MB 该设多大。

nginx 开了 proxy_request_buffering 时会带 Content-Length,据此给出真实体积;
没有该头(chunked)时退回已收到多少并标注为下界。同时补一条 warn 日志便于统计。
【竞态】31ce5e2 的 CI 在 Node 20/22 上失败、18 通过:

    not ok 37 - 413 报错包含实际请求体积(客户端要知道超了多少)
    AssertionError  expected: true  actual: false

失败的是最后一条 assert.ok(s.proxy.logs().includes(...)) —— 代理的日志经 stdout
异步送到测试进程,可能晚于 HTTP 响应到达。同一进程里日志确实先于响应写出,
但 stdout 与 TCP 响应是两条独立通道,父进程处理顺序没有保证。Node 18 恰好赶上、
20/22 没赶上。

前两条 assert.match("exceeds 1MB limit" 与 "body is N.NMB")在三种 Node 上都通过 ——
说明**修复本身是对的**,不稳的只是我对日志的断言。改为有界轮询(最多 2s)。

【矩阵】原来只跑 18/20/22,而实际部署(systemd 服务)跑的是 Node v24.16.0 ——
生产版本不在 CI 里是明确的漏洞。补上 24,并更新注释说明选型理由。

本机 Node v24.20.0 下全套 97 项通过。
- 18/20 已出维护期,CI 不再覆盖(engines >=18 仅作声明)
- 新增 test-bun job:Bun 运行时跑全套测试,被子测代理经
  process.execPath 派生,Bun 下整条链路都是 Bun
- Bun 的 node:http 基于 fetch 实现,不支持 CONNECT 方法
  (发起即报错),issue MAXeaglet#18 的两个隧道用例在 Bun 下显式跳过;
  新增 npm run test:bun 便于本地对齐
…glet#54 工具截图单发 + MAXeaglet#32 跟进

冲突取舍:
- 指纹 CPU 表取我方 threads 版(上游是 TEMP-REVERT 旧表,且合并后代码依赖 .threads)
- 三处非流式静默列表的 finish-step 取我方:上游侧与同 switch 的实质处理重复,是死代码
- MAXeaglet#18 跟进(redactProxyUrl 日志脱敏 / 启动即校验 / 204/205/304 空 body)与 MAXeaglet#50 重试取上游
- 启动横幅两边合并:redact 后的 upstreamProxy + upstreamRetry + fingerprint 三行齐全
- helpers/stream-end 测试取我方(严格超集:handled 接管契约 / +116 行回归用例)
- README:环境变量总表与 Docker 表取上游版;设备指纹与工具截图预算两章都保留
…completed

MAXeaglet#54 把 response.created 提前到上游一返回 200 就发(治首字前 15~40s 静默期被
nginx/CDN 掐连接),translator.started(=createdSent)自此恒为 true,
「outputTokens===0 && !translator.started」成了死代码:空响应经 finish()
包装成 response.completed 谎报成功(旧版 cce214d 是 429 rate_limit_error),
与 MAXeaglet#38/MAXeaglet#39 修掉的「静默截断谎报成功」同类。

- 判据换成 hasOutput(是否真的产出过 output item:outputIndex>0 || doneItems>0)
- 命中时若响应头已提交(常态),不能再 sendResponsesError —— 会抛
  ERR_HTTP_HEADERS_SENT;按本文件既有失败口径 translator.fail → response.failed
  (status:"failed"、error.code:"upstream_error"、message 说明空响应)
- 该分支就地 res.end():return 会跳过流式分支尾部的 res.end(),
  漏掉客户端会挂在永不结束的 SSE 上
- 非流式路径未被 MAXeaglet#54 波及(按 fullText/thinkingText/toolCalls 判空),仍是 429
- 新增 test/responses-zero-output.test.mjs(修复前跑它如预期红,修复后绿);
  README 两个语言的零输出防护/429 行同步修正
MAXeaglet#50 把 res.on('close') 放在「attempt === 1 且已拿到上游响应头」的分支里,
而第 1 次尝试恰恰可能在响应头之前就闪断(正是重试特性要处理的场景),
监听器永远注册不上:之后所有尝试都看不见客户端断连 ——
客户端已断开仍会继续打上游(白烧额度 / 放大 QPS),
对着空气交付的成功还会被误记成 Upstream retry recovered / recovered 统计。

- 注册移到 attemptLoop 之前无条件执行、恰好一次;回调体未动。闭包读的
  abortController 是每次尝试重绑的 let,取到的永远是当前尝试的控制器,
  无需重复挂载。
- test/upstream-retry.test.mjs 新增 mock 动作 destroy-before-headers(-delayed)
  (既有动作全都先 flushHeaders,恰好绕开该缺口),补两个用例:
  ① 响应头前闪断 + 第 2 次尝试中断开 → generateCalls 停在 2、有 Client
     disconnected、无误记 recovered(修复前该用例如期红:打满 3 次上游);
  ② 对照:响应头前闪断 + 客户端在线 → 照常重试并如实记 recovered。
- 相邻套件 connection-lifecycle / stream-end / regressions 共 32/32 本地全绿
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants