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