Skip to main content

lanekeep_js/
error.rs

1//! Why sandboxed execution failed.
2
3use std::time::Duration;
4
5use thiserror::Error;
6
7/// A failure inside the sandbox.
8///
9/// Every variant here aborts the run. Skipping the offending rule and continuing is the
10/// friendlier-looking behavior and is wrong: a timeout is timing-dependent, so a rule that
11/// trips on a loaded machine and not on an idle one would make output vary between runs on
12/// identical input — the property the rest of the design works to prevent. A checker that
13/// could not finish must not be mistaken for one that found nothing.
14#[derive(Debug, Clone, PartialEq, Eq, Error)]
15pub enum SandboxError {
16    /// One handler invocation exceeded its budget.
17    #[error(
18        "rule execution exceeded its {budget:?} budget\n  \
19         the handler was still running after {budget:?} and was stopped\n  \
20         a rule that cannot finish in that time usually needs a tighter query, not a \
21         longer clock — raise it with `timeout` on the rule if the work is genuinely heavy"
22    )]
23    RuleTimeout {
24        /// The budget that was exceeded.
25        budget: Duration,
26    },
27
28    /// The run as a whole exceeded its wall-clock budget.
29    #[error(
30        "the run exceeded its {budget:?} budget after {elapsed:?}\n  \
31         no single rule necessarily misbehaved — the total simply ran too long\n  \
32         raise it with `--timeout`, or narrow what is being checked"
33    )]
34    RunTimeout {
35        /// The global budget.
36        budget: Duration,
37        /// How long the run had actually been going.
38        elapsed: Duration,
39    },
40
41    /// The runtime hit its memory ceiling.
42    #[error(
43        "rule execution exceeded its {limit_bytes} byte memory ceiling\n  \
44         this usually means a rule accumulated without bound — check for a loop that \
45         collects into an array or map that is never cleared"
46    )]
47    MemoryExceeded {
48        /// The ceiling that was hit.
49        limit_bytes: usize,
50    },
51
52    /// The rule threw, or failed to parse.
53    #[error("rule threw: {message}{}", crate::error::indent_stack(.stack.as_deref()))]
54    Script {
55        /// The thrown value's message.
56        message: String,
57        /// The stack trace, if the thrown value carried one.
58        stack: Option<String>,
59    },
60
61    /// The rule threw something that is not an `Error`.
62    #[error(
63        "rule threw a non-Error value\n  \
64         throwing a string or object loses the stack trace; throw an Error instead"
65    )]
66    NonErrorThrown,
67
68    /// The engine failed for a reason lanekeep does not model.
69    #[error("javascript engine error: {0}")]
70    Engine(String),
71}
72
73impl SandboxError {
74    /// Whether this failure came from a breached limit rather than from rule logic.
75    ///
76    /// Both cancel the run. The distinction is for the diagnostic: a limit breach is a
77    /// budget problem, a thrown error is a bug in the rule.
78    #[must_use]
79    pub const fn is_limit_breach(&self) -> bool {
80        matches!(
81            self,
82            Self::RuleTimeout { .. } | Self::RunTimeout { .. } | Self::MemoryExceeded { .. }
83        )
84    }
85}
86
87fn indent_stack(stack: Option<&str>) -> String {
88    use std::fmt::Write as _;
89
90    match stack {
91        Some(s) if !s.trim().is_empty() => s.lines().fold(String::new(), |mut out, line| {
92            // Writing into a String cannot fail, and swallowing the Result here keeps this
93            // a plain fold rather than a loop with an unreachable error arm.
94            let _ = write!(out, "\n  {}", line.trim_end());
95            out
96        }),
97        _ => String::new(),
98    }
99}
100
101#[cfg(test)]
102mod tests {
103    use super::*;
104
105    #[test]
106    fn limit_breaches_are_distinguishable_from_rule_bugs() {
107        assert!(
108            SandboxError::RuleTimeout {
109                budget: Duration::from_secs(1)
110            }
111            .is_limit_breach()
112        );
113        assert!(
114            SandboxError::RunTimeout {
115                budget: Duration::from_secs(15),
116                elapsed: Duration::from_secs(16),
117            }
118            .is_limit_breach()
119        );
120        assert!(SandboxError::MemoryExceeded { limit_bytes: 1024 }.is_limit_breach());
121
122        assert!(
123            !SandboxError::Script {
124                message: "boom".to_owned(),
125                stack: None
126            }
127            .is_limit_breach()
128        );
129        assert!(!SandboxError::NonErrorThrown.is_limit_breach());
130        assert!(!SandboxError::Engine("odd".to_owned()).is_limit_breach());
131    }
132
133    #[test]
134    fn the_rule_timeout_message_suggests_the_usual_fix() {
135        // The common cause is a query so broad the handler runs on thousands of matches,
136        // and the fix is the query rather than the clock. Saying so is most of the value.
137        let rendered = SandboxError::RuleTimeout {
138            budget: Duration::from_secs(1),
139        }
140        .to_string();
141        assert!(rendered.contains("tighter query"), "{rendered}");
142        assert!(rendered.contains("timeout"), "{rendered}");
143    }
144
145    #[test]
146    fn the_run_timeout_message_does_not_blame_a_rule() {
147        // When the global budget fires, no individual rule necessarily misbehaved. A
148        // message implying otherwise sends the reader looking for a culprit that is not
149        // there.
150        let rendered = SandboxError::RunTimeout {
151            budget: Duration::from_secs(15),
152            elapsed: Duration::from_secs(16),
153        }
154        .to_string();
155        assert!(rendered.contains("no single rule"), "{rendered}");
156    }
157
158    #[test]
159    fn a_script_error_renders_its_stack_indented() {
160        let rendered = SandboxError::Script {
161            message: "cannot read x".to_owned(),
162            stack: Some("at check (rule.ts:4:2)\nat <eval> (rule.ts:1:1)".to_owned()),
163        }
164        .to_string();
165
166        assert!(
167            rendered.starts_with("rule threw: cannot read x"),
168            "{rendered}"
169        );
170        assert!(
171            rendered.contains("\n  at check (rule.ts:4:2)"),
172            "{rendered}"
173        );
174    }
175
176    #[test]
177    fn a_script_error_without_a_stack_renders_cleanly() {
178        let rendered = SandboxError::Script {
179            message: "boom".to_owned(),
180            stack: None,
181        }
182        .to_string();
183        assert_eq!(rendered, "rule threw: boom");
184
185        let blank = SandboxError::Script {
186            message: "boom".to_owned(),
187            stack: Some("  \n".to_owned()),
188        }
189        .to_string();
190        assert_eq!(
191            blank, "rule threw: boom",
192            "whitespace-only stack should not add lines"
193        );
194    }
195}