Skip to content

fix(run): bound child output capture and reaper threads - #340

Merged
schickling merged 2 commits into
mainfrom
schickling/2026-08-25-issue339
Aug 25, 2026
Merged

fix(run): bound child output capture and reaper threads#340
schickling merged 2 commits into
mainfrom
schickling/2026-08-25-issue339

Conversation

@schickling-assistant

Copy link
Copy Markdown
Contributor

Closes #339

Problem

The supervisor's shell-out helpers (src/run.rs output_with_input_timeout_observed, src/ding/mod.rs output_with_timeout) redirect child stdout/stderr to tempfiles — sound, since files keep an escaped descendant from blocking cleanup and make bounded read-back deadlock-free — then unconditionally rewind both streams and read_to_end them into fresh heap buffers. Peak supervisor RSS scaled 1:1 with child output volume and summed across concurrent calls, despite doc comments claiming "bounded output capture". Nothing bounded it; no spec or invariant ever defined a bound. The same audit surfaced sibling hazards: detached reaper threads accumulated without bound under timeout storms, eval run steps buffered whole child outputs (and full pty peek --full scrollback) in memory before writing log files, and the bash judge piped stdout/stderr it never drained — a deadlock on chatty judges.

Measured on Linux x86_64 with a standalone copy of the exact mechanism (dd-produced child output, per-process VmHWM):

Case Load Peak RSS delta
single call 16 / 64 / 128 MiB child stdout +16.2 / +64.2 / +128.2 MiB
8 concurrent calls 8 × 16 MiB ~+103 MiB
control: tail-capped read-back 8 × 64 MiB children, 1 MiB cap +9 MiB flat

The control shows the tempfile design already supports bounded capture; only the unconditional materialization was at fault.

Goal

Supervisor peak RSS no longer scales with child output volume; worst case is bounded by concurrent calls × 2 × cap regardless of child behavior, with truncation observable and the one genuinely unbounded consumer made explicit.

Decisions

  • Diagnostics capture is capped at 256 KiB per stream, tail-preserving (option A of three). Error messages need the last bytes; the head of a flood is noise. Truncation emits one diagnostic line naming command, stream, kept/total bytes, and cap. Alternatives rejected: disk-spill references for oversized diagnostics (spill-file lifecycle for no demonstrated consumer) and streaming every shell-out to log files exec-backend style (changes every error path; far larger diff).
  • Payload capture stays complete and explicit. pty list --json parses structured output that must be whole, so it moves to a distinctly named output_full_stdout_with_timeout whose doc comment states stdout is intentionally uncapped and why. Bounded-tail remains the default so an uncapped read is always a visible choice at the call site.
  • One shared reaper thread drains killed children over a channel (reap_detached), replacing per-timeout detached threads in both helpers.
  • Eval steps and log dumps stream to their files directly (Stdio::from(File) + std::io::copy), preserving byte-identical .runs/<id>.{out,err,exit} and combined-log semantics including retry-overwrites-with-final-attempt; the bash judge switches undrained pipes to null stdio (only its exit status was ever consumed).
  • Consumer inventory justified the split: only pty list --json needs whole stdout; pty peek consumes terminal screens (far below the cap); all other sites use stderr only trimmed inside error strings.

Decision record: docs/vrs/.decisions/0007-child-output-capture-is-bounded-and-tail-preserving.md.

Verification

  • New tests: tail-preservation under truncation (start marker dropped, end marker kept, len == cap), full-stdout variant returns output larger than the cap intact, shared reaper clears an observed zombie. Eval streaming round-trips 64 KiB stdout + 64 KiB stderr byte-identically into .out, .err, .exit, and the combined log with correct ordering.
  • All pre-existing helper/judge tests pass unchanged.
  • Full lib suite green locally (cargo test --offline --lib: 467 passed, 0 failed).
  • Truncation diagnostic behaviorally verified: st2: truncated stdout capture of \./flood`: keeping last 262144 of 1048584 bytes (cap 262144)`.

Complexity

No new dependencies or module boundaries. Adds one shared reaper thread and two small helpers (read_bounded_tail, output_full_stdout_with_timeout) at the lowest common point; ding's diverged duplicate now reuses them instead of carrying a third copy of read-back logic.

Concerns

  • Diagnostics for oversized children lose their head. Error messages built from stderr keep the tail, where failure text lives.
  • The tempfile redirection is now load-bearing beyond cleanup: it is what makes bounded read-back deadlock-free. A future move back to pipes must preserve a byte bound.
  • One pre-existing timing-sensitive test (persistent_advisory_warnings_surface_once_not_per_pass, asserts ≥3 reconcile passes within 300 ms) flaked once under full-suite parallel load during development; it uses a fake runner outside this change's path and passed in isolation, on rerun, and in CI-style runs.

Follow-ups

  • src/opencode_session.rs carries pre-existing unused-variable warnings in its test fixture (untouched here).

References

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@ffce621

The shell-out helpers redirected child stdout/stderr to tempfiles but then
read both streams fully into heap Vecs, so peak RSS scaled 1:1 with child
output volume and summed across concurrent calls (measured: 128 MiB child
output -> +128 MiB RSS; 8 concurrent captures sum). The doc comments claimed
bounded capture; nothing bounded it.

- Diagnostics capture is now capped at 256 KiB per stream, tail-preserving
  (the last bytes are what error messages need), with a diagnostic line on
  truncation.
- `pty list --json` moves to an explicit full-stdout variant: parsed
  structured output must be whole; stdout is intentionally uncapped there.
- One shared reaper thread replaces detached thread-per-timed-out-child,
  which accumulated without bound under timeout storms.
- Eval run steps stream child output straight into their capture files and
  combined logs instead of buffering whole outputs; agent log dumps stream
  scrollback to file without materializing it; the bash judge uses null
  stdio instead of undrained pipes that could deadlock on chatty judges.

Decision record: docs/vrs/.decisions/0007.

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@ffce621
Decision 0007 landed with the fix, but the requirement and spec did not
state the bound. R32's reaper clause already covered teardown ownership;
R34 now requires capture memory to be cap-bounded, tail-preserving, and
observable, with unbounded reads opt-in at the call site. The supervision
spec gains a child-process execution section pointing at D0007.

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@ffce621
@schickling-assistant

Copy link
Copy Markdown
Contributor Author

End-to-end verification against the real supervisor loop (not just unit tests), pre-fix vs post-fix binaries built from 55efd31 (base) and this branch:

Setup: empty catalog, st2 up --catalog … --host testhost --interval 1 looping, with a fake pty on PATH whose list --json returns valid JSON on stdout and 200 MiB of zeros on stderr per invocation — i.e., every reconcile pass hands the supervisor a chatty child.

Binary Peak RSS (VmHWM after 8 passes)
base (55efd31) 210 MB
this branch 7.4 MB

On this branch each pass emits the truncation diagnostic (st2: truncated stderr capture of \pty`: keeping last 262144 of 209715200 bytes (cap 262144)`) and the loop stays healthy: JSON parses, passes proceed, clean shutdown. The pre-fix binary buffered all 200 MiB per pass while its resident set stayed at ~6.6 MB between passes — exactly the transient-spike shape reported in #339.

The uncapped path was also exercised end-to-end by accident in the first run: noise on stdout flows through the intentionally uncapped output_full_stdout_with_timeout and fails JSON parsing with "trailing characters" — complete data reaches the parser, as designed.

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@ffce621

@schickling

Copy link
Copy Markdown
Contributor

@codex

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 1d5bee6e8f

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/eval_run.rs
Comment on lines +972 to +974
cmd.stdout(Stdio::from(out_file))
.stderr(Stdio::from(err_file));
Ok(cmd.status()?.code().unwrap_or(-1))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep run-step stdin disconnected

When st2 eval is launched from an interactive terminal and a run step reads stdin, replacing Command::output() with Command::status() makes the shell inherit st2's stdin instead of receiving immediate EOF. Such a step can now consume the operator's input or hang the evaluation indefinitely, since run steps have no timeout. Set stdin explicitly to Stdio::null() while retaining the new file-backed stdout/stderr capture.

Useful? React with 👍 / 👎.

@schickling
schickling merged commit 1da5809 into main Aug 25, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants