use exfiltrate_internal::ring::Ring;
use std::sync::LazyLock;
use wasm_lite_std::Mutex;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct PanicRecord {
pub message: String,
pub location: Option<String>,
pub thread: String,
pub since_start: std::time::Duration,
pub backtrace: Option<String>,
}
static PANICS: LazyLock<Mutex<Ring<PanicRecord>>> = LazyLock::new(|| Mutex::new(Ring::new(64)));
static STARTED: LazyLock<wasm_lite_std::time::Instant> =
LazyLock::new(wasm_lite_std::time::Instant::now);
pub(crate) fn install_hook(capacity: usize) {
use std::sync::Once;
static ONCE: Once = Once::new();
ONCE.call_once(|| {
LazyLock::force(&STARTED);
PANICS.with_mut_sync(|ring| ring.set_capacity(capacity));
let previous = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
record(info);
previous(info);
}));
});
}
fn record(info: &std::panic::PanicHookInfo<'_>) {
let record = PanicRecord {
message: payload_message(info),
location: info.location().map(|location| {
format!(
"{}:{}:{}",
location.file(),
location.line(),
location.column()
)
}),
thread: std::thread::current()
.name()
.map(str::to_string)
.unwrap_or_else(|| "<unnamed>".to_string()),
since_start: STARTED.elapsed(),
backtrace: capture_backtrace(),
};
PANICS.with_mut_sync(|ring| {
ring.push(record);
});
}
fn payload_message(info: &std::panic::PanicHookInfo<'_>) -> String {
if let Some(message) = info.payload().downcast_ref::<&str>() {
(*message).to_string()
} else if let Some(message) = info.payload().downcast_ref::<String>() {
message.clone()
} else {
"<non-string panic payload>".to_string()
}
}
fn capture_backtrace() -> Option<String> {
let backtrace = std::backtrace::Backtrace::capture();
match backtrace.status() {
std::backtrace::BacktraceStatus::Captured => Some(backtrace.to_string()),
_ => None,
}
}
pub(crate) fn since(
since: u64,
tail: Option<usize>,
) -> exfiltrate_internal::ring::RingSlice<PanicRecord> {
PANICS.with_sync(|ring| {
let slice = ring.since(since, tail, |_| true);
exfiltrate_internal::ring::RingSlice {
records: slice.records.into_iter().cloned().collect(),
next_cursor: slice.next_cursor,
missed: slice.missed,
dropped_total: slice.dropped_total,
}
})
}
pub(crate) fn isolate<R>(
command_name: &str,
body: impl FnOnce() -> Result<R, exfiltrate_internal::command::Response>,
) -> Result<R, exfiltrate_internal::command::Response> {
#[cfg(target_arch = "wasm32")]
{
let _ = command_name;
body()
}
#[cfg(not(target_arch = "wasm32"))]
{
match std::panic::catch_unwind(std::panic::AssertUnwindSafe(body)) {
Ok(result) => result,
Err(payload) => {
let message = if let Some(message) = payload.downcast_ref::<&str>() {
(*message).to_string()
} else if let Some(message) = payload.downcast_ref::<String>() {
message.clone()
} else {
"<non-string panic payload>".to_string()
};
Err(exfiltrate_internal::command::Response::String(format!(
"command '{command_name}' panicked: {message}\n\
The debug connection survived; the application's own state may not have. \
Run `exfiltrate panics` for the location and backtrace."
)))
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use exfiltrate_internal::command::Response;
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn a_panicking_body_becomes_a_failed_response_naming_the_command() {
let previous = std::panic::take_hook();
std::panic::set_hook(Box::new(|_| {}));
let result: Result<(), Response> = isolate("boom", || panic!("kaboom"));
std::panic::set_hook(previous);
let message = result.unwrap_err().to_string();
assert!(message.contains("command 'boom' panicked"), "{message}");
assert!(message.contains("kaboom"), "{message}");
assert!(message.contains("exfiltrate panics"), "{message}");
}
#[test]
fn a_body_that_does_not_panic_is_passed_through_unchanged() {
let ok: Result<u32, Response> = isolate("fine", || Ok(7));
assert_eq!(ok.unwrap(), 7);
let err: Result<u32, Response> = isolate("fine", || Err(Response::String("no".into())));
assert_eq!(err.unwrap_err(), Response::String("no".into()));
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn captured_panics_are_readable_with_a_cursor() {
install_hook(8);
let before = since(0, None).next_cursor;
let previous = std::panic::take_hook();
std::panic::set_hook(Box::new(|info| {
super::record(info);
}));
let _ = std::panic::catch_unwind(|| panic!("recorded panic"));
std::panic::set_hook(previous);
let slice = since(before, None);
assert!(
slice
.records
.iter()
.any(|record| record.message == "recorded panic"),
"{:?}",
slice.records
);
let record = slice
.records
.iter()
.find(|record| record.message == "recorded panic")
.unwrap();
assert!(record.location.as_deref().unwrap().contains("panics.rs"));
assert!(slice.next_cursor > before);
}
}