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, 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
23pub static DOCKER_CLIENT: LazyLock<Option<Docker>> = LazyLock::new(|| {
33 match Docker::connect_with_unix(
34 &RUNNER_CONFIG.docker_socket_path,
35 120,
36 bollard::API_DEFAULT_VERSION,
37 ) {
38 Ok(d) => Some(d),
39 Err(e) => {
40 tracing::error!(
41 error = ?e,
42 socket = %RUNNER_CONFIG.docker_socket_path,
43 "无法连接 Docker daemon,代码运行功能将不可用。\
44 请确认 Docker 已安装并运行,或设置正确的 DOCKER_SOCKET_PATH",
45 );
46 None
47 }
48 }
49});
50
51fn get_docker() -> Result<&'static Docker, bollard::errors::Error> {
56 DOCKER_CLIENT
57 .as_ref()
58 .ok_or_else(|| bollard::errors::Error::IOError {
59 err: std::io::Error::new(
60 std::io::ErrorKind::NotFound,
61 "Docker daemon 不可用(未安装或未运行)",
62 ),
63 })
64}
65
66#[derive(Clone, Debug, PartialEq)]
71pub struct CacheVolume {
72 pub name: String,
73 pub mount_path: String,
74}
75
76pub fn build_host_config(
77 limits: &ResourceLimits,
78 cache_volume: Option<&CacheVolume>,
79) -> HostConfig {
80 let mut tmpfs = HashMap::new();
81 tmpfs.insert("/code".to_string(), "size=16m,mode=1777".to_string());
86 tmpfs.insert("/tmp".to_string(), "size=64m,mode=1777,exec".to_string());
90 tmpfs.insert("/run".to_string(), "size=16m,mode=1777".to_string());
91
92 let memory = (limits.memory_mb * 1024 * 1024) as i64;
93
94 HostConfig {
95 cpu_quota: Some((limits.cpu_cores * 100_000.0) as i64),
96 cpu_period: Some(100_000),
97 memory: Some(memory),
98 memory_swap: Some(memory), network_mode: Some(if limits.allow_network {
100 "bridge".to_string()
101 } else {
102 "none".to_string()
103 }),
104 readonly_rootfs: Some(true),
105 tmpfs: Some(tmpfs),
106 binds: cache_volume.map(|cv| vec![format!("{}:{}", cv.name, cv.mount_path)]),
110 pids_limit: Some(64),
111 ulimits: Some(vec![ResourcesUlimits {
116 name: Some("nofile".to_string()),
117 soft: Some(64),
118 hard: Some(64),
119 }]),
120 cap_drop: Some(vec!["ALL".to_string()]),
121 security_opt: Some(vec!["no-new-privileges".to_string()]),
122 auto_remove: Some(false), ..Default::default()
124 }
125}
126
127struct ContainerGuard {
128 container_id: String,
129 docker: Docker,
130 cleanup_signal: Option<CleanupSignal>,
131}
132
133async fn remove_container_with_retry(docker: Docker, container_id: String) -> bool {
134 let max_attempts = 3u8;
135 let mut backoff = Duration::from_millis(200);
136 for attempt in 1..=max_attempts {
137 let remove_options = Some(RemoveContainerOptions {
138 force: true,
139 ..Default::default()
140 });
141 match docker.remove_container(&container_id, remove_options).await {
142 Ok(()) => return true,
143 Err(e) if attempt < max_attempts => {
144 tracing::warn!(
145 attempt,
146 max_attempts,
147 "remove_container 失败,稍后重试: {:?}",
148 e
149 );
150 tokio::time::sleep(backoff).await;
151 backoff *= 2;
152 }
153 Err(e) => {
154 tracing::error!(
155 container_id = %container_id,
156 "重试 {} 次后仍无法删除容器,可能泄漏;请手动执行 `docker rm -f {}`: {:?}",
157 max_attempts,
158 container_id,
159 e
160 );
161 return false;
162 }
163 }
164 }
165 false
166}
167
168impl Drop for ContainerGuard {
169 fn drop(&mut self) {
170 let docker = self.docker.clone();
171 let container_id = self.container_id.clone();
172 let cleanup_signal = self.cleanup_signal.take();
173 tokio::spawn(async move {
178 let cleaned = remove_container_with_retry(docker, container_id).await;
179 if let Some(signal) = cleanup_signal {
180 let _ = signal.send(cleaned);
181 }
182 });
183 }
184}
185
186pub async fn run_in_container(
187 image_name: &str,
188 run_cmd: &str,
189 source: &str,
190 ext: &str,
191 limits: ResourceLimits,
192 cache_volume: Option<&CacheVolume>,
193) -> ContainerResult {
194 run_in_container_inner(image_name, run_cmd, source, ext, limits, cache_volume, None).await
195}
196
197async fn run_in_container_inner(
198 image_name: &str,
199 run_cmd: &str,
200 source: &str,
201 ext: &str,
202 limits: ResourceLimits,
203 cache_volume: Option<&CacheVolume>,
204 cleanup_signal: Option<CleanupSignal>,
205) -> ContainerResult {
206 let docker = get_docker()?;
207 let host_config = build_host_config(&limits, cache_volume);
208
209 let setup_cmd = format!("cat > /code/main.{} && exec {}", ext, run_cmd);
211 let cmd = vec!["sh".to_string(), "-c".to_string(), setup_cmd];
212
213 let config = ContainerCreateBody {
214 image: Some(image_name.to_string()),
215 cmd: Some(cmd),
216 host_config: Some(host_config),
217 attach_stdin: Some(true),
218 attach_stdout: Some(true),
219 attach_stderr: Some(true),
220 open_stdin: Some(true),
221 stdin_once: Some(true),
222 user: Some("1000:1000".to_string()), working_dir: Some("/code".to_string()),
224 ..Default::default()
225 };
226
227 let container = docker
228 .create_container(None::<CreateContainerOptions>, config)
229 .await?;
230
231 let container_id = container.id;
232 let _guard = ContainerGuard {
233 container_id: container_id.clone(),
234 docker: docker.clone(),
235 cleanup_signal,
236 };
237
238 let attach_res = docker
240 .attach_container(
241 &container_id,
242 Some(AttachContainerOptions {
243 stdin: true,
244 stdout: true,
245 stderr: true,
246 stream: true,
247 logs: false,
248 ..Default::default()
249 }),
250 )
251 .await;
252
253 let (mut writer, mut stream) = match attach_res {
254 Ok(res) => (res.input, res.output),
255 Err(e) => return Err(e),
256 };
257
258 docker
260 .start_container(&container_id, None::<StartContainerOptions>)
261 .await?;
262
263 use tokio::io::AsyncWriteExt;
265 let write_fut = async {
266 let _ = writer.write_all(source.as_bytes()).await;
267 let _ = writer.flush().await;
268 let _ = writer.shutdown().await;
269 };
270
271 if timeout(Duration::from_secs(5), write_fut).await.is_err() {
272 return Err(bollard::errors::Error::IOError {
273 err: std::io::Error::new(std::io::ErrorKind::TimedOut, "Writing to stdin timed out"),
274 });
275 }
276 drop(writer);
277
278 let wait_future = async {
280 let mut wait_stream = docker.wait_container(&container_id, None::<WaitContainerOptions>);
281 wait_stream.next().await
282 };
283
284 let wait_res = timeout(Duration::from_secs(limits.timeout_secs), wait_future).await;
285
286 let mut timed_out = false;
287 let mut exit_code = None;
288
289 match wait_res {
290 Ok(Some(Ok(exit_status))) => {
291 exit_code = Some(exit_status.status_code);
292 }
293 Ok(_) => {} Err(_) => {
295 timed_out = true;
297 let _ = docker.kill_container(&container_id, None).await;
298 }
299 }
300
301 let mut stdout_buf = Vec::new();
303 let mut stderr_buf = Vec::new();
304
305 while let Some(item) = stream.next().await {
306 match item {
307 Ok(chunk) => {
308 match chunk {
309 LogOutput::StdOut { message } => {
310 let remaining = (limits.output_bytes as usize)
311 .saturating_sub(stdout_buf.len() + stderr_buf.len());
312 if remaining > 0 {
313 let to_add = message.len().min(remaining);
314 stdout_buf.extend_from_slice(&message[..to_add]);
315 }
316 }
317 LogOutput::StdErr { message } => {
318 let remaining = (limits.output_bytes as usize)
319 .saturating_sub(stdout_buf.len() + stderr_buf.len());
320 if remaining > 0 {
321 let to_add = message.len().min(remaining);
322 stderr_buf.extend_from_slice(&message[..to_add]);
323 }
324 }
325 _ => {}
326 }
327 if stdout_buf.len() + stderr_buf.len() >= limits.output_bytes as usize {
328 break;
329 }
330 }
331 Err(e) => {
332 tracing::error!("Error reading container log stream: {:?}", e);
333 break;
334 }
335 }
336 }
337
338 let inspect = docker.inspect_container(&container_id, None).await;
340 let oom_killed = inspect
341 .ok()
342 .and_then(|info| info.state.and_then(|s| s.oom_killed))
343 .unwrap_or(false);
344
345 let limit_bytes = limits.output_bytes as usize;
347 let stdout_len = stdout_buf.len().min(limit_bytes);
348 let stderr_len = stderr_buf.len().min(limit_bytes);
349
350 let stdout = String::from_utf8_lossy(&stdout_buf[..stdout_len]).into_owned();
351 let stderr = String::from_utf8_lossy(&stderr_buf[..stderr_len]).into_owned();
352
353 if timed_out {
354 return Err(bollard::errors::Error::IOError {
355 err: std::io::Error::new(std::io::ErrorKind::TimedOut, "Execution timed out"),
356 });
357 }
358
359 Ok((exit_code, stdout, stderr, oom_killed))
360}
361
362#[cfg(test)]
363fn run_in_container_with_cleanup_signal<'a>(
364 image_name: &'a str,
365 run_cmd: &'a str,
366 source: &'a str,
367 ext: &'a str,
368 limits: ResourceLimits,
369 cache_volume: Option<&'a CacheVolume>,
370) -> (
371 impl Future<Output = ContainerResult> + 'a,
372 oneshot::Receiver<bool>,
373) {
374 let (sender, receiver) = oneshot::channel();
375 (
376 run_in_container_inner(
377 image_name,
378 run_cmd,
379 source,
380 ext,
381 limits,
382 cache_volume,
383 Some(sender),
384 ),
385 receiver,
386 )
387}
388
389#[derive(Clone, Debug)]
393pub enum OutputChunk {
394 Stdout(String),
396 Stderr(String),
398 Done {
401 exit_code: Option<i64>,
402 oom_killed: bool,
403 timed_out: bool,
404 duration_ms: u64,
405 error: Option<String>,
409 },
410}
411
412pub async fn run_in_container_stream(
424 image_name: &str,
425 run_cmd: &str,
426 source: &str,
427 ext: &str,
428 limits: ResourceLimits,
429 cache_volume: Option<&CacheVolume>,
430 tx: tokio::sync::mpsc::Sender<OutputChunk>,
431) -> Result<(Option<i64>, String, String, bool, bool), bollard::errors::Error> {
432 let docker = get_docker()?;
433 let host_config = build_host_config(&limits, cache_volume);
434
435 let setup_cmd = format!("cat > /code/main.{} && exec {}", ext, run_cmd);
437 let cmd = vec!["sh".to_string(), "-c".to_string(), setup_cmd];
438
439 let config = ContainerCreateBody {
440 image: Some(image_name.to_string()),
441 cmd: Some(cmd),
442 host_config: Some(host_config),
443 attach_stdin: Some(true),
444 attach_stdout: Some(true),
445 attach_stderr: Some(true),
446 open_stdin: Some(true),
447 stdin_once: Some(true),
448 user: Some("1000:1000".to_string()),
449 working_dir: Some("/code".to_string()),
450 ..Default::default()
451 };
452
453 let container = docker
454 .create_container(None::<CreateContainerOptions>, config)
455 .await?;
456
457 let container_id = container.id;
458 let _guard = ContainerGuard {
459 container_id: container_id.clone(),
460 docker: docker.clone(),
461 cleanup_signal: None,
462 };
463
464 let attach_res = docker
466 .attach_container(
467 &container_id,
468 Some(AttachContainerOptions {
469 stdin: true,
470 stdout: true,
471 stderr: true,
472 stream: true,
473 logs: false,
474 ..Default::default()
475 }),
476 )
477 .await;
478
479 let (mut writer, mut stream) = match attach_res {
480 Ok(res) => (res.input, res.output),
481 Err(e) => return Err(e),
482 };
483
484 docker
485 .start_container(&container_id, None::<StartContainerOptions>)
486 .await?;
487
488 let start_time = std::time::Instant::now();
490
491 use tokio::io::AsyncWriteExt;
493 let write_fut = async {
494 let _ = writer.write_all(source.as_bytes()).await;
495 let _ = writer.flush().await;
496 let _ = writer.shutdown().await;
497 };
498
499 if timeout(Duration::from_secs(5), write_fut).await.is_err() {
500 return Err(bollard::errors::Error::IOError {
501 err: std::io::Error::new(std::io::ErrorKind::TimedOut, "Writing to stdin timed out"),
502 });
503 }
504 drop(writer);
505
506 let mut stdout_buf = Vec::new();
517 let mut stderr_buf = Vec::new();
518 let limit_bytes = limits.output_bytes as usize;
519 let mut client_disconnected = false;
520 let mut timed_out = false;
521 let mut exit_code = None;
522
523 let log_reader = async {
527 while let Some(item) = stream.next().await {
528 match item {
529 Ok(chunk) => match chunk {
530 LogOutput::StdOut { message } => {
531 let remaining =
532 limit_bytes.saturating_sub(stdout_buf.len() + stderr_buf.len());
533 if remaining > 0 {
534 let to_add = message.len().min(remaining);
535 let slice = &message[..to_add];
536 stdout_buf.extend_from_slice(slice);
537 if !client_disconnected {
538 let text = String::from_utf8_lossy(slice).into_owned();
539 if tx.send(OutputChunk::Stdout(text)).await.is_err() {
540 client_disconnected = true;
541 }
542 }
543 }
544 }
545 LogOutput::StdErr { message } => {
546 let remaining =
547 limit_bytes.saturating_sub(stdout_buf.len() + stderr_buf.len());
548 if remaining > 0 {
549 let to_add = message.len().min(remaining);
550 let slice = &message[..to_add];
551 stderr_buf.extend_from_slice(slice);
552 if !client_disconnected {
553 let text = String::from_utf8_lossy(slice).into_owned();
554 if tx.send(OutputChunk::Stderr(text)).await.is_err() {
555 client_disconnected = true;
556 }
557 }
558 }
559 }
560 _ => {}
561 },
562 Err(e) => {
563 tracing::error!("Error reading container log stream: {:?}", e);
564 break;
565 }
566 }
567 if stdout_buf.len() + stderr_buf.len() >= limit_bytes {
568 break;
569 }
570 }
571 };
572
573 let wait_future = async {
575 let mut wait_stream = docker.wait_container(&container_id, None::<WaitContainerOptions>);
576 wait_stream.next().await
577 };
578 let wait_with_timeout = async {
579 match timeout(Duration::from_secs(limits.timeout_secs), wait_future).await {
580 Ok(Some(Ok(exit_status))) => Some(exit_status.status_code),
581 Ok(Some(Err(_))) | Ok(None) => None, Err(_) => {
583 timed_out = true;
585 let _ = docker.kill_container(&container_id, None).await;
586 None
587 }
588 }
589 };
590
591 tokio::select! {
595 status = wait_with_timeout => {
596 exit_code = status;
597 while let Some(item) = stream.next().await {
600 if let Ok(chunk) = item {
601 match chunk {
602 LogOutput::StdOut { message } => {
603 let remaining = limit_bytes.saturating_sub(stdout_buf.len() + stderr_buf.len());
604 let to_add = message.len().min(remaining);
605 if to_add > 0 {
606 let slice = &message[..to_add];
607 stdout_buf.extend_from_slice(slice);
608 if !client_disconnected {
609 let text = String::from_utf8_lossy(slice).into_owned();
610 let _ = tx.send(OutputChunk::Stdout(text)).await;
611 }
612 }
613 }
614 LogOutput::StdErr { message } => {
615 let remaining = limit_bytes.saturating_sub(stdout_buf.len() + stderr_buf.len());
616 let to_add = message.len().min(remaining);
617 if to_add > 0 {
618 let slice = &message[..to_add];
619 stderr_buf.extend_from_slice(slice);
620 if !client_disconnected {
621 let text = String::from_utf8_lossy(slice).into_owned();
622 let _ = tx.send(OutputChunk::Stderr(text)).await;
623 }
624 }
625 }
626 _ => {}
627 }
628 }
629 }
630 }
631 _ = log_reader => {
632 let mut wait_stream = docker.wait_container(&container_id, None::<WaitContainerOptions>);
638 match timeout(Duration::from_secs(limits.timeout_secs), wait_stream.next()).await {
639 Ok(Some(Ok(status))) => exit_code = Some(status.status_code),
640 Ok(Some(Err(_))) | Ok(None) => {}
641 Err(_) => {
642 timed_out = true;
644 let _ = docker.kill_container(&container_id, None).await;
645 if let Some(Ok(status)) = wait_stream.next().await {
646 exit_code = Some(status.status_code);
647 }
648 }
649 }
650 }
651 }
652
653 let inspect = docker.inspect_container(&container_id, None).await;
655 let oom_killed = inspect
656 .ok()
657 .and_then(|info| info.state.and_then(|s| s.oom_killed))
658 .unwrap_or(false);
659 let duration_ms = start_time.elapsed().as_millis() as u64;
661 if !client_disconnected {
662 let _ = tx
663 .send(OutputChunk::Done {
664 exit_code,
665 oom_killed,
666 timed_out,
667 duration_ms,
668 error: None,
669 })
670 .await;
671 }
672
673 let stdout_len = stdout_buf.len().min(limit_bytes);
674 let stderr_len = stderr_buf.len().min(limit_bytes);
675 let stdout = String::from_utf8_lossy(&stdout_buf[..stdout_len]).into_owned();
676 let stderr = String::from_utf8_lossy(&stderr_buf[..stderr_len]).into_owned();
677
678 if timed_out {
679 return Err(bollard::errors::Error::IOError {
680 err: std::io::Error::new(std::io::ErrorKind::TimedOut, "Execution timed out"),
681 });
682 }
683
684 Ok((exit_code, stdout, stderr, oom_killed, timed_out))
685}
686
687#[cfg(test)]
688mod tests {
689 use super::*;
690 use crate::infra::runner_config::ResourceLimits;
691
692 #[test]
693 fn test_host_config_generation() {
694 let limits = ResourceLimits {
695 cpu_cores: 1.5,
696 memory_mb: 256,
697 timeout_secs: 5,
698 output_bytes: 1024,
699 allow_network: false,
700 };
701 let host_config = build_host_config(&limits, None);
702 assert_eq!(host_config.cpu_quota, Some(150_000));
703 assert_eq!(host_config.memory, Some(256 * 1024 * 1024));
704 assert_eq!(host_config.readonly_rootfs, Some(true));
705 assert_eq!(host_config.network_mode.as_deref(), Some("none"));
706
707 assert_eq!(
710 host_config.cap_drop.as_deref(),
711 Some(&["ALL".to_string()][..])
712 );
713 assert_eq!(
714 host_config.security_opt.as_deref(),
715 Some(&["no-new-privileges".to_string()][..])
716 );
717 assert_eq!(host_config.pids_limit, Some(64));
718 assert_eq!(host_config.memory_swap, host_config.memory);
720 assert_eq!(host_config.memory_swap, Some(256 * 1024 * 1024));
721 }
722
723 #[test]
724 fn host_config_cache_volume_binds() {
725 let limits = ResourceLimits {
728 cpu_cores: 1.0,
729 memory_mb: 128,
730 timeout_secs: 5,
731 output_bytes: 1024,
732 allow_network: false,
733 };
734 assert_eq!(build_host_config(&limits, None).binds, None);
735
736 let cache = CacheVolume {
737 name: "yggdrasil-gocache".to_string(),
738 mount_path: "/go-cache".to_string(),
739 };
740 assert_eq!(
741 build_host_config(&limits, Some(&cache)).binds,
742 Some(vec!["yggdrasil-gocache:/go-cache".to_string()])
743 );
744 }
745
746 #[test]
747 fn host_config_ulimits_drops_only_nofile() {
748 let limits = ResourceLimits {
751 cpu_cores: 1.0,
752 memory_mb: 128,
753 timeout_secs: 5,
754 output_bytes: 1024,
755 allow_network: false,
756 };
757 let host_config = build_host_config(&limits, None);
758 let ulimits = host_config.ulimits.expect("ulimits must be set");
759 assert_eq!(ulimits.len(), 1, "only nofile, no nproc");
760 let nf = &ulimits[0];
761 assert_eq!(nf.name.as_deref(), Some("nofile"));
762 assert_eq!(nf.soft, Some(64));
763 assert_eq!(nf.hard, Some(64));
764 }
765
766 #[test]
767 fn host_config_tmpfs_has_exec_on_tmp_only() {
768 let limits = ResourceLimits {
771 cpu_cores: 1.0,
772 memory_mb: 128,
773 timeout_secs: 5,
774 output_bytes: 1024,
775 allow_network: false,
776 };
777 let host_config = build_host_config(&limits, None);
778 let tmpfs = host_config.tmpfs.expect("tmpfs must be set");
779 assert_eq!(tmpfs.len(), 3);
780 assert!(tmpfs["/tmp"].contains("exec"));
781 assert!(!tmpfs["/code"].contains("exec"));
782 assert!(!tmpfs["/run"].contains("exec"));
783 }
784
785 #[test]
786 fn host_config_network_mode_follows_allow_network() {
787 let base = ResourceLimits {
788 cpu_cores: 1.0,
789 memory_mb: 128,
790 timeout_secs: 5,
791 output_bytes: 1024,
792 allow_network: false,
793 };
794 assert_eq!(
795 build_host_config(&base, None).network_mode.as_deref(),
796 Some("none")
797 );
798 let mut net = base;
799 net.allow_network = true;
800 assert_eq!(
801 build_host_config(&net, None).network_mode.as_deref(),
802 Some("bridge")
803 );
804 }
805
806 #[test]
807 fn host_config_cpu_and_memory_scale_with_limits() {
808 let limits = ResourceLimits {
810 cpu_cores: 2.0,
811 memory_mb: 512,
812 timeout_secs: 5,
813 output_bytes: 1024,
814 allow_network: false,
815 };
816 let host_config = build_host_config(&limits, None);
817 assert_eq!(host_config.cpu_quota, Some(200_000));
818 assert_eq!(host_config.cpu_period, Some(100_000));
819 assert_eq!(host_config.memory, Some(512 * 1024 * 1024));
820 assert_eq!(host_config.memory_swap, Some(512 * 1024 * 1024));
821 }
822
823 async fn require_docker_with_image(image: &str) -> Option<&'static Docker> {
836 let docker = DOCKER_CLIENT.as_ref()?;
837 docker.version().await.ok()?;
839 docker.inspect_image(image).await.ok()?;
841 Some(docker)
842 }
843
844 #[tokio::test]
845 #[serial_test::serial]
846 async fn test_run_in_container_success() {
847 if require_docker_with_image("alpine:latest").await.is_none() {
848 eprintln!("skip: Docker daemon 不可用或缺少 alpine:latest");
849 return;
850 }
851 let limits = ResourceLimits {
852 cpu_cores: 1.0,
853 memory_mb: 128,
854 timeout_secs: 5,
855 output_bytes: 1024,
856 allow_network: false,
857 };
858 let (exit_code, stdout, stderr, oom_killed) = run_in_container(
859 "alpine:latest",
860 "cat /code/main.txt",
861 "hello world",
862 "txt",
863 limits,
864 None,
865 )
866 .await
867 .unwrap();
868
869 assert_eq!(exit_code, Some(0));
870 assert_eq!(stdout, "hello world");
871 assert!(stderr.is_empty());
872 assert!(!oom_killed);
873 }
874
875 #[tokio::test]
876 #[serial_test::serial]
877 async fn test_run_in_container_output_truncation() {
878 if require_docker_with_image("alpine:latest").await.is_none() {
879 eprintln!("skip: Docker daemon 不可用或缺少 alpine:latest");
880 return;
881 }
882 let limits = ResourceLimits {
883 cpu_cores: 1.0,
884 memory_mb: 128,
885 timeout_secs: 5,
886 output_bytes: 5,
887 allow_network: false,
888 };
889 let (exit_code, stdout, stderr, oom_killed) = run_in_container(
890 "alpine:latest",
891 "cat /code/main.txt",
892 "hello world",
893 "txt",
894 limits,
895 None,
896 )
897 .await
898 .unwrap();
899
900 assert_eq!(exit_code, Some(0));
901 assert_eq!(stdout, "hello");
902 assert!(stderr.is_empty());
903 assert!(!oom_killed);
904 }
905
906 #[tokio::test]
907 #[serial_test::serial]
908 async fn test_run_in_container_timeout() {
909 if require_docker_with_image("alpine:latest").await.is_none() {
910 eprintln!("skip: Docker daemon 不可用或缺少 alpine:latest");
911 return;
912 }
913 let limits = ResourceLimits {
914 cpu_cores: 1.0,
915 memory_mb: 128,
916 timeout_secs: 1,
917 output_bytes: 1024,
918 allow_network: false,
919 };
920 let res = run_in_container("alpine:latest", "sleep 10", "", "txt", limits, None).await;
921
922 assert!(res.is_err());
923 let err = res.unwrap_err();
924 match err {
925 bollard::errors::Error::IOError { err } => {
926 assert_eq!(err.kind(), std::io::ErrorKind::TimedOut);
927 }
928 _ => panic!("Expected IOError(TimedOut), got {:?}", err),
929 }
930 }
931
932 #[tokio::test]
933 #[serial_test::serial]
934 async fn test_run_in_container_cancellation() {
935 use bollard::query_parameters::ListContainersOptions;
936 let docker = match require_docker_with_image("alpine:latest").await {
937 Some(d) => d,
938 None => {
939 eprintln!("skip: Docker daemon 不可用或缺少 alpine:latest");
940 return;
941 }
942 };
943
944 let before = docker
945 .list_containers(Some(ListContainersOptions {
946 all: true,
947 ..Default::default()
948 }))
949 .await
950 .unwrap();
951 let before_ids: std::collections::HashSet<String> =
952 before.into_iter().map(|c| c.id.unwrap()).collect();
953
954 let limits = ResourceLimits {
955 cpu_cores: 1.0,
956 memory_mb: 128,
957 timeout_secs: 10,
958 output_bytes: 1024,
959 allow_network: false,
960 };
961
962 let (run_fut, cleanup_done) = run_in_container_with_cleanup_signal(
963 "alpine:latest",
964 "sleep 100",
965 "",
966 "txt",
967 limits,
968 None,
969 );
970
971 tokio::select! {
972 _ = run_fut => {
973 panic!("Should have been cancelled");
974 }
975 _ = tokio::time::sleep(Duration::from_secs(1)) => {
976 }
978 }
979
980 match timeout(Duration::from_secs(30), cleanup_done).await {
983 Ok(Ok(true)) => {}
984 Ok(Ok(false)) => panic!("Container cleanup failed after retries"),
985 Ok(Err(_)) => {
986 return;
988 }
989 Err(_) => panic!("Container cleanup did not finish within 30 seconds"),
990 }
991
992 let deadline = Duration::from_secs(5);
995 let poll_interval = Duration::from_millis(250);
996 let start = std::time::Instant::now();
997
998 let leaked = loop {
999 tokio::time::sleep(poll_interval).await;
1000
1001 let after = docker
1002 .list_containers(Some(ListContainersOptions {
1003 all: true,
1004 ..Default::default()
1005 }))
1006 .await
1007 .unwrap();
1008
1009 let leaked: Vec<String> = after
1010 .into_iter()
1011 .filter(|c| {
1012 c.id.as_ref().is_some_and(|id| !before_ids.contains(id))
1013 && c.image.as_deref() == Some("alpine:latest")
1014 })
1015 .map(|c| c.id.unwrap())
1016 .collect();
1017
1018 if leaked.is_empty() || start.elapsed() >= deadline {
1019 break leaked;
1020 }
1021 };
1022
1023 assert_eq!(
1024 leaked.len(),
1025 0,
1026 "Found {} leaked containers after confirmed cleanup",
1027 leaked.len()
1028 );
1029 }
1030}