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            file_output: matches!(spec.output_capture, OutputCapture::File { .. }),
205            stdin_taken: false,
206            stdout_taken: false,
207            stderr_taken: false,
208        }))
209    }
210}
211
212/// Test-side controller for a [`MockProcess`]. Cloneable; all clones
213/// reference the same underlying state.
214#[derive(Clone)]
215pub struct MockHandleController {
216    state: Arc<MockState>,
217}
218
219impl MockHandleController {
220    /// Append bytes to the mock's stdout buffer. Subsequent reads on the
221    /// stdout reader will see them.
222    pub fn append_stdout(&self, bytes: &[u8]) {
223        let mut data = self.state.stdout.lock().unwrap();
224        data.extend_from_slice(bytes);
225        self.state.stdout_cv.notify_all();
226    }
227
228    /// Append bytes to the mock's stderr buffer.
229    pub fn append_stderr(&self, bytes: &[u8]) {
230        let mut data = self.state.stderr.lock().unwrap();
231        data.extend_from_slice(bytes);
232        self.state.stderr_cv.notify_all();
233    }
234
235    /// Mark the process as having exited with the given status. Drains
236    /// any blocked `wait()` callers and closes the stdout/stderr readers.
237    pub fn complete_with(&self, status: ExitStatus) {
238        let mut exit = self.state.exit.lock().unwrap();
239        if exit.is_none() {
240            *exit = Some(ExitOutcome {
241                status,
242                killed: false,
243            });
244        }
245        drop(exit);
246        self.state.notify_exit_and_pipes();
247    }
248
249    /// Returns true if [`MockKiller::kill`] has been invoked since spawn.
250    pub fn was_killed(&self) -> bool {
251        self.state
252            .exit
253            .lock()
254            .unwrap()
255            .as_ref()
256            .map(|o| o.killed)
257            .unwrap_or(false)
258    }
259
260    /// Returns the bytes the test-tool side wrote to the mock's stdin
261    /// reader (after the process-tool path closed stdin).
262    pub fn stdin_written(&self) -> Vec<u8> {
263        self.state.stdin_written.lock().unwrap().clone()
264    }
265}
266
267struct MockState {
268    /// Bytes available to the stdout reader. Drained as the reader pulls.
269    stdout: Mutex<Vec<u8>>,
270    /// Bytes available to the stderr reader.
271    stderr: Mutex<Vec<u8>>,
272    /// Captured stdin bytes the spawn-side wrote.
273    stdin_written: Mutex<Vec<u8>>,
274    /// Final status, set by `complete_with` or by the killer.
275    exit: Mutex<Option<ExitOutcome>>,
276    exit_cv: Condvar,
277    stdout_cv: Condvar,
278    stderr_cv: Condvar,
279    /// Force-timeout config copied from MockProcessConfig.
280    force_timeout: bool,
281    wait_error: Option<String>,
282    cleanup_report: Option<ProcessCleanupReport>,
283    stdout_hangs_after_exit_until_kill: bool,
284    stderr_hangs_after_exit_until_kill: bool,
285    pipes_released_by_cleanup: Mutex<bool>,
286}
287
288#[derive(Clone, Copy, Debug)]
289struct ExitOutcome {
290    status: ExitStatus,
291    killed: bool,
292}
293
294impl MockState {
295    fn new(config: &MockProcessConfig) -> Self {
296        let exit = config.exit_status.map(|status| ExitOutcome {
297            status,
298            killed: false,
299        });
300        Self {
301            stdout: Mutex::new(config.stdout.clone()),
302            stderr: Mutex::new(config.stderr.clone()),
303            stdin_written: Mutex::new(Vec::new()),
304            exit: Mutex::new(exit),
305            exit_cv: Condvar::new(),
306            stdout_cv: Condvar::new(),
307            stderr_cv: Condvar::new(),
308            force_timeout: config.force_timeout,
309            wait_error: config.wait_error.clone(),
310            cleanup_report: config.cleanup_report.clone(),
311            stdout_hangs_after_exit_until_kill: config.stdout_hangs_after_exit_until_kill,
312            stderr_hangs_after_exit_until_kill: config.stderr_hangs_after_exit_until_kill,
313            pipes_released_by_cleanup: Mutex::new(false),
314        }
315    }
316
317    fn is_exited(&self) -> bool {
318        self.exit.lock().unwrap().is_some()
319    }
320
321    fn wait_for_exit(&self, timeout: Option<Duration>) -> Option<ExitOutcome> {
322        let mut exit = self.exit.lock().unwrap();
323        if let Some(timeout) = timeout {
324            if exit.is_none() {
325                let (next, result) = self.exit_cv.wait_timeout(exit, timeout).unwrap();
326                exit = next;
327                if result.timed_out() && exit.is_none() {
328                    return None;
329                }
330            }
331        } else {
332            while exit.is_none() {
333                exit = self.exit_cv.wait(exit).unwrap();
334            }
335        }
336        *exit
337    }
338
339    fn record_kill(&self) {
340        let mut exit = self.exit.lock().unwrap();
341        if exit.is_none() {
342            *exit = Some(ExitOutcome {
343                status: ExitStatus::from_signal(9),
344                killed: true,
345            });
346        } else if let Some(outcome) = exit.as_mut() {
347            outcome.killed = true;
348        }
349        drop(exit);
350        *self.pipes_released_by_cleanup.lock().unwrap() = true;
351        self.notify_exit_and_pipes();
352    }
353
354    fn pipe_has_reached_eof(&self, kind: PipeKind) -> bool {
355        if !self.is_exited() {
356            return false;
357        }
358        let hangs_until_cleanup = match kind {
359            PipeKind::Stdout => self.stdout_hangs_after_exit_until_kill,
360            PipeKind::Stderr => self.stderr_hangs_after_exit_until_kill,
361        };
362        !hangs_until_cleanup || *self.pipes_released_by_cleanup.lock().unwrap()
363    }
364
365    fn notify_exit_and_pipes(&self) {
366        self.exit_cv.notify_all();
367
368        // Pipe readers wait on the pipe mutex but also observe `exit`. Take
369        // the pipe locks before notifying so an exit cannot be signaled in the
370        // gap between a reader's exit check and its condvar wait.
371        {
372            let _stdout = self.stdout.lock().unwrap();
373            self.stdout_cv.notify_all();
374        }
375        {
376            let _stderr = self.stderr.lock().unwrap();
377            self.stderr_cv.notify_all();
378        }
379    }
380
381    fn cleanup_report(&self, root_pid: u32, signal: i32) -> ProcessCleanupReport {
382        self.cleanup_report
383            .clone()
384            .unwrap_or_else(|| ProcessCleanupReport::for_signal(Some(root_pid), signal))
385    }
386}
387
388/// Mock process backed by a shared `MockState`.
389pub struct MockProcess {
390    pid: u32,
391    pgid: Option<u32>,
392    killer: Arc<dyn ProcessKiller>,
393    state: Arc<MockState>,
394    file_output: bool,
395    stdin_taken: bool,
396    stdout_taken: bool,
397    stderr_taken: bool,
398}
399
400impl ProcessHandle for MockProcess {
401    fn pid(&self) -> Option<u32> {
402        Some(self.pid)
403    }
404
405    fn process_group_id(&self) -> Option<u32> {
406        self.pgid
407    }
408
409    fn killer(&self) -> Arc<dyn ProcessKiller> {
410        Arc::clone(&self.killer)
411    }
412
413    fn take_stdin(&mut self) -> Option<Box<dyn Write + Send>> {
414        if self.stdin_taken {
415            return None;
416        }
417        self.stdin_taken = true;
418        Some(Box::new(MockStdin {
419            state: Arc::clone(&self.state),
420        }))
421    }
422
423    fn take_stdout(&mut self) -> Option<Box<dyn Read + Send>> {
424        if self.file_output {
425            return None;
426        }
427        if self.stdout_taken {
428            return None;
429        }
430        self.stdout_taken = true;
431        Some(Box::new(MockStdoutReader {
432            state: Arc::clone(&self.state),
433            kind: PipeKind::Stdout,
434        }))
435    }
436
437    fn take_stderr(&mut self) -> Option<Box<dyn Read + Send>> {
438        if self.file_output {
439            return None;
440        }
441        if self.stderr_taken {
442            return None;
443        }
444        self.stderr_taken = true;
445        Some(Box::new(MockStdoutReader {
446            state: Arc::clone(&self.state),
447            kind: PipeKind::Stderr,
448        }))
449    }
450
451    fn wait_with_timeout(
452        &mut self,
453        timeout: Option<Duration>,
454        interrupt: &dyn Fn() -> bool,
455    ) -> io::Result<WaitOutcome> {
456        if let Some(error) = self.state.wait_error.as_ref() {
457            return Err(io::Error::other(error.clone()));
458        }
459        if self.state.force_timeout {
460            self.state.record_kill();
461            return Ok(WaitOutcome::TimedOut(
462                self.state.cleanup_report(self.pid, 9),
463            ));
464        }
465        // Wait in short condvar slices so the interrupt callback is observed
466        // (mirrors the real spawner's ~20ms `try_wait` poll loop) while
467        // remaining deterministic: nothing here depends on how many slices
468        // elapse, only on which condition fires first.
469        let deadline = timeout.map(|timeout| std::time::Instant::now() + timeout);
470        loop {
471            if let Some(outcome) = self.state.wait_for_exit(Some(Duration::from_millis(5))) {
472                return Ok(WaitOutcome::Exited(outcome.status));
473            }
474            if interrupt() {
475                self.state.record_kill();
476                return Ok(WaitOutcome::Interrupted(
477                    self.state.cleanup_report(self.pid, 15),
478                ));
479            }
480            if deadline.is_some_and(|deadline| std::time::Instant::now() >= deadline) {
481                self.state.record_kill();
482                return Ok(WaitOutcome::TimedOut(
483                    self.state.cleanup_report(self.pid, 9),
484                ));
485            }
486        }
487    }
488
489    fn wait(&mut self) -> io::Result<ExitStatus> {
490        if let Some(error) = self.state.wait_error.as_ref() {
491            return Err(io::Error::other(error.clone()));
492        }
493        let outcome = self
494            .state
495            .wait_for_exit(None)
496            .expect("wait without timeout returned None");
497        Ok(outcome.status)
498    }
499}
500
501struct MockStdin {
502    state: Arc<MockState>,
503}
504
505impl Write for MockStdin {
506    fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
507        self.state
508            .stdin_written
509            .lock()
510            .unwrap()
511            .extend_from_slice(buf);
512        Ok(buf.len())
513    }
514
515    fn flush(&mut self) -> io::Result<()> {
516        Ok(())
517    }
518}
519
520#[derive(Clone, Copy)]
521enum PipeKind {
522    Stdout,
523    Stderr,
524}
525
526struct MockStdoutReader {
527    state: Arc<MockState>,
528    kind: PipeKind,
529}
530
531impl MockStdoutReader {
532    fn pipe_lock(&self) -> &Mutex<Vec<u8>> {
533        match self.kind {
534            PipeKind::Stdout => &self.state.stdout,
535            PipeKind::Stderr => &self.state.stderr,
536        }
537    }
538
539    fn pipe_cv(&self) -> &Condvar {
540        match self.kind {
541            PipeKind::Stdout => &self.state.stdout_cv,
542            PipeKind::Stderr => &self.state.stderr_cv,
543        }
544    }
545}
546
547impl Read for MockStdoutReader {
548    fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
549        let lock = self.pipe_lock();
550        let cv = self.pipe_cv();
551        let mut data = lock.lock().unwrap();
552        loop {
553            if !data.is_empty() {
554                let n = data.len().min(buf.len());
555                buf[..n].copy_from_slice(&data[..n]);
556                data.drain(..n);
557                return Ok(n);
558            }
559            // Empty buffer: if the process is exited, signal EOF;
560            // otherwise wait for either more bytes or exit.
561            if self.state.pipe_has_reached_eof(self.kind) {
562                return Ok(0);
563            }
564            data = cv.wait(data).unwrap();
565        }
566    }
567}
568
569struct MockKiller {
570    pid: u32,
571    state: Arc<MockState>,
572}
573
574impl ProcessKiller for MockKiller {
575    fn kill(&self) -> ProcessCleanupReport {
576        self.state.record_kill();
577        self.state.cleanup_report(self.pid, 9)
578    }
579}