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