Skip to main content

proc_guard/
lib.rs

1//! Process kill-guard (spike D4). The worker tree is host -> cmd.exe -> java.exe; a plain
2//! child.kill() reaps only cmd.exe and orphans the JVM. On Windows a job with
3//! JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE kills the entire subtree the moment the host drops the last
4//! job handle (including host process death). On Unix, process groups (setsid + SIGKILL to the
5//! process-group id) give the same effect. The crate builds everywhere with a no-op stub for
6//! exotic platforms.
7
8#[cfg(windows)]
9mod windows_imp {
10    use std::io;
11    use std::os::windows::io::{AsRawHandle, RawHandle};
12    use std::process::Child;
13    use windows::Win32::Foundation::{CloseHandle, HANDLE};
14    use windows::Win32::System::JobObjects::{
15        AssignProcessToJobObject, CreateJobObjectW, JobObjectExtendedLimitInformation,
16        SetInformationJobObject, TerminateJobObject, JOBOBJECT_EXTENDED_LIMIT_INFORMATION,
17        JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
18    };
19
20    pub struct JobObject {
21        handle: Option<HANDLE>,
22    }
23
24    // SAFETY: `handle` is a process-wide Windows job-object kernel handle. The Win32 job APIs used
25    // here (AssignProcessToJobObject / SetInformationJobObject / CloseHandle) are thread-safe, so the
26    // wrapper is sound to move across threads (Send) and share by reference (Sync). `HANDLE` is only
27    // !Send/!Sync by default because the `windows` crate wraps a raw pointer without annotating it;
28    // without these impls the whole BootedWorker is !Send, which breaks holding it across spawned
29    // tokio tasks or in an Arc<Mutex<_>> (merge-gate finding).
30    unsafe impl Send for JobObject {}
31    unsafe impl Sync for JobObject {}
32
33    impl JobObject {
34        pub fn new() -> io::Result<Self> {
35            // SAFETY: CreateJobObjectW with null args creates an unnamed job; we own the handle.
36            let handle = unsafe { CreateJobObjectW(None, None) }
37                .map_err(|e| io::Error::other(format!("CreateJobObjectW: {e}")))?;
38            let mut info = JOBOBJECT_EXTENDED_LIMIT_INFORMATION::default();
39            info.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
40            // SAFETY: `info` outlives the call; size matches the info class.
41            unsafe {
42                SetInformationJobObject(
43                    handle,
44                    JobObjectExtendedLimitInformation,
45                    &info as *const _ as *const core::ffi::c_void,
46                    std::mem::size_of::<JOBOBJECT_EXTENDED_LIMIT_INFORMATION>() as u32,
47                )
48            }
49            .map_err(|e| io::Error::other(format!("SetInformationJobObject: {e}")))?;
50            Ok(JobObject {
51                handle: Some(handle),
52            })
53        }
54
55        pub fn assign(&self, child: &Child) -> io::Result<()> {
56            self.assign_raw_handle(child.as_raw_handle())
57        }
58
59        /// Assign a bare process handle to the job. After `kill_group` the job handle is gone, so
60        /// there is nothing left to assign into: report `InvalidInput` rather than panic.
61        pub fn assign_raw_handle(&self, handle: RawHandle) -> io::Result<()> {
62            let job = self
63                .handle
64                .ok_or_else(|| io::Error::from(io::ErrorKind::InvalidInput))?;
65            let child_handle = HANDLE(handle);
66            // SAFETY: both handles are valid; the child is alive at assign time.
67            unsafe { AssignProcessToJobObject(job, child_handle) }
68                .map_err(|e| io::Error::other(format!("AssignProcessToJobObject: {e}")))
69        }
70
71        /// Terminate every process in the job and release the handle, so a second call and the
72        /// `Drop` are no-ops.
73        pub fn kill_group(&mut self) -> io::Result<()> {
74            let Some(handle) = self.handle.take() else {
75                return Ok(());
76            };
77            // SAFETY: `handle` is a live job handle we own and are about to close.
78            let res = unsafe { TerminateJobObject(handle, 1) }
79                .map_err(|e| io::Error::other(format!("TerminateJobObject: {e}")));
80            // SAFETY: `handle` was created by us and is taken, so it is not closed elsewhere.
81            unsafe {
82                let _ = CloseHandle(handle);
83            }
84            res
85        }
86
87        /// Disarm the guard WITHOUT killing: clear `KILL_ON_JOB_CLOSE`, then close the handle, so every process
88        /// already in the job (a service the child started, say) outlives the guard. If the flag cannot be
89        /// cleared the handle is kept (still armed) and the error returned, so the caller can `kill_group`
90        /// instead of silently leaking an armed job.
91        pub fn release(&mut self) -> io::Result<()> {
92            let Some(handle) = self.handle.take() else {
93                return Ok(());
94            };
95            let info = JOBOBJECT_EXTENDED_LIMIT_INFORMATION::default();
96            // SAFETY: `info` outlives the call; size matches the info class; `handle` is our live job handle.
97            let cleared = unsafe {
98                SetInformationJobObject(
99                    handle,
100                    JobObjectExtendedLimitInformation,
101                    &info as *const _ as *const core::ffi::c_void,
102                    std::mem::size_of::<JOBOBJECT_EXTENDED_LIMIT_INFORMATION>() as u32,
103                )
104            };
105            match cleared {
106                Ok(()) => {
107                    // SAFETY: `handle` was created by us and is taken, so it is not closed elsewhere.
108                    unsafe {
109                        let _ = CloseHandle(handle);
110                    }
111                    Ok(())
112                }
113                Err(e) => {
114                    self.handle = Some(handle);
115                    Err(io::Error::other(format!("SetInformationJobObject: {e}")))
116                }
117            }
118        }
119
120        /// Whether a job handle is still held (i.e. the guard has something to kill/close).
121        pub fn is_armed(&self) -> bool {
122            self.handle.is_some()
123        }
124    }
125
126    impl Drop for JobObject {
127        fn drop(&mut self) {
128            // Closing the last handle triggers KILL_ON_JOB_CLOSE for the whole tree. `kill_group`
129            // takes the handle, so a guard that was killed explicitly is a no-op here.
130            if let Some(handle) = self.handle.take() {
131                // SAFETY: handle was created by us and not closed elsewhere.
132                unsafe {
133                    let _ = CloseHandle(handle);
134                }
135            }
136        }
137    }
138}
139
140#[cfg(unix)]
141mod unix_imp {
142    use nix::sys::signal;
143    use nix::unistd::Pid;
144    use std::io;
145    use std::os::unix::process::CommandExt;
146    use std::process::{Child, Command};
147
148    /// Process group kill-guard for Unix. The child is spawned in a new session
149    /// (via `setsid()`), so it becomes the leader of a new process group. On drop,
150    /// the entire group is sent `SIGKILL`, ensuring no orphaned JVM subprocesses.
151    pub struct ProcessGroupGuard {
152        pid: Option<u32>,
153    }
154
155    impl ProcessGroupGuard {
156        /// Create a fresh guard (no child assigned yet).
157        pub fn new() -> io::Result<Self> {
158            Ok(ProcessGroupGuard { pid: None })
159        }
160
161        /// Configure the command to create a new process group / session.
162        /// Must be called before `cmd.spawn()`.
163        pub fn configure_command(&self, cmd: &mut Command) {
164            // SAFETY: setsid() is async-signal-safe and is called in the child
165            // process context between fork and exec. It creates a new session
166            // and process group with the child as leader.
167            unsafe {
168                cmd.pre_exec(|| {
169                    nix::unistd::setsid().map_err(|e| io::Error::from_raw_os_error(e as i32))?;
170                    Ok(())
171                });
172            }
173        }
174
175        /// Record the child PID so the guard can kill the process group on drop.
176        pub fn assign(&mut self, child: &Child) {
177            self.pid = Some(child.id());
178        }
179
180        /// Record a bare PID (the leader of the process group) to kill on drop.
181        pub fn assign_pid(&mut self, pid: u32) {
182            self.pid = Some(pid);
183        }
184
185        /// Kill the process group with SIGKILL. `killpg` takes a POSITIVE process-group id: the
186        /// leader's PID *is* the group id (the child became group leader via `setsid`), and a
187        /// negated id is rejected with `EINVAL`. `ESRCH` means the group is already gone — the
188        /// normal case after a clean exit — so it maps to `Ok(())`. The pid is taken so the guard
189        /// is disarmed and a second call, and the `Drop`, are no-ops.
190        pub fn kill_group(&mut self) -> io::Result<()> {
191            let Some(pid) = self.pid else {
192                return Ok(());
193            };
194            let res = match signal::killpg(Pid::from_raw(pid as i32), signal::Signal::SIGKILL) {
195                Ok(()) => Ok(()),
196                Err(nix::errno::Errno::ESRCH) => Ok(()),
197                Err(e) => Err(io::Error::from_raw_os_error(e as i32)),
198            };
199            self.pid.take();
200            res
201        }
202
203        /// Disarm the guard WITHOUT killing: the group outlives the guard.
204        pub fn release(&mut self) -> io::Result<()> {
205            self.pid.take();
206            Ok(())
207        }
208
209        /// Whether a pid is recorded and the guard can still act.
210        pub fn is_armed(&self) -> bool {
211            self.pid.is_some()
212        }
213    }
214
215    impl Drop for ProcessGroupGuard {
216        fn drop(&mut self) {
217            // Best-effort: kill the process group. A caller that cares about the error calls
218            // `kill_group` explicitly first (this crate has no log sink).
219            let _ = self.kill_group();
220        }
221    }
222}
223
224#[cfg(not(any(windows, unix)))]
225mod fallback_imp {
226    use std::io;
227    use std::process::Child;
228
229    /// No-op job on platforms that are neither Windows nor Unix (e.g. WASM).
230    pub struct JobObject;
231    impl JobObject {
232        pub fn new() -> io::Result<Self> {
233            Ok(JobObject)
234        }
235        pub fn assign(&self, _child: &Child) -> io::Result<()> {
236            Ok(())
237        }
238        /// No-op: this platform has no process groups.
239        pub fn assign_pid(&mut self, _pid: u32) {}
240        /// No-op: there is never anything to kill.
241        pub fn kill_group(&mut self) -> io::Result<()> {
242            Ok(())
243        }
244        /// No-op: there is never anything to release.
245        pub fn release(&mut self) -> io::Result<()> {
246            Ok(())
247        }
248        /// A no-op guard is never armed.
249        pub fn is_armed(&self) -> bool {
250            false
251        }
252    }
253}
254
255#[cfg(windows)]
256pub use self::windows_imp::JobObject;
257
258#[cfg(unix)]
259pub use self::unix_imp::ProcessGroupGuard as JobObject;
260
261#[cfg(not(any(windows, unix)))]
262pub use self::fallback_imp::JobObject;
263
264#[cfg(all(test, windows))]
265mod tests {
266    use super::*;
267    use std::process::Command;
268    use std::time::Duration;
269
270    // Spawn cmd.exe that in turn spawns a long-lived grandchild (ping -t), assign the DIRECT child
271    // to a job, then drop the job: the whole tree must die, proving grandchildren are reaped (the
272    // exact host->cmd.exe->java.exe shape spike D4 measured).
273    #[test]
274    fn dropping_job_kills_grandchild_tree() {
275        let mut child = Command::new("cmd.exe")
276            .args(["/c", "ping -n 60 127.0.0.1 >NUL"])
277            .spawn()
278            .expect("spawn cmd");
279        let pid = child.id();
280        {
281            let job = JobObject::new().expect("create job");
282            job.assign(&child).expect("assign child");
283            // job drops here -> KILL_ON_JOB_CLOSE fires
284        }
285        std::thread::sleep(Duration::from_millis(500));
286        // The child must now be gone; try_wait returns Some(exit) once reaped.
287        let status = child.try_wait().expect("try_wait");
288        assert!(
289            status.is_some(),
290            "child pid {pid} should be killed by job close"
291        );
292        let _ = child.kill();
293    }
294
295    // `release` disarms the guard WITHOUT killing: the tree must outlive both the release and the drop
296    // (a service a CLI call auto-started has to survive the call's guard). The control above proves the
297    // same shape dies without it, so this cannot pass for a guard that never kills.
298    #[test]
299    fn release_lets_the_tree_outlive_the_job() {
300        let mut child = Command::new("cmd.exe")
301            .args(["/c", "ping -n 60 127.0.0.1 >NUL"])
302            .spawn()
303            .expect("spawn cmd");
304        {
305            let mut job = JobObject::new().expect("create job");
306            job.assign(&child).expect("assign child");
307            job.release().expect("release the job");
308            assert!(!job.is_armed(), "release must disarm the guard");
309            // job drops here: nothing must be killed
310        }
311        std::thread::sleep(Duration::from_millis(500));
312        let status = child.try_wait().expect("try_wait");
313        assert!(status.is_none(), "a released job must not kill the child");
314        // The tree is no longer guarded: reap it by hand (`/T` takes the ping grandchild too).
315        let _ = Command::new("taskkill")
316            .args(["/F", "/T", "/PID", &child.id().to_string()])
317            .output();
318        let _ = child.wait();
319    }
320}
321
322#[cfg(all(test, unix))]
323mod tests {
324    use super::unix_imp::ProcessGroupGuard;
325    use std::process::Command;
326    use std::time::{Duration, Instant};
327
328    // Spawn a shell that runs `sleep 60`, configure it to create a new session (process group),
329    // assign the child to the guard, then drop the guard: the group leader must die, proving the
330    // guard actually signals the group (assert the LEADER only; grandchild death is
331    // `assign_pid_kills_group_on_drop`'s job).
332    #[test]
333    fn dropping_process_group_kills_leader() {
334        let mut cmd = Command::new("sh");
335        cmd.args(["-c", "sleep 60"]);
336
337        let mut guard = ProcessGroupGuard::new().expect("create guard");
338        guard.configure_command(&mut cmd);
339
340        let mut child = cmd.spawn().expect("spawn sh");
341        let pid = child.id();
342
343        guard.assign(&child);
344
345        // Give the process a moment to start.
346        std::thread::sleep(Duration::from_millis(100));
347
348        // Verify the child is still running.
349        assert!(
350            child.try_wait().expect("try_wait").is_none(),
351            "child should still be running"
352        );
353
354        // Drop the guard — this sends SIGKILL to the process group.
355        drop(guard);
356
357        // The leader must be gone within 1 s (poll every 50 ms).
358        let deadline = Instant::now() + Duration::from_secs(1);
359        loop {
360            if child.try_wait().expect("try_wait").is_some() {
361                break;
362            }
363            assert!(
364                Instant::now() < deadline,
365                "child pid {pid} should be killed when the guard drops"
366            );
367            std::thread::sleep(Duration::from_millis(50));
368        }
369    }
370
371    // `release` disarms the guard WITHOUT killing: the group must outlive both the release and the
372    // drop (the Unix twin of `release_lets_the_tree_outlive_the_job`).
373    #[test]
374    fn release_lets_the_group_outlive_the_guard() {
375        let mut cmd = Command::new("sh");
376        cmd.args(["-c", "sleep 60"]);
377        let mut guard = ProcessGroupGuard::new().expect("create guard");
378        guard.configure_command(&mut cmd);
379        let mut child = cmd.spawn().expect("spawn sh");
380        guard.assign(&child);
381        guard.release().expect("release the guard");
382        assert!(!guard.is_armed(), "release must disarm the guard");
383        drop(guard);
384        std::thread::sleep(Duration::from_millis(300));
385        let status = child.try_wait().expect("try_wait");
386        let _ = child.kill();
387        let _ = child.wait();
388        assert!(status.is_none(), "a released guard must not kill the group");
389    }
390
391    // Spawn a shell that spawns a grandchild and waits; assign the LEADER pid to the guard, read
392    // the grandchild pid from stdout, then drop the guard: the whole group — leader and grandchild
393    // — must die. This is the guarantee the guard exists for.
394    #[test]
395    fn assign_pid_kills_group_on_drop() {
396        use nix::errno::Errno;
397        use nix::sys::signal;
398        use nix::unistd::Pid;
399        use std::io::{BufRead, BufReader};
400        use std::process::Stdio;
401
402        let mut cmd = Command::new("sh");
403        cmd.arg("-c").arg("sleep 30 & echo $!; wait");
404        cmd.stdout(Stdio::piped());
405
406        let mut guard = ProcessGroupGuard::new().expect("create guard");
407        guard.configure_command(&mut cmd);
408
409        let mut child = cmd.spawn().expect("spawn sh");
410
411        let stdout = child.stdout.take().expect("piped stdout");
412        let mut reader = BufReader::new(stdout);
413        let mut line = String::new();
414        reader.read_line(&mut line).expect("read grandchild pid");
415        let grandchild: i32 = line.trim().parse().expect("parse grandchild pid");
416
417        guard.assign_pid(child.id());
418
419        drop(guard);
420
421        // Within 1 s the grandchild must be gone: `kill(pid, 0)` must fail with ESRCH, or (Linux
422        // only) /proc must report it a zombie — a non-reaping PID 1 leaves one behind. A missing or
423        // unreadable /proc entry counts as "not a zombie", so macOS relies on ESRCH alone.
424        let deadline = Instant::now() + Duration::from_secs(1);
425        loop {
426            match signal::kill(Pid::from_raw(grandchild), None) {
427                Err(Errno::ESRCH) => break,
428                _ =>
429                {
430                    #[cfg(target_os = "linux")]
431                    if is_zombie(grandchild) {
432                        break;
433                    }
434                }
435            }
436            assert!(
437                Instant::now() < deadline,
438                "grandchild pid {grandchild} should die with its process group"
439            );
440            std::thread::sleep(Duration::from_millis(50));
441        }
442
443        // Reap the (already group-killed) leader so the test leaves no zombie and clippy is happy.
444        let _ = child.wait();
445    }
446
447    // A group that is already gone answers ESRCH; that is the NORMAL path after a clean exit, so it
448    // must map to `Ok(())`. Asserting the `Ok` pair alone would pass for a body that never clears
449    // the pid, so also assert `is_armed()` flips false after the FIRST call (the `take()`).
450    #[test]
451    fn kill_group_esrch_is_ok() {
452        let mut cmd = Command::new("sh");
453        cmd.arg("-c").arg("exit 0");
454
455        let mut guard = ProcessGroupGuard::new().expect("create guard");
456        guard.configure_command(&mut cmd);
457
458        let mut child = cmd.spawn().expect("spawn sh");
459        let pid = child.id();
460        // Reap the leader FIRST: an exited-but-unreaped leader still answers `killpg(pgid, 0) == 0`.
461        child.wait().expect("wait for leader");
462
463        guard.assign_pid(pid);
464        assert!(guard.is_armed(), "guard should be armed after assign_pid");
465
466        assert!(
467            guard.kill_group().is_ok(),
468            "first kill_group on a gone group maps ESRCH to Ok"
469        );
470        assert!(!guard.is_armed(), "kill_group must clear the pid");
471        assert!(
472            guard.kill_group().is_ok(),
473            "second kill_group is a no-op Ok"
474        );
475    }
476
477    #[cfg(target_os = "linux")]
478    fn is_zombie(pid: i32) -> bool {
479        match std::fs::read_to_string(format!("/proc/{pid}/stat")) {
480            // `pid (comm) state …`; split on the LAST `") "` so a comm containing it cannot fool us.
481            Ok(stat) => stat
482                .rsplit(") ")
483                .next()
484                .is_some_and(|rest| rest.starts_with('Z')),
485            Err(_) => false,
486        }
487    }
488}