public: true
这个 topic 讨论 Local System shell 在高输出、后台任务和并发命令下的稳定性。
Agent 会频繁运行路径扫描、测试、构建、日志查看和后台命令。这些命令本身不一定复杂,但输出量无法估算。当前 lh 会把 shell stdout/stderr chunk 转成 JS string,并保存在 shell session 内;当输出持续增长,lh 的内存也随历史输出增长。
IO Buffer / Memory 增长速度
输出压力实验验证 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 MiB | Docker Node 22 实测,约占 memory limit 的 50.6% |
| 1 GiB | 约 524 MiB | Docker 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 RSS | peak RSS | RSS 增量 | 结果 |
|---|---|---|---|---|---|
| 2 | 40 MiB | 约 112 MiB | 约 160 MiB | 约 48 MiB | pass |
| 4 | 80 MiB | 约 126 MiB | 约 214 MiB | 约 88 MiB | pass |
| 8 | 160 MiB | 约 126 MiB | 约 424 MiB | 约 299 MiB | pass |
| 16 | 320 MiB | 约 125 MiB | 约 548 MiB | 约 422 MiB | pass |
| 32 | 640 MiB | 约 126 MiB | 约 602 MiB | 约 476 MiB | V8 OOM |
这组结果说明:当前 baseline 的内存增长和历史输出量强相关。2 到 16 并发下,heap 增长接近 1 MiB output -> 1 MiB heap。
优化方案
- 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 之后读取。
- 命令输出存放:stdout/stderr 进入内存中的 stream queue。每个 chunk 追加到队尾,并带递增的
- 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 MiBstdout。 - 后台命令并发数为
2 / 4 / 8 / 16 / 32, 总输出量为40 MiB到640 MiB。
实验期间不调用
getCommandOutput,只观察 shell session 存活时lh的内存增长。
| 方案 | 40 MiB | 80 MiB | 160 MiB | 320 MiB | 640 MiB |
|---|---|---|---|---|---|
| baseline-current peak heap | 约 56 MiB | 约 95 MiB | 约 171 MiB | 约 334 MiB | V8 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 需要引入
seqcursor、retained window 和历史缺失语义。 tail-spool更贴近当前 stream 模型,但要补 spool file、cleanup、截断元数据和错误处理。fd-direct改动最大,需要重做 output fd、tail/range read、disk cap、cleanup、权限和 path 语义。
- bounded stream queue 需要引入
- 从 agent 友好看:
- bounded stream queue 对 agent 不够友好,因为旧输出可能已经被丢弃。
tail-spool和fd-direct都可以返回 bounded preview,并把完整输出作为文件路径交给 agent 后续读取或 grep。fd-direct更接近 Claude Code 的模型:命令输出先落盘,agent 先看 preview,需要完整内容时再读文件。