1use std::io;
30use std::process::{
31 Child, ChildStderr, ChildStdin, ChildStdout, Command, ExitStatus, Output, Stdio,
32};
33use std::sync::Arc;
34
35use super::registry;
36use super::spawn_retry::spawn_retrying_busy_executable;
37
38pub struct ScopedChild {
40 inner: Option<Child>,
44 id: Option<u64>,
46 process_tree: Option<registry::ProcessTreeHandle>,
48 inherited_tree_pid: Option<u32>,
50}
51
52#[derive(Clone)]
54pub struct ProcessTreeTerminator {
55 target: TerminationTarget,
56 direct_pid: u32,
57}
58
59#[derive(Clone)]
60enum TerminationTarget {
61 ProcessTree(registry::ProcessTreeHandle),
62 Process(u32),
63}
64
65impl ProcessTreeTerminator {
66 pub fn terminate(&self) -> io::Result<()> {
67 let result = match &self.target {
68 TerminationTarget::ProcessTree(process_tree) => process_tree.terminate(),
69 TerminationTarget::Process(pid) => {
70 registry::kill_pid(*pid);
71 Ok(())
72 }
73 };
74 if result.is_err() {
75 registry::kill_pid(self.direct_pid);
76 }
77 result
78 }
79
80 #[cfg(all(test, windows))]
81 fn is_alive(&self) -> bool {
82 match &self.target {
83 TerminationTarget::ProcessTree(process_tree) => process_tree.is_alive(),
84 TerminationTarget::Process(pid) => registry::pid_is_alive(*pid),
85 }
86 }
87}
88
89impl ScopedChild {
90 pub fn spawn(command: &mut Command) -> io::Result<Self> {
92 let child = spawn_retrying_busy_executable(command)?;
93 let id = registry::register(child.id());
94 Ok(Self {
95 inner: Some(child),
96 id: Some(id),
97 process_tree: None,
98 inherited_tree_pid: None,
99 })
100 }
101
102 pub fn spawn_process_tree(command: &mut Command) -> io::Result<Self> {
105 if crate::process_tree::inherits_managed_process_tree() {
106 let child = spawn_retrying_busy_executable(command)?;
107 let pid = child.id();
108 let id = registry::register(pid);
109 return Ok(Self {
110 inner: Some(child),
111 id: Some(id),
112 process_tree: None,
113 inherited_tree_pid: Some(pid),
114 });
115 }
116
117 crate::process_tree::configure_std_command(command);
118 let mut child = spawn_retrying_busy_executable(command)?;
119 let process_tree = match crate::process_tree::ProcessTree::for_std_child(&child) {
120 Ok(process_tree) => Arc::new(process_tree),
121 Err(error) => {
122 terminate_failed_setup(&mut child);
123 return Err(error);
124 }
125 };
126 let id = registry::register_process_tree(Arc::clone(&process_tree));
127 Ok(Self {
128 inner: Some(child),
129 id: Some(id),
130 process_tree: Some(process_tree),
131 inherited_tree_pid: None,
132 })
133 }
134
135 pub fn process_tree_terminator(&self) -> Option<ProcessTreeTerminator> {
138 let direct_pid = self.inner.as_ref().map(Child::id)?;
139 if let Some(process_tree) = self.process_tree.as_ref() {
140 return Some(ProcessTreeTerminator {
141 target: TerminationTarget::ProcessTree(Arc::clone(process_tree)),
142 direct_pid,
143 });
144 }
145 self.inherited_tree_pid.map(|pid| ProcessTreeTerminator {
146 target: TerminationTarget::Process(pid),
147 direct_pid,
148 })
149 }
150
151 pub fn id(&self) -> u32 {
154 self.inner.as_ref().map_or(0, Child::id)
155 }
156
157 pub fn take_stdin(&mut self) -> Option<ChildStdin> {
161 self.inner.as_mut().and_then(|c| c.stdin.take())
162 }
163
164 pub fn take_stdout(&mut self) -> Option<ChildStdout> {
170 self.inner.as_mut().and_then(|c| c.stdout.take())
171 }
172
173 pub fn take_stderr(&mut self) -> Option<ChildStderr> {
176 self.inner.as_mut().and_then(|child| child.stderr.take())
177 }
178
179 #[expect(
189 clippy::expect_used,
190 reason = "ScopedChild owns inner until one terminal wait method consumes it"
191 )]
192 pub fn wait_with_output(mut self) -> io::Result<Output> {
193 let child = self.inner.take().expect("inner already taken");
194 let id = self.id.take();
195 let result = child.wait_with_output();
196 if let Some(id) = id {
197 registry::deregister(id);
198 }
199 result
200 }
201
202 #[expect(
210 clippy::expect_used,
211 reason = "ScopedChild owns inner until one terminal wait method consumes it"
212 )]
213 pub fn wait(mut self) -> io::Result<ExitStatus> {
214 let mut child = self.inner.take().expect("inner already taken");
215 let id = self.id.take();
216 let result = child.wait();
217 if let Some(id) = id {
218 registry::deregister(id);
219 }
220 result
221 }
222}
223
224fn terminate_failed_setup(child: &mut Child) {
225 let _ = crate::process_tree::cleanup_std_child(None, child);
226}
227
228impl Drop for ScopedChild {
229 fn drop(&mut self) {
230 if let Some(mut child) = self.inner.take() {
231 let running = !matches!(child.try_wait(), Ok(Some(_)));
232 if running {
233 let _ = crate::process_tree::cleanup_std_child(
234 self.process_tree.as_deref(),
235 &mut child,
236 );
237 }
238 }
239 if let Some(id) = self.id.take() {
240 registry::deregister(id);
241 }
242 }
243}
244
245pub fn status(command: &mut Command) -> io::Result<ExitStatus> {
247 let scoped = ScopedChild::spawn(command)?;
248 scoped.wait()
249}
250
251pub fn output(command: &mut Command) -> io::Result<Output> {
259 command
260 .stdin(Stdio::null())
261 .stdout(Stdio::piped())
262 .stderr(Stdio::piped());
263 ScopedChild::spawn(command)?.wait_with_output()
264}
265
266#[cfg(test)]
267#[expect(
268 clippy::expect_used,
269 reason = "test setup failures should fail at the exact setup operation"
270)]
271mod tests {
272 use super::*;
273
274 #[test]
275 #[cfg(unix)]
276 fn scoped_child_drop_deregisters() {
277 let mut cmd = Command::new("true");
278 let child = ScopedChild::spawn(&mut cmd).expect("spawn true");
279 let id = child.id.expect("freshly spawned wrapper has an id");
280 assert!(registry::is_registered(id));
281 drop(child);
282 assert!(!registry::is_registered(id));
283 }
284
285 #[test]
286 #[cfg(unix)]
287 fn scoped_child_drop_terminates_and_reaps_a_running_child() {
288 let mut command = Command::new("sleep");
289 command.arg("30");
290 let child = ScopedChild::spawn(&mut command).expect("spawn sleep");
291 let pid = child.id();
292
293 drop(child);
294
295 assert!(
296 !registry::pid_is_alive(pid),
297 "running child {pid} survived ScopedChild::drop"
298 );
299 }
300
301 #[test]
302 #[cfg(unix)]
303 fn scoped_child_wait_deregisters_and_succeeds() {
304 let mut cmd = Command::new("true");
305 let child = ScopedChild::spawn(&mut cmd).expect("spawn true");
306 let id = child.id.expect("freshly spawned wrapper has an id");
307 assert!(registry::is_registered(id));
308 let status = child.wait().expect("wait true");
309 assert!(status.success());
310 assert!(!registry::is_registered(id));
311 }
312
313 #[test]
314 #[cfg(unix)]
315 fn scoped_child_wait_with_output_deregisters_and_collects_stdout() {
316 let mut cmd = Command::new("echo");
317 cmd.arg("hello").stdout(Stdio::piped());
318 let child = ScopedChild::spawn(&mut cmd).expect("spawn echo");
319 let id = child.id.expect("freshly spawned wrapper has an id");
320 assert!(registry::is_registered(id));
321 let output = child.wait_with_output().expect("wait echo");
322 assert!(output.status.success());
323 assert_eq!(output.stdout, b"hello\n");
324 assert!(!registry::is_registered(id));
325 }
326
327 #[test]
328 #[cfg(unix)]
329 fn output_helper_collects_stdout() {
330 let mut cmd = Command::new("echo");
331 cmd.arg("hello")
332 .stdout(Stdio::piped())
333 .stderr(Stdio::piped());
334 let output = output(&mut cmd).expect("echo");
335 assert!(output.status.success());
336 assert_eq!(output.stdout, b"hello\n");
337 }
338
339 #[cfg(any(unix, windows))]
340 #[test]
341 fn nested_managed_process_tree_terminates_inherited_child() {
342 const HELPER_ENV: &str = "FALLOW_NESTED_PROCESS_TREE_TEST";
343 const ROOT_ENV: &str = "FALLOW_NESTED_PROCESS_TREE_ROOT";
344 const TEST_NAME: &str =
345 "scoped_child::tests::nested_managed_process_tree_terminates_inherited_child";
346
347 match std::env::var(HELPER_ENV).ok().as_deref() {
348 Some("nested") => {
349 let root = std::env::var_os(ROOT_ENV).expect("nested helper root");
350 std::fs::write(
351 std::path::Path::new(&root).join("nested.pid"),
352 std::process::id().to_string(),
353 )
354 .expect("write nested PID");
355 std::thread::sleep(std::time::Duration::from_secs(30));
356 return;
357 }
358 Some("outer") => {
359 let root = std::env::var_os(ROOT_ENV).expect("outer helper root");
360 let mut command =
361 Command::new(std::env::current_exe().expect("current test executable"));
362 command
363 .args(["--exact", TEST_NAME, "--nocapture"])
364 .env(HELPER_ENV, "nested")
365 .env(ROOT_ENV, root);
366 let child =
367 ScopedChild::spawn_process_tree(&mut command).expect("spawn nested helper");
368 let _ = child.wait();
369 return;
370 }
371 _ => {}
372 }
373
374 let root = tempfile::tempdir().expect("temporary nested process-tree root");
375 let mut command = Command::new(std::env::current_exe().expect("current test executable"));
376 command
377 .args(["--exact", TEST_NAME, "--nocapture"])
378 .env(HELPER_ENV, "outer")
379 .env(ROOT_ENV, root.path());
380 let outer = ScopedChild::spawn_process_tree(&mut command).expect("spawn outer helper");
381 let terminator = outer
382 .process_tree_terminator()
383 .expect("outer process-tree terminator");
384 let pid_path = root.path().join("nested.pid");
385 let ready_deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
386 while !pid_path.exists() && std::time::Instant::now() < ready_deadline {
387 std::thread::sleep(std::time::Duration::from_millis(20));
388 }
389 let nested_pid = std::fs::read_to_string(&pid_path)
390 .expect("nested PID")
391 .trim()
392 .parse::<u32>()
393 .expect("numeric nested PID");
394
395 terminator.terminate().expect("terminate outer tree");
396 let status = outer.wait().expect("reap outer helper");
397 assert!(!status.success(), "outer helper was not terminated");
398
399 let exit_deadline = std::time::Instant::now() + std::time::Duration::from_secs(3);
400 while registry::pid_is_alive(nested_pid) && std::time::Instant::now() < exit_deadline {
401 std::thread::sleep(std::time::Duration::from_millis(20));
402 }
403 assert!(
404 !registry::pid_is_alive(nested_pid),
405 "nested managed child {nested_pid} survived outer cleanup"
406 );
407 }
408
409 #[cfg(windows)]
410 #[test]
411 fn windows_job_object_terminates_descendants_without_taskkill_lookup() {
412 const HELPER_ENV: &str = "FALLOW_WINDOWS_JOB_OBJECT_TEST_ROOT";
413 const TASKKILL_MARKER_ENV: &str = "FALLOW_FAKE_TASKKILL_MARKER";
414 const TEST_NAME: &str = "scoped_child::tests::windows_job_object_terminates_descendants_without_taskkill_lookup";
415
416 if let Some(root) = std::env::var_os(HELPER_ENV) {
417 run_windows_job_object_helper(std::path::Path::new(&root));
418 return;
419 }
420
421 let root = tempfile::tempdir().expect("temporary Windows Job Object root");
422 compile_fake_taskkill(root.path(), TASKKILL_MARKER_ENV);
423 std::fs::write(
424 root.path().join("descendant.cmd"),
425 "@echo off\r\necho ready>descendant-ready\r\nping.exe -n 30 127.0.0.1 >NUL\r\n",
426 )
427 .expect("write descendant script");
428 std::fs::write(
429 root.path().join("leader.cmd"),
430 "@echo off\r\nstart \"\" /B cmd.exe /D /S /C call descendant.cmd\r\nping.exe -n 30 127.0.0.1 >NUL\r\n",
431 )
432 .expect("write leader script");
433 let mut search_paths = vec![root.path().to_path_buf()];
434 if let Some(path) = std::env::var_os("PATH") {
435 search_paths.extend(std::env::split_paths(&path));
436 }
437 let search_path =
438 std::env::join_paths(search_paths).expect("prepend fake taskkill to PATH");
439
440 let output = Command::new(std::env::current_exe().expect("current test executable"))
441 .args(["--exact", TEST_NAME, "--nocapture"])
442 .current_dir(root.path())
443 .env(HELPER_ENV, root.path())
444 .env(TASKKILL_MARKER_ENV, root.path().join("taskkill-invoked"))
445 .env("PATH", search_path)
446 .env_remove("NoDefaultCurrentDirectoryInExePath")
447 .output()
448 .expect("run Windows Job Object helper");
449
450 assert!(
451 output.status.success(),
452 "helper failed: {}",
453 String::from_utf8_lossy(&output.stderr)
454 );
455 assert!(
456 !root.path().join("taskkill-invoked").exists(),
457 "cleanup executed project-local taskkill"
458 );
459 }
460
461 #[cfg(windows)]
462 fn compile_fake_taskkill(root: &std::path::Path, marker_env: &str) {
463 let source = root.join("fake-taskkill.rs");
464 let executable = root.join("taskkill.exe");
465 std::fs::write(
466 &source,
467 format!(
468 "fn main() {{ let marker = std::env::var_os({marker_env:?}).expect(\"marker path\"); std::fs::write(marker, b\"invoked\").expect(\"write marker\"); }}"
469 ),
470 )
471 .expect("write fake taskkill source");
472 let rustc = std::env::var_os("RUSTC").unwrap_or_else(|| "rustc".into());
473 let output = Command::new(rustc)
474 .args(["--edition=2024", "-o"])
475 .arg(&executable)
476 .arg(&source)
477 .output()
478 .expect("compile fake taskkill executable");
479 assert!(
480 output.status.success(),
481 "fake taskkill compilation failed: {}",
482 String::from_utf8_lossy(&output.stderr)
483 );
484 }
485
486 #[cfg(windows)]
487 fn run_windows_job_object_helper(root: &std::path::Path) {
488 use std::time::{Duration, Instant};
489
490 let mut command = Command::new("cmd.exe");
491 command
492 .args(["/D", "/S", "/C", "call leader.cmd"])
493 .current_dir(root)
494 .stdin(Stdio::null())
495 .stdout(Stdio::null())
496 .stderr(Stdio::null());
497 let child = ScopedChild::spawn_process_tree(&mut command).expect("spawn Windows job tree");
498 let terminator = child
499 .process_tree_terminator()
500 .expect("Windows process-tree terminator");
501 let ready = root.join("descendant-ready");
502 let ready_deadline = Instant::now() + Duration::from_secs(5);
503 while !ready.exists() && Instant::now() < ready_deadline {
504 std::thread::sleep(Duration::from_millis(20));
505 }
506 assert!(ready.exists(), "descendant did not start inside the job");
507
508 let started = Instant::now();
509 terminator
510 .terminate()
511 .expect("terminate Windows Job Object");
512 let status = child.wait().expect("reap Windows job leader");
513 assert!(
514 !status.success(),
515 "terminated job leader exited successfully"
516 );
517 assert!(
518 started.elapsed() < Duration::from_secs(2),
519 "wait remained blocked after Job Object termination"
520 );
521
522 let exit_deadline = Instant::now() + Duration::from_secs(2);
523 while terminator.is_alive() && Instant::now() < exit_deadline {
524 std::thread::sleep(Duration::from_millis(20));
525 }
526 assert!(
527 !terminator.is_alive(),
528 "job descendants survived termination"
529 );
530 }
531}