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 #[cfg(unix)]
265 fn assert_deregistered(id: u64) {
266 registry::deregister(id);
267 }
268
269 #[test]
270 #[cfg(unix)]
271 fn scoped_child_drop_deregisters() {
272 let mut cmd = Command::new("true");
273 let child = ScopedChild::spawn(&mut cmd).expect("spawn true");
274 let id = child.id.expect("freshly spawned wrapper has an id");
275 assert!(id > 0);
276 drop(child);
277 assert_deregistered(id);
278 }
279
280 #[test]
281 #[cfg(unix)]
282 fn scoped_child_drop_terminates_and_reaps_a_running_child() {
283 let mut command = Command::new("sleep");
284 command.arg("30");
285 let child = ScopedChild::spawn(&mut command).expect("spawn sleep");
286 let pid = child.id();
287
288 drop(child);
289
290 assert!(
291 !registry::pid_is_alive(pid),
292 "running child {pid} survived ScopedChild::drop"
293 );
294 }
295
296 #[test]
297 #[cfg(unix)]
298 fn scoped_child_wait_deregisters_and_succeeds() {
299 let mut cmd = Command::new("true");
300 let child = ScopedChild::spawn(&mut cmd).expect("spawn true");
301 let id = child.id.expect("freshly spawned wrapper has an id");
302 let status = child.wait().expect("wait true");
303 assert!(status.success());
304 assert_deregistered(id);
305 }
306
307 #[test]
308 #[cfg(unix)]
309 fn output_helper_collects_stdout() {
310 let mut cmd = Command::new("echo");
311 cmd.arg("hello")
312 .stdout(Stdio::piped())
313 .stderr(Stdio::piped());
314 let output = output(&mut cmd).expect("echo");
315 assert!(output.status.success());
316 assert_eq!(output.stdout, b"hello\n");
317 }
318
319 #[cfg(any(unix, windows))]
320 #[test]
321 fn nested_managed_process_tree_terminates_inherited_child() {
322 const HELPER_ENV: &str = "FALLOW_NESTED_PROCESS_TREE_TEST";
323 const ROOT_ENV: &str = "FALLOW_NESTED_PROCESS_TREE_ROOT";
324 const TEST_NAME: &str =
325 "scoped_child::tests::nested_managed_process_tree_terminates_inherited_child";
326
327 match std::env::var(HELPER_ENV).ok().as_deref() {
328 Some("nested") => {
329 let root = std::env::var_os(ROOT_ENV).expect("nested helper root");
330 std::fs::write(
331 std::path::Path::new(&root).join("nested.pid"),
332 std::process::id().to_string(),
333 )
334 .expect("write nested PID");
335 std::thread::sleep(std::time::Duration::from_secs(30));
336 return;
337 }
338 Some("outer") => {
339 let root = std::env::var_os(ROOT_ENV).expect("outer helper root");
340 let mut command =
341 Command::new(std::env::current_exe().expect("current test executable"));
342 command
343 .args(["--exact", TEST_NAME, "--nocapture"])
344 .env(HELPER_ENV, "nested")
345 .env(ROOT_ENV, root);
346 let child =
347 ScopedChild::spawn_process_tree(&mut command).expect("spawn nested helper");
348 let _ = child.wait();
349 return;
350 }
351 _ => {}
352 }
353
354 let root = tempfile::tempdir().expect("temporary nested process-tree root");
355 let mut command = Command::new(std::env::current_exe().expect("current test executable"));
356 command
357 .args(["--exact", TEST_NAME, "--nocapture"])
358 .env(HELPER_ENV, "outer")
359 .env(ROOT_ENV, root.path());
360 let outer = ScopedChild::spawn_process_tree(&mut command).expect("spawn outer helper");
361 let terminator = outer
362 .process_tree_terminator()
363 .expect("outer process-tree terminator");
364 let pid_path = root.path().join("nested.pid");
365 let ready_deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
366 while !pid_path.exists() && std::time::Instant::now() < ready_deadline {
367 std::thread::sleep(std::time::Duration::from_millis(20));
368 }
369 let nested_pid = std::fs::read_to_string(&pid_path)
370 .expect("nested PID")
371 .trim()
372 .parse::<u32>()
373 .expect("numeric nested PID");
374
375 terminator.terminate().expect("terminate outer tree");
376 let status = outer.wait().expect("reap outer helper");
377 assert!(!status.success(), "outer helper was not terminated");
378
379 let exit_deadline = std::time::Instant::now() + std::time::Duration::from_secs(3);
380 while registry::pid_is_alive(nested_pid) && std::time::Instant::now() < exit_deadline {
381 std::thread::sleep(std::time::Duration::from_millis(20));
382 }
383 assert!(
384 !registry::pid_is_alive(nested_pid),
385 "nested managed child {nested_pid} survived outer cleanup"
386 );
387 }
388
389 #[cfg(windows)]
390 #[test]
391 fn windows_job_object_terminates_descendants_without_taskkill_lookup() {
392 const HELPER_ENV: &str = "FALLOW_WINDOWS_JOB_OBJECT_TEST_ROOT";
393 const TASKKILL_MARKER_ENV: &str = "FALLOW_FAKE_TASKKILL_MARKER";
394 const TEST_NAME: &str = "scoped_child::tests::windows_job_object_terminates_descendants_without_taskkill_lookup";
395
396 if let Some(root) = std::env::var_os(HELPER_ENV) {
397 run_windows_job_object_helper(std::path::Path::new(&root));
398 return;
399 }
400
401 let root = tempfile::tempdir().expect("temporary Windows Job Object root");
402 compile_fake_taskkill(root.path(), TASKKILL_MARKER_ENV);
403 std::fs::write(
404 root.path().join("descendant.cmd"),
405 "@echo off\r\necho ready>descendant-ready\r\nping.exe -n 30 127.0.0.1 >NUL\r\n",
406 )
407 .expect("write descendant script");
408 std::fs::write(
409 root.path().join("leader.cmd"),
410 "@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",
411 )
412 .expect("write leader script");
413 let mut search_paths = vec![root.path().to_path_buf()];
414 if let Some(path) = std::env::var_os("PATH") {
415 search_paths.extend(std::env::split_paths(&path));
416 }
417 let search_path =
418 std::env::join_paths(search_paths).expect("prepend fake taskkill to PATH");
419
420 let output = Command::new(std::env::current_exe().expect("current test executable"))
421 .args(["--exact", TEST_NAME, "--nocapture"])
422 .current_dir(root.path())
423 .env(HELPER_ENV, root.path())
424 .env(TASKKILL_MARKER_ENV, root.path().join("taskkill-invoked"))
425 .env("PATH", search_path)
426 .env_remove("NoDefaultCurrentDirectoryInExePath")
427 .output()
428 .expect("run Windows Job Object helper");
429
430 assert!(
431 output.status.success(),
432 "helper failed: {}",
433 String::from_utf8_lossy(&output.stderr)
434 );
435 assert!(
436 !root.path().join("taskkill-invoked").exists(),
437 "cleanup executed project-local taskkill"
438 );
439 }
440
441 #[cfg(windows)]
442 fn compile_fake_taskkill(root: &std::path::Path, marker_env: &str) {
443 let source = root.join("fake-taskkill.rs");
444 let executable = root.join("taskkill.exe");
445 std::fs::write(
446 &source,
447 format!(
448 "fn main() {{ let marker = std::env::var_os({marker_env:?}).expect(\"marker path\"); std::fs::write(marker, b\"invoked\").expect(\"write marker\"); }}"
449 ),
450 )
451 .expect("write fake taskkill source");
452 let rustc = std::env::var_os("RUSTC").unwrap_or_else(|| "rustc".into());
453 let output = Command::new(rustc)
454 .args(["--edition=2024", "-o"])
455 .arg(&executable)
456 .arg(&source)
457 .output()
458 .expect("compile fake taskkill executable");
459 assert!(
460 output.status.success(),
461 "fake taskkill compilation failed: {}",
462 String::from_utf8_lossy(&output.stderr)
463 );
464 }
465
466 #[cfg(windows)]
467 fn run_windows_job_object_helper(root: &std::path::Path) {
468 use std::time::{Duration, Instant};
469
470 let mut command = Command::new("cmd.exe");
471 command
472 .args(["/D", "/S", "/C", "call leader.cmd"])
473 .current_dir(root)
474 .stdin(Stdio::null())
475 .stdout(Stdio::null())
476 .stderr(Stdio::null());
477 let child = ScopedChild::spawn_process_tree(&mut command).expect("spawn Windows job tree");
478 let terminator = child
479 .process_tree_terminator()
480 .expect("Windows process-tree terminator");
481 let ready = root.join("descendant-ready");
482 let ready_deadline = Instant::now() + Duration::from_secs(5);
483 while !ready.exists() && Instant::now() < ready_deadline {
484 std::thread::sleep(Duration::from_millis(20));
485 }
486 assert!(ready.exists(), "descendant did not start inside the job");
487
488 let started = Instant::now();
489 terminator
490 .terminate()
491 .expect("terminate Windows Job Object");
492 let status = child.wait().expect("reap Windows job leader");
493 assert!(
494 !status.success(),
495 "terminated job leader exited successfully"
496 );
497 assert!(
498 started.elapsed() < Duration::from_secs(2),
499 "wait remained blocked after Job Object termination"
500 );
501
502 let exit_deadline = Instant::now() + Duration::from_secs(2);
503 while terminator.is_alive() && Instant::now() < exit_deadline {
504 std::thread::sleep(Duration::from_millis(20));
505 }
506 assert!(
507 !terminator.is_alive(),
508 "job descendants survived termination"
509 );
510 }
511}