1use anyhow::Result;
2#[allow(clippy::disallowed_types, clippy::disallowed_methods)]
3use std::process::{Child, ExitStatus};
4
5use crate::contract::BackgroundHandle;
6
7#[derive(Debug)]
8#[allow(clippy::disallowed_types, clippy::disallowed_methods)]
9pub struct ChildHandle {
10 pub(crate) child: Child,
11 pub(crate) io_threads: Vec<std::thread::JoinHandle<()>>,
12 reaped: bool,
13}
14
15impl ChildHandle {
16 #[allow(clippy::disallowed_types, clippy::disallowed_methods)]
17 pub(crate) fn new(child: Child, io_threads: Vec<std::thread::JoinHandle<()>>) -> Self {
18 Self {
19 child,
20 io_threads,
21 reaped: false,
22 }
23 }
24}
25
26impl BackgroundHandle for ChildHandle {
27 fn try_wait(&mut self) -> Result<Option<ExitStatus>> {
28 let res = self.child.try_wait()?;
29 if res.is_some() {
32 self.reaped = true;
33 }
34 Ok(res)
35 }
36
37 fn wait(&mut self) -> Result<ExitStatus> {
38 let status = self.child.wait()?;
39 for thread in self.io_threads.drain(..) {
41 let _ = thread.join();
42 }
43 self.reaped = true;
44 Ok(status)
45 }
46
47 fn kill(&mut self) -> Result<()> {
48 if self.child.try_wait()?.is_none() {
51 let _ = self.child.kill();
52 }
53 Ok(())
54 }
55}
56
57impl Drop for ChildHandle {
58 fn drop(&mut self) {
59 if self.reaped {
63 return;
64 }
65 if matches!(self.child.try_wait(), Ok(None)) {
66 let _ = self.child.kill();
67 let _ = self.child.wait();
69 }
70 }
74}
75
76#[cfg(test)]
77mod tests {
78 use super::*;
79 use crate::contract::{
80 CommandContext, CommandMode, CommandOptions, CommandResult, CommandStdout, ProcessManager,
81 };
82 use crate::shell_manager::ShellProcessManager;
83 use oxdock_fs::{GuardedPath, PolicyPath};
84 use std::collections::HashMap;
85
86 fn make_ctx() -> (oxdock_fs::GuardedTempDir, CommandContext) {
87 let temp = GuardedPath::tempdir().expect("tempdir");
88 let guard = temp.as_guarded_path().clone();
89 let cwd: PolicyPath = guard.clone().into();
90 let map: HashMap<String, String> = HashMap::new();
91 let ctx = CommandContext::from_map(&cwd, &map, &guard, &guard, &guard);
92 (temp, ctx)
93 }
94
95 #[cfg(unix)]
96 #[cfg_attr(
97 miri,
98 ignore = "spawns processes; Miri does not support process execution"
99 )]
100 #[test]
101 fn drop_kills_spawned_child_before_it_can_finish() {
102 use crate::builder::CommandBuilder;
103 use oxdock_fs::PathResolver;
104
105 let temp = oxdock_fs::GuardedPath::tempdir().expect("tempdir");
106 let root = temp.as_guarded_path().clone();
107 let marker = root.join("late.txt").expect("marker path");
108 let marker_display = marker.display().to_string();
109
110 let resolver = PathResolver::new_guarded(root.clone(), root.clone()).expect("resolver");
111 let mut builder = CommandBuilder::new("sh");
112 builder.args(["-c", &format!("sleep 1; echo done > {marker_display}")]);
113 let handle = builder.spawn().expect("spawn");
114 drop(handle);
117
118 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(3);
119 while std::time::Instant::now() < deadline {
120 assert!(
121 resolver.read_file(&marker).is_err(),
122 "child must be killed by Drop before writing {marker_display}"
123 );
124 std::thread::sleep(std::time::Duration::from_millis(50));
125 }
126 }
127
128 fn long_running_script() -> &'static str {
129 #[cfg(windows)]
130 {
131 "ping -n 30 127.0.0.1 >NUL"
132 }
133 #[cfg(not(windows))]
134 {
135 "sleep 30"
136 }
137 }
138
139 #[cfg_attr(
140 miri,
141 ignore = "spawns processes; Miri does not support process execution"
142 )]
143 #[test]
144 fn child_handle_background_lifecycle_polls_then_waits() {
145 let (_temp, ctx) = make_ctx();
146 let mut pm = ShellProcessManager;
147 let options = CommandOptions {
148 mode: CommandMode::Background,
149 ..Default::default()
150 };
151 let mut handle = match pm.run_command(&ctx, "exit 0", options).expect("run") {
152 CommandResult::Background(handle) => handle,
153 CommandResult::Completed => panic!("expected Background, got Completed"),
154 CommandResult::Captured(_) => panic!("expected Background, got Captured"),
155 };
156
157 let mut status = None;
158 for _ in 0..500 {
159 if let Some(done) = handle.try_wait().expect("try_wait") {
160 status = Some(done);
161 break;
162 }
163 std::thread::sleep(std::time::Duration::from_millis(10));
164 }
165 let status = status.expect("child should exit within polling window");
166 assert!(status.success());
167
168 let waited = handle.wait().expect("wait");
169 assert!(waited.success());
170 }
171
172 #[cfg_attr(
173 miri,
174 ignore = "spawns processes; Miri does not support process execution"
175 )]
176 #[test]
177 fn child_handle_kill_is_idempotent_and_wait_joins_threads() {
178 let (_temp, ctx) = make_ctx();
179 let mut pm = ShellProcessManager;
180 let options = CommandOptions {
181 mode: CommandMode::Background,
182 stdout: CommandStdout::Stream(std::sync::Arc::new(std::sync::Mutex::new(
183 Vec::<u8>::new(),
184 ))),
185 ..Default::default()
186 };
187 let mut handle = match pm
188 .run_command(&ctx, long_running_script(), options)
189 .expect("run")
190 {
191 CommandResult::Background(handle) => handle,
192 CommandResult::Completed => panic!("expected Background, got Completed"),
193 CommandResult::Captured(_) => panic!("expected Background, got Captured"),
194 };
195
196 handle.kill().expect("first kill");
197 handle.kill().expect("second kill must be idempotent");
198 let _status = handle.wait().expect("wait after kill");
199 }
200}