affaan-m/ECC · error · anyhow::Error
Child stderr was not piped
Error message
Child stderr was not piped
What it means
Raised by capture_command_output in ecc2/src/session/runtime.rs:166 after spawning a child process whose stderr handle is missing. The function explicitly configures `.stderr(Stdio::piped())` before spawn, so a None stderr indicates the spawned binary or platform runtime ignored/rejected the piped inheritance, or the Command was preconfigured with a different Stdio before being passed in. The child is killed and reaped before bailing to avoid zombies.
Source
Thrown at ecc2/src/session/runtime.rs:166
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.with_context(|| format!("Failed to start process for session {}", session_id))?;
let stdout = match child.stdout.take() {
Some(stdout) => stdout,
None => {
let _ = child.kill().await;
let _ = child.wait().await;
anyhow::bail!("Child stdout was not piped");
}
};
let stderr = match child.stderr.take() {
Some(stderr) => stderr,
None => {
let _ = child.kill().await;
let _ = child.wait().await;
anyhow::bail!("Child stderr was not piped");
}
};
let pid = child
.id()
.ok_or_else(|| anyhow::anyhow!("Spawned process did not expose a process id"))?;
db_writer.update_pid(Some(pid)).await?;
db_writer.update_state(SessionState::Running).await?;
db_writer.touch_heartbeat().await?;
let heartbeat_writer = db_writer.clone();
let heartbeat_task = tokio::spawn(async move {
let mut ticker = time::interval(heartbeat_interval);
ticker.set_missed_tick_behavior(MissedTickBehavior::Delay);
loop {
ticker.tick().await;
if heartbeat_writer.touch_heartbeat().await.is_err() {
break;View on GitHub (pinned to 01e15490f0)
Solutions
- Inspect the Command passed into capture_command_output and ensure no caller has separately invoked .stderr() on it (the function sets Stdio::piped() itself, so external stderr configuration is the usual culprit).
- Run the spawn in isolation with a trivial command (e.g. /bin/true) to determine whether the issue is process-specific or environmental; if trivial commands succeed, the target binary is resetting fd 2.
- Check the container/sandbox configuration (systemd-nspawn, firejail, Bubblewrap) for fd inheritance restrictions on stderr and adjust the descriptor inheritance policy.
- Upgrade tokio to a version whose process implementation reliably surfaces piped handles on the target platform.
Example fix
// before: caller pre-configures stderr, masking the piped setting
let mut cmd = Command::new("claude");
cmd.stderr(Stdio::inherit());
capture_command_output(db_path, id, cmd, store, interval).await?;
// after: let capture_command_output own the stdio configuration
let mut cmd = Command::new("claude");
capture_command_output(db_path, id, cmd, store, interval).await?; Defensive patterns
Strategy: validation
Validate before calling
// Validate the Command before handing it to capture_command_output.
// The function itself sets stdout/stderr to piped, so callers must NOT
// pre-configure stdio. Guard against accidental override:
use std::process::Stdio;
use tokio::process::Command;
fn build_session_command(program: &str, args: &[&str]) -> Command {
let mut cmd = Command::new(program);
cmd.args(args);
// intentionally do NOT call .stdout()/.stderr() here;
// capture_command_output owns the piped configuration.
cmd
}
// Smoke-spawn once at startup to confirm the platform surfaces piped handles:
async fn smoke_spawn() -> anyhow::Result<()> {
let mut cmd = Command::new("true");
cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
let mut child = cmd.spawn().context("smoke spawn")?;
if child.stdout.take().is_none() || child.stderr.take().is_none() {
anyhow::bail!("platform does not surface piped stdio for child processes");
}
child.wait().await?;
Ok(())
} Type guard
// Type guard on a pre-built Command is not meaningful in Rust (Stdio is set
// internally), so the guard is a lint: assert at the call boundary that the
// caller has not touched stdio. Encode it as a builder that returns an opaque
// type which cannot expose .stdout()/.stderr().
pub struct SessionCommand(tokio::process::Command);
impl SessionCommand {
pub fn new(program: &str) -> Self { Self(Command::new(program)) }
pub fn args(mut self, args: &[&str]) -> Self { self.0.args(args); self }
pub(crate) fn into_inner(self) -> Command { self.0 }
}
// Callers cannot set stdio because the inner Command is private.
// capture_command_output takes SessionCommand, not Command. Try / catch
// capture_command_output returns Result<ExitStatus>; wrap the call to surface
// a clearer error to the daemon layer.
match capture_command_output(db_path, session_id, cmd, output_store, heartbeat).await {
Ok(status) => Ok(status),
Err(e) if e.to_string().contains("Child stderr was not piped") => {
tracing::error!("stdio piping rejected for {session_id}; check sandbox/subreaper");
Err(e)
}
Err(e) => Err(e),
} Prevention
- Never call .stderr() or .stdout() on a Command you pass to capture_command_output; the function owns stdio.
- Smoke-test process spawning once at daemon startup to detect sandbox/subreaper fd inheritance issues early.
- Run session children under a runtime that does not install a global SIGCHLD handler that reaps foreign children.
- Pin tokio to a version known to populate Child::stderr on your target platform.
When it happens
Trigger: Calling capture_command_output with a Command that has already had .stderr() set to Stdio::null or Stdio::inherit by the caller (overriding the piped setting inside the function the function's own .stderr(Stdio::piped()) is applied unconditionally, so this only fires on platform/runtime deviations or a Command whose stderr inheritance was forcibly disabled). On Unix it can surface when spawning a process under a session leader that detaches stdio, or when the OS-level fork/exec fails mid-way after the handle is created but before it is wired to the child.
Common situations: Running under a container/sandbox that remaps fd 2; spawning a setuid binary that resets inherited descriptors; a test harness that builds the Command with std.process and passes it in after stderr was redirected; tokio version mismatch where Child::stderr is not populated on certain platforms.
Related errors
- Spawned process did not expose a process id
- Child stdout was not piped
- Command "${commandName}" terminated by signal ${result.signa
- The local Itô CLI could not be started: ${result.error.messa
- ${command} ${args.join(' ')} failed: ${result.error.message}
AI-assisted analysis of affaan-m/ECC@01e15490f0 (2026-08-13).
Data as JSON: /api/errors/eacf1222c27f8a18.
Report an issue: GitHub.