1use futures::StreamExt;
2use std::collections::HashMap;
3#[cfg(test)]
4use std::future::Future;
5use std::sync::LazyLock;
6use std::time::Duration;
7use tokio::sync::oneshot;
8use tokio::time::timeout;
9
10use crate::infra::runner_config::{ResourceLimits, RUNNER_CONFIG};
11use bollard::container::LogOutput;
12use bollard::models::{ContainerCreateBody, ContainerWaitResponse, HostConfig, ResourcesUlimits};
13use bollard::query_parameters::{
14 AttachContainerOptions, CreateContainerOptions, RemoveContainerOptions, StartContainerOptions,
15 WaitContainerOptions,
16};
17use bollard::Docker;
18
19type CleanupSignal = oneshot::Sender<bool>;
20type ContainerOutput = (Option<i64>, String, String, bool);
21type ContainerResult = Result<ContainerOutput, bollard::errors::Error>;
22
23const LOG_DRAIN_TIMEOUT: Duration = Duration::from_secs(5);
28
29fn wait_exit_code(item: Result<ContainerWaitResponse, bollard::errors::Error>) -> Option<i64> {
34 match item {
35 Ok(status) => Some(status.status_code),
36 Err(bollard::errors::Error::DockerContainerWaitError { code, .. }) => Some(code),
37 Err(_) => None,
38 }
39}
40
41pub static DOCKER_CLIENT: LazyLock<Option<Docker>> = LazyLock::new(|| {
51 match Docker::connect_with_unix(
52 &RUNNER_CONFIG.docker_socket_path,
53 120,
54 bollard::API_DEFAULT_VERSION,
55 ) {
56 Ok(d) => Some(d),
57 Err(e) => {
58 tracing::error!(
59 error = ?e,
60 socket = %RUNNER_CONFIG.docker_socket_path,
61 "无法连接 Docker daemon,代码运行功能将不可用。\
62 请确认 Docker 已安装并运行,或设置正确的 DOCKER_SOCKET_PATH",
63 );
64 None
65 }
66 }
67});
68
69fn get_docker() -> Result<&'static Docker, bollard::errors::Error> {
74 DOCKER_CLIENT
75 .as_ref()
76 .ok_or_else(|| bollard::errors::Error::IOError {
77 err: std::io::Error::new(
78 std::io::ErrorKind::NotFound,
79 "Docker daemon 不可用(未安装或未运行)",
80 ),
81 })
82}
83
84#[derive(Clone, Debug, PartialEq)]
89pub struct CacheVolume {
90 pub name: String,
91 pub mount_path: String,
92}
93
94pub fn build_host_config(
95 limits: &ResourceLimits,
96 cache_volume: Option<&CacheVolume>,
97) -> HostConfig {
98 let mut tmpfs = HashMap::new();
99 tmpfs.insert("/code".to_string(), "size=16m,mode=1777".to_string());
104 tmpfs.insert("/tmp".to_string(), "size=64m,mode=1777,exec".to_string());
108 tmpfs.insert("/run".to_string(), "size=16m,mode=1777".to_string());
109
110 let memory = (limits.memory_mb * 1024 * 1024) as i64;
111
112 HostConfig {
113 cpu_quota: Some((limits.cpu_cores * 100_000.0) as i64),
114 cpu_period: Some(100_000),
115 memory: Some(memory),
116 memory_swap: Some(memory), network_mode: Some(if limits.allow_network {
118 "bridge".to_string()
119 } else {
120 "none".to_string()
121 }),
122 readonly_rootfs: Some(true),
123 tmpfs: Some(tmpfs),
124 binds: cache_volume.map(|cv| vec![format!("{}:{}", cv.name, cv.mount_path)]),
128 pids_limit: Some(64),
129 ulimits: Some(vec![ResourcesUlimits {
134 name: Some("nofile".to_string()),
135 soft: Some(64),
136 hard: Some(64),
137 }]),
138 cap_drop: Some(vec!["ALL".to_string()]),
139 security_opt: Some(vec!["no-new-privileges".to_string()]),
140 auto_remove: Some(false), ..Default::default()
142 }
143}
144
145struct ContainerGuard {
146 container_id: String,
147 docker: Docker,
148 cleanup_signal: Option<CleanupSignal>,
149}
150
151async fn remove_container_with_retry(docker: Docker, container_id: String) -> bool {
152 let max_attempts = 3u8;
153 let mut backoff = Duration::from_millis(200);
154 for attempt in 1..=max_attempts {
155 let remove_options = Some(RemoveContainerOptions {
156 force: true,
157 ..Default::default()
158 });
159 match docker.remove_container(&container_id, remove_options).await {
160 Ok(()) => return true,
161 Err(e) if attempt < max_attempts => {
162 tracing::warn!(
163 attempt,
164 max_attempts,
165 "remove_container 失败,稍后重试: {:?}",
166 e
167 );
168 tokio::time::sleep(backoff).await;
169 backoff *= 2;
170 }
171 Err(e) => {
172 tracing::error!(
173 container_id = %container_id,
174 "重试 {} 次后仍无法删除容器,可能泄漏;请手动执行 `docker rm -f {}`: {:?}",
175 max_attempts,
176 container_id,
177 e
178 );
179 return false;
180 }
181 }
182 }
183 false
184}
185
186impl Drop for ContainerGuard {
187 fn drop(&mut self) {
188 let docker = self.docker.clone();
189 let container_id = self.container_id.clone();
190 let cleanup_signal = self.cleanup_signal.take();
191 tokio::spawn(async move {
196 let cleaned = remove_container_with_retry(docker, container_id).await;
197 if let Some(signal) = cleanup_signal {
198 let _ = signal.send(cleaned);
199 }
200 });
201 }
202}
203
204pub async fn run_in_container(
205 image_name: &str,
206 run_cmd: &str,
207 source: &str,
208 ext: &str,
209 limits: ResourceLimits,
210 cache_volume: Option<&CacheVolume>,
211) -> ContainerResult {
212 run_in_container_inner(image_name, run_cmd, source, ext, limits, cache_volume, None).await
213}
214
215async fn run_in_container_inner(
216 image_name: &str,
217 run_cmd: &str,
218 source: &str,
219 ext: &str,
220 limits: ResourceLimits,
221 cache_volume: Option<&CacheVolume>,
222 cleanup_signal: Option<CleanupSignal>,
223) -> ContainerResult {
224 let docker = get_docker()?;
225 let host_config = build_host_config(&limits, cache_volume);
226
227 let setup_cmd = format!("cat > /code/main.{} && exec {}", ext, run_cmd);
229 let cmd = vec!["sh".to_string(), "-c".to_string(), setup_cmd];
230
231 let config = ContainerCreateBody {
232 image: Some(image_name.to_string()),
233 cmd: Some(cmd),
234 host_config: Some(host_config),
235 attach_stdin: Some(true),
236 attach_stdout: Some(true),
237 attach_stderr: Some(true),
238 open_stdin: Some(true),
239 stdin_once: Some(true),
240 user: Some("1000:1000".to_string()), working_dir: Some("/code".to_string()),
242 ..Default::default()
243 };
244
245 let container = docker
246 .create_container(None::<CreateContainerOptions>, config)
247 .await?;
248
249 let container_id = container.id;
250 let _guard = ContainerGuard {
251 container_id: container_id.clone(),
252 docker: docker.clone(),
253 cleanup_signal,
254 };
255
256 let attach_res = docker
258 .attach_container(
259 &container_id,
260 Some(AttachContainerOptions {
261 stdin: true,
262 stdout: true,
263 stderr: true,
264 stream: true,
265 logs: false,
266 ..Default::default()
267 }),
268 )
269 .await;
270
271 let (mut writer, mut stream) = match attach_res {
272 Ok(res) => (res.input, res.output),
273 Err(e) => return Err(e),
274 };
275
276 docker
278 .start_container(&container_id, None::<StartContainerOptions>)
279 .await?;
280
281 use tokio::io::AsyncWriteExt;
283 let write_fut = async {
284 let _ = writer.write_all(source.as_bytes()).await;
285 let _ = writer.flush().await;
286 let _ = writer.shutdown().await;
287 };
288
289 if timeout(Duration::from_secs(5), write_fut).await.is_err() {
290 return Err(bollard::errors::Error::IOError {
291 err: std::io::Error::new(std::io::ErrorKind::TimedOut, "Writing to stdin timed out"),
292 });
293 }
294 drop(writer);
295
296 let wait_future = async {
298 let mut wait_stream = docker.wait_container(&container_id, None::<WaitContainerOptions>);
299 wait_stream.next().await
300 };
301
302 let wait_res = timeout(Duration::from_secs(limits.timeout_secs), wait_future).await;
303
304 let mut timed_out = false;
305 let mut exit_code = None;
306
307 match wait_res {
308 Ok(item) => {
309 exit_code = item.and_then(wait_exit_code);
310 if exit_code.is_none() {
311 let _ = docker.kill_container(&container_id, None).await;
314 }
315 }
316 Err(_) => {
317 timed_out = true;
319 let _ = docker.kill_container(&container_id, None).await;
320 }
321 }
322
323 let mut stdout_buf = Vec::new();
325 let mut stderr_buf = Vec::new();
326
327 let drain_logs = async {
330 while let Some(item) = stream.next().await {
331 match item {
332 Ok(chunk) => {
333 match chunk {
334 LogOutput::StdOut { message } => {
335 let remaining = (limits.output_bytes as usize)
336 .saturating_sub(stdout_buf.len() + stderr_buf.len());
337 if remaining > 0 {
338 let to_add = message.len().min(remaining);
339 stdout_buf.extend_from_slice(&message[..to_add]);
340 }
341 }
342 LogOutput::StdErr { message } => {
343 let remaining = (limits.output_bytes as usize)
344 .saturating_sub(stdout_buf.len() + stderr_buf.len());
345 if remaining > 0 {
346 let to_add = message.len().min(remaining);
347 stderr_buf.extend_from_slice(&message[..to_add]);
348 }
349 }
350 _ => {}
351 }
352 if stdout_buf.len() + stderr_buf.len() >= limits.output_bytes as usize {
353 break;
354 }
355 }
356 Err(e) => {
357 tracing::error!("Error reading container log stream: {:?}", e);
358 break;
359 }
360 }
361 }
362 };
363 if timeout(LOG_DRAIN_TIMEOUT, drain_logs).await.is_err() {
364 tracing::warn!(
365 container_id = %container_id,
366 "容器日志流在等待结束后仍未关闭,放弃读取剩余日志"
367 );
368 }
369
370 let inspect = docker.inspect_container(&container_id, None).await;
372 let oom_killed = inspect
373 .ok()
374 .and_then(|info| info.state.and_then(|s| s.oom_killed))
375 .unwrap_or(false);
376
377 let limit_bytes = limits.output_bytes as usize;
379 let stdout_len = stdout_buf.len().min(limit_bytes);
380 let stderr_len = stderr_buf.len().min(limit_bytes);
381
382 let stdout = String::from_utf8_lossy(&stdout_buf[..stdout_len]).into_owned();
383 let stderr = String::from_utf8_lossy(&stderr_buf[..stderr_len]).into_owned();
384
385 if timed_out {
386 return Err(bollard::errors::Error::IOError {
387 err: std::io::Error::new(std::io::ErrorKind::TimedOut, "Execution timed out"),
388 });
389 }
390
391 Ok((exit_code, stdout, stderr, oom_killed))
392}
393
394#[cfg(test)]
395fn run_in_container_with_cleanup_signal<'a>(
396 image_name: &'a str,
397 run_cmd: &'a str,
398 source: &'a str,
399 ext: &'a str,
400 limits: ResourceLimits,
401 cache_volume: Option<&'a CacheVolume>,
402) -> (
403 impl Future<Output = ContainerResult> + 'a,
404 oneshot::Receiver<bool>,
405) {
406 let (sender, receiver) = oneshot::channel();
407 (
408 run_in_container_inner(
409 image_name,
410 run_cmd,
411 source,
412 ext,
413 limits,
414 cache_volume,
415 Some(sender),
416 ),
417 receiver,
418 )
419}
420
421#[derive(Clone, Debug)]
425pub enum OutputChunk {
426 Stage(&'static str),
428 Stdout(String),
430 Stderr(String),
432 Done {
435 exit_code: Option<i64>,
436 oom_killed: bool,
437 timed_out: bool,
438 duration_ms: u64,
439 error: Option<String>,
443 },
444}
445
446pub async fn run_in_container_stream(
459 image_name: &str,
460 run_cmd: &str,
461 source: &str,
462 ext: &str,
463 limits: ResourceLimits,
464 cache_volume: Option<&CacheVolume>,
465 tx: tokio::sync::mpsc::Sender<OutputChunk>,
466) -> Result<(Option<i64>, String, String, bool), bollard::errors::Error> {
467 let docker = get_docker()?;
468 let host_config = build_host_config(&limits, cache_volume);
469
470 let setup_cmd = format!("cat > /code/main.{} && exec {}", ext, run_cmd);
472 let cmd = vec!["sh".to_string(), "-c".to_string(), setup_cmd];
473
474 let config = ContainerCreateBody {
475 image: Some(image_name.to_string()),
476 cmd: Some(cmd),
477 host_config: Some(host_config),
478 attach_stdin: Some(true),
479 attach_stdout: Some(true),
480 attach_stderr: Some(true),
481 open_stdin: Some(true),
482 stdin_once: Some(true),
483 user: Some("1000:1000".to_string()),
484 working_dir: Some("/code".to_string()),
485 ..Default::default()
486 };
487
488 let container = docker
489 .create_container(None::<CreateContainerOptions>, config)
490 .await?;
491
492 let container_id = container.id;
493 let _guard = ContainerGuard {
494 container_id: container_id.clone(),
495 docker: docker.clone(),
496 cleanup_signal: None,
497 };
498
499 let attach_res = docker
501 .attach_container(
502 &container_id,
503 Some(AttachContainerOptions {
504 stdin: true,
505 stdout: true,
506 stderr: true,
507 stream: true,
508 logs: false,
509 ..Default::default()
510 }),
511 )
512 .await;
513
514 let (mut writer, mut stream) = match attach_res {
515 Ok(res) => (res.input, res.output),
516 Err(e) => return Err(e),
517 };
518
519 docker
520 .start_container(&container_id, None::<StartContainerOptions>)
521 .await?;
522 let _ = tx.send(OutputChunk::Stage("running")).await;
523
524 let start_time = std::time::Instant::now();
526
527 use tokio::io::AsyncWriteExt;
529 let write_fut = async {
530 let _ = writer.write_all(source.as_bytes()).await;
531 let _ = writer.flush().await;
532 let _ = writer.shutdown().await;
533 };
534
535 if timeout(Duration::from_secs(5), write_fut).await.is_err() {
536 return Err(bollard::errors::Error::IOError {
537 err: std::io::Error::new(std::io::ErrorKind::TimedOut, "Writing to stdin timed out"),
538 });
539 }
540 drop(writer);
541
542 let mut stdout_buf = Vec::new();
553 let mut stderr_buf = Vec::new();
554 let limit_bytes = limits.output_bytes as usize;
555 let mut client_disconnected = false;
556 let mut timed_out = false;
557 let mut exit_code = None;
558
559 let log_reader = async {
563 while let Some(item) = stream.next().await {
564 match item {
565 Ok(chunk) => match chunk {
566 LogOutput::StdOut { message } => {
567 let remaining =
568 limit_bytes.saturating_sub(stdout_buf.len() + stderr_buf.len());
569 if remaining > 0 {
570 let to_add = message.len().min(remaining);
571 let slice = &message[..to_add];
572 stdout_buf.extend_from_slice(slice);
573 if !client_disconnected {
574 let text = String::from_utf8_lossy(slice).into_owned();
575 if tx.send(OutputChunk::Stdout(text)).await.is_err() {
576 client_disconnected = true;
577 }
578 }
579 }
580 }
581 LogOutput::StdErr { message } => {
582 let remaining =
583 limit_bytes.saturating_sub(stdout_buf.len() + stderr_buf.len());
584 if remaining > 0 {
585 let to_add = message.len().min(remaining);
586 let slice = &message[..to_add];
587 stderr_buf.extend_from_slice(slice);
588 if !client_disconnected {
589 let text = String::from_utf8_lossy(slice).into_owned();
590 if tx.send(OutputChunk::Stderr(text)).await.is_err() {
591 client_disconnected = true;
592 }
593 }
594 }
595 }
596 _ => {}
597 },
598 Err(e) => {
599 tracing::error!("Error reading container log stream: {:?}", e);
600 break;
601 }
602 }
603 if stdout_buf.len() + stderr_buf.len() >= limit_bytes {
604 break;
605 }
606 }
607 };
608
609 let wait_future = async {
611 let mut wait_stream = docker.wait_container(&container_id, None::<WaitContainerOptions>);
612 wait_stream.next().await
613 };
614 let wait_with_timeout = async {
615 match timeout(Duration::from_secs(limits.timeout_secs), wait_future).await {
616 Ok(item) => {
617 let code = item.and_then(wait_exit_code);
618 if code.is_none() {
619 let _ = docker.kill_container(&container_id, None).await;
621 }
622 code
623 }
624 Err(_) => {
625 timed_out = true;
627 let _ = docker.kill_container(&container_id, None).await;
628 None
629 }
630 }
631 };
632
633 tokio::select! {
637 status = wait_with_timeout => {
638 exit_code = status;
639 let drain = async {
642 while let Some(item) = stream.next().await {
643 if let Ok(chunk) = item {
644 match chunk {
645 LogOutput::StdOut { message } => {
646 let remaining =
647 limit_bytes.saturating_sub(stdout_buf.len() + stderr_buf.len());
648 let to_add = message.len().min(remaining);
649 if to_add > 0 {
650 let slice = &message[..to_add];
651 stdout_buf.extend_from_slice(slice);
652 if !client_disconnected {
653 let text = String::from_utf8_lossy(slice).into_owned();
654 let _ = tx.send(OutputChunk::Stdout(text)).await;
655 }
656 }
657 }
658 LogOutput::StdErr { message } => {
659 let remaining =
660 limit_bytes.saturating_sub(stdout_buf.len() + stderr_buf.len());
661 let to_add = message.len().min(remaining);
662 if to_add > 0 {
663 let slice = &message[..to_add];
664 stderr_buf.extend_from_slice(slice);
665 if !client_disconnected {
666 let text = String::from_utf8_lossy(slice).into_owned();
667 let _ = tx.send(OutputChunk::Stderr(text)).await;
668 }
669 }
670 }
671 _ => {}
672 }
673 }
674 }
675 };
676 if timeout(LOG_DRAIN_TIMEOUT, drain).await.is_err() {
677 tracing::warn!(
678 container_id = %container_id,
679 "容器日志流在等待结束后仍未关闭,放弃读取剩余日志"
680 );
681 }
682 }
683 _ = log_reader => {
684 let mut wait_stream = docker.wait_container(&container_id, None::<WaitContainerOptions>);
690 match timeout(Duration::from_secs(limits.timeout_secs), wait_stream.next()).await {
691 Ok(item) => exit_code = item.and_then(wait_exit_code),
692 Err(_) => {
693 timed_out = true;
695 let _ = docker.kill_container(&container_id, None).await;
696 if let Some(item) = wait_stream.next().await {
697 exit_code = wait_exit_code(item);
698 }
699 }
700 }
701 }
702 }
703
704 if timed_out {
705 return Err(bollard::errors::Error::IOError {
706 err: std::io::Error::new(std::io::ErrorKind::TimedOut, "Execution timed out"),
707 });
708 }
709
710 let inspect = docker.inspect_container(&container_id, None).await;
712 let oom_killed = inspect
713 .ok()
714 .and_then(|info| info.state.and_then(|s| s.oom_killed))
715 .unwrap_or(false);
716
717 let duration_ms = start_time.elapsed().as_millis() as u64;
719 if !client_disconnected {
720 let _ = tx
721 .send(OutputChunk::Done {
722 exit_code,
723 oom_killed,
724 timed_out: false,
725 duration_ms,
726 error: None,
727 })
728 .await;
729 }
730
731 let stdout_len = stdout_buf.len().min(limit_bytes);
732 let stderr_len = stderr_buf.len().min(limit_bytes);
733 let stdout = String::from_utf8_lossy(&stdout_buf[..stdout_len]).into_owned();
734 let stderr = String::from_utf8_lossy(&stderr_buf[..stderr_len]).into_owned();
735
736 Ok((exit_code, stdout, stderr, oom_killed))
737}
738
739#[cfg(test)]
740mod tests {
741 use super::*;
742 use crate::infra::runner_config::ResourceLimits;
743
744 #[test]
745 fn test_host_config_generation() {
746 let limits = ResourceLimits {
747 cpu_cores: 1.5,
748 memory_mb: 256,
749 timeout_secs: 5,
750 output_bytes: 1024,
751 allow_network: false,
752 };
753 let host_config = build_host_config(&limits, None);
754 assert_eq!(host_config.cpu_quota, Some(150_000));
755 assert_eq!(host_config.memory, Some(256 * 1024 * 1024));
756 assert_eq!(host_config.readonly_rootfs, Some(true));
757 assert_eq!(host_config.network_mode.as_deref(), Some("none"));
758
759 assert_eq!(
762 host_config.cap_drop.as_deref(),
763 Some(&["ALL".to_string()][..])
764 );
765 assert_eq!(
766 host_config.security_opt.as_deref(),
767 Some(&["no-new-privileges".to_string()][..])
768 );
769 assert_eq!(host_config.pids_limit, Some(64));
770 assert_eq!(host_config.memory_swap, host_config.memory);
772 assert_eq!(host_config.memory_swap, Some(256 * 1024 * 1024));
773 }
774
775 #[test]
776 fn host_config_cache_volume_binds() {
777 let limits = ResourceLimits {
780 cpu_cores: 1.0,
781 memory_mb: 128,
782 timeout_secs: 5,
783 output_bytes: 1024,
784 allow_network: false,
785 };
786 assert_eq!(build_host_config(&limits, None).binds, None);
787
788 let cache = CacheVolume {
789 name: "yggdrasil-gocache".to_string(),
790 mount_path: "/go-cache".to_string(),
791 };
792 assert_eq!(
793 build_host_config(&limits, Some(&cache)).binds,
794 Some(vec!["yggdrasil-gocache:/go-cache".to_string()])
795 );
796 }
797
798 #[test]
799 fn host_config_ulimits_drops_only_nofile() {
800 let limits = ResourceLimits {
803 cpu_cores: 1.0,
804 memory_mb: 128,
805 timeout_secs: 5,
806 output_bytes: 1024,
807 allow_network: false,
808 };
809 let host_config = build_host_config(&limits, None);
810 let ulimits = host_config.ulimits.expect("ulimits must be set");
811 assert_eq!(ulimits.len(), 1, "only nofile, no nproc");
812 let nf = &ulimits[0];
813 assert_eq!(nf.name.as_deref(), Some("nofile"));
814 assert_eq!(nf.soft, Some(64));
815 assert_eq!(nf.hard, Some(64));
816 }
817
818 #[test]
819 fn host_config_tmpfs_has_exec_on_tmp_only() {
820 let limits = ResourceLimits {
823 cpu_cores: 1.0,
824 memory_mb: 128,
825 timeout_secs: 5,
826 output_bytes: 1024,
827 allow_network: false,
828 };
829 let host_config = build_host_config(&limits, None);
830 let tmpfs = host_config.tmpfs.expect("tmpfs must be set");
831 assert_eq!(tmpfs.len(), 3);
832 assert!(tmpfs["/tmp"].contains("exec"));
833 assert!(!tmpfs["/code"].contains("exec"));
834 assert!(!tmpfs["/run"].contains("exec"));
835 }
836
837 #[test]
838 fn host_config_network_mode_follows_allow_network() {
839 let base = ResourceLimits {
840 cpu_cores: 1.0,
841 memory_mb: 128,
842 timeout_secs: 5,
843 output_bytes: 1024,
844 allow_network: false,
845 };
846 assert_eq!(
847 build_host_config(&base, None).network_mode.as_deref(),
848 Some("none")
849 );
850 let mut net = base;
851 net.allow_network = true;
852 assert_eq!(
853 build_host_config(&net, None).network_mode.as_deref(),
854 Some("bridge")
855 );
856 }
857
858 #[test]
859 fn host_config_cpu_and_memory_scale_with_limits() {
860 let limits = ResourceLimits {
862 cpu_cores: 2.0,
863 memory_mb: 512,
864 timeout_secs: 5,
865 output_bytes: 1024,
866 allow_network: false,
867 };
868 let host_config = build_host_config(&limits, None);
869 assert_eq!(host_config.cpu_quota, Some(200_000));
870 assert_eq!(host_config.cpu_period, Some(100_000));
871 assert_eq!(host_config.memory, Some(512 * 1024 * 1024));
872 assert_eq!(host_config.memory_swap, Some(512 * 1024 * 1024));
873 }
874
875 async fn require_docker_with_image(image: &str) -> Option<&'static Docker> {
888 let docker = DOCKER_CLIENT.as_ref()?;
889 docker.version().await.ok()?;
891 docker.inspect_image(image).await.ok()?;
893 Some(docker)
894 }
895
896 #[tokio::test]
897 #[serial_test::serial]
898 async fn test_run_in_container_success() {
899 if require_docker_with_image("alpine:latest").await.is_none() {
900 eprintln!("skip: Docker daemon 不可用或缺少 alpine:latest");
901 return;
902 }
903 let limits = ResourceLimits {
904 cpu_cores: 1.0,
905 memory_mb: 128,
906 timeout_secs: 5,
907 output_bytes: 1024,
908 allow_network: false,
909 };
910 let (exit_code, stdout, stderr, oom_killed) = run_in_container(
911 "alpine:latest",
912 "cat /code/main.txt",
913 "hello world",
914 "txt",
915 limits,
916 None,
917 )
918 .await
919 .unwrap();
920
921 assert_eq!(exit_code, Some(0));
922 assert_eq!(stdout, "hello world");
923 assert!(stderr.is_empty());
924 assert!(!oom_killed);
925 }
926
927 #[tokio::test]
928 #[serial_test::serial]
929 async fn test_run_in_container_output_truncation() {
930 if require_docker_with_image("alpine:latest").await.is_none() {
931 eprintln!("skip: Docker daemon 不可用或缺少 alpine:latest");
932 return;
933 }
934 let limits = ResourceLimits {
935 cpu_cores: 1.0,
936 memory_mb: 128,
937 timeout_secs: 5,
938 output_bytes: 5,
939 allow_network: false,
940 };
941 let (exit_code, stdout, stderr, oom_killed) = run_in_container(
942 "alpine:latest",
943 "cat /code/main.txt",
944 "hello world",
945 "txt",
946 limits,
947 None,
948 )
949 .await
950 .unwrap();
951
952 assert_eq!(exit_code, Some(0));
953 assert_eq!(stdout, "hello");
954 assert!(stderr.is_empty());
955 assert!(!oom_killed);
956 }
957
958 #[tokio::test]
959 #[serial_test::serial]
960 async fn nonzero_exit_code_is_reported_by_both_paths() {
961 if require_docker_with_image("alpine:latest").await.is_none() {
962 eprintln!("skip: Docker daemon 不可用或缺少 alpine:latest");
963 return;
964 }
965 let limits = ResourceLimits {
966 cpu_cores: 1.0,
967 memory_mb: 128,
968 timeout_secs: 5,
969 output_bytes: 1024,
970 allow_network: false,
971 };
972 let (exit_code, _, stderr, _) = run_in_container(
973 "alpine:latest",
974 "sh /code/main.sh",
975 "echo boom >&2; exit 3",
976 "sh",
977 limits.clone(),
978 None,
979 )
980 .await
981 .unwrap();
982 assert_eq!(exit_code, Some(3));
983 assert_eq!(stderr, "boom\n");
984
985 let (tx, mut rx) = tokio::sync::mpsc::channel(64);
986 let (exit_code, _, _, _) = run_in_container_stream(
987 "alpine:latest",
988 "sh /code/main.sh",
989 "exit 3",
990 "sh",
991 limits,
992 None,
993 tx,
994 )
995 .await
996 .unwrap();
997 assert_eq!(exit_code, Some(3));
998 let mut done_code = None;
999 while let Some(chunk) = rx.recv().await {
1000 if let OutputChunk::Done { exit_code, .. } = chunk {
1001 done_code = exit_code;
1002 }
1003 }
1004 assert_eq!(done_code, Some(3));
1005 }
1006
1007 #[tokio::test]
1008 #[serial_test::serial]
1009 async fn test_run_in_container_timeout() {
1010 if require_docker_with_image("alpine:latest").await.is_none() {
1011 eprintln!("skip: Docker daemon 不可用或缺少 alpine:latest");
1012 return;
1013 }
1014 let limits = ResourceLimits {
1015 cpu_cores: 1.0,
1016 memory_mb: 128,
1017 timeout_secs: 1,
1018 output_bytes: 1024,
1019 allow_network: false,
1020 };
1021 let res = run_in_container("alpine:latest", "sleep 10", "", "txt", limits, None).await;
1022
1023 assert!(res.is_err());
1024 let err = res.unwrap_err();
1025 match err {
1026 bollard::errors::Error::IOError { err } => {
1027 assert_eq!(err.kind(), std::io::ErrorKind::TimedOut);
1028 }
1029 _ => panic!("Expected IOError(TimedOut), got {:?}", err),
1030 }
1031 }
1032
1033 #[tokio::test]
1034 #[serial_test::serial]
1035 async fn stream_timeout_returns_timed_out_without_done_chunk() {
1036 if require_docker_with_image("alpine:latest").await.is_none() {
1037 eprintln!("skip: Docker daemon 不可用或缺少 alpine:latest");
1038 return;
1039 }
1040 let limits = ResourceLimits {
1041 cpu_cores: 1.0,
1042 memory_mb: 128,
1043 timeout_secs: 1,
1044 output_bytes: 1024,
1045 allow_network: false,
1046 };
1047 let (tx, mut rx) = tokio::sync::mpsc::channel(64);
1048 let res =
1049 run_in_container_stream("alpine:latest", "sleep 10", "", "txt", limits, None, tx).await;
1050
1051 match res {
1052 Err(bollard::errors::Error::IOError { err }) => {
1053 assert_eq!(err.kind(), std::io::ErrorKind::TimedOut);
1054 }
1055 other => panic!("Expected IOError(TimedOut), got {:?}", other),
1056 }
1057 while let Some(chunk) = rx.recv().await {
1059 assert!(
1060 !matches!(chunk, OutputChunk::Done { .. }),
1061 "runner pushed Done on timeout"
1062 );
1063 }
1064 }
1065
1066 #[tokio::test]
1067 #[serial_test::serial]
1068 async fn test_run_in_container_cancellation() {
1069 use bollard::query_parameters::ListContainersOptions;
1070 let docker = match require_docker_with_image("alpine:latest").await {
1071 Some(d) => d,
1072 None => {
1073 eprintln!("skip: Docker daemon 不可用或缺少 alpine:latest");
1074 return;
1075 }
1076 };
1077
1078 let before = docker
1079 .list_containers(Some(ListContainersOptions {
1080 all: true,
1081 ..Default::default()
1082 }))
1083 .await
1084 .unwrap();
1085 let before_ids: std::collections::HashSet<String> =
1086 before.into_iter().map(|c| c.id.unwrap()).collect();
1087
1088 let limits = ResourceLimits {
1089 cpu_cores: 1.0,
1090 memory_mb: 128,
1091 timeout_secs: 10,
1092 output_bytes: 1024,
1093 allow_network: false,
1094 };
1095
1096 let (run_fut, cleanup_done) = run_in_container_with_cleanup_signal(
1097 "alpine:latest",
1098 "sleep 100",
1099 "",
1100 "txt",
1101 limits,
1102 None,
1103 );
1104
1105 tokio::select! {
1106 _ = run_fut => {
1107 panic!("Should have been cancelled");
1108 }
1109 _ = tokio::time::sleep(Duration::from_secs(1)) => {
1110 }
1112 }
1113
1114 match timeout(Duration::from_secs(30), cleanup_done).await {
1117 Ok(Ok(true)) => {}
1118 Ok(Ok(false)) => panic!("Container cleanup failed after retries"),
1119 Ok(Err(_)) => {
1120 return;
1122 }
1123 Err(_) => panic!("Container cleanup did not finish within 30 seconds"),
1124 }
1125
1126 let deadline = Duration::from_secs(5);
1129 let poll_interval = Duration::from_millis(250);
1130 let start = std::time::Instant::now();
1131
1132 let leaked = loop {
1133 tokio::time::sleep(poll_interval).await;
1134
1135 let after = docker
1136 .list_containers(Some(ListContainersOptions {
1137 all: true,
1138 ..Default::default()
1139 }))
1140 .await
1141 .unwrap();
1142
1143 let leaked: Vec<String> = after
1144 .into_iter()
1145 .filter(|c| {
1146 c.id.as_ref().is_some_and(|id| !before_ids.contains(id))
1147 && c.image.as_deref() == Some("alpine:latest")
1148 })
1149 .map(|c| c.id.unwrap())
1150 .collect();
1151
1152 if leaked.is_empty() || start.elapsed() >= deadline {
1153 break leaked;
1154 }
1155 };
1156
1157 assert_eq!(
1158 leaked.len(),
1159 0,
1160 "Found {} leaked containers after confirmed cleanup",
1161 leaked.len()
1162 );
1163 }
1164}