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_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 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 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 assert_eq!(resolve("plain"), OsString::from("plain"));
1493 assert_eq!(resolve("missing"), OsString::from("missing"));
1494 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 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}