harn_hostlib/process/handle.rs
1//! Process abstraction trait used by `tools/proc` and
2//! `tools/long_running`.
3//!
4//! Tier 1C of the de-flake epic (#1057). Production code spawns through
5//! the [`ProcessSpawner`] trait — the default implementation in
6//! `process::real` wraps `std::process::Child` and goes through
7//! `harn_vm::process_sandbox`. Tests install a `MockSpawner` (see
8//! `process::mock`) that returns deterministic [`MockProcess`] handles,
9//! so process-tool tests no longer depend on real subprocess scheduling
10//! or wall-clock timing.
11
12use std::collections::BTreeMap;
13use std::io::{self, Read, Write};
14use std::path::PathBuf;
15use std::sync::Arc;
16use std::time::Duration;
17
18/// Resolved exit information for a finished process. Mirrors the subset of
19/// `std::process::ExitStatus` that the process-tool builtins surface.
20#[derive(Clone, Copy, Debug, PartialEq, Eq)]
21pub struct ExitStatus {
22 /// Exit code from `exit(2)` / `_exit(2)`. `None` means the process did not
23 /// exit normally (it was terminated by a signal).
24 pub code: Option<i32>,
25 /// Unix signal that terminated the process, when applicable. `None` on
26 /// non-Unix targets or when the process exited normally.
27 pub signal: Option<i32>,
28}
29
30impl ExitStatus {
31 /// Construct a normal exit with the given code.
32 pub fn from_code(code: i32) -> Self {
33 Self {
34 code: Some(code),
35 signal: None,
36 }
37 }
38
39 /// Construct a signal-terminated exit.
40 pub fn from_signal(signal: i32) -> Self {
41 Self {
42 code: None,
43 signal: Some(signal),
44 }
45 }
46}
47
48/// How a spawn should treat the parent's environment. Mirrors the legacy
49/// `EnvMode` from `tools/proc.rs`.
50#[derive(Clone, Copy, Debug, PartialEq, Eq)]
51pub enum EnvMode {
52 /// Inherit the parent's environment, then apply `env` overrides.
53 InheritClean,
54 /// Clear the environment, then apply `env`.
55 Replace,
56 /// Inherit the parent's environment and apply `env` (default behaviour).
57 Patch,
58}
59
60/// Parameters describing a single spawn. The spawner is responsible for any
61/// sandbox setup (Linux seccomp/landlock, macOS sandbox-exec, etc.) and for
62/// configuring the child's process group when requested.
63#[derive(Clone, Debug)]
64pub struct SpawnSpec {
65 /// Builtin name surfaced in error messages (e.g. `"hostlib_tools_run_command"`).
66 pub builtin: &'static str,
67 /// Program to execute. Must be non-empty (validated by the spawner).
68 pub program: String,
69 /// Arguments to pass to the program.
70 pub args: Vec<String>,
71 /// Working directory for the child. `None` inherits the parent's cwd.
72 pub cwd: Option<PathBuf>,
73 /// Environment overrides to apply (interpretation depends on `env_mode`).
74 pub env: BTreeMap<String, String>,
75 /// How to treat the parent's environment.
76 pub env_mode: EnvMode,
77 /// Whether stdin will be written to (`true`) or piped to /dev/null (`false`).
78 pub use_stdin: bool,
79 /// Set the child's process group to its own pid (`setpgid(0, 0)`). Used
80 /// for long-running handles so the kill-by-pgid path works.
81 pub configure_process_group: bool,
82}
83
84/// Handle to a running (or finished) process. Used by both the synchronous
85/// `proc::run` path and the long-running waiter thread.
86///
87/// The trait is intentionally small: the legacy code already managed
88/// stdout/stderr drain on dedicated threads, and stdin is written once after
89/// spawn — wrapping those reads/writes via boxed trait objects keeps the
90/// real and mock paths uniform without forcing async into the rest of the
91/// hostlib.
92pub trait ProcessHandle: Send {
93 /// OS process id, when available.
94 fn pid(&self) -> Option<u32>;
95
96 /// OS process group id, when available. Falls back to [`Self::pid`] on
97 /// platforms that don't expose process groups.
98 fn process_group_id(&self) -> Option<u32>;
99
100 /// Returns a killer that can terminate the process even after the
101 /// stdout/stderr/wait halves have been moved into the waiter thread.
102 fn killer(&self) -> Arc<dyn ProcessKiller>;
103
104 /// Take ownership of the stdin pipe, if the spawn requested one.
105 fn take_stdin(&mut self) -> Option<Box<dyn Write + Send>>;
106
107 /// Take ownership of the stdout reader.
108 fn take_stdout(&mut self) -> Option<Box<dyn Read + Send>>;
109
110 /// Take ownership of the stderr reader.
111 fn take_stderr(&mut self) -> Option<Box<dyn Read + Send>>;
112
113 /// Wait for the process to exit, optionally with a timeout. Returns
114 /// `(Some(status), false)` when the process exited cleanly,
115 /// `(None, true)` when the timeout elapsed (and the spawner killed the
116 /// child), or `(None, false)` when the wait failed for a reason other
117 /// than the timeout.
118 fn wait_with_timeout(
119 &mut self,
120 timeout: Option<Duration>,
121 ) -> io::Result<(Option<ExitStatus>, bool)>;
122
123 /// Block until the process exits, no timeout.
124 fn wait(&mut self) -> io::Result<ExitStatus>;
125}
126
127/// Kill side of a [`ProcessHandle`]. Cloneable via `Arc` so cancellation
128/// works after the waiter thread has taken ownership of the handle itself.
129pub trait ProcessKiller: Send + Sync {
130 /// Send SIGKILL to the process (and its process group, when applicable).
131 fn kill(&self);
132}
133
134/// Spawner abstraction: produces [`ProcessHandle`] instances.
135pub trait ProcessSpawner: Send + Sync {
136 /// Spawn the configured process.
137 fn spawn(&self, spec: SpawnSpec) -> Result<Box<dyn ProcessHandle>, ProcessError>;
138}
139
140/// Errors raised by a spawner. These map onto `HostlibError::Backend` /
141/// `HostlibError::InvalidParameter` at the call site so the script-side
142/// surface stays unchanged.
143#[derive(Clone, Debug, thiserror::Error)]
144pub enum ProcessError {
145 /// `argv` was empty or otherwise malformed.
146 #[error("invalid argv: {0}")]
147 InvalidArgv(String),
148 /// Sandbox setup (e.g. landlock policy assembly) failed.
149 #[error("sandbox setup failed: {0}")]
150 SandboxSetup(String),
151 /// Sandbox rejected the supplied cwd.
152 #[error("sandbox cwd rejected: {0}")]
153 SandboxCwd(String),
154 /// Sandbox rejected the spawn at execve time.
155 #[error("sandbox rejected spawn: {0}")]
156 SandboxSpawn(String),
157 /// Generic spawn failure (typically io::Error from `Command::spawn`).
158 #[error("spawn failed: {0}")]
159 Spawn(String),
160}
161
162use std::cell::RefCell;
163
164thread_local! {
165 static THREAD_SPAWNER: RefCell<Option<Arc<dyn ProcessSpawner>>> = const { RefCell::new(None) };
166}
167
168/// Install a per-thread spawner used by `spawn_process` from this thread.
169/// Returns a guard that restores the previous spawner on drop. Tests use
170/// this to install a [`super::mock::MockSpawner`]; production never calls
171/// it (the default real spawner runs whenever no per-thread spawner is
172/// installed).
173///
174/// Thread-local rather than global so parallel test execution is safe.
175/// Process-tool spawns happen on the test's thread; the long-running
176/// waiter threads operate on the handle that was already returned, so
177/// they don't perform spawner lookups themselves.
178pub fn install_spawner(spawner: Arc<dyn ProcessSpawner>) -> SpawnerGuard {
179 let prev = THREAD_SPAWNER.with(|slot| slot.replace(Some(spawner)));
180 SpawnerGuard { prev: Some(prev) }
181}
182
183/// Guard returned by [`install_spawner`]. Restores the previous spawner on
184/// drop so installs nest correctly across tests.
185pub struct SpawnerGuard {
186 prev: Option<Option<Arc<dyn ProcessSpawner>>>,
187}
188
189impl Drop for SpawnerGuard {
190 fn drop(&mut self) {
191 if let Some(prev) = self.prev.take() {
192 THREAD_SPAWNER.with(|slot| {
193 *slot.borrow_mut() = prev;
194 });
195 }
196 }
197}
198
199/// Return the currently installed spawner for this thread, falling back
200/// to the default real spawner.
201pub fn current_spawner() -> Arc<dyn ProcessSpawner> {
202 THREAD_SPAWNER
203 .with(|slot| slot.borrow().clone())
204 .unwrap_or_else(super::real::default_spawner)
205}
206
207/// Spawn a process via the currently installed spawner.
208pub fn spawn_process(spec: SpawnSpec) -> Result<Box<dyn ProcessHandle>, ProcessError> {
209 current_spawner().spawn(spec)
210}