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