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(unix)]
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 leader_exit_observed: std::sync::atomic::AtomicBool::new(false),
184 })
185 }
186
187 #[cfg(windows)]
188 #[expect(unsafe_code, reason = "Windows Job Objects require Win32 FFI calls")]
189 fn for_windows_handle(process: std::os::windows::io::RawHandle, pid: u32) -> io::Result<Self> {
190 use std::mem;
191 use std::ptr;
192
193 use windows_sys::Win32::System::JobObjects::{
194 AssignProcessToJobObject, CreateJobObjectW, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
195 JOBOBJECT_EXTENDED_LIMIT_INFORMATION, JobObjectExtendedLimitInformation,
196 SetInformationJobObject,
197 };
198
199 let job = unsafe { CreateJobObjectW(ptr::null(), ptr::null()) };
202 if job.is_null() {
203 return Err(io::Error::last_os_error());
204 }
205 let job = WindowsJobGuard::new(WindowsHandle(job as isize));
206 let mut limits = JOBOBJECT_EXTENDED_LIMIT_INFORMATION::default();
207 limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
208
209 if unsafe {
212 SetInformationJobObject(
213 job.raw()?,
214 JobObjectExtendedLimitInformation,
215 (&raw const limits).cast(),
216 mem::size_of_val(&limits) as u32,
217 )
218 } == 0
219 {
220 return Err(io::Error::last_os_error());
221 }
222
223 if unsafe { AssignProcessToJobObject(job.raw()?, process.cast()) } == 0 {
226 return Err(io::Error::last_os_error());
227 }
228
229 resume_suspended_process(pid)?;
230 Ok(Self { job: job.disarm()? })
231 }
232
233 #[cfg(unix)]
235 #[expect(
236 unsafe_code,
237 reason = "POSIX process-group termination requires libc::kill"
238 )]
239 pub fn terminate(&self) -> io::Result<()> {
240 if unsafe { libc::kill(-self.process_group_id, libc::SIGKILL) } == 0 {
243 return Ok(());
244 }
245
246 let error = io::Error::last_os_error();
247 if error.raw_os_error() == Some(libc::ESRCH) {
248 return Ok(());
249 }
250 #[cfg(target_vendor = "apple")]
251 if error.raw_os_error() == Some(libc::EPERM) && self.leader_has_exited() {
257 return Ok(());
258 }
259 Err(error)
260 }
261
262 #[cfg(all(unix, target_vendor = "apple"))]
266 fn leader_has_exited(&self) -> bool {
267 if self
268 .leader_exit_observed
269 .load(std::sync::atomic::Ordering::Relaxed)
270 {
271 return true;
272 }
273 match self.has_exited_without_reaping() {
274 Ok(exited) => exited,
275 Err(error) => error.raw_os_error() == Some(libc::ECHILD),
276 }
277 }
278
279 #[cfg(all(feature = "tokio", unix))]
281 pub async fn wait_for_exit_without_reaping(&self) -> io::Result<()> {
282 loop {
283 if self.has_exited_without_reaping()? {
284 return Ok(());
285 }
286 tokio::time::sleep(CLEANUP_POLL_INTERVAL).await;
287 }
288 }
289
290 #[cfg(unix)]
293 #[expect(
294 unsafe_code,
295 reason = "non-reaping POSIX child observation requires waitid"
296 )]
297 pub fn has_exited_without_reaping(&self) -> io::Result<bool> {
298 let mut info = std::mem::MaybeUninit::<libc::siginfo_t>::zeroed();
299 let result = unsafe {
302 libc::waitid(
303 libc::P_PID,
304 self.process_group_id as libc::id_t,
305 info.as_mut_ptr(),
306 libc::WEXITED | libc::WNOHANG | libc::WNOWAIT,
307 )
308 };
309 if result != 0 {
310 return Err(io::Error::last_os_error());
311 }
312
313 let exited = unsafe { info.assume_init().si_pid() } != 0;
316 if exited {
317 self.leader_exit_observed
318 .store(true, std::sync::atomic::Ordering::Relaxed);
319 }
320 Ok(exited)
321 }
322
323 #[cfg(windows)]
325 #[expect(
326 unsafe_code,
327 reason = "Windows Job Object termination requires a Win32 FFI call"
328 )]
329 pub fn terminate(&self) -> io::Result<()> {
330 use windows_sys::Win32::System::JobObjects::TerminateJobObject;
331
332 if unsafe { TerminateJobObject(self.job.raw(), 1) } != 0 {
334 return Ok(());
335 }
336 Err(io::Error::last_os_error())
337 }
338
339 #[cfg(not(any(unix, windows)))]
341 pub fn terminate(&self) -> io::Result<()> {
342 Err(io::Error::new(
343 io::ErrorKind::Unsupported,
344 "process-tree termination is unsupported on this platform",
345 ))
346 }
347
348 #[cfg(unix)]
349 #[expect(
350 unsafe_code,
351 reason = "POSIX process-group liveness checks require libc::kill"
352 )]
353 pub(crate) fn is_alive(&self) -> bool {
354 unsafe { libc::kill(-self.process_group_id, 0) == 0 }
356 }
357
358 #[cfg(windows)]
359 #[expect(
360 unsafe_code,
361 reason = "Windows Job Object liveness requires QueryInformationJobObject"
362 )]
363 pub(crate) fn is_alive(&self) -> bool {
364 use std::mem;
365 use std::ptr;
366
367 use windows_sys::Win32::System::JobObjects::{
368 JOBOBJECT_BASIC_ACCOUNTING_INFORMATION, JobObjectBasicAccountingInformation,
369 QueryInformationJobObject,
370 };
371
372 let mut accounting = JOBOBJECT_BASIC_ACCOUNTING_INFORMATION::default();
373 unsafe {
376 QueryInformationJobObject(
377 self.job.raw(),
378 JobObjectBasicAccountingInformation,
379 (&raw mut accounting).cast(),
380 mem::size_of_val(&accounting) as u32,
381 ptr::null_mut(),
382 ) != 0
383 && accounting.ActiveProcesses > 0
384 }
385 }
386
387 #[cfg(not(any(unix, windows)))]
388 pub(crate) fn is_alive(&self) -> bool {
389 false
390 }
391}
392
393pub struct ChildCleanup {
395 pub status: Option<ExitStatus>,
397 pub errors: Vec<String>,
399}
400
401#[cfg(feature = "tokio")]
402pub async fn cleanup_tokio_child(
404 process_tree: Option<&ProcessTree>,
405 child: &mut tokio::process::Child,
406) -> ChildCleanup {
407 let mut errors = Vec::new();
408 if !request_tree_termination(process_tree, &mut errors)
409 && let Err(error) = child.start_kill()
410 {
411 errors.push(format!("failed to kill direct subprocess: {error}"));
412 }
413
414 let status = match tokio::time::timeout(CLEANUP_GRACE, child.wait()).await {
415 Ok(Ok(status)) => {
416 return ChildCleanup {
417 status: Some(status),
418 errors,
419 };
420 }
421 Ok(Err(error)) => {
422 errors.push(format!("failed to reap direct subprocess: {error}"));
423 return ChildCleanup {
424 status: None,
425 errors,
426 };
427 }
428 Err(_) => {
429 errors.push(format!(
430 "direct subprocess did not exit within {}ms cleanup grace",
431 CLEANUP_GRACE.as_millis()
432 ));
433 None
434 }
435 };
436
437 if let Err(error) = child.start_kill() {
438 errors.push(format!("failed to retry direct subprocess kill: {error}"));
439 }
440 let status = match tokio::time::timeout(REAP_RETRY_GRACE, child.wait()).await {
441 Ok(Ok(status)) => Some(status),
442 Ok(Err(error)) => {
443 errors.push(format!(
444 "failed to reap direct subprocess after retry: {error}"
445 ));
446 status
447 }
448 Err(_) => {
449 errors.push(format!(
450 "direct subprocess still did not exit after {}ms kill retry",
451 REAP_RETRY_GRACE.as_millis()
452 ));
453 status
454 }
455 };
456 ChildCleanup { status, errors }
457}
458
459pub fn cleanup_std_child(
461 process_tree: Option<&ProcessTree>,
462 child: &mut std::process::Child,
463) -> ChildCleanup {
464 let mut errors = Vec::new();
465 if !request_tree_termination(process_tree, &mut errors)
466 && let Err(error) = child.kill()
467 {
468 errors.push(format!("failed to kill direct subprocess: {error}"));
469 }
470
471 let status = match poll_std_child(child, CLEANUP_GRACE) {
472 Ok(Some(status)) => {
473 return ChildCleanup {
474 status: Some(status),
475 errors,
476 };
477 }
478 Ok(None) => {
479 errors.push(format!(
480 "direct subprocess did not exit within {}ms cleanup grace",
481 CLEANUP_GRACE.as_millis()
482 ));
483 None
484 }
485 Err(error) => {
486 errors.push(format!("failed to reap direct subprocess: {error}"));
487 return ChildCleanup {
488 status: None,
489 errors,
490 };
491 }
492 };
493
494 if let Err(error) = child.kill() {
495 errors.push(format!("failed to retry direct subprocess kill: {error}"));
496 }
497 let status = match poll_std_child(child, REAP_RETRY_GRACE) {
498 Ok(Some(status)) => Some(status),
499 Ok(None) => {
500 errors.push(format!(
501 "direct subprocess still did not exit after {}ms kill retry",
502 REAP_RETRY_GRACE.as_millis()
503 ));
504 status
505 }
506 Err(error) => {
507 errors.push(format!(
508 "failed to reap direct subprocess after retry: {error}"
509 ));
510 status
511 }
512 };
513 ChildCleanup { status, errors }
514}
515
516fn request_tree_termination(process_tree: Option<&ProcessTree>, errors: &mut Vec<String>) -> bool {
517 let Some(process_tree) = process_tree else {
518 return false;
519 };
520 match process_tree.terminate() {
521 Ok(()) => true,
522 Err(error) => {
523 errors.push(format!("failed to terminate subprocess tree: {error}"));
524 false
525 }
526 }
527}
528
529fn poll_std_child(
530 child: &mut std::process::Child,
531 grace: Duration,
532) -> io::Result<Option<ExitStatus>> {
533 let deadline = Instant::now() + grace;
534 loop {
535 if let Some(status) = child.try_wait()? {
536 return Ok(Some(status));
537 }
538
539 let remaining = deadline.saturating_duration_since(Instant::now());
540 if remaining.is_zero() {
541 return Ok(None);
542 }
543 std::thread::sleep(CLEANUP_POLL_INTERVAL.min(remaining));
544 }
545}
546
547#[cfg(all(test, feature = "tokio", unix))]
548#[expect(
549 clippy::expect_used,
550 reason = "test setup failures should fail at the exact setup operation"
551)]
552mod tests {
553 use super::*;
554
555 #[tokio::test]
556 #[expect(
557 unsafe_code,
558 reason = "the regression test verifies that the observed PID remains reserved"
559 )]
560 async fn non_reaping_exit_observation_reserves_process_group_identity() {
561 let mut command = tokio::process::Command::new("/bin/sh");
562 command.args(["-c", "exit 0"]);
563 configure_tokio_command(&mut command);
564 let mut child = command.spawn().expect("test subprocess");
565 let pid = child.id().expect("test subprocess PID");
566 let process_tree = ProcessTree::for_tokio_child(&child).expect("test process tree");
567
568 tokio::time::timeout(
569 Duration::from_secs(1),
570 process_tree.wait_for_exit_without_reaping(),
571 )
572 .await
573 .expect("test subprocess exit")
574 .expect("non-reaping exit observation");
575 let leader_is_reserved = unsafe { libc::kill(pid as libc::pid_t, 0) } == 0;
577 let cleanup = cleanup_tokio_child(Some(&process_tree), &mut child).await;
578
579 assert!(leader_is_reserved, "subprocess leader was reaped too early");
580 assert!(cleanup.errors.is_empty(), "{:?}", cleanup.errors);
581 assert!(cleanup.status.is_some_and(|status| status.success()));
582 }
583}
584
585#[cfg(windows)]
586fn resume_suspended_process(pid: u32) -> io::Result<()> {
587 const THREAD_DISCOVERY_ATTEMPTS: usize = 20;
588 const THREAD_DISCOVERY_DELAY: std::time::Duration = std::time::Duration::from_millis(5);
589
590 for _ in 0..THREAD_DISCOVERY_ATTEMPTS {
591 match find_process_thread(pid) {
592 Ok(thread) => return resume_thread(&thread),
593 Err(error) if error.kind() == io::ErrorKind::NotFound => {
594 std::thread::sleep(THREAD_DISCOVERY_DELAY);
595 }
596 Err(error) => return Err(error),
597 }
598 }
599
600 Err(io::Error::new(
601 io::ErrorKind::NotFound,
602 format!("could not find suspended primary thread for fallow subprocess {pid}"),
603 ))
604}
605
606#[cfg(windows)]
607#[expect(
608 unsafe_code,
609 reason = "thread discovery requires Windows ToolHelp FFI calls"
610)]
611fn find_process_thread(pid: u32) -> io::Result<WindowsHandle> {
612 use std::mem;
613
614 use windows_sys::Win32::Foundation::{ERROR_NO_MORE_FILES, INVALID_HANDLE_VALUE};
615 use windows_sys::Win32::System::Diagnostics::ToolHelp::{
616 CreateToolhelp32Snapshot, TH32CS_SNAPTHREAD, THREADENTRY32, Thread32First, Thread32Next,
617 };
618 use windows_sys::Win32::System::Threading::{OpenThread, THREAD_SUSPEND_RESUME};
619
620 let snapshot = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0) };
622 if snapshot == INVALID_HANDLE_VALUE {
623 return Err(io::Error::last_os_error());
624 }
625 let snapshot = WindowsHandle(snapshot as isize);
626 let mut entry = THREADENTRY32 {
627 dwSize: mem::size_of::<THREADENTRY32>() as u32,
628 ..THREADENTRY32::default()
629 };
630
631 if unsafe { Thread32First(snapshot.raw(), &raw mut entry) } == 0 {
633 return Err(thread_enumeration_error(pid, ERROR_NO_MORE_FILES));
634 }
635
636 loop {
637 if entry.th32OwnerProcessID == pid {
638 let thread = unsafe { OpenThread(THREAD_SUSPEND_RESUME, 0, entry.th32ThreadID) };
640 if thread.is_null() {
641 return Err(io::Error::last_os_error());
642 }
643 return Ok(WindowsHandle(thread as isize));
644 }
645
646 if unsafe { Thread32Next(snapshot.raw(), &raw mut entry) } == 0 {
648 return Err(thread_enumeration_error(pid, ERROR_NO_MORE_FILES));
649 }
650 }
651}
652
653#[cfg(windows)]
654fn thread_enumeration_error(pid: u32, no_more_files: u32) -> io::Error {
655 let error = io::Error::last_os_error();
656 if error.raw_os_error() == i32::try_from(no_more_files).ok() {
657 return io::Error::new(
658 io::ErrorKind::NotFound,
659 format!("no thread found for fallow subprocess {pid}"),
660 );
661 }
662 error
663}
664
665#[cfg(windows)]
666#[expect(
667 unsafe_code,
668 reason = "resuming a Windows thread requires ResumeThread"
669)]
670fn resume_thread(thread: &WindowsHandle) -> io::Result<()> {
671 use windows_sys::Win32::System::Threading::ResumeThread;
672
673 let previous_count = unsafe { ResumeThread(thread.raw()) };
675 if previous_count == u32::MAX {
676 return Err(io::Error::last_os_error());
677 }
678 if previous_count == 0 {
679 return Err(io::Error::other(
680 "fallow subprocess primary thread was not suspended",
681 ));
682 }
683
684 Ok(())
685}