Skip to main content

yggdrasil/api/code_runner/
execute.rs

1//! 代码执行 server functions:StartExec / GetExecResult。
2//!
3//! StartExec 流程:速率限制 → 语言白名单 → 源码大小校验 → 入队(DashMap)
4//! → spawn 后台 task(信号量限并发 + clamp_limits + run_in_container)。
5//! 返回 task_id 供前端轮询。GetExecResult 读取任务条目。
6//!
7//! 错误脱敏:匿名可见错误(不支持的语言、超限、限流)返回中文消息;系统内部
8//! 异常(容器拉起失败等)记服务端日志,对前端返回统一「系统暂时不可用」。
9//!
10//! 双目标可见性:本模块**不** cfg-gate(server function 需对 WASM 可见以便客户端
11//! 调用),但所有 server-only 的 `use` 与全局静态量都单独 gate,使 WASM 侧仅保留
12//! 函数签名(body 被 server 宏剥离)。与 posts 模块的约定一致。
13
14// 与 posts / settings 模块一致:Dioxus `#[server]` 宏触发 deprecated/unit 提示,按项目惯例放行。
15#![allow(clippy::unused_unit, deprecated)]
16
17use dioxus::prelude::*;
18
19// 共享数据类型在函数签名中出现,双目标可见,不 gate。
20use crate::api::code_runner::{ExecRequest, ExecTask};
21// ExecResult/ExecStatus 仅在 server function body(被宏剥离到 WASM 之外)内使用。
22#[cfg(feature = "server")]
23use crate::api::code_runner::{ExecResult, ExecStatus};
24
25// server-only 辅助模块与依赖:仅在 server function body(被宏剥离到 WASM 之外)内使用。
26#[cfg(feature = "server")]
27use crate::api::auth::get_current_admin_user;
28#[cfg(feature = "server")]
29use crate::api::code_runner::languages::{is_supported_lang, normalize_lang, LANGUAGES};
30#[cfg(feature = "server")]
31use crate::api::code_runner::progress::{
32    gc_old_tasks, insert_task, update_task_result, update_task_stage, StreamEntry, EXEC_STREAMS,
33    EXEC_TASKS,
34};
35#[cfg(feature = "server")]
36use crate::api::rate_limit::{check_code_exec_limit, get_client_ip};
37#[cfg(feature = "server")]
38use crate::infra::docker::{run_in_container, run_in_container_stream, OutputChunk};
39#[cfg(feature = "server")]
40use crate::infra::runner_config::{clamp_limits, RUNNER_CONFIG};
41#[cfg(feature = "server")]
42use std::sync::{Arc, LazyLock};
43#[cfg(feature = "server")]
44use std::time::Duration;
45#[cfg(feature = "server")]
46use tokio::sync::Semaphore;
47
48/// 并发容器控制信号量,限制同时在跑的容器数量(`CODE_RUNNER_MAX_CONCURRENT`)。
49#[cfg(feature = "server")]
50pub static RUNNER_SEMAPHORE: LazyLock<Arc<Semaphore>> =
51    LazyLock::new(|| Arc::new(Semaphore::new(RUNNER_CONFIG.max_concurrent)));
52
53/// 从 FullstackContext 提取客户端 IP(无上下文时退回 "unknown")。
54#[cfg(feature = "server")]
55async fn client_ip() -> String {
56    match dioxus::fullstack::FullstackContext::current() {
57        Some(ctx) => {
58            let headers = ctx.parts_mut().headers.clone();
59            get_client_ip(&headers).await
60        }
61        None => "unknown".to_string(),
62    }
63}
64
65/// 公共校验逻辑:速率限制 + 语言白名单 + 源码大小。
66///
67/// [`start_exec`](轮询路径)与 [`start_exec_stream`](SSE 路径)共用,
68/// 保证两条路径的准入约束完全一致。admin 跳过速率限制。
69#[cfg(feature = "server")]
70fn validate_exec_request(req: &ExecRequest) -> Result<(), ServerFnError> {
71    // 1. 语言白名单
72    if !is_supported_lang(&req.language) {
73        return Err(ServerFnError::new("不支持该执行语言".to_string()));
74    }
75
76    // 2. 源码大小限制
77    if req.source.len() > RUNNER_CONFIG.max_source_bytes as usize {
78        return Err(ServerFnError::new("源代码过大".to_string()));
79    }
80
81    Ok(())
82}
83
84/// 速率限制检查(admin 放行)。
85#[cfg(feature = "server")]
86async fn check_rate_limit_for_user() -> Result<(), ServerFnError> {
87    let is_admin = get_current_admin_user().await.is_ok();
88    if !is_admin {
89        let ip = client_ip().await;
90        if let Err(msg) = check_code_exec_limit(&ip) {
91            return Err(ServerFnError::new(msg));
92        }
93    }
94    Ok(())
95}
96
97/// 后台执行容器任务的核心:信号量限并发 → clamp_limits → 调用 runner → 写 EXEC_TASKS。
98///
99/// [`start_exec`](轮询路径)与 [`start_exec_stream`](SSE 路径)共用本函数,
100/// 唯一的行为分叉是 `stream_tx`:
101/// - `None`:走 [`run_in_container`],输出仅落终态 buffer(轮询路径)。
102/// - `Some(tx)`:走 [`run_in_container_stream`],逐 chunk 推给 SSE channel(流式路径)。
103///
104/// 其余(并发槽获取、资源钳制、状态映射、超时/失败脱敏、日志前缀)两条路径完全一致。
105/// 两个 runner 返回元组仅末尾多一个 `_timed_out`,此处统一裁成 4 元组后续处理。
106#[cfg(feature = "server")]
107fn spawn_exec_task(
108    task_id: String,
109    req: ExecRequest,
110    stream_tx: Option<tokio::sync::mpsc::Sender<OutputChunk>>,
111) {
112    // 归一化为 canonical key(js→node / ts→bun / rs→rust),确保 LANGUAGES.get 命中、
113    // ExecResult.language 回显 canonical(与 markdown 渲染期的 data-lang 一致)。
114    let lang_key = normalize_lang(&req.language);
115    tokio::spawn(async move {
116        let sem = &*RUNNER_SEMAPHORE;
117
118        // 排队等待可用容器槽
119        let ticket = match tokio::time::timeout(
120            Duration::from_secs(RUNNER_CONFIG.queue_timeout_secs),
121            sem.acquire(),
122        )
123        .await
124        {
125            Ok(Ok(t)) => t,
126            _ => {
127                update_task_stage(&task_id, ExecStatus::Failed, "系统繁忙,排队超时");
128                return;
129            }
130        };
131
132        update_task_stage(&task_id, ExecStatus::Running, "启动容器");
133
134        let lang_def = match LANGUAGES.get(&lang_key) {
135            Some(d) => d,
136            None => {
137                // 理论不可达:入口已校验白名单;防御性兜底。
138                update_task_stage(&task_id, ExecStatus::Failed, "语言未注册");
139                return;
140            }
141        };
142
143        // 资源限制合并与钳制
144        let base_limits = req
145            .overrides
146            .unwrap_or_else(|| lang_def.default_limits.clone());
147        let final_limits = clamp_limits(base_limits, lang_def.allow_network);
148
149        let start_time = chrono::Utc::now();
150        // 唯一的行为分叉:流式路径逐 chunk 推 SSE,非流式仅落终态 buffer。
151        let stream_suffix = if stream_tx.is_some() { " (stream)" } else { "" };
152        let res = match &stream_tx {
153            Some(tx) => run_in_container_stream(
154                &lang_def.image,
155                &lang_def.run_cmd,
156                &req.source,
157                &lang_def.extension,
158                final_limits,
159                lang_def.cache_volume.as_ref(),
160                tx.clone(),
161            )
162            .await
163            .map(|(exit_code, stdout, stderr, oom_killed, _)| {
164                (exit_code, stdout, stderr, oom_killed)
165            }),
166            None => {
167                run_in_container(
168                    &lang_def.image,
169                    &lang_def.run_cmd,
170                    &req.source,
171                    &lang_def.extension,
172                    final_limits,
173                    lang_def.cache_volume.as_ref(),
174                )
175                .await
176            }
177        };
178        let duration_ms = (chrono::Utc::now() - start_time).num_milliseconds().max(0) as u64;
179
180        drop(ticket); // 显式释放信号量
181
182        match res {
183            Ok((exit_code, stdout, stderr, oom_killed)) => {
184                let status = if oom_killed {
185                    ExecStatus::OomKilled
186                } else if exit_code == Some(0) {
187                    ExecStatus::Success
188                } else {
189                    ExecStatus::Error
190                };
191                let exec_res = ExecResult {
192                    status: status.clone(),
193                    stdout,
194                    stderr,
195                    exit_code,
196                    duration_ms,
197                    language: lang_key,
198                };
199                update_task_result(&task_id, status, exec_res);
200            }
201            Err(e) => {
202                // 系统内部异常脱敏:日志记完整 error,前端只见分类后的可操作消息。
203                tracing::error!(error = ?e, task_id = %task_id, "container execution failed{}", stream_suffix);
204                let (status, stderr_msg) = classify_runner_error(&e, &lang_def.image);
205
206                // 流式路径:run_in_container_stream 在 create_container 阶段就 Err 返回
207                // (镜像缺失 / daemon 不可达),此时 Done chunk 从未推送,SSE 流静默关闭,
208                // 前端只能显示笼统的「连接异常」。这里补推一个带 error 的 Done chunk,
209                // 让前端拿到可操作消息。send 失败(客户端已断开)则忽略——轮询兜底仍兜得住。
210                if let Some(tx) = &stream_tx {
211                    let _ = tx
212                        .send(OutputChunk::Done {
213                            exit_code: None,
214                            oom_killed: false,
215                            timed_out: status == ExecStatus::Timeout,
216                            duration_ms,
217                            error: Some(stderr_msg.clone()),
218                        })
219                        .await;
220                }
221
222                let exec_res = ExecResult {
223                    status: status.clone(),
224                    stdout: String::new(),
225                    stderr: stderr_msg,
226                    exit_code: None,
227                    duration_ms,
228                    language: lang_key,
229                };
230                update_task_result(&task_id, status, exec_res);
231            }
232        }
233    });
234}
235
236/// 把容器执行错误分类为前端可读的状态与脱敏消息。
237///
238/// issue #20 的核心诉求:让「镜像未构建」「daemon 不可达」从笼统的「系统暂时不可用」
239/// 中区分出来,给出可操作的修复指引。
240///
241/// 判定策略:
242/// - 超时:`docker.rs` 用 `Error::IOError { kind: TimedOut }` 上报(`docker.rs:223-227`、
243///   `:305-309`)。`io::Error` 的 Display **只渲染自定义消息**(如 "Execution timed
244///   out"),不含 kind 名——故靠字符串 `contains("TimedOut")` 永远命中不了真实超时路径。
245///   这里按**变体 + kind** 判定,是最可靠的信号。
246/// - 镜像缺失:Docker Engine API 稳定文本 "No such image",走 Display 字符串匹配。
247/// - daemon 不可达:来自 `get_docker()` 构造的 io::Error 消息(`docker.rs:54`),走字符串匹配。
248/// - 其余:兜底「系统暂时不可用」。
249#[cfg(feature = "server")]
250fn classify_runner_error(e: &bollard::errors::Error, image: &str) -> (ExecStatus, String) {
251    // 超时必须按变体 + kind 判定:io::Error 的 Display 不含 kind 名。
252    if let bollard::errors::Error::IOError { err } = e {
253        if err.kind() == std::io::ErrorKind::TimedOut {
254            return (ExecStatus::Timeout, "执行超时".to_string());
255        }
256    }
257
258    let s = e.to_string();
259    if s.contains("No such image") {
260        (
261            ExecStatus::Failed,
262            format!("运行器镜像未构建:{image}。请在宿主执行:bash docker/build-runners.sh"),
263        )
264    } else if s.contains("Docker daemon 不可用") {
265        (
266            ExecStatus::Failed,
267            "Docker 未运行或 socket 未挂载,代码运行不可用".to_string(),
268        )
269    } else {
270        (ExecStatus::Failed, "系统暂时不可用".to_string())
271    }
272}
273
274/// 提交一次代码执行请求。
275///
276/// 同步校验通过后立即返回 task_id,容器在后台执行;结果通过
277/// [`get_exec_result`] 轮询查询。语言不支持 / 源码过大 / 触发限流时同步返回错误。
278///
279/// admin 角色跳过速率限制(便于作者在沙箱调试),但仍受并发槽、
280/// 资源钳制与源码大小校验约束。
281#[server(StartExec, "/api")]
282pub async fn start_exec(req: ExecRequest) -> Result<String, ServerFnError> {
283    check_rate_limit_for_user().await?;
284    validate_exec_request(&req)?;
285
286    // 生成任务 ID 并入队
287    let task_id = uuid::Uuid::new_v4().to_string();
288    insert_task(task_id.clone());
289
290    // 顺手回收过期任务(同时清 EXEC_TASKS 和 EXEC_STREAMS)
291    gc_old_tasks();
292
293    // 后台执行:信号量限并发 → clamp_limits → run_in_container
294    // (spawn 逻辑见 spawn_exec_task;stream_tx=None 走非流式 runner)
295    spawn_exec_task(task_id.clone(), req, None);
296
297    Ok(task_id)
298}
299
300/// 提交一次流式代码执行请求。
301///
302/// 校验链与 [`start_exec`] 完全一致(速率限制 + 白名单 + 大小),返回 task_id。
303/// 前端拿到 task_id 后用 EventSource 连 `GET /api/exec/stream?task_id=X`,
304/// SSE handler 从 `EXEC_STREAMS` 取走 receiver 做流式输出。
305///
306/// 后台 spawn 与 `start_exec` 并行写两处:
307/// - `run_in_container_stream` 推 chunk 到 SSE(流式路径)
308/// - `update_task_result` 写 `EXEC_TASKS`(轮询兜底路径,SSE 不可用或 Tiptap 编辑器用)
309#[server(StartExecStream, "/api")]
310pub async fn start_exec_stream(req: ExecRequest) -> Result<String, ServerFnError> {
311    check_rate_limit_for_user().await?;
312    validate_exec_request(&req)?;
313
314    let task_id = uuid::Uuid::new_v4().to_string();
315    // EXEC_TASKS 给轮询兜底路径(get_exec_result / Tiptap 编辑器 run-code 闭包)。
316    insert_task(task_id.clone());
317
318    // EXEC_STREAMS 给 SSE 流式路径:创建 channel,rx 存表等前端取,tx 进后台 task。
319    let (tx, rx) = tokio::sync::mpsc::channel(64);
320    EXEC_STREAMS.insert(
321        task_id.clone(),
322        StreamEntry {
323            rx,
324            created_at: chrono::Utc::now(),
325        },
326    );
327
328    gc_old_tasks();
329
330    // 公共 spawn 逻辑见 spawn_exec_task;stream_tx=Some(tx) 走 run_in_container_stream。
331    spawn_exec_task(task_id.clone(), req, Some(tx));
332
333    Ok(task_id)
334}
335
336/// 查询任务执行结果(前端轮询)。
337#[server(GetExecResult, "/api")]
338pub async fn get_exec_result(task_id: String) -> Result<ExecTask, ServerFnError> {
339    if let Some(task) = EXEC_TASKS.get(&task_id) {
340        Ok(task.clone())
341    } else {
342        Err(ServerFnError::new("找不到指定的任务".to_string()))
343    }
344}
345
346#[cfg(all(test, feature = "server"))]
347mod tests {
348    use super::*;
349
350    fn req(language: &str, source: &str) -> ExecRequest {
351        ExecRequest {
352            language: language.to_string(),
353            source: source.to_string(),
354            overrides: None,
355        }
356    }
357
358    #[test]
359    fn validate_accepts_registered_language() {
360        // python/node/go/rust/bun 默认注册,应放行。
361        for lang in ["python", "node", "go", "rust", "bun"] {
362            assert!(
363                validate_exec_request(&req(lang, "x")).is_ok(),
364                "{lang} 应被支持"
365            );
366        }
367    }
368
369    #[test]
370    fn validate_accepts_aliases() {
371        // 别名经 is_supported_lang 内的 normalize_lang 归一后命中注册表,应放行。
372        // 锁定该契约:前端 CodeRunner 可能带着别名(如历史渲染的 data-lang="js")
373        // 调 StartExec,校验链不能因别名拒绝。
374        for lang in ["js", "javascript", "rs", "ts", "typescript"] {
375            assert!(
376                validate_exec_request(&req(lang, "x")).is_ok(),
377                "别名 {lang} 应被支持"
378            );
379        }
380    }
381
382    #[test]
383    fn validate_language_is_case_and_whitespace_insensitive() {
384        // is_supported_lang 内部 trim+lowercase,校验链应透传该容忍。
385        assert!(validate_exec_request(&req("Python", "x")).is_ok());
386        assert!(validate_exec_request(&req("  RUST  ", "x")).is_ok());
387        assert!(validate_exec_request(&req("Go", "x")).is_ok());
388    }
389
390    #[test]
391    fn validate_rejects_unregistered_language() {
392        // 未注册语言 / 命令注入尝试都应在白名单阶段被拒。
393        assert!(validate_exec_request(&req("c", "x")).is_err());
394        assert!(validate_exec_request(&req("bash", "x")).is_err());
395        assert!(validate_exec_request(&req("python2", "x")).is_err());
396        assert!(validate_exec_request(&req("", "x")).is_err());
397    }
398
399    #[test]
400    fn validate_rejects_multi_token_language() {
401        // 语言字段不会到达容器层:只有命中 LANGUAGES 的 key 才放行,
402        // 否则被拒。任何多 token / 含 shell 元字符的值都因找不到注册项而拒绝。
403        assert!(validate_exec_request(&req("python; rm -rf /", "x")).is_err());
404        assert!(validate_exec_request(&req("python node", "x")).is_err());
405        assert!(validate_exec_request(&req("python$(whoami)", "x")).is_err());
406    }
407
408    #[test]
409    fn validate_language_tolerates_surrounding_whitespace() {
410        // is_supported_lang 内部 trim+lowercase:首尾空白(含换行)被吃掉后等于 "python"。
411        // 锁定该契约——语言字段只用于 HashMap 查 key,空白无害。
412        assert!(validate_exec_request(&req("python\n", "x")).is_ok());
413        assert!(validate_exec_request(&req("\tpython\t", "x")).is_ok());
414    }
415
416    #[test]
417    fn validate_rejects_source_exceeding_max_bytes() {
418        let max = RUNNER_CONFIG.max_source_bytes as usize;
419        // 恰好 max 放行,max+1 拒绝——边界精确,防止 off-by-one 让攻击者多塞 1 字节。
420        let exactly = "a".repeat(max);
421        assert!(
422            validate_exec_request(&req("python", &exactly)).is_ok(),
423            "源码恰好等于上限应放行"
424        );
425        let over = "a".repeat(max + 1);
426        let err = validate_exec_request(&req("python", &over)).expect_err("超出上限应拒绝");
427        assert!(
428            err.to_string().contains("过大"),
429            "错误信息应提及大小: {err}"
430        );
431    }
432
433    #[test]
434    fn validate_empty_source_accepted_for_supported_lang() {
435        // 空源码不是校验层的职责(容器层处理),这里只验语言/大小。
436        // 锁定该契约:空源码 + 合法语言 → Ok,避免未来误把空当拒绝。
437        assert!(validate_exec_request(&req("python", "")).is_ok());
438    }
439
440    #[test]
441    fn validate_checks_language_before_size() {
442        // 不支持语言 + 巨大源码:应先因语言被拒(而非先报大小)。
443        let huge = "a".repeat((RUNNER_CONFIG.max_source_bytes as usize) + 100);
444        let err = validate_exec_request(&req("brainfuck", &huge)).unwrap_err();
445        assert!(err.to_string().contains("语言"), "应先报语言错误: {err}");
446    }
447
448    #[test]
449    fn classify_runner_error_image_missing() {
450        // issue #20 的核心场景:404 + "No such image" 应给出可操作的构建指引,
451        // 而非笼统的「系统暂时不可用」。
452        let e = bollard::errors::Error::DockerResponseServerError {
453            status_code: 404,
454            message: "No such image: yggdrasil-runner-python:latest".to_string(),
455        };
456        let (status, stderr) = classify_runner_error(&e, "yggdrasil-runner-python:latest");
457        assert_eq!(status, ExecStatus::Failed);
458        assert_eq!(
459            stderr,
460            "运行器镜像未构建:yggdrasil-runner-python:latest。请在宿主执行:bash docker/build-runners.sh"
461        );
462    }
463
464    #[test]
465    fn classify_runner_error_daemon_unavailable() {
466        // get_docker() 在 DOCKER_CLIENT 为 None 时构造的 IOError(NotFound)。
467        let e = bollard::errors::Error::IOError {
468            err: std::io::Error::new(
469                std::io::ErrorKind::NotFound,
470                "Docker daemon 不可用(未安装或未运行)",
471            ),
472        };
473        let (status, stderr) = classify_runner_error(&e, "yggdrasil-runner-python:latest");
474        assert_eq!(status, ExecStatus::Failed);
475        assert_eq!(stderr, "Docker 未运行或 socket 未挂载,代码运行不可用");
476    }
477
478    #[test]
479    fn classify_runner_error_timeout() {
480        // docker.rs 超时路径:IOError { kind: TimedOut }。
481        // 注意:io::Error Display 只渲染自定义消息 "Execution timed out",
482        // 不含 kind 名 "TimedOut"——锁定按变体 + kind 判定(而非字符串匹配)。
483        let e = bollard::errors::Error::IOError {
484            err: std::io::Error::new(std::io::ErrorKind::TimedOut, "Execution timed out"),
485        };
486        let (status, stderr) = classify_runner_error(&e, "yggdrasil-runner-python:latest");
487        assert_eq!(status, ExecStatus::Timeout);
488        assert_eq!(stderr, "执行超时");
489    }
490
491    #[test]
492    fn classify_runner_error_generic_fallback() {
493        // 其它服务端错误(如 500)兜底为「系统暂时不可用」。
494        let e = bollard::errors::Error::DockerResponseServerError {
495            status_code: 500,
496            message: "boom".to_string(),
497        };
498        let (status, stderr) = classify_runner_error(&e, "yggdrasil-runner-python:latest");
499        assert_eq!(status, ExecStatus::Failed);
500        assert_eq!(stderr, "系统暂时不可用");
501    }
502}