Summary
The supervisor's shell-out helpers capture the entire stdout/stderr of every child in memory, with no size cap, despite their doc comments saying "bounded output capture":
src/run.rs output_with_input_timeout_observed (rev b8a3b24a68e5b18521a63262629eb25a8dea5dfc, lines 214–217): after the child exits, both tempfiles are rewound and fully read into fresh Vec<u8> buffers via read_to_end.
src/ding/mod.rs lines 431–436: same pattern in its own copy of output_with_timeout.
The tempfile redirection itself is sound (it prevents an escaped descendant from blocking cleanup). The unbounded cost comes entirely from materializing the whole captured output on the heap for every single command invocation.
Impact
Peak RSS of the supervisor process scales 1:1 with child output volume, and concurrent invocations (one reconcile pass fanning out over many tasks) sum. During bursts of churn — mass task restarts where each restart shells out to helpers that can emit large output — this produces transient multi-GiB RSS spikes even though steady-state supervisor RSS is tens of MiB. Under timeout storms there is a second, independent hazard in the same functions: each timed-out child gets a detached thread::spawn(move || { let _ = child.wait(); }) reaper thread (run.rs line 95, ding/mod.rs line 424) with no bound on how many accumulate.
Minimal reproduction
A standalone harness containing a verbatim copy of output_with_input_timeout_observed (plus its two private helpers) at rev b8a3b24, instrumented with /proc/self/status RSS measurement:
https://github.com/schickling-repros/2026-08-st2-unbounded-child-output-capture
Single command: cargo run --release (Linux, needs dd; three pinned crates). Each part runs in its own subprocess because VmHWM is a process-lifetime high-water mark. Measured output (Linux x86_64, rustc 1.97.0):
Part 1 — single call, growing child stdout volume
child MiB RSS before peak RSS
--------- ---------- --------
16 2.4MiB 18.4MiB
64 2.4MiB 66.4MiB
256 2.4MiB 258.4MiB
1024 2.4MiB 1026.4MiB
Part 2 — 8 concurrent captures x 128 MiB each (= 1024 MiB total in flight)
captured 1024 MiB total; RSS before 2.4 MiB -> peak 895.4 MiB (delta 893.0 MiB)
Part 3 — control: identical children, read-back capped at 1 MiB
4 x 256 MiB children; RSS before 2.4 MiB -> peak 3.4 MiB (delta 1.0 MiB)
Part 1 shows peak RSS = baseline + full captured volume for every call. Part 2 shows concurrent calls accumulating to roughly the sum of their outputs. Part 3 shows the control: identical children read back through a 1 MiB-capped reader keep RSS flat, i.e. the tempfile design already supports bounded capture with no behavioral loss beyond the cap itself.
Expected
Capture should be bounded/streaming as the doc comment promises: peak RSS stays roughly flat regardless of child output volume. A small fixed ceiling (ring buffer or Read::take on the rewound tempfile) would preserve all currently consumed behavior while making the memory bound explicit; oversized output could be truncated or spilled to the tempfile path that already exists.
Actual
Every command invocation allocates the full child output in RSS; concurrent invocations stack.
Posted on behalf of @schickling
| field |
value |
agent_identity |
unknown |
agent_persona |
generalist |
agent_supervisor |
unavailable |
agent_tool |
OMP |
agent_tool_version |
18.0.3 |
agent_runtime |
OMP 18.0.3 |
tooling_profile |
dotfiles@f33cd9c-dirty |
Summary
The supervisor's shell-out helpers capture the entire stdout/stderr of every child in memory, with no size cap, despite their doc comments saying "bounded output capture":
src/run.rsoutput_with_input_timeout_observed(revb8a3b24a68e5b18521a63262629eb25a8dea5dfc, lines 214–217): after the child exits, both tempfiles are rewound and fully read into freshVec<u8>buffers viaread_to_end.src/ding/mod.rslines 431–436: same pattern in its own copy ofoutput_with_timeout.The tempfile redirection itself is sound (it prevents an escaped descendant from blocking cleanup). The unbounded cost comes entirely from materializing the whole captured output on the heap for every single command invocation.
Impact
Peak RSS of the supervisor process scales 1:1 with child output volume, and concurrent invocations (one reconcile pass fanning out over many tasks) sum. During bursts of churn — mass task restarts where each restart shells out to helpers that can emit large output — this produces transient multi-GiB RSS spikes even though steady-state supervisor RSS is tens of MiB. Under timeout storms there is a second, independent hazard in the same functions: each timed-out child gets a detached
thread::spawn(move || { let _ = child.wait(); })reaper thread (run.rsline 95,ding/mod.rsline 424) with no bound on how many accumulate.Minimal reproduction
A standalone harness containing a verbatim copy of
output_with_input_timeout_observed(plus its two private helpers) at revb8a3b24, instrumented with/proc/self/statusRSS measurement:https://github.com/schickling-repros/2026-08-st2-unbounded-child-output-capture
Single command:
cargo run --release(Linux, needsdd; three pinned crates). Each part runs in its own subprocess becauseVmHWMis a process-lifetime high-water mark. Measured output (Linux x86_64, rustc 1.97.0):Part 1 shows peak RSS = baseline + full captured volume for every call. Part 2 shows concurrent calls accumulating to roughly the sum of their outputs. Part 3 shows the control: identical children read back through a 1 MiB-capped reader keep RSS flat, i.e. the tempfile design already supports bounded capture with no behavioral loss beyond the cap itself.
Expected
Capture should be bounded/streaming as the doc comment promises: peak RSS stays roughly flat regardless of child output volume. A small fixed ceiling (ring buffer or
Read::takeon the rewound tempfile) would preserve all currently consumed behavior while making the memory bound explicit; oversized output could be truncated or spilled to the tempfile path that already exists.Actual
Every command invocation allocates the full child output in RSS; concurrent invocations stack.
Posted on behalf of @schickling
agent_identityagent_personaagent_supervisoragent_toolagent_tool_versionagent_runtimetooling_profile