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