exfiltrate 0.3.0

An embeddable debug tool for Rust.
Documentation
// SPDX-License-Identifier: MIT OR Apache-2.0
//! Implements the `panics` command.
use crate::command::{Command, Response};
use exfiltrate_internal::args::{ArgKind, ArgSpec, ParsedArgs};

/// The `panics` command.
///
/// Reads back what the capturing panic hook recorded. See [`crate::panics`] for
/// why this exists separately from panic isolation and why it is the half that
/// matters most on WASM.
pub(crate) struct Panics;

static ARGS: &[ArgSpec] = &[
    ArgSpec::flag(
        "since",
        "only show panics with a cursor at or after this value",
        ArgKind::Integer,
    ),
    ArgSpec::flag("tail", "show only the last N panics", ArgKind::Integer),
    ArgSpec::flag(
        "backtrace",
        "include backtraces where they were captured",
        ArgKind::Bool,
    ),
];

impl Command for Panics {
    fn name(&self) -> &'static str {
        "panics"
    }

    fn short_description(&self) -> &'static str {
        "Shows panics captured in this process.  Use this when a program died or a command failed strangely."
    }

    fn full_description(&self) -> &'static str {
        "Shows panics captured by exfiltrate's panic hook.

Each record carries the message, the source location, the thread name, and how long
after startup it happened. Backtraces are captured only when RUST_BACKTRACE is set,
and are omitted from the output unless you pass --backtrace.

The buffer is bounded (see `Config::panic_capacity`), so the output reports how many
records were dropped rather than pretending to be complete.

`--since` takes the cursor printed at the end of the previous call, so polling this
command returns only what is new.

On wasm32 this is the only panic facility that works: the module is built with
panic=abort, so a panic cannot be caught, only recorded on the way past."
    }

    fn args(&self) -> &'static [ArgSpec] {
        ARGS
    }

    fn execute(&self, args: Vec<String>) -> Result<Response, Response> {
        let parsed = ParsedArgs::parse(self.args(), args).map_err(Response::String)?;
        let since = parsed.integer("since").unwrap_or(0).max(0) as u64;
        let tail = parsed
            .integer("tail")
            .filter(|tail| *tail > 0)
            .map(|tail| tail as usize);
        let slice = crate::panics::since(since, tail);
        Ok(render(&slice, parsed.boolean("backtrace")).into())
    }
}

fn render(
    slice: &exfiltrate_internal::ring::RingSlice<crate::panics::PanicRecord>,
    backtraces: bool,
) -> String {
    let mut out = String::new();
    if slice.records.is_empty() {
        out.push_str("no panics have been captured\n");
    }
    for record in &slice.records {
        out.push_str(&format!(
            "[{:>8.3}s] thread {:?} panicked at {}\n  {}\n",
            record.since_start.as_secs_f64(),
            record.thread,
            record.location.as_deref().unwrap_or("<unknown location>"),
            record.message
        ));
        if backtraces {
            match &record.backtrace {
                Some(backtrace) => {
                    for line in backtrace.lines() {
                        out.push_str("    ");
                        out.push_str(line);
                        out.push('\n');
                    }
                }
                None => out.push_str("    (no backtrace; set RUST_BACKTRACE=1 before starting)\n"),
            }
        }
    }
    if slice.missed > 0 {
        out.push_str(&format!(
            "\n{} panic(s) were dropped before this call could read them.\n",
            slice.missed
        ));
    }
    out.push_str(&format!(
        "\ncursor={} dropped_total={}\n",
        slice.next_cursor, slice.dropped_total
    ));
    out
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::panics::PanicRecord;
    use exfiltrate_internal::ring::RingSlice;
    use std::time::Duration;

    fn slice(records: Vec<PanicRecord>, missed: u64) -> RingSlice<PanicRecord> {
        RingSlice {
            next_cursor: records.len() as u64 + missed,
            records,
            missed,
            dropped_total: missed,
        }
    }

    fn record() -> PanicRecord {
        PanicRecord {
            message: "kaboom".to_string(),
            location: Some("src/lib.rs:10:5".to_string()),
            thread: "worker".to_string(),
            since_start: Duration::from_millis(1500),
            backtrace: Some("frame one\nframe two".to_string()),
        }
    }

    #[test]
    fn a_record_reports_where_when_and_on_which_thread() {
        let text = render(&slice(vec![record()], 0), false);
        assert!(text.contains("kaboom"), "{text}");
        assert!(text.contains("src/lib.rs:10:5"), "{text}");
        assert!(text.contains("\"worker\""), "{text}");
        assert!(text.contains("1.500s"), "{text}");
        assert!(!text.contains("frame one"), "backtraces are opt-in: {text}");
    }

    #[test]
    fn backtraces_appear_only_when_asked_for() {
        let text = render(&slice(vec![record()], 0), true);
        assert!(text.contains("frame one"), "{text}");
        assert!(text.contains("frame two"), "{text}");
    }

    #[test]
    fn a_missing_backtrace_explains_how_to_get_one() {
        let mut without = record();
        without.backtrace = None;
        let text = render(&slice(vec![without], 0), true);
        assert!(text.contains("RUST_BACKTRACE=1"), "{text}");
    }

    #[test]
    fn dropped_records_are_reported_rather_than_hidden() {
        let text = render(&slice(vec![record()], 5), false);
        assert!(text.contains("5 panic(s) were dropped"), "{text}");
    }

    #[test]
    fn an_empty_buffer_says_so_and_still_returns_a_cursor() {
        let text = render(&slice(Vec::new(), 0), false);
        assert!(text.contains("no panics have been captured"), "{text}");
        assert!(text.contains("cursor=0"), "{text}");
    }

    #[test]
    fn the_command_accepts_its_flags_and_refuses_others() {
        assert!(
            Panics
                .execute(vec!["--tail".to_string(), "1".to_string()])
                .is_ok()
        );
        let error = Panics
            .execute(vec!["--nope".to_string()])
            .unwrap_err()
            .to_string();
        assert!(error.contains("--nope"), "{error}");
    }
}