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;