1use serde::{Deserialize, Serialize};
10use std::io::{Read, Write};
11use std::os::unix::process::CommandExt;
12use std::path::Path;
13use std::process::{Command, ExitStatus, Stdio};
14use std::time::Duration;
15use wait_timeout::ChildExt;
16
17use crate::operation_bound::{OperationBound, Remaining, duration_millis};
18
19pub const REMOVAL_TIMEOUT: Duration = Duration::from_secs(30);
23const INTERRUPT_POLL_INTERVAL: Duration = Duration::from_millis(50);
24const COMMAND_REAP_GRACE: Duration = Duration::from_secs(5);
25const COMMAND_IO_DRAIN_GRACE: Duration = Duration::from_secs(5);
26
27pub fn docker_device_args(spec: &str) -> [String; 2] {
32 ["--gpus".to_owned(), format!("\"device={spec}\"")]
33}
34
35pub enum BoundedWait {
37 Exited {
38 status: ExitStatus,
39 stdout: Vec<u8>,
40 stderr: Vec<u8>,
41 },
42 Expired {
46 kill: std::io::Result<()>,
47 operation_elapsed_ms: u64,
48 cleanup: Option<CommandCleanupEvidence>,
49 },
50 Interrupted {
53 kill: std::io::Result<()>,
54 operation_elapsed_ms: u64,
55 cleanup: CommandCleanupEvidence,
56 },
57}
58
59#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
60#[serde(deny_unknown_fields)]
61pub struct CommandCleanupEvidence {
62 pub trigger: CommandCleanupTrigger,
63 pub elapsed_ms: u64,
64 pub reap_grace_ms: u64,
65 pub io_drain_grace_ms: u64,
66 pub kill_attempted: bool,
67 pub verified: bool,
68 pub error: Option<String>,
69}
70
71#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
72#[serde(rename_all = "snake_case")]
73pub enum CommandCleanupTrigger {
74 Deadline,
75 Interruption,
76 WaitFailure,
77}
78
79pub enum BoundedError {
81 Launch(std::io::Error),
82 Stdin(std::io::Error),
83 Wait(std::io::Error),
84 WaitCleanup {
85 source: std::io::Error,
86 operation_elapsed_ms: u64,
87 cleanup: CommandCleanupEvidence,
88 },
89}
90
91pub fn run_with_bound<S: AsRef<std::ffi::OsStr>>(
97 argv: &[S],
98 cwd: Option<&Path>,
99 stdin_payload: Option<&[u8]>,
100 bound: &OperationBound,
101 attempt_cap: Option<Duration>,
102) -> Result<BoundedWait, BoundedError> {
103 run_with_bound_mode(
104 argv,
105 cwd,
106 stdin_payload,
107 bound,
108 attempt_cap,
109 true,
110 crate::interrupt::received,
111 )
112}
113
114pub fn run_cleanup_with_bound<S: AsRef<std::ffi::OsStr>>(
115 argv: &[S],
116 cwd: Option<&Path>,
117 stdin_payload: Option<&[u8]>,
118 bound: &OperationBound,
119 attempt_cap: Option<Duration>,
120) -> Result<BoundedWait, BoundedError> {
121 run_with_bound_mode(
122 argv,
123 cwd,
124 stdin_payload,
125 bound,
126 attempt_cap,
127 false,
128 crate::interrupt::received,
129 )
130}
131
132fn run_with_bound_mode<S: AsRef<std::ffi::OsStr>, F: FnMut() -> bool>(
133 argv: &[S],
134 cwd: Option<&Path>,
135 stdin_payload: Option<&[u8]>,
136 bound: &OperationBound,
137 attempt_cap: Option<Duration>,
138 interruptible: bool,
139 mut interrupted: F,
140) -> Result<BoundedWait, BoundedError> {
141 let attempt = bound.attempt(attempt_cap);
142 if matches!(attempt.remaining(), Remaining::Expired) {
143 return Ok(BoundedWait::Expired {
144 kill: Ok(()),
145 operation_elapsed_ms: bound.elapsed_ms(),
146 cleanup: None,
147 });
148 }
149 let Some(program) = argv.first() else {
150 return Err(BoundedError::Launch(std::io::Error::new(
151 std::io::ErrorKind::InvalidInput,
152 "external command argv is empty",
153 )));
154 };
155 let mut command = Command::new(program);
156 command
157 .args(&argv[1..])
158 .stdin(if stdin_payload.is_some() {
159 Stdio::piped()
160 } else {
161 Stdio::null()
162 })
163 .stdout(Stdio::piped())
164 .stderr(Stdio::piped())
165 .process_group(0);
166 if let Some(cwd) = cwd {
167 command.current_dir(cwd);
168 }
169 let mut child = command.spawn().map_err(BoundedError::Launch)?;
170 let stdin_write = if let Some(payload) = stdin_payload {
171 let mut stdin = child
172 .stdin
173 .take()
174 .ok_or_else(|| BoundedError::Stdin(std::io::Error::other("stdin was not piped")))?;
175 let payload = payload.to_owned();
176 Some(std::thread::spawn(move || {
177 stdin.write_all(&payload).and_then(|()| stdin.flush())
178 }))
179 } else {
180 None
181 };
182 let mut stdout_pipe = child.stdout.take();
183 let stdout_drain = std::thread::spawn(move || {
184 let mut buffer = Vec::new();
185 if let Some(pipe) = stdout_pipe.as_mut() {
186 let _ = pipe.read_to_end(&mut buffer);
187 }
188 buffer
189 });
190 let mut stderr_pipe = child.stderr.take();
191 let stderr_drain = std::thread::spawn(move || {
192 let mut buffer = Vec::new();
193 if let Some(pipe) = stderr_pipe.as_mut() {
194 let _ = pipe.read_to_end(&mut buffer);
195 }
196 buffer
197 });
198 let status = loop {
199 if interruptible && interrupted() {
200 let operation_elapsed_ms = bound.elapsed_ms();
201 let (kill, cleanup) = terminate_group_and_finish_io(
202 &mut child,
203 stdin_write,
204 stdout_drain,
205 stderr_drain,
206 CommandCleanupTrigger::Interruption,
207 );
208 return Ok(BoundedWait::Interrupted {
209 kill,
210 operation_elapsed_ms,
211 cleanup,
212 });
213 }
214 let Some(wait) = wait_slice(&attempt) else {
215 let operation_elapsed_ms = bound.elapsed_ms();
216 let (kill, cleanup) = terminate_group_and_finish_io(
217 &mut child,
218 stdin_write,
219 stdout_drain,
220 stderr_drain,
221 CommandCleanupTrigger::Deadline,
222 );
223 return Ok(BoundedWait::Expired {
224 kill,
225 operation_elapsed_ms,
226 cleanup: Some(cleanup),
227 });
228 };
229 match child.wait_timeout(wait) {
230 Ok(Some(status)) => break status,
231 Ok(None) => {}
232 Err(error) => {
233 let operation_elapsed_ms = bound.elapsed_ms();
234 let (_, cleanup) = terminate_group_and_finish_io(
235 &mut child,
236 stdin_write,
237 stdout_drain,
238 stderr_drain,
239 CommandCleanupTrigger::WaitFailure,
240 );
241 return Err(BoundedError::WaitCleanup {
242 source: error,
243 operation_elapsed_ms,
244 cleanup,
245 });
246 }
247 }
248 };
249 while !io_finished(&stdin_write, &stdout_drain, &stderr_drain) {
250 if interruptible && interrupted() {
251 let operation_elapsed_ms = bound.elapsed_ms();
252 let (kill, cleanup) = terminate_group_and_finish_io(
253 &mut child,
254 stdin_write,
255 stdout_drain,
256 stderr_drain,
257 CommandCleanupTrigger::Interruption,
258 );
259 return Ok(BoundedWait::Interrupted {
260 kill,
261 operation_elapsed_ms,
262 cleanup,
263 });
264 }
265 let Some(wait) = wait_slice(&attempt) else {
266 let operation_elapsed_ms = bound.elapsed_ms();
267 let (kill, cleanup) = terminate_group_and_finish_io(
268 &mut child,
269 stdin_write,
270 stdout_drain,
271 stderr_drain,
272 CommandCleanupTrigger::Deadline,
273 );
274 return Ok(BoundedWait::Expired {
275 kill,
276 operation_elapsed_ms,
277 cleanup: Some(cleanup),
278 });
279 };
280 std::thread::sleep(wait);
281 }
282 let stdin_result = join_writer_result(stdin_write);
283 let stdout = join_drain(stdout_drain);
284 let stderr = join_drain(stderr_drain);
285 stdin_result.map_err(BoundedError::Stdin)?;
286 let stdout = stdout?;
287 let stderr = stderr?;
288 Ok(BoundedWait::Exited {
289 status,
290 stdout,
291 stderr,
292 })
293}
294
295fn wait_slice(attempt: &crate::operation_bound::AttemptBound) -> Option<Duration> {
296 match attempt.remaining() {
297 Remaining::Finite(remaining) => Some(remaining.min(INTERRUPT_POLL_INTERVAL)),
298 Remaining::Expired => None,
299 Remaining::Unbounded => Some(INTERRUPT_POLL_INTERVAL),
300 }
301}
302
303fn io_finished(
304 writer: &Option<std::thread::JoinHandle<std::io::Result<()>>>,
305 stdout: &std::thread::JoinHandle<Vec<u8>>,
306 stderr: &std::thread::JoinHandle<Vec<u8>>,
307) -> bool {
308 writer.as_ref().is_none_or(|writer| writer.is_finished())
309 && stdout.is_finished()
310 && stderr.is_finished()
311}
312
313fn terminate_group_and_finish_io(
314 child: &mut std::process::Child,
315 writer: Option<std::thread::JoinHandle<std::io::Result<()>>>,
316 stdout: std::thread::JoinHandle<Vec<u8>>,
317 stderr: std::thread::JoinHandle<Vec<u8>>,
318 trigger: CommandCleanupTrigger,
319) -> (std::io::Result<()>, CommandCleanupEvidence) {
320 let started = std::time::Instant::now();
321 let kill = terminate_process_group(child);
322 let result = match kill {
323 Ok(()) => finish_io_with_grace(writer, stdout, stderr),
324 Err(error) => Err(error),
325 };
326 let evidence = CommandCleanupEvidence {
327 trigger,
328 elapsed_ms: duration_millis(started.elapsed()),
329 reap_grace_ms: duration_millis(COMMAND_REAP_GRACE),
330 io_drain_grace_ms: duration_millis(COMMAND_IO_DRAIN_GRACE),
331 kill_attempted: true,
332 verified: result.is_ok(),
333 error: result.as_ref().err().map(ToString::to_string),
334 };
335 (result, evidence)
336}
337
338fn terminate_process_group(child: &mut std::process::Child) -> std::io::Result<()> {
339 let pid = rustix::process::Pid::from_raw(child.id() as i32)
340 .ok_or_else(|| std::io::Error::other("child process id is zero"))?;
341 let signal = rustix::process::kill_process_group(pid, rustix::process::Signal::KILL);
342 if signal.is_err() && child.try_wait()?.is_none() {
343 child.kill()?;
344 }
345 match child.wait_timeout(COMMAND_REAP_GRACE)? {
346 Some(_) => Ok(()),
347 None => Err(std::io::Error::new(
348 std::io::ErrorKind::TimedOut,
349 format!(
350 "command child did not reap within {} seconds",
351 COMMAND_REAP_GRACE.as_secs()
352 ),
353 )),
354 }
355}
356
357fn finish_io_with_grace(
358 writer: Option<std::thread::JoinHandle<std::io::Result<()>>>,
359 stdout: std::thread::JoinHandle<Vec<u8>>,
360 stderr: std::thread::JoinHandle<Vec<u8>>,
361) -> std::io::Result<()> {
362 let started = std::time::Instant::now();
363 while !io_finished(&writer, &stdout, &stderr) {
364 if started.elapsed() >= COMMAND_IO_DRAIN_GRACE {
365 return Err(std::io::Error::new(
366 std::io::ErrorKind::TimedOut,
367 format!(
368 "command I/O did not drain within {} seconds after termination",
369 COMMAND_IO_DRAIN_GRACE.as_secs()
370 ),
371 ));
372 }
373 std::thread::sleep(INTERRUPT_POLL_INTERVAL);
374 }
375 join_writer_completion(writer)?;
381 let _ = join_drain(stdout).map_err(bounded_error_into_io)?;
382 let _ = join_drain(stderr).map_err(bounded_error_into_io)?;
383 Ok(())
384}
385
386fn join_writer_completion(
387 writer: Option<std::thread::JoinHandle<std::io::Result<()>>>,
388) -> std::io::Result<()> {
389 let Some(writer) = writer else {
390 return Ok(());
391 };
392 writer
393 .join()
394 .map(|_| ())
395 .map_err(|_| std::io::Error::other("stdin writer thread panicked"))
396}
397
398fn bounded_error_into_io(error: BoundedError) -> std::io::Error {
399 match error {
400 BoundedError::Launch(error) | BoundedError::Stdin(error) | BoundedError::Wait(error) => {
401 error
402 }
403 BoundedError::WaitCleanup { source, .. } => source,
404 }
405}
406
407fn join_writer_result(
408 writer: Option<std::thread::JoinHandle<std::io::Result<()>>>,
409) -> std::io::Result<()> {
410 let Some(writer) = writer else {
411 return Ok(());
412 };
413 match writer.join() {
414 Ok(result) => result,
415 Err(_) => Err(std::io::Error::other("stdin writer thread panicked")),
416 }
417}
418
419fn join_drain(drain: std::thread::JoinHandle<Vec<u8>>) -> Result<Vec<u8>, BoundedError> {
420 drain
421 .join()
422 .map_err(|_| BoundedError::Wait(std::io::Error::other("output drain thread panicked")))
423}
424
425pub enum Removal {
427 Confirmed { already_absent: bool },
428 Unconfirmed(RemovalFailure),
429}
430
431pub enum RemovalFailure {
433 Launch(std::io::Error),
434 Wait(std::io::Error),
435 WaitCleanup {
436 source: std::io::Error,
437 operation_elapsed_ms: u64,
438 client_cleanup: CommandCleanupEvidence,
439 },
440 Deadline {
441 operation_elapsed_ms: u64,
442 client_cleanup: Option<CommandCleanupEvidence>,
443 },
444 Exit {
445 status: ExitStatus,
446 stderr: String,
447 },
448 Ssh(String),
449}
450
451pub fn remove_container(target: Option<&str>, container: &str) -> Removal {
458 let bound = OperationBound::finite(REMOVAL_TIMEOUT);
459 let argv = match target {
460 Some(target) => crate::ssh::ssh_argv(
461 target,
462 &format!("docker rm -f {}", crate::shell::shell_quote(container)),
463 ),
464 None => vec![
465 "docker".to_owned(),
466 "rm".to_owned(),
467 "-f".to_owned(),
468 container.to_owned(),
469 ],
470 };
471 let (status, stderr) = match run_cleanup_with_bound(&argv, None, None, &bound, None) {
472 Ok(BoundedWait::Exited { status, stderr, .. }) => {
473 (status, String::from_utf8_lossy(&stderr).into_owned())
474 }
475 Ok(BoundedWait::Expired {
476 operation_elapsed_ms,
477 cleanup,
478 ..
479 }) => {
480 return Removal::Unconfirmed(RemovalFailure::Deadline {
481 operation_elapsed_ms,
482 client_cleanup: cleanup,
483 });
484 }
485 Ok(BoundedWait::Interrupted { .. }) => {
486 return Removal::Unconfirmed(RemovalFailure::Wait(std::io::Error::new(
487 std::io::ErrorKind::Interrupted,
488 "container cleanup was interrupted",
489 )));
490 }
491 Err(BoundedError::Launch(error)) => {
492 return Removal::Unconfirmed(match target {
493 Some(target) => {
494 RemovalFailure::Ssh(format!("failed to launch SSH for {target:?}: {error}"))
495 }
496 None => RemovalFailure::Launch(error),
497 });
498 }
499 Err(BoundedError::Stdin(error)) | Err(BoundedError::Wait(error)) => {
500 return Removal::Unconfirmed(RemovalFailure::Wait(error));
501 }
502 Err(BoundedError::WaitCleanup {
503 source,
504 operation_elapsed_ms,
505 cleanup,
506 }) => {
507 return Removal::Unconfirmed(RemovalFailure::WaitCleanup {
508 source,
509 operation_elapsed_ms,
510 client_cleanup: cleanup,
511 });
512 }
513 };
514 if status.success() {
515 return Removal::Confirmed {
516 already_absent: false,
517 };
518 }
519 if stderr.contains("No such container") {
520 return Removal::Confirmed {
521 already_absent: true,
522 };
523 }
524 if stderr.contains("is already in progress")
528 && confirm_container_absent(target, container, &bound)
529 {
530 return Removal::Confirmed {
531 already_absent: true,
532 };
533 }
534 Removal::Unconfirmed(RemovalFailure::Exit { status, stderr })
535}
536
537fn confirm_container_absent(target: Option<&str>, container: &str, bound: &OperationBound) -> bool {
541 let argv = match target {
542 Some(target) => crate::ssh::ssh_argv(
543 target,
544 &format!(
545 "docker container inspect --format {{{{.Id}}}} {}",
546 crate::shell::shell_quote(container)
547 ),
548 ),
549 None => vec![
550 "docker".to_owned(),
551 "container".to_owned(),
552 "inspect".to_owned(),
553 "--format".to_owned(),
554 "{{.Id}}".to_owned(),
555 container.to_owned(),
556 ],
557 };
558 loop {
559 if let Ok(BoundedWait::Exited { status, stderr, .. }) =
560 run_cleanup_with_bound(&argv, None, None, bound, None)
561 && !status.success()
562 && String::from_utf8_lossy(&stderr).contains("No such container")
563 {
564 return true;
565 }
566 if bound.is_expired() {
567 return false;
568 }
569 let sleep = match bound.attempt(Some(Duration::from_millis(250))).remaining() {
570 Remaining::Finite(duration) => duration,
571 Remaining::Expired => return false,
572 Remaining::Unbounded => Duration::from_millis(250),
573 };
574 std::thread::sleep(sleep);
575 }
576}
577
578#[cfg(test)]
579mod tests {
580 use super::{BoundedWait, CommandCleanupTrigger, run_with_bound, run_with_bound_mode};
581 use crate::operation_bound::OperationBound;
582 use std::time::Duration;
583
584 #[test]
585 fn unbounded_command_wait_terminates_when_the_process_exits() {
586 let argv = vec!["sh".to_owned(), "-c".to_owned(), "exit 0".to_owned()];
587 let outcome = run_with_bound(&argv, None, None, &OperationBound::unbounded(), None);
588
589 assert!(matches!(
590 outcome,
591 Ok(BoundedWait::Exited { status, .. }) if status.success()
592 ));
593 }
594
595 #[test]
596 fn pending_interruption_terminates_an_unbounded_command() {
597 let argv = vec!["sh".to_owned(), "-c".to_owned(), "sleep 60".to_owned()];
598 let outcome = run_with_bound_mode(
599 &argv,
600 None,
601 None,
602 &OperationBound::unbounded(),
603 None,
604 true,
605 || true,
606 );
607
608 let (operation_elapsed_ms, cleanup) = match outcome {
609 Ok(BoundedWait::Interrupted {
610 kill: Ok(()),
611 operation_elapsed_ms,
612 cleanup,
613 }) => (operation_elapsed_ms, cleanup),
614 other => {
615 assert!(
616 matches!(other, Ok(BoundedWait::Interrupted { .. })),
617 "interrupted command did not return cleanup evidence"
618 );
619 return;
620 }
621 };
622 assert!(operation_elapsed_ms < 1_000);
623 assert!(cleanup.verified, "{cleanup:?}");
624 assert_eq!(cleanup.trigger, CommandCleanupTrigger::Interruption);
625 assert!(cleanup.kill_attempted);
626 assert_eq!(cleanup.reap_grace_ms, 5_000);
627 assert_eq!(cleanup.io_drain_grace_ms, 5_000);
628 }
629
630 #[test]
631 fn blocked_stdin_write_cannot_outlive_the_owner_bound() {
632 let argv = vec![
633 "sh".to_owned(),
634 "-c".to_owned(),
635 "while :; do :; done".to_owned(),
636 ];
637 let payload = vec![b'x'; 1024 * 1024];
638 let bound = OperationBound::finite(Duration::from_millis(50));
639
640 let outcome = run_with_bound(&argv, None, Some(&payload), &bound, None);
641
642 assert!(matches!(
643 outcome,
644 Ok(BoundedWait::Expired { kill: Ok(()), .. })
645 ));
646 }
647
648 #[test]
649 fn inherited_output_pipe_cannot_outlive_the_owner_bound() {
650 let argv = vec![
651 "sh".to_owned(),
652 "-c".to_owned(),
653 "sleep 60 & exit 0".to_owned(),
654 ];
655 let bound = OperationBound::finite(Duration::from_millis(50));
656
657 let outcome = run_with_bound(&argv, None, None, &bound, None);
658
659 assert!(matches!(
660 outcome,
661 Ok(BoundedWait::Expired { kill: Ok(()), .. })
662 ));
663 }
664}