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