Skip to main content

oxdock_process/
contract.rs

1use std::collections::HashMap;
2
3use anyhow::{Result, bail};
4use oxdock_fs::{CargoScratch, GuardedPath, PolicyPath};
5#[allow(clippy::disallowed_types, clippy::disallowed_methods)]
6use std::process::ExitStatus;
7
8use std::sync::{Arc, Mutex};
9
10/// Context passed to process managers describing the current execution
11/// environment. Clones are cheap and explicit so background handles can own
12/// their working roots without juggling lifetimes.
13#[derive(Clone, Debug)]
14pub struct CommandContext {
15    cwd: PolicyPath,
16    envs: Arc<HashMap<String, String>>,
17    cargo_target_dir: CargoScratch,
18    workspace_root: GuardedPath,
19    build_context: GuardedPath,
20}
21
22impl CommandContext {
23    pub fn new(
24        cwd: &PolicyPath,
25        envs: Arc<HashMap<String, String>>,
26        cargo_target_dir: &CargoScratch,
27        workspace_root: &GuardedPath,
28        build_context: &GuardedPath,
29    ) -> Self {
30        Self {
31            cwd: cwd.clone(),
32            envs,
33            cargo_target_dir: cargo_target_dir.clone(),
34            workspace_root: workspace_root.clone(),
35            build_context: build_context.clone(),
36        }
37    }
38
39    /// Convenience constructor cloning a plain map into a fresh `Arc`.
40    pub fn from_map(
41        cwd: &PolicyPath,
42        envs: &HashMap<String, String>,
43        cargo_target_dir: &CargoScratch,
44        workspace_root: &GuardedPath,
45        build_context: &GuardedPath,
46    ) -> Self {
47        Self::new(
48            cwd,
49            Arc::new(envs.clone()),
50            cargo_target_dir,
51            workspace_root,
52            build_context,
53        )
54    }
55
56    pub fn cwd(&self) -> &PolicyPath {
57        &self.cwd
58    }
59
60    pub fn envs(&self) -> &Arc<HashMap<String, String>> {
61        &self.envs
62    }
63
64    pub fn cargo_target_dir(&self) -> &CargoScratch {
65        &self.cargo_target_dir
66    }
67
68    pub fn workspace_root(&self) -> &GuardedPath {
69        &self.workspace_root
70    }
71
72    pub fn build_context(&self) -> &GuardedPath {
73        &self.build_context
74    }
75}
76
77/// Handle for background processes spawned by a [`ProcessManager`].
78pub trait BackgroundHandle: Send {
79    fn try_wait(&mut self) -> Result<Option<ExitStatus>>;
80    fn kill(&mut self) -> Result<()>;
81    fn wait(&mut self) -> Result<ExitStatus>;
82}
83
84pub type SharedInput = Arc<Mutex<dyn std::io::Read + Send>>;
85pub type SharedOutput = Arc<Mutex<dyn std::io::Write + Send>>;
86
87#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
88pub enum CommandMode {
89    #[default]
90    Foreground,
91    Background,
92}
93
94#[derive(Clone, Default)]
95pub enum CommandStdout {
96    #[default]
97    Inherit,
98    Stream(SharedOutput),
99    Capture,
100    /// Direct OS kernel pipe writer for concurrent pipelines. Single use:
101    /// the handle is taken on spawn and the parent retains no copy, so the
102    /// reader observes EOF once the producer exits. Only valid with
103    /// concurrently spawned consumers (`ASYNC`); never for sequential steps.
104    #[cfg(not(miri))]
105    OsPipe(OsPipeWriter),
106}
107
108/// Owned OS kernel pipe reader half behind a single use slot. `Clone`
109/// shares the slot; `take` transfers the handle exactly once so no parent
110/// copy survives spawn to starve the consumer of EOF. Backed by
111/// `std::io::pipe` (stable since Rust 1.87): `pipe` on Unix, `CreatePipe`
112/// on Windows.
113#[cfg(not(miri))]
114#[derive(Clone)]
115pub struct OsPipeReader {
116    inner: Arc<Mutex<Option<std::io::PipeReader>>>,
117}
118
119/// Owned OS kernel pipe writer half behind a single use slot. See
120/// [`OsPipeReader`] for the shared slot semantics.
121#[cfg(not(miri))]
122#[derive(Clone)]
123pub struct OsPipeWriter {
124    inner: Arc<Mutex<Option<std::io::PipeWriter>>>,
125}
126
127#[cfg(not(miri))]
128impl OsPipeReader {
129    fn new(reader: std::io::PipeReader) -> Self {
130        Self {
131            inner: Arc::new(Mutex::new(Some(reader))),
132        }
133    }
134
135    /// Take the handle for `Stdio::from`. Bails deterministically if the
136    /// descriptor was already consumed so a second spawn can never reuse a
137    /// spent pipe or leave stdio unbound.
138    pub fn take(&self) -> Result<std::io::PipeReader> {
139        self.inner
140            .lock()
141            .map_err(|_| anyhow::anyhow!("os pipe reader lock poisoned"))?
142            .take()
143            .ok_or_else(|| {
144                anyhow::anyhow!("os pipe handle has already been consumed by another process")
145            })
146    }
147}
148
149#[cfg(not(miri))]
150impl OsPipeWriter {
151    fn new(writer: std::io::PipeWriter) -> Self {
152        Self {
153            inner: Arc::new(Mutex::new(Some(writer))),
154        }
155    }
156
157    /// Take the handle for `Stdio::from`. Bails deterministically if the
158    /// descriptor was already consumed so a second spawn can never reuse a
159    /// spent pipe or leave stdio unbound.
160    pub fn take(&self) -> Result<std::io::PipeWriter> {
161        self.inner
162            .lock()
163            .map_err(|_| anyhow::anyhow!("os pipe writer lock poisoned"))?
164            .take()
165            .ok_or_else(|| {
166                anyhow::anyhow!("os pipe handle has already been consumed by another process")
167            })
168    }
169}
170
171/// Create a cross platform anonymous OS pipe pair for concurrent `ASYNC`
172/// pipelines. The caller moves each half into a spawn and drops any other
173/// copies immediately after spawning, otherwise the reader never sees EOF.
174#[cfg(not(miri))]
175pub fn create_os_pipe() -> Result<(OsPipeReader, OsPipeWriter)> {
176    let (reader, writer) = std::io::pipe()?;
177    Ok((OsPipeReader::new(reader), OsPipeWriter::new(writer)))
178}
179
180#[derive(Clone, Default)]
181pub enum CommandStdin {
182    /// Isolated null stdin. Preserves the previous `None` behavior.
183    #[default]
184    Null,
185    Inherit,
186    Stream(SharedInput),
187    /// Direct OS kernel pipe reader for concurrent pipelines. See
188    /// [`CommandStdout::OsPipe`] for the single use contract.
189    #[cfg(not(miri))]
190    OsPipe(OsPipeReader),
191}
192
193impl From<Option<SharedInput>> for CommandStdin {
194    fn from(stdin: Option<SharedInput>) -> Self {
195        match stdin {
196            Some(reader) => CommandStdin::Stream(reader),
197            None => CommandStdin::Null,
198        }
199    }
200}
201
202#[derive(Clone, Default)]
203pub enum CommandStderr {
204    #[default]
205    Inherit,
206    Stream(SharedOutput),
207    /// Direct OS kernel pipe writer, mirroring [`CommandStdout::OsPipe`].
208    /// Merging stdout and stderr into one live name takes the same slot
209    /// twice, so the second take bails; merge in shell via `2>&1` instead.
210    #[cfg(not(miri))]
211    OsPipe(OsPipeWriter),
212}
213
214#[derive(Clone, Default)]
215pub struct CommandOptions {
216    pub mode: CommandMode,
217    pub stdin: CommandStdin,
218    pub stdout: CommandStdout,
219    pub stderr: CommandStderr,
220}
221
222impl CommandOptions {
223    pub fn foreground() -> Self {
224        Self::default()
225    }
226
227    pub fn background() -> Self {
228        Self {
229            mode: CommandMode::Background,
230            ..Self::default()
231        }
232    }
233}
234
235pub enum CommandResult<H> {
236    Completed,
237    Captured(Vec<u8>),
238    Background(H),
239}
240
241/// Host environment variable that forces spawned children to inherit the
242/// parent's stdout/stderr instead of using the executor's stream routing.
243/// Recognized values are `"1"` and case-insensitive `"true"`. Set on the
244/// script environment (an `ENV` step or host inherit), not the process
245/// environment: the executor reads it from [`CommandContext::envs`].
246pub const INHERIT_STDOUT_ENV_VAR: &str = "OXDOCK_INHERIT_STDOUT";
247
248/// Host process-environment variable enabling `eprintln!` diagnostics for
249/// every spawned command (program plus argv/script). Read from the process
250/// environment at spawn time; any value (including empty) enables it.
251pub const PROCESS_DEBUG_ENV_VAR: &str = "OXBOOK_DEBUG";
252
253/// Abstraction for running shell commands both in the foreground and
254/// background. `oxdock-core` relies on this trait to decouple the executor
255/// from `std::process::Command`, which in turn enables Miri-friendly test
256/// doubles.
257pub trait ProcessManager: Clone + Send + 'static {
258    type Handle: BackgroundHandle + Clone + Send + 'static;
259
260    fn run_command(
261        &mut self,
262        ctx: &CommandContext,
263        script: &str,
264        options: CommandOptions,
265    ) -> Result<CommandResult<Self::Handle>>;
266
267    /// Spawn a command without waiting for completion. Returns a background
268    /// handle that can be polled or waited on later. The default implementation
269    /// delegates to `run_command` with `CommandMode::Background`.
270    fn spawn_command(
271        &mut self,
272        ctx: &CommandContext,
273        script: &str,
274        options: CommandOptions,
275    ) -> Result<CommandResult<Self::Handle>> {
276        self.run_command(ctx, script, options)
277    }
278
279    /// Run an executable directly with an argument vector (no shell).
280    /// Backs the `RUN ["exe", "arg", ...]` exec form. The default
281    /// implementation bails so existing out-of-tree managers keep
282    /// compiling; in-tree managers override this.
283    fn run_argv(
284        &mut self,
285        _ctx: &CommandContext,
286        argv: &[String],
287        _options: CommandOptions,
288    ) -> Result<CommandResult<Self::Handle>> {
289        bail!("run_argv not implemented for argv {argv:?}")
290    }
291
292    /// Spawn an argv command without waiting for completion. The default
293    /// implementation delegates to `run_argv`, mirroring `spawn_command`.
294    fn spawn_argv(
295        &mut self,
296        ctx: &CommandContext,
297        argv: &[String],
298        options: CommandOptions,
299    ) -> Result<CommandResult<Self::Handle>> {
300        self.run_argv(ctx, argv, options)
301    }
302}