{"record":{"id":"1fbd997496e2fbff","repo":"affaan-m/ECC","slug":"spawned-process-did-not-expose-a-process-id","errorCode":null,"errorMessage":"Spawned process did not expose a process id","messagePattern":"Spawned process did not expose a process id","errorType":"exception","errorClass":"anyhow::Error","httpStatus":null,"severity":"error","filePath":"ecc2/src/session/runtime.rs","lineNumber":172,"sourceCode":"            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;\n                }\n            }\n        });\n\n        let stdout_task = tokio::spawn(capture_stream(\n            session_id.clone(),","sourceCodeStart":154,"sourceCodeEnd":190,"githubUrl":"https://github.com/affaan-m/ECC/blob/01e15490f04e29cfefe3896951f43db46994d8ee/ecc2/src/session/runtime.rs#L154-L190","documentation":"Raised by capture_command_output in ecc2/src/session/runtime.rs:172 when tokio::process::Child::id() returns None. tokio returns None when the underlying process has already been reaped (waited on) or when the process was spawned detached and its id was not retained. This fires immediately after spawn and before any explicit wait, so a None id here signals that the OS finished and reaped the process between spawn and the id() call, or that spawn ran with kill_on_drop/daemon semantics that detached the child.","triggerScenarios":"Spawning a command that exits synchronously (e.g. /bin/true, a bad argv that fails exec) such that, by the time id() runs, the child has exited and tokio's reaper has cleared its pid; spawning under an environment where SIGCHLD handling is overridden by a library (e.g. some MPI or container runtimes) causing early reap; a bug where the Command had .kill_on_drop(true) plus a transient owner drop.","commonSituations":"CI runners with aggressive subreaper setups (e.g. tini/init as PID 1 reaping children); spawning a wrapper script that exec's and exits instantly; race against a watchdog that kills the session immediately after Running state is set; mismatched tokio versions where Child::id is not stabilized for the configured runtime.","solutions":["Verify the command being spawned is long-lived; if it is a wrapper, have it exec the real binary instead of returning, so the pid stays live.","Check for an external subreaper (init/tini/dumb-init as PID 1 in the container) and either disable it for session children or spawn with a process-group leader so the id is retained.","Reproduce with logging: print child.id() immediately after spawn to confirm the race window, then add a tiny tokio::task::yield_now or check child.try_wait() to distinguish already-exited from detached.","Ensure tokio's process feature is enabled and the runtime is a multi-threaded runtime (current_thread runtime does not support tokio::process)."],"exampleFix":"// before: assumes id() is always Some\nlet pid = child.id().ok_or_else(|| anyhow::anyhow!(\"Spawned process did not expose a process id\"))?;\n\n// after: distinguish already-exited from genuinely detached\nlet pid = match child.id() {\n    Some(pid) => pid,\n    None => match child.try_wait()? {\n        Some(status) => anyhow::bail!(\"Child exited before pid was read: {status}\"),\n        None => anyhow::bail!(\"Spawned process is detached and exposes no pid\"),\n    },\n};","handlingStrategy":"validation","validationCode":"// Distinguish already-exited from detached before treating as fatal.\nasync fn spawn_and_capture_pid(child: &mut tokio::process::Child) -> anyhow::Result<u32> {\n    match child.id() {\n        Some(pid) => Ok(pid),\n        None => match child.try_wait()? {\n            Some(status) => anyhow::bail!(\"child exited before pid was read: {status}\"),\n            None => anyhow::bail!(\"child is detached; no pid available\"),\n        },\n    }\n}\n\n// Also validate the runtime before relying on tokio::process:\nfn assert_multi_thread_runtime() -> anyhow::Result<()> {\n    // tokio::process panics on current_thread runtimes in some versions.\n    // Ensure the caller built the runtime with #[tokio::main] (multi-threaded).\n    Ok(())\n}","typeGuard":"// No type guard: Child::id() -> Option<u32> is already a precise Option.\n// The defensive layer is the try_wait() branch shown in validationCode.","tryCatchPattern":"// In capture_command_output callers, treat a missing pid as fatal but log\n// the wait status so the operator knows whether the child exited instantly.\nmatch runtime::capture_command_output(/* ... */).await {\n    Ok(status) => Ok(status),\n    Err(e) if e.to_string().contains(\"did not expose a process id\") => {\n        tracing::error!(\"session {id} child detached or exited instantly\");\n        Err(e)\n    }\n    Err(e) => Err(e),\n}","preventionTips":["Do not spawn commands that exit synchronously as session children; use a long-lived wrapper.","Avoid container init systems (tini, dumb-init) that reap grandchildren of the session leader.","Keep ecc2 on a single tokio version across the fleet; pid surfacing varies between minors.","Log child.try_wait() right after spawn in debug builds to catch instant-exit races."],"tags":["process","tokio","spawn","pid","runtime","race-condition"],"backgroundTag":null,"analyzedSha":"01e15490f04e29cfefe3896951f43db46994d8ee","analyzedAt":"2026-08-13T00:31:08.655Z","schemaVersion":2},"datasetVersion":"2026-08-13T04:17:16.726Z"}