logicaffeine_web/ui/
fault.rs1use std::cell::{Cell, RefCell};
22use std::panic::PanicHookInfo;
23use std::sync::Once;
24
25#[derive(Clone, Copy, Debug, PartialEq, Eq)]
27pub enum Cause {
28 Panic,
30 StackExhausted,
32 Trap,
34}
35
36#[derive(Clone, Debug, PartialEq, Eq)]
38pub struct Fault {
39 pub cause: Cause,
41 pub message: String,
43 pub location: Option<String>,
45}
46
47impl Fault {
48 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#[derive(Clone, Debug, PartialEq, Eq)]
76pub struct ActionFault {
77 pub action: &'static str,
79 pub fault: Fault,
81}
82
83impl ActionFault {
84 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 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
101pub 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
120struct Escape {
122 message: String,
123 cause: Cause,
124}
125
126thread_local! {
127 static DEPTH: Cell<usize> = const { Cell::new(0) };
129 static CAUGHT: RefCell<Option<Fault>> = const { RefCell::new(None) };
131}
132
133fn 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 #[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 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 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 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;