Skip to content

[Bug] v0.9.3 堆外内存(ArrayBuffer)无界增长导致内核级 OOM,上游流错误后疑未销毁 #1430

Description

@yangxiang92

概述

v0.9.3 在生产环境反复出现 内核级 OOM(非 V8 heap OOM):进程 anon-rss 涨到 13.514.2GB 被内核 SIGKILL,周期 47 分钟,单日 8 次并多次拖垮整机。

内存增长发生在 V8 堆外(ArrayBuffer backing store),因此 --max-old-space-size 无效,也不会产生 fatal report。

#1408 不同:#1408 是 V8 heap OOM(有 fatal report),其修复(#1414)已包含在 v0.9.3 中;本 issue 是堆外内存,失效模式和触发路径都不同。

环境

  • 镜像:ghcr.io/ding113/claude-code-hub:v0.9.3
  • Node:v26.7.0,V8 heap 上限 4.09GB
  • 宿主:15GB 内存 / 8 核,Docker Compose(app 初始无 mem_limit
  • 配置:除 ENABLE_MULTI_PROVIDER_TYPES=trueSTORE_SESSION_MESSAGES=false 外基本为默认值
  • 负载:约 200~550 请求/分钟,客户端为 Claude Code 与 Codex,上游为第三方中转站

观测数据

用 5 秒分辨率采样器记录,起爆时自动抓取 smaps 与 socket 队列。

增长形态(一次典型起爆):

09:22:22   813,868 kB
09:22:27  1,759,180 kB   +945 MB / 5s
09:22:33  2,676,196 kB   +917 MB / 5s
09:22:38  3,934,496 kB  +1258 MB / 5s
09:22:44  5,386,984 kB  +1452 MB / 5s

内存位置 —— 增长集中在单块匿名映射内,映射数量恒定:

爆炸期: 3,602,476 kB  @ 7f7567ff9000-7f7667ffa000   regions=1779
        (4GB 边界对齐,单块 > heap 上限 4.09GB → V8 cage 内的 ArrayBuffer)
正常期:    97,220 kB  @ 2bc40000-31c4c000            regions=1101

早期一次采样更明显:regions=1213 全程不变,而 total_anon 从 5.08GB 涨到 8.09GB —— 是既有映射原地长大,不是新建缓冲区。

socket 队列 —— 这是最有指向性的一组:

爆炸期:
  recvq=273,206  peer=<上游 CF>:443
  recvq=121,701  peer=<容器网关>:33186
  recvq=119,274  peer=<容器网关>:44196
  recvq=119,274  peer=<容器网关>:44170     ← 多条连接堆积完全相同的字节数
  recvq=119,274  peer=<容器网关>:33220
  recvq=119,274  peer=<容器网关>:33218
  total_recvq=1,337,790   total_sendq=0

正常期:
  total_recvq=878         total_sendq=694,177

爆炸时 recvq 大量积压而 sendq 归零,正常时恰好相反。多条连接堆积完全相同的 119,274 字节说明不是随机积压。

其他排除项:连接数在爆炸期恒定(48 / 61~72,不随内存增长);线程恒定 11;data/reports/ 始终为空(V8 从未触及自身上限);replay_payloads 仅 92MB;Redis 399MB。

暴涨期间应用完全静默:日志从 908 行/分钟衰减到 0,采样器记录 req5s=0,即无新请求进入时内存仍在以 370~500MB/s 增长。事件循环被饿死是结果而非原因。

可疑代码路径

起爆前的错误榜首是 undici body stream error (caught early),一个容器生命周期内 141 次,同时伴随 Provider error occurred 122 次、All providers failed 46 次、熔断器触发 —— 上游中转站大面积出错时触发。

src/app/v1/_lib/proxy/forwarder.ts:8292 附近:

const rawBody = undiciRes.body as Readable;
rawBody.on("error", (err) => {
  const code = (err as NodeJS.ErrnoException).code;
  const isExpectedDisconnect = code === "ECONNRESET" || ...;
  const log = isExpectedDisconnect ? logger.debug : logger.warn;
  log("ProxyForwarder: undici body stream error (caught early)", {...});
});   // 仅记录日志,未调用 destroy() 或 resume()

同一函数内的 gzip 分支有对应的销毁处理(gunzip.destroy(err),注释引用了 #1147 的段错误教训),但非 gzip 分支的 rawBody 自身错误路径没有。

需要说明:nodeStreamToWebStreamSafe 内部确实注册了 nodeStream.on("error", onError) 并会 controller.error(err) 向下游传播,所以错误并非完全未被处理。我们未能证明具体的对象保留链 —— 抓 heap snapshot 需要在泄漏进行中操作,而当时进程已达 13GB、宿主机在 thrashing,无法安全采集。

一个可能相关的组合是 v0.9.0 引入的需求驱动背压(node-stream-to-web.ts:71 默认 pause(),仅在 pull()resume()):上游流出错后如果下游不再 pull、且该流未被 destroy,socket 数据会持续留在内核 recvq,这与观测到的 recvq 积压 / sendq=0 吻合。这一点是推断,仅供参考。

版本线索

  • rawBody.on("error") 只记日志的写法自 v0.3.20(2025-11-30,commit eecdb757)就存在
  • nodeStream.pause() 需求驱动背压在 v0.9.0(2026-07-17,commit 3724315b / 3673105a)引入
  • v0.8.10 的 node-stream-to-web.tspause() 出现 0 次

即单看那个 error handler 长期存在且未致泄漏,问题在 v0.9.x 才显现。

缓解措施与效果

给 app 容器加 mem_limit: 3g + memswap_limit: 3g(后来放宽到 6g)后:

加之前 加之后
全机 OOM 8 次 0 次
load average 峰值 95.74 7 以下
最低可用内存 105MB 9GB+
故障影响面 整机(16 个容器) 仅 app 容器自愈重启

另外设 ENABLE_REQUEST_REPLAY=false 后观察 10.5 小时未复发,但负载回到白天水位(连接数 20 → 72)后立即再次爆发两次 —— replay 不是根因

附带一个发现:本部署中 replay 命中率为 0(ReplaySpool 完成 5694 次,日志与源码中均无缓存取用事件)。原因是 replayId 由 bodyHash 派生,而 Claude Code 在流中断后会把已接收内容并入对话历史再发起新请求,body 变化导致 replayId 永不匹配。这与本 issue 无关,但或许值得单独关注。

建议

  1. rawBody.on("error") 中补充 rawBody.destroy(err)(或至少 resume() 排空),与同函数 gzip 分支保持一致
  2. 为堆外缓冲增加全局配额 —— 目前 REPLAY_MAX_PAYLOAD_BYTES(8MB)、STREAM_GATE_PREBUFFER_BYTE_CAP(10MB)都是单流上限,并发流总量无天花板
  3. 建议在 docker-compose.yaml 中为 app 服务提供默认 mem_limit,避免单进程失控拖垮整机

复现条件

无法稳定构造,但触发条件较明确:上游供应商大面积返回错误或断流(表现为 undici body stream error 密集出现、All providers failed、熔断器触发),同时保持 200+ 请求/分钟的并发流式负载。我们环境下白天高峰期必现,夜间低负载时不出现。

如需进一步数据(完整采样日志、smaps 序列、socket 队列快照)可以提供。

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:corebugSomething isn't workingoncallCritical blocking issue requiring immediate oncall attention

    Projects

    Status
    Backlog

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions