Skip to main content

yggdrasil/components/code_runner/runner/
state.rs

1//! 运行状态:进行中三个阶段,结束后一种结果。每种状态对应标题栏的「圆点 + 文字」、
2//! 色调、输出区的说明,以及是否在输出区给出重试按钮。
3#[cfg(any(target_arch = "wasm32", test))]
4use crate::api::code_runner::{ExecResult, ExecStatus};
5#[cfg(any(target_arch = "wasm32", test))]
6use crate::api::error::ApiError;
7
8/// 进行中的阶段。排队与运行只在浏览器端由流事件构造。
9#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
10#[derive(Clone, Copy, Debug, PartialEq)]
11pub(super) enum Phase {
12    /// 并发槽位已满,等待。
13    Queued,
14    /// 提交中或正在拉起容器。
15    Preparing,
16    /// 容器已启动。
17    Running,
18}
19
20/// 一次运行的结果。上一次的结果在新一次开始时清空。真实结果只在浏览器端构造。
21#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
22#[derive(Clone, Debug, PartialEq)]
23pub(super) enum Outcome {
24    Success {
25        duration_ms: u64,
26    },
27    RunError {
28        exit_code: Option<i64>,
29        duration_ms: u64,
30    },
31    Timeout {
32        duration_ms: u64,
33    },
34    OutOfMemory {
35        duration_ms: u64,
36    },
37    /// 服务端限流消息(每分钟或每天的上限)。
38    RateLimited(String),
39    /// 运行环境的问题,附服务端给出的可读消息。
40    SystemFailed(String),
41    /// 输出流断开,结果可能不完整。
42    Disconnected,
43    /// 图鉴播放作者保存的输出。
44    Example {
45        meta: String,
46        error: String,
47    },
48}
49
50#[derive(Clone, Debug, PartialEq)]
51pub(super) enum RunState {
52    Idle,
53    Busy(Phase),
54    Finished(Outcome),
55}
56
57/// 状态色调:成功与进行中用中性色,资源受限用琥珀色,出错用红色。
58#[derive(Clone, Copy, Debug, PartialEq)]
59pub(super) enum Tone {
60    Neutral,
61    Warn,
62    Danger,
63}
64
65impl Tone {
66    pub fn as_str(self) -> &'static str {
67        match self {
68            Tone::Neutral => "neutral",
69            Tone::Warn => "warn",
70            Tone::Danger => "danger",
71        }
72    }
73}
74
75impl RunState {
76    pub fn is_running(&self) -> bool {
77        matches!(self, Self::Busy(_))
78    }
79
80    pub fn is_finished(&self) -> bool {
81        matches!(self, Self::Finished(_))
82    }
83
84    pub fn label(&self) -> &'static str {
85        match self {
86            Self::Idle => "未运行",
87            Self::Busy(Phase::Queued) => "排队中",
88            Self::Busy(Phase::Preparing) => "准备中",
89            Self::Busy(Phase::Running) => "运行中",
90            Self::Finished(outcome) => match outcome {
91                Outcome::Success { .. } => "成功",
92                Outcome::RunError { .. } => "运行错误",
93                Outcome::Timeout { .. } => "超时",
94                Outcome::OutOfMemory { .. } => "内存超限",
95                Outcome::RateLimited(_) => "请求过频",
96                Outcome::SystemFailed(_) => "系统失败",
97                Outcome::Disconnected => "连接中断",
98                Outcome::Example { .. } => "示例",
99            },
100        }
101    }
102
103    pub fn tone(&self) -> Tone {
104        match self {
105            Self::Finished(Outcome::Timeout { .. } | Outcome::OutOfMemory { .. })
106            | Self::Finished(Outcome::RateLimited(_)) => Tone::Warn,
107            Self::Finished(
108                Outcome::RunError { .. } | Outcome::SystemFailed(_) | Outcome::Disconnected,
109            ) => Tone::Danger,
110            Self::Finished(Outcome::Example { error, .. }) if !error.is_empty() => Tone::Danger,
111            _ => Tone::Neutral,
112        }
113    }
114
115    /// 输出区标题栏右侧的耗时与退出码。
116    pub fn meta(&self) -> String {
117        match self {
118            Self::Finished(Outcome::Success { duration_ms }) => {
119                format!("退出码 0 · {}", seconds(*duration_ms))
120            }
121            Self::Finished(Outcome::RunError {
122                exit_code,
123                duration_ms,
124            }) => match exit_code {
125                Some(code) => format!("退出码 {code} · {}", seconds(*duration_ms)),
126                None => seconds(*duration_ms),
127            },
128            Self::Finished(
129                Outcome::Timeout { duration_ms } | Outcome::OutOfMemory { duration_ms },
130            ) => seconds(*duration_ms),
131            Self::Finished(Outcome::Example { meta, .. }) => meta.clone(),
132            _ => String::new(),
133        }
134    }
135
136    /// 进行中阶段的提示,显示在终端骨架上,不占输出区高度;没有时不渲染。
137    pub fn busy_hint(&self) -> Option<&'static str> {
138        match self {
139            Self::Busy(Phase::Queued) => Some("并发已满,空出槽位后自动开始"),
140            Self::Busy(Phase::Preparing) => Some("正在启动容器…"),
141            _ => None,
142        }
143    }
144
145    /// 结束后输出区底部的说明;没有时不渲染。
146    pub fn note(&self) -> Option<String> {
147        let text = match self {
148            Self::Finished(Outcome::Timeout { .. }) => "超过时间上限被终止;输出到终止前为止",
149            Self::Finished(Outcome::OutOfMemory { .. }) => "内存超过上限,进程被系统终止",
150            Self::Finished(Outcome::RateLimited(message)) => return Some(message.clone()),
151            Self::Finished(Outcome::SystemFailed(message)) if !message.is_empty() => {
152                return Some(message.clone())
153            }
154            Self::Finished(Outcome::SystemFailed(_)) => "运行环境出了问题,不是代码的错",
155            Self::Finished(Outcome::Disconnected) => "输出流断开,结果可能不完整",
156            Self::Finished(Outcome::Example { error, .. }) if !error.is_empty() => {
157                return Some(error.clone())
158            }
159            _ => return None,
160        };
161        Some(text.to_string())
162    }
163
164    /// 与代码无关、再试一次可能成功的结果。
165    pub fn retryable(&self) -> bool {
166        matches!(
167            self,
168            Self::Finished(
169                Outcome::RateLimited(_) | Outcome::SystemFailed(_) | Outcome::Disconnected
170            )
171        )
172    }
173
174    /// 提交被服务端拒绝:限流消息单独归类,其余视为系统失败。
175    #[cfg(any(target_arch = "wasm32", test))]
176    pub fn rejected(error: &ApiError) -> Self {
177        let message = crate::utils::error::error_reason(error)
178            .unwrap_or_else(|| "运行服务暂时不可用,请稍后再试".to_string());
179        if message.contains("频繁") || message.contains("次数已达上限") {
180            Self::Finished(Outcome::RateLimited(message))
181        } else {
182            Self::Finished(Outcome::SystemFailed(message))
183        }
184    }
185
186    /// 轮询兜底路径的终态结果。
187    #[cfg(any(target_arch = "wasm32", test))]
188    pub fn result(result: &ExecResult) -> Self {
189        let duration_ms = result.duration_ms;
190        Self::Finished(match result.status {
191            ExecStatus::Success => Outcome::Success { duration_ms },
192            ExecStatus::Timeout => Outcome::Timeout { duration_ms },
193            ExecStatus::OomKilled => Outcome::OutOfMemory { duration_ms },
194            ExecStatus::Error => Outcome::RunError {
195                exit_code: result.exit_code,
196                duration_ms,
197            },
198            ExecStatus::RateLimited => Outcome::RateLimited("运行太频繁,稍后再试".into()),
199            ExecStatus::Failed | ExecStatus::Queued | ExecStatus::Running => {
200                Outcome::SystemFailed(result.stderr.trim().to_string())
201            }
202        })
203    }
204}
205
206/// 流式路径 `done` 事件的载荷。
207#[cfg(any(target_arch = "wasm32", test))]
208#[derive(serde::Deserialize)]
209pub(super) struct DonePayload {
210    pub exit_code: Option<i64>,
211    pub oom_killed: bool,
212    pub timed_out: bool,
213    pub duration_ms: u64,
214    #[serde(default)]
215    pub error: Option<String>,
216}
217
218#[cfg(any(target_arch = "wasm32", test))]
219impl DonePayload {
220    /// 超时与内存超限优先于系统错误:容器被终止时服务端也会附带错误消息。
221    pub fn outcome(self) -> RunState {
222        let duration_ms = self.duration_ms;
223        RunState::Finished(if self.timed_out {
224            Outcome::Timeout { duration_ms }
225        } else if self.oom_killed {
226            Outcome::OutOfMemory { duration_ms }
227        } else if let Some(error) = self.error {
228            Outcome::SystemFailed(error)
229        } else if self.exit_code == Some(0) {
230            Outcome::Success { duration_ms }
231        } else {
232            Outcome::RunError {
233                exit_code: self.exit_code,
234                duration_ms,
235            }
236        })
237    }
238}
239
240/// `0.04 s`、`0.8 s`、`12 s`:秒数随量级减少小数位。
241fn seconds(ms: u64) -> String {
242    let s = ms as f64 / 1000.0;
243    if s < 1.0 {
244        format!("{s:.2} s")
245    } else if s < 10.0 {
246        format!("{s:.1} s")
247    } else {
248        format!("{s:.0} s")
249    }
250}
251
252#[cfg(test)]
253mod tests {
254    use super::*;
255
256    fn done(exit_code: Option<i64>, oom: bool, timed_out: bool, error: Option<&str>) -> RunState {
257        DonePayload {
258            exit_code,
259            oom_killed: oom,
260            timed_out,
261            duration_ms: 40,
262            error: error.map(str::to_string),
263        }
264        .outcome()
265    }
266
267    #[test]
268    fn done_payload_maps_to_each_outcome() {
269        assert_eq!(done(Some(0), false, false, None).label(), "成功");
270        assert_eq!(done(Some(1), false, false, None).label(), "运行错误");
271        assert_eq!(done(None, false, true, Some("执行超时")).label(), "超时");
272        assert_eq!(done(Some(137), true, false, None).label(), "内存超限");
273        assert_eq!(
274            done(None, false, false, Some("镜像未构建")).label(),
275            "系统失败"
276        );
277    }
278
279    #[test]
280    fn tones_follow_severity() {
281        assert_eq!(done(Some(0), false, false, None).tone(), Tone::Neutral);
282        assert_eq!(done(None, false, true, None).tone(), Tone::Warn);
283        assert_eq!(done(Some(1), false, false, None).tone(), Tone::Danger);
284        assert_eq!(RunState::Busy(Phase::Running).tone(), Tone::Neutral);
285    }
286
287    #[test]
288    fn meta_shows_exit_code_and_duration() {
289        assert_eq!(
290            done(Some(0), false, false, None).meta(),
291            "退出码 0 · 0.04 s"
292        );
293        assert_eq!(
294            done(Some(1), false, false, None).meta(),
295            "退出码 1 · 0.04 s"
296        );
297        assert_eq!(seconds(800), "0.80 s");
298        assert_eq!(seconds(2_340), "2.3 s");
299        assert_eq!(seconds(15_000), "15 s");
300    }
301
302    #[test]
303    fn rejected_submission_separates_rate_limits() {
304        let limited = RunState::rejected(&ApiError::BadRequest("请求过于频繁,请稍后再试".into()));
305        assert_eq!(limited.label(), "请求过频");
306        assert_eq!(limited.note().as_deref(), Some("请求过于频繁,请稍后再试"));
307        assert!(limited.retryable());
308        let oversized = RunState::rejected(&ApiError::BadRequest("源代码过大".into()));
309        assert_eq!(oversized.label(), "系统失败");
310        assert_eq!(oversized.note().as_deref(), Some("源代码过大"));
311    }
312
313    #[test]
314    fn busy_phases_never_add_a_note_row() {
315        for phase in [Phase::Queued, Phase::Preparing, Phase::Running] {
316            assert_eq!(RunState::Busy(phase).note(), None, "{phase:?} 不应占输出区");
317        }
318        assert!(RunState::Busy(Phase::Queued).busy_hint().is_some());
319        assert!(RunState::Busy(Phase::Preparing).busy_hint().is_some());
320        assert!(RunState::Busy(Phase::Running).busy_hint().is_none());
321        assert!(RunState::Finished(Outcome::Disconnected)
322            .busy_hint()
323            .is_none());
324    }
325
326    #[test]
327    fn only_environment_failures_offer_retry() {
328        assert!(RunState::Finished(Outcome::Disconnected).retryable());
329        assert!(!done(Some(1), false, false, None).retryable());
330        assert!(!done(None, false, true, None).retryable());
331    }
332
333    #[test]
334    fn poll_results_never_stay_running() {
335        for status in [
336            ExecStatus::Success,
337            ExecStatus::Error,
338            ExecStatus::Timeout,
339            ExecStatus::OomKilled,
340            ExecStatus::Failed,
341        ] {
342            let result = ExecResult {
343                status,
344                stdout: String::new(),
345                stderr: String::new(),
346                exit_code: None,
347                duration_ms: 12,
348                language: "python".into(),
349            };
350            let state = RunState::result(&result);
351            assert!(state.is_finished());
352        }
353    }
354}