Skip to main content

submilli_engine/
backtrace.rs

1//! Trap → Submilli-source backtrace rendering.
2
3use wasmtime::{Error, FrameInfo, Trap, WasmBacktrace};
4
5use crate::diagnostics::write_source_block;
6use crate::rendering::{RenderError, RenderLimits, RenderedText, Writer};
7use crate::source::Sources;
8use crate::{FileId, Span};
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum BacktraceMode {
12    Full,
13    /// Every user frame plus the innermost and outermost non-user frame.
14    /// Middle non-user frames are dropped — boundary frames anchor where
15    /// user code crossed into the runtime without exposing prelude internals.
16    LlmTrimmed,
17}
18
19/// An uncaught thrown `Error`, carrying its `name: message` text plus the
20/// throw-site backtrace the engine attached to the escaped exception;
21/// `uncaught_error` re-wraps both here so [`render`] prints a source
22/// backtrace like a trap.
23#[derive(Debug)]
24pub struct ThrownError {
25    pub message: String,
26    pub backtrace: Option<WasmBacktrace>,
27    /// Set when the escaped error is a denial the runtime itself threw, as
28    /// opposed to a `PermissionDeniedError` the program constructed.
29    pub denial: Option<crate::runtime::host::Denial>,
30}
31
32impl std::fmt::Display for ThrownError {
33    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
34        f.write_str(&self.message)
35    }
36}
37
38impl std::error::Error for ThrownError {}
39
40/// Render the backtrace against `sources`. `file` is the entrypoint script; the
41/// registry may also include package sources whose DWARF paths can render
42/// source context for package frames.
43pub fn render(
44    error: &Error,
45    sources: &Sources,
46    file: FileId,
47    mode: BacktraceMode,
48) -> Option<String> {
49    match render_checked(error, sources, file, mode) {
50        Ok(rendered) => rendered.map(|text| text.text),
51        Err(failure) => Some(crate::rendering::failure_text(
52            &failure_message(error),
53            &failure,
54        )),
55    }
56}
57
58pub fn render_checked(
59    error: &Error,
60    sources: &Sources,
61    file: FileId,
62    mode: BacktraceMode,
63) -> Result<Option<RenderedText>, RenderError> {
64    let (trace, label) = if let Some(thrown) = error.downcast_ref::<ThrownError>() {
65        (thrown.backtrace.as_ref(), "thrown here")
66    } else {
67        (error.downcast_ref::<WasmBacktrace>(), trap_label_for(error))
68    };
69    let Some(trace) = trace else {
70        return Ok(None);
71    };
72    sources
73        .get(file)
74        .ok_or(crate::source::SourceError::UnknownFile { file })?;
75    let mut has_frame = false;
76    let rendered = Writer::render(RenderLimits::default(), |out| {
77        out.push("error: ")?;
78        write_failure(out, error)?;
79        out.push("\n")?;
80        let mut first = None;
81        let mut last = None;
82        for (i, frame) in trace.frames().iter().enumerate() {
83            out.step()?;
84            if frame.symbols().first().and_then(|s| s.name()).is_none() {
85                continue;
86            }
87            if !is_source_frame(frame, sources) {
88                first.get_or_insert(i);
89                last = Some(i);
90            }
91        }
92        let mut rendered_index = 0usize;
93        // No temporary frame vectors: both scans share the work budget.
94        let mut last_kept = None;
95        for (i, frame) in trace.frames().iter().enumerate() {
96            out.step()?;
97            if frame.symbols().first().and_then(|s| s.name()).is_none() {
98                continue;
99            }
100            if mode == BacktraceMode::Full
101                || is_source_frame(frame, sources)
102                || Some(i) == first
103                || Some(i) == last
104            {
105                last_kept = Some(i);
106            }
107        }
108        for (i, frame) in trace.frames().iter().enumerate() {
109            out.step()?;
110            if frame.symbols().first().and_then(|s| s.name()).is_none() {
111                continue;
112            }
113            if mode == BacktraceMode::LlmTrimmed
114                && !is_source_frame(frame, sources)
115                && Some(i) != first
116                && Some(i) != last
117            {
118                continue;
119            }
120            let role = if rendered_index == 0 {
121                label
122            } else if Some(i) == last_kept {
123                "entry"
124            } else {
125                "caller"
126            };
127            write_frame(out, frame, sources, role)?;
128            rendered_index = rendered_index
129                .checked_add(1)
130                .ok_or(RenderError::Formatting)?;
131            has_frame = true;
132        }
133        Ok(())
134    })?;
135    Ok((has_frame || rendered.truncated).then_some(rendered))
136}
137
138pub fn failure_message_checked(error: &Error) -> Result<RenderedText, RenderError> {
139    Writer::render(RenderLimits::default(), |out| write_failure(out, error))
140}
141
142fn write_failure(out: &mut Writer, error: &Error) -> Result<(), RenderError> {
143    match error.downcast_ref::<Trap>() {
144        Some(Trap::Interrupt) => out.push("timeout exceeded"),
145        Some(Trap::OutOfFuel) => out.push("fuel exhausted"),
146        Some(Trap::UnreachableCodeReached) => out.push("unreachable code reached"),
147        _ => out.format(format_args!("{error}")),
148    }
149}
150
151/// The one-line text for a run's failure: the header [`render`] puts above the
152/// frames, and what a caller prints when `render` has no frames to show.
153///
154/// Most engine messages already say what the program did (`null reference`,
155/// `cast failure`, `out of bounds array access`) and are used verbatim. These
156/// three name an engine mechanism instead — `interrupt` and
157/// `all fuel consumed by WebAssembly` tell a reader nothing they can act on, and
158/// leak the host into a diagnostic that is supposed to be about their code.
159///
160/// Deliberately not the same table as [`trap_label_for`]: a stack overflow reads
161/// better as the engine's `call stack exhausted` in the header (it says what
162/// happened) and as `stack overflow` in the frame label (it names the frame).
163pub fn failure_message(error: &Error) -> String {
164    failure_message_checked(error).map_or_else(
165        |failure| crate::rendering::failure_text("runtime execution failed", &failure),
166        |rendered| rendered.text,
167    )
168}
169
170fn trap_label_for(error: &Error) -> &'static str {
171    match error.downcast_ref::<Trap>() {
172        Some(Trap::UnreachableCodeReached) => "unreachable code reached",
173        Some(Trap::OutOfFuel) => "fuel exhausted",
174        Some(Trap::StackOverflow) => "stack overflow",
175        Some(Trap::Interrupt) => "timeout exceeded",
176        Some(_) | None => "trap raised here",
177    }
178}
179
180fn write_frame(
181    out: &mut Writer,
182    frame: &FrameInfo,
183    sources: &Sources,
184    role: &str,
185) -> Result<(), RenderError> {
186    let Some(sym) = frame.symbols().first() else {
187        return Ok(());
188    };
189    let Some(name) = sym.name() else {
190        return Ok(());
191    };
192    let path = sym.file().unwrap_or("?");
193    let line = sym.line().unwrap_or(0);
194    let col = sym.column().unwrap_or(0);
195    write_symbol(out, name, path, line, col, sources, role)
196}
197
198fn write_symbol(
199    out: &mut Writer,
200    name: &str,
201    path: &str,
202    line: u32,
203    col: u32,
204    sources: &Sources,
205    role: &str,
206) -> Result<(), RenderError> {
207    let context = if let Some((file, source)) = sources.find_path(path)
208        && line != 0
209    {
210        let index = source.line_index();
211        let offset = index.byte_offset(line, col.max(1))?;
212        Some((index, Span::new(file, offset, offset)?))
213    } else {
214        None
215    };
216    out.format(format_args!(
217        "  at {name} ({path}:{line}:{col})  [{role}]\n"
218    ))?;
219    if let Some((index, span)) = context {
220        let width = index.line_count().max(1).to_string().len();
221        write_source_block(out, index, span, width)?;
222    }
223    Ok(())
224}
225
226fn is_source_frame(frame: &FrameInfo, sources: &Sources) -> bool {
227    frame
228        .symbols()
229        .first()
230        .and_then(|s| s.file())
231        .is_some_and(|p| sources.find_path(p).is_some())
232}
233
234/// The engine delegates alternate Display to anyhow's cause iterator. Its writes
235/// pass through the same bounded sink, including separators between causes.
236pub fn failure_chain_checked(error: &Error) -> Result<RenderedText, RenderError> {
237    Writer::render(RenderLimits::default(), |out| {
238        out.format(format_args!("{error:#}"))
239    })
240}
241
242/// Returns every user frame plus the first and last non-user frame.
243#[cfg(test)]
244fn kept_indices(is_user: &[bool]) -> Vec<usize> {
245    let first_non_user = is_user.iter().position(|u| !u);
246    let last_non_user = is_user.iter().rposition(|u| !u);
247    (0..is_user.len())
248        .filter(|i| is_user[*i] || Some(*i) == first_non_user || Some(*i) == last_non_user)
249        .collect()
250}
251
252#[cfg(test)]
253mod tests {
254    use super::*;
255
256    #[test]
257    fn malformed_symbol_context_fails_before_a_long_frame_can_truncate() {
258        let (sources, _) = Sources::single("test.ts", "x").unwrap();
259        let long_name = "frame".repeat(20_000);
260        let result = Writer::render(RenderLimits::default(), |out| {
261            write_symbol(out, &long_name, "test.ts", 99, 1, &sources, "caller")
262        });
263        assert!(matches!(result, Err(RenderError::Source(_))));
264        let valid = Writer::render(RenderLimits::default(), |out| {
265            write_symbol(out, &long_name, "test.ts", 1, 1, &sources, "caller")
266        })
267        .unwrap();
268        assert!(valid.truncated);
269        let unknown = Writer::render(RenderLimits::default(), |out| {
270            write_symbol(out, "frame", "unknown.ts", 99, 1, &sources, "caller")
271        })
272        .unwrap();
273        assert!(unknown.text.contains("unknown.ts:99:1"));
274    }
275
276    #[test]
277    fn oversized_failure_chain_keeps_its_primary_message() {
278        let error = Error::msg("primary failure ".repeat(20_000));
279        let result = failure_chain_checked(&error).unwrap();
280        assert!(result.truncated);
281        assert!(result.text.starts_with("primary failure"));
282        assert!(result.text.len() <= RenderLimits::default().bytes);
283    }
284
285    #[test]
286    fn curated_trap_messages_replace_the_engines_wording() {
287        // `Interrupt` and `OutOfFuel` name a host mechanism; a reader can act on
288        // neither. The rest of the table already describes what the program did.
289        for (trap, expected) in [
290            (Trap::Interrupt, "timeout exceeded"),
291            (Trap::OutOfFuel, "fuel exhausted"),
292            (Trap::UnreachableCodeReached, "unreachable code reached"),
293        ] {
294            assert_eq!(failure_message(&Error::new(trap)), expected);
295        }
296    }
297
298    #[test]
299    fn uncurated_traps_keep_the_engine_message() {
300        assert_eq!(
301            failure_message(&Error::new(Trap::NullReference)),
302            "null reference",
303        );
304    }
305
306    fn run(is_user: &[bool]) -> Vec<bool> {
307        kept_indices(is_user)
308            .into_iter()
309            .map(|i| is_user[i])
310            .collect()
311    }
312
313    #[test]
314    fn empty_in_empty_out() {
315        assert!(run(&[]).is_empty());
316    }
317
318    #[test]
319    fn all_user_unchanged() {
320        assert_eq!(run(&[true]), vec![true]);
321        assert_eq!(run(&[true, true, true]), vec![true, true, true]);
322    }
323
324    #[test]
325    fn single_non_user_kept() {
326        assert_eq!(run(&[false]), vec![false]);
327    }
328
329    #[test]
330    fn one_non_user_then_users() {
331        assert_eq!(run(&[false, true, true]), vec![false, true, true]);
332    }
333
334    #[test]
335    fn middle_non_user_dropped() {
336        assert_eq!(
337            run(&[false, false, false, true, true]),
338            vec![false, false, true, true],
339        );
340    }
341
342    #[test]
343    fn interleaved_non_user_keeps_anchors_only() {
344        assert_eq!(
345            run(&[false, true, false, true, false]),
346            vec![false, true, true, false],
347        );
348    }
349
350    #[test]
351    fn all_non_user_keeps_first_and_last() {
352        assert_eq!(run(&[false, false, false]), vec![false, false]);
353        assert_eq!(run(&[false, false, false, false]), vec![false, false],);
354    }
355}