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