docs(agents): document code runner and fix clippy/biome lint

文档:
- AGENTS.md 新增「Code Runner」架构章节(三层架构 + WASM 可见性规则 +
  governor 0.8 无 per_day 的处理 + runner 镜像契约)
- Environment 段补齐 CODE_RUNNER_* 与 RATE_LIMIT_CODE_EXEC_* 环境变量

Lint 修复(clippy --all-targets --all-features -D warnings 全绿):
- runner.rs/runner.rs: use_signal 冗余闭包 → 直接传 fn
- runner_config.rs: &*RUNNER_CONFIG 去 deref;assert_eq!(...false/true) → assert!
- markdown.rs: 局部变量 /// 改 //;Option.map().flatten() → and_then
- languages.rs: 删除从未读取的 LanguageDef.name 字段(语言名即 map key)
- docker.rs: start_container if let Err → ?;timeout match → is_err();
  嵌套 if 合并(修 task 2 遗留 + clippy 1.96 新 lint)
- pages/admin/posts.rs: use_signal 冗余闭包(预存 lint,顺带修绿)
- codemirror editor.ts: biome format 重排 setSchema 单行
This commit is contained in:
xfy 2026-07-03 18:57:25 +08:00
parent 9851f98dc0
commit f3eb93f320
9 changed files with 75 additions and 53 deletions

View File

@ -75,6 +75,9 @@ RATE_LIMIT_COMMENT_PER_SEC=1 # comment posting
RATE_LIMIT_COMMENT_BURST=5
RATE_LIMIT_UNKNOWN_PER_SEC=30 # fallback bucket when real client IP can't be determined
RATE_LIMIT_UNKNOWN_BURST=100
RATE_LIMIT_CODE_EXEC_PER_SEC=1 # code runner per-IP burst (governor: integer only, no decimals)
RATE_LIMIT_CODE_EXEC_BURST=3
RATE_LIMIT_CODE_EXEC_DAILY=50 # code runner per-IP daily cap
DB_POOL_SIZE=20 # database connection pool size
MIGRATE_STARTUP_TIMEOUT_SECS=30 # how long startup waits for PostgreSQL before giving up
STATEMENT_TIMEOUT_SECS=30 # per-query timeout; slow queries are canceled to protect the pool
@ -82,6 +85,23 @@ SSR_CACHE_SECS=3600 # incremental SSR cache TTL (set 0 in dev)
SYSINFO_SAMPLE_SECS=0.5 # sysinfo sampling interval in seconds, supports decimals
```
Code Runner tuning (all optional, sane defaults):
```
CODE_RUNNER_ALLOW_NETWORK=false # global network switch; AND-ed with per-language allow_network
CODE_RUNNER_MAX_CONCURRENT=4 # tokio Semaphore for in-flight containers
CODE_RUNNER_MAX_CPU_CORES=2.0 # upper clamp for cpu_cores (lower bound 0.1)
CODE_RUNNER_MAX_MEMORY_MB=1024 # upper clamp for memory_mb (lower bound 16)
CODE_RUNNER_MAX_TIMEOUT_SECS=30 # upper clamp for timeout_secs (lower bound 1)
CODE_RUNNER_MAX_OUTPUT_BYTES=1048576 # hard cap on stdout+stderr captured
CODE_RUNNER_MAX_SOURCE_BYTES=65536 # max source size accepted by StartExec
CODE_RUNNER_QUEUE_TIMEOUT_SECS=30 # how long a task waits for a container slot before failing
CODE_RUNNER_TASK_TTL_SECS=300 # DashMap task entry lifetime (gc_old_tasks)
CODE_RUNNER_LANGUAGES=python,node # ops whitelist (must also exist in LANGUAGES registry)
DOCKER_SOCKET_PATH=/var/run/docker.sock
```
Session / security tuning:
```
@ -129,10 +149,11 @@ src/api/ — server functions + Axum handlers
markdown.rs — Markdown→HTML rendering (pulldown-cmark + ammonia sanitization)
image.rs — image serving with processing pipeline + disk+memory cache
upload.rs — image upload, auto-converts to WebP
rate_limit.rs — governor-based rate limiting (5 tiers: strict/upload/image/comment/unknown)
rate_limit.rs — governor-based rate limiting (6 tiers: strict/upload/image/comment/unknown/code_exec)
settings.rs — site settings server functions (trash retention, etc.)
slug.rs — URL slug generation
posts/ — CRUD server functions for blog posts
code_runner/ — runnable code-block server functions + data structures (see Code Runner section)
src/auth/ — password hashing (Argon2) + session token management
src/bin/ — generate_highlight_css (build-time CSS generation)
src/cache.rs — moka future-based caches for posts, tags, stats + cache_stats()
@ -150,8 +171,23 @@ src/theme.rs — light/dark theme with SSR cookie + WASM localStorage
src/webp.rs — zenwebp encode/decode (image crate has no WebP)
src/tiptap_bridge.rs — wasm-bindgen bindings for Tiptap editor
src/codemirror_bridge.rs — wasm-bindgen bindings for CodeMirror editor (mirrors tiptap_bridge)
src/infra/ — Docker execution layer + runner config (server-only); see Code Runner section
```
## Code Runner (` ```lang runnable ` code blocks)
Readers can execute fenced code blocks in isolated Docker containers; authors get a `/admin/runner` trial sandbox. Three-layer architecture, all Docker interaction gated behind `#[cfg(feature = "server")]`:
- **Execution layer** (`src/infra/docker.rs`, server-only): `bollard` client over Unix socket (`DOCKER_CLIENT` LazyLock). `run_in_container` creates a read-only-rootfs container (tmpfs `/code`,`/tmp`,`/run`; cpu/memory/pids/ulimits cap; `cap_drop=ALL`; `no-new-privileges`; non-root `1000:1000`; `network_mode` none/bridge), injects source via stdin, waits with timeout, captures stdout/stderr (truncated to `output_bytes`), inspects OOM, force-removes via `ContainerGuard` Drop. `src/infra/runner_config.rs` (`RUNNER_CONFIG`) reads all `CODE_RUNNER_*` env vars; `clamp_limits` AND-merges request overrides × language `allow_network` × global switch.
- **API layer** (`src/api/code_runner/`): shared `ExecRequest`/`ExecResult`/`ExecStatus`/`ExecTask` structs (compile on both targets); `progress.rs` = DashMap task registry + `gc_old_tasks`; `languages.rs` = `LANGUAGES` registry + `parse_fence_info`; `execute.rs` = `StartExec`/`GetExecResult` server functions (double rate-limit → whitelist → size check → enqueue → `tokio::spawn` + `RUNNER_SEMAPHORE` concurrency cap → clamp → run_in_container). System errors are sanitized to 「系统暂时不可用」 for anonymous callers; full errors in server logs only.
- **Markdown/render layer**: a fenced block ` ```python runnable {...overrides} ` renders to `<pre data-runnable="true" data-lang="python" data-overrides="..." data-source="...">` (sanitizer whitelists these 4 attrs on `<pre>`). `PostContent` (`src/components/post/post_content.rs`) splits `content_html` into `Html`/`Runnable` fragments so each runnable block renders as a real `<CodeRunner>` vdom element (no manual DOM mutation → no hydration conflict). `CodeRunner` component polls `GetExecResult` via WASM-friendly `sleep_ms`.
**Critical WASM-visibility rule**: `code_runner/execute.rs` is **not** cfg-gated (server functions must be visible to the client), but every server-only `use`/static inside it is individually `#[cfg(feature = "server")]`. The shared `use` of `ExecRequest`/`ExecTask` (used in signatures) stays ungated. `code_runner/languages.rs` and `code_runner/progress.rs` (pure server helpers) are wholly gated. Mirrors the `posts/` module convention.
**Governor 0.8 caveat**: `Quota::per_day` does not exist; `CODE_EXEC_DAILY_LIMITER` uses `Quota::with_period(24h).allow_burst(daily)`. `RATE_LIMIT_CODE_EXEC_PER_SEC` must be an integer (governor's `per_second` takes `NonZeroU32`; decimals in `.env` fall back to the default 1).
**Runner images** (`docker/`): `build-runners.sh` builds `yggdrasil-runner-base``yggdrasil-runner-python``yggdrasil-runner-node`; tags must match `LANGUAGES` image fields. Python image symlinks `python``python3` to match `run_cmd`. `runner.toml` files are image self-descriptions only — runtime config is the Rust `LANGUAGES` registry + `CODE_RUNNER_*` env, not parsed from toml.
## Frontend Lib Subprojects
Four Vite-built IIFE libraries under `libs/`, managed as a **pnpm workspace** (single `libs/pnpm-lock.yaml`, shared `libs/tsconfig.base.json` + `libs/biome.json`, hoisted devDeps). Built artifacts go to `public/<name>/`**do not edit `public/<name>/` files; they are build artifacts**. Each `build` script is `tsc --noEmit && vite build` (type-check before bundle). Output is IIFE because Dioxus `[web.resource] script` injects bare `<script src>` without `type="module"` support. Registered globally in `Dioxus.toml` `script`/`style` arrays.

View File

@ -78,9 +78,7 @@ export class CodeMirrorInstance {
// vim 必须在 keymap 最前(@replit/codemirror-vim 仓库要求)
this.vimCompartment.of(options.vim ? [vim()] : []),
this.themeCompartment.of(themeExtension(theme)),
this.languageCompartment.of(
buildLanguageExtension(this.language, schema),
),
this.languageCompartment.of(buildLanguageExtension(this.language, schema)),
EditorView.updateListener.of((v) => {
if (v.docChanged) {
options.onChange?.(this.view.state.doc.toString());
@ -115,9 +113,7 @@ export class CodeMirrorInstance {
setLanguage(lang: string): void {
this.language = lang;
this.view.dispatch({
effects: this.languageCompartment.reconfigure(
buildLanguageExtension(lang, this.schema),
),
effects: this.languageCompartment.reconfigure(buildLanguageExtension(lang, this.schema)),
});
}
@ -128,9 +124,7 @@ export class CodeMirrorInstance {
// 仅当当前是 SQL 语言时才重配 extension其它语言不消费 schema
if ((this.language ?? '').toLowerCase() === 'sql' || !this.language) {
this.view.dispatch({
effects: this.languageCompartment.reconfigure(
buildLanguageExtension('sql', schema),
),
effects: this.languageCompartment.reconfigure(buildLanguageExtension('sql', schema)),
});
}
}

View File

@ -12,9 +12,8 @@ use std::sync::LazyLock;
use crate::infra::runner_config::{ResourceLimits, RUNNER_CONFIG};
/// 单个语言的运行定义。
/// 单个语言的运行定义。语言名即 [`LANGUAGES`] 的 key不再冗余存字段。
pub struct LanguageDef {
pub name: String,
/// 容器镜像task 12 的 docker build 产出,如 `yggdrasil-runner-python:latest`)。
pub image: String,
/// 容器内执行命令(源码会注入到 `/code/main.{ext}`)。
@ -33,7 +32,6 @@ pub static LANGUAGES: LazyLock<HashMap<String, LanguageDef>> = LazyLock::new(||
m.insert(
"python".to_string(),
LanguageDef {
name: "python".to_string(),
image: "yggdrasil-runner-python:latest".to_string(),
run_cmd: "python /code/main.py".to_string(),
extension: "py".to_string(),
@ -51,7 +49,6 @@ pub static LANGUAGES: LazyLock<HashMap<String, LanguageDef>> = LazyLock::new(||
m.insert(
"node".to_string(),
LanguageDef {
name: "node".to_string(),
image: "yggdrasil-runner-node:latest".to_string(),
run_cmd: "node /code/main.js".to_string(),
extension: "js".to_string(),

View File

@ -90,9 +90,9 @@ pub fn render_markdown_enhanced(md: &str) -> RenderedContent {
let mut in_heading = false;
let mut in_codeblock = false;
let mut code_lang: Option<String> = None;
/// 可运行代码块的 (lang, html-escaped overrides JSON)。
/// 为 None 表示普通代码块。原始源码在 End 处从 code_buffer 转义后存入 data-source
/// 供阅读器无损提取(避免从高亮 HTML 反解)。
// 可运行代码块的 (lang, html-escaped overrides JSON)。
// 为 None 表示普通代码块。原始源码在 End 处从 code_buffer 转义后存入 data-source
// 供阅读器无损提取(避免从高亮 HTML 反解)。
let mut code_runnable: Option<(String, String)> = None;
let mut code_buffer = String::new();
let mut non_heading_events: Vec<Event> = Vec::new();
@ -157,9 +157,7 @@ pub fn render_markdown_enhanced(md: &str) -> RenderedContent {
};
// 解析围栏 info识别 `runnable` 标记与可选 ResourceLimits JSON 覆盖。
// 仅在「标记为 runnable 且语言受支持」时挂 data-*,供阅读器扫描挂载运行器。
code_runnable = code_lang
.as_deref()
.map(|info| {
code_runnable = code_lang.as_deref().and_then(|info| {
let (lang, runnable, overrides) =
crate::api::code_runner::languages::parse_fence_info(info);
if runnable && crate::api::code_runner::languages::is_supported_lang(&lang) {
@ -172,8 +170,7 @@ pub fn render_markdown_enhanced(md: &str) -> RenderedContent {
} else {
None
}
})
.flatten();
});
code_buffer.clear();
}
Event::Text(text) if in_codeblock => {

View File

@ -29,10 +29,10 @@ pub fn CodeRunner(
overrides: Option<ResourceLimits>,
) -> 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());
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);
// 为每个实例生成稳定的容器 idCodeMirror 容器,由调用方在 WASM 端挂载)。
// use_id 在 Dioxus 0.7 提供 scope 内稳定的 id退回 use_hook 保证只算一次。

View File

@ -118,9 +118,9 @@ pub async fn run_in_container(
};
// Start container
if let Err(e) = docker.start_container(&container_id, None::<StartContainerOptions<String>>).await {
return Err(e);
}
docker
.start_container(&container_id, None::<StartContainerOptions<String>>)
.await?;
// Write source code to stdin and drop/close the writer
use tokio::io::AsyncWriteExt;
@ -130,7 +130,7 @@ pub async fn run_in_container(
let _ = writer.shutdown().await;
};
if let Err(_) = timeout(Duration::from_secs(5), write_fut).await {
if timeout(Duration::from_secs(5), write_fut).await.is_err() {
return Err(bollard::errors::Error::IOError {
err: std::io::Error::new(std::io::ErrorKind::TimedOut, "Writing to stdin timed out")
});
@ -366,12 +366,10 @@ mod tests {
let mut leaked = Vec::new();
for c in after {
let id = c.id.unwrap();
if !before_ids.contains(&id) {
if c.image.as_deref() == Some("alpine:latest") {
if !before_ids.contains(&id) && c.image.as_deref() == Some("alpine:latest") {
leaked.push(id);
}
}
}
let leaked_count = leaked.len();
for id in leaked {

View File

@ -56,7 +56,7 @@ pub static RUNNER_CONFIG: LazyLock<RunnerConfig> = LazyLock::new(|| {
#[cfg(feature = "server")]
pub fn clamp_limits(merged: ResourceLimits, lang_allows_network: bool) -> ResourceLimits {
clamp_limits_impl(merged, lang_allows_network, &*RUNNER_CONFIG)
clamp_limits_impl(merged, lang_allows_network, &RUNNER_CONFIG)
}
#[cfg(feature = "server")]
@ -102,7 +102,7 @@ mod tests {
assert!(clamped.memory_mb <= 1024);
assert!(clamped.timeout_secs <= 30);
assert!(clamped.output_bytes <= 1048576);
assert_eq!(clamped.allow_network, false);
assert!(!clamped.allow_network);
}
#[test]
@ -132,7 +132,7 @@ mod tests {
assert_eq!(clamped.memory_mb, 8);
assert_eq!(clamped.timeout_secs, 0);
assert_eq!(clamped.output_bytes, 50);
assert_eq!(clamped.allow_network, true);
assert!(clamped.allow_network);
}
#[test]

View File

@ -60,10 +60,10 @@ pub fn PostsPage(page: i32) -> Element {
// 删除中 / 重建中文章 ID 集合:均由本组件持有(业务逻辑不归 hook 管)。
// 改为非乐观删除后行会保留至请求完成,可并发点多个删除,故用 HashSet
// 与 rebuilding 同形,按行通过 contains 判断 loading 态。
let mut deleting = use_signal(|| std::collections::HashSet::<i32>::new());
let mut deleting = use_signal(std::collections::HashSet::<i32>::new);
// 重建中文章 ID 集合:支持多篇文章并发重建(行不会随点击消失,单值会被后点
// 的覆盖先点的,故用 HashSet按行通过 contains 判断 loading 态。
let mut rebuilding = use_signal(|| std::collections::HashSet::<i32>::new());
let mut rebuilding = use_signal(std::collections::HashSet::<i32>::new);
// 重建缓存的状态由本组件持有并下发给 RebuildCacheBar结果消息也在本组件
// 渲染header 与表格之间的独立行),既不撑高 header 触发 items-center 重排,
// 也不脱离文档流溢进表格。rebuilding 仅按钮态用,不在此渲染。

View File

@ -31,8 +31,8 @@ pub fn Runner() -> Element {
let mut lang = use_signal(|| "python".to_string());
// 语言切换时刷新示例源码(首次进入也有默认值)。
let mut source = use_signal(|| default_source("python"));
let mut overrides_json = use_signal(|| String::new());
let mut override_error = use_signal(|| String::new());
let mut overrides_json = use_signal(String::new);
let mut override_error = use_signal(String::new);
let current_source = source();
let current_lang = lang();