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    let pgid = Pid::from_raw(process_group_id as libc::pid_t);
205    if let Err(err) = signal::killpg(pgid, signal.as_nix_signal()) {
206        // ESRCH means cleanup has already completed. Rust does not classify
207        // it as ErrorKind::NotFound on every host (including macOS).
208        if err != Errno::ESRCH {
209            return Err(nix_err_to_io(err));
210        }
211    }
212
213    Ok(())
214}
215
216/// Use Windows' process-tree termination as the process-group equivalent.
217#[cfg(windows)]
218pub fn kill_process_group(process_group_id: u32) -> io::Result<()> {
219    kill_process(process_group_id)
220}
221
222/// Use Windows' process-tree termination as the process-group equivalent.
223#[cfg(windows)]
224pub fn kill_process_group_with_signal(process_group_id: u32, _signal: KillSignal) -> io::Result<()> {
225    kill_process(process_group_id)
226}
227
228/// No-op on platforms without process-group or process-tree support.
229#[cfg(all(not(unix), not(windows)))]
230pub fn kill_process_group(_process_group_id: u32) -> io::Result<()> {
231    Ok(())
232}
233
234/// No-op on platforms without process-group or process-tree support.
235#[cfg(all(not(unix), not(windows)))]
236pub fn kill_process_group_with_signal(_process_group_id: u32, _signal: KillSignal) -> io::Result<()> {
237    Ok(())
238}
239
240/// Kill the process group for a tokio child (best-effort).
241#[cfg(unix)]
242pub fn kill_child_process_group(child: &mut Child) -> io::Result<()> {
243    kill_child_process_group_with_signal(child, KillSignal::Kill)
244}
245
246/// Kill the process group for a tokio child with a specific signal.
247#[cfg(unix)]
248pub fn kill_child_process_group_with_signal(child: &mut Child, signal: KillSignal) -> io::Result<()> {
249    if let Some(pid) = child.id() {
250        return kill_process_group_by_pid_with_signal(pid, signal);
251    }
252
253    Ok(())
254}
255
256/// No-op on non-Unix platforms.
257#[cfg(not(unix))]
258pub fn kill_child_process_group(_child: &mut tokio::process::Child) -> io::Result<()> {
259    Ok(())
260}
261
262/// No-op on non-Unix platforms.
263#[cfg(not(unix))]
264pub fn kill_child_process_group_with_signal(_child: &mut tokio::process::Child, _signal: KillSignal) -> io::Result<()> {
265    Ok(())
266}
267
268/// Kill a process by PID on Windows.
269#[cfg(windows)]
270pub fn kill_process(pid: u32) -> io::Result<()> {
271    let status = std::process::Command::new("taskkill")
272        .args(["/PID", &pid.to_string(), "/T", "/F"])
273        .status()?;
274    if status.success() {
275        Ok(())
276    } else {
277        Err(io::Error::other("taskkill failed"))
278    }
279}
280
281/// No-op on non-Windows platforms.
282#[cfg(not(windows))]
283pub fn kill_process(_pid: u32) -> io::Result<()> {
284    Ok(())
285}
286
287/// Result of a graceful termination attempt.
288#[derive(Debug, Clone, Copy, PartialEq, Eq)]
289pub enum GracefulTerminationResult {
290    /// Process exited gracefully after SIGTERM/SIGINT.
291    GracefulExit,
292    /// Process had to be forcefully killed with SIGKILL.
293    ForcefulKill,
294    /// Process was already not running.
295    AlreadyExited,
296    /// Failed to check or terminate the process.
297    Error,
298}
299
300/// Check if a process (by PID) is still running.
301#[cfg(unix)]
302fn is_process_running(pid: u32) -> bool {
303    let target_pid = Pid::from_raw(pid as libc::pid_t);
304    match signal::kill(target_pid, None::<Signal>) {
305        Ok(()) => true,
306        // EPERM = exists but no permission (still running)
307        Err(Errno::EPERM) => true,
308        Err(_) => false,
309    }
310}
311
312#[cfg(not(unix))]
313#[allow(dead_code, reason = "Intentional compatibility, platform, or test-only suppression.")]
314fn is_process_running(_pid: u32) -> bool {
315    // On non-Unix, assume running (will fail gracefully)
316    true
317}
318
319/// Gracefully terminate a process group by PID.
320///
321/// This function implements a staged termination strategy:
322/// 1. Send the initial signal (default: SIGTERM, or SIGINT for interactive processes)
323/// 2. Wait up to `grace_period` for the process to exit
324/// 3. If still running, send SIGKILL
325///
326/// Returns information about how the termination completed.
327///
328/// # Arguments
329/// * `pid` - Process ID (will be used to resolve the process group)
330/// * `initial_signal` - Signal to try first (SIGINT, SIGTERM)
331/// * `grace_period` - How long to wait before SIGKILL
332#[cfg(unix)]
333pub fn graceful_kill_process_group(
334    pid: u32,
335    initial_signal: KillSignal,
336    grace_period: std::time::Duration,
337) -> GracefulTerminationResult {
338    // Check if already exited
339    if !is_process_running(pid) {
340        return GracefulTerminationResult::AlreadyExited;
341    }
342
343    // Resolve PGID
344    let target_pid = Pid::from_raw(pid as libc::pid_t);
345    let Ok(pgid) = unistd::getpgid(Some(target_pid)) else {
346        // Can't get PGID - process may have already exited.
347        return GracefulTerminationResult::AlreadyExited;
348    };
349
350    // Send initial signal (SIGTERM or SIGINT)
351    let signal = match initial_signal {
352        KillSignal::Kill => Signal::SIGTERM, // Don't send SIGKILL as initial.
353        other => other.as_nix_signal(),
354    };
355
356    if let Err(err) = signal::killpg(pgid, signal) {
357        if err != Errno::ESRCH {
358            return GracefulTerminationResult::Error;
359        }
360        return GracefulTerminationResult::AlreadyExited;
361    }
362
363    // Wait for graceful exit
364    let deadline = std::time::Instant::now() + grace_period;
365    let poll_interval = std::time::Duration::from_millis(10);
366
367    while std::time::Instant::now() < deadline {
368        if !is_process_running(pid) {
369            return GracefulTerminationResult::GracefulExit;
370        }
371        std::thread::sleep(poll_interval);
372    }
373
374    // Still running - force kill.
375    // Use the robust termination behavior from codex-rs/utils/pty PR 12688
376    // by attempting both a pgid kill and a direct pid kill.
377    let _ = signal::killpg(pgid, Signal::SIGKILL);
378    if let Err(err) = signal::kill(target_pid, Signal::SIGKILL) {
379        if err == Errno::ESRCH {
380            // Exited between check and kill.
381            return GracefulTerminationResult::GracefulExit;
382        }
383        return GracefulTerminationResult::Error;
384    }
385
386    GracefulTerminationResult::ForcefulKill
387}
388
389/// Graceful termination on non-Unix (best effort).
390///
391/// On Windows, uses `taskkill` without `/F` first, then retries with `/F`
392/// after the grace period.
393#[cfg(not(unix))]
394pub fn graceful_kill_process_group(
395    pid: u32,
396    initial_signal: KillSignal,
397    grace_period: std::time::Duration,
398) -> GracefulTerminationResult {
399    #[cfg(windows)]
400    {
401        let _ = initial_signal;
402        let pid_arg = pid.to_string();
403        match std::process::Command::new("taskkill").args(["/PID", &pid_arg, "/T"]).status() {
404            Ok(status) if status.success() => {
405                std::thread::sleep(grace_period);
406                match kill_process(pid) {
407                    Ok(()) => GracefulTerminationResult::ForcefulKill,
408                    Err(_) => GracefulTerminationResult::AlreadyExited,
409                }
410            }
411            Ok(_) => match kill_process(pid) {
412                Ok(()) => GracefulTerminationResult::ForcefulKill,
413                Err(_) => GracefulTerminationResult::AlreadyExited,
414            },
415            Err(_) => GracefulTerminationResult::Error,
416        }
417    }
418    #[cfg(not(windows))]
419    {
420        let _ = (pid, initial_signal, grace_period);
421        GracefulTerminationResult::Error
422    }
423}
424
425/// Gracefully terminate a process group with default settings.
426///
427/// Uses SIGTERM and the default grace period (500ms).
428pub fn graceful_kill_process_group_default(pid: u32) -> GracefulTerminationResult {
429    graceful_kill_process_group(pid, KillSignal::Term, std::time::Duration::from_millis(DEFAULT_GRACEFUL_TIMEOUT_MS))
430}
431
432/// Async-safe wrapper for graceful process-group termination.
433///
434/// This offloads the synchronous graceful-kill loop to Tokio's blocking pool so
435/// async runtime threads are not occupied by polling sleeps.
436pub async fn graceful_kill_process_group_default_async(pid: u32) -> GracefulTerminationResult {
437    tokio::task::spawn_blocking(move || graceful_kill_process_group_default(pid))
438        .await
439        .unwrap_or(GracefulTerminationResult::Error)
440}
441
442#[cfg(test)]
443mod tests {
444    use super::*;
445
446    #[cfg(unix)]
447    #[test]
448    fn process_group_cleanup_is_idempotent_after_child_exit() {
449        use std::os::unix::process::CommandExt;
450        let mut child = std::process::Command::new("/bin/sh")
451            .args(["-c", "exit 0"])
452            .process_group(0)
453            .spawn()
454            .expect("spawn isolated group");
455        let pid = child.id();
456        assert!(child.wait().expect("reap child").success());
457        assert!(kill_process_group(pid).is_ok());
458        assert!(kill_process_group_with_signal(pid, KillSignal::Int).is_ok());
459    }
460
461    #[test]
462    fn test_set_parent_death_signal_no_panic() {
463        // Just verify it doesn't panic
464        #[cfg(target_os = "linux")]
465        {
466            let parent_pid = unistd::getpid().as_raw();
467            // Note: This will likely fail in tests since we're not in pre_exec
468            // but it should not panic
469            let _ = set_parent_death_signal(parent_pid);
470        }
471        #[cfg(not(target_os = "linux"))]
472        {
473            assert!(set_parent_death_signal(0).is_ok());
474        }
475    }
476
477    #[test]
478    fn test_kill_nonexistent_process_group() {
479        // Killing a non-existent process group should not error on non-Unix
480        // On Unix, ESRCH (no such process) is converted to Ok() in our implementation
481        #[cfg(unix)]
482        {
483            // Try to kill a very high PID that definitely doesn't exist
484            // Our implementation should return Ok for ESRCH
485            let result = kill_process_group(2_000_000_000);
486            // Just verify it doesn't panic - result depends on kernel
487            let _ = result;
488        }
489        #[cfg(not(unix))]
490        {
491            let result = kill_process_group(999_999);
492            assert!(result.is_ok());
493        }
494    }
495
496    #[test]
497    fn test_kill_signal_values() {
498        // Verify KillSignal enum values
499        assert_ne!(KillSignal::Int, KillSignal::Term);
500        assert_ne!(KillSignal::Term, KillSignal::Kill);
501        assert_ne!(KillSignal::Int, KillSignal::Kill);
502
503        // Test default
504        assert_eq!(KillSignal::default(), KillSignal::Kill);
505    }
506
507    #[test]
508    fn test_graceful_termination_result_debug() {
509        // Verify GracefulTerminationResult can be formatted
510        let results = [
511            GracefulTerminationResult::GracefulExit,
512            GracefulTerminationResult::ForcefulKill,
513            GracefulTerminationResult::AlreadyExited,
514            GracefulTerminationResult::Error,
515        ];
516        for result in &results {
517            let _ = format!("{result:?}");
518        }
519    }
520
521    #[test]
522    fn test_graceful_kill_nonexistent_process() {
523        // Gracefully killing a non-existent PID should return AlreadyExited
524        let result = graceful_kill_process_group_default(2_000_000_000);
525        #[cfg(unix)]
526        {
527            // On Unix, non-existent processes return AlreadyExited
528            assert_eq!(result, GracefulTerminationResult::AlreadyExited);
529        }
530        #[cfg(not(unix))]
531        {
532            // On non-Unix, behavior varies
533            let _ = result;
534        }
535    }
536
537    #[tokio::test]
538    async fn test_graceful_kill_nonexistent_process_async() {
539        let result = graceful_kill_process_group_default_async(2_000_000_000).await;
540        #[cfg(unix)]
541        {
542            assert_eq!(result, GracefulTerminationResult::AlreadyExited);
543        }
544        #[cfg(not(unix))]
545        {
546            let _ = result;
547        }
548    }
549
550    #[cfg(unix)]
551    #[test]
552    fn test_is_process_running_self() {
553        // Our own process should be running
554        let pid = std::process::id();
555        assert!(is_process_running(pid));
556    }
557
558    #[cfg(unix)]
559    #[test]
560    fn test_is_process_running_nonexistent() {
561        // A very high PID should not be running
562        assert!(!is_process_running(2_000_000_000));
563    }
564}