1use anyhow::{Context as _, Result};
2use std::process::Stdio;
3
4pub 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 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 pub async fn output(self) -> Result<std::process::Output> {
105 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 #[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 pub(crate) struct JobObject(HANDLE);
171
172 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 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 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}