1use std::io;
4use std::process::ExitStatus;
5use std::time::{Duration, Instant};
6
7const CLEANUP_GRACE: Duration = Duration::from_secs(1);
8const REAP_RETRY_GRACE: Duration = Duration::from_millis(100);
9const CLEANUP_POLL_INTERVAL: Duration = Duration::from_millis(10);
10const MANAGED_PROCESS_TREE_ENV: &str = "FALLOW_MANAGED_PROCESS_TREE";
11
12#[cfg(feature = "tokio")]
14pub fn configure_tokio_command(command: &mut tokio::process::Command) {
15 configure_std_command(command.as_std_mut());
16}
17
18pub fn configure_std_command(command: &mut std::process::Command) {
20 command.env(MANAGED_PROCESS_TREE_ENV, "1");
21
22 #[cfg(unix)]
23 {
24 use std::os::unix::process::CommandExt;
25
26 command.process_group(0);
27 }
28
29 #[cfg(windows)]
30 {
31 use std::os::windows::process::CommandExt;
32
33 use windows_sys::Win32::System::Threading::CREATE_SUSPENDED;
34
35 command.creation_flags(CREATE_SUSPENDED);
36 }
37
38 #[cfg(not(any(unix, windows)))]
39 let _ = command;
40}
41
42pub fn inherits_managed_process_tree() -> bool {
49 std::env::var_os(MANAGED_PROCESS_TREE_ENV).is_some_and(|value| value == "1")
50}
51
52#[cfg(windows)]
53struct WindowsHandle(isize);
54
55#[cfg(windows)]
56impl WindowsHandle {
57 fn raw(&self) -> windows_sys::Win32::Foundation::HANDLE {
58 self.0 as _
59 }
60}
61
62#[cfg(windows)]
63#[expect(unsafe_code, reason = "owned Windows handles require CloseHandle")]
64impl Drop for WindowsHandle {
65 fn drop(&mut self) {
66 use windows_sys::Win32::Foundation::CloseHandle;
67
68 unsafe { CloseHandle(self.raw()) };
70 }
71}
72
73#[cfg(windows)]
74struct WindowsJobGuard {
75 job: Option<WindowsHandle>,
76}
77
78#[cfg(windows)]
79impl WindowsJobGuard {
80 fn new(job: WindowsHandle) -> Self {
81 Self { job: Some(job) }
82 }
83
84 fn raw(&self) -> io::Result<windows_sys::Win32::Foundation::HANDLE> {
85 self.job
86 .as_ref()
87 .map(WindowsHandle::raw)
88 .ok_or_else(|| io::Error::other("Windows Job Object guard is disarmed"))
89 }
90
91 fn disarm(mut self) -> io::Result<WindowsHandle> {
92 self.job
93 .take()
94 .ok_or_else(|| io::Error::other("Windows Job Object guard is already disarmed"))
95 }
96}
97
98#[cfg(windows)]
99#[expect(
100 unsafe_code,
101 reason = "armed Windows Job Object cleanup requires TerminateJobObject"
102)]
103impl Drop for WindowsJobGuard {
104 fn drop(&mut self) {
105 use windows_sys::Win32::System::JobObjects::TerminateJobObject;
106
107 let Some(job) = self.job.as_ref() else {
108 return;
109 };
110 unsafe { TerminateJobObject(job.raw(), 1) };
113 }
114}
115
116pub struct ProcessTree {
118 #[cfg(unix)]
119 process_group_id: i32,
120 #[cfg(all(unix, target_vendor = "apple"))]
121 leader_exit_observed: std::sync::atomic::AtomicBool,
122 #[cfg(windows)]
123 job: WindowsHandle,
124}
125
126impl ProcessTree {
127 #[cfg(all(feature = "tokio", unix))]
129 pub fn for_tokio_child(child: &tokio::process::Child) -> io::Result<Self> {
130 let pid = child
131 .id()
132 .ok_or_else(|| io::Error::other("fallow subprocess exited before setup"))?;
133 Self::for_pid(pid)
134 }
135
136 #[cfg(all(feature = "tokio", windows))]
138 pub fn for_tokio_child(child: &tokio::process::Child) -> io::Result<Self> {
139 let pid = child
140 .id()
141 .ok_or_else(|| io::Error::other("fallow subprocess exited before setup"))?;
142 let handle = child
143 .raw_handle()
144 .ok_or_else(|| io::Error::other("fallow subprocess exited before setup"))?;
145 Self::for_windows_handle(handle, pid)
146 }
147
148 #[cfg(all(feature = "tokio", not(any(unix, windows))))]
150 pub fn for_tokio_child(_child: &tokio::process::Child) -> io::Result<Self> {
151 Ok(Self {})
152 }
153
154 #[cfg(unix)]
157 pub fn for_std_child(child: &std::process::Child) -> io::Result<Self> {
158 Self::for_pid(child.id())
159 }
160
161 #[cfg(windows)]
164 pub fn for_std_child(child: &std::process::Child) -> io::Result<Self> {
165 use std::os::windows::io::AsRawHandle;
166
167 Self::for_windows_handle(child.as_raw_handle(), child.id())
168 }
169
170 #[cfg(not(any(unix, windows)))]
173 pub fn for_std_child(_child: &std::process::Child) -> io::Result<Self> {
174 Ok(Self {})
175 }
176
177 #[cfg(unix)]
178 pub(crate) fn for_pid(pid: u32) -> io::Result<Self> {
179 let process_group_id = i32::try_from(pid)
180 .map_err(|_| io::Error::other(format!("invalid fallow subprocess PID {pid}")))?;
181 Ok(Self {
182 process_group_id,
183 #[cfg(target_vendor = "apple")]
184 leader_exit_observed: std::sync::atomic::AtomicBool::new(false),
185 })
186 }
187
188 #[cfg(windows)]
189 #[expect(unsafe_code, reason = "Windows Job Objects require Win32 FFI calls")]
190 fn for_windows_handle(process: std::os::windows::io::RawHandle, pid: u32) -> io::Result<Self> {
191 use std::mem;
192 use std::ptr;
193
194 use windows_sys::Win32::System::JobObjects::{
195 AssignProcessToJobObject, CreateJobObjectW, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
196 JOBOBJECT_EXTENDED_LIMIT_INFORMATION, JobObjectExtendedLimitInformation,
197 SetInformationJobObject,
198 };
199
200 let job = unsafe { CreateJobObjectW(ptr::null(), ptr::null()) };
203 if job.is_null() {
204 return Err(io::Error::last_os_error());
205 }
206 let job = WindowsJobGuard::new(WindowsHandle(job as isize));
207 let mut limits = JOBOBJECT_EXTENDED_LIMIT_INFORMATION::default();
208 limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
209
210 if unsafe {
213 SetInformationJobObject(
214 job.raw()?,
215 JobObjectExtendedLimitInformation,
216 (&raw const limits).cast(),
217 mem::size_of_val(&limits) as u32,
218 )
219 } == 0
220 {
221 return Err(io::Error::last_os_error());
222 }
223
224 if unsafe { AssignProcessToJobObject(job.raw()?, process.cast()) } == 0 {
227 return Err(io::Error::last_os_error());
228 }
229
230 resume_suspended_process(pid)?;
231 Ok(Self { job: job.disarm()? })
232 }
233
234 #[cfg(unix)]
236 #[expect(
237 unsafe_code,
238 reason = "POSIX process-group termination requires libc::kill"
239 )]
240 pub fn terminate(&self) -> io::Result<()> {
241 if unsafe { libc::kill(-self.process_group_id, libc::SIGKILL) } == 0 {
244 return Ok(());
245 }
246
247 let error = io::Error::last_os_error();
248 if error.raw_os_error() == Some(libc::ESRCH) {
249 return Ok(());
250 }
251 #[cfg(target_vendor = "apple")]
252 if error.raw_os_error() == Some(libc::EPERM) && self.leader_has_exited() {
258 return Ok(());
259 }
260 Err(error)
261 }
262
263 #[cfg(all(unix, target_vendor = "apple"))]
267 fn leader_has_exited(&self) -> bool {
268 if self
269 .leader_exit_observed
270 .load(std::sync::atomic::Ordering::Relaxed)
271 {
272 return true;
273 }
274 match self.has_exited_without_reaping() {
275 Ok(exited) => exited,
276 Err(error) => error.raw_os_error() == Some(libc::ECHILD),
277 }
278 }
279
280 #[cfg(all(feature = "tokio", unix))]
282 pub async fn wait_for_exit_without_reaping(&self) -> io::Result<()> {
283 loop {
284 if self.has_exited_without_reaping()? {
285 return Ok(());
286 }
287 tokio::time::sleep(CLEANUP_POLL_INTERVAL).await;
288 }
289 }
290
291 #[cfg(unix)]
294 #[expect(
295 unsafe_code,
296 reason = "non-reaping POSIX child observation requires waitid"
297 )]
298 pub fn has_exited_without_reaping(&self) -> io::Result<bool> {
299 let mut info = std::mem::MaybeUninit::<libc::siginfo_t>::zeroed();
300 let process_id = libc::id_t::try_from(self.process_group_id)
301 .map_err(|_| io::Error::from(io::ErrorKind::InvalidInput))?;
302 let result = unsafe {
305 libc::waitid(
306 libc::P_PID,
307 process_id,
308 info.as_mut_ptr(),
309 libc::WEXITED | libc::WNOHANG | libc::WNOWAIT,
310 )
311 };
312 if result != 0 {
313 return Err(io::Error::last_os_error());
314 }
315
316 let info = unsafe { info.assume_init() };
318 let exited = unsafe { info.si_pid() } != 0;
321 #[cfg(target_vendor = "apple")]
323 if exited {
324 self.leader_exit_observed
325 .store(true, std::sync::atomic::Ordering::Relaxed);
326 }
327 Ok(exited)
328 }
329
330 #[cfg(windows)]
332 #[expect(
333 unsafe_code,
334 reason = "Windows Job Object termination requires a Win32 FFI call"
335 )]
336 pub fn terminate(&self) -> io::Result<()> {
337 use windows_sys::Win32::System::JobObjects::TerminateJobObject;
338
339 if unsafe { TerminateJobObject(self.job.raw(), 1) } != 0 {
341 return Ok(());
342 }
343 Err(io::Error::last_os_error())
344 }
345
346 #[cfg(not(any(unix, windows)))]
348 pub fn terminate(&self) -> io::Result<()> {
349 Err(io::Error::new(
350 io::ErrorKind::Unsupported,
351 "process-tree termination is unsupported on this platform",
352 ))
353 }
354
355 #[cfg(unix)]
356 #[expect(
357 unsafe_code,
358 reason = "POSIX process-group liveness checks require libc::kill"
359 )]
360 pub(crate) fn is_alive(&self) -> bool {
361 #[cfg(target_os = "linux")]
363 if let Some(running) =
364 crate::proc_state::process_group_has_running_member(self.process_group_id)
365 {
366 return running;
367 }
368 unsafe { libc::kill(-self.process_group_id, 0) == 0 }
370 }
371
372 #[cfg(windows)]
373 #[expect(
374 unsafe_code,
375 reason = "Windows Job Object liveness requires QueryInformationJobObject"
376 )]
377 pub(crate) fn is_alive(&self) -> bool {
378 use std::mem;
379 use std::ptr;
380
381 use windows_sys::Win32::System::JobObjects::{
382 JOBOBJECT_BASIC_ACCOUNTING_INFORMATION, JobObjectBasicAccountingInformation,
383 QueryInformationJobObject,
384 };
385
386 let mut accounting = JOBOBJECT_BASIC_ACCOUNTING_INFORMATION::default();
387 unsafe {
390 QueryInformationJobObject(
391 self.job.raw(),
392 JobObjectBasicAccountingInformation,
393 (&raw mut accounting).cast(),
394 mem::size_of_val(&accounting) as u32,
395 ptr::null_mut(),
396 ) != 0
397 && accounting.ActiveProcesses > 0
398 }
399 }
400
401 #[cfg(not(any(unix, windows)))]
402 pub(crate) fn is_alive(&self) -> bool {
403 false
404 }
405}
406
407pub struct ChildCleanup {
409 pub status: Option<ExitStatus>,
411 pub errors: Vec<String>,
413}
414
415#[cfg(feature = "tokio")]
416pub async fn cleanup_tokio_child(
418 process_tree: Option<&ProcessTree>,
419 child: &mut tokio::process::Child,
420) -> ChildCleanup {
421 let mut errors = Vec::new();
422 if !request_tree_termination(process_tree, &mut errors)
423 && let Err(error) = child.start_kill()
424 {
425 errors.push(format!("failed to kill direct subprocess: {error}"));
426 }
427
428 let status = match tokio::time::timeout(CLEANUP_GRACE, child.wait()).await {
429 Ok(Ok(status)) => {
430 return ChildCleanup {
431 status: Some(status),
432 errors,
433 };
434 }
435 Ok(Err(error)) => {
436 errors.push(format!("failed to reap direct subprocess: {error}"));
437 return ChildCleanup {
438 status: None,
439 errors,
440 };
441 }
442 Err(_) => {
443 errors.push(format!(
444 "direct subprocess did not exit within {}ms cleanup grace",
445 CLEANUP_GRACE.as_millis()
446 ));
447 None
448 }
449 };
450
451 if let Err(error) = child.start_kill() {
452 errors.push(format!("failed to retry direct subprocess kill: {error}"));
453 }
454 let status = match tokio::time::timeout(REAP_RETRY_GRACE, child.wait()).await {
455 Ok(Ok(status)) => Some(status),
456 Ok(Err(error)) => {
457 errors.push(format!(
458 "failed to reap direct subprocess after retry: {error}"
459 ));
460 status
461 }
462 Err(_) => {
463 errors.push(format!(
464 "direct subprocess still did not exit after {}ms kill retry",
465 REAP_RETRY_GRACE.as_millis()
466 ));
467 status
468 }
469 };
470 ChildCleanup { status, errors }
471}
472
473pub fn cleanup_std_child(
475 process_tree: Option<&ProcessTree>,
476 child: &mut std::process::Child,
477) -> ChildCleanup {
478 let mut errors = Vec::new();
479 if !request_tree_termination(process_tree, &mut errors)
480 && let Err(error) = child.kill()
481 {
482 errors.push(format!("failed to kill direct subprocess: {error}"));
483 }
484
485 let status = match poll_std_child(child, CLEANUP_GRACE) {
486 Ok(Some(status)) => {
487 return ChildCleanup {
488 status: Some(status),
489 errors,
490 };
491 }
492 Ok(None) => {
493 errors.push(format!(
494 "direct subprocess did not exit within {}ms cleanup grace",
495 CLEANUP_GRACE.as_millis()
496 ));
497 None
498 }
499 Err(error) => {
500 errors.push(format!("failed to reap direct subprocess: {error}"));
501 return ChildCleanup {
502 status: None,
503 errors,
504 };
505 }
506 };
507
508 if let Err(error) = child.kill() {
509 errors.push(format!("failed to retry direct subprocess kill: {error}"));
510 }
511 let status = match poll_std_child(child, REAP_RETRY_GRACE) {
512 Ok(Some(status)) => Some(status),
513 Ok(None) => {
514 errors.push(format!(
515 "direct subprocess still did not exit after {}ms kill retry",
516 REAP_RETRY_GRACE.as_millis()
517 ));
518 status
519 }
520 Err(error) => {
521 errors.push(format!(
522 "failed to reap direct subprocess after retry: {error}"
523 ));
524 status
525 }
526 };
527 ChildCleanup { status, errors }
528}
529
530fn request_tree_termination(process_tree: Option<&ProcessTree>, errors: &mut Vec<String>) -> bool {
531 let Some(process_tree) = process_tree else {
532 return false;
533 };
534 match process_tree.terminate() {
535 Ok(()) => true,
536 Err(error) => {
537 errors.push(format!("failed to terminate subprocess tree: {error}"));
538 false
539 }
540 }
541}
542
543fn poll_std_child(
544 child: &mut std::process::Child,
545 grace: Duration,
546) -> io::Result<Option<ExitStatus>> {
547 let deadline = Instant::now() + grace;
548 loop {
549 if let Some(status) = child.try_wait()? {
550 return Ok(Some(status));
551 }
552
553 let remaining = deadline.saturating_duration_since(Instant::now());
554 if remaining.is_zero() {
555 return Ok(None);
556 }
557 std::thread::sleep(CLEANUP_POLL_INTERVAL.min(remaining));
558 }
559}
560
561#[cfg(all(test, feature = "tokio", unix))]
562#[expect(
563 clippy::expect_used,
564 reason = "test setup failures should fail at the exact setup operation"
565)]
566mod tests {
567 use super::*;
568
569 #[tokio::test]
570 #[expect(
571 unsafe_code,
572 reason = "the regression test verifies that the observed PID remains reserved"
573 )]
574 async fn non_reaping_exit_observation_reserves_process_group_identity() {
575 let mut command = tokio::process::Command::new("/bin/sh");
576 command.args(["-c", "exit 0"]);
577 configure_tokio_command(&mut command);
578 let mut child = command.spawn().expect("test subprocess");
579 let pid = child.id().expect("test subprocess PID");
580 let process_tree = ProcessTree::for_tokio_child(&child).expect("test process tree");
581
582 tokio::time::timeout(
583 Duration::from_secs(1),
584 process_tree.wait_for_exit_without_reaping(),
585 )
586 .await
587 .expect("test subprocess exit")
588 .expect("non-reaping exit observation");
589 let leader_is_reserved = unsafe { libc::kill(pid as libc::pid_t, 0) } == 0;
591 let cleanup = cleanup_tokio_child(Some(&process_tree), &mut child).await;
592
593 assert!(leader_is_reserved, "subprocess leader was reaped too early");
594 assert!(cleanup.errors.is_empty(), "{:?}", cleanup.errors);
595 assert!(cleanup.status.is_some_and(|status| status.success()));
596 }
597}
598
599#[cfg(windows)]
600fn resume_suspended_process(pid: u32) -> io::Result<()> {
601 const THREAD_DISCOVERY_ATTEMPTS: usize = 20;
602 const THREAD_DISCOVERY_DELAY: std::time::Duration = std::time::Duration::from_millis(5);
603
604 for _ in 0..THREAD_DISCOVERY_ATTEMPTS {
605 match find_process_thread(pid) {
606 Ok(thread) => return resume_thread(&thread),
607 Err(error) if error.kind() == io::ErrorKind::NotFound => {
608 std::thread::sleep(THREAD_DISCOVERY_DELAY);
609 }
610 Err(error) => return Err(error),
611 }
612 }
613
614 Err(io::Error::new(
615 io::ErrorKind::NotFound,
616 format!("could not find suspended primary thread for fallow subprocess {pid}"),
617 ))
618}
619
620#[cfg(windows)]
621#[expect(
622 unsafe_code,
623 reason = "thread discovery requires Windows ToolHelp FFI calls"
624)]
625fn find_process_thread(pid: u32) -> io::Result<WindowsHandle> {
626 use std::mem;
627
628 use windows_sys::Win32::Foundation::{ERROR_NO_MORE_FILES, INVALID_HANDLE_VALUE};
629 use windows_sys::Win32::System::Diagnostics::ToolHelp::{
630 CreateToolhelp32Snapshot, TH32CS_SNAPTHREAD, THREADENTRY32, Thread32First, Thread32Next,
631 };
632 use windows_sys::Win32::System::Threading::{OpenThread, THREAD_SUSPEND_RESUME};
633
634 let snapshot = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0) };
636 if snapshot == INVALID_HANDLE_VALUE {
637 return Err(io::Error::last_os_error());
638 }
639 let snapshot = WindowsHandle(snapshot as isize);
640 let mut entry = THREADENTRY32 {
641 dwSize: mem::size_of::<THREADENTRY32>() as u32,
642 ..THREADENTRY32::default()
643 };
644
645 if unsafe { Thread32First(snapshot.raw(), &raw mut entry) } == 0 {
647 return Err(thread_enumeration_error(pid, ERROR_NO_MORE_FILES));
648 }
649
650 loop {
651 if entry.th32OwnerProcessID == pid {
652 let thread = unsafe { OpenThread(THREAD_SUSPEND_RESUME, 0, entry.th32ThreadID) };
654 if thread.is_null() {
655 return Err(io::Error::last_os_error());
656 }
657 return Ok(WindowsHandle(thread as isize));
658 }
659
660 if unsafe { Thread32Next(snapshot.raw(), &raw mut entry) } == 0 {
662 return Err(thread_enumeration_error(pid, ERROR_NO_MORE_FILES));
663 }
664 }
665}
666
667#[cfg(windows)]
668fn thread_enumeration_error(pid: u32, no_more_files: u32) -> io::Error {
669 let error = io::Error::last_os_error();
670 if error.raw_os_error() == i32::try_from(no_more_files).ok() {
671 return io::Error::new(
672 io::ErrorKind::NotFound,
673 format!("no thread found for fallow subprocess {pid}"),
674 );
675 }
676 error
677}
678
679#[cfg(windows)]
680#[expect(
681 unsafe_code,
682 reason = "resuming a Windows thread requires ResumeThread"
683)]
684fn resume_thread(thread: &WindowsHandle) -> io::Result<()> {
685 use windows_sys::Win32::System::Threading::ResumeThread;
686
687 let previous_count = unsafe { ResumeThread(thread.raw()) };
689 if previous_count == u32::MAX {
690 return Err(io::Error::last_os_error());
691 }
692 if previous_count == 0 {
693 return Err(io::Error::other(
694 "fallow subprocess primary thread was not suspended",
695 ));
696 }
697
698 Ok(())
699}