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 connect RSS。
  • NODE_OPTIONS=--max-old-space-size=64 下,约 31MB stdout burst 已触发 V8 OOM。
  • NODE_OPTIONS=--max-old-space-size=96 下,4 x 20MB background stdout/stderr 均可触发 V8 OOM。
  • getCommandOutput2 x 20MB 下未造成明显额外尖峰;更早的 bytes 转 JS string 阶段已经足以崩溃。

叙事线

一句话版本:这不是 shell 子进程天然卡死的问题,而是 lh connect 把无界 shell output 纳入 Node/V8 heap 长期管理后,父进程被输出流量拖垮的问题。

这条问题线按七步推进:

  1. 问题发现:lh connect 在 Local System 高输出场景下会失去响应,agent 侧表现为 503/504,gateway 可能看到 WebSocket abnormal close。
  2. 理论假设:shell stdout/stderr 是无界 stream。当前 lh connect 把 stream 转成 JS string 并长期保存在 stdout[] / stderr[],导致内存随输出字节数线性增长。
  3. 指标定义:不用 OOM 0/1 单点判断,而是同时观察输入压力、管理成本、响应性、背压、生存性。
  4. Synthetic case:用 B2/B4/C2/B5/B6/B7 固定输出量、并发数、stdout/stderr、chunk 形态、polling 行为。
  5. 真实场景 case:用 R1 路径扫描和 R2 日志流验证机制不是 synthetic 独有。
  6. 改进目标:shell 输出不应成为 lh connect 的长期内存占用。lh connect 只承担 bounded 管理成本,输出正文应进入 bounded preview、ring buffer、drop/cap policy 或落盘路径。
  7. 回归验证:修复后重复 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 -Rrg --hiddenR1、C2
权限错误风暴find /、扫描 /proc/sys、系统目录C2、R1
日志流docker logs -fkubectl logs -ftail -fjournalctl -fR2、B4
测试/构建日志pytest -vvpnpm testmake V=1tsc --traceResolutionB4、B6、R2
大文件 dumpcat large.logjq . huge.jsonpg_dumptar -tvfB2、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 外内存:BufferArrayBuffer、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():说明 rssheapTotalheapUsedexternalarrayBuffers 的含义。
  • 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。

图片参考:

对象放在哪里

可以粗略把 Node 进程内存分成四层:

区域典型内容是否受 V8 GC 管理对本问题的意义
Stack当前调用栈、局部变量槽位、返回地址、少量引用否,主要由 OS/thread 管理不是本轮 OOM 主因
V8 heapJS object、Array、Function、Closure、String、Promise、Map 等stdout[]stderr[]、JS string 都在这里
External / ArrayBufferBuffer 背后的 bytes、ArrayBuffer backing store、C++ 对象部分由 JS wrapper 关联统计,但 bytes 常在 heap 外child stdout/stderr 进入 Node 时通常先是 Buffer
Native / OSlibuv handle、动态库、线程栈、mmap、malloc arena、文件描述符相关结构解释为什么 RSS 大于 heapUsed

对当前 lh connect 问题,关键路径是:

  1. OS pipe 把 child stdout/stderr bytes 交给 Node。
  2. Node stream 的 data 事件通常给出 Buffer。这部分 bytes 可体现在 external / arrayBuffers
  3. 当前代码调用 data.toString(),这会创建 JS string。JS string 属于 V8 heap。
  4. string 被 push 到 stdout[] / stderr[],数组和 string 之间保持强引用。
  5. 只要 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 limitheap_size_limitheap / 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=512m512 MiB约 259 MiB约 50.6%
Docker Node 22 --memory=1024m1024 MiB约 524 MiB约 51.2%
Docker Node 22 --memory=2048m2048 MiB约 1048 MiB约 51.2%

解释:

  • Docker 不加 --memory 时,容器内 Node 看到的是宿主机约 22GiB 内存,默认 heap 约 4GiB。
  • Docker 加 --memory 后,cgroup memory.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 内。
  • getCommandOutputlastReadStdout/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_secproducer 输出速率
stdout_bytes / stderr_bytesstdout 与 stderr 分流
chunk_countstream data 事件数量
avg_chunk_size平均 chunk 大小

这些指标帮助区分慢速 20MB、瞬时 20MB、短行多 chunk、长块少 chunk。

管理成本

指标含义修复后目标
delta_rss_per_output_byteΔRSS / output_bytes_totalcap 后接近 0
delta_heap_per_output_byteΔheapUsed / output_bytes_totalcap 后接近 0
peak_rsslh 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_p95Node event loop 延迟有上界
time_to_drain_after_exit子进程退出后 drain 完成时间有上界
getCommandOutput_latencypolling 读取延迟不随完整历史线性增长

Node 层不易直接读取 OS pipe occupancy。必要时可用 strace 或 eBPF 观察 child write(1, ...) 是否阻塞。

生存性

指标含义修复后目标
oomlh connect 是否 V8 OOM必须为 0
ws_abnormal_close_1006gateway 是否看到异常断连必须为 0
gateway_503_504agent 链路是否因 socket 消失失败必须为 0

ws_abnormal_close_1006 是辅助信号,不是 OOM 必要条件。主判据仍是 lh connect 是否存活,以及内存是否有上界。

生命周期指标

orphan_process_countkill_completion_time_ms、process tree cleanup 属于独立问题线,详见 lh-shell-process-lifecycle-experiment。本笔记只保留关联,不把它作为 IO buffer 修复的主判据。

实验环境

工作目录/home/cy948/workspace/github/lobe-search-agent-eval
tmux sessionlh-shell-exp
connect panelh-shell-exp:0.0
agent panelh-shell-exp:0.1
app serverhttp://localhost:3210
device gatewayhttp://localhost:8787
agent gatewayhttp://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_latencyoom=0
B2单次 burst 能否撑爆 V8 heappeak_rssoom
B3background 是否仍保留输出stable_rssretained_bytes_per_session
B4多 session stdout 是否线性叠加delta_rss_per_output_byteoom
C2stderr 是否有同类风险stderr_bytesoom
B5polling 是否造成二次放大getCommandOutput_latencypeak_rss
B6chunk 形态是否影响峰值chunk_countavg_chunk_sizepeak_rss
B7已读输出是否释放stable_rssretained_bytes_per_session
R1路径扫描真实 workload 是否命中peak_rssdelta_rss_per_output_byte
R2日志流真实 workload 是否撑爆oomws_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 20MB stdout,run_in_background=true
  • 输出结束后 sleep,保持 shell session 存活。
  • 不调用 getCommandOutput

成功判据:runCommand 已返回 shell_id,但 lh connect RSS 随输出增长。

B4:多 background 并发输出

模拟目标:agent 并发启动多个后台 shell,每个 shell 都输出中等体量内容。

实验设置:

  • old-space 96MB。
  • 依次尝试 2 x 20MB3 x 20MB4 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 20MB background。
  • 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 connect RSS 是否升高。
  • 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'

复现步骤:

  1. runCommand background 启动上面的命令,记录返回的 shell_id
  2. 调用 killCommand,传入该 shell_id
  3. 观察 killCommand 返回 {"success": true}
  4. 在宿主机检查进程树:
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 / nested sh / 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 PID1761569
shell_idsh-4
exit_code0
stdoutCASE_A_DONE
stderr
RSS123324KB -> 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 bytesold-space 96MB峰值约 157952KB,后约 148640KB正常,GC 后回落
B2-v8-64约 31,156,890 bytesold-space 64MB触发 V8 OOMlh 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 connect PID: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_idsh-1
agent 行为只调用 runCommand,未调用 getCommandOutput / killCommand
RSS baseline121260KB
RSS peak136272KB
RSS stable124060KB
childshell 仍存活
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 connect PID:1796012
  • NODE_OPTIONS=--max-old-space-size=96
  • 两个 runCommand 在同一 assistant step 发起。
  • 两个命令均为 background,输出后 sleep 保持 session 存活。

结果:

指标
shell_idsh-1, sh-2
RSS baseline126124KB
RSS peak174156KB
RSS stable159956KB
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 connect PID:1797643
  • NODE_OPTIONS=--max-old-space-size=96
  • 三个 runCommand 在同一 assistant step 发起。
  • 三个命令均为 background,输出后 sleep 保持 session 存活。

结果:

指标
shell_idsh-1, sh-2, sh-3
RSS baseline126396KB
RSS peak186724KB
RSS stable182512KB
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 connect PID:1800181
  • NODE_OPTIONS=--max-old-space-size=96
  • 四个 runCommand 在同一 assistant step 发起。
  • 四个命令均为 background,输出后 sleep 保持 session 存活。

结果:

指标
shell_idsh-1, sh-2, sh-3, sh-4
RSS baseline125948KB
外部采样最后可见 RSS121756KB
崩溃时间12:43:50
agent四个 runCommand 均先返回 OK,agent finished
connectV8 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 connect PID:1802331
  • NODE_OPTIONS=--max-old-space-size=96
  • 先启动 2 x 20MB stdout background session。
  • 等两个命令退出后,对 sh-1sh-2 并行调用 getCommandOutput
  • 不调用 killCommand

结果:

阶段RSS
baseline125964KB
background 输出 peak173844KB
background 输出后 stable160060KB
polling 后 stable161252KB

工具结果:

shell_idexit_codeoutput
sh-10大量 a,返回侧截断
sh-20大量 b,返回侧截断

结论:在 2 x 20MB 下,getCommandOutputslice().join() 没有触发明显 RSS 尖峰或 OOM;本轮主内存压力仍来自 background 输出阶段。B4-4 说明更早的 data chunk 转 string/保留路径已经足以触发 OOM。

C2:stderr 并发风暴

C2-2:2 x 20MB stderr

时间:2026-06-10 12:52。

设置:

  • lh connect PID:1805258
  • NODE_OPTIONS=--max-old-space-size=96
  • 两个 runCommand 在同一 assistant step 发起。
  • 两个命令均为 background,写 stderr 后 sleep。

结果:

指标
RSS baseline125988KB
RSS peak174012KB
RSS stable160424KB
agent正常 finished,无 503/504

结论:2 x 20MB stderr2 x 20MB stdout 曲线接近,说明 stdout/stderr 在当前 buffer 管理上的内存风险基本一致。

C2-4:4 x 20MB stderr

时间:2026-06-10 12:55。

设置:

  • lh connect PID:1807654
  • NODE_OPTIONS=--max-old-space-size=96
  • 四个 runCommand 在同一 assistant step 发起。
  • 四个命令均为 background,写 stderr 后 sleep。

结果:

指标
shell_idsh-1, sh-2, sh-3, sh-4
RSS baseline126260KB
外部采样最后可见 RSS198244KB
崩溃时间12:56:08
agent四个 runCommand 均先返回 OK,agent finished
connectV8 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

结果:

CasePID输出形态RSS baselineRSS peakRSS stable结果
B6-large1809574约 30MB,480 次 64KB 写入125752KB154648KB149328KB正常
B6-small1811091约 30MB,约 314573 条短行125796KB168548KB150076KB正常

结论:同样约 30MB,短行/多次写入的峰值 RSS 更高,说明 chunk 数量、行形态或 stream 事件切分会放大瞬时分配压力;稳定 RSS 接近,说明长期保留主要仍由总字节量决定。

B7:已读输出是否释放

时间:2026-06-10 13:04-13:10。

设置:

  • lh connect PID:1812851
  • NODE_OPTIONS=--max-old-space-size=96
  • 先启动 2 x 20MB stdout background session。
  • 等命令退出后,对 sh-1 / sh-2getCommandOutput
  • 再执行 tiny command:echo B7_TINY_OK
  • 最后对 sh-1 / sh-2killCommand

结果:

阶段RSS
baseline125876KB
background 输出 peak173584KB
background 输出后 stable159688KB
polling 后 stable161252KB
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 connect clean 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 RSS126MB
R1 后 RSS208MB
runCommand 返回四个均 success=true
lh connect未立即 OOM

结论:真实 find/路径扫描形态能命中同一条输出保留路径,并显著抬高 lh connect RSS。该形态未立即 OOM,可能与输出速度、chunk 形态、系统缓存、路径行长度有关。它适合作为真实 workload 增压 case,但不是最稳定红灯 case。

R2:真实日志流输出

时间:2026-06-10 13:49。

设置:

  • lh connect clean 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-1sh-4
lh connectV8 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 -ftail -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 20MB stdout/stderr 在 96MB old-space 下均可触发 V8 OOM。
  • 崩溃栈落在 StringBytes::Encode,说明 bytes 转 JS string 阶段就是高风险点;不需要等到 getCommandOutput polling 才能崩。
  • getCommandOutput2 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 一样受控。

最小修复路径

  1. 在 shell session 内引入 byte 级 retained cap,例如每个 session 每个 stream 只保留最近 N bytes。
  2. data 事件里先按 byte 计量和裁剪,再进入长期 retained buffer,避免无界 data.toString() 后 push。
  3. getCommandOutput 基于游标返回增量 preview,同时返回 truncated_or_dropped_bytestotal_stdout_bytestotal_stderr_bytes
  4. 已读或已丢弃内容不能继续占用 retained buffer。游标语义需要和 ring buffer/drop policy 对齐。
  5. 对超大输出保留 summary,例如 total bytes、dropped bytes、last N bytes、exit code、是否仍在运行。

更完整路径

  1. 小输出走内存 ring buffer,保证低成本和低延迟。
  2. 大输出可选落盘 spool,agent 默认读取 tail/preview,需要完整日志时返回文件引用或专门读取接口。
  3. 对输出速度过高的 session 增加 backpressure 或 hard cap policy,避免 connect 在 bytes 到 string 转换阶段被打爆。
  4. 增加 per-session 和 global budget,避免多 background session 线性叠加。
  5. 给 gateway/agent 侧暴露清晰错误:输出已截断、输出过大、命令仍在运行、命令被 kill。

回归验收

验收项修复前修复后目标
B4 4 x 20MB stdoutold-space 96MB 下 OOM不 OOM,RSS 受 cap 控制
C2 4 x 20MB stderrold-space 96MB 下 OOM不 OOM,RSS 受 cap 控制
R2 4 x 20MB log streamOOM,gateway 1006不 OOM,无 gateway 1006
B5 polling返回侧截断,但内部旧 chunk 保留polling 不依赖完整历史 join
B7 已读输出读取后 RSS 无可见下降retained buffer 不随已读历史增长
B6 小 chunk峰值更高chunk 数量不造成无界对象保留

后续执行顺序

  1. 先修 IO buffer:bounded retained buffer、byte stats、输出截断语义、回归测试。
  2. 再修 process lifecycle:process group 或 process tree kill、cleanupAll 语义、残留进程测试。
  3. 最后做横向借鉴:Claude Code 的落盘输出和 tail preview,Codex 的 byte chunk、seq/cursor、bounded replay。
  4. 对每一轮修复都跑 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