Skip to main content

util/
process.rs

1use anyhow::{Context as _, Result};
2use std::process::Stdio;
3
4/// A wrapper around `smol::process::Child` that attempts to clean up subprocess
5/// trees: on Unix by using process groups, and on Windows by using job objects.
6///
7/// On Windows, if job setup and assignment succeed, dropping this struct
8/// closes the job handle and terminates processes in the job. If either step
9/// fails, the direct child remains usable but descendants are not tracked.
10/// A descendant spawned before job assignment can also escape the job.
11pub struct Child {
12    process: smol::process::Child,
13    #[cfg(windows)]
14    job: Option<windows_job::JobObject>,
15}
16
17impl std::ops::Deref for Child {
18    type Target = smol::process::Child;
19
20    fn deref(&self) -> &Self::Target {
21        &self.process
22    }
23}
24
25impl std::ops::DerefMut for Child {
26    fn deref_mut(&mut self) -> &mut Self::Target {
27        &mut self.process
28    }
29}
30
31impl Child {
32    #[cfg(not(windows))]
33    pub fn spawn(
34        mut command: std::process::Command,
35        stdin: Stdio,
36        stdout: Stdio,
37        stderr: Stdio,
38    ) -> Result<Self> {
39        crate::set_pre_exec_to_start_new_session(&mut command);
40        let mut command = smol::process::Command::from(command);
41        let process = command
42            .stdin(stdin)
43            .stdout(stdout)
44            .stderr(stderr)
45            .spawn()
46            .with_context(|| {
47                format!(
48                    "failed to spawn command {}",
49                    crate::redact::redact_command(&format!("{command:?}"))
50                )
51            })?;
52        Ok(Self { process })
53    }
54
55    #[cfg(windows)]
56    pub fn spawn(
57        command: std::process::Command,
58        stdin: Stdio,
59        stdout: Stdio,
60        stderr: Stdio,
61    ) -> Result<Self> {
62        let mut command = smol::process::Command::from(command);
63        let process = command
64            .stdin(stdin)
65            .stdout(stdout)
66            .stderr(stderr)
67            .spawn()
68            .with_context(|| {
69                format!(
70                    "failed to spawn command {}",
71                    crate::redact::redact_command(&format!("{command:?}"))
72                )
73            })?;
74
75        // Assign the child to a job object configured to kill the entire
76        // process tree when the last job handle is closed, so descendants
77        // (e.g. node workers and MCP servers spawned by agent servers) are
78        // reaped even if the direct child doesn't clean them up. Any process
79        // the child spawns after this assignment is automatically part of the
80        // job.
81        //
82        // There is a small race: descendants the child spawns between the
83        // `spawn()` call returning and the assignment below escape the job.
84        // Closing it fully would require creating the process suspended
85        // (`CREATE_SUSPENDED`), assigning it, then resuming it, which the
86        // std/smol process APIs don't support without reimplementing process
87        // creation. The window is normally short, but descendants created in
88        // it are not tracked by the job.
89        let job = windows_job::JobObject::new()
90            .and_then(|job| {
91                job.assign_process(process.id())?;
92                Ok(job)
93            })
94            .map_err(|error| {
95                log::error!("failed to assign spawned process to a job object: {error:#}");
96            })
97            .ok();
98
99        Ok(Self { process, job })
100    }
101
102    /// Consumes the child, draining its stdout/stderr and waiting for it to
103    /// exit, then returns the collected output.
104    pub async fn output(self) -> Result<std::process::Output> {
105        // NOTE: Keep `self` alive across this await, do not destructure it to
106        // pull `process` out first. On Windows that drops the job object early,
107        // which triggers `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE` and kills the
108        // child before `output()` finishes collecting its stdout/stderr.
109        Ok(self.process.output().await?)
110    }
111
112    #[cfg(not(windows))]
113    pub fn kill(&mut self) -> Result<()> {
114        let pid = self.process.id();
115        unsafe {
116            libc::killpg(pid as i32, libc::SIGKILL);
117        }
118        Ok(())
119    }
120
121    #[cfg(windows)]
122    pub fn kill(&mut self) -> Result<()> {
123        if let Some(job) = &self.job {
124            job.terminate()
125        } else {
126            self.process.kill()?;
127            Ok(())
128        }
129    }
130
131    /// Returns job activity, or `None` when descendant cleanup is unavailable.
132    ///
133    /// The count includes descendants after the direct child exits. `None`
134    /// means job setup failed, not that the process tree is empty.
135    ///
136    /// # Errors
137    ///
138    /// Returns an error if Windows cannot query the owned job's activity.
139    #[cfg(windows)]
140    pub fn has_running_processes(&self) -> Result<Option<bool>> {
141        self.job
142            .as_ref()
143            .map(windows_job::JobObject::has_running_processes)
144            .transpose()
145    }
146}
147
148#[cfg(windows)]
149mod windows_job {
150    use crate::ResultExt as _;
151    use anyhow::{Context as _, Result};
152    use windows::Win32::{
153        Foundation::{CloseHandle, HANDLE},
154        System::{
155            JobObjects::{
156                AssignProcessToJobObject, CreateJobObjectW, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
157                JOBOBJECT_BASIC_ACCOUNTING_INFORMATION, JOBOBJECT_EXTENDED_LIMIT_INFORMATION,
158                JobObjectBasicAccountingInformation, JobObjectExtendedLimitInformation,
159                QueryInformationJobObject, SetInformationJobObject, TerminateJobObject,
160            },
161            Threading::{OpenProcess, PROCESS_SET_QUOTA, PROCESS_TERMINATE},
162        },
163    };
164
165    /// A Win32 job object configured with `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE`:
166    /// all processes assigned to the job (and their descendants) are terminated
167    /// when the last handle to the job is closed, which happens when this struct
168    /// is dropped, or when the OS closes the owning process's handles after it
169    /// exits for any reason.
170    pub(crate) struct JobObject(HANDLE);
171
172    // SAFETY: Job object handles can be used from any thread.
173    unsafe impl Send for JobObject {}
174    unsafe impl Sync for JobObject {}
175
176    impl JobObject {
177        pub(crate) fn new() -> Result<Self> {
178            unsafe {
179                let job =
180                    Self(CreateJobObjectW(None, None).context("failed to create job object")?);
181                let mut info = JOBOBJECT_EXTENDED_LIMIT_INFORMATION::default();
182                info.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
183                SetInformationJobObject(
184                    job.0,
185                    JobObjectExtendedLimitInformation,
186                    &info as *const _ as *const _,
187                    size_of::<JOBOBJECT_EXTENDED_LIMIT_INFORMATION>() as u32,
188                )
189                .context("failed to set job object limits")?;
190                Ok(job)
191            }
192        }
193
194        pub(crate) fn assign_process(&self, pid: u32) -> Result<()> {
195            unsafe {
196                let process = OpenProcess(PROCESS_SET_QUOTA | PROCESS_TERMINATE, false, pid)
197                    .context("failed to open process")?;
198                let result = AssignProcessToJobObject(self.0, process)
199                    .context("failed to assign process to job object");
200                CloseHandle(process).log_err();
201                result
202            }
203        }
204
205        pub(crate) fn terminate(&self) -> Result<()> {
206            unsafe { TerminateJobObject(self.0, 1).context("failed to terminate job object") }
207        }
208
209        pub(crate) fn has_running_processes(&self) -> Result<bool> {
210            let mut accounting = JOBOBJECT_BASIC_ACCOUNTING_INFORMATION::default();
211            // SAFETY: self owns a valid job handle, and accounting is writable
212            // for the exact buffer size passed to this synchronous query.
213            unsafe {
214                QueryInformationJobObject(
215                    Some(self.0),
216                    JobObjectBasicAccountingInformation,
217                    &mut accounting as *mut _ as *mut _,
218                    size_of::<JOBOBJECT_BASIC_ACCOUNTING_INFORMATION>() as u32,
219                    None,
220                )
221                .context("failed to query active job processes")?;
222            }
223            Ok(accounting.ActiveProcesses > 0)
224        }
225    }
226
227    impl Drop for JobObject {
228        fn drop(&mut self) {
229            unsafe {
230                CloseHandle(self.0).log_err();
231            }
232        }
233    }
234}
235
236#[cfg(all(test, windows))]
237mod windows_tests {
238    use super::*;
239    use std::time::{Duration, Instant};
240
241    #[test]
242    fn test_unmanaged_child_reports_unavailable_job_activity() {
243        let mut command = gpui_util::new_std_command("ping.exe");
244        command.args(["-n", "60", "127.0.0.1"]);
245        let process = smol::process::Command::from(command)
246            .stdin(Stdio::null())
247            .stdout(Stdio::null())
248            .stderr(Stdio::null())
249            .kill_on_drop(true)
250            .spawn()
251            .expect("failed to spawn unmanaged process");
252        let mut child = Child { process, job: None };
253
254        let pid = child.id();
255        assert!(
256            process_is_alive(pid),
257            "direct child should still be running"
258        );
259        assert_eq!(
260            child.has_running_processes().expect("failed to query job"),
261            None
262        );
263        child
264            .kill()
265            .expect("failed to kill unassigned direct child");
266        smol::block_on(child.status()).expect("failed to reap direct child");
267        assert_process_exits(pid, "direct child should be terminated");
268    }
269
270    #[test]
271    fn test_job_tracks_grandchild_after_direct_child_exits() {
272        let temp_dir = tempfile::tempdir().unwrap();
273        let pid_file = temp_dir.path().join("grandchild_pid");
274        let mut command = gpui_util::new_std_command("powershell.exe");
275        command.args(["-NoProfile", "-Command"]).arg(format!(
276            "$p = Start-Process -FilePath ping.exe -ArgumentList @('-n','60','127.0.0.1') -PassThru -WindowStyle Hidden; \
277             Set-Content -LiteralPath '{}' -Value $p.Id",
278            pid_file.display()
279        ));
280        let mut child = Child::spawn(command, Stdio::null(), Stdio::null(), Stdio::null())
281            .expect("failed to spawn powershell");
282        let status = smol::block_on(child.status()).expect("failed to await powershell");
283        assert!(status.success(), "powershell failed: {status}");
284
285        let grandchild_pid = std::fs::read_to_string(&pid_file)
286            .expect("powershell did not record grandchild pid")
287            .trim()
288            .parse::<u32>()
289            .expect("invalid grandchild pid");
290        assert!(process_is_alive(grandchild_pid));
291        assert_eq!(
292            child.has_running_processes().expect("failed to query job"),
293            Some(true),
294            "job should remain active after its direct child exits"
295        );
296
297        child.kill().expect("failed to terminate job");
298        let deadline = Instant::now() + Duration::from_secs(2);
299        while child.has_running_processes().expect("failed to query job") != Some(false) {
300            assert!(Instant::now() < deadline, "job did not become inactive");
301            std::thread::sleep(Duration::from_millis(50));
302        }
303        assert_process_exits(grandchild_pid, "job grandchild should be terminated");
304    }
305
306    /// Spawns a process tree `powershell -> ping` via `Child::spawn` and
307    /// returns the `Child` along with the pid of the grandchild (`ping`).
308    fn spawn_process_tree(temp_dir: &std::path::Path) -> (Child, u32) {
309        let pid_file = temp_dir.join("grandchild_pid");
310        let mut command = std::process::Command::new("powershell.exe");
311        command.args(["-NoProfile", "-Command"]).arg(format!(
312            "$p = Start-Process -FilePath ping.exe -ArgumentList @('-n','60','127.0.0.1') -PassThru -WindowStyle Hidden; \
313             Set-Content -LiteralPath '{}' -Value $p.Id; \
314             Wait-Process -Id $p.Id",
315            pid_file.display()
316        ));
317        let child = Child::spawn(command, Stdio::null(), Stdio::null(), Stdio::null())
318            .expect("failed to spawn powershell");
319
320        let deadline = Instant::now() + Duration::from_secs(5);
321        let grandchild_pid = loop {
322            if let Ok(contents) = std::fs::read_to_string(&pid_file)
323                && let Ok(pid) = contents.trim().parse::<u32>()
324            {
325                break pid;
326            }
327            assert!(
328                Instant::now() < deadline,
329                "timed out waiting for grandchild pid file"
330            );
331            std::thread::sleep(Duration::from_millis(50));
332        };
333        assert!(
334            process_is_alive(grandchild_pid),
335            "grandchild should be alive after spawning"
336        );
337        (child, grandchild_pid)
338    }
339
340    fn process_is_alive(pid: u32) -> bool {
341        use windows::Win32::{
342            Foundation::{CloseHandle, STILL_ACTIVE},
343            System::Threading::{
344                GetExitCodeProcess, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION,
345            },
346        };
347
348        unsafe {
349            let Ok(handle) = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid) else {
350                return false;
351            };
352            let mut exit_code = 0u32;
353            let alive = GetExitCodeProcess(handle, &mut exit_code).is_ok()
354                && exit_code == STILL_ACTIVE.0 as u32;
355            CloseHandle(handle).expect("failed to close process handle");
356            alive
357        }
358    }
359
360    fn assert_process_exits(pid: u32, message: &str) {
361        let deadline = Instant::now() + Duration::from_secs(2);
362        while process_is_alive(pid) {
363            assert!(Instant::now() < deadline, "{message} (pid {pid})");
364            std::thread::sleep(Duration::from_millis(100));
365        }
366    }
367
368    #[test]
369    fn test_kill_terminates_grandchildren() {
370        let temp_dir = tempfile::tempdir().unwrap();
371        let (mut child, grandchild_pid) = spawn_process_tree(temp_dir.path());
372
373        child.kill().expect("failed to kill child");
374
375        assert_process_exits(
376            grandchild_pid,
377            "grandchild should be terminated after killing the child",
378        );
379    }
380
381    #[test]
382    fn test_drop_terminates_grandchildren() {
383        let temp_dir = tempfile::tempdir().unwrap();
384        let (child, grandchild_pid) = spawn_process_tree(temp_dir.path());
385
386        drop(child);
387
388        assert_process_exits(
389            grandchild_pid,
390            "grandchild should be terminated after dropping the child",
391        );
392    }
393}