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