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

  1. 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).
  2. 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.
  3. Check the container/sandbox configuration (systemd-nspawn, firejail, Bubblewrap) for fd inheritance restrictions on stderr and adjust the descriptor inheritance policy.
  4. 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

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


AI-assisted analysis of affaan-m/ECC@01e15490f0 (2026-08-13). Data as JSON: /api/errors/eacf1222c27f8a18. Report an issue: GitHub.