ifc_lite_wasm/utils.rs
1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5//! Panic plumbing for the browser.
6//!
7//! With `panic = "abort"` every Rust panic reaches JavaScript as the same
8//! content-free `RuntimeError: unreachable`, so error tracking kept minting
9//! untriageable issues that said nothing but "unreachable" (issues #1196 and
10//! #2527). The hook installed here keeps `console_error_panic_hook`'s console
11//! report and ADDITIONALLY stashes the panic's source location on the realm's
12//! JS global (`globalThis.__ifclite_wasm_panic = { location, at }`), where the
13//! viewer's analytics `before_send` gate picks it up and attaches it to the
14//! trap's exception event (`apps/viewer/src/lib/analytics-scrub.ts`).
15//!
16//! Privacy contract: ONLY the source location travels — the panic payload
17//! message can embed model-derived text (an entity value, a name), so it stays
18//! console-only. [`sanitize_panic_path`] additionally strips build-machine
19//! prefixes so no checkout path or username can leave the browser.
20
21#[cfg(feature = "console_error_panic_hook")]
22use std::panic;
23
24/// JS global property the most recent panic's location is stashed under.
25/// Kept in lockstep with `WASM_PANIC_STASH_KEY` in
26/// `apps/viewer/src/lib/analytics-scrub.ts`,
27/// `packages/geometry/src/wasm-panic-forward.ts`, and
28/// `packages/parser/src/wasm-panic-forward.ts`.
29#[cfg(feature = "console_error_panic_hook")]
30#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
31const PANIC_STASH_KEY: &str = "__ifclite_wasm_panic";
32
33/// Stash `{ location: "crate/src/file.rs:line:col", at: Date.now() }` on the
34/// JS global of whichever realm (window or worker) this instance runs in.
35/// Best-effort: a JS-side failure must never disturb the panic report itself.
36///
37/// `wasm32`-only: the `js-sys`/`wasm-bindgen` calls it makes have no native
38/// implementation and SIGABRT (double-panic-while-panicking) when invoked
39/// off wasm32 — e.g. `IfcAPI::new()` (which installs this hook) runs under
40/// plain `cargo test` too, where a deliberately-triggered panic (see
41/// `api/mod_tests.rs`'s poison-recovery test) must stay a catchable
42/// `std::thread::Result::Err`, not crash the whole test binary.
43#[cfg(all(feature = "console_error_panic_hook", target_arch = "wasm32"))]
44fn stash_panic_location(info: &panic::PanicHookInfo<'_>) {
45 let Some(location) = info.location() else {
46 return;
47 };
48 stash_location_parts(location.file(), location.line(), location.column());
49}
50
51/// The stash write itself, split from the hook so it is observable: a REAL
52/// panic aborts the test runner under `panic = "abort"`, so the wasm32 test
53/// leg (`tests/panic_stash.rs`) exercises this seam directly. `#[doc(hidden)]`
54/// `pub` for that test only — not part of the JS API (no `#[wasm_bindgen]`).
55#[cfg(all(feature = "console_error_panic_hook", target_arch = "wasm32"))]
56#[doc(hidden)]
57pub fn stash_location_parts(file: &str, line: u32, column: u32) {
58 let text = format!("{}:{}:{}", sanitize_panic_path(file), line, column);
59 let stash = js_sys::Object::new();
60 let ok = js_sys::Reflect::set(
61 &stash,
62 &wasm_bindgen::JsValue::from_str("location"),
63 &wasm_bindgen::JsValue::from_str(&text),
64 )
65 .is_ok()
66 && js_sys::Reflect::set(
67 &stash,
68 &wasm_bindgen::JsValue::from_str("at"),
69 &wasm_bindgen::JsValue::from_f64(js_sys::Date::now()),
70 )
71 .is_ok();
72 if ok {
73 let _ = js_sys::Reflect::set(
74 &js_sys::global(),
75 &wasm_bindgen::JsValue::from_str(PANIC_STASH_KEY),
76 &stash.into(),
77 );
78 }
79}
80
81/// Strip build-machine prefixes from a panic location's file path so the
82/// stashed location is safe to ship: crate identity stays, checkout paths and
83/// usernames go.
84///
85/// - workspace-relative paths (the common case) pass through unchanged;
86/// - cargo-registry dependency paths keep only `<crate>-<version>/…`;
87/// - absolute paths containing the workspace's `rust/` root are cut there;
88/// - any other absolute path keeps a short tail, with a `Users`/`home`
89/// segment (and the username after it) always removed first.
90#[cfg_attr(
91 any(not(feature = "console_error_panic_hook"), not(target_arch = "wasm32")),
92 allow(dead_code)
93)]
94fn sanitize_panic_path(file: &str) -> String {
95 let unified = file.replace('\\', "/");
96 // Dependency from the cargo registry: skip through the registry index
97 // segment, keep the crate-version tail.
98 if let Some(idx) = unified.find("/registry/src/") {
99 let rest = &unified[idx + "/registry/src/".len()..];
100 if let Some(slash) = rest.find('/') {
101 return rest[slash + 1..].to_string();
102 }
103 }
104 // Absolute checkout of this workspace: the deepest `rust/` segment is the
105 // workspace root, so the tail from there is the stable identity.
106 if let Some(idx) = unified.rfind("/rust/") {
107 return unified[idx + 1..].to_string();
108 }
109 let is_absolute = unified.starts_with('/') || unified.as_bytes().get(1) == Some(&b':');
110 if !is_absolute {
111 return unified;
112 }
113 // Unrecognised absolute path (a rustc std mapping, a stray local build):
114 // drop a home directory outright, then keep at most four segments.
115 let segments: Vec<&str> = unified.split('/').filter(|s| !s.is_empty()).collect();
116 let after_home = match segments.iter().position(|s| *s == "Users" || *s == "home") {
117 Some(idx) => &segments[(idx + 2).min(segments.len())..],
118 None => &segments[..],
119 };
120 let keep = after_home.len().saturating_sub(4);
121 after_home[keep..].join("/")
122}
123
124/// Install the panic hook: `console_error_panic_hook`'s console report plus
125/// the analytics location stash above (wasm32 only — see
126/// [`stash_panic_location`]). Idempotent.
127pub fn set_panic_hook() {
128 #[cfg(feature = "console_error_panic_hook")]
129 {
130 use std::sync::Once;
131 static SET_HOOK: Once = Once::new();
132 SET_HOOK.call_once(|| {
133 panic::set_hook(Box::new(|info| {
134 #[cfg(target_arch = "wasm32")]
135 stash_panic_location(info);
136 console_error_panic_hook::hook(info);
137 }));
138 });
139 }
140}
141
142#[cfg(test)]
143#[path = "utils_tests.rs"]
144mod tests;