date: 2026-06-10 tags: [inbox, project/cli-agent, type/experiment-report] public: true
lh Local System Shell IO Buffer 与输出压力实验
摘要
目标:验证 lh connect 在 Local System runCommand 高输出/并发输出场景下是否会因为 Node/V8 heap 压力失去响应,并定位触发阈值。
当前判断:
- 低输出、慢 I/O 本身不会拖住
lh connect。 - 大 stdout 已能显著抬高
lh connectRSS。 NODE_OPTIONS=--max-old-space-size=64下,约 31MB stdout burst 已触发 V8 OOM。NODE_OPTIONS=--max-old-space-size=96下,4 x 20MBbackground stdout/stderr 均可触发 V8 OOM。getCommandOutput在2 x 20MB下未造成明显额外尖峰;更早的 bytes 转 JS string 阶段已经足以崩溃。
叙事线
一句话版本:这不是 shell 子进程天然卡死的问题,而是 lh connect 把无界 shell output 纳入 Node/V8 heap 长期管理后,父进程被输出流量拖垮的问题。
这条问题线按七步推进:
- 问题发现:
lh connect在 Local System 高输出场景下会失去响应,agent 侧表现为 503/504,gateway 可能看到 WebSocket abnormal close。 - 理论假设:shell stdout/stderr 是无界 stream。当前
lh connect把 stream 转成 JS string 并长期保存在stdout[]/stderr[],导致内存随输出字节数线性增长。 - 指标定义:不用 OOM 0/1 单点判断,而是同时观察输入压力、管理成本、响应性、背压、生存性。
- Synthetic case:用 B2/B4/C2/B5/B6/B7 固定输出量、并发数、stdout/stderr、chunk 形态、polling 行为。
- 真实场景 case:用 R1 路径扫描和 R2 日志流验证机制不是 synthetic 独有。
- 改进目标:shell 输出不应成为
lh connect的长期内存占用。lh connect只承担 bounded 管理成本,输出正文应进入 bounded preview、ring buffer、drop/cap policy 或落盘路径。 - 回归验证:修复后重复 B4/C2/R2,确认内存不再随历史输出线性增长,并确认 gateway 不再因为 connect OOM 看到异常断连。
目标不变量:
Memory(lh connect) = base + O(active_sessions * retained_cap) + O(metadata)
不应出现:
Memory(lh connect) = base + O(total_output_bytes)
真实场景画像
当前问题最容易出现在高输出、低选择性、后台或并发运行的命令里。
| 场景 | 代表命令 | 对应实验 |
|---|---|---|
| 路径扫描 | find /、ls -R、rg --hidden | R1、C2 |
| 权限错误风暴 | find /、扫描 /proc、/sys、系统目录 | C2、R1 |
| 日志流 | docker logs -f、kubectl logs -f、tail -f、journalctl -f | R2、B4 |
| 测试/构建日志 | pytest -vv、pnpm test、make V=1、tsc --traceResolution | B4、B6、R2 |
| 大文件 dump | cat large.log、jq . huge.json、pg_dump、tar -tvf | B2、B3 |
| agent 并发叠加 | 多个 background runCommand 同时输出 | B4、C2、R2 |
R1 说明真实路径扫描能显著增加 RSS。R2 说明日志流形态可以直接复现 OOM。
背景机制
Node/V8 内存
Node 进程的内存不只包含 V8 heap。
- V8 heap:JS object、string、array、closure 等主要对象所在区域。
- V8 heap spaces:
new_space放短命对象,old_space放晋升后的长期对象,large_object_space放大对象,另有 code/trusted/read-only 等内部空间。 - heap 外内存:
Buffer、ArrayBuffer、native addon、libuv handle、线程栈、mmap、动态库等。 - RSS:OS 看到的进程常驻内存,通常大于 V8
heapUsed,也可能大于--max-old-space-size。
--max-old-space-size=N 主要限制 V8 old space,不是限制整个进程 RSS。接近限制时 V8 会更频繁 GC;回收失败时进程会报 JavaScript heap out of memory。
Node 官方原文:
- Understanding and Tuning Memory:说明 JavaScript objects、arrays、functions 分配在 V8 heap,且可用
v8.getHeapStatistics()查看 heap limit。 - process.memoryUsage():说明
rss、heapTotal、heapUsed、external、arrayBuffers的含义。 - v8.getHeapSpaceStatistics():说明可以查看 V8 heap spaces,但 space 的顺序和可用性不保证跨版本稳定。
- CLI options:说明
--max-old-space-size、--max-semi-space-size、--max-old-space-size-percentage。 - Tracing garbage collection:说明如何打开并解读 Node GC trace。
- V8 Orinoco GC:V8 官方介绍 generational heap、minor GC、major GC、semi-space、promotion。
图片参考:
- V8 heap generation 示意图,Red Hat Developer:图里把 New Space、Old Space、promotion、major GC 画在一起,适合快速建立直觉。
- V8 young generation From-Space / To-Space 示意图,V8 相关文章索引图:V8 官方文章中有 young generation evacuate、promotion 的图。
对象放在哪里
可以粗略把 Node 进程内存分成四层:
| 区域 | 典型内容 | 是否受 V8 GC 管理 | 对本问题的意义 |
|---|---|---|---|
| Stack | 当前调用栈、局部变量槽位、返回地址、少量引用 | 否,主要由 OS/thread 管理 | 不是本轮 OOM 主因 |
| V8 heap | JS object、Array、Function、Closure、String、Promise、Map 等 | 是 | stdout[]、stderr[]、JS string 都在这里 |
| External / ArrayBuffer | Buffer 背后的 bytes、ArrayBuffer backing store、C++ 对象 | 部分由 JS wrapper 关联统计,但 bytes 常在 heap 外 | child stdout/stderr 进入 Node 时通常先是 Buffer |
| Native / OS | libuv handle、动态库、线程栈、mmap、malloc arena、文件描述符相关结构 | 否 | 解释为什么 RSS 大于 heapUsed |
对当前 lh connect 问题,关键路径是:
- OS pipe 把 child stdout/stderr bytes 交给 Node。
- Node stream 的
data事件通常给出Buffer。这部分 bytes 可体现在external/arrayBuffers。 - 当前代码调用
data.toString(),这会创建 JS string。JS string 属于 V8 heap。 - string 被 push 到
stdout[]/stderr[],数组和 string 之间保持强引用。 - 只要 shell session 没清理,这些 string 就不会被 GC 视为垃圾。
因此,本问题不是“GC 不工作”,而是“应用仍然持有引用”。GC 只能回收不可达对象,不能回收仍被 stdout[] / stderr[] 引用的输出 string。
V8 generation 迁移机制
V8 使用 generational GC,核心假设是多数新对象很快死亡,少数对象会活很久。
简化路径:
new object/string
-> new_space nursery
-> minor GC/scavenge 后仍存活
-> copied 到 young generation 的另一块 semi-space
-> 再次或多次存活
-> promoted 到 old_space
-> major GC/mark-compact 才会处理
更贴近 V8 的说法:
new_space属于 young generation,使用 semi-space 设计,常见名字是 From-Space 和 To-Space。- minor GC 也叫 Scavenger。它只处理 young generation,把仍然可达的对象复制到新的空间,死亡对象不复制。
- Scavenge 结束后,From-Space 和 To-Space 会交换角色。
- 如果对象在 young generation 中存活过若干轮,它会被 promoted 到 old generation。
old_space存放长期存活对象,major GC 通常比 minor GC 更重。- 很大的对象可能进入
large_object_space。这类对象不适合频繁移动,管理成本也更高。
套回本实验:
- 单个 stdout chunk 转成的 string 一开始可能是短命对象。
- 但
stdout[]/stderr[]继续引用它,它就会在 minor GC 中存活。 - background session 保持存活时,旧 string 会继续被引用,并逐步进入 old_space。
- 多个 background session 同时输出时,old_space 中长期可达 string 快速累积。
- old_space 接近
--max-old-space-size后,V8 会更频繁做 major GC。若 GC 后仍无法释放足够空间,就触发JavaScript heap out of memory。
这解释了 B4/C2/R2 的现象:即使 agent 没有 polling,lh connect 也会在 bytes 转 JS string 和长期引用阶段被打爆。
Docker/cgroup 与默认 heap
Docker memory limit 是 cgroup 上限,不是给 Node 预分配一块固定内存。现代 Node 能感知容器 memory limit,并据此选择默认 V8 heap limit。
本机重新验证:
| 运行环境 | 可见 memory limit | heap_size_limit | heap / limit |
|---|---|---|---|
| 宿主机 Node v24.15.0 | 约 22016 MiB | 约 4288 MiB | 约 19.5% |
Docker Node 22,无 --memory | 约 22016 MiB | 约 4144 MiB | 约 18.8% |
Docker Node 22 --memory=512m | 512 MiB | 约 259 MiB | 约 50.6% |
Docker Node 22 --memory=1024m | 1024 MiB | 约 524 MiB | 约 51.2% |
Docker Node 22 --memory=2048m | 2048 MiB | 约 1048 MiB | 约 51.2% |
解释:
- Docker 不加
--memory时,容器内 Node 看到的是宿主机约 22GiB 内存,默认 heap 约 4GiB。 - Docker 加
--memory后,cgroupmemory.max会成为 Node/V8 选择默认 heap 的重要输入。 - 在 512MiB、1GiB、2GiB 这几个容器限制下,Node 22 默认
heap_size_limit大约是 cgroup memory limit 的 51%。 - 这个比例不能直接外推到大内存宿主机。宿主机约 22GiB 内存下,默认 heap 被 V8 heuristic 或默认上限压在 4GiB 左右,所以比例降到约 19%。
Harbor/terminal-bench 侧:
- smoke case 显式
memory_mb = 1024。 - 本机 cache 中 89 个 terminal-bench task 未显式声明
memory_mb。 - Harbor task 默认
memory_mb = 2048。 - 因此正式 terminal-bench case 若无 override,容器内 Node 默认 V8 heap limit 可按
2048MiB * 51%粗略估算,约为 1048 MiB;smoke case 可按1024MiB * 51%粗略估算,约为 524 MiB。
lh Local System 工作方式
关键路径:
runCommand使用/bin/sh -c <command>启动 child process。- child stdout/stderr 通过 pipe 回到
lh connect。 lh connect将每个 data chunk 转成 string,分别 push 到stdout[]/stderr[]。run_in_background=true只让runCommand立即返回shell_id;输出仍持续保存在同一个ShellProcessManager内。getCommandOutput用lastReadStdout/lastReadStderr作为游标,slice().join('')生成增量输出。getCommandOutput不删除已读 chunk;旧输出会保留到 shell session 被 kill 或进程退出清理。truncateOutput只限制返回给模型的字符串长度,不限制内部保留输出。
实验负载 fidelity
本轮实验是机制压测,不是精确模拟某一个真实命令。
一致点:
- 触发真实 Local System
runCommand。 - 真实经过 child stdout/stderr pipe。
- 真实经过
data.toString()和stdout[]/stderr[]保留路径。 - background 的返回时机和输出继续保留语义一致。
差异点:
- 负载使用 Node 子进程稳定造固定字节量;真实命令可能是
find、test runner、编译器、包管理器等。 - chunk 边界、行长、ANSI、多字节字符比例与真实 workload 不完全一致。
- 部分 case 禁止 polling/kill,是为了隔离变量。
因此:实验严格命中 lh shell output buffer 管理路径;但 workload fidelity 需要通过后续 find/权限错误、test runner/log 型 case 补足。
指标体系
本问题不能只用 OOM 0/1 判断。OOM 是红线,内存增长斜率才是设计是否健康的核心指标。
输入压力
| 指标 | 含义 |
|---|---|
output_bytes_total | 子进程实际输出字节数 |
output_bytes_per_sec | producer 输出速率 |
stdout_bytes / stderr_bytes | stdout 与 stderr 分流 |
chunk_count | stream data 事件数量 |
avg_chunk_size | 平均 chunk 大小 |
这些指标帮助区分慢速 20MB、瞬时 20MB、短行多 chunk、长块少 chunk。
管理成本
| 指标 | 含义 | 修复后目标 |
|---|---|---|
delta_rss_per_output_byte | ΔRSS / output_bytes_total | cap 后接近 0 |
delta_heap_per_output_byte | ΔheapUsed / output_bytes_total | cap 后接近 0 |
peak_rss | lh connect 峰值 RSS | 受 session cap 控制 |
stable_rss | 输出结束后的稳定 RSS | 不随历史输出线性增长 |
retained_bytes_per_session | 每个 session 内部保留输出量 | 不超过配置 cap |
truncated_or_dropped_bytes | 被截断或丢弃的字节数 | 明确记录并可返回给模型 |
修复前预期:delta_rss_per_output_byte 接近线性,多个 background session 叠加后触发 OOM。
修复后预期:超过 retained cap 后,继续输出不会继续推高 lh connect 内存。
响应性和背压
| 指标 | 含义 | 修复后目标 |
|---|---|---|
tool_call_latency_p95 | 高输出压力下 tool-call 延迟 | 有上界 |
event_loop_delay_p95 | Node event loop 延迟 | 有上界 |
time_to_drain_after_exit | 子进程退出后 drain 完成时间 | 有上界 |
getCommandOutput_latency | polling 读取延迟 | 不随完整历史线性增长 |
Node 层不易直接读取 OS pipe occupancy。必要时可用 strace 或 eBPF 观察 child write(1, ...) 是否阻塞。
生存性
| 指标 | 含义 | 修复后目标 |
|---|---|---|
oom | lh connect 是否 V8 OOM | 必须为 0 |
ws_abnormal_close_1006 | gateway 是否看到异常断连 | 必须为 0 |
gateway_503_504 | agent 链路是否因 socket 消失失败 | 必须为 0 |
ws_abnormal_close_1006 是辅助信号,不是 OOM 必要条件。主判据仍是 lh connect 是否存活,以及内存是否有上界。
生命周期指标
orphan_process_count、kill_completion_time_ms、process tree cleanup 属于独立问题线,详见 lh-shell-process-lifecycle-experiment。本笔记只保留关联,不把它作为 IO buffer 修复的主判据。
实验环境
| 项 | 值 |
|---|---|
| 工作目录 | /home/cy948/workspace/github/lobe-search-agent-eval |
| tmux session | lh-shell-exp |
| connect pane | lh-shell-exp:0.0 |
| agent pane | lh-shell-exp:0.1 |
| app server | http://localhost:3210 |
| device gateway | http://localhost:8787 |
| agent gateway | http://localhost:8788 |
| app server graph 开关 | TB_GRAPH_AGENT=0 |
| connect heap 设置 | 主要使用 NODE_OPTIONS=--max-old-space-size=96 |
观测命令:
ps -o pid,ppid,stat,%cpu,rss,command -p <lh-connect-pid>
pgrep -P <lh-connect-pid> -a
Case 矩阵
Case 到指标映射
| Case | 主要回答的问题 | 核心指标 |
|---|---|---|
| Baseline | 低输出链路是否健康 | tool_call_latency、oom=0 |
| B2 | 单次 burst 能否撑爆 V8 heap | peak_rss、oom |
| B3 | background 是否仍保留输出 | stable_rss、retained_bytes_per_session |
| B4 | 多 session stdout 是否线性叠加 | delta_rss_per_output_byte、oom |
| C2 | stderr 是否有同类风险 | stderr_bytes、oom |
| B5 | polling 是否造成二次放大 | getCommandOutput_latency、peak_rss |
| B6 | chunk 形态是否影响峰值 | chunk_count、avg_chunk_size、peak_rss |
| B7 | 已读输出是否释放 | stable_rss、retained_bytes_per_session |
| R1 | 路径扫描真实 workload 是否命中 | peak_rss、delta_rss_per_output_byte |
| R2 | 日志流真实 workload 是否撑爆 | oom、ws_abnormal_close_1006 |
Baseline:链路与低输出
模拟目标:普通低输出命令。
实验设置:foreground,低 stdout/stderr,确认 gateway、agent、device、server 链路健康。
成功判据:agent 完成,lh connect RSS 基本不变,无 503/504。
B0/B1/B2:单 foreground stdout burst
模拟目标:单个命令一次性产生大量 stdout,类似模型误跑高输出命令。
实验设置:
- B0:约 1MB stdout。
- B1:约 8MB stdout。
- B2:约 31MB stdout。
- 对 B2 分别测试 unrestricted、old-space 96MB、old-space 64MB。
成功判据:记录 RSS 增量、是否 GC 后回落、是否 OOM。
B3:单 background 输出保留
模拟目标:agent 启动后台任务后不立即读取输出,但任务仍持续产生 stdout。
实验设置:
- old-space 96MB。
1 x 20MBstdout,run_in_background=true。- 输出结束后 sleep,保持 shell session 存活。
- 不调用
getCommandOutput。
成功判据:runCommand 已返回 shell_id,但 lh connect RSS 随输出增长。
B4:多 background 并发输出
模拟目标:agent 并发启动多个后台 shell,每个 shell 都输出中等体量内容。
实验设置:
- old-space 96MB。
- 依次尝试
2 x 20MB、3 x 20MB、4 x 20MB。 - 每个命令输出后 sleep,保持 shell session 存活。
- 优先要求 agent 并发发起多个
runCommand;如果 agent 串行,则仍可观察多个 background session 累积保留。
成功判据:记录每组 RSS、是否出现 503/504、是否 OOM。
B5:background 后 polling getCommandOutput
模拟目标:后台任务输出已积累,agent 再轮询读取输出。
实验设置:
- 基于 B4 中仍存活的 shell。
- 对每个
shell_id调用getCommandOutput。 - 观察
slice().join('')带来的瞬时 RSS/GC/响应尖峰。
成功判据:polling 时 RSS 是否明显跳涨,是否触发 503/504/OOM。
C2:stderr 并发风暴
模拟目标:类似 find / 权限错误风暴,大量 stderr 而非 stdout。
实验设置:
- old-space 96MB。
2 x 20MB stderr,再按稳定性升级到4 x 20MB stderr。- background,输出后 sleep。
成功判据:stderr 路径是否与 stdout 有相同内存风险。
B6:小 chunk 多行 vs 大 chunk 少行
模拟目标:同样总字节数下,小 chunk/多行输出导致更高 string array 与 join 成本。
实验设置:
- old-space 96MB。
- 大 chunk:约 30MB,总行数少,每次写较大块。
- 小 chunk:约 30MB,几十万短行。
成功判据:同字节量下,小 chunk 是否带来更高 RSS 或更慢响应。
B7:已读输出是否释放
模拟目标:验证 getCommandOutput 后旧输出是否仍驻留。
实验设置:
- 跑
2 x 20MBbackground。 - polling 读取输出。
- 再跑一个 tiny command 验证可用性。
killCommand清理 shell。- 比较 kill 前后 RSS。
成功判据:若 polling 后 RSS 不降,kill 后才有机会回落,说明读取输出不释放内部 buffer。
R1/R2:真实 workload 形态补充
模拟目标:用更接近真实世界的命令形态补足 synthetic workload fidelity。
R1:路径扫描输出。
timeout 180s sh -c 'while :; do find /usr /home/cy948/workspace/github -maxdepth 8 -type f -print 2>&1; done | head -c 20971520; sleep 120'
R2:日志流持续输出。
timeout 180s sh -c 'yes LOG_STREAM_LINE_abcdefghijklmnopqrstuvwxyz0123456789 | head -c 20971520; sleep 120'
共同设置:
NODE_OPTIONS=--max-old-space-size=96。- 四个
runCommand并发发起。 - 均设置
run_in_background=true。 - 每个命令输出约 20MB 后 sleep,保持 session 存活。
成功判据:
- R1/R2 是否进入同一条 stdout/stderr pipe 管理路径。
lh connectRSS 是否升高。- R2 是否能像 B4-4 一样触发 V8 OOM。
- gateway 是否出现 WebSocket abnormal close 或后续 503/504。
D1:process tree 生命周期复现
模拟目标:验证 killCommand 是否能清理 shell 启动的子孙进程。
复现命令:
timeout 180s sh -c 'while :; do find /usr /home/cy948/workspace/github -maxdepth 8 -type f -print 2>&1; done | head -c 20971520; sleep 120'
复现步骤:
- 用
runCommandbackground 启动上面的命令,记录返回的shell_id。 - 调用
killCommand,传入该shell_id。 - 观察
killCommand返回{"success": true}。 - 在宿主机检查进程树:
pgrep -af 'timeout 180s sh -c|find /usr /home/cy948/workspace/github|maxdepth 8'
ps -o pid,ppid,pgid,sid,stat,etime,args -p <pid-list>
预期风险:
- 如果只杀直接 child,
timeout/ nestedsh/ pipeline 中的子孙进程可能继续存在。 lh connect可能已经认为 shell session 已经退出或已被 kill,但 OS 进程仍在运行。- 后续需要按 process group 或 process tree 维度清理,而不是只调用 direct child
process.kill()。
详细记录已拆到 lh-shell-process-lifecycle-experiment。后续 IO buffer 实验只把它作为关联风险,不作为本问题主线。
已完成结果
Baseline 0:低输出命令链路
时间:2026-06-10 11:35 左右。
命令:
bash -lc 'for i in 1 2 3 4 5; do find . -maxdepth 4 -type f >/dev/null 2>/dev/null; sleep 1; done; echo CASE_A_DONE'
结果:
| 指标 | 值 |
|---|---|
lh connect PID | 1761569 |
| shell_id | sh-4 |
| exit_code | 0 |
| stdout | CASE_A_DONE |
| stderr | 空 |
| RSS | 约 123324KB -> 123364KB |
| agent | 正常 finished |
结论:低输出慢命令不会拖住父进程。
B0/B1/B2:单 foreground stdout burst
| Case | 输出量 | heap 设置 | RSS 变化 | 结果 |
|---|---|---|---|---|
| B0 | 约 1,006,890 bytes | 默认 | 125980KB -> 125992KB | 正常 |
| B1 | 约 7,892,890 bytes | 默认 | 126036KB -> 142856KB | 正常,未快速回落 |
| B2 | 约 31,156,890 bytes | 默认 | 约 143MB -> 211MB | 正常,RSS 明显升高 |
| B2-v8-96 | 约 31,156,890 bytes | old-space 96MB | 峰值约 157952KB,后约 148640KB | 正常,GC 后回落 |
| B2-v8-64 | 约 31,156,890 bytes | old-space 64MB | 触发 V8 OOM | lh connect 崩溃,agent 侧 503 |
B2-v8-64 关键日志:
FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory
[1] 1779531 IOT instruction (core dumped)
Device tool call failed (HTTP 503)
结论:单个 31MB stdout burst 已足以在 64MB old-space 下打爆 lh connect,说明压力主要来自 V8 heap 中的 JS string/object 保留与拼接。
实验结果
B3:单 background 输出保留
时间:2026-06-10 12:34。
设置:
lh connectPID:1794490。NODE_OPTIONS=--max-old-space-size=96。- stdout 约 20MB,
run_in_background=true。 - 命令输出后
setTimeout保持 session 存活。
命令:
timeout 180s node -e 'const chunk="x".repeat(1024); for (let i=0;i<20480;i++) process.stdout.write(chunk+"\n"); setTimeout(()=>{},120000)'
结果:
| 指标 | 值 |
|---|---|
| shell_id | sh-1 |
| agent 行为 | 只调用 runCommand,未调用 getCommandOutput / killCommand |
| RSS baseline | 约 121260KB |
| RSS peak | 约 136272KB |
| RSS stable | 约 124060KB |
| child | shell 仍存活 |
| agent | 正常 finished |
结论:background 返回后,lh connect 仍会继续处理和保留 stdout;20MB 单任务在 96MB old-space 下未崩溃,但可见短时 RSS 抬升和 GC 回落。
B4:多 background 并发输出
B4-2:2 x 20MB stdout
时间:2026-06-10 12:36。
设置:
lh connectPID:1796012。NODE_OPTIONS=--max-old-space-size=96。- 两个
runCommand在同一 assistant step 发起。 - 两个命令均为 background,输出后 sleep 保持 session 存活。
结果:
| 指标 | 值 |
|---|---|
| shell_id | sh-1, sh-2 |
| RSS baseline | 约 126124KB |
| RSS peak | 约 174156KB |
| RSS stable | 约 159956KB |
| child | 两个 shell 均存活 |
| agent | 正常 finished,无 503/504 |
结论:2 x 20MB background stdout 在 96MB old-space 下未崩溃,但稳定 RSS 比单 background 明显更高,说明多个 shell session 的输出保留会叠加。
B4-3:3 x 20MB stdout
时间:2026-06-10 12:39。
设置:
lh connectPID:1797643。NODE_OPTIONS=--max-old-space-size=96。- 三个
runCommand在同一 assistant step 发起。 - 三个命令均为 background,输出后 sleep 保持 session 存活。
结果:
| 指标 | 值 |
|---|---|
| shell_id | sh-1, sh-2, sh-3 |
| RSS baseline | 约 126396KB |
| RSS peak | 约 186724KB |
| RSS stable | 约 182512KB |
| agent | 正常 finished,无 503/504 |
结论:3 x 20MB background stdout 仍未崩溃,但稳定 RSS 已接近 183MB;相比 2 x 20MB,增量继续叠加。
B4-4:4 x 20MB stdout
时间:2026-06-10 12:43。
设置:
lh connectPID:1800181。NODE_OPTIONS=--max-old-space-size=96。- 四个
runCommand在同一 assistant step 发起。 - 四个命令均为 background,输出后 sleep 保持 session 存活。
结果:
| 指标 | 值 |
|---|---|
| shell_id | sh-1, sh-2, sh-3, sh-4 |
| RSS baseline | 约 125948KB |
| 外部采样最后可见 RSS | 约 121756KB |
| 崩溃时间 | 约 12:43:50 |
| agent | 四个 runCommand 均先返回 OK,agent finished |
| connect | V8 OOM,进程退出 |
关键日志:
FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory
StringBytes::Encode
[1] 1800181 IOT instruction (core dumped)
结论:4 x 20MB background stdout 在 96MB old-space 下足以打爆 lh connect。崩溃栈包含 StringBytes::Encode,说明压力发生在 child stdout bytes 转 JS string / V8 heap 分配路径;不是 agent polling 输出导致的二次放大。
B5:background 后 polling
时间:2026-06-10 12:47-12:51。
设置:
lh connectPID:1802331。NODE_OPTIONS=--max-old-space-size=96。- 先启动
2 x 20MB stdoutbackground session。 - 等两个命令退出后,对
sh-1和sh-2并行调用getCommandOutput。 - 不调用
killCommand。
结果:
| 阶段 | RSS |
|---|---|
| baseline | 约 125964KB |
| background 输出 peak | 约 173844KB |
| background 输出后 stable | 约 160060KB |
| polling 后 stable | 约 161252KB |
工具结果:
| shell_id | exit_code | output |
|---|---|---|
sh-1 | 0 | 大量 a,返回侧截断 |
sh-2 | 0 | 大量 b,返回侧截断 |
结论:在 2 x 20MB 下,getCommandOutput 的 slice().join() 没有触发明显 RSS 尖峰或 OOM;本轮主内存压力仍来自 background 输出阶段。B4-4 说明更早的 data chunk 转 string/保留路径已经足以触发 OOM。
C2:stderr 并发风暴
C2-2:2 x 20MB stderr
时间:2026-06-10 12:52。
设置:
lh connectPID:1805258。NODE_OPTIONS=--max-old-space-size=96。- 两个
runCommand在同一 assistant step 发起。 - 两个命令均为 background,写 stderr 后 sleep。
结果:
| 指标 | 值 |
|---|---|
| RSS baseline | 约 125988KB |
| RSS peak | 约 174012KB |
| RSS stable | 约 160424KB |
| agent | 正常 finished,无 503/504 |
结论:2 x 20MB stderr 与 2 x 20MB stdout 曲线接近,说明 stdout/stderr 在当前 buffer 管理上的内存风险基本一致。
C2-4:4 x 20MB stderr
时间:2026-06-10 12:55。
设置:
lh connectPID:1807654。NODE_OPTIONS=--max-old-space-size=96。- 四个
runCommand在同一 assistant step 发起。 - 四个命令均为 background,写 stderr 后 sleep。
结果:
| 指标 | 值 |
|---|---|
| shell_id | sh-1, sh-2, sh-3, sh-4 |
| RSS baseline | 约 126260KB |
| 外部采样最后可见 RSS | 约 198244KB |
| 崩溃时间 | 约 12:56:08 |
| agent | 四个 runCommand 均先返回 OK,agent finished |
| connect | V8 OOM,进程退出 |
关键日志:
FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory
StringBytes::Encode
[1] 1807654 IOT instruction (core dumped)
结论:stderr 四并发与 stdout 四并发一样能打爆 lh connect,且栈同样指向 bytes 转 JS string。find / 这类 stderr 权限错误风暴具备同类风险。
B6:小 chunk 多行 vs 大 chunk 少行
时间:2026-06-10 12:59-13:03。
设置:
NODE_OPTIONS=--max-old-space-size=96。- 两轮均为单 background stdout,输出后 sleep。
- 每轮 clean slate 重启
lh connect。
结果:
| Case | PID | 输出形态 | RSS baseline | RSS peak | RSS stable | 结果 |
|---|---|---|---|---|---|---|
| B6-large | 1809574 | 约 30MB,480 次 64KB 写入 | 125752KB | 154648KB | 149328KB | 正常 |
| B6-small | 1811091 | 约 30MB,约 314573 条短行 | 125796KB | 168548KB | 150076KB | 正常 |
结论:同样约 30MB,短行/多次写入的峰值 RSS 更高,说明 chunk 数量、行形态或 stream 事件切分会放大瞬时分配压力;稳定 RSS 接近,说明长期保留主要仍由总字节量决定。
B7:已读输出是否释放
时间:2026-06-10 13:04-13:10。
设置:
lh connectPID:1812851。NODE_OPTIONS=--max-old-space-size=96。- 先启动
2 x 20MB stdoutbackground session。 - 等命令退出后,对
sh-1/sh-2调getCommandOutput。 - 再执行 tiny command:
echo B7_TINY_OK。 - 最后对
sh-1/sh-2调killCommand。
结果:
| 阶段 | RSS |
|---|---|
| baseline | 约 125876KB |
| background 输出 peak | 约 173584KB |
| background 输出后 stable | 约 159688KB |
| polling 后 stable | 约 161252KB |
| kill 后 2 分钟 | 约 161376KB |
工具结果:
| 步骤 | 结果 |
|---|---|
getCommandOutput(sh-1) | exit_code=0,返回大量 b |
getCommandOutput(sh-2) | exit_code=0,返回大量 a |
runCommand(echo B7_TINY_OK) | exit_code=0,输出 B7_TINY_OK |
killCommand(sh-1/sh-2) | 均 success=true |
结论:getCommandOutput 后 RSS 不下降;killCommand 删除 shell session 后,短时间 RSS 仍未回落。这不能证明 JS 对象仍被引用,因为 V8/allocator 可能不立刻还内存给 OS;但可以证明“读过输出”不会带来可观察的进程 RSS 释放。结合代码,已读 chunk 在 stdout[] / stderr[] 中仍保留,直到 session 被清理。
R1:真实路径扫描输出
时间:2026-06-10 13:47-13:48。
设置:
lh connectclean slate,NODE_OPTIONS=--max-old-space-size=96。- 四个 background
runCommand,每个命令输出约 20MB 路径/错误流后 sleep。
命令:
timeout 180s sh -c 'while :; do find /usr /home/cy948/workspace/github -maxdepth 8 -type f -print 2>&1; done | head -c 20971520; sleep 120'
结果:
| 指标 | 值 |
|---|---|
lh connect baseline RSS | 约 126MB |
| R1 后 RSS | 约 208MB |
runCommand 返回 | 四个均 success=true |
lh connect | 未立即 OOM |
结论:真实 find/路径扫描形态能命中同一条输出保留路径,并显著抬高 lh connect RSS。该形态未立即 OOM,可能与输出速度、chunk 形态、系统缓存、路径行长度有关。它适合作为真实 workload 增压 case,但不是最稳定红灯 case。
R2:真实日志流输出
时间:2026-06-10 13:49。
设置:
lh connectclean slate,NODE_OPTIONS=--max-old-space-size=96。- 四个 background
runCommand,每个命令输出约 20MB 日志行后 sleep。
命令:
timeout 180s sh -c 'yes LOG_STREAM_LINE_abcdefghijklmnopqrstuvwxyz0123456789 | head -c 20971520; sleep 120'
结果:
| 指标 | 值 |
|---|---|
runCommand 返回 | 四个均 success=true,shell_id 为 sh-1 至 sh-4 |
lh connect | V8 OOM,进程退出 |
| 崩溃栈 | StringBytes::Encode |
| gateway | 出现 WebSocket abnormal close,code 1006 |
关键日志:
FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory
StringBytes::Encode
结论:日志流类真实 workload 可以稳定复现 B4-4 的输出 OOM 问题。docker logs -f、tail -f、高频 test/build log 等真实场景具备同类风险。
D1:killCommand 未清理 process tree
时间:2026-06-10 13:48。
结论:这是独立于输出 OOM 的第二个问题。当前 killCommand 语义不足以保证清理 shell 启动的 process tree。详细复现、证据和修复方向已拆到 lh-shell-process-lifecycle-experiment。
当前结论草案
- 当前问题更像
lh connect的输出管理问题,不是 OS child process 自身“卡死”。 - background session 是高风险路径,因为它降低了 agent 等待成本,却没有降低
lh connect的输出保留成本。 - 本轮最强触发点是多个 background session 同时输出:
4 x 20MBstdout/stderr 在 96MB old-space 下均可触发 V8 OOM。 - 崩溃栈落在
StringBytes::Encode,说明 bytes 转 JS string 阶段就是高风险点;不需要等到getCommandOutputpolling 才能崩。 getCommandOutput在2 x 20MB下未造成明显额外尖峰,但读取后 RSS 不下降;结合代码,读过的 chunk 仍保留。- 短行/多 chunk 负载比大 chunk 负载有更高瞬时 RSS,真实日志型 workload 可能更危险。
- R2 日志流形态已经证明真实 workload 也能复现 OOM,不只是 synthetic Node 输出。
killCommand存在 process tree 生命周期问题:工具返回 success 不代表子孙进程已全部结束。- 修复方向应优先考虑 bounded buffer、按 byte 计量、读后释放或落盘、输出 preview 与完整日志分离。
- 进程生命周期修复方向应考虑 process group 或 process tree kill,避免只杀 direct child。
改进方案草案
设计原则
- 父进程不能把 child process 的完整 stdout/stderr 当成长期 JS string 状态保存。
- 内存上界应由配置决定,而不是由命令历史输出总量决定。
- output preview 和完整日志是两种产品语义:preview 适合返回给 agent,完整日志更适合落盘或按需读取。
- 截断、丢弃、落盘都必须有可观测元数据,不能静默吞输出。
- stdout 和 stderr 应共享同一种 bounded 策略,只在通道标签上区分。
- background session 的输出成本必须和 foreground 一样受控。
最小修复路径
- 在 shell session 内引入 byte 级 retained cap,例如每个 session 每个 stream 只保留最近 N bytes。
data事件里先按 byte 计量和裁剪,再进入长期 retained buffer,避免无界data.toString()后 push。getCommandOutput基于游标返回增量 preview,同时返回truncated_or_dropped_bytes、total_stdout_bytes、total_stderr_bytes。- 已读或已丢弃内容不能继续占用 retained buffer。游标语义需要和 ring buffer/drop policy 对齐。
- 对超大输出保留 summary,例如 total bytes、dropped bytes、last N bytes、exit code、是否仍在运行。
更完整路径
- 小输出走内存 ring buffer,保证低成本和低延迟。
- 大输出可选落盘 spool,agent 默认读取 tail/preview,需要完整日志时返回文件引用或专门读取接口。
- 对输出速度过高的 session 增加 backpressure 或 hard cap policy,避免 connect 在 bytes 到 string 转换阶段被打爆。
- 增加 per-session 和 global budget,避免多 background session 线性叠加。
- 给 gateway/agent 侧暴露清晰错误:输出已截断、输出过大、命令仍在运行、命令被 kill。
回归验收
| 验收项 | 修复前 | 修复后目标 |
|---|---|---|
B4 4 x 20MB stdout | old-space 96MB 下 OOM | 不 OOM,RSS 受 cap 控制 |
C2 4 x 20MB stderr | old-space 96MB 下 OOM | 不 OOM,RSS 受 cap 控制 |
R2 4 x 20MB log stream | OOM,gateway 1006 | 不 OOM,无 gateway 1006 |
| B5 polling | 返回侧截断,但内部旧 chunk 保留 | polling 不依赖完整历史 join |
| B7 已读输出 | 读取后 RSS 无可见下降 | retained buffer 不随已读历史增长 |
| B6 小 chunk | 峰值更高 | chunk 数量不造成无界对象保留 |
后续执行顺序
- 先修 IO buffer:bounded retained buffer、byte stats、输出截断语义、回归测试。
- 再修 process lifecycle:process group 或 process tree kill、cleanupAll 语义、残留进程测试。
- 最后做横向借鉴:Claude Code 的落盘输出和 tail preview,Codex 的 byte chunk、seq/cursor、bounded replay。
- 对每一轮修复都跑 Baseline、B4、C2、R2。Baseline 保证正常低输出不回归,后三个 case 保证高输出不再打爆 connect。
TODO
- 全部实验跑完并写入本笔记后,使用
../lobe-search-eval脚本里的 notify 通知用户。 - IO buffer 修复:把 stdout/stderr 长期保留改成 bounded retained buffer。
- IO buffer 修复:补充 total bytes、retained bytes、dropped bytes、truncated 标记。
- IO buffer 验证:用 B4/C2/R2 作为红灯回归 case。
- IO buffer 验证:用 Baseline/B0/B1 确认普通低输出命令不回归。
- Lifecycle 旁线:后续修复
killCommand/cleanupAll的 process tree 生命周期问题,详见 lh-shell-process-lifecycle-experiment。