Skip to main content

harn_hostlib/process/
mock.rs

1//! Test-only [`ProcessSpawner`] / [`ProcessHandle`] implementations.
2//!
3//! Tests install a [`MockSpawner`] via
4//! [`super::handle::install_spawner`], enqueue per-spawn responses, and
5//! drive the resulting [`MockProcess`] state explicitly via the controller
6//! returned at enqueue time. There are zero real subprocesses, no
7//! `thread::sleep`, no `Instant::now` polling.
8
9use std::collections::VecDeque;
10use std::io::{self, Read, Write};
11use std::sync::{Arc, Condvar, Mutex};
12use std::time::Duration;
13
14use super::handle::{
15    ExitStatus, ProcessError, ProcessHandle, ProcessKiller, ProcessSpawner, SpawnSpec,
16};
17
18/// Behaviour to script for a single mocked spawn.
19#[derive(Clone, Debug)]
20pub struct MockProcessConfig {
21    /// PID returned by [`ProcessHandle::pid`] for this spawn. Must be > 0
22    /// because `process_tools` test assertions check `> 0`.
23    pub pid: u32,
24    /// Process-group id returned by [`ProcessHandle::process_group_id`].
25    pub pgid: Option<u32>,
26    /// Initial stdout bytes available before any test-side appends.
27    pub stdout: Vec<u8>,
28    /// Initial stderr bytes available before any test-side appends.
29    pub stderr: Vec<u8>,
30    /// If `Some`, the process is already complete and `wait*` returns this
31    /// immediately. If `None`, the process stays "running" until the test
32    /// signals exit via the controller.
33    pub exit_status: Option<ExitStatus>,
34    /// If `true`, [`ProcessHandle::wait_with_timeout`] reports a timeout
35    /// regardless of `exit_status`. Used to test the timeout path without
36    /// real subprocess scheduling.
37    pub force_timeout: bool,
38    /// If non-`None`, force [`ProcessSpawner::spawn`] to fail with this
39    /// error instead of returning a handle. Used to exercise sandbox /
40    /// invalid-argv error paths.
41    pub spawn_error: Option<ProcessError>,
42}
43
44impl Default for MockProcessConfig {
45    fn default() -> Self {
46        Self {
47            pid: 99_999,
48            pgid: Some(99_999),
49            stdout: Vec::new(),
50            stderr: Vec::new(),
51            exit_status: Some(ExitStatus::from_code(0)),
52            force_timeout: false,
53            spawn_error: None,
54        }
55    }
56}
57
58impl MockProcessConfig {
59    /// Convenience: build a successful spawn with the given exit code, no
60    /// stdout/stderr.
61    pub fn completed(exit_code: i32) -> Self {
62        Self {
63            exit_status: Some(ExitStatus::from_code(exit_code)),
64            ..Self::default()
65        }
66    }
67
68    /// Convenience: build a successful spawn with the given exit code and
69    /// inline stdout payload.
70    pub fn with_stdout(exit_code: i32, stdout: impl Into<Vec<u8>>) -> Self {
71        Self {
72            stdout: stdout.into(),
73            exit_status: Some(ExitStatus::from_code(exit_code)),
74            ..Self::default()
75        }
76    }
77
78    /// Convenience: build a config that stays "running" until the test
79    /// signals exit via the controller. Used for long-running and
80    /// timeout tests.
81    pub fn running() -> Self {
82        Self {
83            exit_status: None,
84            ..Self::default()
85        }
86    }
87}
88
89#[derive(Default)]
90struct MockSpawnerInner {
91    queue: VecDeque<(MockProcessConfig, Arc<MockState>)>,
92    captured: Vec<SpawnSpec>,
93    last_controller: Option<MockHandleController>,
94}
95
96/// Test [`ProcessSpawner`] that returns scripted [`MockProcess`] handles
97/// and captures the [`SpawnSpec`] passed to each spawn.
98pub struct MockSpawner {
99    inner: Mutex<MockSpawnerInner>,
100}
101
102impl Default for MockSpawner {
103    fn default() -> Self {
104        Self::new()
105    }
106}
107
108impl MockSpawner {
109    /// Build an empty spawner. Call [`Self::enqueue`] to script behaviour
110    /// for each anticipated spawn.
111    pub fn new() -> Self {
112        Self {
113            inner: Mutex::new(MockSpawnerInner::default()),
114        }
115    }
116
117    /// Enqueue a configuration for the next spawn. Returns a controller
118    /// that lets the test drive the resulting [`MockProcess`] state
119    /// (append stdout, complete with status, etc.). For one-shot
120    /// foreground tests, the controller may simply be dropped.
121    pub fn enqueue(&self, config: MockProcessConfig) -> MockHandleController {
122        let state = Arc::new(MockState::new(&config));
123        let controller = MockHandleController {
124            state: Arc::clone(&state),
125        };
126        let mut inner = self.inner.lock().expect("MockSpawner mutex poisoned");
127        inner.queue.push_back((config, state));
128        inner.last_controller = Some(controller.clone());
129        controller
130    }
131
132    /// Returns the [`SpawnSpec`] objects captured so far, in order.
133    pub fn captured(&self) -> Vec<SpawnSpec> {
134        self.inner
135            .lock()
136            .expect("MockSpawner mutex poisoned")
137            .captured
138            .clone()
139    }
140
141    /// Returns the latest controller installed via [`Self::enqueue`].
142    /// Convenience for tests that only enqueue one config.
143    pub fn last_controller(&self) -> Option<MockHandleController> {
144        self.inner
145            .lock()
146            .expect("MockSpawner mutex poisoned")
147            .last_controller
148            .clone()
149    }
150}
151
152impl ProcessSpawner for MockSpawner {
153    fn spawn(&self, spec: SpawnSpec) -> Result<Box<dyn ProcessHandle>, ProcessError> {
154        let (config, state) = {
155            let mut inner = self.inner.lock().expect("MockSpawner mutex poisoned");
156            inner.captured.push(spec);
157            inner.queue.pop_front().expect(
158                "MockSpawner: spawn() called with no enqueued configuration. Call \
159                 MockSpawner::enqueue(...) before each expected spawn.",
160            )
161        };
162
163        if let Some(err) = config.spawn_error {
164            return Err(err);
165        }
166
167        let killer: Arc<dyn ProcessKiller> = Arc::new(MockKiller {
168            state: Arc::clone(&state),
169        });
170
171        Ok(Box::new(MockProcess {
172            pid: config.pid,
173            pgid: config.pgid,
174            killer,
175            state,
176            stdin_taken: false,
177            stdout_taken: false,
178            stderr_taken: false,
179        }))
180    }
181}
182
183/// Test-side controller for a [`MockProcess`]. Cloneable; all clones
184/// reference the same underlying state.
185#[derive(Clone)]
186pub struct MockHandleController {
187    state: Arc<MockState>,
188}
189
190impl MockHandleController {
191    /// Append bytes to the mock's stdout buffer. Subsequent reads on the
192    /// stdout reader will see them.
193    pub fn append_stdout(&self, bytes: &[u8]) {
194        let mut data = self.state.stdout.lock().unwrap();
195        data.extend_from_slice(bytes);
196        self.state.stdout_cv.notify_all();
197    }
198
199    /// Append bytes to the mock's stderr buffer.
200    pub fn append_stderr(&self, bytes: &[u8]) {
201        let mut data = self.state.stderr.lock().unwrap();
202        data.extend_from_slice(bytes);
203        self.state.stderr_cv.notify_all();
204    }
205
206    /// Mark the process as having exited with the given status. Drains
207    /// any blocked `wait()` callers and closes the stdout/stderr readers.
208    pub fn complete_with(&self, status: ExitStatus) {
209        let mut exit = self.state.exit.lock().unwrap();
210        if exit.is_none() {
211            *exit = Some(ExitOutcome {
212                status,
213                killed: false,
214            });
215        }
216        drop(exit);
217        self.state.notify_exit_and_pipes();
218    }
219
220    /// Returns true if [`MockKiller::kill`] has been invoked since spawn.
221    pub fn was_killed(&self) -> bool {
222        self.state
223            .exit
224            .lock()
225            .unwrap()
226            .as_ref()
227            .map(|o| o.killed)
228            .unwrap_or(false)
229    }
230
231    /// Returns the bytes the test-tool side wrote to the mock's stdin
232    /// reader (after the process-tool path closed stdin).
233    pub fn stdin_written(&self) -> Vec<u8> {
234        self.state.stdin_written.lock().unwrap().clone()
235    }
236}
237
238struct MockState {
239    /// Bytes available to the stdout reader. Drained as the reader pulls.
240    stdout: Mutex<Vec<u8>>,
241    /// Bytes available to the stderr reader.
242    stderr: Mutex<Vec<u8>>,
243    /// Captured stdin bytes the spawn-side wrote.
244    stdin_written: Mutex<Vec<u8>>,
245    /// Final status, set by `complete_with` or by the killer.
246    exit: Mutex<Option<ExitOutcome>>,
247    exit_cv: Condvar,
248    stdout_cv: Condvar,
249    stderr_cv: Condvar,
250    /// Force-timeout config copied from MockProcessConfig.
251    force_timeout: bool,
252}
253
254#[derive(Clone, Copy, Debug)]
255struct ExitOutcome {
256    status: ExitStatus,
257    killed: bool,
258}
259
260impl MockState {
261    fn new(config: &MockProcessConfig) -> Self {
262        let exit = config.exit_status.map(|status| ExitOutcome {
263            status,
264            killed: false,
265        });
266        Self {
267            stdout: Mutex::new(config.stdout.clone()),
268            stderr: Mutex::new(config.stderr.clone()),
269            stdin_written: Mutex::new(Vec::new()),
270            exit: Mutex::new(exit),
271            exit_cv: Condvar::new(),
272            stdout_cv: Condvar::new(),
273            stderr_cv: Condvar::new(),
274            force_timeout: config.force_timeout,
275        }
276    }
277
278    fn is_exited(&self) -> bool {
279        self.exit.lock().unwrap().is_some()
280    }
281
282    fn wait_for_exit(&self, timeout: Option<Duration>) -> Option<ExitOutcome> {
283        let mut exit = self.exit.lock().unwrap();
284        if let Some(timeout) = timeout {
285            if exit.is_none() {
286                let (next, result) = self.exit_cv.wait_timeout(exit, timeout).unwrap();
287                exit = next;
288                if result.timed_out() && exit.is_none() {
289                    return None;
290                }
291            }
292        } else {
293            while exit.is_none() {
294                exit = self.exit_cv.wait(exit).unwrap();
295            }
296        }
297        *exit
298    }
299
300    fn record_kill(&self) {
301        let mut exit = self.exit.lock().unwrap();
302        if exit.is_none() {
303            *exit = Some(ExitOutcome {
304                status: ExitStatus::from_signal(9),
305                killed: true,
306            });
307        } else if let Some(outcome) = exit.as_mut() {
308            outcome.killed = true;
309        }
310        drop(exit);
311        self.notify_exit_and_pipes();
312    }
313
314    fn notify_exit_and_pipes(&self) {
315        self.exit_cv.notify_all();
316
317        // Pipe readers wait on the pipe mutex but also observe `exit`. Take
318        // the pipe locks before notifying so an exit cannot be signaled in the
319        // gap between a reader's exit check and its condvar wait.
320        {
321            let _stdout = self.stdout.lock().unwrap();
322            self.stdout_cv.notify_all();
323        }
324        {
325            let _stderr = self.stderr.lock().unwrap();
326            self.stderr_cv.notify_all();
327        }
328    }
329}
330
331/// Mock process backed by a shared `MockState`.
332pub struct MockProcess {
333    pid: u32,
334    pgid: Option<u32>,
335    killer: Arc<dyn ProcessKiller>,
336    state: Arc<MockState>,
337    stdin_taken: bool,
338    stdout_taken: bool,
339    stderr_taken: bool,
340}
341
342impl ProcessHandle for MockProcess {
343    fn pid(&self) -> Option<u32> {
344        Some(self.pid)
345    }
346
347    fn process_group_id(&self) -> Option<u32> {
348        self.pgid
349    }
350
351    fn killer(&self) -> Arc<dyn ProcessKiller> {
352        Arc::clone(&self.killer)
353    }
354
355    fn take_stdin(&mut self) -> Option<Box<dyn Write + Send>> {
356        if self.stdin_taken {
357            return None;
358        }
359        self.stdin_taken = true;
360        Some(Box::new(MockStdin {
361            state: Arc::clone(&self.state),
362        }))
363    }
364
365    fn take_stdout(&mut self) -> Option<Box<dyn Read + Send>> {
366        if self.stdout_taken {
367            return None;
368        }
369        self.stdout_taken = true;
370        Some(Box::new(MockStdoutReader {
371            state: Arc::clone(&self.state),
372            kind: PipeKind::Stdout,
373        }))
374    }
375
376    fn take_stderr(&mut self) -> Option<Box<dyn Read + Send>> {
377        if self.stderr_taken {
378            return None;
379        }
380        self.stderr_taken = true;
381        Some(Box::new(MockStdoutReader {
382            state: Arc::clone(&self.state),
383            kind: PipeKind::Stderr,
384        }))
385    }
386
387    fn wait_with_timeout(
388        &mut self,
389        timeout: Option<Duration>,
390    ) -> io::Result<(Option<ExitStatus>, bool)> {
391        if self.state.force_timeout {
392            self.state.record_kill();
393            return Ok((None, true));
394        }
395        let Some(timeout) = timeout else {
396            let outcome = self
397                .state
398                .wait_for_exit(None)
399                .expect("wait without timeout returned None");
400            return Ok((Some(outcome.status), false));
401        };
402        match self.state.wait_for_exit(Some(timeout)) {
403            Some(outcome) => Ok((Some(outcome.status), false)),
404            None => {
405                self.state.record_kill();
406                Ok((None, true))
407            }
408        }
409    }
410
411    fn wait(&mut self) -> io::Result<ExitStatus> {
412        let outcome = self
413            .state
414            .wait_for_exit(None)
415            .expect("wait without timeout returned None");
416        Ok(outcome.status)
417    }
418}
419
420struct MockStdin {
421    state: Arc<MockState>,
422}
423
424impl Write for MockStdin {
425    fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
426        self.state
427            .stdin_written
428            .lock()
429            .unwrap()
430            .extend_from_slice(buf);
431        Ok(buf.len())
432    }
433
434    fn flush(&mut self) -> io::Result<()> {
435        Ok(())
436    }
437}
438
439#[derive(Clone, Copy)]
440enum PipeKind {
441    Stdout,
442    Stderr,
443}
444
445struct MockStdoutReader {
446    state: Arc<MockState>,
447    kind: PipeKind,
448}
449
450impl MockStdoutReader {
451    fn pipe_lock(&self) -> &Mutex<Vec<u8>> {
452        match self.kind {
453            PipeKind::Stdout => &self.state.stdout,
454            PipeKind::Stderr => &self.state.stderr,
455        }
456    }
457
458    fn pipe_cv(&self) -> &Condvar {
459        match self.kind {
460            PipeKind::Stdout => &self.state.stdout_cv,
461            PipeKind::Stderr => &self.state.stderr_cv,
462        }
463    }
464}
465
466impl Read for MockStdoutReader {
467    fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
468        let lock = self.pipe_lock();
469        let cv = self.pipe_cv();
470        let mut data = lock.lock().unwrap();
471        loop {
472            if !data.is_empty() {
473                let n = data.len().min(buf.len());
474                buf[..n].copy_from_slice(&data[..n]);
475                data.drain(..n);
476                return Ok(n);
477            }
478            // Empty buffer: if the process is exited, signal EOF;
479            // otherwise wait for either more bytes or exit.
480            if self.state.is_exited() {
481                return Ok(0);
482            }
483            data = cv.wait(data).unwrap();
484        }
485    }
486}
487
488struct MockKiller {
489    state: Arc<MockState>,
490}
491
492impl ProcessKiller for MockKiller {
493    fn kill(&self) {
494        self.state.record_kill();
495    }
496}