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