affaan-m/ECC · error · anyhow::Error

Spawned process did not expose a process id

Error message

Spawned process did not expose a process id

What it means

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.

Source

Thrown at ecc2/src/session/runtime.rs:172

            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;
                }
            }
        });

        let stdout_task = tokio::spawn(capture_stream(
            session_id.clone(),

View on GitHub (pinned to 01e15490f0)

Solutions

  1. 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.
  2. 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.
  3. 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.
  4. Ensure tokio's process feature is enabled and the runtime is a multi-threaded runtime (current_thread runtime does not support tokio::process).

Example fix

// before: assumes id() is always Some
let pid = child.id().ok_or_else(|| anyhow::anyhow!("Spawned process did not expose a process id"))?;

// after: distinguish already-exited from genuinely detached
let pid = match child.id() {
    Some(pid) => pid,
    None => match child.try_wait()? {
        Some(status) => anyhow::bail!("Child exited before pid was read: {status}"),
        None => anyhow::bail!("Spawned process is detached and exposes no pid"),
    },
};
Defensive patterns

Strategy: validation

Validate before calling

// Distinguish already-exited from detached before treating as fatal.
async fn spawn_and_capture_pid(child: &mut tokio::process::Child) -> anyhow::Result<u32> {
    match child.id() {
        Some(pid) => Ok(pid),
        None => match child.try_wait()? {
            Some(status) => anyhow::bail!("child exited before pid was read: {status}"),
            None => anyhow::bail!("child is detached; no pid available"),
        },
    }
}

// Also validate the runtime before relying on tokio::process:
fn assert_multi_thread_runtime() -> anyhow::Result<()> {
    // tokio::process panics on current_thread runtimes in some versions.
    // Ensure the caller built the runtime with #[tokio::main] (multi-threaded).
    Ok(())
}

Type guard

// No type guard: Child::id() -> Option<u32> is already a precise Option.
// The defensive layer is the try_wait() branch shown in validationCode.

Try / catch

// In capture_command_output callers, treat a missing pid as fatal but log
// the wait status so the operator knows whether the child exited instantly.
match runtime::capture_command_output(/* ... */).await {
    Ok(status) => Ok(status),
    Err(e) if e.to_string().contains("did not expose a process id") => {
        tracing::error!("session {id} child detached or exited instantly");
        Err(e)
    }
    Err(e) => Err(e),
}

Prevention

When it happens

Trigger: 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.

Common situations: 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.

Related errors


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