haz-exec 0.1.0

Async task execution engine for haz.
Documentation
//! [`ProcessSpawner`] trait, [`Process`] handle trait, and the
//! supporting value types every implementation produces.
//!
//! The trait pair abstracts the executor's view of the host OS's
//! process facilities so the production backend and the scriptable
//! test-double mock can share one shape. The production backend
//! [`StdProcessSpawner`](crate::std_impl::StdProcessSpawner) wraps
//! [`tokio::process::Command`]; the mock lives alongside under
//! [`crate::mock_impl`] but is gated to test builds only and is not
//! part of the crate's stable public surface. The trait associated
//! types still carry tokio types ([`tokio::io::AsyncRead`]); the
//! executor is tokio-bound by design and does not aim to be
//! runtime-neutral.
//!
//! Stream ownership is structural rather than convention. The
//! [`Spawned`] value returned from [`ProcessSpawner::spawn`] carries
//! the child's stdout and stderr as fields; callers move them into
//! reader tasks while keeping the [`Process`] value for waiting and
//! signalling. This mirrors the take-once invariant of
//! [`tokio::process::Child::stdout`] without exposing it as a runtime
//! check.

use std::ffi::OsString;
use std::future::Future;
use std::path::PathBuf;

use snafu::Snafu;
use tokio::io::AsyncRead;

/// Numeric process identifier as reported by the host OS.
///
/// Wraps [`u32`] so the type system distinguishes a process id from
/// every other numeric identifier the codebase carries.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct ProcessId(pub u32);

/// Termination or cancellation signal the executor delivers to a
/// child process during the cancellation flow (`EXEC-013`,
/// `EXEC-014`).
///
/// The variants are intentionally limited to the signals the executor
/// actually uses; this is not a full POSIX signal vocabulary.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Signal {
    /// `SIGINT`, the interactive interrupt (e.g. Ctrl+C). Mapped to
    /// the platform equivalent on non-Unix hosts.
    Interrupt,
    /// `SIGTERM`, the polite-termination signal. Mapped to the
    /// platform equivalent on non-Unix hosts.
    Terminate,
    /// `SIGKILL`, the unconditional kill.
    Kill,
}

/// Final state of a child process as reported by [`Process::wait`].
///
/// Aliased to [`std::process::ExitStatus`] so callers can use the
/// platform-extension traits (e.g. `ExitStatusExt::signal()` on Unix)
/// without an extra conversion layer.
pub type ExitStatus = std::process::ExitStatus;

/// Description of one process the executor wants to spawn.
///
/// Carries the program, its argument vector, the working directory
/// (always absolute), and the environment variables the child should
/// see. The [`Self::env`] vector is preserved verbatim by all
/// implementations so test assertions are deterministic.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SpawnPlan {
    /// The executable to launch. May be an absolute path
    /// (e.g. `/bin/sh`) or a name to be resolved via `PATH`
    /// (e.g. `echo`).
    pub program: OsString,
    /// The argument vector passed to the child. Does NOT include
    /// `argv[0]`; implementations supply that from [`Self::program`].
    pub args: Vec<OsString>,
    /// Environment variables the child receives. Order is preserved;
    /// when the same name appears twice, last-write-wins (matching
    /// [`std::process::Command::env`] semantics).
    pub env: Vec<(OsString, OsString)>,
    /// Working directory the child runs in. MUST be absolute.
    pub cwd: PathBuf,
}

/// Failure modes shared by every [`ProcessSpawner`] and [`Process`]
/// method.
#[derive(Debug, Snafu)]
#[snafu(visibility(pub(crate)))]
pub enum ProcessError {
    /// The cwd in the [`SpawnPlan`] was relative; absolute paths are
    /// required.
    #[snafu(display("expected absolute cwd, got: {}", cwd.display()))]
    NonAbsoluteCwd {
        /// Relative cwd that was rejected.
        cwd: PathBuf,
    },

    /// The OS refused to spawn the child (executable not found,
    /// permission denied, fork/exec failure, etc.).
    #[snafu(display(
        "failed to spawn process for: {}: {source}",
        program.to_string_lossy()
    ))]
    SpawnFailed {
        /// Program the executor tried to launch.
        program: OsString,
        /// Underlying I/O error.
        source: std::io::Error,
    },

    /// The OS refused to deliver a signal to the child, or the
    /// implementation does not support the requested signal on the
    /// host platform (e.g. SIGTERM / SIGINT on Windows).
    #[snafu(display("failed to deliver signal {signal:?} to pid {pid:?}: {source}"))]
    SignalFailed {
        /// Signal the executor tried to deliver.
        signal: Signal,
        /// Process id of the target, when available.
        pid: Option<ProcessId>,
        /// Underlying I/O error.
        source: std::io::Error,
    },

    /// Reaping the child failed.
    #[snafu(display("failed to wait for pid {pid:?}: {source}"))]
    WaitFailed {
        /// Process id of the target, when available.
        pid: Option<ProcessId>,
        /// Underlying I/O error.
        source: std::io::Error,
    },
}

/// Handle to one spawned child process.
///
/// Implementors expose the lifecycle operations the executor needs
/// after `spawn`: identity ([`Self::id`]), signal delivery
/// ([`Self::send_signal`]), and reaping ([`Self::wait`]).
///
/// stdout and stderr are NOT exposed through this trait. The
/// [`ProcessSpawner::spawn`] call extracts them and returns them as
/// fields on a [`Spawned`] value, which is the structural enforcement
/// of the take-once invariant on those streams.
pub trait Process {
    /// Concrete async byte-stream type the implementation uses for
    /// the child's stdout. Both [`StdProcess`](crate::std_impl::StdProcess)
    /// and the mock satisfy [`AsyncRead`] + [`Send`] + [`Unpin`].
    type Stdout: AsyncRead + Send + Unpin;
    /// Concrete async byte-stream type the implementation uses for
    /// the child's stderr.
    type Stderr: AsyncRead + Send + Unpin;

    /// Numeric process id, or [`None`] once the child has been
    /// reaped (after [`Self::wait`] resolves).
    fn id(&self) -> Option<ProcessId>;

    /// Deliver `signal` to the child. Best-effort: returns as soon as
    /// the signal is queued by the kernel; does NOT wait for the
    /// child to acknowledge or terminate.
    ///
    /// On Unix, the std backend targets the child's process group
    /// (`kill(-pgid, sig)`) so any subprocesses the task itself
    /// forked also receive the signal (`EXEC-013` step 2,
    /// `EXEC-014`). On non-Unix hosts, only [`Signal::Kill`] is
    /// implemented; [`Signal::Terminate`] and [`Signal::Interrupt`]
    /// surface [`std::io::ErrorKind::Unsupported`].
    ///
    /// # Errors
    ///
    /// Returns [`ProcessError::SignalFailed`] when the OS rejects the
    /// signal (e.g. the child has already been reaped) or when the
    /// implementation does not support the requested variant on the
    /// host platform.
    fn send_signal(&mut self, signal: Signal) -> Result<(), ProcessError>;

    /// Wait for the child to terminate and yield its exit status.
    ///
    /// # Errors
    ///
    /// Returns [`ProcessError::WaitFailed`] when reaping fails.
    fn wait(&mut self) -> impl Future<Output = Result<ExitStatus, ProcessError>> + Send;
}

/// Triple of values produced by [`ProcessSpawner::spawn`].
///
/// The owned-at-spawn shape: stdout and stderr are taken out of the
/// child once at spawn time and exposed as fields. Callers move them
/// into reader tasks while keeping [`Self::process`] for waiting and
/// signalling.
pub struct Spawned<P: Process> {
    /// Handle to the spawned child for waiting and signalling.
    pub process: P,
    /// Async byte stream of the child's stdout.
    pub stdout: P::Stdout,
    /// Async byte stream of the child's stderr.
    pub stderr: P::Stderr,
}

/// Trait for spawning child processes.
///
/// The production backend
/// [`StdProcessSpawner`](crate::std_impl::StdProcessSpawner) wraps
/// [`tokio::process::Command`]; a scriptable test-double mock lives
/// alongside under [`crate::mock_impl`] but is gated to test builds
/// only.
///
/// Consumers borrow a `ProcessSpawner` implementation generically
/// rather than depending on either concrete impl, which keeps test
/// code free of the production tokio command wiring while remaining
/// on the same tokio runtime.
pub trait ProcessSpawner {
    /// The implementation's concrete [`Process`] type.
    type Process: Process;

    /// Spawn a child process per `plan`.
    ///
    /// On success, both stdout and stderr are configured as pipes and
    /// returned in the [`Spawned`] value's fields; stdin is closed.
    /// The returned future is [`Send`] so the executor can spawn it
    /// onto a multi-threaded runtime.
    ///
    /// # Errors
    ///
    /// Returns [`ProcessError::NonAbsoluteCwd`] when `plan.cwd` is
    /// relative, and [`ProcessError::SpawnFailed`] when the OS
    /// refuses to launch the child.
    fn spawn(
        &self,
        plan: &SpawnPlan,
    ) -> impl Future<Output = Result<Spawned<Self::Process>, ProcessError>> + Send;
}