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