exfiltrate 0.3.0

An embeddable debug tool for Rust.
Documentation
// SPDX-License-Identifier: MIT OR Apache-2.0
//! Panic capture, and panic isolation for command execution.
//!
//! # Two halves, and they are not equally portable
//!
//! **Isolation.** A command's `execute` is user code written in a hurry — that
//! is the whole point of the crate — so panicking is the expected case, not the
//! exceptional one. [`catch_unwind`](std::panic::catch_unwind) turns it into a
//! failed response and leaves the connection alive. This works on native targets
//! only: `.cargo/config.toml` builds non-host targets with
//! `build-std = ["std", "panic_abort"]`, so on `wasm32-unknown-unknown` a panic
//! aborts the module and there is nothing left to catch it with. That asymmetry
//! is documented rather than papered over, because a caller who believes their
//! commands are sandboxed in a browser would be wrong.
//!
//! **Capture.** The panic hook records the message before the process dies, and
//! `exfiltrate panics` reads it back. This half *is* portable, and it matters
//! most exactly where isolation is unavailable: on WASM or a remote device,
//! stderr may be somewhere nobody can read.
//!
//! # Hook chaining
//!
//! [`install_hook`] takes the previous hook with [`std::panic::take_hook`] and
//! calls through to it, so installing exfiltrate's hook never silences another
//! one. Note that `wasm_lite::set_panic_hook()` does *not* chain — it replaces —
//! so call it **before** [`crate::begin`], or exfiltrate's capture will be the
//! thing that gets replaced.
//!
//! # Unwind safety
//!
//! Catching a panic across `&dyn Command` requires
//! [`AssertUnwindSafe`](std::panic::AssertUnwindSafe), because the command lives
//! in a shared registry and the compiler cannot know its interior state survives.
//! The assertion being made here is narrow and worth stating: a panicking command
//! may well have left *its own* state inconsistent, and this code makes no claim
//! otherwise. The claim is only that the debug channel itself holds no invariant
//! that the unwind could have broken — it does not, the registry is only ever
//! read during execution — so the connection can keep serving other commands.

use exfiltrate_internal::ring::Ring;
use std::sync::LazyLock;
use wasm_lite_std::Mutex;

/// One captured panic.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct PanicRecord {
    /// The panic message, as the payload rendered it.
    pub message: String,
    /// `file:line:column`, when the panic reported a location.
    pub location: Option<String>,
    /// The name of the thread that panicked.
    pub thread: String,
    /// Seconds since the process started, from the monotonic clock.
    pub since_start: std::time::Duration,
    /// The backtrace, when `RUST_BACKTRACE` asked for one.
    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);

/// Installs the capturing panic hook, chaining to whatever was there before.
///
/// Calling this more than once is harmless: only the first call installs, so a
/// second `begin()` does not build a chain of identical hooks.
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);
            // Chain, always. Silencing the hook the application installed —
            // which on wasm is the one that gets the message to the console —
            // would trade one unreadable panic for another.
            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(),
    };
    // A panic inside the hook would abort the process, so a poisoned or
    // contended lock must not be allowed to matter more than the panic already
    // being reported. `with_mut_sync` does not panic, but the push is kept as
    // small as possible regardless.
    PANICS.with_mut_sync(|ring| {
        ring.push(record);
    });
}

/// Renders the panic payload, which is `&str` or `String` in practice.
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()),
        // Unsupported or disabled: say nothing rather than storing the string
        // "disabled backtrace", which reads like a backtrace in a list of them.
        _ => None,
    }
}

/// Every captured panic at or after `since`, with cursor bookkeeping.
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,
        }
    })
}

/// Runs a command, converting a panic into a failed response.
///
/// On `wasm32` with `panic_abort` this is a plain call: there is no unwind to
/// catch, and pretending otherwise would be a lie the type system would not
/// catch either.
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;

    // Native only: on wasm32 with `panic_abort` there is no unwind to catch,
    // so provoking a panic here would abort the test binary. That is the
    // asymmetry this module documents rather than a gap in coverage.
    #[cfg(not(target_arch = "wasm32"))]
    #[test]
    fn a_panicking_body_becomes_a_failed_response_naming_the_command() {
        // The hook prints to stderr; silence it just for this call so the test
        // output stays readable, then put back whatever was there.
        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();
        // The chained hook installed above still runs; this only silences the
        // default printer sitting underneath it.
        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);
    }
}