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