1use futures::StreamExt;
2use std::collections::HashMap;
3use std::sync::LazyLock;
4use std::time::Duration;
5use tokio::time::timeout;
6
7use crate::infra::runner_config::{ResourceLimits, RUNNER_CONFIG};
8use bollard::container::LogOutput;
9use bollard::models::{ContainerCreateBody, HostConfig, ResourcesUlimits};
10use bollard::query_parameters::{
11 AttachContainerOptions, CreateContainerOptions, RemoveContainerOptions, StartContainerOptions,
12 WaitContainerOptions,
13};
14use bollard::Docker;
15
16pub static DOCKER_CLIENT: LazyLock<Option<Docker>> = LazyLock::new(|| {
26 match Docker::connect_with_unix(
27 &RUNNER_CONFIG.docker_socket_path,
28 120,
29 bollard::API_DEFAULT_VERSION,
30 ) {
31 Ok(d) => Some(d),
32 Err(e) => {
33 tracing::error!(
34 error = ?e,
35 socket = %RUNNER_CONFIG.docker_socket_path,
36 "无法连接 Docker daemon,代码运行功能将不可用。\
37 请确认 Docker 已安装并运行,或设置正确的 DOCKER_SOCKET_PATH",
38 );
39 None
40 }
41 }
42});
43
44fn get_docker() -> Result<&'static Docker, bollard::errors::Error> {
49 DOCKER_CLIENT
50 .as_ref()
51 .ok_or_else(|| bollard::errors::Error::IOError {
52 err: std::io::Error::new(
53 std::io::ErrorKind::NotFound,
54 "Docker daemon 不可用(未安装或未运行)",
55 ),
56 })
57}
58
59pub fn build_host_config(limits: &ResourceLimits) -> HostConfig {
60 let mut tmpfs = HashMap::new();
61 tmpfs.insert("/code".to_string(), "size=16m,mode=1777".to_string());
66 tmpfs.insert("/tmp".to_string(), "size=64m,mode=1777,exec".to_string());
70 tmpfs.insert("/run".to_string(), "size=16m,mode=1777".to_string());
71
72 let memory = (limits.memory_mb * 1024 * 1024) as i64;
73
74 HostConfig {
75 cpu_quota: Some((limits.cpu_cores * 100_000.0) as i64),
76 cpu_period: Some(100_000),
77 memory: Some(memory),
78 memory_swap: Some(memory), network_mode: Some(if limits.allow_network {
80 "bridge".to_string()
81 } else {
82 "none".to_string()
83 }),
84 readonly_rootfs: Some(true),
85 tmpfs: Some(tmpfs),
86 pids_limit: Some(64),
87 ulimits: Some(vec![ResourcesUlimits {
92 name: Some("nofile".to_string()),
93 soft: Some(64),
94 hard: Some(64),
95 }]),
96 cap_drop: Some(vec!["ALL".to_string()]),
97 security_opt: Some(vec!["no-new-privileges".to_string()]),
98 auto_remove: Some(false), ..Default::default()
100 }
101}
102
103struct ContainerGuard {
104 container_id: String,
105 docker: Docker,
106}
107
108impl Drop for ContainerGuard {
109 fn drop(&mut self) {
110 let docker = self.docker.clone();
111 let container_id = self.container_id.clone();
112 tokio::spawn(async move {
117 let max_attempts = 3u8;
118 let mut backoff = Duration::from_millis(200);
119 for attempt in 1..=max_attempts {
120 let remove_options = Some(RemoveContainerOptions {
121 force: true,
122 ..Default::default()
123 });
124 match docker.remove_container(&container_id, remove_options).await {
125 Ok(()) => return,
126 Err(e) if attempt < max_attempts => {
127 tracing::warn!(
128 attempt,
129 max_attempts,
130 "remove_container 失败,稍后重试: {:?}",
131 e
132 );
133 tokio::time::sleep(backoff).await;
134 backoff *= 2;
135 }
136 Err(e) => {
137 tracing::error!(
138 container_id = %container_id,
139 "重试 {} 次后仍无法删除容器,可能泄漏;请手动执行 `docker rm -f {}`: {:?}",
140 max_attempts,
141 container_id,
142 e
143 );
144 return;
145 }
146 }
147 }
148 });
149 }
150}
151
152pub async fn run_in_container(
153 image_name: &str,
154 run_cmd: &str,
155 source: &str,
156 ext: &str,
157 limits: ResourceLimits,
158) -> Result<(Option<i64>, String, String, bool), bollard::errors::Error> {
159 let docker = get_docker()?;
160 let host_config = build_host_config(&limits);
161
162 let setup_cmd = format!("cat > /code/main.{} && exec {}", ext, run_cmd);
164 let cmd = vec!["sh".to_string(), "-c".to_string(), setup_cmd];
165
166 let config = ContainerCreateBody {
167 image: Some(image_name.to_string()),
168 cmd: Some(cmd),
169 host_config: Some(host_config),
170 attach_stdin: Some(true),
171 attach_stdout: Some(true),
172 attach_stderr: Some(true),
173 open_stdin: Some(true),
174 stdin_once: Some(true),
175 user: Some("1000:1000".to_string()), working_dir: Some("/code".to_string()),
177 ..Default::default()
178 };
179
180 let container = docker
181 .create_container(None::<CreateContainerOptions>, config)
182 .await?;
183
184 let container_id = container.id;
185 let _guard = ContainerGuard {
186 container_id: container_id.clone(),
187 docker: docker.clone(),
188 };
189
190 let attach_res = docker
192 .attach_container(
193 &container_id,
194 Some(AttachContainerOptions {
195 stdin: true,
196 stdout: true,
197 stderr: true,
198 stream: true,
199 logs: false,
200 ..Default::default()
201 }),
202 )
203 .await;
204
205 let (mut writer, mut stream) = match attach_res {
206 Ok(res) => (res.input, res.output),
207 Err(e) => return Err(e),
208 };
209
210 docker
212 .start_container(&container_id, None::<StartContainerOptions>)
213 .await?;
214
215 use tokio::io::AsyncWriteExt;
217 let write_fut = async {
218 let _ = writer.write_all(source.as_bytes()).await;
219 let _ = writer.flush().await;
220 let _ = writer.shutdown().await;
221 };
222
223 if timeout(Duration::from_secs(5), write_fut).await.is_err() {
224 return Err(bollard::errors::Error::IOError {
225 err: std::io::Error::new(std::io::ErrorKind::TimedOut, "Writing to stdin timed out"),
226 });
227 }
228 drop(writer);
229
230 let wait_future = async {
232 let mut wait_stream = docker.wait_container(&container_id, None::<WaitContainerOptions>);
233 wait_stream.next().await
234 };
235
236 let wait_res = timeout(Duration::from_secs(limits.timeout_secs), wait_future).await;
237
238 let mut timed_out = false;
239 let mut exit_code = None;
240
241 match wait_res {
242 Ok(Some(Ok(exit_status))) => {
243 exit_code = Some(exit_status.status_code);
244 }
245 Ok(_) => {} Err(_) => {
247 timed_out = true;
249 let _ = docker.kill_container(&container_id, None).await;
250 }
251 }
252
253 let mut stdout_buf = Vec::new();
255 let mut stderr_buf = Vec::new();
256
257 while let Some(item) = stream.next().await {
258 match item {
259 Ok(chunk) => {
260 match chunk {
261 LogOutput::StdOut { message } => {
262 let remaining = (limits.output_bytes as usize)
263 .saturating_sub(stdout_buf.len() + stderr_buf.len());
264 if remaining > 0 {
265 let to_add = message.len().min(remaining);
266 stdout_buf.extend_from_slice(&message[..to_add]);
267 }
268 }
269 LogOutput::StdErr { message } => {
270 let remaining = (limits.output_bytes as usize)
271 .saturating_sub(stdout_buf.len() + stderr_buf.len());
272 if remaining > 0 {
273 let to_add = message.len().min(remaining);
274 stderr_buf.extend_from_slice(&message[..to_add]);
275 }
276 }
277 _ => {}
278 }
279 if stdout_buf.len() + stderr_buf.len() >= limits.output_bytes as usize {
280 break;
281 }
282 }
283 Err(e) => {
284 tracing::error!("Error reading container log stream: {:?}", e);
285 break;
286 }
287 }
288 }
289
290 let inspect = docker.inspect_container(&container_id, None).await;
292 let oom_killed = inspect
293 .ok()
294 .and_then(|info| info.state.and_then(|s| s.oom_killed))
295 .unwrap_or(false);
296
297 let limit_bytes = limits.output_bytes as usize;
299 let stdout_len = stdout_buf.len().min(limit_bytes);
300 let stderr_len = stderr_buf.len().min(limit_bytes);
301
302 let stdout = String::from_utf8_lossy(&stdout_buf[..stdout_len]).into_owned();
303 let stderr = String::from_utf8_lossy(&stderr_buf[..stderr_len]).into_owned();
304
305 if timed_out {
306 return Err(bollard::errors::Error::IOError {
307 err: std::io::Error::new(std::io::ErrorKind::TimedOut, "Execution timed out"),
308 });
309 }
310
311 Ok((exit_code, stdout, stderr, oom_killed))
312}
313
314#[derive(Clone, Debug)]
318pub enum OutputChunk {
319 Stdout(String),
321 Stderr(String),
323 Done {
326 exit_code: Option<i64>,
327 oom_killed: bool,
328 timed_out: bool,
329 duration_ms: u64,
330 },
331}
332
333pub async fn run_in_container_stream(
345 image_name: &str,
346 run_cmd: &str,
347 source: &str,
348 ext: &str,
349 limits: ResourceLimits,
350 tx: tokio::sync::mpsc::Sender<OutputChunk>,
351) -> Result<(Option<i64>, String, String, bool, bool), bollard::errors::Error> {
352 let docker = get_docker()?;
353 let host_config = build_host_config(&limits);
354
355 let setup_cmd = format!("cat > /code/main.{} && exec {}", ext, run_cmd);
357 let cmd = vec!["sh".to_string(), "-c".to_string(), setup_cmd];
358
359 let config = ContainerCreateBody {
360 image: Some(image_name.to_string()),
361 cmd: Some(cmd),
362 host_config: Some(host_config),
363 attach_stdin: Some(true),
364 attach_stdout: Some(true),
365 attach_stderr: Some(true),
366 open_stdin: Some(true),
367 stdin_once: Some(true),
368 user: Some("1000:1000".to_string()),
369 working_dir: Some("/code".to_string()),
370 ..Default::default()
371 };
372
373 let container = docker
374 .create_container(None::<CreateContainerOptions>, config)
375 .await?;
376
377 let container_id = container.id;
378 let _guard = ContainerGuard {
379 container_id: container_id.clone(),
380 docker: docker.clone(),
381 };
382
383 let attach_res = docker
385 .attach_container(
386 &container_id,
387 Some(AttachContainerOptions {
388 stdin: true,
389 stdout: true,
390 stderr: true,
391 stream: true,
392 logs: false,
393 ..Default::default()
394 }),
395 )
396 .await;
397
398 let (mut writer, mut stream) = match attach_res {
399 Ok(res) => (res.input, res.output),
400 Err(e) => return Err(e),
401 };
402
403 docker
404 .start_container(&container_id, None::<StartContainerOptions>)
405 .await?;
406
407 let start_time = std::time::Instant::now();
409
410 use tokio::io::AsyncWriteExt;
412 let write_fut = async {
413 let _ = writer.write_all(source.as_bytes()).await;
414 let _ = writer.flush().await;
415 let _ = writer.shutdown().await;
416 };
417
418 if timeout(Duration::from_secs(5), write_fut).await.is_err() {
419 return Err(bollard::errors::Error::IOError {
420 err: std::io::Error::new(std::io::ErrorKind::TimedOut, "Writing to stdin timed out"),
421 });
422 }
423 drop(writer);
424
425 let mut stdout_buf = Vec::new();
436 let mut stderr_buf = Vec::new();
437 let limit_bytes = limits.output_bytes as usize;
438 let mut client_disconnected = false;
439 let mut timed_out = false;
440 let mut exit_code = None;
441
442 let log_reader = async {
446 while let Some(item) = stream.next().await {
447 match item {
448 Ok(chunk) => match chunk {
449 LogOutput::StdOut { message } => {
450 let remaining =
451 limit_bytes.saturating_sub(stdout_buf.len() + stderr_buf.len());
452 if remaining > 0 {
453 let to_add = message.len().min(remaining);
454 let slice = &message[..to_add];
455 stdout_buf.extend_from_slice(slice);
456 if !client_disconnected {
457 let text = String::from_utf8_lossy(slice).into_owned();
458 if tx.send(OutputChunk::Stdout(text)).await.is_err() {
459 client_disconnected = true;
460 }
461 }
462 }
463 }
464 LogOutput::StdErr { message } => {
465 let remaining =
466 limit_bytes.saturating_sub(stdout_buf.len() + stderr_buf.len());
467 if remaining > 0 {
468 let to_add = message.len().min(remaining);
469 let slice = &message[..to_add];
470 stderr_buf.extend_from_slice(slice);
471 if !client_disconnected {
472 let text = String::from_utf8_lossy(slice).into_owned();
473 if tx.send(OutputChunk::Stderr(text)).await.is_err() {
474 client_disconnected = true;
475 }
476 }
477 }
478 }
479 _ => {}
480 },
481 Err(e) => {
482 tracing::error!("Error reading container log stream: {:?}", e);
483 break;
484 }
485 }
486 if stdout_buf.len() + stderr_buf.len() >= limit_bytes {
487 break;
488 }
489 }
490 };
491
492 let wait_future = async {
494 let mut wait_stream = docker.wait_container(&container_id, None::<WaitContainerOptions>);
495 wait_stream.next().await
496 };
497 let wait_with_timeout = async {
498 match timeout(Duration::from_secs(limits.timeout_secs), wait_future).await {
499 Ok(Some(Ok(exit_status))) => Some(exit_status.status_code),
500 Ok(Some(Err(_))) => None, Ok(None) => None, Err(_) => {
503 timed_out = true;
505 let _ = docker.kill_container(&container_id, None).await;
506 None
507 }
508 }
509 };
510
511 tokio::select! {
515 status = wait_with_timeout => {
516 exit_code = status;
517 while let Some(item) = stream.next().await {
520 if let Ok(chunk) = item {
521 match chunk {
522 LogOutput::StdOut { message } => {
523 let remaining = limit_bytes.saturating_sub(stdout_buf.len() + stderr_buf.len());
524 let to_add = message.len().min(remaining);
525 if to_add > 0 {
526 let slice = &message[..to_add];
527 stdout_buf.extend_from_slice(slice);
528 if !client_disconnected {
529 let text = String::from_utf8_lossy(slice).into_owned();
530 let _ = tx.send(OutputChunk::Stdout(text)).await;
531 }
532 }
533 }
534 LogOutput::StdErr { message } => {
535 let remaining = limit_bytes.saturating_sub(stdout_buf.len() + stderr_buf.len());
536 let to_add = message.len().min(remaining);
537 if to_add > 0 {
538 let slice = &message[..to_add];
539 stderr_buf.extend_from_slice(slice);
540 if !client_disconnected {
541 let text = String::from_utf8_lossy(slice).into_owned();
542 let _ = tx.send(OutputChunk::Stderr(text)).await;
543 }
544 }
545 }
546 _ => {}
547 }
548 }
549 }
550 }
551 _ = log_reader => {
552 let mut wait_stream = docker.wait_container(&container_id, None::<WaitContainerOptions>);
558 match timeout(Duration::from_secs(limits.timeout_secs), wait_stream.next()).await {
559 Ok(Some(Ok(status))) => exit_code = Some(status.status_code),
560 Ok(Some(Err(_))) | Ok(None) => {}
561 Err(_) => {
562 timed_out = true;
564 let _ = docker.kill_container(&container_id, None).await;
565 if let Some(Ok(status)) = wait_stream.next().await {
566 exit_code = Some(status.status_code);
567 }
568 }
569 }
570 }
571 }
572
573 let inspect = docker.inspect_container(&container_id, None).await;
575 let oom_killed = inspect
576 .ok()
577 .and_then(|info| info.state.and_then(|s| s.oom_killed))
578 .unwrap_or(false);
579
580 let duration_ms = start_time.elapsed().as_millis() as u64;
582 if !client_disconnected {
583 let _ = tx
584 .send(OutputChunk::Done {
585 exit_code,
586 oom_killed,
587 timed_out,
588 duration_ms,
589 })
590 .await;
591 }
592
593 let stdout_len = stdout_buf.len().min(limit_bytes);
594 let stderr_len = stderr_buf.len().min(limit_bytes);
595 let stdout = String::from_utf8_lossy(&stdout_buf[..stdout_len]).into_owned();
596 let stderr = String::from_utf8_lossy(&stderr_buf[..stderr_len]).into_owned();
597
598 if timed_out {
599 return Err(bollard::errors::Error::IOError {
600 err: std::io::Error::new(std::io::ErrorKind::TimedOut, "Execution timed out"),
601 });
602 }
603
604 Ok((exit_code, stdout, stderr, oom_killed, timed_out))
605}
606
607#[cfg(test)]
608mod tests {
609 use super::*;
610 use crate::infra::runner_config::ResourceLimits;
611
612 #[test]
613 fn test_host_config_generation() {
614 let limits = ResourceLimits {
615 cpu_cores: 1.5,
616 memory_mb: 256,
617 timeout_secs: 5,
618 output_bytes: 1024,
619 allow_network: false,
620 };
621 let host_config = build_host_config(&limits);
622 assert_eq!(host_config.cpu_quota, Some(150_000));
623 assert_eq!(host_config.memory, Some(256 * 1024 * 1024));
624 assert_eq!(host_config.readonly_rootfs, Some(true));
625 assert_eq!(host_config.network_mode.as_deref(), Some("none"));
626
627 assert_eq!(
630 host_config.cap_drop.as_deref(),
631 Some(&["ALL".to_string()][..])
632 );
633 assert_eq!(
634 host_config.security_opt.as_deref(),
635 Some(&["no-new-privileges".to_string()][..])
636 );
637 assert_eq!(host_config.pids_limit, Some(64));
638 assert_eq!(host_config.memory_swap, host_config.memory);
640 assert_eq!(host_config.memory_swap, Some(256 * 1024 * 1024));
641 }
642
643 #[test]
644 fn host_config_ulimits_drops_only_nofile() {
645 let limits = ResourceLimits {
648 cpu_cores: 1.0,
649 memory_mb: 128,
650 timeout_secs: 5,
651 output_bytes: 1024,
652 allow_network: false,
653 };
654 let host_config = build_host_config(&limits);
655 let ulimits = host_config.ulimits.expect("ulimits must be set");
656 assert_eq!(ulimits.len(), 1, "only nofile, no nproc");
657 let nf = &ulimits[0];
658 assert_eq!(nf.name.as_deref(), Some("nofile"));
659 assert_eq!(nf.soft, Some(64));
660 assert_eq!(nf.hard, Some(64));
661 }
662
663 #[test]
664 fn host_config_tmpfs_has_exec_on_tmp_only() {
665 let limits = ResourceLimits {
668 cpu_cores: 1.0,
669 memory_mb: 128,
670 timeout_secs: 5,
671 output_bytes: 1024,
672 allow_network: false,
673 };
674 let host_config = build_host_config(&limits);
675 let tmpfs = host_config.tmpfs.expect("tmpfs must be set");
676 assert_eq!(tmpfs.len(), 3);
677 assert!(tmpfs["/tmp"].contains("exec"));
678 assert!(!tmpfs["/code"].contains("exec"));
679 assert!(!tmpfs["/run"].contains("exec"));
680 }
681
682 #[test]
683 fn host_config_network_mode_follows_allow_network() {
684 let base = ResourceLimits {
685 cpu_cores: 1.0,
686 memory_mb: 128,
687 timeout_secs: 5,
688 output_bytes: 1024,
689 allow_network: false,
690 };
691 assert_eq!(
692 build_host_config(&base).network_mode.as_deref(),
693 Some("none")
694 );
695 let mut net = base;
696 net.allow_network = true;
697 assert_eq!(
698 build_host_config(&net).network_mode.as_deref(),
699 Some("bridge")
700 );
701 }
702
703 #[test]
704 fn host_config_cpu_and_memory_scale_with_limits() {
705 let limits = ResourceLimits {
707 cpu_cores: 2.0,
708 memory_mb: 512,
709 timeout_secs: 5,
710 output_bytes: 1024,
711 allow_network: false,
712 };
713 let host_config = build_host_config(&limits);
714 assert_eq!(host_config.cpu_quota, Some(200_000));
715 assert_eq!(host_config.cpu_period, Some(100_000));
716 assert_eq!(host_config.memory, Some(512 * 1024 * 1024));
717 assert_eq!(host_config.memory_swap, Some(512 * 1024 * 1024));
718 }
719
720 async fn require_docker_with_image(image: &str) -> Option<&'static Docker> {
733 let docker = DOCKER_CLIENT.as_ref()?;
734 docker.version().await.ok()?;
736 docker.inspect_image(image).await.ok()?;
738 Some(docker)
739 }
740
741 #[tokio::test]
742 #[serial_test::serial]
743 async fn test_run_in_container_success() {
744 if require_docker_with_image("alpine:latest").await.is_none() {
745 eprintln!("skip: Docker daemon 不可用或缺少 alpine:latest");
746 return;
747 }
748 let limits = ResourceLimits {
749 cpu_cores: 1.0,
750 memory_mb: 128,
751 timeout_secs: 5,
752 output_bytes: 1024,
753 allow_network: false,
754 };
755 let (exit_code, stdout, stderr, oom_killed) = run_in_container(
756 "alpine:latest",
757 "cat /code/main.txt",
758 "hello world",
759 "txt",
760 limits,
761 )
762 .await
763 .unwrap();
764
765 assert_eq!(exit_code, Some(0));
766 assert_eq!(stdout, "hello world");
767 assert!(stderr.is_empty());
768 assert!(!oom_killed);
769 }
770
771 #[tokio::test]
772 #[serial_test::serial]
773 async fn test_run_in_container_output_truncation() {
774 if require_docker_with_image("alpine:latest").await.is_none() {
775 eprintln!("skip: Docker daemon 不可用或缺少 alpine:latest");
776 return;
777 }
778 let limits = ResourceLimits {
779 cpu_cores: 1.0,
780 memory_mb: 128,
781 timeout_secs: 5,
782 output_bytes: 5,
783 allow_network: false,
784 };
785 let (exit_code, stdout, stderr, oom_killed) = run_in_container(
786 "alpine:latest",
787 "cat /code/main.txt",
788 "hello world",
789 "txt",
790 limits,
791 )
792 .await
793 .unwrap();
794
795 assert_eq!(exit_code, Some(0));
796 assert_eq!(stdout, "hello");
797 assert!(stderr.is_empty());
798 assert!(!oom_killed);
799 }
800
801 #[tokio::test]
802 #[serial_test::serial]
803 async fn test_run_in_container_timeout() {
804 if require_docker_with_image("alpine:latest").await.is_none() {
805 eprintln!("skip: Docker daemon 不可用或缺少 alpine:latest");
806 return;
807 }
808 let limits = ResourceLimits {
809 cpu_cores: 1.0,
810 memory_mb: 128,
811 timeout_secs: 1,
812 output_bytes: 1024,
813 allow_network: false,
814 };
815 let res = run_in_container("alpine:latest", "sleep 10", "", "txt", limits).await;
816
817 assert!(res.is_err());
818 let err = res.unwrap_err();
819 match err {
820 bollard::errors::Error::IOError { err } => {
821 assert_eq!(err.kind(), std::io::ErrorKind::TimedOut);
822 }
823 _ => panic!("Expected IOError(TimedOut), got {:?}", err),
824 }
825 }
826
827 #[tokio::test]
828 #[serial_test::serial]
829 async fn test_run_in_container_cancellation() {
830 use bollard::query_parameters::ListContainersOptions;
831 let docker = match require_docker_with_image("alpine:latest").await {
832 Some(d) => d,
833 None => {
834 eprintln!("skip: Docker daemon 不可用或缺少 alpine:latest");
835 return;
836 }
837 };
838
839 let before = docker
840 .list_containers(Some(ListContainersOptions {
841 all: true,
842 ..Default::default()
843 }))
844 .await
845 .unwrap();
846 let before_ids: std::collections::HashSet<String> =
847 before.into_iter().map(|c| c.id.unwrap()).collect();
848
849 let limits = ResourceLimits {
850 cpu_cores: 1.0,
851 memory_mb: 128,
852 timeout_secs: 10,
853 output_bytes: 1024,
854 allow_network: false,
855 };
856
857 let run_fut = run_in_container("alpine:latest", "sleep 100", "", "txt", limits);
858
859 tokio::select! {
860 _ = run_fut => {
861 panic!("Should have been cancelled");
862 }
863 _ = tokio::time::sleep(Duration::from_secs(1)) => {
864 }
866 }
867
868 tokio::time::sleep(Duration::from_secs(2)).await;
869
870 let after = docker
871 .list_containers(Some(ListContainersOptions {
872 all: true,
873 ..Default::default()
874 }))
875 .await
876 .unwrap();
877
878 let mut leaked = Vec::new();
879 for c in after {
880 let id = c.id.unwrap();
881 if !before_ids.contains(&id) && c.image.as_deref() == Some("alpine:latest") {
882 leaked.push(id);
883 }
884 }
885
886 let leaked_count = leaked.len();
887 for id in leaked {
888 let _ = docker
889 .remove_container(
890 &id,
891 Some(RemoveContainerOptions {
892 force: true,
893 ..Default::default()
894 }),
895 )
896 .await;
897 }
898
899 assert_eq!(leaked_count, 0, "Found {} leaked containers", leaked_count);
900 }
901}