{"record":{"id":"eacf1222c27f8a18","repo":"affaan-m/ECC","slug":"child-stderr-was-not-piped","errorCode":null,"errorMessage":"Child stderr was not piped","messagePattern":"Child stderr was not piped","errorType":"exception","errorClass":"anyhow::Error","httpStatus":null,"severity":"error","filePath":"ecc2/src/session/runtime.rs","lineNumber":166,"sourceCode":"            .stdout(Stdio::piped())\n            .stderr(Stdio::piped())\n            .spawn()\n            .with_context(|| format!(\"Failed to start process for session {}\", session_id))?;\n\n        let stdout = match child.stdout.take() {\n            Some(stdout) => stdout,\n            None => {\n                let _ = child.kill().await;\n                let _ = child.wait().await;\n                anyhow::bail!(\"Child stdout was not piped\");\n            }\n        };\n        let stderr = match child.stderr.take() {\n            Some(stderr) => stderr,\n            None => {\n                let _ = child.kill().await;\n                let _ = child.wait().await;\n                anyhow::bail!(\"Child stderr was not piped\");\n            }\n        };\n\n        let pid = child\n            .id()\n            .ok_or_else(|| anyhow::anyhow!(\"Spawned process did not expose a process id\"))?;\n        db_writer.update_pid(Some(pid)).await?;\n        db_writer.update_state(SessionState::Running).await?;\n        db_writer.touch_heartbeat().await?;\n\n        let heartbeat_writer = db_writer.clone();\n        let heartbeat_task = tokio::spawn(async move {\n            let mut ticker = time::interval(heartbeat_interval);\n            ticker.set_missed_tick_behavior(MissedTickBehavior::Delay);\n            loop {\n                ticker.tick().await;\n                if heartbeat_writer.touch_heartbeat().await.is_err() {\n                    break;","sourceCodeStart":148,"sourceCodeEnd":184,"githubUrl":"https://github.com/affaan-m/ECC/blob/01e15490f04e29cfefe3896951f43db46994d8ee/ecc2/src/session/runtime.rs#L148-L184","documentation":"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.","triggerScenarios":"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.","commonSituations":"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.","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."],"exampleFix":"// before: caller pre-configures stderr, masking the piped setting\nlet mut cmd = Command::new(\"claude\");\ncmd.stderr(Stdio::inherit());\ncapture_command_output(db_path, id, cmd, store, interval).await?;\n\n// after: let capture_command_output own the stdio configuration\nlet mut cmd = Command::new(\"claude\");\ncapture_command_output(db_path, id, cmd, store, interval).await?;","handlingStrategy":"validation","validationCode":"// Validate the Command before handing it to capture_command_output.\n// The function itself sets stdout/stderr to piped, so callers must NOT\n// pre-configure stdio. Guard against accidental override:\nuse std::process::Stdio;\nuse tokio::process::Command;\n\nfn build_session_command(program: &str, args: &[&str]) -> Command {\n    let mut cmd = Command::new(program);\n    cmd.args(args);\n    // intentionally do NOT call .stdout()/.stderr() here;\n    // capture_command_output owns the piped configuration.\n    cmd\n}\n\n// Smoke-spawn once at startup to confirm the platform surfaces piped handles:\nasync fn smoke_spawn() -> anyhow::Result<()> {\n    let mut cmd = Command::new(\"true\");\n    cmd.stdout(Stdio::piped()).stderr(Stdio::piped());\n    let mut child = cmd.spawn().context(\"smoke spawn\")?;\n    if child.stdout.take().is_none() || child.stderr.take().is_none() {\n        anyhow::bail!(\"platform does not surface piped stdio for child processes\");\n    }\n    child.wait().await?;\n    Ok(())\n}","typeGuard":"// Type guard on a pre-built Command is not meaningful in Rust (Stdio is set\n// internally), so the guard is a lint: assert at the call boundary that the\n// caller has not touched stdio. Encode it as a builder that returns an opaque\n// type which cannot expose .stdout()/.stderr().\npub struct SessionCommand(tokio::process::Command);\n\nimpl SessionCommand {\n    pub fn new(program: &str) -> Self { Self(Command::new(program)) }\n    pub fn args(mut self, args: &[&str]) -> Self { self.0.args(args); self }\n    pub(crate) fn into_inner(self) -> Command { self.0 }\n}\n// Callers cannot set stdio because the inner Command is private.\n// capture_command_output takes SessionCommand, not Command.","tryCatchPattern":"// capture_command_output returns Result<ExitStatus>; wrap the call to surface\n// a clearer error to the daemon layer.\nmatch capture_command_output(db_path, session_id, cmd, output_store, heartbeat).await {\n    Ok(status) => Ok(status),\n    Err(e) if e.to_string().contains(\"Child stderr was not piped\") => {\n        tracing::error!(\"stdio piping rejected for {session_id}; check sandbox/subreaper\");\n        Err(e)\n    }\n    Err(e) => Err(e),\n}","preventionTips":["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."],"tags":["process","tokio","stdio","spawn","runtime"],"backgroundTag":null,"analyzedSha":"01e15490f04e29cfefe3896951f43db46994d8ee","analyzedAt":"2026-08-13T00:31:08.655Z","schemaVersion":2},"datasetVersion":"2026-08-13T04:17:16.726Z"}