Skip to main content

supercov_engine/
process_supervision.rs

1//! Privacy-preserving child-process supervision for arbitrary test commands.
2
3// Descriptor and watchdog handling reads the filesystem only on Unix; on
4// Windows the module path is unused and the first Windows build said so.
5#[cfg(unix)]
6use std::fs;
7use std::{
8    ffi::OsString,
9    fs::OpenOptions,
10    io::{self, Read, Write},
11    path::{Path, PathBuf},
12    process::{Child, Command, ExitStatus, Stdio},
13    sync::{
14        Mutex, MutexGuard,
15        atomic::{AtomicI32, Ordering},
16    },
17    thread,
18    time::{Duration, Instant},
19};
20
21use serde::{Deserialize, Serialize};
22use supercov_contracts::{
23    COMMAND_TERMINATION_GRACE_MS, COMMAND_TIMEOUT_EXIT_CODE, DEFAULT_DIAGNOSTIC_INTERVAL_MS,
24};
25
26const POLL_INTERVAL: Duration = Duration::from_millis(10);
27
28#[cfg(unix)]
29use std::os::{
30    fd::{AsRawFd, FromRawFd, OwnedFd},
31    unix::{ffi::OsStrExt as _, process::CommandExt as _},
32};
33
34#[derive(Debug)]
35pub enum SupervisionError {
36    InvalidMilliseconds {
37        name: String,
38    },
39    EmptyCommand,
40    Spawn {
41        program: OsString,
42        source: io::Error,
43    },
44    Wait(io::Error),
45    Signal(io::Error),
46    PlatformOperation {
47        operation: &'static str,
48        source: io::Error,
49    },
50    UnsupportedPlatform(&'static str),
51}
52
53impl std::fmt::Display for SupervisionError {
54    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
55        match self {
56            Self::InvalidMilliseconds { name } => {
57                write!(
58                    formatter,
59                    "{name} must be a positive integer number of milliseconds"
60                )
61            }
62            Self::EmptyCommand => write!(formatter, "test command must not be empty"),
63            Self::Spawn { program, source } => {
64                write!(
65                    formatter,
66                    "could not spawn {}: {source}",
67                    program.to_string_lossy()
68                )
69            }
70            Self::Wait(error) => write!(formatter, "could not wait for test command: {error}"),
71            Self::Signal(error) => {
72                write!(formatter, "could not install signal forwarding: {error}")
73            }
74            Self::PlatformOperation { operation, source } => {
75                write!(formatter, "could not {operation}: {source}")
76            }
77            Self::UnsupportedPlatform(reason) => write!(
78                formatter,
79                "unsupported process supervision platform: {reason}"
80            ),
81        }
82    }
83}
84
85impl std::error::Error for SupervisionError {}
86
87#[derive(Debug, Clone, PartialEq, Eq)]
88pub struct CommandSpec {
89    pub program: OsString,
90    pub arguments: Vec<OsString>,
91    pub cwd: PathBuf,
92    /// `None` inherits the supervisor environment. `Some` clears it first and
93    /// installs exactly these values.
94    pub environment: Option<Vec<(OsString, OsString)>>,
95    /// When set, stdout and stderr are merged into this newly-created file.
96    /// The orchestration layer owns publication and cleanup of the file.
97    pub captured_output: Option<PathBuf>,
98}
99
100impl CommandSpec {
101    pub fn command(&self) -> Result<Command, SupervisionError> {
102        if self.program.is_empty() {
103            return Err(SupervisionError::EmptyCommand);
104        }
105        let mut command = Command::new(&self.program);
106        command
107            .args(&self.arguments)
108            .current_dir(&self.cwd)
109            .stdin(Stdio::inherit());
110        if let Some(path) = &self.captured_output {
111            let output = OpenOptions::new()
112                .write(true)
113                .create_new(true)
114                .open(path)
115                .map_err(|source| SupervisionError::PlatformOperation {
116                    operation: "create captured process output",
117                    source,
118                })?;
119            let errors =
120                output
121                    .try_clone()
122                    .map_err(|source| SupervisionError::PlatformOperation {
123                        operation: "clone captured process output",
124                        source,
125                    })?;
126            command
127                .stdout(Stdio::from(output))
128                .stderr(Stdio::from(errors));
129        } else {
130            command.stdout(Stdio::inherit()).stderr(Stdio::inherit());
131        }
132        if let Some(environment) = &self.environment {
133            command.env_clear().envs(environment.iter().cloned());
134        }
135        #[cfg(windows)]
136        {
137            use std::os::windows::process::CommandExt;
138            use windows_sys::Win32::System::Threading::{
139                CREATE_NEW_PROCESS_GROUP, CREATE_SUSPENDED,
140            };
141            command.creation_flags(CREATE_NEW_PROCESS_GROUP | CREATE_SUSPENDED);
142        }
143        Ok(command)
144    }
145}
146
147#[derive(Debug, Clone, Copy, PartialEq, Eq)]
148pub struct SupervisionOptions {
149    pub diagnostic_interval: Duration,
150    pub timeout: Option<Duration>,
151    pub termination_grace: Duration,
152}
153
154impl Default for SupervisionOptions {
155    fn default() -> Self {
156        Self {
157            diagnostic_interval: Duration::from_millis(DEFAULT_DIAGNOSTIC_INTERVAL_MS),
158            timeout: None,
159            termination_grace: Duration::from_millis(COMMAND_TERMINATION_GRACE_MS),
160        }
161    }
162}
163
164impl SupervisionOptions {
165    pub fn from_environment() -> Result<Self, SupervisionError> {
166        Ok(Self {
167            diagnostic_interval: positive_milliseconds(
168                std::env::var("SUPERCOV_DIAGNOSTIC_INTERVAL_MS")
169                    .ok()
170                    .as_deref(),
171                "SUPERCOV_DIAGNOSTIC_INTERVAL_MS",
172            )?
173            .unwrap_or_else(|| Duration::from_millis(DEFAULT_DIAGNOSTIC_INTERVAL_MS)),
174            timeout: positive_milliseconds(
175                std::env::var("SUPERCOV_COMMAND_TIMEOUT_MS").ok().as_deref(),
176                "SUPERCOV_COMMAND_TIMEOUT_MS",
177            )?,
178            termination_grace: Duration::from_millis(COMMAND_TERMINATION_GRACE_MS),
179        })
180    }
181}
182
183#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
184#[serde(rename_all = "camelCase")]
185pub struct ProcessSnapshot {
186    pub pid: u32,
187    pub parent_pid: u32,
188    #[serde(skip_serializing_if = "Option::is_none")]
189    pub state: Option<String>,
190    #[serde(skip_serializing_if = "Option::is_none")]
191    pub cpu_tenths: Option<u64>,
192    pub executable: String,
193}
194
195#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
196#[serde(rename_all = "UPPERCASE")]
197pub enum ForwardedSignal {
198    Sighup,
199    Sigint,
200    Sigterm,
201}
202
203impl ForwardedSignal {
204    pub fn exit_code(self) -> i32 {
205        match self {
206            Self::Sighup => 129,
207            Self::Sigint => 130,
208            Self::Sigterm => 143,
209        }
210    }
211
212    #[cfg(unix)]
213    fn raw(self) -> i32 {
214        match self {
215            Self::Sighup => libc::SIGHUP,
216            Self::Sigint => libc::SIGINT,
217            Self::Sigterm => libc::SIGTERM,
218        }
219    }
220}
221
222#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
223#[serde(rename_all = "camelCase")]
224pub struct SupervisedResult {
225    pub status: Option<i32>,
226    pub signal: Option<i32>,
227    pub timed_out: bool,
228    pub interrupted_signal: Option<ForwardedSignal>,
229}
230
231#[derive(Debug)]
232pub struct SupervisedOutput {
233    pub result: SupervisedResult,
234    pub stdout: Vec<u8>,
235    pub stderr: Vec<u8>,
236}
237
238impl SupervisedResult {
239    pub fn exit_code(&self) -> i32 {
240        if self.timed_out {
241            COMMAND_TIMEOUT_EXIT_CODE
242        } else if let Some(signal) = self.interrupted_signal {
243            signal.exit_code()
244        } else {
245            self.status.unwrap_or(128)
246        }
247    }
248}
249
250pub fn positive_milliseconds(
251    value: Option<&str>,
252    name: &str,
253) -> Result<Option<Duration>, SupervisionError> {
254    let Some(value) = value.filter(|value| !value.is_empty()) else {
255        return Ok(None);
256    };
257    let milliseconds = value
258        .parse::<u64>()
259        .ok()
260        .filter(|milliseconds| *milliseconds > 0)
261        .ok_or_else(|| SupervisionError::InvalidMilliseconds { name: name.into() })?;
262    Ok(Some(Duration::from_millis(milliseconds)))
263}
264
265fn process_inventory() -> Vec<ProcessSnapshot> {
266    use sysinfo::{ProcessRefreshKind, RefreshKind, System};
267
268    let system = System::new_with_specifics(
269        RefreshKind::nothing().with_processes(ProcessRefreshKind::nothing().with_cpu()),
270    );
271    system
272        .processes()
273        .iter()
274        .map(|(pid, process)| ProcessSnapshot {
275            pid: pid.as_u32(),
276            parent_pid: process.parent().map_or(0, sysinfo::Pid::as_u32),
277            state: Some(process_status(process.status()).into()),
278            cpu_tenths: Some(process.accumulated_cpu_time() / 100),
279            executable: Path::new(process.name())
280                .file_name()
281                .and_then(|value| value.to_str())
282                .unwrap_or("unknown")
283                .to_owned(),
284        })
285        .collect()
286}
287
288fn process_status(status: sysinfo::ProcessStatus) -> &'static str {
289    use sysinfo::ProcessStatus;
290    match status {
291        ProcessStatus::Idle => "I",
292        ProcessStatus::Run => "R",
293        ProcessStatus::Sleep => "S",
294        ProcessStatus::Stop => "T",
295        ProcessStatus::Zombie => "Z",
296        ProcessStatus::Tracing => "t",
297        ProcessStatus::Dead => "X",
298        ProcessStatus::Wakekill => "K",
299        ProcessStatus::Waking => "W",
300        ProcessStatus::Parked => "P",
301        ProcessStatus::LockBlocked => "L",
302        ProcessStatus::UninterruptibleDiskSleep => "D",
303        ProcessStatus::Suspended => "S",
304        ProcessStatus::Unknown(_) => "?",
305    }
306}
307
308pub fn descendant_process_tree(root_pid: u32) -> Vec<ProcessSnapshot> {
309    let inventory = process_inventory();
310    let mut descendants = std::collections::BTreeSet::from([root_pid]);
311    loop {
312        let before = descendants.len();
313        for process in &inventory {
314            if descendants.contains(&process.parent_pid) {
315                descendants.insert(process.pid);
316            }
317        }
318        if descendants.len() == before {
319            break;
320        }
321    }
322    let mut result = inventory
323        .into_iter()
324        .filter(|process| descendants.contains(&process.pid))
325        .collect::<Vec<_>>();
326    result.sort_by_key(|process| process.pid);
327    result
328}
329
330fn format_duration(milliseconds: u128) -> String {
331    if milliseconds < 1_000 {
332        return format!("{milliseconds}ms");
333    }
334    let seconds = (milliseconds + 500) / 1_000;
335    if seconds < 60 {
336        return format!("{seconds}s");
337    }
338    format!("{}m{:02}s", seconds / 60, seconds % 60)
339}
340
341pub fn format_process_diagnostic(
342    root_pid: u32,
343    elapsed: Duration,
344    tree: &[ProcessSnapshot],
345) -> String {
346    let mut output = format!(
347        "[supercov] command still running after {}",
348        format_duration(elapsed.as_millis())
349    );
350    if tree.is_empty() {
351        output.push_str(&format!("\n  pid={root_pid} process details unavailable"));
352        return output;
353    }
354    for process in tree {
355        output.push_str(&format!(
356            "\n  pid={} ppid={} exe={}",
357            process.pid, process.parent_pid, process.executable
358        ));
359        if let Some(state) = &process.state {
360            output.push_str(&format!(" state={state}"));
361        }
362        if let Some(cpu_tenths) = process.cpu_tenths {
363            output.push_str(&format!(" cpu={}.{}s", cpu_tenths / 10, cpu_tenths % 10));
364        }
365    }
366    output
367}
368
369#[cfg(unix)]
370struct SignalFlags {
371    _exclusive: MutexGuard<'static, ()>,
372    previous: Vec<(i32, libc::sigaction)>,
373}
374
375#[cfg(unix)]
376impl SignalFlags {
377    fn install() -> Result<Self, SupervisionError> {
378        let exclusive = SIGNAL_HANDLER_LOCK
379            .lock()
380            .unwrap_or_else(std::sync::PoisonError::into_inner);
381        RECEIVED_SIGNAL.store(0, Ordering::SeqCst);
382        let mut previous = Vec::new();
383        for signal in [libc::SIGHUP, libc::SIGINT, libc::SIGTERM] {
384            // SAFETY: zero is a valid initial state for `sigaction`; every
385            // field used by the kernel is initialized below before the call.
386            let mut action = unsafe { std::mem::zeroed::<libc::sigaction>() };
387            action.sa_sigaction = record_signal as *const () as usize;
388            // SAFETY: `action.sa_mask` is a valid, writable signal set.
389            unsafe { libc::sigemptyset(&mut action.sa_mask) };
390            action.sa_flags = 0;
391            // SAFETY: `old` is initialized by a successful `sigaction` call.
392            let mut old = unsafe { std::mem::zeroed::<libc::sigaction>() };
393            // SAFETY: pointers reference live `sigaction` values and the
394            // signal is one of the three catchable POSIX signals above.
395            if unsafe { libc::sigaction(signal, &action, &mut old) } != 0 {
396                for (installed, old) in previous.iter().rev() {
397                    // SAFETY: restores a handler returned by `sigaction`.
398                    let _ = unsafe { libc::sigaction(*installed, old, std::ptr::null_mut()) };
399                }
400                return Err(SupervisionError::Signal(io::Error::last_os_error()));
401            }
402            previous.push((signal, old));
403        }
404        Ok(Self {
405            _exclusive: exclusive,
406            previous,
407        })
408    }
409
410    fn received(&self) -> Option<ForwardedSignal> {
411        // A supervisor is shared by every concurrently running child in one
412        // execution session. Keep the signal visible until the session guard
413        // is dropped so every process group observes the same interruption.
414        match RECEIVED_SIGNAL.load(Ordering::SeqCst) {
415            libc::SIGHUP => Some(ForwardedSignal::Sighup),
416            libc::SIGINT => Some(ForwardedSignal::Sigint),
417            libc::SIGTERM => Some(ForwardedSignal::Sigterm),
418            _ => None,
419        }
420    }
421}
422
423#[cfg(unix)]
424impl Drop for SignalFlags {
425    fn drop(&mut self) {
426        for (signal, previous) in self.previous.drain(..).rev() {
427            // SAFETY: `previous` came directly from a successful `sigaction`
428            // call for the same signal and remains live for this call.
429            let _ = unsafe { libc::sigaction(signal, &previous, std::ptr::null_mut()) };
430        }
431        RECEIVED_SIGNAL.store(0, Ordering::SeqCst);
432    }
433}
434
435#[cfg(unix)]
436static SIGNAL_HANDLER_LOCK: Mutex<()> = Mutex::new(());
437#[cfg(unix)]
438static RECEIVED_SIGNAL: AtomicI32 = AtomicI32::new(0);
439
440#[cfg(unix)]
441extern "C" fn record_signal(signal: i32) {
442    RECEIVED_SIGNAL.store(signal, Ordering::SeqCst);
443}
444
445#[cfg(windows)]
446struct SignalFlags {
447    _exclusive: MutexGuard<'static, ()>,
448}
449
450#[cfg(windows)]
451impl SignalFlags {
452    fn install() -> Result<Self, SupervisionError> {
453        use windows_sys::Win32::System::Console::SetConsoleCtrlHandler;
454
455        let exclusive = SIGNAL_HANDLER_LOCK
456            .lock()
457            .unwrap_or_else(std::sync::PoisonError::into_inner);
458        RECEIVED_SIGNAL.store(0, Ordering::SeqCst);
459        // SAFETY: `record_console_signal` has the required system ABI and
460        // remains installed only while this guard is alive.
461        if unsafe { SetConsoleCtrlHandler(Some(record_console_signal), 1) } == 0 {
462            return Err(SupervisionError::Signal(io::Error::last_os_error()));
463        }
464        Ok(Self {
465            _exclusive: exclusive,
466        })
467    }
468
469    fn received(&self) -> Option<ForwardedSignal> {
470        match RECEIVED_SIGNAL.load(Ordering::SeqCst) {
471            2 => Some(ForwardedSignal::Sigint),
472            15 => Some(ForwardedSignal::Sigterm),
473            _ => None,
474        }
475    }
476}
477
478#[cfg(windows)]
479impl Drop for SignalFlags {
480    fn drop(&mut self) {
481        use windows_sys::Win32::System::Console::SetConsoleCtrlHandler;
482
483        // SAFETY: removes exactly the handler installed by `install`.
484        let _ = unsafe { SetConsoleCtrlHandler(Some(record_console_signal), 0) };
485        RECEIVED_SIGNAL.store(0, Ordering::SeqCst);
486    }
487}
488
489#[cfg(windows)]
490static SIGNAL_HANDLER_LOCK: Mutex<()> = Mutex::new(());
491#[cfg(windows)]
492static RECEIVED_SIGNAL: AtomicI32 = AtomicI32::new(0);
493
494#[cfg(windows)]
495unsafe extern "system" fn record_console_signal(control: u32) -> i32 {
496    use windows_sys::Win32::System::Console::{
497        CTRL_BREAK_EVENT, CTRL_C_EVENT, CTRL_CLOSE_EVENT, CTRL_LOGOFF_EVENT, CTRL_SHUTDOWN_EVENT,
498    };
499
500    match control {
501        CTRL_C_EVENT | CTRL_BREAK_EVENT => {
502            RECEIVED_SIGNAL.store(2, Ordering::SeqCst);
503            1
504        }
505        CTRL_CLOSE_EVENT | CTRL_LOGOFF_EVENT | CTRL_SHUTDOWN_EVENT => {
506            RECEIVED_SIGNAL.store(15, Ordering::SeqCst);
507            1
508        }
509        _ => 0,
510    }
511}
512
513#[cfg(windows)]
514struct JobHandle(windows_sys::Win32::Foundation::HANDLE);
515
516// A job-object handle is a process-wide kernel token, not a pointer into this
517// thread's memory: assigning a process to it, terminating it and closing it are
518// all safe from any thread, which is the same invariant std's OwnedHandle
519// carries by being Send and Sync. Without these the raw HANDLE makes the whole
520// supervisor !Sync on Windows, and the Rust test runner, which shares one
521// supervisor across scoped threads, does not compile there -- the first
522// Windows build found exactly that.
523#[cfg(windows)]
524unsafe impl Send for JobHandle {}
525#[cfg(windows)]
526unsafe impl Sync for JobHandle {}
527
528#[cfg(windows)]
529impl JobHandle {
530    fn new() -> Result<Self, SupervisionError> {
531        use windows_sys::Win32::System::JobObjects::{
532            CreateJobObjectW, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
533            JOBOBJECT_EXTENDED_LIMIT_INFORMATION, JobObjectExtendedLimitInformation,
534            SetInformationJobObject,
535        };
536
537        // SAFETY: null security attributes and name create one private job.
538        let handle = unsafe { CreateJobObjectW(std::ptr::null(), std::ptr::null()) };
539        if handle.is_null() {
540            return Err(SupervisionError::PlatformOperation {
541                operation: "create a Windows Job Object",
542                source: io::Error::last_os_error(),
543            });
544        }
545        let job = Self(handle);
546        let mut limits = JOBOBJECT_EXTENDED_LIMIT_INFORMATION::default();
547        limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
548        // SAFETY: the buffer is a live value of the exact information class
549        // and length requested by SetInformationJobObject.
550        if unsafe {
551            SetInformationJobObject(
552                job.0,
553                JobObjectExtendedLimitInformation,
554                (&raw const limits).cast(),
555                std::mem::size_of_val(&limits) as u32,
556            )
557        } == 0
558        {
559            return Err(SupervisionError::PlatformOperation {
560                operation: "configure Windows Job Object containment",
561                source: io::Error::last_os_error(),
562            });
563        }
564        Ok(job)
565    }
566
567    fn assign(&self, child: &Child) -> Result<(), SupervisionError> {
568        use std::os::windows::io::AsRawHandle;
569        use windows_sys::Win32::System::JobObjects::AssignProcessToJobObject;
570
571        // SAFETY: Child owns a live process handle with the rights granted by
572        // CreateProcess; the job handle stays live for the complete plan.
573        if unsafe { AssignProcessToJobObject(self.0, child.as_raw_handle().cast()) } == 0 {
574            return Err(SupervisionError::PlatformOperation {
575                operation: "assign the suspended command to its Windows Job Object",
576                source: io::Error::last_os_error(),
577            });
578        }
579        Ok(())
580    }
581
582    fn terminate(&self) {
583        use windows_sys::Win32::System::JobObjects::TerminateJobObject;
584        // SAFETY: the handle owns this invocation's process tree. Failure can
585        // only mean the tree has already exited, so termination is best effort.
586        let _ = unsafe { TerminateJobObject(self.0, 1) };
587    }
588}
589
590#[cfg(windows)]
591impl Drop for JobHandle {
592    fn drop(&mut self) {
593        use windows_sys::Win32::Foundation::CloseHandle;
594        // JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE makes this the final, crash-safe
595        // containment boundary for descendants that outlive their root.
596        let _ = unsafe { CloseHandle(self.0) };
597    }
598}
599
600#[cfg(windows)]
601fn resume_suspended_process(pid: u32) -> Result<(), SupervisionError> {
602    use windows_sys::Win32::{
603        Foundation::{CloseHandle, INVALID_HANDLE_VALUE},
604        System::{
605            Diagnostics::ToolHelp::{
606                CreateToolhelp32Snapshot, TH32CS_SNAPTHREAD, THREADENTRY32, Thread32First,
607                Thread32Next,
608            },
609            Threading::{OpenThread, ResumeThread, THREAD_SUSPEND_RESUME},
610        },
611    };
612
613    // The stdlib exposes the process handle but not CreateProcess's primary
614    // thread handle. Starting suspended, assigning the job, then resuming the
615    // process-owned thread from a ToolHelp snapshot closes the escape race.
616    let snapshot = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0) };
617    if snapshot == INVALID_HANDLE_VALUE {
618        return Err(SupervisionError::PlatformOperation {
619            operation: "enumerate the suspended command threads",
620            source: io::Error::last_os_error(),
621        });
622    }
623    struct Snapshot(windows_sys::Win32::Foundation::HANDLE);
624    impl Drop for Snapshot {
625        fn drop(&mut self) {
626            let _ = unsafe { CloseHandle(self.0) };
627        }
628    }
629    let _snapshot = Snapshot(snapshot);
630    let mut entry = THREADENTRY32 {
631        dwSize: std::mem::size_of::<THREADENTRY32>() as u32,
632        ..Default::default()
633    };
634    if unsafe { Thread32First(snapshot, &raw mut entry) } == 0 {
635        return Err(SupervisionError::PlatformOperation {
636            operation: "read the suspended command thread snapshot",
637            source: io::Error::last_os_error(),
638        });
639    }
640    loop {
641        if entry.th32OwnerProcessID == pid {
642            let thread = unsafe { OpenThread(THREAD_SUSPEND_RESUME, 0, entry.th32ThreadID) };
643            if thread.is_null() {
644                return Err(SupervisionError::PlatformOperation {
645                    operation: "open the suspended command's primary thread",
646                    source: io::Error::last_os_error(),
647                });
648            }
649            // SAFETY: the handle identifies a suspended thread owned by the
650            // just-created process and is closed immediately after resuming.
651            let resumed = unsafe { ResumeThread(thread) };
652            let resume_error = (resumed == u32::MAX).then(io::Error::last_os_error);
653            let _ = unsafe { CloseHandle(thread) };
654            if let Some(source) = resume_error {
655                return Err(SupervisionError::PlatformOperation {
656                    operation: "resume the contained command",
657                    source,
658                });
659            }
660            return Ok(());
661        }
662        entry.dwSize = std::mem::size_of::<THREADENTRY32>() as u32;
663        if unsafe { Thread32Next(snapshot, &raw mut entry) } == 0 {
664            break;
665        }
666    }
667    Err(SupervisionError::PlatformOperation {
668        operation: "locate the suspended command's primary thread",
669        source: io::Error::new(io::ErrorKind::NotFound, "process thread was absent"),
670    })
671}
672
673#[cfg(windows)]
674fn forward_windows_control(child: &Child) {
675    use windows_sys::Win32::System::Console::{CTRL_BREAK_EVENT, GenerateConsoleCtrlEvent};
676    // CREATE_NEW_PROCESS_GROUP makes the child's PID its console group ID.
677    // Some non-console commands reject the event; the grace-period Job Object
678    // termination remains authoritative in that case.
679    let _ = unsafe { GenerateConsoleCtrlEvent(CTRL_BREAK_EVENT, child.id()) };
680}
681
682/// The write end is held only by the supervising process. A tiny watchdog
683/// created in the command's pre-exec child blocks on the read end. Normal
684/// completion, an unwind, or uncatchable supervisor death all close this
685/// descriptor and make the watchdog kill the command's complete process group.
686#[cfg(unix)]
687struct ParentDeathGuard {
688    _writer: OwnedFd,
689}
690
691#[cfg(unix)]
692fn parent_death_pipe() -> Result<(OwnedFd, OwnedFd), SupervisionError> {
693    let mut descriptors = [-1_i32; 2];
694    // SAFETY: `descriptors` is a live two-element output buffer for pipe(2).
695    if unsafe { libc::pipe(descriptors.as_mut_ptr()) } != 0 {
696        return Err(SupervisionError::PlatformOperation {
697            operation: "create parent-death supervision pipe",
698            source: io::Error::last_os_error(),
699        });
700    }
701    // SAFETY: pipe(2) returned two newly owned descriptors.
702    let read = unsafe { OwnedFd::from_raw_fd(descriptors[0]) };
703    // SAFETY: same as above for the write end.
704    let write = unsafe { OwnedFd::from_raw_fd(descriptors[1]) };
705    for descriptor in [read.as_raw_fd(), write.as_raw_fd()] {
706        // SAFETY: the descriptor is live and F_SETFD accepts FD_CLOEXEC.
707        if unsafe { libc::fcntl(descriptor, libc::F_SETFD, libc::FD_CLOEXEC) } != 0 {
708            return Err(SupervisionError::PlatformOperation {
709                operation: "protect parent-death supervision pipe across exec",
710                source: io::Error::last_os_error(),
711            });
712        }
713    }
714    Ok((read, write))
715}
716
717#[cfg(unix)]
718fn spawn_contained(
719    command: &mut Command,
720    program: &OsString,
721    watchdog_program: Option<&Path>,
722) -> Result<(Child, Option<ParentDeathGuard>), SupervisionError> {
723    let Some(watchdog_program) = watchdog_program else {
724        command.process_group(0);
725        let child = command.spawn().map_err(|source| SupervisionError::Spawn {
726            program: program.clone(),
727            source,
728        })?;
729        return Ok((child, None));
730    };
731    let (read, write) = parent_death_pipe()?;
732    let (ready_read, ready_write) = parent_death_pipe()?;
733    let read_descriptor = read.as_raw_fd();
734    let write_descriptor = write.as_raw_fd();
735    let ready_read_descriptor = ready_read.as_raw_fd();
736    let ready_write_descriptor = ready_write.as_raw_fd();
737    let watchdog_program = std::ffi::CString::new(watchdog_program.as_os_str().as_bytes())
738        .map_err(|_| SupervisionError::PlatformOperation {
739            operation: "encode parent-death watchdog executable",
740            source: io::Error::new(io::ErrorKind::InvalidInput, "path contains a NUL byte"),
741        })?;
742    let watchdog_argument =
743        std::ffi::CString::new("__watch-process-group").expect("static CString");
744    // SAFETY: this closure calls only async-signal-safe syscalls between fork
745    // and exec. The forked watchdog immediately execs the already-loaded
746    // Supercov binary; it never returns to Rust or touches an inherited lock.
747    unsafe {
748        command.pre_exec(move || {
749            if libc::setpgid(0, 0) != 0 {
750                return Err(io::Error::last_os_error());
751            }
752            let _ = libc::close(write_descriptor);
753            let watchdog = libc::fork();
754            if watchdog < 0 {
755                return Err(io::Error::last_os_error());
756            }
757            if watchdog == 0 {
758                let _ = libc::close(ready_read_descriptor);
759                if libc::dup2(read_descriptor, 0) < 0 || libc::dup2(ready_write_descriptor, 3) < 0 {
760                    libc::_exit(125);
761                }
762                for descriptor in [read_descriptor, ready_write_descriptor, 1, 2] {
763                    if descriptor != 0 && descriptor != 3 {
764                        let _ = libc::close(descriptor);
765                    }
766                }
767                let arguments = [
768                    watchdog_program.as_ptr(),
769                    watchdog_argument.as_ptr(),
770                    std::ptr::null(),
771                ];
772                libc::execv(watchdog_program.as_ptr(), arguments.as_ptr());
773                libc::_exit(125);
774            }
775            let _ = libc::close(read_descriptor);
776            let _ = libc::close(ready_write_descriptor);
777            let mut ready = 0_u8;
778            loop {
779                let received = libc::read(ready_read_descriptor, (&raw mut ready).cast(), 1);
780                if received == 1 && ready == 1 {
781                    break;
782                }
783                if received < 0 && io::Error::last_os_error().raw_os_error() == Some(libc::EINTR) {
784                    continue;
785                }
786                return Err(io::Error::other(
787                    "parent-death watchdog failed before command exec",
788                ));
789            }
790            let _ = libc::close(ready_read_descriptor);
791            Ok(())
792        });
793    }
794    let child = command.spawn().map_err(|source| SupervisionError::Spawn {
795        program: program.clone(),
796        source,
797    })?;
798    drop(read);
799    drop(ready_read);
800    drop(ready_write);
801    Ok((child, Some(ParentDeathGuard { _writer: write })))
802}
803
804#[cfg(unix)]
805pub fn watch_parent_process_group() -> io::Result<()> {
806    // The target waits for our readiness byte, so its PID remains both our
807    // parent PID and the process-group ID until containment is armed.
808    let process_group = unsafe { libc::getppid() };
809    if process_group <= 1 {
810        return Err(io::Error::other(
811            "parent-death watchdog has no target process",
812        ));
813    }
814    // SAFETY: the watchdog is a non-leader child in the target's process group.
815    if unsafe { libc::setsid() } < 0 {
816        return Err(io::Error::last_os_error());
817    }
818    let descriptor_root = if Path::new("/proc/self/fd").is_dir() {
819        Path::new("/proc/self/fd")
820    } else {
821        Path::new("/dev/fd")
822    };
823    let descriptors = fs::read_dir(descriptor_root)?
824        .filter_map(Result::ok)
825        .filter_map(|entry| entry.file_name().to_string_lossy().parse::<i32>().ok())
826        .filter(|descriptor| !matches!(*descriptor, 0 | 3))
827        .collect::<Vec<_>>();
828    for descriptor in descriptors {
829        // SAFETY: closing a descriptor that the directory iterator already
830        // released, or one concurrently absent, is harmless.
831        let _ = unsafe { libc::close(descriptor) };
832    }
833    let ready = [1_u8];
834    // SAFETY: pre-exec mapped the private readiness pipe to descriptor 3.
835    if unsafe { libc::write(3, ready.as_ptr().cast(), ready.len()) } != 1 {
836        return Err(io::Error::last_os_error());
837    }
838    let _ = unsafe { libc::close(3) };
839    let mut buffer = [0_u8; 1];
840    loop {
841        // The supervisor never writes. EOF means normal supervisor teardown,
842        // unwind, or uncatchable process death.
843        let read = unsafe { libc::read(0, buffer.as_mut_ptr().cast(), buffer.len()) };
844        if read == 0 {
845            break;
846        }
847        if read < 0 {
848            if io::Error::last_os_error().raw_os_error() == Some(libc::EINTR) {
849                continue;
850            }
851            break;
852        }
853    }
854    let _ = unsafe { libc::kill(-process_group, libc::SIGKILL) };
855    Ok(())
856}
857
858#[cfg(not(unix))]
859pub fn watch_parent_process_group() -> io::Result<()> {
860    Err(io::Error::new(
861        io::ErrorKind::Unsupported,
862        "the POSIX parent-death watchdog is unavailable",
863    ))
864}
865
866#[cfg(unix)]
867fn signal_process_group(child: &mut Child, signal: i32) {
868    let pid = child.id() as i32;
869    // SAFETY: `kill` is async-signal-safe and receives a process-group ID
870    // created for this child before exec. Failure can mean the child exited
871    // between `try_wait` and this call, so it is intentionally non-fatal.
872    let group_result = unsafe { libc::kill(-pid, signal) };
873    if group_result != 0 {
874        // SAFETY: same rationale, with the child PID as a last-resort target.
875        let _ = unsafe { libc::kill(pid, signal) };
876    }
877}
878
879#[cfg(unix)]
880fn exit_parts(status: ExitStatus) -> (Option<i32>, Option<i32>) {
881    use std::os::unix::process::ExitStatusExt;
882    (status.code(), status.signal())
883}
884
885#[cfg(not(unix))]
886fn exit_parts(status: ExitStatus) -> (Option<i32>, Option<i32>) {
887    (status.code(), None)
888}
889
890fn write_diagnostic(child: &Child, started: Instant, writer: &mut dyn Write) {
891    let tree = descendant_process_tree(child.id());
892    let diagnostic = format_process_diagnostic(child.id(), started.elapsed(), &tree);
893    let verbose = std::env::var("SUPERCOV_VERBOSE")
894        .or_else(|_| std::env::var("SUPERCOV_DEBUG"))
895        .is_ok_and(|value| matches!(value.as_str(), "1" | "true" | "yes"));
896    let diagnostic = if verbose {
897        diagnostic.as_str()
898    } else {
899        diagnostic.lines().next().unwrap_or(diagnostic.as_str())
900    };
901    let _ = writeln!(writer, "{}", diagnostic).and_then(|_| writer.flush());
902}
903
904fn validate_options(options: SupervisionOptions) -> Result<(), SupervisionError> {
905    if options.diagnostic_interval.is_zero() || options.termination_grace.is_zero() {
906        return Err(SupervisionError::InvalidMilliseconds {
907            name: "process supervision interval".into(),
908        });
909    }
910    if options.timeout.is_some_and(|timeout| timeout.is_zero()) {
911        return Err(SupervisionError::InvalidMilliseconds {
912            name: "SUPERCOV_COMMAND_TIMEOUT_MS".into(),
913        });
914    }
915    Ok(())
916}
917
918fn read_pipe(mut pipe: impl Read) -> io::Result<Vec<u8>> {
919    let mut bytes = Vec::new();
920    pipe.read_to_end(&mut bytes)?;
921    Ok(bytes)
922}
923
924fn captured_bytes(
925    reader: thread::JoinHandle<io::Result<Vec<u8>>>,
926    stream: &'static str,
927) -> Result<Vec<u8>, SupervisionError> {
928    reader
929        .join()
930        .map_err(|_| SupervisionError::PlatformOperation {
931            operation: "join captured process output reader",
932            source: io::Error::other(format!("{stream} reader panicked")),
933        })?
934        .map_err(|source| SupervisionError::PlatformOperation {
935            operation: "read captured process output",
936            source,
937        })
938}
939
940#[cfg(unix)]
941pub struct ProcessSupervisor {
942    signals: SignalFlags,
943    watchdog_program: Option<PathBuf>,
944}
945
946#[cfg(unix)]
947impl ProcessSupervisor {
948    pub fn new() -> Result<Self, SupervisionError> {
949        Ok(Self {
950            signals: SignalFlags::install()?,
951            watchdog_program: None,
952        })
953    }
954
955    pub fn new_crash_safe(watchdog_program: &Path) -> Result<Self, SupervisionError> {
956        let watchdog_program = fs::canonicalize(watchdog_program).map_err(|source| {
957            SupervisionError::PlatformOperation {
958                operation: "resolve parent-death watchdog executable",
959                source,
960            }
961        })?;
962        if !fs::metadata(&watchdog_program).is_ok_and(|metadata| metadata.is_file()) {
963            return Err(SupervisionError::PlatformOperation {
964                operation: "validate parent-death watchdog executable",
965                source: io::Error::new(io::ErrorKind::InvalidInput, "expected a regular file"),
966            });
967        }
968        Ok(Self {
969            signals: SignalFlags::install()?,
970            watchdog_program: Some(watchdog_program),
971        })
972    }
973
974    pub fn supervise(
975        &self,
976        spec: &CommandSpec,
977        options: SupervisionOptions,
978        writer: &mut dyn Write,
979    ) -> Result<SupervisedResult, SupervisionError> {
980        validate_options(options)?;
981        if let Some(signal) = self.signals.received() {
982            return Ok(SupervisedResult {
983                status: None,
984                signal: Some(signal.raw()),
985                timed_out: false,
986                interrupted_signal: Some(signal),
987            });
988        }
989        let mut command = spec.command()?;
990        let (mut child, _parent_death_guard) = spawn_contained(
991            &mut command,
992            &spec.program,
993            self.watchdog_program.as_deref(),
994        )?;
995        self.monitor(&mut child, options, writer)
996    }
997
998    pub fn supervise_captured(
999        &self,
1000        spec: &CommandSpec,
1001        options: SupervisionOptions,
1002        writer: &mut dyn Write,
1003    ) -> Result<SupervisedOutput, SupervisionError> {
1004        validate_options(options)?;
1005        if spec.captured_output.is_some() {
1006            return Err(SupervisionError::PlatformOperation {
1007                operation: "configure separate captured process output",
1008                source: io::Error::new(
1009                    io::ErrorKind::InvalidInput,
1010                    "merged and separate capture cannot be requested together",
1011                ),
1012            });
1013        }
1014        if let Some(signal) = self.signals.received() {
1015            return Ok(SupervisedOutput {
1016                result: SupervisedResult {
1017                    status: None,
1018                    signal: Some(signal.raw()),
1019                    timed_out: false,
1020                    interrupted_signal: Some(signal),
1021                },
1022                stdout: Vec::new(),
1023                stderr: Vec::new(),
1024            });
1025        }
1026        let mut command = spec.command()?;
1027        command.stdout(Stdio::piped()).stderr(Stdio::piped());
1028        let (mut child, parent_death_guard) = spawn_contained(
1029            &mut command,
1030            &spec.program,
1031            self.watchdog_program.as_deref(),
1032        )?;
1033        let stdout = child.stdout.take().expect("piped stdout");
1034        let stderr = child.stderr.take().expect("piped stderr");
1035        let stdout_reader = thread::spawn(move || read_pipe(stdout));
1036        let stderr_reader = thread::spawn(move || read_pipe(stderr));
1037        let result = self.monitor(&mut child, options, writer);
1038        // Closing the liveness writer makes the watchdog kill any descendants
1039        // that retained the output pipes after the root command exited.
1040        drop(parent_death_guard);
1041        let stdout = captured_bytes(stdout_reader, "stdout")?;
1042        let stderr = captured_bytes(stderr_reader, "stderr")?;
1043        Ok(SupervisedOutput {
1044            result: result?,
1045            stdout,
1046            stderr,
1047        })
1048    }
1049
1050    fn monitor(
1051        &self,
1052        child: &mut Child,
1053        options: SupervisionOptions,
1054        writer: &mut dyn Write,
1055    ) -> Result<SupervisedResult, SupervisionError> {
1056        let started = Instant::now();
1057        let mut next_diagnostic = started + options.diagnostic_interval;
1058        let timeout_at = options.timeout.map(|timeout| started + timeout);
1059        let mut termination: Option<(Instant, Option<ForwardedSignal>)> = None;
1060        let mut timed_out = false;
1061        let mut interrupted_signal = None;
1062        let mut escalated = false;
1063
1064        loop {
1065            let status = match child.try_wait() {
1066                Ok(status) => status,
1067                Err(error) => {
1068                    signal_process_group(child, libc::SIGKILL);
1069                    let _ = child.wait();
1070                    return Err(SupervisionError::Wait(error));
1071                }
1072            };
1073            if let Some(status) = status {
1074                let (status, signal) = exit_parts(status);
1075                return Ok(SupervisedResult {
1076                    status,
1077                    signal,
1078                    timed_out,
1079                    interrupted_signal,
1080                });
1081            }
1082            let now = Instant::now();
1083            if termination.is_none()
1084                && let Some(signal) = self.signals.received()
1085            {
1086                interrupted_signal = Some(signal);
1087                signal_process_group(child, signal.raw());
1088                termination = Some((now, Some(signal)));
1089            }
1090            if termination.is_none() && timeout_at.is_some_and(|deadline| now >= deadline) {
1091                timed_out = true;
1092                let _ = writeln!(
1093                writer,
1094                "[supercov] command exceeded SUPERCOV_COMMAND_TIMEOUT_MS={}; terminating process group",
1095                options.timeout.expect("timeout deadline").as_millis()
1096            )
1097            .and_then(|_| writer.flush());
1098                signal_process_group(child, libc::SIGTERM);
1099                termination = Some((now, None));
1100                write_diagnostic(child, started, writer);
1101            }
1102            if now >= next_diagnostic && !timed_out {
1103                write_diagnostic(child, started, writer);
1104                while next_diagnostic <= now {
1105                    next_diagnostic += options.diagnostic_interval;
1106                }
1107            }
1108            if !escalated
1109                && termination.is_some_and(|(terminated_at, _)| {
1110                    now.duration_since(terminated_at) >= options.termination_grace
1111                })
1112            {
1113                signal_process_group(child, libc::SIGKILL);
1114                escalated = true;
1115            }
1116            thread::sleep(POLL_INTERVAL);
1117        }
1118    }
1119}
1120
1121#[cfg(windows)]
1122pub struct ProcessSupervisor {
1123    signals: SignalFlags,
1124    job: JobHandle,
1125}
1126
1127#[cfg(windows)]
1128impl ProcessSupervisor {
1129    pub fn new() -> Result<Self, SupervisionError> {
1130        Ok(Self {
1131            signals: SignalFlags::install()?,
1132            job: JobHandle::new()?,
1133        })
1134    }
1135
1136    pub fn new_crash_safe(_watchdog_program: &Path) -> Result<Self, SupervisionError> {
1137        Self::new()
1138    }
1139
1140    pub fn supervise(
1141        &self,
1142        spec: &CommandSpec,
1143        options: SupervisionOptions,
1144        writer: &mut dyn Write,
1145    ) -> Result<SupervisedResult, SupervisionError> {
1146        validate_options(options)?;
1147        if let Some(signal) = self.signals.received() {
1148            return Ok(SupervisedResult {
1149                status: None,
1150                signal: None,
1151                timed_out: false,
1152                interrupted_signal: Some(signal),
1153            });
1154        }
1155        let mut command = spec.command()?;
1156        let mut child = command.spawn().map_err(|source| SupervisionError::Spawn {
1157            program: spec.program.clone(),
1158            source,
1159        })?;
1160        if let Err(error) = self.job.assign(&child) {
1161            let _ = child.kill();
1162            let _ = child.wait();
1163            return Err(error);
1164        }
1165        if let Err(error) = resume_suspended_process(child.id()) {
1166            self.job.terminate();
1167            let _ = child.wait();
1168            return Err(error);
1169        }
1170        self.monitor(&mut child, options, writer)
1171    }
1172
1173    pub fn supervise_captured(
1174        &self,
1175        spec: &CommandSpec,
1176        options: SupervisionOptions,
1177        writer: &mut dyn Write,
1178    ) -> Result<SupervisedOutput, SupervisionError> {
1179        validate_options(options)?;
1180        if spec.captured_output.is_some() {
1181            return Err(SupervisionError::PlatformOperation {
1182                operation: "configure separate captured process output",
1183                source: io::Error::new(
1184                    io::ErrorKind::InvalidInput,
1185                    "merged and separate capture cannot be requested together",
1186                ),
1187            });
1188        }
1189        if let Some(signal) = self.signals.received() {
1190            return Ok(SupervisedOutput {
1191                result: SupervisedResult {
1192                    status: None,
1193                    signal: None,
1194                    timed_out: false,
1195                    interrupted_signal: Some(signal),
1196                },
1197                stdout: Vec::new(),
1198                stderr: Vec::new(),
1199            });
1200        }
1201        let mut command = spec.command()?;
1202        command.stdout(Stdio::piped()).stderr(Stdio::piped());
1203        let mut child = command.spawn().map_err(|source| SupervisionError::Spawn {
1204            program: spec.program.clone(),
1205            source,
1206        })?;
1207        if let Err(error) = self.job.assign(&child) {
1208            let _ = child.kill();
1209            let _ = child.wait();
1210            return Err(error);
1211        }
1212        if let Err(error) = resume_suspended_process(child.id()) {
1213            self.job.terminate();
1214            let _ = child.wait();
1215            return Err(error);
1216        }
1217        let stdout = child.stdout.take().expect("piped stdout");
1218        let stderr = child.stderr.take().expect("piped stderr");
1219        let stdout_reader = thread::spawn(move || read_pipe(stdout));
1220        let stderr_reader = thread::spawn(move || read_pipe(stderr));
1221        let result = self.monitor(&mut child, options, writer);
1222        let stdout = captured_bytes(stdout_reader, "stdout")?;
1223        let stderr = captured_bytes(stderr_reader, "stderr")?;
1224        Ok(SupervisedOutput {
1225            result: result?,
1226            stdout,
1227            stderr,
1228        })
1229    }
1230
1231    fn monitor(
1232        &self,
1233        child: &mut Child,
1234        options: SupervisionOptions,
1235        writer: &mut dyn Write,
1236    ) -> Result<SupervisedResult, SupervisionError> {
1237        let started = Instant::now();
1238        let mut next_diagnostic = started + options.diagnostic_interval;
1239        let timeout_at = options.timeout.map(|timeout| started + timeout);
1240        let mut termination: Option<Instant> = None;
1241        let mut timed_out = false;
1242        let mut interrupted_signal = None;
1243        let mut escalated = false;
1244
1245        loop {
1246            let status = match child.try_wait() {
1247                Ok(status) => status,
1248                Err(error) => {
1249                    self.job.terminate();
1250                    let _ = child.wait();
1251                    return Err(SupervisionError::Wait(error));
1252                }
1253            };
1254            if let Some(status) = status {
1255                let (status, signal) = exit_parts(status);
1256                return Ok(SupervisedResult {
1257                    status,
1258                    signal,
1259                    timed_out,
1260                    interrupted_signal,
1261                });
1262            }
1263            let now = Instant::now();
1264            if termination.is_none()
1265                && let Some(signal) = self.signals.received()
1266            {
1267                interrupted_signal = Some(signal);
1268                forward_windows_control(&child);
1269                termination = Some(now);
1270            }
1271            if termination.is_none() && timeout_at.is_some_and(|deadline| now >= deadline) {
1272                timed_out = true;
1273                let _ = writeln!(
1274                    writer,
1275                    "[supercov] command exceeded SUPERCOV_COMMAND_TIMEOUT_MS={}; terminating process group",
1276                    options.timeout.expect("timeout deadline").as_millis()
1277                )
1278                .and_then(|_| writer.flush());
1279                forward_windows_control(&child);
1280                termination = Some(now);
1281                write_diagnostic(&child, started, writer);
1282            }
1283            if now >= next_diagnostic && !timed_out {
1284                write_diagnostic(&child, started, writer);
1285                while next_diagnostic <= now {
1286                    next_diagnostic += options.diagnostic_interval;
1287                }
1288            }
1289            if !escalated
1290                && termination.is_some_and(|terminated_at| {
1291                    now.duration_since(terminated_at) >= options.termination_grace
1292                })
1293            {
1294                self.job.terminate();
1295                escalated = true;
1296            }
1297            thread::sleep(POLL_INTERVAL);
1298        }
1299    }
1300}
1301
1302#[cfg(not(any(unix, windows)))]
1303pub struct ProcessSupervisor;
1304
1305#[cfg(not(any(unix, windows)))]
1306impl ProcessSupervisor {
1307    pub fn new() -> Result<Self, SupervisionError> {
1308        Err(SupervisionError::UnsupportedPlatform(
1309            "this target has no process-tree containment implementation",
1310        ))
1311    }
1312
1313    pub fn new_crash_safe(_watchdog_program: &Path) -> Result<Self, SupervisionError> {
1314        Self::new()
1315    }
1316
1317    pub fn supervise(
1318        &self,
1319        _spec: &CommandSpec,
1320        _options: SupervisionOptions,
1321        _writer: &mut dyn Write,
1322    ) -> Result<SupervisedResult, SupervisionError> {
1323        Err(SupervisionError::UnsupportedPlatform(
1324            "this target has no process-tree containment implementation",
1325        ))
1326    }
1327
1328    pub fn supervise_captured(
1329        &self,
1330        _spec: &CommandSpec,
1331        _options: SupervisionOptions,
1332        _writer: &mut dyn Write,
1333    ) -> Result<SupervisedOutput, SupervisionError> {
1334        Err(SupervisionError::UnsupportedPlatform(
1335            "this target has no process-tree containment implementation",
1336        ))
1337    }
1338}
1339
1340pub fn supervise_command(
1341    spec: &CommandSpec,
1342    options: SupervisionOptions,
1343    writer: &mut dyn Write,
1344) -> Result<SupervisedResult, SupervisionError> {
1345    ProcessSupervisor::new()?.supervise(spec, options, writer)
1346}
1347
1348pub fn supervise_captured_command(
1349    spec: &CommandSpec,
1350    options: SupervisionOptions,
1351    writer: &mut dyn Write,
1352) -> Result<SupervisedOutput, SupervisionError> {
1353    ProcessSupervisor::new()?.supervise_captured(spec, options, writer)
1354}
1355
1356#[cfg(test)]
1357mod tests {
1358    use super::*;
1359
1360    #[test]
1361    fn parses_only_positive_integer_milliseconds() {
1362        assert_eq!(positive_milliseconds(None, "VALUE").unwrap(), None);
1363        assert_eq!(
1364            positive_milliseconds(Some("50"), "VALUE").unwrap(),
1365            Some(Duration::from_millis(50))
1366        );
1367        for value in ["0", "-1", "1.5", "NaN", " 1"] {
1368            assert!(positive_milliseconds(Some(value), "VALUE").is_err());
1369        }
1370    }
1371
1372    #[test]
1373    fn diagnostic_format_is_sanitized_and_reference_compatible() {
1374        let output = format_process_diagnostic(
1375            20,
1376            Duration::from_millis(61_000),
1377            &[ProcessSnapshot {
1378                pid: 20,
1379                parent_pid: 10,
1380                executable: "node".into(),
1381                state: Some("S".into()),
1382                cpu_tenths: Some(13),
1383            }],
1384        );
1385        assert_eq!(
1386            output,
1387            "[supercov] command still running after 1m01s\n  pid=20 ppid=10 exe=node state=S cpu=1.3s"
1388        );
1389        assert!(!output.contains("argv"));
1390    }
1391
1392    #[cfg(unix)]
1393    #[test]
1394    fn returns_the_child_status_without_a_default_timeout() {
1395        let root = std::env::current_dir().unwrap();
1396        let spec = CommandSpec {
1397            program: "/bin/sh".into(),
1398            arguments: vec!["-c".into(), "exit 7".into()],
1399            cwd: root,
1400            environment: None,
1401            captured_output: None,
1402        };
1403        let mut diagnostics = Vec::new();
1404        let result =
1405            supervise_command(&spec, SupervisionOptions::default(), &mut diagnostics).unwrap();
1406        assert_eq!(result.exit_code(), 7);
1407        assert!(!result.timed_out);
1408        assert!(diagnostics.is_empty());
1409    }
1410
1411    #[cfg(unix)]
1412    #[test]
1413    fn captures_stdout_and_stderr_separately_without_losing_status() {
1414        let spec = CommandSpec {
1415            program: "/bin/sh".into(),
1416            arguments: vec![
1417                "-c".into(),
1418                "printf stdout-value; printf stderr-value >&2; exit 9".into(),
1419            ],
1420            cwd: std::env::current_dir().unwrap(),
1421            environment: None,
1422            captured_output: None,
1423        };
1424        let mut diagnostics = Vec::new();
1425        let output =
1426            supervise_captured_command(&spec, SupervisionOptions::default(), &mut diagnostics)
1427                .unwrap();
1428        assert_eq!(output.result.exit_code(), 9);
1429        assert_eq!(output.stdout, b"stdout-value");
1430        assert_eq!(output.stderr, b"stderr-value");
1431        assert!(diagnostics.is_empty());
1432    }
1433
1434    #[cfg(windows)]
1435    #[test]
1436    fn returns_the_windows_child_status_without_a_default_timeout() {
1437        let spec = CommandSpec {
1438            program: "cmd.exe".into(),
1439            arguments: vec!["/D".into(), "/S".into(), "/C".into(), "exit /b 7".into()],
1440            cwd: std::env::current_dir().unwrap(),
1441            environment: None,
1442            captured_output: None,
1443        };
1444        let mut diagnostics = Vec::new();
1445        let result =
1446            supervise_command(&spec, SupervisionOptions::default(), &mut diagnostics).unwrap();
1447        assert_eq!(result.exit_code(), 7);
1448        assert!(!result.timed_out);
1449        assert!(diagnostics.is_empty());
1450    }
1451
1452    #[cfg(unix)]
1453    #[test]
1454    fn explicit_timeout_reports_and_returns_124() {
1455        let root = std::env::current_dir().unwrap();
1456        let spec = CommandSpec {
1457            program: "/bin/sh".into(),
1458            arguments: vec!["-c".into(), "while :; do sleep 1; done".into()],
1459            cwd: root,
1460            environment: None,
1461            captured_output: None,
1462        };
1463        let mut diagnostics = Vec::new();
1464        let result = supervise_command(
1465            &spec,
1466            SupervisionOptions {
1467                diagnostic_interval: Duration::from_millis(20),
1468                timeout: Some(Duration::from_millis(70)),
1469                termination_grace: Duration::from_millis(50),
1470            },
1471            &mut diagnostics,
1472        )
1473        .unwrap();
1474        let diagnostics = String::from_utf8(diagnostics).unwrap();
1475        assert_eq!(result.exit_code(), COMMAND_TIMEOUT_EXIT_CODE);
1476        assert!(result.timed_out);
1477        assert!(diagnostics.contains("command still running after"));
1478        assert!(diagnostics.contains("SUPERCOV_COMMAND_TIMEOUT_MS=70"));
1479    }
1480
1481    #[cfg(windows)]
1482    #[test]
1483    fn timeout_terminates_the_complete_windows_job() {
1484        use std::{
1485            fs,
1486            time::{SystemTime, UNIX_EPOCH},
1487        };
1488
1489        let unique = SystemTime::now()
1490            .duration_since(UNIX_EPOCH)
1491            .unwrap()
1492            .as_nanos();
1493        let root = std::env::temp_dir().join(format!(
1494            "supercov-windows-job-{}-{unique}",
1495            std::process::id()
1496        ));
1497        fs::create_dir_all(&root).unwrap();
1498        struct RemoveOnDrop(PathBuf);
1499        impl Drop for RemoveOnDrop {
1500            fn drop(&mut self) {
1501                let _ = fs::remove_dir_all(&self.0);
1502            }
1503        }
1504        let _cleanup = RemoveOnDrop(root.clone());
1505        let ready = root.join("descendant-ready");
1506        let marker = root.join("descendant-survived");
1507        let mut environment = std::env::vars_os().collect::<Vec<_>>();
1508        environment.extend([
1509            ("SUPERCOV_WINDOWS_PARENT_HELPER".into(), "1".into()),
1510            ("SUPERCOV_WINDOWS_READY".into(), ready.as_os_str().into()),
1511            ("SUPERCOV_WINDOWS_MARKER".into(), marker.as_os_str().into()),
1512        ]);
1513        let spec = CommandSpec {
1514            program: std::env::current_exe().unwrap().into_os_string(),
1515            arguments: vec![
1516                "--ignored".into(),
1517                "windows_timeout_parent_helper".into(),
1518                "--nocapture".into(),
1519            ],
1520            cwd: root,
1521            environment: Some(environment),
1522            captured_output: None,
1523        };
1524        let mut diagnostics = Vec::new();
1525        let result = supervise_command(
1526            &spec,
1527            SupervisionOptions {
1528                diagnostic_interval: Duration::from_secs(60),
1529                timeout: Some(Duration::from_millis(750)),
1530                termination_grace: Duration::from_millis(50),
1531            },
1532            &mut diagnostics,
1533        )
1534        .unwrap();
1535
1536        assert!(result.timed_out);
1537        assert_eq!(result.exit_code(), COMMAND_TIMEOUT_EXIT_CODE);
1538        assert!(
1539            ready.exists(),
1540            "the helper did not prove that its descendant started before timeout"
1541        );
1542        thread::sleep(Duration::from_millis(1_700));
1543        assert!(
1544            !marker.exists(),
1545            "a descendant escaped the Windows Job Object after timeout"
1546        );
1547        assert!(
1548            String::from_utf8(diagnostics)
1549                .unwrap()
1550                .contains("terminating process group")
1551        );
1552    }
1553
1554    #[cfg(windows)]
1555    #[test]
1556    #[ignore = "subprocess helper for timeout_terminates_the_complete_windows_job"]
1557    fn windows_timeout_parent_helper() {
1558        use std::fs;
1559
1560        if std::env::var_os("SUPERCOV_WINDOWS_PARENT_HELPER").is_none() {
1561            return;
1562        }
1563        let mut child = Command::new(std::env::current_exe().unwrap())
1564            .args(["--ignored", "windows_timeout_marker_helper", "--nocapture"])
1565            .stdin(Stdio::null())
1566            .stdout(Stdio::null())
1567            .stderr(Stdio::null())
1568            .spawn()
1569            .unwrap();
1570        fs::write(
1571            std::env::var_os("SUPERCOV_WINDOWS_READY").unwrap(),
1572            child.id().to_string(),
1573        )
1574        .unwrap();
1575        child.wait().unwrap();
1576    }
1577
1578    #[cfg(windows)]
1579    #[test]
1580    #[ignore = "subprocess helper for timeout_terminates_the_complete_windows_job"]
1581    fn windows_timeout_marker_helper() {
1582        use std::fs;
1583
1584        if std::env::var_os("SUPERCOV_WINDOWS_PARENT_HELPER").is_none() {
1585            return;
1586        }
1587        thread::sleep(Duration::from_millis(1_500));
1588        fs::write(
1589            std::env::var_os("SUPERCOV_WINDOWS_MARKER").unwrap(),
1590            b"escaped",
1591        )
1592        .unwrap();
1593    }
1594
1595    #[cfg(unix)]
1596    #[test]
1597    fn diagnostic_write_failures_never_change_the_child_result() {
1598        struct BrokenWriter;
1599        impl Write for BrokenWriter {
1600            fn write(&mut self, _buffer: &[u8]) -> io::Result<usize> {
1601                Err(io::Error::new(
1602                    io::ErrorKind::BrokenPipe,
1603                    "closed diagnostic stream",
1604                ))
1605            }
1606
1607            fn flush(&mut self) -> io::Result<()> {
1608                Ok(())
1609            }
1610        }
1611
1612        let spec = CommandSpec {
1613            program: "/bin/sh".into(),
1614            arguments: vec!["-c".into(), "sleep 0.05; exit 0".into()],
1615            cwd: std::env::current_dir().unwrap(),
1616            environment: None,
1617            captured_output: None,
1618        };
1619        let result = supervise_command(
1620            &spec,
1621            SupervisionOptions {
1622                diagnostic_interval: Duration::from_millis(10),
1623                timeout: None,
1624                termination_grace: Duration::from_millis(50),
1625            },
1626            &mut BrokenWriter,
1627        )
1628        .unwrap();
1629        assert_eq!(result.exit_code(), 0);
1630    }
1631}