diff --git a/src/api/code_runner/execute.rs b/src/api/code_runner/execute.rs index 9aef43c..7de5995 100644 --- a/src/api/code_runner/execute.rs +++ b/src/api/code_runner/execute.rs @@ -6,31 +6,46 @@ //! //! 错误脱敏:匿名可见错误(不支持的语言、超限、限流)返回中文消息;系统内部 //! 异常(容器拉起失败等)记服务端日志,对前端返回统一「系统暂时不可用」。 +//! +//! 双目标可见性:本模块**不** cfg-gate(server function 需对 WASM 可见以便客户端 +//! 调用),但所有 server-only 的 `use` 与全局静态量都单独 gate,使 WASM 侧仅保留 +//! 函数签名(body 被 server 宏剥离)。与 posts 模块的约定一致。 // 与 posts / settings 模块一致:Dioxus `#[server]` 宏触发 deprecated/unit 提示,按项目惯例放行。 #![allow(clippy::unused_unit, deprecated)] -use std::sync::{Arc, LazyLock}; -use std::time::Duration; - -use chrono::Utc; use dioxus::prelude::*; -use tokio::sync::Semaphore; +// 共享数据类型在函数签名中出现,双目标可见,不 gate。 +use crate::api::code_runner::{ExecRequest, ExecResult, ExecStatus, ExecTask}; + +// server-only 辅助模块与依赖:仅在 server function body(被宏剥离到 WASM 之外)内使用。 +#[cfg(feature = "server")] use crate::api::code_runner::languages::{is_supported_lang, LANGUAGES}; +#[cfg(feature = "server")] use crate::api::code_runner::progress::{ gc_old_tasks, insert_task, update_task_result, update_task_stage, EXEC_TASKS, }; -use crate::api::code_runner::{ExecRequest, ExecResult, ExecStatus, ExecTask}; +#[cfg(feature = "server")] use crate::api::rate_limit::{check_code_exec_limit, get_client_ip}; +#[cfg(feature = "server")] use crate::infra::docker::run_in_container; +#[cfg(feature = "server")] use crate::infra::runner_config::{clamp_limits, RUNNER_CONFIG}; +#[cfg(feature = "server")] +use std::sync::{Arc, LazyLock}; +#[cfg(feature = "server")] +use std::time::Duration; +#[cfg(feature = "server")] +use tokio::sync::Semaphore; /// 并发容器控制信号量,限制同时在跑的容器数量(`CODE_RUNNER_MAX_CONCURRENT`)。 +#[cfg(feature = "server")] pub static RUNNER_SEMAPHORE: LazyLock> = LazyLock::new(|| Arc::new(Semaphore::new(RUNNER_CONFIG.max_concurrent))); /// 从 FullstackContext 提取客户端 IP(无上下文时退回 "unknown")。 +#[cfg(feature = "server")] fn client_ip() -> String { match dioxus::fullstack::FullstackContext::current() { Some(ctx) => { @@ -107,8 +122,7 @@ pub async fn start_exec(req: ExecRequest) -> Result { .unwrap_or_else(|| lang_def.default_limits.clone()); let final_limits = clamp_limits(base_limits, lang_def.allow_network); - let start_time = Utc::now(); - + let start_time = chrono::Utc::now(); let res = run_in_container( &lang_def.image, &lang_def.run_cmd, @@ -117,8 +131,7 @@ pub async fn start_exec(req: ExecRequest) -> Result { final_limits, ) .await; - - let duration_ms = (Utc::now() - start_time) + let duration_ms = (chrono::Utc::now() - start_time) .num_milliseconds() .max(0) as u64; @@ -155,7 +168,7 @@ pub async fn start_exec(req: ExecRequest) -> Result { }; let exec_res = ExecResult { status: status.clone(), - stdout: "".to_string(), + stdout: String::new(), stderr: if is_timeout { "执行超时".to_string() } else { diff --git a/src/api/code_runner/mod.rs b/src/api/code_runner/mod.rs index e4f39df..e66e3bd 100644 --- a/src/api/code_runner/mod.rs +++ b/src/api/code_runner/mod.rs @@ -71,10 +71,10 @@ pub struct ExecTask { pub result: Option, } -// 子模块在后续任务中实现: -#[cfg(feature = "server")] -pub mod execute; +// execute.rs 含 server function,需对双目标可见(不能 cfg-gate);其 server-only +// 依赖在文件内单独 gate。languages / progress 是纯 server 辅助,整体 gate。 #[cfg(feature = "server")] pub mod languages; #[cfg(feature = "server")] pub mod progress; +pub mod execute; diff --git a/src/components/code_runner/mod.rs b/src/components/code_runner/mod.rs new file mode 100644 index 0000000..ca028ee --- /dev/null +++ b/src/components/code_runner/mod.rs @@ -0,0 +1,10 @@ +//! 代码运行器(Code Runner)组件。 +//! +//! 提供可运行代码块的交互 UI:显示源码、运行按钮、运行阶段、输出区。 +//! 点击 Run 时调用 `StartExec` server function 提交,轮询 `GetExecResult` 直到终态。 +//! +//! CodeMirror 编辑器实例由调用方(阅读器扫描 / 后台试运行)在 WASM 端按容器 id +//! 挂载;本组件只负责布局、状态机与轮询,与编辑器桥接保持解耦。 + +pub mod runner; +pub use runner::CodeRunner; diff --git a/src/components/code_runner/runner.rs b/src/components/code_runner/runner.rs new file mode 100644 index 0000000..37d805d --- /dev/null +++ b/src/components/code_runner/runner.rs @@ -0,0 +1,191 @@ +//! CodeRunner 组件实现:源码 + 运行按钮 + 轮询 + 输出区。 + +use dioxus::prelude::*; + +use crate::api::code_runner::execute::{get_exec_result, start_exec}; +use crate::api::code_runner::{ExecRequest, ExecStatus}; +use crate::infra::runner_config::ResourceLimits; +use crate::utils::time::sleep_ms; + +/// 轮询间隔(毫秒)。 +const POLL_INTERVAL_MS: u32 = 500; +/// 轮询最大次数兜底,避免任务卡在 Running 状态时无限轮询。 +const MAX_POLLS: u32 = 240; // 500ms * 240 = 120s 上限 + +/// 代码运行器组件。 +/// +/// Props: +/// - `source`:初始源码(用于展示与提交)。 +/// - `language`:语言标识(python / node 等)。 +/// - `overrides`:可选资源限制覆盖。 +/// +/// `mut` 信号仅在 WASM 的 spawn 闭包内被 `.set()`,server 构建会触发 unused_mut, +/// 故按项目惯例加 `cfg_attr` 放行(参见 AGENTS.md「mut bindings needed only on WASM」)。 +#[component] +#[cfg_attr(not(target_arch = "wasm32"), allow(unused_mut))] +pub fn CodeRunner( + source: String, + language: String, + overrides: Option, +) -> Element { + let mut running = use_signal(|| false); + let mut stage = use_signal(|| String::new()); + let mut output = use_signal(|| String::new()); + let mut exit_info = use_signal(|| String::new()); + let mut error_msg = use_signal(|| String::new()); + + // 为每个实例生成稳定的容器 id(CodeMirror 容器,由调用方在 WASM 端挂载)。 + // use_id 在 Dioxus 0.7 提供 scope 内稳定的 id;退回 use_hook 保证只算一次。 + let container_id = use_hook(|| { + format!( + "code-runner-{}", + // 简单用一次性随机后缀;非安全场景,避免额外依赖。 + now_pseudo_unique() + ) + }); + + // 提前克隆一份给 run 闭包使用,避免 move 闭包抢走 rsx! 仍要读的 source/language。 + let run_source = source.clone(); + let run_language = language.clone(); + let run_overrides = overrides.clone(); + + let run_code = move |_| { + if running() { + return; + } + running.set(true); + stage.set("提交中...".to_string()); + output.set(String::new()); + exit_info.set(String::new()); + error_msg.set(String::new()); + + let req = ExecRequest { + language: run_language.clone(), + source: run_source.clone(), + overrides: run_overrides.clone(), + }; + + spawn(async move { + match start_exec(req).await { + Ok(task_id) => { + let mut polls = 0u32; + loop { + polls += 1; + sleep_ms(POLL_INTERVAL_MS).await; + match get_exec_result(task_id.clone()).await { + Ok(task) => { + stage.set(task.stage.clone()); + let terminal = task.status != ExecStatus::Queued + && task.status != ExecStatus::Running; + if terminal { + running.set(false); + if let Some(res) = task.result { + let out = format!( + "Stdout:\n{}\nStderr:\n{}", + res.stdout, res.stderr + ); + output.set(out); + exit_info.set(format!( + "耗时: {}ms · 状态: {}", + res.duration_ms, + status_label(&res.status) + )); + if res.status == ExecStatus::Success { + error_msg.set(String::new()); + } else { + error_msg + .set(status_label(&res.status)); + } + } + break; + } + if polls >= MAX_POLLS { + running.set(false); + stage.set("查询超时".to_string()); + error_msg.set("轮询超时,请重试".to_string()); + break; + } + } + Err(_) => { + running.set(false); + stage.set("结果获取异常".to_string()); + error_msg.set("结果获取异常".to_string()); + break; + } + } + } + } + Err(e) => { + running.set(false); + let msg = e.to_string(); + stage.set(msg.clone()); + error_msg.set(msg); + } + } + }); + }; + + rsx! { + div { class: "code-runner-container border border-base-300 rounded-xl overflow-hidden my-4 bg-base-100", + div { class: "flex justify-between items-center bg-base-200 px-3 py-1 text-sm border-b border-base-300", + span { class: "font-mono font-bold text-base-content/80", "{language}" } + button { + class: "btn btn-xs btn-primary rounded-lg", + disabled: running(), + onclick: run_code, + if running() { + span { class: "loading loading-spinner loading-xs mr-1" } + "{stage()}" + } else { + "Run" + } + } + } + // CodeMirror 容器:调用方(阅读器扫描 / 后台试运行)在 WASM 端按此 id 挂载编辑器。 + // 服务端渲染时仅展示源码文本,避免在 SSR 阶段拉起 JS 编辑器。 + div { + id: "{container_id}", + class: "code-runner-editor min-h-[100px] font-mono text-sm", + // 源码以 data 属性承载,供挂载脚本读取初始化(避免 SSR 时把源码当 HTML 解析)。 + "data-source": "{source}", + } + if !output().is_empty() { + div { class: "output-area bg-neutral text-neutral-content p-3 rounded-lg m-2 font-mono text-xs whitespace-pre-wrap break-all", + div { class: "text-gray-400 border-b border-gray-600 pb-1 mb-1 flex justify-between items-center", + span { "Console Output" } + span { class: "text-gray-500", "{exit_info()}" } + } + {output()} + } + } + if !error_msg().is_empty() { + div { class: "px-3 pb-2 text-xs text-error", + {error_msg()} + } + } + } + } +} + +/// 把 ExecStatus 映射成中文标签。 +fn status_label(status: &ExecStatus) -> String { + match status { + ExecStatus::Queued => "排队中".to_string(), + ExecStatus::Running => "运行中".to_string(), + ExecStatus::Success => "成功".to_string(), + ExecStatus::Timeout => "超时".to_string(), + ExecStatus::OomKilled => "内存超限".to_string(), + ExecStatus::Error => "运行错误".to_string(), + ExecStatus::Failed => "系统失败".to_string(), + ExecStatus::RateLimited => "请求过频".to_string(), + } +} + +/// 生成一个伪唯一后缀(基于时间戳 + 计数器),用于容器 id。 +/// 非安全用途,仅避免同页多实例 id 冲突。 +fn now_pseudo_unique() -> String { + use std::sync::atomic::{AtomicU64, Ordering}; + static COUNTER: AtomicU64 = AtomicU64::new(0); + let n = COUNTER.fetch_add(1, Ordering::Relaxed); + format!("{}-{}", crate::utils::time::now_millis(), n) +} diff --git a/src/components/mod.rs b/src/components/mod.rs index bd3c79e..88ef56e 100644 --- a/src/components/mod.rs +++ b/src/components/mod.rs @@ -9,6 +9,8 @@ pub mod admin_layout; /// 后台页面骨架屏组件。 pub mod admin_skeleton; +/// 代码运行器组件(可运行代码块 UI)。 +pub mod code_runner; /// 评论相关组件。 pub mod comments; /// 空状态组件(无数据时的插画配图 + 文案)。