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>>(
99 argv: &[S],
100 env_remove: &[&str],
101 cwd: Option<&Path>,
102 stdin_payload: Option<&[u8]>,
103 bound: &OperationBound,
104 attempt_cap: Option<Duration>,
105) -> Result<BoundedWait, BoundedError> {
106 run_with_bound_mode(
107 argv,
108 env_remove,
109 cwd,
110 stdin_payload,
111 bound,
112 attempt_cap,
113 true,
114 crate::interrupt::received,
115 )
116}
117
118pub fn run_cleanup_with_bound<S: AsRef<std::ffi::OsStr>>(
119 argv: &[S],
120 env_remove: &[&str],
121 cwd: Option<&Path>,
122 stdin_payload: Option<&[u8]>,
123 bound: &OperationBound,
124 attempt_cap: Option<Duration>,
125) -> Result<BoundedWait, BoundedError> {
126 run_with_bound_mode(
127 argv,
128 env_remove,
129 cwd,
130 stdin_payload,
131 bound,
132 attempt_cap,
133 false,
134 crate::interrupt::received,
135 )
136}
137
138#[allow(clippy::too_many_arguments)]
139fn run_with_bound_mode<S: AsRef<std::ffi::OsStr>, F: FnMut() -> bool>(
140 argv: &[S],
141 env_remove: &[&str],
142 cwd: Option<&Path>,
143 stdin_payload: Option<&[u8]>,
144 bound: &OperationBound,
145 attempt_cap: Option<Duration>,
146 interruptible: bool,
147 mut interrupted: F,
148) -> Result<BoundedWait, BoundedError> {
149 let attempt = bound.attempt(attempt_cap);
150 if matches!(attempt.remaining(), Remaining::Expired) {
151 return Ok(BoundedWait::Expired {
152 kill: Ok(()),
153 operation_elapsed_ms: bound.elapsed_ms(),
154 cleanup: None,
155 });
156 }
157 let Some(program) = argv.first() else {
158 return Err(BoundedError::Launch(std::io::Error::new(
159 std::io::ErrorKind::InvalidInput,
160 "external command argv is empty",
161 )));
162 };
163 let mut command = Command::new(program);
164 command
165 .args(&argv[1..])
166 .stdin(if stdin_payload.is_some() {
167 Stdio::piped()
168 } else {
169 Stdio::null()
170 })
171 .stdout(Stdio::piped())
172 .stderr(Stdio::piped())
173 .process_group(0);
174 for variable in env_remove {
175 command.env_remove(variable);
176 }
177 if let Some(cwd) = cwd {
178 command.current_dir(cwd);
179 }
180 let mut child = command.spawn().map_err(BoundedError::Launch)?;
181 let stdin_write = if let Some(payload) = stdin_payload {
182 let mut stdin = child
183 .stdin
184 .take()
185 .ok_or_else(|| BoundedError::Stdin(std::io::Error::other("stdin was not piped")))?;
186 let payload = payload.to_owned();
187 Some(std::thread::spawn(move || {
188 stdin.write_all(&payload).and_then(|()| stdin.flush())
189 }))
190 } else {
191 None
192 };
193 let mut stdout_pipe = child.stdout.take();
194 let stdout_drain = std::thread::spawn(move || {
195 let mut buffer = Vec::new();
196 if let Some(pipe) = stdout_pipe.as_mut() {
197 let _ = pipe.read_to_end(&mut buffer);
198 }
199 buffer
200 });
201 let mut stderr_pipe = child.stderr.take();
202 let stderr_drain = std::thread::spawn(move || {
203 let mut buffer = Vec::new();
204 if let Some(pipe) = stderr_pipe.as_mut() {
205 let _ = pipe.read_to_end(&mut buffer);
206 }
207 buffer
208 });
209 let status = loop {
210 if interruptible && interrupted() {
211 let operation_elapsed_ms = bound.elapsed_ms();
212 let (kill, cleanup) = terminate_group_and_finish_io(
213 &mut child,
214 stdin_write,
215 stdout_drain,
216 stderr_drain,
217 CommandCleanupTrigger::Interruption,
218 );
219 return Ok(BoundedWait::Interrupted {
220 kill,
221 operation_elapsed_ms,
222 cleanup,
223 });
224 }
225 let Some(wait) = wait_slice(&attempt) else {
226 let operation_elapsed_ms = bound.elapsed_ms();
227 let (kill, cleanup) = terminate_group_and_finish_io(
228 &mut child,
229 stdin_write,
230 stdout_drain,
231 stderr_drain,
232 CommandCleanupTrigger::Deadline,
233 );
234 return Ok(BoundedWait::Expired {
235 kill,
236 operation_elapsed_ms,
237 cleanup: Some(cleanup),
238 });
239 };
240 match child.wait_timeout(wait) {
241 Ok(Some(status)) => break status,
242 Ok(None) => {}
243 Err(error) => {
244 let operation_elapsed_ms = bound.elapsed_ms();
245 let (_, cleanup) = terminate_group_and_finish_io(
246 &mut child,
247 stdin_write,
248 stdout_drain,
249 stderr_drain,
250 CommandCleanupTrigger::WaitFailure,
251 );
252 return Err(BoundedError::WaitCleanup {
253 source: error,
254 operation_elapsed_ms,
255 cleanup,
256 });
257 }
258 }
259 };
260 while !io_finished(&stdin_write, &stdout_drain, &stderr_drain) {
261 if interruptible && interrupted() {
262 let operation_elapsed_ms = bound.elapsed_ms();
263 let (kill, cleanup) = terminate_group_and_finish_io(
264 &mut child,
265 stdin_write,
266 stdout_drain,
267 stderr_drain,
268 CommandCleanupTrigger::Interruption,
269 );
270 return Ok(BoundedWait::Interrupted {
271 kill,
272 operation_elapsed_ms,
273 cleanup,
274 });
275 }
276 let Some(wait) = wait_slice(&attempt) else {
277 let operation_elapsed_ms = bound.elapsed_ms();
278 let (kill, cleanup) = terminate_group_and_finish_io(
279 &mut child,
280 stdin_write,
281 stdout_drain,
282 stderr_drain,
283 CommandCleanupTrigger::Deadline,
284 );
285 return Ok(BoundedWait::Expired {
286 kill,
287 operation_elapsed_ms,
288 cleanup: Some(cleanup),
289 });
290 };
291 std::thread::sleep(wait);
292 }
293 let stdin_result = join_writer_result(stdin_write);
294 let stdout = join_drain(stdout_drain);
295 let stderr = join_drain(stderr_drain);
296 stdin_result.map_err(BoundedError::Stdin)?;
297 let stdout = stdout?;
298 let stderr = stderr?;
299 Ok(BoundedWait::Exited {
300 status,
301 stdout,
302 stderr,
303 })
304}
305
306fn wait_slice(attempt: &crate::operation_bound::AttemptBound) -> Option<Duration> {
307 match attempt.remaining() {
308 Remaining::Finite(remaining) => Some(remaining.min(INTERRUPT_POLL_INTERVAL)),
309 Remaining::Expired => None,
310 Remaining::Unbounded => Some(INTERRUPT_POLL_INTERVAL),
311 }
312}
313
314fn io_finished(
315 writer: &Option<std::thread::JoinHandle<std::io::Result<()>>>,
316 stdout: &std::thread::JoinHandle<Vec<u8>>,
317 stderr: &std::thread::JoinHandle<Vec<u8>>,
318) -> bool {
319 writer.as_ref().is_none_or(|writer| writer.is_finished())
320 && stdout.is_finished()
321 && stderr.is_finished()
322}
323
324fn terminate_group_and_finish_io(
325 child: &mut std::process::Child,
326 writer: Option<std::thread::JoinHandle<std::io::Result<()>>>,
327 stdout: std::thread::JoinHandle<Vec<u8>>,
328 stderr: std::thread::JoinHandle<Vec<u8>>,
329 trigger: CommandCleanupTrigger,
330) -> (std::io::Result<()>, CommandCleanupEvidence) {
331 let started = std::time::Instant::now();
332 let kill = terminate_process_group(child);
333 let result = match kill {
334 Ok(()) => finish_io_with_grace(writer, stdout, stderr),
335 Err(error) => Err(error),
336 };
337 let evidence = CommandCleanupEvidence {
338 trigger,
339 elapsed_ms: duration_millis(started.elapsed()),
340 reap_grace_ms: duration_millis(COMMAND_REAP_GRACE),
341 io_drain_grace_ms: duration_millis(COMMAND_IO_DRAIN_GRACE),
342 kill_attempted: true,
343 verified: result.is_ok(),
344 error: result.as_ref().err().map(ToString::to_string),
345 };
346 (result, evidence)
347}
348
349fn terminate_process_group(child: &mut std::process::Child) -> std::io::Result<()> {
350 let pid = rustix::process::Pid::from_raw(child.id() as i32)
351 .ok_or_else(|| std::io::Error::other("child process id is zero"))?;
352 let signal = rustix::process::kill_process_group(pid, rustix::process::Signal::KILL);
353 if signal.is_err() && child.try_wait()?.is_none() {
354 child.kill()?;
355 }
356 match child.wait_timeout(COMMAND_REAP_GRACE)? {
357 Some(_) => Ok(()),
358 None => Err(std::io::Error::new(
359 std::io::ErrorKind::TimedOut,
360 format!(
361 "command child did not reap within {} seconds",
362 COMMAND_REAP_GRACE.as_secs()
363 ),
364 )),
365 }
366}
367
368fn finish_io_with_grace(
369 writer: Option<std::thread::JoinHandle<std::io::Result<()>>>,
370 stdout: std::thread::JoinHandle<Vec<u8>>,
371 stderr: std::thread::JoinHandle<Vec<u8>>,
372) -> std::io::Result<()> {
373 let started = std::time::Instant::now();
374 while !io_finished(&writer, &stdout, &stderr) {
375 if started.elapsed() >= COMMAND_IO_DRAIN_GRACE {
376 return Err(std::io::Error::new(
377 std::io::ErrorKind::TimedOut,
378 format!(
379 "command I/O did not drain within {} seconds after termination",
380 COMMAND_IO_DRAIN_GRACE.as_secs()
381 ),
382 ));
383 }
384 std::thread::sleep(INTERRUPT_POLL_INTERVAL);
385 }
386 join_writer_completion(writer)?;
392 let _ = join_drain(stdout).map_err(bounded_error_into_io)?;
393 let _ = join_drain(stderr).map_err(bounded_error_into_io)?;
394 Ok(())
395}
396
397fn join_writer_completion(
398 writer: Option<std::thread::JoinHandle<std::io::Result<()>>>,
399) -> std::io::Result<()> {
400 let Some(writer) = writer else {
401 return Ok(());
402 };
403 writer
404 .join()
405 .map(|_| ())
406 .map_err(|_| std::io::Error::other("stdin writer thread panicked"))
407}
408
409fn bounded_error_into_io(error: BoundedError) -> std::io::Error {
410 match error {
411 BoundedError::Launch(error) | BoundedError::Stdin(error) | BoundedError::Wait(error) => {
412 error
413 }
414 BoundedError::WaitCleanup { source, .. } => source,
415 }
416}
417
418fn join_writer_result(
419 writer: Option<std::thread::JoinHandle<std::io::Result<()>>>,
420) -> std::io::Result<()> {
421 let Some(writer) = writer else {
422 return Ok(());
423 };
424 match writer.join() {
425 Ok(result) => result,
426 Err(_) => Err(std::io::Error::other("stdin writer thread panicked")),
427 }
428}
429
430fn join_drain(drain: std::thread::JoinHandle<Vec<u8>>) -> Result<Vec<u8>, BoundedError> {
431 drain
432 .join()
433 .map_err(|_| BoundedError::Wait(std::io::Error::other("output drain thread panicked")))
434}
435
436pub enum Removal {
438 Confirmed { already_absent: bool },
439 Unconfirmed(RemovalFailure),
440}
441
442pub enum RemovalFailure {
444 Launch(std::io::Error),
445 Wait(std::io::Error),
446 WaitCleanup {
447 source: std::io::Error,
448 operation_elapsed_ms: u64,
449 client_cleanup: CommandCleanupEvidence,
450 },
451 Deadline {
452 operation_elapsed_ms: u64,
453 client_cleanup: Option<CommandCleanupEvidence>,
454 },
455 Exit {
456 status: ExitStatus,
457 stderr: String,
458 },
459 Ssh(String),
460}
461
462pub fn remove_container(target: Option<&str>, container: &str) -> Removal {
469 let bound = OperationBound::finite(REMOVAL_TIMEOUT);
470 let (argv, env_remove): (Vec<String>, &[&str]) = match target {
474 Some(target) => (
475 crate::ssh::ssh_argv(
476 target,
477 &format!("docker rm -f {}", crate::shell::shell_quote(container)),
478 ),
479 crate::ssh::SSH_ENV_REMOVE,
480 ),
481 None => (
482 vec![
483 "docker".to_owned(),
484 "rm".to_owned(),
485 "-f".to_owned(),
486 container.to_owned(),
487 ],
488 &[],
489 ),
490 };
491 let (status, stderr) = match run_cleanup_with_bound(&argv, env_remove, None, None, &bound, None)
492 {
493 Ok(BoundedWait::Exited { status, stderr, .. }) => {
494 (status, String::from_utf8_lossy(&stderr).into_owned())
495 }
496 Ok(BoundedWait::Expired {
497 operation_elapsed_ms,
498 cleanup,
499 ..
500 }) => {
501 return Removal::Unconfirmed(RemovalFailure::Deadline {
502 operation_elapsed_ms,
503 client_cleanup: cleanup,
504 });
505 }
506 Ok(BoundedWait::Interrupted { .. }) => {
507 return Removal::Unconfirmed(RemovalFailure::Wait(std::io::Error::new(
508 std::io::ErrorKind::Interrupted,
509 "container cleanup was interrupted",
510 )));
511 }
512 Err(BoundedError::Launch(error)) => {
513 return Removal::Unconfirmed(match target {
514 Some(target) => {
515 RemovalFailure::Ssh(format!("failed to launch SSH for {target:?}: {error}"))
516 }
517 None => RemovalFailure::Launch(error),
518 });
519 }
520 Err(BoundedError::Stdin(error)) | Err(BoundedError::Wait(error)) => {
521 return Removal::Unconfirmed(RemovalFailure::Wait(error));
522 }
523 Err(BoundedError::WaitCleanup {
524 source,
525 operation_elapsed_ms,
526 cleanup,
527 }) => {
528 return Removal::Unconfirmed(RemovalFailure::WaitCleanup {
529 source,
530 operation_elapsed_ms,
531 client_cleanup: cleanup,
532 });
533 }
534 };
535 if status.success() {
536 return Removal::Confirmed {
537 already_absent: false,
538 };
539 }
540 if stderr.contains("No such container") {
541 return Removal::Confirmed {
542 already_absent: true,
543 };
544 }
545 if stderr.contains("is already in progress")
549 && confirm_container_absent(target, container, &bound)
550 {
551 return Removal::Confirmed {
552 already_absent: true,
553 };
554 }
555 Removal::Unconfirmed(RemovalFailure::Exit { status, stderr })
556}
557
558fn confirm_container_absent(target: Option<&str>, container: &str, bound: &OperationBound) -> bool {
562 let (argv, env_remove): (Vec<String>, &[&str]) = match target {
563 Some(target) => (
564 crate::ssh::ssh_argv(
565 target,
566 &format!(
567 "docker container inspect --format {{{{.Id}}}} {}",
568 crate::shell::shell_quote(container)
569 ),
570 ),
571 crate::ssh::SSH_ENV_REMOVE,
572 ),
573 None => (
574 vec![
575 "docker".to_owned(),
576 "container".to_owned(),
577 "inspect".to_owned(),
578 "--format".to_owned(),
579 "{{.Id}}".to_owned(),
580 container.to_owned(),
581 ],
582 &[],
583 ),
584 };
585 loop {
586 if let Ok(BoundedWait::Exited { status, stderr, .. }) =
587 run_cleanup_with_bound(&argv, env_remove, None, None, bound, None)
588 && !status.success()
589 && String::from_utf8_lossy(&stderr).contains("No such container")
590 {
591 return true;
592 }
593 if bound.is_expired() {
594 return false;
595 }
596 let sleep = match bound.attempt(Some(Duration::from_millis(250))).remaining() {
597 Remaining::Finite(duration) => duration,
598 Remaining::Expired => return false,
599 Remaining::Unbounded => Duration::from_millis(250),
600 };
601 std::thread::sleep(sleep);
602 }
603}
604
605#[cfg(test)]
606mod tests {
607 use super::{BoundedWait, CommandCleanupTrigger, run_with_bound, run_with_bound_mode};
608 use crate::operation_bound::OperationBound;
609 use std::time::Duration;
610
611 #[test]
612 fn unbounded_command_wait_terminates_when_the_process_exits() {
613 let argv = vec!["sh".to_owned(), "-c".to_owned(), "exit 0".to_owned()];
614 let outcome = run_with_bound(&argv, &[], None, None, &OperationBound::unbounded(), None);
615
616 assert!(matches!(
617 outcome,
618 Ok(BoundedWait::Exited { status, .. }) if status.success()
619 ));
620 }
621
622 #[test]
623 fn pending_interruption_terminates_an_unbounded_command() {
624 let argv = vec!["sh".to_owned(), "-c".to_owned(), "sleep 60".to_owned()];
625 let outcome = run_with_bound_mode(
626 &argv,
627 &[],
628 None,
629 None,
630 &OperationBound::unbounded(),
631 None,
632 true,
633 || true,
634 );
635
636 let (operation_elapsed_ms, cleanup) = match outcome {
637 Ok(BoundedWait::Interrupted {
638 kill: Ok(()),
639 operation_elapsed_ms,
640 cleanup,
641 }) => (operation_elapsed_ms, cleanup),
642 other => {
643 assert!(
644 matches!(other, Ok(BoundedWait::Interrupted { .. })),
645 "interrupted command did not return cleanup evidence"
646 );
647 return;
648 }
649 };
650 assert!(operation_elapsed_ms < 1_000);
651 assert!(cleanup.verified, "{cleanup:?}");
652 assert_eq!(cleanup.trigger, CommandCleanupTrigger::Interruption);
653 assert!(cleanup.kill_attempted);
654 assert_eq!(cleanup.reap_grace_ms, 5_000);
655 assert_eq!(cleanup.io_drain_grace_ms, 5_000);
656 }
657
658 #[test]
659 fn blocked_stdin_write_cannot_outlive_the_owner_bound() {
660 let argv = vec![
661 "sh".to_owned(),
662 "-c".to_owned(),
663 "while :; do :; done".to_owned(),
664 ];
665 let payload = vec![b'x'; 1024 * 1024];
666 let bound = OperationBound::finite(Duration::from_millis(50));
667
668 let outcome = run_with_bound(&argv, &[], None, Some(&payload), &bound, None);
669
670 assert!(matches!(
671 outcome,
672 Ok(BoundedWait::Expired { kill: Ok(()), .. })
673 ));
674 }
675
676 #[test]
677 fn inherited_output_pipe_cannot_outlive_the_owner_bound() {
678 let argv = vec![
679 "sh".to_owned(),
680 "-c".to_owned(),
681 "sleep 60 & exit 0".to_owned(),
682 ];
683 let bound = OperationBound::finite(Duration::from_millis(50));
684
685 let outcome = run_with_bound(&argv, &[], None, None, &bound, None);
686
687 assert!(matches!(
688 outcome,
689 Ok(BoundedWait::Expired { kill: Ok(()), .. })
690 ));
691 }
692
693 #[test]
694 fn env_remove_strips_inherited_variables_from_the_child() {
695 let argv = vec!["/usr/bin/env".to_owned()];
698
699 let inherited = run_with_bound(&argv, &[], None, None, &OperationBound::unbounded(), None);
700 assert!(matches!(
701 inherited,
702 Ok(BoundedWait::Exited { ref stdout, .. }) if stdout.starts_with(b"PATH=")
703 || stdout.windows(6).any(|window| window == b"\nPATH=")
704 ));
705
706 let stripped = run_with_bound(
707 &argv,
708 &["PATH"],
709 None,
710 None,
711 &OperationBound::unbounded(),
712 None,
713 );
714 assert!(matches!(
715 stripped,
716 Ok(BoundedWait::Exited { ref stdout, .. }) if !stdout.starts_with(b"PATH=")
717 && !stdout.windows(6).any(|window| window == b"\nPATH=")
718 ));
719 }
720}