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(
184 clippy::expect_used,
185 reason = "ScopedChild owns inner until one terminal wait method consumes it"
186 )]
187 pub fn wait_with_output(mut self) -> io::Result<Output> {
188 let child = self.inner.take().expect("inner already taken");
189 let id = self.id.take();
190 let result = child.wait_with_output();
191 if let Some(id) = id {
192 registry::deregister(id);
193 }
194 result
195 }
196
197 #[expect(
200 clippy::expect_used,
201 reason = "ScopedChild owns inner until one terminal wait method consumes it"
202 )]
203 pub fn wait(mut self) -> io::Result<ExitStatus> {
204 let mut child = self.inner.take().expect("inner already taken");
205 let id = self.id.take();
206 let result = child.wait();
207 if let Some(id) = id {
208 registry::deregister(id);
209 }
210 result
211 }
212}
213
214fn terminate_failed_setup(child: &mut Child) {
215 let _ = crate::process_tree::cleanup_std_child(None, child);
216}
217
218impl Drop for ScopedChild {
219 fn drop(&mut self) {
220 if let Some(mut child) = self.inner.take() {
221 let running = !matches!(child.try_wait(), Ok(Some(_)));
222 if running {
223 let _ = crate::process_tree::cleanup_std_child(
224 self.process_tree.as_deref(),
225 &mut child,
226 );
227 }
228 }
229 if let Some(id) = self.id.take() {
230 registry::deregister(id);
231 }
232 }
233}
234
235pub fn status(command: &mut Command) -> io::Result<ExitStatus> {
237 let scoped = ScopedChild::spawn(command)?;
238 scoped.wait()
239}
240
241pub fn output(command: &mut Command) -> io::Result<Output> {
249 command
250 .stdin(Stdio::null())
251 .stdout(Stdio::piped())
252 .stderr(Stdio::piped());
253 ScopedChild::spawn(command)?.wait_with_output()
254}
255
256#[cfg(test)]
257#[expect(
258 clippy::expect_used,
259 reason = "test setup failures should fail at the exact setup operation"
260)]
261mod tests {
262 use super::*;
263
264 #[test]
265 #[cfg(unix)]
266 fn scoped_child_drop_deregisters() {
267 let mut cmd = Command::new("true");
268 let child = ScopedChild::spawn(&mut cmd).expect("spawn true");
269 let id = child.id.expect("freshly spawned wrapper has an id");
270 assert!(registry::is_registered(id));
271 drop(child);
272 assert!(!registry::is_registered(id));
273 }
274
275 #[test]
276 #[cfg(unix)]
277 fn scoped_child_drop_terminates_and_reaps_a_running_child() {
278 let mut command = Command::new("sleep");
279 command.arg("30");
280 let child = ScopedChild::spawn(&mut command).expect("spawn sleep");
281 let pid = child.id();
282
283 drop(child);
284
285 assert!(
286 !registry::pid_is_alive(pid),
287 "running child {pid} survived ScopedChild::drop"
288 );
289 }
290
291 #[test]
292 #[cfg(unix)]
293 fn scoped_child_wait_deregisters_and_succeeds() {
294 let mut cmd = Command::new("true");
295 let child = ScopedChild::spawn(&mut cmd).expect("spawn true");
296 let id = child.id.expect("freshly spawned wrapper has an id");
297 assert!(registry::is_registered(id));
298 let status = child.wait().expect("wait true");
299 assert!(status.success());
300 assert!(!registry::is_registered(id));
301 }
302
303 #[test]
304 #[cfg(unix)]
305 fn scoped_child_wait_with_output_deregisters_and_collects_stdout() {
306 let mut cmd = Command::new("echo");
307 cmd.arg("hello").stdout(Stdio::piped());
308 let child = ScopedChild::spawn(&mut cmd).expect("spawn echo");
309 let id = child.id.expect("freshly spawned wrapper has an id");
310 assert!(registry::is_registered(id));
311 let output = child.wait_with_output().expect("wait echo");
312 assert!(output.status.success());
313 assert_eq!(output.stdout, b"hello\n");
314 assert!(!registry::is_registered(id));
315 }
316
317 #[test]
318 #[cfg(unix)]
319 fn output_helper_collects_stdout() {
320 let mut cmd = Command::new("echo");
321 cmd.arg("hello")
322 .stdout(Stdio::piped())
323 .stderr(Stdio::piped());
324 let output = output(&mut cmd).expect("echo");
325 assert!(output.status.success());
326 assert_eq!(output.stdout, b"hello\n");
327 }
328
329 #[cfg(any(unix, windows))]
330 #[test]
331 fn nested_managed_process_tree_terminates_inherited_child() {
332 const HELPER_ENV: &str = "FALLOW_NESTED_PROCESS_TREE_TEST";
333 const ROOT_ENV: &str = "FALLOW_NESTED_PROCESS_TREE_ROOT";
334 const TEST_NAME: &str =
335 "scoped_child::tests::nested_managed_process_tree_terminates_inherited_child";
336
337 match std::env::var(HELPER_ENV).ok().as_deref() {
338 Some("nested") => {
339 let root = std::env::var_os(ROOT_ENV).expect("nested helper root");
340 std::fs::write(
341 std::path::Path::new(&root).join("nested.pid"),
342 std::process::id().to_string(),
343 )
344 .expect("write nested PID");
345 std::thread::sleep(std::time::Duration::from_secs(30));
346 return;
347 }
348 Some("outer") => {
349 let root = std::env::var_os(ROOT_ENV).expect("outer helper root");
350 let mut command =
351 Command::new(std::env::current_exe().expect("current test executable"));
352 command
353 .args(["--exact", TEST_NAME, "--nocapture"])
354 .env(HELPER_ENV, "nested")
355 .env(ROOT_ENV, root);
356 let child =
357 ScopedChild::spawn_process_tree(&mut command).expect("spawn nested helper");
358 let _ = child.wait();
359 return;
360 }
361 _ => {}
362 }
363
364 let root = tempfile::tempdir().expect("temporary nested process-tree root");
365 let mut command = Command::new(std::env::current_exe().expect("current test executable"));
366 command
367 .args(["--exact", TEST_NAME, "--nocapture"])
368 .env(HELPER_ENV, "outer")
369 .env(ROOT_ENV, root.path());
370 let outer = ScopedChild::spawn_process_tree(&mut command).expect("spawn outer helper");
371 let terminator = outer
372 .process_tree_terminator()
373 .expect("outer process-tree terminator");
374 let pid_path = root.path().join("nested.pid");
375 let ready_deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
376 while !pid_path.exists() && std::time::Instant::now() < ready_deadline {
377 std::thread::sleep(std::time::Duration::from_millis(20));
378 }
379 let nested_pid = std::fs::read_to_string(&pid_path)
380 .expect("nested PID")
381 .trim()
382 .parse::<u32>()
383 .expect("numeric nested PID");
384
385 terminator.terminate().expect("terminate outer tree");
386 let status = outer.wait().expect("reap outer helper");
387 assert!(!status.success(), "outer helper was not terminated");
388
389 let exit_deadline = std::time::Instant::now() + std::time::Duration::from_secs(3);
390 while registry::pid_is_alive(nested_pid) && std::time::Instant::now() < exit_deadline {
391 std::thread::sleep(std::time::Duration::from_millis(20));
392 }
393 assert!(
394 !registry::pid_is_alive(nested_pid),
395 "nested managed child {nested_pid} survived outer cleanup"
396 );
397 }
398
399 #[cfg(windows)]
400 #[test]
401 fn windows_job_object_terminates_descendants_without_taskkill_lookup() {
402 const HELPER_ENV: &str = "FALLOW_WINDOWS_JOB_OBJECT_TEST_ROOT";
403 const TASKKILL_MARKER_ENV: &str = "FALLOW_FAKE_TASKKILL_MARKER";
404 const TEST_NAME: &str = "scoped_child::tests::windows_job_object_terminates_descendants_without_taskkill_lookup";
405
406 if let Some(root) = std::env::var_os(HELPER_ENV) {
407 run_windows_job_object_helper(std::path::Path::new(&root));
408 return;
409 }
410
411 let root = tempfile::tempdir().expect("temporary Windows Job Object root");
412 compile_fake_taskkill(root.path(), TASKKILL_MARKER_ENV);
413 std::fs::write(
414 root.path().join("descendant.cmd"),
415 "@echo off\r\necho ready>descendant-ready\r\nping.exe -n 30 127.0.0.1 >NUL\r\n",
416 )
417 .expect("write descendant script");
418 std::fs::write(
419 root.path().join("leader.cmd"),
420 "@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",
421 )
422 .expect("write leader script");
423 let mut search_paths = vec![root.path().to_path_buf()];
424 if let Some(path) = std::env::var_os("PATH") {
425 search_paths.extend(std::env::split_paths(&path));
426 }
427 let search_path =
428 std::env::join_paths(search_paths).expect("prepend fake taskkill to PATH");
429
430 let output = Command::new(std::env::current_exe().expect("current test executable"))
431 .args(["--exact", TEST_NAME, "--nocapture"])
432 .current_dir(root.path())
433 .env(HELPER_ENV, root.path())
434 .env(TASKKILL_MARKER_ENV, root.path().join("taskkill-invoked"))
435 .env("PATH", search_path)
436 .env_remove("NoDefaultCurrentDirectoryInExePath")
437 .output()
438 .expect("run Windows Job Object helper");
439
440 assert!(
441 output.status.success(),
442 "helper failed: {}",
443 String::from_utf8_lossy(&output.stderr)
444 );
445 assert!(
446 !root.path().join("taskkill-invoked").exists(),
447 "cleanup executed project-local taskkill"
448 );
449 }
450
451 #[cfg(windows)]
452 fn compile_fake_taskkill(root: &std::path::Path, marker_env: &str) {
453 let source = root.join("fake-taskkill.rs");
454 let executable = root.join("taskkill.exe");
455 std::fs::write(
456 &source,
457 format!(
458 "fn main() {{ let marker = std::env::var_os({marker_env:?}).expect(\"marker path\"); std::fs::write(marker, b\"invoked\").expect(\"write marker\"); }}"
459 ),
460 )
461 .expect("write fake taskkill source");
462 let rustc = std::env::var_os("RUSTC").unwrap_or_else(|| "rustc".into());
463 let output = Command::new(rustc)
464 .args(["--edition=2024", "-o"])
465 .arg(&executable)
466 .arg(&source)
467 .output()
468 .expect("compile fake taskkill executable");
469 assert!(
470 output.status.success(),
471 "fake taskkill compilation failed: {}",
472 String::from_utf8_lossy(&output.stderr)
473 );
474 }
475
476 #[cfg(windows)]
477 fn run_windows_job_object_helper(root: &std::path::Path) {
478 use std::time::{Duration, Instant};
479
480 let mut command = Command::new("cmd.exe");
481 command
482 .args(["/D", "/S", "/C", "call leader.cmd"])
483 .current_dir(root)
484 .stdin(Stdio::null())
485 .stdout(Stdio::null())
486 .stderr(Stdio::null());
487 let child = ScopedChild::spawn_process_tree(&mut command).expect("spawn Windows job tree");
488 let terminator = child
489 .process_tree_terminator()
490 .expect("Windows process-tree terminator");
491 let ready = root.join("descendant-ready");
492 let ready_deadline = Instant::now() + Duration::from_secs(5);
493 while !ready.exists() && Instant::now() < ready_deadline {
494 std::thread::sleep(Duration::from_millis(20));
495 }
496 assert!(ready.exists(), "descendant did not start inside the job");
497
498 let started = Instant::now();
499 terminator
500 .terminate()
501 .expect("terminate Windows Job Object");
502 let status = child.wait().expect("reap Windows job leader");
503 assert!(
504 !status.success(),
505 "terminated job leader exited successfully"
506 );
507 assert!(
508 started.elapsed() < Duration::from_secs(2),
509 "wait remained blocked after Job Object termination"
510 );
511
512 let exit_deadline = Instant::now() + Duration::from_secs(2);
513 while terminator.is_alive() && Instant::now() < exit_deadline {
514 std::thread::sleep(Duration::from_millis(20));
515 }
516 assert!(
517 !terminator.is_alive(),
518 "job descendants survived termination"
519 );
520 }
521}