public: true

这个 topic 讨论 Local System shell 在高输出、后台任务和并发命令下的稳定性。

Agent 会频繁运行路径扫描、测试、构建、日志查看和后台命令。这些命令本身不一定复杂,但输出量无法估算。当前 lh 会把 shell stdout/stderr chunk 转成 JS string,并保存在 shell session 内;当输出持续增长,lh 的内存也随历史输出增长。

IO Buffer / Memory 增长速度

buffer 输出压力实验

输出压力实验验证 lh 在大 stdout / stderr 和并发后台输出下会显著抬高内存,并在小 heap 下触发 V8 OOM。

  • 先看 V8 heap 预算。Docker memory limit 是 cgroup 上限,不是给 Node 预分配的固定 heap。Node 会根据可见内存选择默认 V8 heap limit。
可见内存Node 默认 V8 heap limit说明
512 MiB约 259 MiBDocker Node 22 实测,约占 memory limit 的 50.6%
1 GiB约 524 MiBDocker Node 22 实测,约占 memory limit 的 51.2%
16 GiB约 4 GiB 量级硬上限;本机 22 GiB 环境下 Node v24 默认 heap limit 约 4288 MiB
  • 增长曲线实验
    • 使用 background_stdout_growth_20MiB:每个 background command 输出 20 MiB stdout,然后保持 shell session 存活;实验期间不调用 getCommandOutput
    • baseline-current 的 RSS 增长:
并发总输出量baseline RSSpeak RSSRSS 增量结果
240 MiB约 112 MiB约 160 MiB约 48 MiBpass
480 MiB约 126 MiB约 214 MiB约 88 MiBpass
8160 MiB约 126 MiB约 424 MiB约 299 MiBpass
16320 MiB约 125 MiB约 548 MiB约 422 MiBpass
32640 MiB约 126 MiB约 602 MiB约 476 MiBV8 OOM

这组结果说明:当前 baseline 的内存增长和历史输出量强相关。2 到 16 并发下,heap 增长接近 1 MiB output -> 1 MiB heap

优化方案

buffer 优化方案

  • Claude Code:
    • 命令输出存放: 普通 Bash/PowerShell 命令默认输出到文件中:stdout 和 stderr 直接写入输出文件 fd,父进程 JS 不监听 stdout/stderr stream,也不把完整输出持续放入 JS heap。
    • Agent 读取: progress 通过定时读取输出文件 tail 实现;命令结束后只返回 bounded preview。输出过大时,结果里带 outputFilePath / outputFileSize,让 agent 知道完整输出在文件里。
  • Codex:
    • 命令输出存放:stdout/stderr 进入内存中的 stream queue。每个 chunk 追加到队尾,并带递增的 seq;queue 有固定 byte 上界,超过后从队头丢弃最旧 chunk。因此 Codex 不落盘,也不保留完整历史,只保留最近窗口。
    • Agent 读取:调用方通过 after_seq 读取某个位置之后的新 chunk,并用 max_bytes 限制单次返回大小。返回结果带新的 next_seq,下一次继续从这个 cursor 之后读取。
  • Open Code:
    • 命令输出存放:stdout/stderr 仍进入 JS stream,并在内存里维护最近输出的 bounded tail。输出超过阈值后,完整输出写入 truncation file;内存里只保留 tail preview 和 progress metadata。
    • Agent 读取:tool result 返回 tail preview。如果发生截断,结果里会提示完整输出保存到文件路径,agent 后续可以用 Read / Grep 查看完整输出。

优化实验验证两个方向:

  • tail-spool 保留当前 stream 模型,但只在内存里保留 bounded tail,完整输出落盘;
  • fd-direct 让 stdout/stderr 直接写 output file fd,从热路径上绕开 JS stream。

实验设定:

  • 每个 background command 输出 20 MiB stdout。
  • 后台命令并发数为 2 / 4 / 8 / 16 / 32, 总输出量为 40 MiB640 MiB

实验期间不调用 getCommandOutput,只观察 shell session 存活时 lh 的内存增长。

方案40 MiB80 MiB160 MiB320 MiB640 MiB
baseline-current peak heap约 56 MiB约 95 MiB约 171 MiB约 334 MiBV8 OOM
tail-spool peak heap约 24 MiB约 37 MiB约 31 MiB约 44 MiB约 66 MiB
fd-direct peak heap约 18 MiB约 17 MiB约 17 MiB约 17 MiB约 17 MiB

实验结果:

  • baseline-current 的 heap 和历史输出量接近线性相关。2 到 16 并发下,约等于 1 MiB output -> 1 MiB heap
  • tail-spool 仍经过 JS stream,但只保留 bounded tail,完整输出落盘。640 MiB 总输出下 peak heap 约 66 MiB
  • fd-direct 让 stdout/stderr 直接写 output file fd,热路径绕开 JS stream。640 MiB 总输出下 peak heap 仍约 17 MiB

方案判断:

  • 从内存压力看:
    • fd-direct 曲线最好。640 MiB 总输出下 peak heap 仍约 17 MiB,stdout/stderr 热路径不进入 JS stream。
    • tail-spool 能解决 retained heap 问题。640 MiB 总输出下 peak heap 约 66 MiB,但 stdout/stderr 仍经过 JS stream,RSS 和短时 stream buffer 仍有压力。
    • bounded stream queue 能给 heap 设置上界,但窗口外历史会被丢弃;它适合看最近输出,不适合作为完整日志回看的唯一方案。
  • 从工程复杂度看:
    • bounded stream queue 需要引入 seq cursor、retained window 和历史缺失语义。
    • tail-spool 更贴近当前 stream 模型,但要补 spool file、cleanup、截断元数据和错误处理。
    • fd-direct 改动最大,需要重做 output fd、tail/range read、disk cap、cleanup、权限和 path 语义。
  • 从 agent 友好看:
    • bounded stream queue 对 agent 不够友好,因为旧输出可能已经被丢弃。
    • tail-spoolfd-direct 都可以返回 bounded preview,并把完整输出作为文件路径交给 agent 后续读取或 grep。
    • fd-direct 更接近 Claude Code 的模型:命令输出先落盘,agent 先看 preview,需要完整内容时再读文件。