Skip to main content

yggdrasil/infra/
docker.rs

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
23/// 容器退出或被 kill 后,读取 attach 流剩余日志的最长等待时间。
24///
25/// 正常情况下流会在容器退出后立即结束;该上限只兜底 kill 失败等异常,
26/// 保证执行函数必然返回并释放信号量许可与 [`ContainerGuard`]。
27const LOG_DRAIN_TIMEOUT: Duration = Duration::from_secs(5);
28
29/// 取 `wait_container` 单条结果里的退出码。
30///
31/// bollard 把非零退出码报成 `Err(DockerContainerWaitError { code, .. })`:容器已正常退出,
32/// 只是程序返回非零。其余错误才是真的拿不到退出码。
33fn 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
41/// 共享的 Docker 客户端。
42///
43/// `connect_with_unix` 会立即探测 socket 是否存在:缺失(未安装 / 未运行 Docker,
44/// 或 `DOCKER_SOCKET_PATH` 指错)时返回 `SocketNotFoundError`。
45///
46/// 连接失败不 panic:`LazyLock` 初始化 panic 后会中毒,之后每次访问都再 panic,
47/// 而博客本身并不依赖 Docker。改为记 error 日志后返回 `None`,代码运行器把
48/// `None` 转成普通 bollard 错误向上冒泡,最终在 execute.rs 的错误脱敏层统一映射为
49/// 「系统暂时不可用」,其余功能不受影响。
50pub 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
69/// 取共享 Docker 客户端;daemon 不可用时返回 IOError(NotFound)。
70///
71/// `NotFound` 既不命中 `TimedOut` 也不命中超时判断,会走 execute.rs 的通用失败路径
72/// (`ExecStatus::Failed` + 「系统暂时不可用」),不会误报成超时。
73fn 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/// 跨运行持久化的编译缓存(Docker named volume)。
85///
86/// 语言镜像把编译缓存目录(如 Go 的 GOCACHE)指到 `mount_path`,docker.rs 把
87/// 共享 named volume 挂载到该路径,使每次运行复用上次编译产物。仅编译型语言使用。
88#[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    // /code 用 mode=1777(sticky + all-rwx)让容器内 1000:1000 用户可写。
100    // 不用 `uid=1000,gid=1000`:那是 Docker 的 tmpfs 扩展选项,Podman 报
101    // `unknown mount option "uid=1000"`。mode=1777 是 POSIX 标准 tmpfs 选项,
102    // Docker 与 Podman 都支持,语义等价(任意 UID 可写 /code)。
103    tmpfs.insert("/code".to_string(), "size=16m,mode=1777".to_string());
104    // /tmp 必须 exec:编译型语言(go/rust)把编译产物落在 /tmp 后再 exec,
105    // Docker tmpfs 默认 noexec 会让执行二进制时报 EACCES(permission denied)。
106    // 解释型语言(python/node)执行根文件系统的解释器,不受影响。
107    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), // = memory, disable swap
117        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        // 编译缓存 named volume:裸卷名 `name:/path` 即命名卷挂载,Docker/Podman
125        // 都会自动创建卷。binds 不受 readonly_rootfs 影响,挂载点仍可写;
126        // mount_path(如 /go-cache)是镜像内新路径,与 tmpfs 挂载无叠加。
127        binds: cache_volume.map(|cv| vec![format!("{}:{}", cv.name, cv.mount_path)]),
128        pids_limit: Some(64),
129        // 只保留 nofile(fd 数上限,语义正常)。
130        // 不设 nproc:RLIMIT_NPROC 在 setrlimit 时按 UID 计数,配合 non-root 用户会让
131        // 容器初始 exec /bin/sh 直接 EAGAIN("exec: resource temporarily unavailable"),
132        // 与容器内实际进程数无关。pids_limit 已在 cgroup 层兜底,nproc 是冗余且有害的双重约束。
133        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), // must be false to avoid premature removal before getting logs
141        ..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        // 容器清理是 fire-and-forget:调用方已返回,无法把错误回传给业务层。
192        // 因此重试几次以抵抗瞬时故障(daemon 繁忙 / socket 抖动),
193        // 仍失败则记录 error 级日志并带上 container_id,便于运维手动 `docker rm -f` 清理,
194        // 避免容器静默泄漏、长期堆积。
195        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    // Source injection script: use sh -c to first receive stdin and write to file, then exec the actual command
228    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()), // non-root user
241        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    // Attach to container to stream stdin, stdout, and stderr
257    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    // Start container
277    docker
278        .start_container(&container_id, None::<StartContainerOptions>)
279        .await?;
280
281    // Write source code to stdin and drop/close the writer
282    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    // Wait for execution with timeout control
297    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                // wait 出错或流提前结束:无法确认容器已退出,同样 kill,
312                // 避免容器继续运行导致下方日志流永不结束。
313                let _ = docker.kill_container(&container_id, None).await;
314            }
315        }
316        Err(_) => {
317            // timeout, kill container
318            timed_out = true;
319            let _ = docker.kill_container(&container_id, None).await;
320        }
321    }
322
323    // Collect logs
324    let mut stdout_buf = Vec::new();
325    let mut stderr_buf = Vec::new();
326
327    // 容器已退出或已被 kill,attach 流随后应结束;有界等待兜底 kill 失败等情况,
328    // 保证函数一定返回,调用方的信号量许可与 ContainerGuard 随之释放。
329    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    // Check OOM status
371    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    // Truncate output to limits.output_bytes
378    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/// 流式输出 chunk:run_in_container_stream 边读日志边推送给 SSE handler。
422///
423/// 序列化后作为 SSE event data;`Done` 同时携带终态信息(退出码 / OOM / 超时 / 耗时)。
424#[derive(Clone, Debug)]
425pub enum OutputChunk {
426    /// 执行阶段:`queued`(等并发槽位)、`preparing`(拉起容器)、`running`(容器已启动)。
427    Stage(&'static str),
428    /// stdout 块(容器逐块产出)。
429    Stdout(String),
430    /// stderr 块(容器逐块产出)。
431    Stderr(String),
432    /// 终态:容器执行结束。exit_code=None 表示拿不到退出码(wait 出错)。
433    /// duration_ms = start_container 到 wait 完成的耗时。
434    Done {
435        exit_code: Option<i64>,
436        oom_killed: bool,
437        timed_out: bool,
438        duration_ms: u64,
439        /// 系统级错误消息(执行超时 / 镜像缺失 / daemon 不可达等)。正常执行时为 None;
440        /// 由 spawn_exec_task 的 Err 分支推送,让前端 SSE 能拿到可操作消息,
441        /// 而非笼统的「连接异常」。
442        error: Option<String>,
443    },
444}
445
446/// 流式执行:与 [`run_in_container`] 相同的容器生命周期与清理(`ContainerGuard`),
447/// 但边读日志流边推 chunk 到 `tx`,同时保留完整 buffer 供调用方回填 EXEC_TASKS。
448///
449/// 与 `run_in_container` 的差异:
450/// 1. 日志循环里每块 chunk 既 `tx.send` 推流,也 append 到本地 buffer。
451/// 2. 用 `tokio::select!` 在日志读取中并发等待 `tx` 关闭——客户端断开(SSE 关闭)
452///    → `tx` 所有 Sender drop → `rx` 返回 None → 中止读取。
453/// 3. 仅在返回 `Ok` 时推终态 `OutputChunk::Done`;返回 `Err`(含执行超时)时不推,
454///    由调用方统一补推,保证每次执行恰好一个 `Done`。
455///
456/// 返回完整 buffer(exit_code / stdout / stderr / oom),供调用方写 EXEC_TASKS,
457/// 让轮询兜底路径(get_exec_result)也能拿到完整结果。
458pub 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    // 与 run_in_container 相同的 stdin 注入脚本。
471    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    // Attach 到容器的 stdin/stdout/stderr 流。
500    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    // 容器开始执行的时刻,用于计算 duration_ms(start_container 返回即视为起点)。
525    let start_time = std::time::Instant::now();
526
527    // 写入源码到 stdin 后关闭 writer。
528    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    // —— 关键:wait_container 与日志读取必须并发,否则流式失效 ——
543    //
544    // 若先 await wait_container(等容器退出)再读日志流,wait 会阻塞到程序结束,
545    // 届时 attach stream 里已缓冲全部输出,stream.next() 一次性快速读完——
546    // 表现为"等完再一次性输出",流式名存实亡。
547    //
548    // 用 tokio::select! 让两条分支并发:
549    // - log_reader:持续读 attach stream,每块 chunk 立即 tx.send 推流 + 累积 buffer
550    // - wait_with_timeout:等容器退出(带超时),退出后日志流自然结束(stream 返回 None)
551    // 先完成的一方触发 select 返回;若 wait 超时则 kill 容器。
552    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    // 日志读取循环:逐块推流 + 累积 buffer。
560    // 循环正常退出条件:stream 返回 None(容器退出后 Docker 关闭 attach 流),
561    // 或输出超限 break,或 select 被另一分支抢先完成(log_reader 被 drop)。
562    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    // 带超时地等待容器退出。
610    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                    // wait 出错或流提前结束:无法确认容器已退出,kill 以免下方日志流永不结束。
620                    let _ = docker.kill_container(&container_id, None).await;
621                }
622                code
623            }
624            Err(_) => {
625                // 超时,杀容器。kill 后 attach stream 会被 Docker 关闭,log_reader 自然结束。
626                timed_out = true;
627                let _ = docker.kill_container(&container_id, None).await;
628                None
629            }
630        }
631    };
632
633    // 并发:哪边先完成就先用其结果。
634    // 通常 wait 先完成(容器退出 → Docker 关闭 attach stream → log_reader 也很快结束),
635    // 但若日志流先因输出超限 break,wait 会被 select drop 掉(容器仍在跑,后续 _guard 清理)。
636    tokio::select! {
637        status = wait_with_timeout => {
638            exit_code = status;
639            // 容器已退出(或已被 kill),attach stream 可能还有缓冲的尾部日志。
640            // 有界等待兜底 kill 失败等异常,保证函数必然返回并释放信号量许可与 ContainerGuard。
641            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            // 日志流先结束(输出超限 break,或 attach 断开),容器可能仍在运行。
685            // C3 修复:旧代码 wait_container().next().await 无超时无 kill,注释还谎称
686            // "短超时"。`while True: print()` 式程序会永久挂起 → RUNNER_SEMAPHORE 许可
687            // 不释放(默认 4 并发 → 4 个即 DoS 全部代码执行器)+ ContainerGuard 不 drop
688            // → 容器永久运行烧 CPU。与 wait_with_timeout 分支对称:带超时地等退出,超时则 kill。
689            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                    // 超时:kill 后再等一次回收 exit_code(kill 后 wait 立即返回)。
694                    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    // 检查 OOM 状态。
711    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    // 推送终态 chunk(客户端已断开则跳过,send 必然失败)。
718    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        // 隔离不变量:这些配置若被误删,容器隔离会悄悄失效。
760        // 单独断言而非隐含在端到端测试里,确保回归可见。
761        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        // memory_swap == memory 才是真正禁用 swap;二者不等价于可换出到磁盘
771        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        // 编译缓存 named volume:None 时不挂载任何 binds;Some 时以裸卷名
778        // `name:/path` 挂载(Docker/Podman 自动创建卷,不受 readonly_rootfs 影响)。
779        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        // nproc 在容器内对 non-root 用户有害(初始 exec /bin/sh 直接 EAGAIN),
801        // 只保留 nofile=64。若有人加回 nproc,这里会失败。
802        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        // /tmp 必须 exec(编译型语言把产物落在 /tmp 后再 exec);
821        // /code、/run 不带 exec(默认 noexec)。若误给 /code 也加 exec,测试失败。
822        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        // 资源钳制是安全边界,断言换算公式不漂移(cpu_cores * 100_000,memory_mb * 1MiB)。
861        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    /// 探测 Docker daemon 是否可用:socket 缺失(`DOCKER_CLIENT == None`)、
876    /// daemon 无响应、或所需镜像不在本地时返回 `None`。集成测试用它做动态
877    /// 守卫——daemon 与镜像都在则跑,否则显式跳过(eprintln + 提前 return),
878    /// 而非 panic 失败。
879    ///
880    /// 这比 `#[ignore]` 更合适:`#[ignore]` 会在所有环境(含有 Docker 的开发机)
881    /// 一律跳过、且需 `cargo test --ignored` 显式触发;探测守卫让这些测试在
882    /// 有 Docker 时自动运行、在无 Docker 或缺镜像的 CI 上静默放行。
883    ///
884    /// 镜像探测:GitHub runner 有 daemon 但默认不带 alpine:latest,创建容器
885    /// 时会 404。这里用 inspect_image 只读检查本地是否已有镜像,缺失即跳过,
886    /// 不在此 pull(CI 不应依赖外网拉镜像——慢且脆弱)。
887    async fn require_docker_with_image(image: &str) -> Option<&'static Docker> {
888        let docker = DOCKER_CLIENT.as_ref()?;
889        // socket 在但 daemon 挂了的情况靠这一步轻量只读调用兜住。
890        docker.version().await.ok()?;
891        // 镜像不在本地 → 视为环境不可用,跳过而非 panic。
892        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        // 超时的 Done 由调用方补推;runner 自己推会让 SSE 收到两个 done。
1058        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                // Cancelled!
1111            }
1112        }
1113
1114        // 取消发生后,Drop 仍通过后台任务删除容器。等待清理任务明确报告结果,
1115        // 不再用固定时间窗口猜测 Docker daemon 是否已经完成删除。
1116        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                // Future 在 create_container 完成前就被取消,没有容器需要清理。
1121                return;
1122            }
1123            Err(_) => panic!("Container cleanup did not finish within 30 seconds"),
1124        }
1125
1126        // remove_container 返回成功后,给 daemon 很短的时间同步 list API,避免
1127        // 删除成功与列表可见性之间的最终一致性造成误报。
1128        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}