1#[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 pub environment: Option<Vec<(OsString, OsString)>>,
95 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 #[cfg(windows)]
155 fn resolved_program(&self) -> OsString {
156 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#[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 let mut action = unsafe { std::mem::zeroed::<libc::sigaction>() };
466 action.sa_sigaction = record_signal as *const () as usize;
467 unsafe { libc::sigemptyset(&mut action.sa_mask) };
469 action.sa_flags = 0;
470 let mut old = unsafe { std::mem::zeroed::<libc::sigaction>() };
472 if unsafe { libc::sigaction(signal, &action, &mut old) } != 0 {
475 for (installed, old) in previous.iter().rev() {
476 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 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 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 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 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#[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 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 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 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 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 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 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 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 let _ = unsafe { GenerateConsoleCtrlEvent(CTRL_BREAK_EVENT, child.id()) };
759}
760
761#[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 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 let read = unsafe { OwnedFd::from_raw_fd(descriptors[0]) };
782 let write = unsafe { OwnedFd::from_raw_fd(descriptors[1]) };
784 for descriptor in [read.as_raw_fd(), write.as_raw_fd()] {
785 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 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 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 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 let _ = unsafe { libc::close(descriptor) };
911 }
912 let ready = [1_u8];
913 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 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 let group_result = unsafe { libc::kill(-pid, signal) };
952 if group_result != 0 {
953 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
969fn write_diagnostic(child: &Child, started: Instant, writer: &mut dyn Write) {
970 let tree = descendant_process_tree(child.id());
971 let diagnostic = format_process_diagnostic(child.id(), started.elapsed(), &tree);
972 let verbose = std::env::var("SUPERCOV_VERBOSE")
973 .or_else(|_| std::env::var("SUPERCOV_DEBUG"))
974 .is_ok_and(|value| matches!(value.as_str(), "1" | "true" | "yes"));
975 let diagnostic = if verbose {
976 diagnostic.as_str()
977 } else {
978 diagnostic.lines().next().unwrap_or(diagnostic.as_str())
979 };
980 let _ = writeln!(writer, "{}", diagnostic).and_then(|_| writer.flush());
981}
982
983fn validate_options(options: SupervisionOptions) -> Result<(), SupervisionError> {
984 if options.diagnostic_interval.is_zero() || options.termination_grace.is_zero() {
985 return Err(SupervisionError::InvalidMilliseconds {
986 name: "process supervision interval".into(),
987 });
988 }
989 if options.timeout.is_some_and(|timeout| timeout.is_zero()) {
990 return Err(SupervisionError::InvalidMilliseconds {
991 name: "SUPERCOV_COMMAND_TIMEOUT_MS".into(),
992 });
993 }
994 Ok(())
995}
996
997fn read_pipe(mut pipe: impl Read) -> io::Result<Vec<u8>> {
998 let mut bytes = Vec::new();
999 pipe.read_to_end(&mut bytes)?;
1000 Ok(bytes)
1001}
1002
1003fn captured_bytes(
1004 reader: thread::JoinHandle<io::Result<Vec<u8>>>,
1005 stream: &'static str,
1006) -> Result<Vec<u8>, SupervisionError> {
1007 reader
1008 .join()
1009 .map_err(|_| SupervisionError::PlatformOperation {
1010 operation: "join captured process output reader",
1011 source: io::Error::other(format!("{stream} reader panicked")),
1012 })?
1013 .map_err(|source| SupervisionError::PlatformOperation {
1014 operation: "read captured process output",
1015 source,
1016 })
1017}
1018
1019#[cfg(unix)]
1020pub struct ProcessSupervisor {
1021 signals: SignalFlags,
1022 watchdog_program: Option<PathBuf>,
1023}
1024
1025#[cfg(unix)]
1026impl ProcessSupervisor {
1027 pub fn new() -> Result<Self, SupervisionError> {
1028 Ok(Self {
1029 signals: SignalFlags::install()?,
1030 watchdog_program: None,
1031 })
1032 }
1033
1034 pub fn new_crash_safe(watchdog_program: &Path) -> Result<Self, SupervisionError> {
1035 let watchdog_program = fs::canonicalize(watchdog_program).map_err(|source| {
1036 SupervisionError::PlatformOperation {
1037 operation: "resolve parent-death watchdog executable",
1038 source,
1039 }
1040 })?;
1041 if !fs::metadata(&watchdog_program).is_ok_and(|metadata| metadata.is_file()) {
1042 return Err(SupervisionError::PlatformOperation {
1043 operation: "validate parent-death watchdog executable",
1044 source: io::Error::new(io::ErrorKind::InvalidInput, "expected a regular file"),
1045 });
1046 }
1047 Ok(Self {
1048 signals: SignalFlags::install()?,
1049 watchdog_program: Some(watchdog_program),
1050 })
1051 }
1052
1053 pub fn supervise(
1054 &self,
1055 spec: &CommandSpec,
1056 options: SupervisionOptions,
1057 writer: &mut dyn Write,
1058 ) -> Result<SupervisedResult, SupervisionError> {
1059 validate_options(options)?;
1060 if let Some(signal) = self.signals.received() {
1061 return Ok(SupervisedResult {
1062 status: None,
1063 signal: Some(signal.raw()),
1064 timed_out: false,
1065 interrupted_signal: Some(signal),
1066 });
1067 }
1068 let mut command = spec.command()?;
1069 let (mut child, _parent_death_guard) = spawn_contained(
1070 &mut command,
1071 &spec.program,
1072 self.watchdog_program.as_deref(),
1073 )?;
1074 self.monitor(&mut child, options, writer)
1075 }
1076
1077 pub fn supervise_captured(
1078 &self,
1079 spec: &CommandSpec,
1080 options: SupervisionOptions,
1081 writer: &mut dyn Write,
1082 ) -> Result<SupervisedOutput, SupervisionError> {
1083 validate_options(options)?;
1084 if spec.captured_output.is_some() {
1085 return Err(SupervisionError::PlatformOperation {
1086 operation: "configure separate captured process output",
1087 source: io::Error::new(
1088 io::ErrorKind::InvalidInput,
1089 "merged and separate capture cannot be requested together",
1090 ),
1091 });
1092 }
1093 if let Some(signal) = self.signals.received() {
1094 return Ok(SupervisedOutput {
1095 result: SupervisedResult {
1096 status: None,
1097 signal: Some(signal.raw()),
1098 timed_out: false,
1099 interrupted_signal: Some(signal),
1100 },
1101 stdout: Vec::new(),
1102 stderr: Vec::new(),
1103 });
1104 }
1105 let mut command = spec.command()?;
1106 command.stdout(Stdio::piped()).stderr(Stdio::piped());
1107 let (mut child, parent_death_guard) = spawn_contained(
1108 &mut command,
1109 &spec.program,
1110 self.watchdog_program.as_deref(),
1111 )?;
1112 let stdout = child.stdout.take().expect("piped stdout");
1113 let stderr = child.stderr.take().expect("piped stderr");
1114 let stdout_reader = thread::spawn(move || read_pipe(stdout));
1115 let stderr_reader = thread::spawn(move || read_pipe(stderr));
1116 let result = self.monitor(&mut child, options, writer);
1117 drop(parent_death_guard);
1120 let stdout = captured_bytes(stdout_reader, "stdout")?;
1121 let stderr = captured_bytes(stderr_reader, "stderr")?;
1122 Ok(SupervisedOutput {
1123 result: result?,
1124 stdout,
1125 stderr,
1126 })
1127 }
1128
1129 fn monitor(
1130 &self,
1131 child: &mut Child,
1132 options: SupervisionOptions,
1133 writer: &mut dyn Write,
1134 ) -> Result<SupervisedResult, SupervisionError> {
1135 let started = Instant::now();
1136 let mut next_diagnostic = started + options.diagnostic_interval;
1137 let timeout_at = options.timeout.map(|timeout| started + timeout);
1138 let mut termination: Option<(Instant, Option<ForwardedSignal>)> = None;
1139 let mut timed_out = false;
1140 let mut interrupted_signal = None;
1141 let mut escalated = false;
1142
1143 loop {
1144 let status = match child.try_wait() {
1145 Ok(status) => status,
1146 Err(error) => {
1147 signal_process_group(child, libc::SIGKILL);
1148 let _ = child.wait();
1149 return Err(SupervisionError::Wait(error));
1150 }
1151 };
1152 if let Some(status) = status {
1153 let (status, signal) = exit_parts(status);
1154 return Ok(SupervisedResult {
1155 status,
1156 signal,
1157 timed_out,
1158 interrupted_signal,
1159 });
1160 }
1161 let now = Instant::now();
1162 if termination.is_none()
1163 && let Some(signal) = self.signals.received()
1164 {
1165 interrupted_signal = Some(signal);
1166 signal_process_group(child, signal.raw());
1167 termination = Some((now, Some(signal)));
1168 }
1169 if termination.is_none() && timeout_at.is_some_and(|deadline| now >= deadline) {
1170 timed_out = true;
1171 let _ = writeln!(
1172 writer,
1173 "[supercov] command exceeded SUPERCOV_COMMAND_TIMEOUT_MS={}; terminating process group",
1174 options.timeout.expect("timeout deadline").as_millis()
1175 )
1176 .and_then(|_| writer.flush());
1177 signal_process_group(child, libc::SIGTERM);
1178 termination = Some((now, None));
1179 write_diagnostic(child, started, writer);
1180 }
1181 if now >= next_diagnostic && !timed_out {
1182 write_diagnostic(child, started, writer);
1183 while next_diagnostic <= now {
1184 next_diagnostic += options.diagnostic_interval;
1185 }
1186 }
1187 if !escalated
1188 && termination.is_some_and(|(terminated_at, _)| {
1189 now.duration_since(terminated_at) >= options.termination_grace
1190 })
1191 {
1192 signal_process_group(child, libc::SIGKILL);
1193 escalated = true;
1194 }
1195 thread::sleep(POLL_INTERVAL);
1196 }
1197 }
1198}
1199
1200#[cfg(windows)]
1201pub struct ProcessSupervisor {
1202 signals: SignalFlags,
1203 job: JobHandle,
1204}
1205
1206#[cfg(windows)]
1207impl ProcessSupervisor {
1208 pub fn new() -> Result<Self, SupervisionError> {
1209 Ok(Self {
1210 signals: SignalFlags::install()?,
1211 job: JobHandle::new()?,
1212 })
1213 }
1214
1215 pub fn new_crash_safe(_watchdog_program: &Path) -> Result<Self, SupervisionError> {
1216 Self::new()
1217 }
1218
1219 pub fn supervise(
1220 &self,
1221 spec: &CommandSpec,
1222 options: SupervisionOptions,
1223 writer: &mut dyn Write,
1224 ) -> Result<SupervisedResult, SupervisionError> {
1225 validate_options(options)?;
1226 if let Some(signal) = self.signals.received() {
1227 return Ok(SupervisedResult {
1228 status: None,
1229 signal: None,
1230 timed_out: false,
1231 interrupted_signal: Some(signal),
1232 });
1233 }
1234 let mut command = spec.command()?;
1235 let mut child = command.spawn().map_err(|source| SupervisionError::Spawn {
1236 program: spec.program.clone(),
1237 source,
1238 })?;
1239 if let Err(error) = self.job.assign(&child) {
1240 let _ = child.kill();
1241 let _ = child.wait();
1242 return Err(error);
1243 }
1244 if let Err(error) = resume_suspended_process(child.id()) {
1245 self.job.terminate();
1246 let _ = child.wait();
1247 return Err(error);
1248 }
1249 self.monitor(&mut child, options, writer)
1250 }
1251
1252 pub fn supervise_captured(
1253 &self,
1254 spec: &CommandSpec,
1255 options: SupervisionOptions,
1256 writer: &mut dyn Write,
1257 ) -> Result<SupervisedOutput, SupervisionError> {
1258 validate_options(options)?;
1259 if spec.captured_output.is_some() {
1260 return Err(SupervisionError::PlatformOperation {
1261 operation: "configure separate captured process output",
1262 source: io::Error::new(
1263 io::ErrorKind::InvalidInput,
1264 "merged and separate capture cannot be requested together",
1265 ),
1266 });
1267 }
1268 if let Some(signal) = self.signals.received() {
1269 return Ok(SupervisedOutput {
1270 result: SupervisedResult {
1271 status: None,
1272 signal: None,
1273 timed_out: false,
1274 interrupted_signal: Some(signal),
1275 },
1276 stdout: Vec::new(),
1277 stderr: Vec::new(),
1278 });
1279 }
1280 let mut command = spec.command()?;
1281 command.stdout(Stdio::piped()).stderr(Stdio::piped());
1282 let mut child = command.spawn().map_err(|source| SupervisionError::Spawn {
1283 program: spec.program.clone(),
1284 source,
1285 })?;
1286 if let Err(error) = self.job.assign(&child) {
1287 let _ = child.kill();
1288 let _ = child.wait();
1289 return Err(error);
1290 }
1291 if let Err(error) = resume_suspended_process(child.id()) {
1292 self.job.terminate();
1293 let _ = child.wait();
1294 return Err(error);
1295 }
1296 let stdout = child.stdout.take().expect("piped stdout");
1297 let stderr = child.stderr.take().expect("piped stderr");
1298 let stdout_reader = thread::spawn(move || read_pipe(stdout));
1299 let stderr_reader = thread::spawn(move || read_pipe(stderr));
1300 let result = self.monitor(&mut child, options, writer);
1301 let stdout = captured_bytes(stdout_reader, "stdout")?;
1302 let stderr = captured_bytes(stderr_reader, "stderr")?;
1303 Ok(SupervisedOutput {
1304 result: result?,
1305 stdout,
1306 stderr,
1307 })
1308 }
1309
1310 fn monitor(
1311 &self,
1312 child: &mut Child,
1313 options: SupervisionOptions,
1314 writer: &mut dyn Write,
1315 ) -> Result<SupervisedResult, SupervisionError> {
1316 let started = Instant::now();
1317 let mut next_diagnostic = started + options.diagnostic_interval;
1318 let timeout_at = options.timeout.map(|timeout| started + timeout);
1319 let mut termination: Option<Instant> = None;
1320 let mut timed_out = false;
1321 let mut interrupted_signal = None;
1322 let mut escalated = false;
1323
1324 loop {
1325 let status = match child.try_wait() {
1326 Ok(status) => status,
1327 Err(error) => {
1328 self.job.terminate();
1329 let _ = child.wait();
1330 return Err(SupervisionError::Wait(error));
1331 }
1332 };
1333 if let Some(status) = status {
1334 let (status, signal) = exit_parts(status);
1335 return Ok(SupervisedResult {
1336 status,
1337 signal,
1338 timed_out,
1339 interrupted_signal,
1340 });
1341 }
1342 let now = Instant::now();
1343 if termination.is_none()
1344 && let Some(signal) = self.signals.received()
1345 {
1346 interrupted_signal = Some(signal);
1347 forward_windows_control(child);
1348 termination = Some(now);
1349 }
1350 if termination.is_none() && timeout_at.is_some_and(|deadline| now >= deadline) {
1351 timed_out = true;
1352 let _ = writeln!(
1353 writer,
1354 "[supercov] command exceeded SUPERCOV_COMMAND_TIMEOUT_MS={}; terminating process group",
1355 options.timeout.expect("timeout deadline").as_millis()
1356 )
1357 .and_then(|_| writer.flush());
1358 forward_windows_control(child);
1359 termination = Some(now);
1360 write_diagnostic(child, started, writer);
1361 }
1362 if now >= next_diagnostic && !timed_out {
1363 write_diagnostic(child, started, writer);
1364 while next_diagnostic <= now {
1365 next_diagnostic += options.diagnostic_interval;
1366 }
1367 }
1368 if !escalated
1369 && termination.is_some_and(|terminated_at| {
1370 now.duration_since(terminated_at) >= options.termination_grace
1371 })
1372 {
1373 self.job.terminate();
1374 escalated = true;
1375 }
1376 thread::sleep(POLL_INTERVAL);
1377 }
1378 }
1379}
1380
1381#[cfg(not(any(unix, windows)))]
1382pub struct ProcessSupervisor;
1383
1384#[cfg(not(any(unix, windows)))]
1385impl ProcessSupervisor {
1386 pub fn new() -> Result<Self, SupervisionError> {
1387 Err(SupervisionError::UnsupportedPlatform(
1388 "this target has no process-tree containment implementation",
1389 ))
1390 }
1391
1392 pub fn new_crash_safe(_watchdog_program: &Path) -> Result<Self, SupervisionError> {
1393 Self::new()
1394 }
1395
1396 pub fn supervise(
1397 &self,
1398 _spec: &CommandSpec,
1399 _options: SupervisionOptions,
1400 _writer: &mut dyn Write,
1401 ) -> Result<SupervisedResult, SupervisionError> {
1402 Err(SupervisionError::UnsupportedPlatform(
1403 "this target has no process-tree containment implementation",
1404 ))
1405 }
1406
1407 pub fn supervise_captured(
1408 &self,
1409 _spec: &CommandSpec,
1410 _options: SupervisionOptions,
1411 _writer: &mut dyn Write,
1412 ) -> Result<SupervisedOutput, SupervisionError> {
1413 Err(SupervisionError::UnsupportedPlatform(
1414 "this target has no process-tree containment implementation",
1415 ))
1416 }
1417}
1418
1419pub fn supervise_command(
1420 spec: &CommandSpec,
1421 options: SupervisionOptions,
1422 writer: &mut dyn Write,
1423) -> Result<SupervisedResult, SupervisionError> {
1424 ProcessSupervisor::new()?.supervise(spec, options, writer)
1425}
1426
1427pub fn supervise_captured_command(
1428 spec: &CommandSpec,
1429 options: SupervisionOptions,
1430 writer: &mut dyn Write,
1431) -> Result<SupervisedOutput, SupervisionError> {
1432 ProcessSupervisor::new()?.supervise_captured(spec, options, writer)
1433}
1434
1435#[cfg(test)]
1436mod tests {
1437 use super::*;
1438
1439 fn scratch_directory(label: &str) -> PathBuf {
1440 static COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
1441 let root = std::env::temp_dir().join(format!(
1442 "supercov-{label}-{}-{}-{}",
1443 std::process::id(),
1444 std::time::SystemTime::now()
1445 .duration_since(std::time::UNIX_EPOCH)
1446 .unwrap()
1447 .as_nanos(),
1448 COUNTER.fetch_add(1, Ordering::Relaxed)
1449 ));
1450 std::fs::create_dir(&root).unwrap();
1451 root
1452 }
1453
1454 #[test]
1455 fn resolves_programs_the_way_the_windows_shell_does() {
1456 let root = scratch_directory("program-resolution");
1457 std::fs::create_dir(root.join("sub")).unwrap();
1458 for name in ["tool.cmd", "tool.CMD", "named.exe", "plain", "sub/tool.cmd"] {
1462 std::fs::write(root.join(name), "").unwrap();
1463 }
1464 let path = Some(std::env::join_paths([root.join("elsewhere"), root.clone()]).unwrap());
1465 let pathext = Some(OsString::from(".exe;.cmd"));
1466 let resolve = |program: &str| {
1467 resolve_program_with(OsStr::new(program), &root, path.clone(), pathext.clone())
1468 };
1469
1470 assert_eq!(resolve("tool"), root.join("tool.cmd").into_os_string());
1471 assert_eq!(resolve("named"), root.join("named.exe").into_os_string());
1472 assert_eq!(
1473 resolve("named.exe"),
1474 root.join("named.exe").into_os_string()
1475 );
1476 assert_eq!(resolve("plain"), OsString::from("plain"));
1478 assert_eq!(resolve("missing"), OsString::from("missing"));
1479 assert_eq!(
1481 resolve("sub/tool"),
1482 root.join("sub/tool.cmd").into_os_string()
1483 );
1484 assert_eq!(
1485 resolve("./sub/tool"),
1486 root.join("./sub/tool.cmd").into_os_string()
1487 );
1488 let absolute = root.join("named.exe");
1489 assert_eq!(
1490 resolve_program_with(
1491 absolute.as_os_str(),
1492 Path::new("/nowhere"),
1493 None,
1494 pathext.clone()
1495 ),
1496 absolute.clone().into_os_string()
1497 );
1498 assert_eq!(
1500 resolve_program_with(OsStr::new("tool"), &root, path.clone(), None),
1501 root.join("tool.CMD").into_os_string()
1502 );
1503 std::fs::remove_dir_all(root).unwrap();
1504 }
1505
1506 #[cfg(windows)]
1507 #[test]
1508 fn starts_a_batch_shim_found_through_pathext() {
1509 let root = scratch_directory("batch-shim");
1510 std::fs::write(root.join("shim.cmd"), "@echo off\r\nexit /b 3\r\n").unwrap();
1511 let mut environment = vec![
1512 (OsString::from("Path"), root.clone().into_os_string()),
1513 (
1514 OsString::from("PATHEXT"),
1515 OsString::from(".COM;.EXE;.BAT;.CMD"),
1516 ),
1517 ];
1518 if let Some(system_root) = std::env::var_os("SystemRoot") {
1519 environment.push((OsString::from("SystemRoot"), system_root));
1520 }
1521 let spec = CommandSpec {
1522 program: "shim".into(),
1523 arguments: vec!["ignored".into()],
1524 cwd: std::env::current_dir().unwrap(),
1525 environment: Some(environment),
1526 captured_output: None,
1527 };
1528 let mut diagnostics = Vec::new();
1529 let result =
1530 supervise_command(&spec, SupervisionOptions::default(), &mut diagnostics).unwrap();
1531 assert_eq!(result.exit_code(), 3);
1532 std::fs::remove_dir_all(root).unwrap();
1533 }
1534
1535 #[test]
1536 fn parses_only_positive_integer_milliseconds() {
1537 assert_eq!(positive_milliseconds(None, "VALUE").unwrap(), None);
1538 assert_eq!(
1539 positive_milliseconds(Some("50"), "VALUE").unwrap(),
1540 Some(Duration::from_millis(50))
1541 );
1542 for value in ["0", "-1", "1.5", "NaN", " 1"] {
1543 assert!(positive_milliseconds(Some(value), "VALUE").is_err());
1544 }
1545 }
1546
1547 #[test]
1548 fn diagnostic_format_is_sanitized_and_reference_compatible() {
1549 let output = format_process_diagnostic(
1550 20,
1551 Duration::from_millis(61_000),
1552 &[ProcessSnapshot {
1553 pid: 20,
1554 parent_pid: 10,
1555 executable: "node".into(),
1556 state: Some("S".into()),
1557 cpu_tenths: Some(13),
1558 }],
1559 );
1560 assert_eq!(
1561 output,
1562 "[supercov] command still running after 1m01s\n pid=20 ppid=10 exe=node state=S cpu=1.3s"
1563 );
1564 assert!(!output.contains("argv"));
1565 }
1566
1567 #[cfg(unix)]
1568 #[test]
1569 fn returns_the_child_status_without_a_default_timeout() {
1570 let root = std::env::current_dir().unwrap();
1571 let spec = CommandSpec {
1572 program: "/bin/sh".into(),
1573 arguments: vec!["-c".into(), "exit 7".into()],
1574 cwd: root,
1575 environment: None,
1576 captured_output: None,
1577 };
1578 let mut diagnostics = Vec::new();
1579 let result =
1580 supervise_command(&spec, SupervisionOptions::default(), &mut diagnostics).unwrap();
1581 assert_eq!(result.exit_code(), 7);
1582 assert!(!result.timed_out);
1583 assert!(diagnostics.is_empty());
1584 }
1585
1586 #[cfg(unix)]
1587 #[test]
1588 fn captures_stdout_and_stderr_separately_without_losing_status() {
1589 let spec = CommandSpec {
1590 program: "/bin/sh".into(),
1591 arguments: vec![
1592 "-c".into(),
1593 "printf stdout-value; printf stderr-value >&2; exit 9".into(),
1594 ],
1595 cwd: std::env::current_dir().unwrap(),
1596 environment: None,
1597 captured_output: None,
1598 };
1599 let mut diagnostics = Vec::new();
1600 let output =
1601 supervise_captured_command(&spec, SupervisionOptions::default(), &mut diagnostics)
1602 .unwrap();
1603 assert_eq!(output.result.exit_code(), 9);
1604 assert_eq!(output.stdout, b"stdout-value");
1605 assert_eq!(output.stderr, b"stderr-value");
1606 assert!(diagnostics.is_empty());
1607 }
1608
1609 #[cfg(windows)]
1610 #[test]
1611 fn returns_the_windows_child_status_without_a_default_timeout() {
1612 let spec = CommandSpec {
1613 program: "cmd.exe".into(),
1614 arguments: vec!["/D".into(), "/S".into(), "/C".into(), "exit /b 7".into()],
1615 cwd: std::env::current_dir().unwrap(),
1616 environment: None,
1617 captured_output: None,
1618 };
1619 let mut diagnostics = Vec::new();
1620 let result =
1621 supervise_command(&spec, SupervisionOptions::default(), &mut diagnostics).unwrap();
1622 assert_eq!(result.exit_code(), 7);
1623 assert!(!result.timed_out);
1624 assert!(diagnostics.is_empty());
1625 }
1626
1627 #[cfg(unix)]
1628 #[test]
1629 fn explicit_timeout_reports_and_returns_124() {
1630 let root = std::env::current_dir().unwrap();
1631 let spec = CommandSpec {
1632 program: "/bin/sh".into(),
1633 arguments: vec!["-c".into(), "while :; do sleep 1; done".into()],
1634 cwd: root,
1635 environment: None,
1636 captured_output: None,
1637 };
1638 let mut diagnostics = Vec::new();
1639 let result = supervise_command(
1640 &spec,
1641 SupervisionOptions {
1642 diagnostic_interval: Duration::from_millis(20),
1643 timeout: Some(Duration::from_millis(70)),
1644 termination_grace: Duration::from_millis(50),
1645 },
1646 &mut diagnostics,
1647 )
1648 .unwrap();
1649 let diagnostics = String::from_utf8(diagnostics).unwrap();
1650 assert_eq!(result.exit_code(), COMMAND_TIMEOUT_EXIT_CODE);
1651 assert!(result.timed_out);
1652 assert!(diagnostics.contains("command still running after"));
1653 assert!(diagnostics.contains("SUPERCOV_COMMAND_TIMEOUT_MS=70"));
1654 }
1655
1656 #[cfg(windows)]
1657 #[test]
1658 fn timeout_terminates_the_complete_windows_job() {
1659 use std::{
1660 fs,
1661 time::{SystemTime, UNIX_EPOCH},
1662 };
1663
1664 let unique = SystemTime::now()
1665 .duration_since(UNIX_EPOCH)
1666 .unwrap()
1667 .as_nanos();
1668 let root = std::env::temp_dir().join(format!(
1669 "supercov-windows-job-{}-{unique}",
1670 std::process::id()
1671 ));
1672 fs::create_dir_all(&root).unwrap();
1673 struct RemoveOnDrop(PathBuf);
1674 impl Drop for RemoveOnDrop {
1675 fn drop(&mut self) {
1676 let _ = fs::remove_dir_all(&self.0);
1677 }
1678 }
1679 let _cleanup = RemoveOnDrop(root.clone());
1680 let ready = root.join("descendant-ready");
1681 let marker = root.join("descendant-survived");
1682 let mut environment = std::env::vars_os().collect::<Vec<_>>();
1683 environment.extend([
1684 ("SUPERCOV_WINDOWS_PARENT_HELPER".into(), "1".into()),
1685 ("SUPERCOV_WINDOWS_READY".into(), ready.as_os_str().into()),
1686 ("SUPERCOV_WINDOWS_MARKER".into(), marker.as_os_str().into()),
1687 ]);
1688 let spec = CommandSpec {
1689 program: std::env::current_exe().unwrap().into_os_string(),
1690 arguments: vec![
1691 "--ignored".into(),
1692 "windows_timeout_parent_helper".into(),
1693 "--nocapture".into(),
1694 ],
1695 cwd: root,
1696 environment: Some(environment),
1697 captured_output: None,
1698 };
1699 let mut diagnostics = Vec::new();
1700 let result = supervise_command(
1701 &spec,
1702 SupervisionOptions {
1703 diagnostic_interval: Duration::from_secs(60),
1704 timeout: Some(Duration::from_millis(750)),
1705 termination_grace: Duration::from_millis(50),
1706 },
1707 &mut diagnostics,
1708 )
1709 .unwrap();
1710
1711 assert!(result.timed_out);
1712 assert_eq!(result.exit_code(), COMMAND_TIMEOUT_EXIT_CODE);
1713 assert!(
1714 ready.exists(),
1715 "the helper did not prove that its descendant started before timeout"
1716 );
1717 thread::sleep(Duration::from_millis(1_700));
1718 assert!(
1719 !marker.exists(),
1720 "a descendant escaped the Windows Job Object after timeout"
1721 );
1722 assert!(
1723 String::from_utf8(diagnostics)
1724 .unwrap()
1725 .contains("terminating process group")
1726 );
1727 }
1728
1729 #[cfg(windows)]
1730 #[test]
1731 #[ignore = "subprocess helper for timeout_terminates_the_complete_windows_job"]
1732 fn windows_timeout_parent_helper() {
1733 use std::fs;
1734
1735 if std::env::var_os("SUPERCOV_WINDOWS_PARENT_HELPER").is_none() {
1736 return;
1737 }
1738 let mut child = Command::new(std::env::current_exe().unwrap())
1739 .args(["--ignored", "windows_timeout_marker_helper", "--nocapture"])
1740 .stdin(Stdio::null())
1741 .stdout(Stdio::null())
1742 .stderr(Stdio::null())
1743 .spawn()
1744 .unwrap();
1745 fs::write(
1746 std::env::var_os("SUPERCOV_WINDOWS_READY").unwrap(),
1747 child.id().to_string(),
1748 )
1749 .unwrap();
1750 child.wait().unwrap();
1751 }
1752
1753 #[cfg(windows)]
1754 #[test]
1755 #[ignore = "subprocess helper for timeout_terminates_the_complete_windows_job"]
1756 fn windows_timeout_marker_helper() {
1757 use std::fs;
1758
1759 if std::env::var_os("SUPERCOV_WINDOWS_PARENT_HELPER").is_none() {
1760 return;
1761 }
1762 thread::sleep(Duration::from_millis(1_500));
1763 fs::write(
1764 std::env::var_os("SUPERCOV_WINDOWS_MARKER").unwrap(),
1765 b"escaped",
1766 )
1767 .unwrap();
1768 }
1769
1770 #[cfg(unix)]
1771 #[test]
1772 fn diagnostic_write_failures_never_change_the_child_result() {
1773 struct BrokenWriter;
1774 impl Write for BrokenWriter {
1775 fn write(&mut self, _buffer: &[u8]) -> io::Result<usize> {
1776 Err(io::Error::new(
1777 io::ErrorKind::BrokenPipe,
1778 "closed diagnostic stream",
1779 ))
1780 }
1781
1782 fn flush(&mut self) -> io::Result<()> {
1783 Ok(())
1784 }
1785 }
1786
1787 let spec = CommandSpec {
1788 program: "/bin/sh".into(),
1789 arguments: vec!["-c".into(), "sleep 0.05; exit 0".into()],
1790 cwd: std::env::current_dir().unwrap(),
1791 environment: None,
1792 captured_output: None,
1793 };
1794 let result = supervise_command(
1795 &spec,
1796 SupervisionOptions {
1797 diagnostic_interval: Duration::from_millis(10),
1798 timeout: None,
1799 termination_grace: Duration::from_millis(50),
1800 },
1801 &mut BrokenWriter,
1802 )
1803 .unwrap();
1804 assert_eq!(result.exit_code(), 0);
1805 }
1806}