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