Skip to main content

supercov_engine/
process_supervision.rs

1//! Privacy-preserving child-process supervision for arbitrary test commands.
2
3use std::{
4    ffi::OsString,
5    io::{self, Write},
6    path::{Path, PathBuf},
7    process::{Child, Command, ExitStatus, Stdio},
8    sync::{
9        Mutex, MutexGuard,
10        atomic::{AtomicI32, Ordering},
11    },
12    thread,
13    time::{Duration, Instant},
14};
15
16use serde::{Deserialize, Serialize};
17use supercov_contracts::{
18    COMMAND_TERMINATION_GRACE_MS, COMMAND_TIMEOUT_EXIT_CODE, DEFAULT_DIAGNOSTIC_INTERVAL_MS,
19};
20
21const POLL_INTERVAL: Duration = Duration::from_millis(10);
22
23#[derive(Debug)]
24pub enum SupervisionError {
25    InvalidMilliseconds {
26        name: String,
27    },
28    EmptyCommand,
29    Spawn {
30        program: OsString,
31        source: io::Error,
32    },
33    Wait(io::Error),
34    Signal(io::Error),
35    PlatformOperation {
36        operation: &'static str,
37        source: io::Error,
38    },
39    UnsupportedPlatform(&'static str),
40}
41
42impl std::fmt::Display for SupervisionError {
43    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
44        match self {
45            Self::InvalidMilliseconds { name } => {
46                write!(
47                    formatter,
48                    "{name} must be a positive integer number of milliseconds"
49                )
50            }
51            Self::EmptyCommand => write!(formatter, "test command must not be empty"),
52            Self::Spawn { program, source } => {
53                write!(
54                    formatter,
55                    "could not spawn {}: {source}",
56                    program.to_string_lossy()
57                )
58            }
59            Self::Wait(error) => write!(formatter, "could not wait for test command: {error}"),
60            Self::Signal(error) => {
61                write!(formatter, "could not install signal forwarding: {error}")
62            }
63            Self::PlatformOperation { operation, source } => {
64                write!(formatter, "could not {operation}: {source}")
65            }
66            Self::UnsupportedPlatform(reason) => write!(
67                formatter,
68                "unsupported process supervision platform: {reason}"
69            ),
70        }
71    }
72}
73
74impl std::error::Error for SupervisionError {}
75
76#[derive(Debug, Clone, PartialEq, Eq)]
77pub struct CommandSpec {
78    pub program: OsString,
79    pub arguments: Vec<OsString>,
80    pub cwd: PathBuf,
81    /// `None` inherits the supervisor environment. `Some` clears it first and
82    /// installs exactly these values.
83    pub environment: Option<Vec<(OsString, OsString)>>,
84}
85
86impl CommandSpec {
87    pub fn command(&self) -> Result<Command, SupervisionError> {
88        if self.program.is_empty() {
89            return Err(SupervisionError::EmptyCommand);
90        }
91        let mut command = Command::new(&self.program);
92        command
93            .args(&self.arguments)
94            .current_dir(&self.cwd)
95            .stdin(Stdio::inherit())
96            .stdout(Stdio::inherit())
97            .stderr(Stdio::inherit());
98        if let Some(environment) = &self.environment {
99            command.env_clear().envs(environment.iter().cloned());
100        }
101        #[cfg(unix)]
102        {
103            use std::os::unix::process::CommandExt;
104            command.process_group(0);
105        }
106        #[cfg(windows)]
107        {
108            use std::os::windows::process::CommandExt;
109            use windows_sys::Win32::System::Threading::{
110                CREATE_NEW_PROCESS_GROUP, CREATE_SUSPENDED,
111            };
112            command.creation_flags(CREATE_NEW_PROCESS_GROUP | CREATE_SUSPENDED);
113        }
114        Ok(command)
115    }
116}
117
118#[derive(Debug, Clone, Copy, PartialEq, Eq)]
119pub struct SupervisionOptions {
120    pub diagnostic_interval: Duration,
121    pub timeout: Option<Duration>,
122    pub termination_grace: Duration,
123}
124
125impl Default for SupervisionOptions {
126    fn default() -> Self {
127        Self {
128            diagnostic_interval: Duration::from_millis(DEFAULT_DIAGNOSTIC_INTERVAL_MS),
129            timeout: None,
130            termination_grace: Duration::from_millis(COMMAND_TERMINATION_GRACE_MS),
131        }
132    }
133}
134
135#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
136#[serde(rename_all = "camelCase")]
137pub struct ProcessSnapshot {
138    pub pid: u32,
139    pub parent_pid: u32,
140    #[serde(skip_serializing_if = "Option::is_none")]
141    pub state: Option<String>,
142    #[serde(skip_serializing_if = "Option::is_none")]
143    pub cpu_tenths: Option<u64>,
144    pub executable: String,
145}
146
147#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
148#[serde(rename_all = "UPPERCASE")]
149pub enum ForwardedSignal {
150    Sighup,
151    Sigint,
152    Sigterm,
153}
154
155impl ForwardedSignal {
156    pub fn exit_code(self) -> i32 {
157        match self {
158            Self::Sighup => 129,
159            Self::Sigint => 130,
160            Self::Sigterm => 143,
161        }
162    }
163
164    #[cfg(unix)]
165    fn raw(self) -> i32 {
166        match self {
167            Self::Sighup => libc::SIGHUP,
168            Self::Sigint => libc::SIGINT,
169            Self::Sigterm => libc::SIGTERM,
170        }
171    }
172}
173
174#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
175#[serde(rename_all = "camelCase")]
176pub struct SupervisedResult {
177    pub status: Option<i32>,
178    pub signal: Option<i32>,
179    pub timed_out: bool,
180    pub interrupted_signal: Option<ForwardedSignal>,
181}
182
183impl SupervisedResult {
184    pub fn exit_code(&self) -> i32 {
185        if self.timed_out {
186            COMMAND_TIMEOUT_EXIT_CODE
187        } else if let Some(signal) = self.interrupted_signal {
188            signal.exit_code()
189        } else {
190            self.status.unwrap_or(128)
191        }
192    }
193}
194
195pub fn positive_milliseconds(
196    value: Option<&str>,
197    name: &str,
198) -> Result<Option<Duration>, SupervisionError> {
199    let Some(value) = value.filter(|value| !value.is_empty()) else {
200        return Ok(None);
201    };
202    let milliseconds = value
203        .parse::<u64>()
204        .ok()
205        .filter(|milliseconds| *milliseconds > 0)
206        .ok_or_else(|| SupervisionError::InvalidMilliseconds { name: name.into() })?;
207    Ok(Some(Duration::from_millis(milliseconds)))
208}
209
210fn process_inventory() -> Vec<ProcessSnapshot> {
211    use sysinfo::{ProcessRefreshKind, RefreshKind, System};
212
213    let system = System::new_with_specifics(
214        RefreshKind::nothing().with_processes(ProcessRefreshKind::nothing().with_cpu()),
215    );
216    system
217        .processes()
218        .iter()
219        .map(|(pid, process)| ProcessSnapshot {
220            pid: pid.as_u32(),
221            parent_pid: process.parent().map_or(0, sysinfo::Pid::as_u32),
222            state: Some(process_status(process.status()).into()),
223            cpu_tenths: Some(process.accumulated_cpu_time() / 100),
224            executable: Path::new(process.name())
225                .file_name()
226                .and_then(|value| value.to_str())
227                .unwrap_or("unknown")
228                .to_owned(),
229        })
230        .collect()
231}
232
233fn process_status(status: sysinfo::ProcessStatus) -> &'static str {
234    use sysinfo::ProcessStatus;
235    match status {
236        ProcessStatus::Idle => "I",
237        ProcessStatus::Run => "R",
238        ProcessStatus::Sleep => "S",
239        ProcessStatus::Stop => "T",
240        ProcessStatus::Zombie => "Z",
241        ProcessStatus::Tracing => "t",
242        ProcessStatus::Dead => "X",
243        ProcessStatus::Wakekill => "K",
244        ProcessStatus::Waking => "W",
245        ProcessStatus::Parked => "P",
246        ProcessStatus::LockBlocked => "L",
247        ProcessStatus::UninterruptibleDiskSleep => "D",
248        ProcessStatus::Suspended => "S",
249        ProcessStatus::Unknown(_) => "?",
250    }
251}
252
253pub fn descendant_process_tree(root_pid: u32) -> Vec<ProcessSnapshot> {
254    let inventory = process_inventory();
255    let mut descendants = std::collections::BTreeSet::from([root_pid]);
256    loop {
257        let before = descendants.len();
258        for process in &inventory {
259            if descendants.contains(&process.parent_pid) {
260                descendants.insert(process.pid);
261            }
262        }
263        if descendants.len() == before {
264            break;
265        }
266    }
267    let mut result = inventory
268        .into_iter()
269        .filter(|process| descendants.contains(&process.pid))
270        .collect::<Vec<_>>();
271    result.sort_by_key(|process| process.pid);
272    result
273}
274
275fn format_duration(milliseconds: u128) -> String {
276    if milliseconds < 1_000 {
277        return format!("{milliseconds}ms");
278    }
279    let seconds = (milliseconds + 500) / 1_000;
280    if seconds < 60 {
281        return format!("{seconds}s");
282    }
283    format!("{}m{:02}s", seconds / 60, seconds % 60)
284}
285
286pub fn format_process_diagnostic(
287    root_pid: u32,
288    elapsed: Duration,
289    tree: &[ProcessSnapshot],
290) -> String {
291    let mut output = format!(
292        "[supercov] command still running after {}",
293        format_duration(elapsed.as_millis())
294    );
295    if tree.is_empty() {
296        output.push_str(&format!("\n  pid={root_pid} process details unavailable"));
297        return output;
298    }
299    for process in tree {
300        output.push_str(&format!(
301            "\n  pid={} ppid={} exe={}",
302            process.pid, process.parent_pid, process.executable
303        ));
304        if let Some(state) = &process.state {
305            output.push_str(&format!(" state={state}"));
306        }
307        if let Some(cpu_tenths) = process.cpu_tenths {
308            output.push_str(&format!(" cpu={}.{}s", cpu_tenths / 10, cpu_tenths % 10));
309        }
310    }
311    output
312}
313
314#[cfg(unix)]
315struct SignalFlags {
316    _exclusive: MutexGuard<'static, ()>,
317    previous: Vec<(i32, libc::sigaction)>,
318}
319
320#[cfg(unix)]
321impl SignalFlags {
322    fn install() -> Result<Self, SupervisionError> {
323        let exclusive = SIGNAL_HANDLER_LOCK
324            .lock()
325            .unwrap_or_else(std::sync::PoisonError::into_inner);
326        RECEIVED_SIGNAL.store(0, Ordering::SeqCst);
327        let mut previous = Vec::new();
328        for signal in [libc::SIGHUP, libc::SIGINT, libc::SIGTERM] {
329            // SAFETY: zero is a valid initial state for `sigaction`; every
330            // field used by the kernel is initialized below before the call.
331            let mut action = unsafe { std::mem::zeroed::<libc::sigaction>() };
332            action.sa_sigaction = record_signal as *const () as usize;
333            // SAFETY: `action.sa_mask` is a valid, writable signal set.
334            unsafe { libc::sigemptyset(&mut action.sa_mask) };
335            action.sa_flags = 0;
336            // SAFETY: `old` is initialized by a successful `sigaction` call.
337            let mut old = unsafe { std::mem::zeroed::<libc::sigaction>() };
338            // SAFETY: pointers reference live `sigaction` values and the
339            // signal is one of the three catchable POSIX signals above.
340            if unsafe { libc::sigaction(signal, &action, &mut old) } != 0 {
341                for (installed, old) in previous.iter().rev() {
342                    // SAFETY: restores a handler returned by `sigaction`.
343                    let _ = unsafe { libc::sigaction(*installed, old, std::ptr::null_mut()) };
344                }
345                return Err(SupervisionError::Signal(io::Error::last_os_error()));
346            }
347            previous.push((signal, old));
348        }
349        Ok(Self {
350            _exclusive: exclusive,
351            previous,
352        })
353    }
354
355    fn received(&self) -> Option<ForwardedSignal> {
356        match RECEIVED_SIGNAL.swap(0, Ordering::SeqCst) {
357            libc::SIGHUP => Some(ForwardedSignal::Sighup),
358            libc::SIGINT => Some(ForwardedSignal::Sigint),
359            libc::SIGTERM => Some(ForwardedSignal::Sigterm),
360            _ => None,
361        }
362    }
363}
364
365#[cfg(unix)]
366impl Drop for SignalFlags {
367    fn drop(&mut self) {
368        for (signal, previous) in self.previous.drain(..).rev() {
369            // SAFETY: `previous` came directly from a successful `sigaction`
370            // call for the same signal and remains live for this call.
371            let _ = unsafe { libc::sigaction(signal, &previous, std::ptr::null_mut()) };
372        }
373        RECEIVED_SIGNAL.store(0, Ordering::SeqCst);
374    }
375}
376
377#[cfg(unix)]
378static SIGNAL_HANDLER_LOCK: Mutex<()> = Mutex::new(());
379#[cfg(unix)]
380static RECEIVED_SIGNAL: AtomicI32 = AtomicI32::new(0);
381
382#[cfg(unix)]
383extern "C" fn record_signal(signal: i32) {
384    RECEIVED_SIGNAL.store(signal, Ordering::SeqCst);
385}
386
387#[cfg(windows)]
388struct SignalFlags {
389    _exclusive: MutexGuard<'static, ()>,
390}
391
392#[cfg(windows)]
393impl SignalFlags {
394    fn install() -> Result<Self, SupervisionError> {
395        use windows_sys::Win32::System::Console::SetConsoleCtrlHandler;
396
397        let exclusive = SIGNAL_HANDLER_LOCK
398            .lock()
399            .unwrap_or_else(std::sync::PoisonError::into_inner);
400        RECEIVED_SIGNAL.store(0, Ordering::SeqCst);
401        // SAFETY: `record_console_signal` has the required system ABI and
402        // remains installed only while this guard is alive.
403        if unsafe { SetConsoleCtrlHandler(Some(record_console_signal), 1) } == 0 {
404            return Err(SupervisionError::Signal(io::Error::last_os_error()));
405        }
406        Ok(Self {
407            _exclusive: exclusive,
408        })
409    }
410
411    fn received(&self) -> Option<ForwardedSignal> {
412        match RECEIVED_SIGNAL.swap(0, Ordering::SeqCst) {
413            2 => Some(ForwardedSignal::Sigint),
414            15 => Some(ForwardedSignal::Sigterm),
415            _ => None,
416        }
417    }
418}
419
420#[cfg(windows)]
421impl Drop for SignalFlags {
422    fn drop(&mut self) {
423        use windows_sys::Win32::System::Console::SetConsoleCtrlHandler;
424
425        // SAFETY: removes exactly the handler installed by `install`.
426        let _ = unsafe { SetConsoleCtrlHandler(Some(record_console_signal), 0) };
427        RECEIVED_SIGNAL.store(0, Ordering::SeqCst);
428    }
429}
430
431#[cfg(windows)]
432static SIGNAL_HANDLER_LOCK: Mutex<()> = Mutex::new(());
433#[cfg(windows)]
434static RECEIVED_SIGNAL: AtomicI32 = AtomicI32::new(0);
435
436#[cfg(windows)]
437unsafe extern "system" fn record_console_signal(control: u32) -> i32 {
438    use windows_sys::Win32::System::Console::{
439        CTRL_BREAK_EVENT, CTRL_C_EVENT, CTRL_CLOSE_EVENT, CTRL_LOGOFF_EVENT, CTRL_SHUTDOWN_EVENT,
440    };
441
442    match control {
443        CTRL_C_EVENT | CTRL_BREAK_EVENT => {
444            RECEIVED_SIGNAL.store(2, Ordering::SeqCst);
445            1
446        }
447        CTRL_CLOSE_EVENT | CTRL_LOGOFF_EVENT | CTRL_SHUTDOWN_EVENT => {
448            RECEIVED_SIGNAL.store(15, Ordering::SeqCst);
449            1
450        }
451        _ => 0,
452    }
453}
454
455#[cfg(windows)]
456struct JobHandle(windows_sys::Win32::Foundation::HANDLE);
457
458#[cfg(windows)]
459impl JobHandle {
460    fn new() -> Result<Self, SupervisionError> {
461        use windows_sys::Win32::System::JobObjects::{
462            CreateJobObjectW, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
463            JOBOBJECT_EXTENDED_LIMIT_INFORMATION, JobObjectExtendedLimitInformation,
464            SetInformationJobObject,
465        };
466
467        // SAFETY: null security attributes and name create one private job.
468        let handle = unsafe { CreateJobObjectW(std::ptr::null(), std::ptr::null()) };
469        if handle.is_null() {
470            return Err(SupervisionError::PlatformOperation {
471                operation: "create a Windows Job Object",
472                source: io::Error::last_os_error(),
473            });
474        }
475        let job = Self(handle);
476        let mut limits = JOBOBJECT_EXTENDED_LIMIT_INFORMATION::default();
477        limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
478        // SAFETY: the buffer is a live value of the exact information class
479        // and length requested by SetInformationJobObject.
480        if unsafe {
481            SetInformationJobObject(
482                job.0,
483                JobObjectExtendedLimitInformation,
484                (&raw const limits).cast(),
485                std::mem::size_of_val(&limits) as u32,
486            )
487        } == 0
488        {
489            return Err(SupervisionError::PlatformOperation {
490                operation: "configure Windows Job Object containment",
491                source: io::Error::last_os_error(),
492            });
493        }
494        Ok(job)
495    }
496
497    fn assign(&self, child: &Child) -> Result<(), SupervisionError> {
498        use std::os::windows::io::AsRawHandle;
499        use windows_sys::Win32::System::JobObjects::AssignProcessToJobObject;
500
501        // SAFETY: Child owns a live process handle with the rights granted by
502        // CreateProcess; the job handle stays live for the complete plan.
503        if unsafe { AssignProcessToJobObject(self.0, child.as_raw_handle().cast()) } == 0 {
504            return Err(SupervisionError::PlatformOperation {
505                operation: "assign the suspended command to its Windows Job Object",
506                source: io::Error::last_os_error(),
507            });
508        }
509        Ok(())
510    }
511
512    fn terminate(&self) {
513        use windows_sys::Win32::System::JobObjects::TerminateJobObject;
514        // SAFETY: the handle owns this invocation's process tree. Failure can
515        // only mean the tree has already exited, so termination is best effort.
516        let _ = unsafe { TerminateJobObject(self.0, 1) };
517    }
518}
519
520#[cfg(windows)]
521impl Drop for JobHandle {
522    fn drop(&mut self) {
523        use windows_sys::Win32::Foundation::CloseHandle;
524        // JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE makes this the final, crash-safe
525        // containment boundary for descendants that outlive their root.
526        let _ = unsafe { CloseHandle(self.0) };
527    }
528}
529
530#[cfg(windows)]
531fn resume_suspended_process(pid: u32) -> Result<(), SupervisionError> {
532    use windows_sys::Win32::{
533        Foundation::{CloseHandle, INVALID_HANDLE_VALUE},
534        System::{
535            Diagnostics::ToolHelp::{
536                CreateToolhelp32Snapshot, TH32CS_SNAPTHREAD, THREADENTRY32, Thread32First,
537                Thread32Next,
538            },
539            Threading::{OpenThread, ResumeThread, THREAD_SUSPEND_RESUME},
540        },
541    };
542
543    // The stdlib exposes the process handle but not CreateProcess's primary
544    // thread handle. Starting suspended, assigning the job, then resuming the
545    // process-owned thread from a ToolHelp snapshot closes the escape race.
546    let snapshot = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0) };
547    if snapshot == INVALID_HANDLE_VALUE {
548        return Err(SupervisionError::PlatformOperation {
549            operation: "enumerate the suspended command threads",
550            source: io::Error::last_os_error(),
551        });
552    }
553    struct Snapshot(windows_sys::Win32::Foundation::HANDLE);
554    impl Drop for Snapshot {
555        fn drop(&mut self) {
556            let _ = unsafe { CloseHandle(self.0) };
557        }
558    }
559    let _snapshot = Snapshot(snapshot);
560    let mut entry = THREADENTRY32 {
561        dwSize: std::mem::size_of::<THREADENTRY32>() as u32,
562        ..Default::default()
563    };
564    if unsafe { Thread32First(snapshot, &raw mut entry) } == 0 {
565        return Err(SupervisionError::PlatformOperation {
566            operation: "read the suspended command thread snapshot",
567            source: io::Error::last_os_error(),
568        });
569    }
570    loop {
571        if entry.th32OwnerProcessID == pid {
572            let thread = unsafe { OpenThread(THREAD_SUSPEND_RESUME, 0, entry.th32ThreadID) };
573            if thread.is_null() {
574                return Err(SupervisionError::PlatformOperation {
575                    operation: "open the suspended command's primary thread",
576                    source: io::Error::last_os_error(),
577                });
578            }
579            // SAFETY: the handle identifies a suspended thread owned by the
580            // just-created process and is closed immediately after resuming.
581            let resumed = unsafe { ResumeThread(thread) };
582            let resume_error = (resumed == u32::MAX).then(io::Error::last_os_error);
583            let _ = unsafe { CloseHandle(thread) };
584            if let Some(source) = resume_error {
585                return Err(SupervisionError::PlatformOperation {
586                    operation: "resume the contained command",
587                    source,
588                });
589            }
590            return Ok(());
591        }
592        entry.dwSize = std::mem::size_of::<THREADENTRY32>() as u32;
593        if unsafe { Thread32Next(snapshot, &raw mut entry) } == 0 {
594            break;
595        }
596    }
597    Err(SupervisionError::PlatformOperation {
598        operation: "locate the suspended command's primary thread",
599        source: io::Error::new(io::ErrorKind::NotFound, "process thread was absent"),
600    })
601}
602
603#[cfg(windows)]
604fn forward_windows_control(child: &Child) {
605    use windows_sys::Win32::System::Console::{CTRL_BREAK_EVENT, GenerateConsoleCtrlEvent};
606    // CREATE_NEW_PROCESS_GROUP makes the child's PID its console group ID.
607    // Some non-console commands reject the event; the grace-period Job Object
608    // termination remains authoritative in that case.
609    let _ = unsafe { GenerateConsoleCtrlEvent(CTRL_BREAK_EVENT, child.id()) };
610}
611
612#[cfg(unix)]
613fn signal_process_group(child: &mut Child, signal: i32) {
614    let pid = child.id() as i32;
615    // SAFETY: `kill` is async-signal-safe and receives a process-group ID
616    // created for this child before exec. Failure can mean the child exited
617    // between `try_wait` and this call, so it is intentionally non-fatal.
618    let group_result = unsafe { libc::kill(-pid, signal) };
619    if group_result != 0 {
620        // SAFETY: same rationale, with the child PID as a last-resort target.
621        let _ = unsafe { libc::kill(pid, signal) };
622    }
623}
624
625#[cfg(unix)]
626fn exit_parts(status: ExitStatus) -> (Option<i32>, Option<i32>) {
627    use std::os::unix::process::ExitStatusExt;
628    (status.code(), status.signal())
629}
630
631#[cfg(not(unix))]
632fn exit_parts(status: ExitStatus) -> (Option<i32>, Option<i32>) {
633    (status.code(), None)
634}
635
636fn write_diagnostic(child: &Child, started: Instant, writer: &mut dyn Write) {
637    let tree = descendant_process_tree(child.id());
638    let _ = writeln!(
639        writer,
640        "{}",
641        format_process_diagnostic(child.id(), started.elapsed(), &tree)
642    )
643    .and_then(|_| writer.flush());
644}
645
646#[cfg(unix)]
647pub struct ProcessSupervisor {
648    signals: SignalFlags,
649}
650
651#[cfg(unix)]
652impl ProcessSupervisor {
653    pub fn new() -> Result<Self, SupervisionError> {
654        Ok(Self {
655            signals: SignalFlags::install()?,
656        })
657    }
658
659    pub fn supervise(
660        &self,
661        spec: &CommandSpec,
662        options: SupervisionOptions,
663        writer: &mut dyn Write,
664    ) -> Result<SupervisedResult, SupervisionError> {
665        if options.diagnostic_interval.is_zero() || options.termination_grace.is_zero() {
666            return Err(SupervisionError::InvalidMilliseconds {
667                name: "process supervision interval".into(),
668            });
669        }
670        if options.timeout.is_some_and(|timeout| timeout.is_zero()) {
671            return Err(SupervisionError::InvalidMilliseconds {
672                name: "SUPERCOV_COMMAND_TIMEOUT_MS".into(),
673            });
674        }
675        if let Some(signal) = self.signals.received() {
676            return Ok(SupervisedResult {
677                status: None,
678                signal: Some(signal.raw()),
679                timed_out: false,
680                interrupted_signal: Some(signal),
681            });
682        }
683        let mut command = spec.command()?;
684        let mut child = command.spawn().map_err(|source| SupervisionError::Spawn {
685            program: spec.program.clone(),
686            source,
687        })?;
688        let started = Instant::now();
689        let mut next_diagnostic = started + options.diagnostic_interval;
690        let timeout_at = options.timeout.map(|timeout| started + timeout);
691        let mut termination: Option<(Instant, Option<ForwardedSignal>)> = None;
692        let mut timed_out = false;
693        let mut interrupted_signal = None;
694        let mut escalated = false;
695
696        loop {
697            let status = match child.try_wait() {
698                Ok(status) => status,
699                Err(error) => {
700                    signal_process_group(&mut child, libc::SIGKILL);
701                    let _ = child.wait();
702                    return Err(SupervisionError::Wait(error));
703                }
704            };
705            if let Some(status) = status {
706                let (status, signal) = exit_parts(status);
707                return Ok(SupervisedResult {
708                    status,
709                    signal,
710                    timed_out,
711                    interrupted_signal,
712                });
713            }
714            let now = Instant::now();
715            if termination.is_none()
716                && let Some(signal) = self.signals.received()
717            {
718                interrupted_signal = Some(signal);
719                signal_process_group(&mut child, signal.raw());
720                termination = Some((now, Some(signal)));
721            }
722            if termination.is_none() && timeout_at.is_some_and(|deadline| now >= deadline) {
723                timed_out = true;
724                let _ = writeln!(
725                writer,
726                "[supercov] command exceeded SUPERCOV_COMMAND_TIMEOUT_MS={}; terminating process group",
727                options.timeout.expect("timeout deadline").as_millis()
728            )
729            .and_then(|_| writer.flush());
730                signal_process_group(&mut child, libc::SIGTERM);
731                termination = Some((now, None));
732                write_diagnostic(&child, started, writer);
733            }
734            if now >= next_diagnostic && !timed_out {
735                write_diagnostic(&child, started, writer);
736                while next_diagnostic <= now {
737                    next_diagnostic += options.diagnostic_interval;
738                }
739            }
740            if !escalated
741                && termination.is_some_and(|(terminated_at, _)| {
742                    now.duration_since(terminated_at) >= options.termination_grace
743                })
744            {
745                signal_process_group(&mut child, libc::SIGKILL);
746                escalated = true;
747            }
748            thread::sleep(POLL_INTERVAL);
749        }
750    }
751}
752
753#[cfg(windows)]
754pub struct ProcessSupervisor {
755    signals: SignalFlags,
756    job: JobHandle,
757}
758
759#[cfg(windows)]
760impl ProcessSupervisor {
761    pub fn new() -> Result<Self, SupervisionError> {
762        Ok(Self {
763            signals: SignalFlags::install()?,
764            job: JobHandle::new()?,
765        })
766    }
767
768    pub fn supervise(
769        &self,
770        spec: &CommandSpec,
771        options: SupervisionOptions,
772        writer: &mut dyn Write,
773    ) -> Result<SupervisedResult, SupervisionError> {
774        if options.diagnostic_interval.is_zero() || options.termination_grace.is_zero() {
775            return Err(SupervisionError::InvalidMilliseconds {
776                name: "process supervision interval".into(),
777            });
778        }
779        if options.timeout.is_some_and(|timeout| timeout.is_zero()) {
780            return Err(SupervisionError::InvalidMilliseconds {
781                name: "SUPERCOV_COMMAND_TIMEOUT_MS".into(),
782            });
783        }
784        if let Some(signal) = self.signals.received() {
785            return Ok(SupervisedResult {
786                status: None,
787                signal: None,
788                timed_out: false,
789                interrupted_signal: Some(signal),
790            });
791        }
792        let mut command = spec.command()?;
793        let mut child = command.spawn().map_err(|source| SupervisionError::Spawn {
794            program: spec.program.clone(),
795            source,
796        })?;
797        if let Err(error) = self.job.assign(&child) {
798            let _ = child.kill();
799            let _ = child.wait();
800            return Err(error);
801        }
802        if let Err(error) = resume_suspended_process(child.id()) {
803            self.job.terminate();
804            let _ = child.wait();
805            return Err(error);
806        }
807        let started = Instant::now();
808        let mut next_diagnostic = started + options.diagnostic_interval;
809        let timeout_at = options.timeout.map(|timeout| started + timeout);
810        let mut termination: Option<Instant> = None;
811        let mut timed_out = false;
812        let mut interrupted_signal = None;
813        let mut escalated = false;
814
815        loop {
816            let status = match child.try_wait() {
817                Ok(status) => status,
818                Err(error) => {
819                    self.job.terminate();
820                    let _ = child.wait();
821                    return Err(SupervisionError::Wait(error));
822                }
823            };
824            if let Some(status) = status {
825                let (status, signal) = exit_parts(status);
826                return Ok(SupervisedResult {
827                    status,
828                    signal,
829                    timed_out,
830                    interrupted_signal,
831                });
832            }
833            let now = Instant::now();
834            if termination.is_none()
835                && let Some(signal) = self.signals.received()
836            {
837                interrupted_signal = Some(signal);
838                forward_windows_control(&child);
839                termination = Some(now);
840            }
841            if termination.is_none() && timeout_at.is_some_and(|deadline| now >= deadline) {
842                timed_out = true;
843                let _ = writeln!(
844                    writer,
845                    "[supercov] command exceeded SUPERCOV_COMMAND_TIMEOUT_MS={}; terminating process group",
846                    options.timeout.expect("timeout deadline").as_millis()
847                )
848                .and_then(|_| writer.flush());
849                forward_windows_control(&child);
850                termination = Some(now);
851                write_diagnostic(&child, started, writer);
852            }
853            if now >= next_diagnostic && !timed_out {
854                write_diagnostic(&child, started, writer);
855                while next_diagnostic <= now {
856                    next_diagnostic += options.diagnostic_interval;
857                }
858            }
859            if !escalated
860                && termination.is_some_and(|terminated_at| {
861                    now.duration_since(terminated_at) >= options.termination_grace
862                })
863            {
864                self.job.terminate();
865                escalated = true;
866            }
867            thread::sleep(POLL_INTERVAL);
868        }
869    }
870}
871
872#[cfg(not(any(unix, windows)))]
873pub struct ProcessSupervisor;
874
875#[cfg(not(any(unix, windows)))]
876impl ProcessSupervisor {
877    pub fn new() -> Result<Self, SupervisionError> {
878        Err(SupervisionError::UnsupportedPlatform(
879            "this target has no process-tree containment implementation",
880        ))
881    }
882
883    pub fn supervise(
884        &self,
885        _spec: &CommandSpec,
886        _options: SupervisionOptions,
887        _writer: &mut dyn Write,
888    ) -> Result<SupervisedResult, SupervisionError> {
889        Err(SupervisionError::UnsupportedPlatform(
890            "this target has no process-tree containment implementation",
891        ))
892    }
893}
894
895pub fn supervise_command(
896    spec: &CommandSpec,
897    options: SupervisionOptions,
898    writer: &mut dyn Write,
899) -> Result<SupervisedResult, SupervisionError> {
900    ProcessSupervisor::new()?.supervise(spec, options, writer)
901}
902
903#[cfg(test)]
904mod tests {
905    use super::*;
906
907    #[test]
908    fn parses_only_positive_integer_milliseconds() {
909        assert_eq!(positive_milliseconds(None, "VALUE").unwrap(), None);
910        assert_eq!(
911            positive_milliseconds(Some("50"), "VALUE").unwrap(),
912            Some(Duration::from_millis(50))
913        );
914        for value in ["0", "-1", "1.5", "NaN", " 1"] {
915            assert!(positive_milliseconds(Some(value), "VALUE").is_err());
916        }
917    }
918
919    #[test]
920    fn diagnostic_format_is_sanitized_and_reference_compatible() {
921        let output = format_process_diagnostic(
922            20,
923            Duration::from_millis(61_000),
924            &[ProcessSnapshot {
925                pid: 20,
926                parent_pid: 10,
927                executable: "node".into(),
928                state: Some("S".into()),
929                cpu_tenths: Some(13),
930            }],
931        );
932        assert_eq!(
933            output,
934            "[supercov] command still running after 1m01s\n  pid=20 ppid=10 exe=node state=S cpu=1.3s"
935        );
936        assert!(!output.contains("argv"));
937    }
938
939    #[cfg(unix)]
940    #[test]
941    fn returns_the_child_status_without_a_default_timeout() {
942        let root = std::env::current_dir().unwrap();
943        let spec = CommandSpec {
944            program: "/bin/sh".into(),
945            arguments: vec!["-c".into(), "exit 7".into()],
946            cwd: root,
947            environment: None,
948        };
949        let mut diagnostics = Vec::new();
950        let result =
951            supervise_command(&spec, SupervisionOptions::default(), &mut diagnostics).unwrap();
952        assert_eq!(result.exit_code(), 7);
953        assert!(!result.timed_out);
954        assert!(diagnostics.is_empty());
955    }
956
957    #[cfg(windows)]
958    #[test]
959    fn returns_the_windows_child_status_without_a_default_timeout() {
960        let spec = CommandSpec {
961            program: "cmd.exe".into(),
962            arguments: vec!["/D".into(), "/S".into(), "/C".into(), "exit /b 7".into()],
963            cwd: std::env::current_dir().unwrap(),
964            environment: None,
965        };
966        let mut diagnostics = Vec::new();
967        let result =
968            supervise_command(&spec, SupervisionOptions::default(), &mut diagnostics).unwrap();
969        assert_eq!(result.exit_code(), 7);
970        assert!(!result.timed_out);
971        assert!(diagnostics.is_empty());
972    }
973
974    #[cfg(unix)]
975    #[test]
976    fn explicit_timeout_reports_and_returns_124() {
977        let root = std::env::current_dir().unwrap();
978        let spec = CommandSpec {
979            program: "/bin/sh".into(),
980            arguments: vec!["-c".into(), "while :; do sleep 1; done".into()],
981            cwd: root,
982            environment: None,
983        };
984        let mut diagnostics = Vec::new();
985        let result = supervise_command(
986            &spec,
987            SupervisionOptions {
988                diagnostic_interval: Duration::from_millis(20),
989                timeout: Some(Duration::from_millis(70)),
990                termination_grace: Duration::from_millis(50),
991            },
992            &mut diagnostics,
993        )
994        .unwrap();
995        let diagnostics = String::from_utf8(diagnostics).unwrap();
996        assert_eq!(result.exit_code(), COMMAND_TIMEOUT_EXIT_CODE);
997        assert!(result.timed_out);
998        assert!(diagnostics.contains("command still running after"));
999        assert!(diagnostics.contains("SUPERCOV_COMMAND_TIMEOUT_MS=70"));
1000    }
1001
1002    #[cfg(windows)]
1003    #[test]
1004    fn timeout_terminates_the_complete_windows_job() {
1005        use std::{
1006            fs,
1007            time::{SystemTime, UNIX_EPOCH},
1008        };
1009
1010        let unique = SystemTime::now()
1011            .duration_since(UNIX_EPOCH)
1012            .unwrap()
1013            .as_nanos();
1014        let root = std::env::temp_dir().join(format!(
1015            "supercov-windows-job-{}-{unique}",
1016            std::process::id()
1017        ));
1018        fs::create_dir_all(&root).unwrap();
1019        struct RemoveOnDrop(PathBuf);
1020        impl Drop for RemoveOnDrop {
1021            fn drop(&mut self) {
1022                let _ = fs::remove_dir_all(&self.0);
1023            }
1024        }
1025        let _cleanup = RemoveOnDrop(root.clone());
1026        let ready = root.join("descendant-ready");
1027        let marker = root.join("descendant-survived");
1028        let mut environment = std::env::vars_os().collect::<Vec<_>>();
1029        environment.extend([
1030            ("SUPERCOV_WINDOWS_PARENT_HELPER".into(), "1".into()),
1031            ("SUPERCOV_WINDOWS_READY".into(), ready.as_os_str().into()),
1032            ("SUPERCOV_WINDOWS_MARKER".into(), marker.as_os_str().into()),
1033        ]);
1034        let spec = CommandSpec {
1035            program: std::env::current_exe().unwrap().into_os_string(),
1036            arguments: vec![
1037                "--ignored".into(),
1038                "windows_timeout_parent_helper".into(),
1039                "--nocapture".into(),
1040            ],
1041            cwd: root,
1042            environment: Some(environment),
1043        };
1044        let mut diagnostics = Vec::new();
1045        let result = supervise_command(
1046            &spec,
1047            SupervisionOptions {
1048                diagnostic_interval: Duration::from_secs(60),
1049                timeout: Some(Duration::from_millis(750)),
1050                termination_grace: Duration::from_millis(50),
1051            },
1052            &mut diagnostics,
1053        )
1054        .unwrap();
1055
1056        assert!(result.timed_out);
1057        assert_eq!(result.exit_code(), COMMAND_TIMEOUT_EXIT_CODE);
1058        assert!(
1059            ready.exists(),
1060            "the helper did not prove that its descendant started before timeout"
1061        );
1062        thread::sleep(Duration::from_millis(1_700));
1063        assert!(
1064            !marker.exists(),
1065            "a descendant escaped the Windows Job Object after timeout"
1066        );
1067        assert!(
1068            String::from_utf8(diagnostics)
1069                .unwrap()
1070                .contains("terminating process group")
1071        );
1072    }
1073
1074    #[cfg(windows)]
1075    #[test]
1076    #[ignore = "subprocess helper for timeout_terminates_the_complete_windows_job"]
1077    fn windows_timeout_parent_helper() {
1078        use std::fs;
1079
1080        if std::env::var_os("SUPERCOV_WINDOWS_PARENT_HELPER").is_none() {
1081            return;
1082        }
1083        let mut child = Command::new(std::env::current_exe().unwrap())
1084            .args(["--ignored", "windows_timeout_marker_helper", "--nocapture"])
1085            .stdin(Stdio::null())
1086            .stdout(Stdio::null())
1087            .stderr(Stdio::null())
1088            .spawn()
1089            .unwrap();
1090        fs::write(
1091            std::env::var_os("SUPERCOV_WINDOWS_READY").unwrap(),
1092            child.id().to_string(),
1093        )
1094        .unwrap();
1095        child.wait().unwrap();
1096    }
1097
1098    #[cfg(windows)]
1099    #[test]
1100    #[ignore = "subprocess helper for timeout_terminates_the_complete_windows_job"]
1101    fn windows_timeout_marker_helper() {
1102        use std::fs;
1103
1104        if std::env::var_os("SUPERCOV_WINDOWS_PARENT_HELPER").is_none() {
1105            return;
1106        }
1107        thread::sleep(Duration::from_millis(1_500));
1108        fs::write(
1109            std::env::var_os("SUPERCOV_WINDOWS_MARKER").unwrap(),
1110            b"escaped",
1111        )
1112        .unwrap();
1113    }
1114
1115    #[cfg(unix)]
1116    #[test]
1117    fn diagnostic_write_failures_never_change_the_child_result() {
1118        struct BrokenWriter;
1119        impl Write for BrokenWriter {
1120            fn write(&mut self, _buffer: &[u8]) -> io::Result<usize> {
1121                Err(io::Error::new(
1122                    io::ErrorKind::BrokenPipe,
1123                    "closed diagnostic stream",
1124                ))
1125            }
1126
1127            fn flush(&mut self) -> io::Result<()> {
1128                Ok(())
1129            }
1130        }
1131
1132        let spec = CommandSpec {
1133            program: "/bin/sh".into(),
1134            arguments: vec!["-c".into(), "sleep 0.05; exit 0".into()],
1135            cwd: std::env::current_dir().unwrap(),
1136            environment: None,
1137        };
1138        let result = supervise_command(
1139            &spec,
1140            SupervisionOptions {
1141                diagnostic_interval: Duration::from_millis(10),
1142                timeout: None,
1143                termination_grace: Duration::from_millis(50),
1144            },
1145            &mut BrokenWriter,
1146        )
1147        .unwrap();
1148        assert_eq!(result.exit_code(), 0);
1149    }
1150}