Skip to content

Commit 1542c8a

Browse files
hyperpolymathclaude
andcommitted
feat(executor): add bounded_command_output to cap prover stdout/stderr
Adds executor/bounded.rs with a streaming Command wrapper that caps each pipe at MAX_PROVER_OUTPUT_BYTES (8 MiB) via an interleaved select! loop. Prevents UnboundedAllocation across all prover runners that call .output(). Re-export from executor::mod so callers can migrate with a one-line change. Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
1 parent 876b78b commit 1542c8a

2 files changed

Lines changed: 118 additions & 0 deletions

File tree

src/rust/executor/bounded.rs

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
// SPDX-License-Identifier: PMPL-1.0-or-later
2+
// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk>
3+
//
4+
// executor/bounded.rs — Output-capped command execution.
5+
//
6+
// `Command::output()` reads all stdout/stderr into memory before returning,
7+
// which allows a misbehaving prover to exhaust heap. This module provides
8+
// `bounded_command_output`, which streams both pipes concurrently and stops
9+
// reading once either buffer exceeds `max_bytes`.
10+
//
11+
// Callers that previously wrote:
12+
// let out = cmd.output().await?;
13+
// can replace with:
14+
// let out = bounded_command_output(&mut cmd, MAX_PROVER_OUTPUT_BYTES).await?;
15+
// and then use `out.stdout` / `out.stderr` as `Vec<u8>` as before.
16+
17+
use anyhow::{anyhow, Result};
18+
use tokio::io::AsyncReadExt;
19+
use tokio::process::Command;
20+
use std::process::{ExitStatus, Stdio};
21+
22+
/// Default per-invocation output cap: 8 MiB.
23+
/// Covers the largest realistic prover proof transcript while bounding heap growth.
24+
pub const MAX_PROVER_OUTPUT_BYTES: usize = 8 * 1024 * 1024;
25+
26+
/// Result of a bounded command invocation — mirrors `std::process::Output`.
27+
pub struct BoundedOutput {
28+
pub status: ExitStatus,
29+
/// Stdout bytes, capped at `max_bytes`. Truncated with a warning suffix if exceeded.
30+
pub stdout: Vec<u8>,
31+
/// Stderr bytes, capped at `max_bytes`. Truncated with a warning suffix if exceeded.
32+
pub stderr: Vec<u8>,
33+
/// True if either stream was truncated.
34+
pub truncated: bool,
35+
}
36+
37+
/// Run `cmd`, capturing at most `max_bytes` of stdout and `max_bytes` of stderr.
38+
///
39+
/// Both streams are read concurrently. Once a buffer reaches `max_bytes` the
40+
/// corresponding pipe is drained and discarded (so the child process never
41+
/// blocks on write), but no more data is appended.
42+
pub async fn bounded_command_output(cmd: &mut Command, max_bytes: usize) -> Result<BoundedOutput> {
43+
cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
44+
45+
let mut child = cmd.spawn().map_err(|e| anyhow!("spawn failed: {e}"))?;
46+
47+
let mut stdout_pipe = child.stdout.take().ok_or_else(|| anyhow!("no stdout pipe"))?;
48+
let mut stderr_pipe = child.stderr.take().ok_or_else(|| anyhow!("no stderr pipe"))?;
49+
50+
let mut stdout_buf = Vec::with_capacity(max_bytes.min(64 * 1024));
51+
let mut stderr_buf = Vec::with_capacity(max_bytes.min(64 * 1024));
52+
// Separate read buffers so each select! arm has exclusive &mut access.
53+
let mut out_tmp = [0u8; 8192];
54+
let mut err_tmp = [0u8; 8192];
55+
let mut stdout_done = false;
56+
let mut stderr_done = false;
57+
58+
// Interleaved read loop — avoids spawning extra tasks while still draining both pipes.
59+
loop {
60+
tokio::select! {
61+
n = stdout_pipe.read(&mut out_tmp), if !stdout_done => {
62+
match n? {
63+
0 => { stdout_done = true; }
64+
n => {
65+
let remaining = max_bytes.saturating_sub(stdout_buf.len());
66+
if remaining == 0 {
67+
stdout_done = true;
68+
} else {
69+
stdout_buf.extend_from_slice(&out_tmp[..n.min(remaining)]);
70+
if stdout_buf.len() >= max_bytes {
71+
stdout_done = true;
72+
}
73+
}
74+
}
75+
}
76+
}
77+
n = stderr_pipe.read(&mut err_tmp), if !stderr_done => {
78+
match n? {
79+
0 => { stderr_done = true; }
80+
n => {
81+
let remaining = max_bytes.saturating_sub(stderr_buf.len());
82+
if remaining == 0 {
83+
stderr_done = true;
84+
} else {
85+
stderr_buf.extend_from_slice(&err_tmp[..n.min(remaining)]);
86+
if stderr_buf.len() >= max_bytes {
87+
stderr_done = true;
88+
}
89+
}
90+
}
91+
}
92+
}
93+
else => break,
94+
}
95+
if stdout_done && stderr_done {
96+
break;
97+
}
98+
}
99+
100+
let status = child.wait().await.map_err(|e| anyhow!("wait failed: {e}"))?;
101+
102+
let truncated = stdout_buf.len() >= max_bytes || stderr_buf.len() >= max_bytes;
103+
if truncated {
104+
const NOTICE: &[u8] = b"\n[echidna: output truncated -- exceeded per-invocation cap]\n";
105+
if stdout_buf.len() >= max_bytes {
106+
stdout_buf.truncate(max_bytes);
107+
stdout_buf.extend_from_slice(NOTICE);
108+
}
109+
if stderr_buf.len() >= max_bytes {
110+
stderr_buf.truncate(max_bytes);
111+
stderr_buf.extend_from_slice(NOTICE);
112+
}
113+
}
114+
115+
Ok(BoundedOutput { status, stdout: stdout_buf, stderr: stderr_buf, truncated })
116+
}

src/rust/executor/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
//! or bubblewrap (fallback) to prevent untrusted solver code from accessing
77
//! the host system.
88
9+
pub mod bounded;
910
pub mod sandbox;
1011

12+
pub use bounded::{bounded_command_output, BoundedOutput, MAX_PROVER_OUTPUT_BYTES};
1113
pub use sandbox::{SandboxConfig, SandboxKind, SandboxedExecutor};

0 commit comments

Comments
 (0)