1#![allow(clippy::unused_unit, deprecated)]
16
17use crate::api::error::ApiError;
18use leptos::prelude::*;
19
20use crate::api::code_runner::{ExecRequest, ExecTask};
22#[cfg(feature = "server")]
24use crate::api::code_runner::{ExecResult, ExecStatus};
25
26#[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#[cfg(feature = "server")]
51pub static RUNNER_SEMAPHORE: LazyLock<Arc<Semaphore>> =
52 LazyLock::new(|| Arc::new(Semaphore::new(RUNNER_CONFIG.max_concurrent)));
53
54#[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#[cfg(feature = "server")]
68fn validate_exec_request(req: &ExecRequest) -> Result<(), ApiError> {
69 if !is_supported_lang(&req.language) {
71 return Err(ApiError::BadRequest("不支持该执行语言".to_string()));
72 }
73
74 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#[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#[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 let lang_key = normalize_lang(&req.language);
113 tokio::spawn(async move {
114 let sem = &*RUNNER_SEMAPHORE;
115 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 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 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 update_task_stage(&task_id, ExecStatus::Failed, "语言未注册");
157 return;
158 }
159 };
160
161 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 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); 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 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 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#[cfg(feature = "server")]
267fn classify_runner_error(e: &bollard::errors::Error, image: &str) -> (ExecStatus, String) {
268 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#[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 let task_id = uuid::Uuid::new_v4().to_string();
305 insert_task(task_id.clone());
306
307 gc_old_tasks();
309
310 spawn_exec_task(task_id.clone(), req, None);
313
314 Ok(task_id)
315}
316
317#[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 insert_task(task_id.clone());
334
335 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_exec_task(task_id.clone(), req, Some(tx));
349
350 Ok(task_id)
351}
352
353#[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 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 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 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 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 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 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 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 assert!(validate_exec_request(&req("python", "")).is_ok());
454 }
455
456 #[test]
457 fn classify_runner_error_image_missing() {
458 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 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 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 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}