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