1use std::cell::RefCell;
33use std::sync::atomic::{AtomicBool, Ordering};
34use std::sync::Arc;
35use std::time::{Duration, Instant};
36
37pub const SUBPROCESS_TERM_GRACE: Duration = Duration::from_secs(2);
42
43#[derive(Clone, Default)]
44struct OpInterrupt {
45 cancel: Option<Arc<AtomicBool>>,
46 deadline: Option<Instant>,
47}
48
49thread_local! {
50 static CURRENT: RefCell<Option<OpInterrupt>> = const { RefCell::new(None) };
51}
52
53pub struct OpInterruptGuard {
57 #[allow(clippy::option_option)]
60 prev: Option<Option<OpInterrupt>>,
61}
62
63impl Drop for OpInterruptGuard {
64 fn drop(&mut self) {
65 if let Some(prev) = self.prev.take() {
66 CURRENT.with(|slot| *slot.borrow_mut() = prev);
67 }
68 }
69}
70
71pub fn install(cancel: Option<Arc<AtomicBool>>, deadline: Option<Instant>) -> OpInterruptGuard {
76 let prev = CURRENT.with(|slot| slot.borrow_mut().replace(OpInterrupt { cancel, deadline }));
77 OpInterruptGuard { prev: Some(prev) }
78}
79
80pub fn installed() -> bool {
85 CURRENT.with(|slot| slot.borrow().is_some())
86}
87
88pub fn requested() -> bool {
92 CURRENT.with(|slot| {
93 let ctx = slot.borrow();
94 let Some(ctx) = ctx.as_ref() else {
95 return false;
96 };
97 if ctx
98 .cancel
99 .as_ref()
100 .is_some_and(|token| token.load(Ordering::SeqCst))
101 {
102 return true;
103 }
104 ctx.deadline
105 .is_some_and(|deadline| Instant::now() >= deadline)
106 })
107}
108
109pub fn configure_kill_group(command: &mut std::process::Command) {
114 #[cfg(unix)]
115 {
116 use std::os::unix::process::CommandExt;
117 command.process_group(0);
118 }
119 #[cfg(not(unix))]
120 {
121 let _ = command;
122 }
123}
124
125pub fn signal_pid_and_group(pid: u32, signal: i32) {
127 #[cfg(unix)]
128 {
129 extern "C" {
132 fn kill(pid: i32, sig: i32) -> i32;
133 }
134 unsafe {
135 kill(-(pid as i32), signal);
136 kill(pid as i32, signal);
137 }
138 }
139 #[cfg(not(unix))]
140 {
141 let _ = (pid, signal);
142 }
143}
144
145pub enum ChildWait {
147 Exited(std::process::ExitStatus),
149 TimedOut,
151 Interrupted(Option<std::process::ExitStatus>),
155}
156
157pub fn wait_child_interruptible(
165 child: &mut std::process::Child,
166 timeout: Option<Duration>,
167) -> std::io::Result<ChildWait> {
168 let deadline = timeout.map(|limit| Instant::now() + limit);
169 loop {
170 if let Some(status) = child.try_wait()? {
171 return Ok(ChildWait::Exited(status));
172 }
173 if requested() {
174 let status = terminate_child_group(child);
175 return Ok(ChildWait::Interrupted(status));
176 }
177 if deadline.is_some_and(|deadline| Instant::now() >= deadline) {
178 if let Some(pid) = child_pid(child) {
180 signal_pid_and_group(pid, 9);
181 }
182 let _ = child.kill();
183 let _ = child.wait();
184 return Ok(ChildWait::TimedOut);
185 }
186 std::thread::sleep(Duration::from_millis(20));
187 }
188}
189
190pub fn terminate_child_group(child: &mut std::process::Child) -> Option<std::process::ExitStatus> {
196 #[cfg(unix)]
197 {
198 if let Some(pid) = child_pid(child) {
199 const SIGTERM: i32 = 15;
200 signal_pid_and_group(pid, SIGTERM);
201 let grace_deadline = Instant::now() + SUBPROCESS_TERM_GRACE;
202 loop {
203 match child.try_wait() {
204 Ok(Some(status)) => {
205 signal_pid_and_group(pid, 9);
208 return Some(status);
209 }
210 Ok(None) => {
211 if Instant::now() >= grace_deadline {
212 break;
213 }
214 std::thread::sleep(Duration::from_millis(20));
215 }
216 Err(_) => break,
217 }
218 }
219 signal_pid_and_group(pid, 9);
220 }
221 }
222 let _ = child.kill();
223 child.wait().ok()
224}
225
226fn child_pid(child: &std::process::Child) -> Option<u32> {
227 let pid = child.id();
228 (pid > 0).then_some(pid)
229}
230
231pub(crate) fn drain_captured_pipe(
241 rx: &std::sync::mpsc::Receiver<Vec<u8>>,
242 killed: bool,
243 child_pid: u32,
244) -> Vec<u8> {
245 use std::sync::mpsc::RecvTimeoutError;
246 if killed {
247 return rx
248 .recv_timeout(Duration::from_millis(100))
249 .unwrap_or_default();
250 }
251 loop {
252 match rx.recv_timeout(Duration::from_millis(20)) {
253 Ok(buf) => return buf,
254 Err(RecvTimeoutError::Disconnected) => return Vec::new(),
255 Err(RecvTimeoutError::Timeout) => {
256 if requested() {
257 const SIGTERM: i32 = 15;
258 signal_pid_and_group(child_pid, SIGTERM);
259 if let Ok(buf) = rx.recv_timeout(SUBPROCESS_TERM_GRACE) {
260 signal_pid_and_group(child_pid, 9);
261 return buf;
262 }
263 signal_pid_and_group(child_pid, 9);
264 return rx
265 .recv_timeout(Duration::from_millis(100))
266 .unwrap_or_default();
267 }
268 }
269 }
270 }
271}
272
273pub(crate) fn spawn_pipe_drain<R: std::io::Read + Send + 'static>(
275 mut reader: R,
276) -> std::sync::mpsc::Receiver<Vec<u8>> {
277 let (tx, rx) = std::sync::mpsc::channel::<Vec<u8>>();
278 std::thread::spawn(move || {
279 let mut buf = Vec::new();
280 let _ = reader.read_to_end(&mut buf);
281 let _ = tx.send(buf);
282 });
283 rx
284}
285
286pub fn capture_output_interruptible(
293 command: &mut std::process::Command,
294) -> std::io::Result<std::process::Output> {
295 use std::process::Stdio;
296 command
297 .stdout(Stdio::piped())
298 .stderr(Stdio::piped())
299 .stdin(Stdio::null());
300 configure_kill_group(command);
301 let mut child = command.spawn()?;
302 let pid = child.id();
303 let rx_out = child.stdout.take().map(spawn_pipe_drain);
304 let rx_err = child.stderr.take().map(spawn_pipe_drain);
305
306 let (status, killed) = match wait_child_interruptible(&mut child, None)? {
307 ChildWait::Exited(status) => (status, false),
308 ChildWait::TimedOut => (std::process::ExitStatus::default(), true),
310 ChildWait::Interrupted(status) => (status.unwrap_or_default(), true),
311 };
312 let stdout = rx_out
313 .map(|rx| drain_captured_pipe(&rx, killed, pid))
314 .unwrap_or_default();
315 let stderr = rx_err
316 .map(|rx| drain_captured_pipe(&rx, killed, pid))
317 .unwrap_or_default();
318 Ok(std::process::Output {
319 status,
320 stdout,
321 stderr,
322 })
323}
324
325#[cfg(test)]
326mod tests {
327 use super::*;
328
329 #[test]
330 fn requested_is_false_without_context() {
331 assert!(!requested());
332 }
333
334 #[test]
335 fn installed_tracks_guard_lifetime() {
336 assert!(!installed());
337 let guard = install(None, None);
338 assert!(installed());
339 drop(guard);
340 assert!(!installed());
341 }
342
343 #[test]
344 fn cancel_token_trips_requested_and_guard_restores() {
345 let token = Arc::new(AtomicBool::new(false));
346 let guard = install(Some(token.clone()), None);
347 assert!(!requested());
348 token.store(true, Ordering::SeqCst);
349 assert!(requested());
350 drop(guard);
351 assert!(!requested());
352 }
353
354 #[test]
355 fn deadline_trips_requested() {
356 let expired = Instant::now()
357 .checked_sub(Duration::from_millis(1))
358 .expect("monotonic clock supports a 1ms test lookback");
359 let _guard = install(None, Some(expired));
360 assert!(requested());
361 }
362
363 #[test]
364 fn nested_installs_restore_in_order() {
365 let outer_token = Arc::new(AtomicBool::new(true));
366 let _outer = install(Some(outer_token), None);
367 assert!(requested());
368 {
369 let _inner = install(None, None);
370 assert!(!requested());
371 }
372 assert!(requested());
373 }
374
375 #[cfg(unix)]
376 #[test]
377 fn interrupted_wait_kills_process_group() {
378 let mut command = std::process::Command::new("sh");
380 command.args(["-c", "sleep 30 & wait"]);
381 configure_kill_group(&mut command);
382 let mut child = command.spawn().expect("spawn sh");
383 let pgid = child.id();
384
385 let cancel = Arc::new(AtomicBool::new(true));
386 let _guard = install(Some(cancel), None);
387 let started = Instant::now();
388 let outcome = wait_child_interruptible(&mut child, None).expect("wait");
389 assert!(matches!(outcome, ChildWait::Interrupted(_)));
390 assert!(started.elapsed() < Duration::from_secs(10));
391
392 extern "C" {
394 fn kill(pid: i32, sig: i32) -> i32;
395 }
396 let group_gone = || unsafe { kill(-(pgid as i32), 0) } != 0;
397 let deadline = Instant::now() + Duration::from_secs(5);
398 while !group_gone() && Instant::now() < deadline {
399 std::thread::sleep(Duration::from_millis(50));
400 }
401 assert!(group_gone(), "process group {pgid} survived interrupt");
402 }
403}