oxdock-process 0.11.0-alpha

Process orchestration for OxDock environments.
Documentation
use std::collections::HashMap;

use anyhow::{Result, bail};
use oxdock_fs::{GuardedPath, PolicyPath};
#[allow(clippy::disallowed_types, clippy::disallowed_methods)]
use std::process::ExitStatus;

use std::sync::{Arc, Mutex};

/// Context passed to process managers describing the current execution
/// environment. Clones are cheap and explicit so background handles can own
/// their working roots without juggling lifetimes.
#[derive(Clone, Debug)]
pub struct CommandContext {
    cwd: PolicyPath,
    envs: Arc<HashMap<String, String>>,
    cargo_target_dir: GuardedPath,
    workspace_root: GuardedPath,
    build_context: GuardedPath,
}

impl CommandContext {
    pub fn new(
        cwd: &PolicyPath,
        envs: Arc<HashMap<String, String>>,
        cargo_target_dir: &GuardedPath,
        workspace_root: &GuardedPath,
        build_context: &GuardedPath,
    ) -> Self {
        Self {
            cwd: cwd.clone(),
            envs,
            cargo_target_dir: cargo_target_dir.clone(),
            workspace_root: workspace_root.clone(),
            build_context: build_context.clone(),
        }
    }

    /// Convenience constructor cloning a plain map into a fresh `Arc`.
    pub fn from_map(
        cwd: &PolicyPath,
        envs: &HashMap<String, String>,
        cargo_target_dir: &GuardedPath,
        workspace_root: &GuardedPath,
        build_context: &GuardedPath,
    ) -> Self {
        Self::new(
            cwd,
            Arc::new(envs.clone()),
            cargo_target_dir,
            workspace_root,
            build_context,
        )
    }

    pub fn cwd(&self) -> &PolicyPath {
        &self.cwd
    }

    pub fn envs(&self) -> &Arc<HashMap<String, String>> {
        &self.envs
    }

    pub fn cargo_target_dir(&self) -> &GuardedPath {
        &self.cargo_target_dir
    }

    pub fn workspace_root(&self) -> &GuardedPath {
        &self.workspace_root
    }

    pub fn build_context(&self) -> &GuardedPath {
        &self.build_context
    }
}

/// Handle for background processes spawned by a [`ProcessManager`].
pub trait BackgroundHandle: Send {
    fn try_wait(&mut self) -> Result<Option<ExitStatus>>;
    fn kill(&mut self) -> Result<()>;
    fn wait(&mut self) -> Result<ExitStatus>;
}

pub type SharedInput = Arc<Mutex<dyn std::io::Read + Send>>;
pub type SharedOutput = Arc<Mutex<dyn std::io::Write + Send>>;

#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum CommandMode {
    #[default]
    Foreground,
    Background,
}

#[derive(Clone, Default)]
pub enum CommandStdout {
    #[default]
    Inherit,
    Stream(SharedOutput),
    Capture,
    /// Direct OS kernel pipe writer for concurrent pipelines. Single use:
    /// the handle is taken on spawn and the parent retains no copy, so the
    /// reader observes EOF once the producer exits. Only valid with
    /// concurrently spawned consumers (`ASYNC`); never for sequential steps.
    #[cfg(not(miri))]
    OsPipe(OsPipeWriter),
}

/// Owned OS kernel pipe reader half behind a single use slot. `Clone`
/// shares the slot; `take` transfers the handle exactly once so no parent
/// copy survives spawn to starve the consumer of EOF. Backed by
/// `std::io::pipe` (stable since Rust 1.87): `pipe` on Unix, `CreatePipe`
/// on Windows.
#[cfg(not(miri))]
#[derive(Clone)]
pub struct OsPipeReader {
    inner: Arc<Mutex<Option<std::io::PipeReader>>>,
}

/// Owned OS kernel pipe writer half behind a single use slot. See
/// [`OsPipeReader`] for the shared slot semantics.
#[cfg(not(miri))]
#[derive(Clone)]
pub struct OsPipeWriter {
    inner: Arc<Mutex<Option<std::io::PipeWriter>>>,
}

#[cfg(not(miri))]
impl OsPipeReader {
    fn new(reader: std::io::PipeReader) -> Self {
        Self {
            inner: Arc::new(Mutex::new(Some(reader))),
        }
    }

    /// Take the handle for `Stdio::from`. Bails deterministically if the
    /// descriptor was already consumed so a second spawn can never reuse a
    /// spent pipe or leave stdio unbound.
    pub fn take(&self) -> Result<std::io::PipeReader> {
        self.inner
            .lock()
            .map_err(|_| anyhow::anyhow!("os pipe reader lock poisoned"))?
            .take()
            .ok_or_else(|| {
                anyhow::anyhow!("os pipe handle has already been consumed by another process")
            })
    }
}

#[cfg(not(miri))]
impl OsPipeWriter {
    fn new(writer: std::io::PipeWriter) -> Self {
        Self {
            inner: Arc::new(Mutex::new(Some(writer))),
        }
    }

    /// Take the handle for `Stdio::from`. Bails deterministically if the
    /// descriptor was already consumed so a second spawn can never reuse a
    /// spent pipe or leave stdio unbound.
    pub fn take(&self) -> Result<std::io::PipeWriter> {
        self.inner
            .lock()
            .map_err(|_| anyhow::anyhow!("os pipe writer lock poisoned"))?
            .take()
            .ok_or_else(|| {
                anyhow::anyhow!("os pipe handle has already been consumed by another process")
            })
    }
}

/// Create a cross platform anonymous OS pipe pair for concurrent `ASYNC`
/// pipelines. The caller moves each half into a spawn and drops any other
/// copies immediately after spawning, otherwise the reader never sees EOF.
#[cfg(not(miri))]
pub fn create_os_pipe() -> Result<(OsPipeReader, OsPipeWriter)> {
    let (reader, writer) = std::io::pipe()?;
    Ok((OsPipeReader::new(reader), OsPipeWriter::new(writer)))
}

#[derive(Clone, Default)]
pub enum CommandStdin {
    /// Isolated null stdin. Preserves the previous `None` behavior.
    #[default]
    Null,
    Inherit,
    Stream(SharedInput),
    /// Direct OS kernel pipe reader for concurrent pipelines. See
    /// [`CommandStdout::OsPipe`] for the single use contract.
    #[cfg(not(miri))]
    OsPipe(OsPipeReader),
}

impl From<Option<SharedInput>> for CommandStdin {
    fn from(stdin: Option<SharedInput>) -> Self {
        match stdin {
            Some(reader) => CommandStdin::Stream(reader),
            None => CommandStdin::Null,
        }
    }
}

#[derive(Clone, Default)]
pub enum CommandStderr {
    #[default]
    Inherit,
    Stream(SharedOutput),
    /// Direct OS kernel pipe writer, mirroring [`CommandStdout::OsPipe`].
    /// Merging stdout and stderr into one live name takes the same slot
    /// twice, so the second take bails; merge in shell via `2>&1` instead.
    #[cfg(not(miri))]
    OsPipe(OsPipeWriter),
}

#[derive(Clone, Default)]
pub struct CommandOptions {
    pub mode: CommandMode,
    pub stdin: CommandStdin,
    pub stdout: CommandStdout,
    pub stderr: CommandStderr,
}

impl CommandOptions {
    pub fn foreground() -> Self {
        Self::default()
    }

    pub fn background() -> Self {
        Self {
            mode: CommandMode::Background,
            ..Self::default()
        }
    }
}

pub enum CommandResult<H> {
    Completed,
    Captured(Vec<u8>),
    Background(H),
}

/// Host environment variable that forces spawned children to inherit the
/// parent's stdout/stderr instead of using the executor's stream routing.
/// Recognized values are `"1"` and case-insensitive `"true"`. Set on the
/// script environment (an `ENV` step or host inherit), not the process
/// environment: the executor reads it from [`CommandContext::envs`].
pub const INHERIT_STDOUT_ENV_VAR: &str = "OXDOCK_INHERIT_STDOUT";

/// Host process-environment variable enabling `eprintln!` diagnostics for
/// every spawned command (program plus argv/script). Read from the process
/// environment at spawn time; any value (including empty) enables it.
pub const PROCESS_DEBUG_ENV_VAR: &str = "OXBOOK_DEBUG";

/// Abstraction for running shell commands both in the foreground and
/// background. `oxdock-core` relies on this trait to decouple the executor
/// from `std::process::Command`, which in turn enables Miri-friendly test
/// doubles.
pub trait ProcessManager: Clone + Send + 'static {
    type Handle: BackgroundHandle + Clone + Send + 'static;

    fn run_command(
        &mut self,
        ctx: &CommandContext,
        script: &str,
        options: CommandOptions,
    ) -> Result<CommandResult<Self::Handle>>;

    /// Spawn a command without waiting for completion. Returns a background
    /// handle that can be polled or waited on later. The default implementation
    /// delegates to `run_command` with `CommandMode::Background`.
    fn spawn_command(
        &mut self,
        ctx: &CommandContext,
        script: &str,
        options: CommandOptions,
    ) -> Result<CommandResult<Self::Handle>> {
        self.run_command(ctx, script, options)
    }

    /// Run an executable directly with an argument vector (no shell).
    /// Backs the `RUN ["exe", "arg", ...]` exec form. The default
    /// implementation bails so existing out-of-tree managers keep
    /// compiling; in-tree managers override this.
    fn run_argv(
        &mut self,
        _ctx: &CommandContext,
        argv: &[String],
        _options: CommandOptions,
    ) -> Result<CommandResult<Self::Handle>> {
        bail!("run_argv not implemented for argv {argv:?}")
    }

    /// Spawn an argv command without waiting for completion. The default
    /// implementation delegates to `run_argv`, mirroring `spawn_command`.
    fn spawn_argv(
        &mut self,
        ctx: &CommandContext,
        argv: &[String],
        options: CommandOptions,
    ) -> Result<CommandResult<Self::Handle>> {
        self.run_argv(ctx, argv, options)
    }
}