Skip to main content

logicaffeine_web/ui/
fault.rs

1//! Containing a failure the Studio did not expect.
2//!
3//! The Studio runs the kernel, the English parser and the prover on the page's own thread, so a
4//! bug in any of them — a panic, or the engine running out of stack on a deeply nested term —
5//! would take the whole page down with it. The web build aborts on panic, and an abort in wasm is
6//! a trap: it throws straight out of the module and leaves the page frozen mid-action.
7//!
8//! [`contain`] runs work behind a boundary that turns such a failure into a [`Fault`] and leaves
9//! the page running. In the browser the boundary is a JavaScript `try`/`catch` around a call back
10//! into the module: a trap unwinds only the frames inside it. What the trap leaves behind is the
11//! module's shadow-stack pointer, still set to the depth where the work died, so the boundary puts
12//! it back before any Rust runs again (`build.rs` exports `__stack_pointer` for this). A panic's
13//! own message is taken on its way out by a panic hook, because the trap that ends it carries
14//! none. Natively a panic unwinds, and the boundary is `catch_unwind`.
15//!
16//! What a trap cannot give back: the heap the abandoned frames owned stays allocated, and a
17//! `RefCell` they held borrowed stays borrowed — a later use of it faults again, and is contained
18//! again. And once a panic has been contained, `std::thread::panicking()` answers `true` for the
19//! rest of the page's life, because the abort that ended the panic never let std count it done.
20
21use std::cell::{Cell, RefCell};
22use std::panic::PanicHookInfo;
23use std::sync::Once;
24
25/// Why contained work stopped.
26#[derive(Clone, Copy, Debug, PartialEq, Eq)]
27pub enum Cause {
28    /// The work panicked.
29    Panic,
30    /// The work ran out of stack — the engine's own, or the module's shadow stack.
31    StackExhausted,
32    /// The engine stopped the work for a reason no panic explains.
33    Trap,
34}
35
36/// A failure [`contain`] caught.
37#[derive(Clone, Debug, PartialEq, Eq)]
38pub struct Fault {
39    /// Why the work stopped.
40    pub cause: Cause,
41    /// What went wrong, in the words of the panic or of the engine.
42    pub message: String,
43    /// Where a panic was raised, as `file:line:column`.
44    pub location: Option<String>,
45}
46
47impl Fault {
48    /// The work ran out of stack.
49    pub fn stack_exhausted() -> Self {
50        Fault { cause: Cause::StackExhausted, message: "ran out of stack space".to_string(), location: None }
51    }
52
53    fn panicked(info: &PanicHookInfo<'_>) -> Self {
54        Fault {
55            cause: Cause::Panic,
56            message: payload_text(info.payload()),
57            location: info.location().map(|at| format!("{}:{}:{}", at.file(), at.line(), at.column())),
58        }
59    }
60
61    fn escaped(escape: Escape) -> Self {
62        match escape.cause {
63            Cause::StackExhausted => Fault::stack_exhausted(),
64            Cause::Panic => Fault { cause: Cause::Panic, message: escape.message, location: None },
65            Cause::Trap => Fault {
66                cause: Cause::Trap,
67                message: format!("the engine stopped it ({})", escape.message),
68                location: None,
69            },
70        }
71    }
72}
73
74/// A Studio action that stopped on a fault: which action, and what stopped it.
75#[derive(Clone, Debug, PartialEq, Eq)]
76pub struct ActionFault {
77    /// The action as its button names it: `Execute`, `Compile`, `Auto`.
78    pub action: &'static str,
79    /// What stopped it.
80    pub fault: Fault,
81}
82
83impl ActionFault {
84    /// One line for the toolbar: the action, and the first line of what stopped it.
85    pub fn headline(&self) -> String {
86        let first = self.fault.message.lines().next().unwrap_or_default();
87        format!("{} failed: {first}", self.action)
88    }
89
90    /// Everything known about the fault, for the note's tooltip.
91    pub fn detail(&self) -> String {
92        let mut detail = format!("{} failed unexpectedly: {}", self.action, self.fault.message);
93        if let Some(location) = &self.fault.location {
94            detail.push_str(&format!("\nat {location}"));
95        }
96        detail.push_str("\nThe Studio is still running, and the editor holds your text as it was.");
97        detail
98    }
99}
100
101/// Run `work` behind the fault boundary: its value, or the [`Fault`] that stopped it.
102pub fn contain<T>(work: impl FnOnce() -> T) -> Result<T, Fault> {
103    install_hook();
104    let mut work = Some(work);
105    let mut value = None;
106    let entered = DEPTH.with(|depth| depth.replace(depth.get() + 1));
107    let outcome = boundary::run(&mut || value = work.take().map(|work| work()));
108    DEPTH.with(|depth| depth.set(entered));
109    let caught = CAUGHT.with(|caught| caught.borrow_mut().take());
110    match outcome {
111        Ok(()) => Ok(value.expect("the boundary returned without running the work")),
112        Err(escape) => {
113            let fault = caught.unwrap_or_else(|| Fault::escaped(escape));
114            boundary::report(&fault);
115            Err(fault)
116        }
117    }
118}
119
120/// What the boundary saw leave the work: the thrown message, and what it says the cause was.
121struct Escape {
122    message: String,
123    cause: Cause,
124}
125
126thread_local! {
127    /// How many boundaries the running code is inside.
128    static DEPTH: Cell<usize> = const { Cell::new(0) };
129    /// The last panic raised inside a boundary, taken by the hook on its way out.
130    static CAUGHT: RefCell<Option<Fault>> = const { RefCell::new(None) };
131}
132
133/// Install, once, the hook that takes a contained panic's message. A panic outside every
134/// boundary goes on to the hook that was installed before, as it always did.
135fn install_hook() {
136    static INSTALLED: Once = Once::new();
137    INSTALLED.call_once(|| {
138        let previous = std::panic::take_hook();
139        std::panic::set_hook(Box::new(move |info| {
140            if DEPTH.with(Cell::get) == 0 {
141                previous(info);
142                return;
143            }
144            let fault = Fault::panicked(info);
145            CAUGHT.with(|caught| {
146                if let Ok(mut slot) = caught.try_borrow_mut() {
147                    *slot = Some(fault);
148                }
149            });
150        }));
151    });
152}
153
154fn payload_text(payload: &(dyn std::any::Any + Send)) -> String {
155    if let Some(text) = payload.downcast_ref::<&str>() {
156        return text.to_string();
157    }
158    if let Some(text) = payload.downcast_ref::<String>() {
159        return text.clone();
160    }
161    "a panic that carried no message".to_string()
162}
163
164#[cfg(not(target_arch = "wasm32"))]
165mod boundary {
166    use super::{payload_text, Cause, Escape, Fault};
167    use std::panic::{catch_unwind, AssertUnwindSafe};
168
169    pub(super) fn run(work: &mut impl FnMut()) -> Result<(), Escape> {
170        catch_unwind(AssertUnwindSafe(work))
171            .map_err(|payload| Escape { message: payload_text(&*payload), cause: Cause::Panic })
172    }
173
174    pub(super) fn report(_: &Fault) {}
175}
176
177#[cfg(target_arch = "wasm32")]
178mod boundary {
179    use super::{Cause, Escape, Fault};
180    use wasm_bindgen::closure::ScopedClosure;
181    use wasm_bindgen::prelude::*;
182
183    // A trap in `work` throws out of every wasm frame it crossed; the `catch` ends it here, in
184    // JavaScript, before control returns to the module. The trap left the shadow-stack pointer
185    // where the dead frames had it — below zero when they overran the stack — so it is put back
186    // to where it stood when the boundary was entered.
187    #[wasm_bindgen(inline_js = r#"
188export function contain_call(stackPointer, work) {
189    const entered = stackPointer ? stackPointer.value : 0;
190    try {
191        work();
192        return undefined;
193    } catch (thrown) {
194        let exhausted = thrown instanceof RangeError
195            || (typeof InternalError === 'function' && thrown instanceof InternalError);
196        if (stackPointer) {
197            exhausted = exhausted || stackPointer.value < 0;
198            stackPointer.value = entered;
199        }
200        const message = thrown && typeof thrown.message === 'string' ? thrown.message : String(thrown);
201        return [message, exhausted];
202    }
203}
204
205// Logging a contained fault must never become a fault of its own: some hosts (a test runner, a
206// worker) have no console to write to.
207export function contain_report(text) {
208    const console = globalThis.console;
209    if (console && typeof console.warn === 'function') {
210        console.warn(text);
211    }
212}
213"#)]
214    extern "C" {
215        fn contain_call(stack_pointer: &JsValue, work: &ScopedClosure<dyn FnMut()>) -> JsValue;
216
217        fn contain_report(text: &str);
218    }
219
220    thread_local! {
221        /// The module's exported `__stack_pointer` global (see `build.rs`).
222        static STACK_POINTER: JsValue =
223            js_sys::Reflect::get(&wasm_bindgen::exports(), &JsValue::from_str("__stack_pointer"))
224                .unwrap_or(JsValue::UNDEFINED);
225    }
226
227    pub(super) fn run(work: &mut impl FnMut()) -> Result<(), Escape> {
228        let stack_pointer = STACK_POINTER.with(JsValue::clone);
229        // Lent to JavaScript for this one call; the build aborts on panic, so the closure needs
230        // no unwind guard of its own — the boundary is the `catch` above.
231        let work = ScopedClosure::<dyn FnMut()>::borrow_mut_aborting(work);
232        let thrown = contain_call(&stack_pointer, &work);
233        if thrown.is_undefined() {
234            return Ok(());
235        }
236        let thrown = js_sys::Array::from(&thrown);
237        let exhausted = thrown.get(1).as_bool().unwrap_or(false);
238        Err(Escape {
239            message: thrown.get(0).as_string().unwrap_or_default(),
240            cause: if exhausted { Cause::StackExhausted } else { Cause::Trap },
241        })
242    }
243
244    /// The console keeps what the panic hook no longer passes on.
245    pub(super) fn report(fault: &Fault) {
246        match &fault.location {
247            Some(location) => contain_report(&format!("contained a fault: {} (at {location})", fault.message)),
248            None => contain_report(&format!("contained a fault: {}", fault.message)),
249        }
250    }
251}
252
253#[cfg(test)]
254#[path = "../../../../crates/logicaffeine_tests/unit/logicaffeine_web/ui/fault/tests.rs"]
255mod tests;