Skip to main content

vtcode_bash_runner/
process_group.rs

1//! Process-group helpers for reliable child process cleanup.
2//!
3//! This module centralizes OS-specific pieces that ensure a spawned
4//! command can be cleaned up reliably:
5//! - `set_process_group` is called in `pre_exec` so the child starts its own
6//!   process group.
7//! - `detach_from_tty` starts a new session so non-interactive children do not
8//!   inherit the controlling TTY.
9//! - `kill_process_group_by_pid` targets the whole group (children/grandchildren)
10//!   instead of a single PID.
11//! - `kill_process_group` targets a known process group ID directly.
12//! - `set_parent_death_signal` (Linux only) arranges for the child to receive a
13//!   `SIGTERM` when the parent exits, and re-checks the parent PID to avoid
14//!   races during fork/exec.
15//! - `graceful_kill_process_group` sends SIGTERM, waits for a grace period, then
16//!   SIGKILL if still running.
17//!
18//! On non-Unix platforms these helpers are no-ops or adapted equivalents.
19//!
20//! Inspired by codex-rs/utils/pty process group management patterns.
21
22use std::io;
23
24#[cfg(windows)]
25mod windows;
26
27#[cfg(unix)]
28use nix::errno::Errno;
29#[cfg(target_os = "linux")]
30use nix::sys::prctl;
31#[cfg(unix)]
32use nix::sys::signal::{self, Signal};
33#[cfg(unix)]
34use nix::unistd::{self, Pid};
35#[cfg(unix)]
36use tokio::process::Child;
37
38/// Default grace period for graceful termination (milliseconds).
39pub const DEFAULT_GRACEFUL_TIMEOUT_MS: u64 = 500;
40
41/// Signal to send when killing process groups.
42#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
43pub enum KillSignal {
44    /// SIGINT - interrupt (Ctrl+C equivalent)
45    Int,
46    /// SIGTERM - allows graceful shutdown
47    Term,
48    /// SIGKILL - immediate termination
49    #[default]
50    Kill,
51}
52
53#[cfg(unix)]
54impl KillSignal {
55    fn as_nix_signal(self) -> Signal {
56        match self {
57            KillSignal::Int => Signal::SIGINT,
58            KillSignal::Term => Signal::SIGTERM,
59            KillSignal::Kill => Signal::SIGKILL,
60        }
61    }
62}
63
64#[cfg(unix)]
65#[cold]
66fn nix_err_to_io(err: Errno) -> io::Error {
67    io::Error::from_raw_os_error(err as i32)
68}
69
70/// Ensure the child receives SIGTERM when the original parent dies.
71///
72/// This should run in `pre_exec` and uses `parent_pid` captured before spawn to
73/// avoid a race where the parent exits between fork and exec.
74#[cfg(target_os = "linux")]
75pub fn set_parent_death_signal(parent_pid: libc::pid_t) -> io::Result<()> {
76    prctl::set_pdeathsig(Some(Signal::SIGTERM)).map_err(nix_err_to_io)?;
77
78    // Re-check parent PID to avoid race condition where parent exits between fork and exec.
79    if unistd::getppid() != Pid::from_raw(parent_pid) {
80        signal::kill(unistd::getpid(), Signal::SIGTERM).map_err(nix_err_to_io)?;
81    }
82
83    Ok(())
84}
85
86/// No-op on non-Linux platforms.
87#[cfg(not(target_os = "linux"))]
88pub fn set_parent_death_signal(_parent_pid: i32) -> io::Result<()> {
89    Ok(())
90}
91
92/// Detach from the controlling TTY by starting a new session.
93///
94/// This is useful for spawning background processes that should not receive
95/// signals from the controlling terminal.
96#[cfg(unix)]
97pub fn detach_from_tty() -> io::Result<()> {
98    match unistd::setsid() {
99        Ok(_) => Ok(()),
100        // EPERM means we're already a session leader, fall back to setpgid.
101        Err(Errno::EPERM) => set_process_group(),
102        Err(err) => Err(nix_err_to_io(err)),
103    }
104}
105
106/// No-op on non-Unix platforms.
107#[cfg(not(unix))]
108pub fn detach_from_tty() -> io::Result<()> {
109    Ok(())
110}
111
112/// Put the calling process into its own process group.
113///
114/// Intended for use in `pre_exec` so the child becomes the group leader.
115#[cfg(unix)]
116pub fn set_process_group() -> io::Result<()> {
117    unistd::setpgid(Pid::from_raw(0), Pid::from_raw(0)).map_err(nix_err_to_io)
118}
119
120/// No-op on non-Unix platforms.
121#[cfg(not(unix))]
122pub fn set_process_group() -> io::Result<()> {
123    Ok(())
124}
125
126/// Kill the process group for the given PID (best-effort).
127///
128/// This resolves the PGID for `pid` and sends SIGKILL to the whole group.
129#[cfg(unix)]
130pub fn kill_process_group_by_pid(pid: u32) -> io::Result<()> {
131    kill_process_group_by_pid_with_signal(pid, KillSignal::Kill)
132}
133
134/// Kill the process group for the given PID with a specific signal.
135#[cfg(unix)]
136pub fn kill_process_group_by_pid_with_signal(pid: u32, signal: KillSignal) -> io::Result<()> {
137    use std::io::ErrorKind;
138
139    let target_pid = Pid::from_raw(pid as libc::pid_t);
140    let pgid = unistd::getpgid(Some(target_pid));
141    let mut pgid_err = None;
142
143    match pgid {
144        Ok(group) => {
145            if let Err(err) = signal::killpg(group, signal.as_nix_signal()) {
146                let io_err = nix_err_to_io(err);
147                if io_err.kind() != ErrorKind::NotFound {
148                    pgid_err = Some(io_err);
149                }
150            }
151        }
152        Err(err) => pgid_err = Some(nix_err_to_io(err)),
153    }
154
155    // Always attempt to kill the direct child process handle as a fallback.
156    // This ensures termination even if the cached PGID was stale or
157    // the process group kill had issues.
158    if let Err(err) = signal::kill(target_pid, signal.as_nix_signal()) {
159        let io_err = nix_err_to_io(err);
160        if io_err.kind() == ErrorKind::NotFound {
161            // If direct kill says not found, we're done regardless of pgid result.
162            return Ok(());
163        }
164        // If we have a pgid error and a direct kill error, prefer the pgid one.
165        if let Some(pgid_error) = pgid_err {
166            return Err(pgid_error);
167        }
168        return Err(io_err);
169    }
170
171    Ok(())
172}
173
174/// Use Windows' process-tree termination as the process-group equivalent.
175#[cfg(windows)]
176pub fn kill_process_group_by_pid(pid: u32) -> io::Result<()> {
177    kill_process(pid)
178}
179
180/// Use Windows' process-tree termination as the process-group equivalent.
181#[cfg(windows)]
182pub fn kill_process_group_by_pid_with_signal(pid: u32, _signal: KillSignal) -> io::Result<()> {
183    kill_process(pid)
184}
185
186/// No-op on platforms without process-group or process-tree support.
187#[cfg(all(not(unix), not(windows)))]
188pub fn kill_process_group_by_pid(_pid: u32) -> io::Result<()> {
189    Ok(())
190}
191
192/// No-op on platforms without process-group or process-tree support.
193#[cfg(all(not(unix), not(windows)))]
194pub fn kill_process_group_by_pid_with_signal(_pid: u32, _signal: KillSignal) -> io::Result<()> {
195    Ok(())
196}
197
198/// Kill a specific process group ID (best-effort).
199#[cfg(unix)]
200pub fn kill_process_group(process_group_id: u32) -> io::Result<()> {
201    kill_process_group_with_signal(process_group_id, KillSignal::Kill)
202}
203
204/// Kill a specific process group ID with a specific signal.
205#[cfg(unix)]
206pub fn kill_process_group_with_signal(process_group_id: u32, signal: KillSignal) -> io::Result<()> {
207    let pgid = Pid::from_raw(process_group_id as libc::pid_t);
208    if let Err(err) = signal::killpg(pgid, signal.as_nix_signal()) {
209        // ESRCH means cleanup has already completed. Rust does not classify
210        // it as ErrorKind::NotFound on every host (including macOS).
211        if err != Errno::ESRCH {
212            return Err(nix_err_to_io(err));
213        }
214    }
215
216    Ok(())
217}
218
219/// Use Windows' process-tree termination as the process-group equivalent.
220#[cfg(windows)]
221pub fn kill_process_group(process_group_id: u32) -> io::Result<()> {
222    kill_process(process_group_id)
223}
224
225/// Use Windows' process-tree termination as the process-group equivalent.
226#[cfg(windows)]
227pub fn kill_process_group_with_signal(process_group_id: u32, _signal: KillSignal) -> io::Result<()> {
228    kill_process(process_group_id)
229}
230
231/// No-op on platforms without process-group or process-tree support.
232#[cfg(all(not(unix), not(windows)))]
233pub fn kill_process_group(_process_group_id: u32) -> io::Result<()> {
234    Ok(())
235}
236
237/// No-op on platforms without process-group or process-tree support.
238#[cfg(all(not(unix), not(windows)))]
239pub fn kill_process_group_with_signal(_process_group_id: u32, _signal: KillSignal) -> io::Result<()> {
240    Ok(())
241}
242
243/// Kill the process group for a tokio child (best-effort).
244#[cfg(unix)]
245pub fn kill_child_process_group(child: &mut Child) -> io::Result<()> {
246    kill_child_process_group_with_signal(child, KillSignal::Kill)
247}
248
249/// Kill the process group for a tokio child with a specific signal.
250#[cfg(unix)]
251pub fn kill_child_process_group_with_signal(child: &mut Child, signal: KillSignal) -> io::Result<()> {
252    if let Some(pid) = child.id() {
253        return kill_process_group_by_pid_with_signal(pid, signal);
254    }
255
256    Ok(())
257}
258
259/// No-op on non-Unix platforms.
260#[cfg(not(unix))]
261pub fn kill_child_process_group(_child: &mut tokio::process::Child) -> io::Result<()> {
262    Ok(())
263}
264
265/// No-op on non-Unix platforms.
266#[cfg(not(unix))]
267pub fn kill_child_process_group_with_signal(_child: &mut tokio::process::Child, _signal: KillSignal) -> io::Result<()> {
268    Ok(())
269}
270
271/// Kill a process by PID on Windows.
272#[cfg(windows)]
273pub fn kill_process(pid: u32) -> io::Result<()> {
274    windows::kill_process(pid)
275}
276
277/// No-op on non-Windows platforms.
278#[cfg(not(windows))]
279pub fn kill_process(_pid: u32) -> io::Result<()> {
280    Ok(())
281}
282
283/// Result of a graceful termination attempt.
284#[derive(Debug, Clone, Copy, PartialEq, Eq)]
285pub enum GracefulTerminationResult {
286    /// Process exited gracefully after SIGTERM/SIGINT.
287    GracefulExit,
288    /// Process had to be forcefully killed with SIGKILL.
289    ForcefulKill,
290    /// Process was already not running.
291    AlreadyExited,
292    /// Failed to check or terminate the process.
293    Error,
294}
295
296/// Check if a process (by PID) is still running.
297#[cfg(unix)]
298fn is_process_running(pid: u32) -> bool {
299    let target_pid = Pid::from_raw(pid as libc::pid_t);
300    match signal::kill(target_pid, None::<Signal>) {
301        Ok(()) => true,
302        // EPERM = exists but no permission (still running)
303        Err(Errno::EPERM) => true,
304        Err(_) => false,
305    }
306}
307
308#[cfg(not(unix))]
309fn is_process_running(_pid: u32) -> bool {
310    // On non-Unix, assume running (will fail gracefully)
311    true
312}
313
314/// Gracefully terminate a process group by PID.
315///
316/// This function implements a staged termination strategy:
317/// 1. Send the initial signal (default: SIGTERM, or SIGINT for interactive processes)
318/// 2. Wait up to `grace_period` for the process to exit
319/// 3. If still running, send SIGKILL
320///
321/// Returns information about how the termination completed.
322///
323/// # Arguments
324/// * `pid` - Process ID (will be used to resolve the process group)
325/// * `initial_signal` - Signal to try first (SIGINT, SIGTERM)
326/// * `grace_period` - How long to wait before SIGKILL
327#[cfg(unix)]
328pub fn graceful_kill_process_group(
329    pid: u32,
330    initial_signal: KillSignal,
331    grace_period: std::time::Duration,
332) -> GracefulTerminationResult {
333    // Check if already exited
334    if !is_process_running(pid) {
335        return GracefulTerminationResult::AlreadyExited;
336    }
337
338    // Resolve PGID
339    let target_pid = Pid::from_raw(pid as libc::pid_t);
340    let Ok(pgid) = unistd::getpgid(Some(target_pid)) else {
341        // Can't get PGID - process may have already exited.
342        return GracefulTerminationResult::AlreadyExited;
343    };
344
345    // Send initial signal (SIGTERM or SIGINT)
346    let signal = match initial_signal {
347        KillSignal::Kill => Signal::SIGTERM, // Don't send SIGKILL as initial.
348        other => other.as_nix_signal(),
349    };
350
351    if let Err(err) = signal::killpg(pgid, signal) {
352        if err != Errno::ESRCH {
353            return GracefulTerminationResult::Error;
354        }
355        return GracefulTerminationResult::AlreadyExited;
356    }
357
358    // Wait for graceful exit
359    let deadline = std::time::Instant::now() + grace_period;
360    let poll_interval = std::time::Duration::from_millis(10);
361
362    while std::time::Instant::now() < deadline {
363        if !is_process_running(pid) {
364            return GracefulTerminationResult::GracefulExit;
365        }
366        std::thread::sleep(poll_interval);
367    }
368
369    // Still running - force kill.
370    // Use the robust termination behavior from codex-rs/utils/pty PR 12688
371    // by attempting both a pgid kill and a direct pid kill.
372    let _ = signal::killpg(pgid, Signal::SIGKILL);
373    if let Err(err) = signal::kill(target_pid, Signal::SIGKILL) {
374        if err == Errno::ESRCH {
375            // Exited between check and kill.
376            return GracefulTerminationResult::GracefulExit;
377        }
378        return GracefulTerminationResult::Error;
379    }
380
381    GracefulTerminationResult::ForcefulKill
382}
383
384/// Graceful termination on non-Unix (best effort).
385///
386/// On Windows, uses `taskkill` without `/F` first, then retries with `/F`
387/// after the grace period.
388#[cfg(not(unix))]
389pub fn graceful_kill_process_group(
390    pid: u32,
391    initial_signal: KillSignal,
392    grace_period: std::time::Duration,
393) -> GracefulTerminationResult {
394    #[cfg(windows)]
395    {
396        let _ = initial_signal;
397        let pid_arg = pid.to_string();
398        match std::process::Command::new("taskkill").args(["/PID", &pid_arg, "/T"]).output() {
399            Ok(output) if output.status.success() => {
400                std::thread::sleep(grace_period);
401                match kill_process(pid) {
402                    Ok(()) => GracefulTerminationResult::ForcefulKill,
403                    Err(_) => GracefulTerminationResult::AlreadyExited,
404                }
405            }
406            Ok(_) => match kill_process(pid) {
407                Ok(()) => GracefulTerminationResult::ForcefulKill,
408                Err(_) => GracefulTerminationResult::AlreadyExited,
409            },
410            Err(_) => GracefulTerminationResult::Error,
411        }
412    }
413    #[cfg(not(windows))]
414    {
415        let _ = (pid, initial_signal, grace_period);
416        GracefulTerminationResult::Error
417    }
418}
419
420/// Gracefully terminate a process group with default settings.
421///
422/// Uses SIGTERM and the default grace period (500ms).
423pub fn graceful_kill_process_group_default(pid: u32) -> GracefulTerminationResult {
424    graceful_kill_process_group(pid, KillSignal::Term, std::time::Duration::from_millis(DEFAULT_GRACEFUL_TIMEOUT_MS))
425}
426
427/// Async-safe wrapper for graceful process-group termination.
428///
429/// This offloads the synchronous graceful-kill loop to Tokio's blocking pool so
430/// async runtime threads are not occupied by polling sleeps.
431pub async fn graceful_kill_process_group_default_async(pid: u32) -> GracefulTerminationResult {
432    tokio::task::spawn_blocking(move || graceful_kill_process_group_default(pid))
433        .await
434        .unwrap_or(GracefulTerminationResult::Error)
435}
436
437#[cfg(test)]
438mod tests {
439    use super::*;
440
441    #[cfg(unix)]
442    #[test]
443    fn process_group_cleanup_is_idempotent_after_child_exit() {
444        use std::os::unix::process::CommandExt;
445        let mut child = std::process::Command::new("/bin/sh")
446            .args(["-c", "exit 0"])
447            .process_group(0)
448            .spawn()
449            .expect("spawn isolated group");
450        let pid = child.id();
451        assert!(child.wait().expect("reap child").success());
452        assert!(kill_process_group(pid).is_ok());
453        assert!(kill_process_group_with_signal(pid, KillSignal::Int).is_ok());
454    }
455
456    #[test]
457    fn test_set_parent_death_signal_no_panic() {
458        // Just verify it doesn't panic
459        #[cfg(target_os = "linux")]
460        {
461            let parent_pid = unistd::getpid().as_raw();
462            // Note: This will likely fail in tests since we're not in pre_exec
463            // but it should not panic
464            let _ = set_parent_death_signal(parent_pid);
465        }
466        #[cfg(not(target_os = "linux"))]
467        {
468            assert!(set_parent_death_signal(0).is_ok());
469        }
470    }
471
472    #[test]
473    fn test_kill_nonexistent_process_group() {
474        // Killing a non-existent process group should not error on non-Unix
475        // On Unix, ESRCH (no such process) is converted to Ok() in our implementation
476        #[cfg(unix)]
477        {
478            // Try to kill a very high PID that definitely doesn't exist
479            // Our implementation should return Ok for ESRCH
480            let result = kill_process_group(2_000_000_000);
481            // Just verify it doesn't panic - result depends on kernel
482            let _ = result;
483        }
484        #[cfg(not(unix))]
485        {
486            let result = kill_process_group(999_999);
487            assert!(result.is_ok());
488        }
489    }
490
491    #[test]
492    fn test_kill_signal_values() {
493        // Verify KillSignal enum values
494        assert_ne!(KillSignal::Int, KillSignal::Term);
495        assert_ne!(KillSignal::Term, KillSignal::Kill);
496        assert_ne!(KillSignal::Int, KillSignal::Kill);
497
498        // Test default
499        assert_eq!(KillSignal::default(), KillSignal::Kill);
500    }
501
502    #[test]
503    fn test_graceful_termination_result_debug() {
504        // Verify GracefulTerminationResult can be formatted
505        let results = [
506            GracefulTerminationResult::GracefulExit,
507            GracefulTerminationResult::ForcefulKill,
508            GracefulTerminationResult::AlreadyExited,
509            GracefulTerminationResult::Error,
510        ];
511        for result in &results {
512            let _ = format!("{result:?}");
513        }
514    }
515
516    #[test]
517    fn test_graceful_kill_nonexistent_process() {
518        // Gracefully killing a non-existent PID should return AlreadyExited
519        let result = graceful_kill_process_group_default(2_000_000_000);
520        #[cfg(unix)]
521        {
522            // On Unix, non-existent processes return AlreadyExited
523            assert_eq!(result, GracefulTerminationResult::AlreadyExited);
524        }
525        #[cfg(not(unix))]
526        {
527            // On non-Unix, behavior varies
528            let _ = result;
529        }
530    }
531
532    #[tokio::test]
533    async fn test_graceful_kill_nonexistent_process_async() {
534        let result = graceful_kill_process_group_default_async(2_000_000_000).await;
535        #[cfg(unix)]
536        {
537            assert_eq!(result, GracefulTerminationResult::AlreadyExited);
538        }
539        #[cfg(not(unix))]
540        {
541            let _ = result;
542        }
543    }
544
545    #[cfg(unix)]
546    #[test]
547    fn test_is_process_running_self() {
548        // Our own process should be running
549        let pid = std::process::id();
550        assert!(is_process_running(pid));
551    }
552
553    #[cfg(unix)]
554    #[test]
555    fn test_is_process_running_nonexistent() {
556        // A very high PID should not be running
557        assert!(!is_process_running(2_000_000_000));
558    }
559}