Skip to content

fix(captcha): 修复 serve 常驻进程 RSS 单调增长(issue #50) - #51

Merged
TriDefender merged 1 commit into
masterfrom
fix/captcha-memory
Sep 22, 2026
Merged

TriDefender merged 1 commit into
masterfrom
fix/captcha-memory

Conversation

@TriDefender

Copy link
Copy Markdown
Owner

Fixes #50

根因

转发管线(proxy/handler.ts)每请求都是干净的函数作用域,不是泄漏源。问题出在 captcha 子系统,三个因素复合成“台阶式只涨不跌”:

  1. 复用窗口内每 solve 累积 SDK 实例图:solveTraceless 在复用的 happy-dom 窗口上无条件重新调用 w.initAliyunCaptcha(...),每个 solve 在同一窗口里实例化一套新的 Aliyun SDK + pe 字节码 VM + FeiLin 指纹 SDK + 2s 心跳 interval,旧实例能否释放取决于 SDK 内部(实测滞留约十几 MB/solve)。REUSE_MAX_SOLVES=25 期间单世代峰值可达数百 MB。
  2. JSC 高水位不归还:destroyDom 的逻辑清理是完备的(happyDOM.close + 别名 refcount/tombstone 卸载 + guest scope 移除),但 Bun/JSC 只在 full collection 时才把页归还 OS——每个窗口世代的分配峰值成为 RSS 的永久地板。这解释了 issue 里的台阶曲线和 2.9 天 9.95GB。
  3. 两处无界结构(量级小但确定):_requestLog 每个窗口内 fetch 都 push 从不截断;_memCdnCache 按 pe 版本轮换只增不减(每版本数百 KB 常驻进程生命周期)。

空载 +1MB/s 的形态与池子稳态补铸(min=15、TTL 95s ≈ 每 6.3s 一个 solve)吻合;deep-idle(900s)停铸后走平,与报告人“静置 8 分钟一条平线”的观测一致。

修复

改动 说明
REUSE_MAX_SOLVES 25→8 世代滞留峰值压到 ~1/3;窗口 boot 摊销损失可忽略(426ms 暖窗口 vs 815ms 冷启动,8 次摊销后每 solve 仍省近半)
destroyDom 尾部节流 full GC Bun.gc(true)(默认 5s 节流,CAPTCHA_GC_MIN_INTERVAL_MS 可调);世代交替点收掉死对象图、促使 JSC 归还页;pe-storm 连续销毁窗口由节流兜底,不会每个失败 solve 都付一次 stop-the-world
_requestLog 环形化(cap 256) stall 检测只读最新条目、失败诊断只看当前 solve 的最后 12 条,语义不变
_memCdnCache FIFO 帽(16 条) 磁盘缓存(~/.zcode-captcha-cdn-cache)仍可回读,只是不再全部常驻内存
删除 solveTimes 从未被 push 的死数组
__captchaMemStats() 测试/调试观测面(环形长度、缓存大小、GC 次数等)

权衡说明

  • CPU vs 内存:复用档位 25→8 后,暖窗口 solve 均摊成本约 +10%(815/8 摊销增量),换来世代峰值 ~1/3——对常驻反代是正确的取舍。
  • full GC 停顿:大堆下 Bun.gc(true) 是同步 stop-the-world,可能上百 ms;5s 节流把它限制在世代交替点,且可用 env 调成 0(关闭节流)或更大值(几乎禁用)。
  • Node 运行时(Android bundle):无 exposed gc 时静默跳过,行为不变差。

验证

  • bun x tsc --noEmit 通过
  • bun test 859/859 通过(含 4 个新增 memory guards 用例:环形截断、CDN 缓存 FIFO、GC 节流、复用档位默认值)
  • Android bundle 已本地重建验证(产物 gitignored,不进库)

遗留建议(不在本 PR 内)

  • 文档化 systemd MemoryMax 作为深度防御(issue 报告人的 workaround 已验证有效)
  • 低流量场景可再压 CAPTCHA_POOL_MIN(当前 15)
  • issue 附注的 claim X-Device-Mid 3001 问题已在 v4.6.8 修复(6b7327e),回复 issue 时可确认

根因(三个复合):
1. 复用窗口内每个 solve 无条件重新调用 initAliyunCaptcha,旧 SDK 实例图
   (pe VM/FeiLin 心跳/XHR 缓冲)滞留在窗口里,REUSE_MAX_SOLVES=25 时
   单世代峰值可达数百 MB;窗口销毁后 JSC 不把页归还 OS,峰值成为
   RSS 永久地板——台阶式只涨不跌,2.9 天实测 9.95GB
2. _requestLog 每个窗口内 fetch 都 push、从不截断(无界,量级小但确定)
3. _memCdnCache 按 pe 版本轮换只增不减(每版本数百 KB 常驻)

修复:
- REUSE_MAX_SOLVES 25→8:世代滞留峰值压到原来 ~1/3,CPU 摊销损失可忽略
- destroyDom 尾部接管式节流 full GC(Bun.gc(true),默认 5s 一次,
  CAPTCHA_GC_MIN_INTERVAL_MS 可调):世代交替点把死对象图收掉、促使 JSC
  归还页;pe-storm 连续销毁由节流兜底
- _requestLog 改环形(cap 256,stall 检测只读最新条目/诊断只看当前 solve
  的最后 12 条,语义不变)
- _memCdnCache 加 FIFO 帽(16 条,磁盘缓存仍可回读)
- 删除死数组 solveTimes
- __captchaMemStats() 导出测试/调试观测面

验证:bun x tsc --noEmit 通过;859/859 测试过(含 4 个新 memory guards 用例)
@TriDefender
TriDefender merged commit 732fdd2 into master Sep 22, 2026
1 check passed
@TriDefender
TriDefender deleted the fix/captcha-memory branch September 22, 2026 19:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant