feat(crash): port Ghostty's local crash reporting
Ports ZIG/src/crash/* as crates/ghostty-crash. Like Ghostty (which uses sentry-native only to write local files), crashes are written as Sentry envelopes (<uuid>.ghosttycrash) to $XDG_STATE_HOME/ghostty/crash: - envelope parse/serialize (all Zig tests ported) - panic hook plus SIGSEGV/SIGBUS/SIGILL/SIGFPE/SIGABRT handlers on an alternate stack; the signal path is allocation-free and re-raises with the default action so exit status and core dumps are unchanged - thread-local crash context (main/renderer/io/read thread, surface dimensions) set from the corresponding threads - `sentry` cargo feature on the root binary, off by default like upstream's -Dsentry; +crash-report lists exactly what is written Deviation: attachments always carry "type":"attachment" (Zig's in-memory attachments omit it and become unparseable). Test Plan: - cargo test -p ghostty-crash (19) - cargo test --test crash (child panics, SIGSEGV, stack overflow and abort each produce a valid envelope listed by +crash-report)
This commit is contained in:
1 parent
516c780f84
commit
b3145ad7c1
11 files changed
+1933
-36
No files matched your search
@@ -11,52 +11,23 @@ use std::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
use ghostty_config::types_b::Duration;
|
use ghostty_config::types_b::Duration;
|
||||||
|
pub use ghostty_crash::Report;
|
||||||
|
|
||||||
use crate::error::Error;
|
use crate::error::Error;
|
||||||
pub use crate::options::NoOptions as Options;
|
pub use crate::options::NoOptions as Options;
|
||||||
|
|
||||||
/// A crash report file (Zig: `crash.Report`).
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub struct Report {
|
|
||||||
pub name: String,
|
|
||||||
/// Modification time in nanoseconds since the unix epoch.
|
|
||||||
pub mtime: i128,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The default crash directory (Zig: `crash.defaultDir`):
|
/// The default crash directory (Zig: `crash.defaultDir`):
|
||||||
/// `$XDG_STATE_HOME/ghostty/crash`.
|
/// `$XDG_STATE_HOME/ghostty/crash`.
|
||||||
pub fn default_dir() -> Result<PathBuf, Error> {
|
pub fn default_dir() -> Result<PathBuf, Error> {
|
||||||
let dir = ghostty_os::xdg::state(&ghostty_os::xdg::Options {
|
let dir = ghostty_crash::default_dir().map_err(|e| Error::Other(e.to_string()))?;
|
||||||
subdir: Some("ghostty/crash"),
|
Ok(dir.path)
|
||||||
home: None,
|
|
||||||
})
|
|
||||||
.map_err(|e| Error::Other(e.to_string()))?;
|
|
||||||
Ok(PathBuf::from(dir))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// List the crash reports (regular files) in `dir`. A directory that cannot
|
/// List the crash reports (regular files) in `dir`, i.e. the
|
||||||
/// be opened yields no reports.
|
/// `.ghosttycrash` envelopes written by `ghostty-crash`. A directory that
|
||||||
|
/// cannot be opened yields no reports.
|
||||||
pub fn list_reports(dir: &std::path::Path) -> Result<Vec<Report>, Error> {
|
pub fn list_reports(dir: &std::path::Path) -> Result<Vec<Report>, Error> {
|
||||||
let Ok(rd) = std::fs::read_dir(dir) else {
|
Ok(ghostty_crash::dir::list_reports(dir)?)
|
||||||
return Ok(vec![]);
|
|
||||||
};
|
|
||||||
let mut reports = vec![];
|
|
||||||
for entry in rd {
|
|
||||||
let entry = entry?;
|
|
||||||
if !entry.file_type()?.is_file() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let mtime = entry.metadata()?.modified()?;
|
|
||||||
let mtime = match mtime.duration_since(UNIX_EPOCH) {
|
|
||||||
Ok(d) => i128::try_from(d.as_nanos()).unwrap_or(i128::MAX),
|
|
||||||
Err(e) => -i128::try_from(e.duration().as_nanos()).unwrap_or(i128::MAX),
|
|
||||||
};
|
|
||||||
reports.push(Report {
|
|
||||||
name: entry.file_name().to_string_lossy().into_owned(),
|
|
||||||
mtime,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Ok(reports)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The `crash-report` command is used to inspect and send crash reports.
|
/// The `crash-report` command is used to inspect and send crash reports.
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
[package]
|
||||||
|
name = "ghostty-crash"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
description = "Local crash capture (Sentry envelope files), port of ghostty/src/crash"
|
||||||
|
|
||||||
|
[features]
|
||||||
|
# Mirrors upstream's `-Dsentry` build option (default off on Linux): when
|
||||||
|
# disabled `init()` is a no-op. `install()` always works (used by tests).
|
||||||
|
sentry = []
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
ghostty-os.workspace = true
|
||||||
|
log = "0.4.34"
|
||||||
|
serde_json = { version = "1", features = ["preserve_order"] }
|
||||||
|
thiserror = "2.0.21"
|
||||||
|
|
||||||
|
[target.'cfg(unix)'.dependencies]
|
||||||
|
libc = "0.2"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tempfile = "3.27.0"
|
||||||
|
|
||||||
|
# The fatal-signal handler needs sigaction/sigaltstack/raw syscalls. Unsafe is
|
||||||
|
# denied crate-wide and only allowed in `signal.rs` with SAFETY comments.
|
||||||
|
[lints.rust]
|
||||||
|
unsafe_code = "deny"
|
||||||
|
|
||||||
|
[lints.clippy]
|
||||||
|
pedantic = { level = "warn", priority = -1 }
|
||||||
|
todo = "warn"
|
||||||
|
unwrap_used = "warn"
|
||||||
|
# Porting a low-level terminal emulator involves a lot of intentional
|
||||||
|
# numeric casts and long state-machine functions; these pedantic lints are
|
||||||
|
# noise for this codebase.
|
||||||
|
cast_possible_truncation = "allow"
|
||||||
|
cast_possible_wrap = "allow"
|
||||||
|
cast_sign_loss = "allow"
|
||||||
|
cast_precision_loss = "allow"
|
||||||
|
cast_lossless = "allow"
|
||||||
|
module_name_repetitions = "allow"
|
||||||
|
too_many_lines = "allow"
|
||||||
|
missing_errors_doc = "allow"
|
||||||
|
missing_panics_doc = "allow"
|
||||||
|
similar_names = "allow"
|
||||||
|
struct_excessive_bools = "allow"
|
||||||
|
fn_params_excessive_bools = "allow"
|
||||||
|
match_same_arms = "allow"
|
||||||
|
unreadable_literal = "allow"
|
||||||
|
many_single_char_names = "allow"
|
||||||
|
doc_markdown = "allow"
|
||||||
|
must_use_candidate = "allow"
|
||||||
|
return_self_not_must_use = "allow"
|
||||||
|
used_underscore_binding = "allow"
|
||||||
|
large_stack_arrays = "allow"
|
||||||
|
inline_always = "allow"
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
//! Thread-local crash context (port of `ThreadState` in `crash/sentry.zig`).
|
||||||
|
//!
|
||||||
|
//! The native Sentry SDK has one global scope and no thread-local scopes, so
|
||||||
|
//! Ghostty keeps thread-specific data in a thread-local that the crash
|
||||||
|
//! callback reads. We do the same: thread entry functions install a
|
||||||
|
//! [`ThreadGuard`], and the panic hook / signal handler read it.
|
||||||
|
//!
|
||||||
|
//! Zig stores a `*Surface` and reads its size when the crash happens. The
|
||||||
|
//! Rust surface is not shareable across threads, so it instead publishes its
|
||||||
|
//! dimensions into a shared [`SurfaceDims`] (plain atomics, readable from a
|
||||||
|
//! signal handler) that the threads hold an `Arc` of.
|
||||||
|
#![allow(unsafe_code)]
|
||||||
|
|
||||||
|
use std::{
|
||||||
|
cell::Cell,
|
||||||
|
ptr,
|
||||||
|
sync::{
|
||||||
|
Arc,
|
||||||
|
atomic::{AtomicU32, Ordering},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/// The type of thread (Zig: `ThreadState.Type`). Used to tag the crash.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
#[repr(u8)]
|
||||||
|
pub enum ThreadType {
|
||||||
|
Main = 1,
|
||||||
|
Renderer = 2,
|
||||||
|
Io = 3,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ThreadType {
|
||||||
|
/// The Zig `@tagName`.
|
||||||
|
#[must_use]
|
||||||
|
pub fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
ThreadType::Main => "main",
|
||||||
|
ThreadType::Renderer => "renderer",
|
||||||
|
ThreadType::Io => "io",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn from_u8(v: u8) -> Option<ThreadType> {
|
||||||
|
match v {
|
||||||
|
1 => Some(ThreadType::Main),
|
||||||
|
2 => Some(ThreadType::Renderer),
|
||||||
|
3 => Some(ThreadType::Io),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The surface dimensions recorded in a crash (`Dimensions` context).
|
||||||
|
/// Updated by the owner of the surface whenever the size changes.
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
pub struct SurfaceDims {
|
||||||
|
screen_width: AtomicU32,
|
||||||
|
screen_height: AtomicU32,
|
||||||
|
grid_columns: AtomicU32,
|
||||||
|
grid_rows: AtomicU32,
|
||||||
|
cell_width: AtomicU32,
|
||||||
|
cell_height: AtomicU32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A plain snapshot of [`SurfaceDims`].
|
||||||
|
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||||
|
pub struct DimsSnapshot {
|
||||||
|
pub screen_width: u32,
|
||||||
|
pub screen_height: u32,
|
||||||
|
pub grid_columns: u32,
|
||||||
|
pub grid_rows: u32,
|
||||||
|
pub cell_width: u32,
|
||||||
|
pub cell_height: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SurfaceDims {
|
||||||
|
#[must_use]
|
||||||
|
pub fn new() -> Arc<SurfaceDims> {
|
||||||
|
Arc::new(SurfaceDims::default())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set(&self, d: DimsSnapshot) {
|
||||||
|
self.screen_width.store(d.screen_width, Ordering::Relaxed);
|
||||||
|
self.screen_height.store(d.screen_height, Ordering::Relaxed);
|
||||||
|
self.grid_columns.store(d.grid_columns, Ordering::Relaxed);
|
||||||
|
self.grid_rows.store(d.grid_rows, Ordering::Relaxed);
|
||||||
|
self.cell_width.store(d.cell_width, Ordering::Relaxed);
|
||||||
|
self.cell_height.store(d.cell_height, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn get(&self) -> DimsSnapshot {
|
||||||
|
DimsSnapshot {
|
||||||
|
screen_width: self.screen_width.load(Ordering::Relaxed),
|
||||||
|
screen_height: self.screen_height.load(Ordering::Relaxed),
|
||||||
|
grid_columns: self.grid_columns.load(Ordering::Relaxed),
|
||||||
|
grid_rows: self.grid_rows.load(Ordering::Relaxed),
|
||||||
|
cell_width: self.cell_width.load(Ordering::Relaxed),
|
||||||
|
cell_height: self.cell_height.load(Ordering::Relaxed),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
thread_local! {
|
||||||
|
// Const-initialized with a type that has no destructor so reading it
|
||||||
|
// from a signal handler is just a TLS load.
|
||||||
|
static STATE: Cell<(u8, *const SurfaceDims)> = const { Cell::new((0, ptr::null())) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets the thread state for the current thread until dropped, then restores
|
||||||
|
/// whatever was set before (Zig: `thread_state = ...; defer thread_state = null`).
|
||||||
|
#[must_use = "the crash context is cleared when the guard is dropped"]
|
||||||
|
pub struct ThreadGuard {
|
||||||
|
prev: (u8, *const SurfaceDims),
|
||||||
|
// Keeps the pointer in `STATE` valid.
|
||||||
|
_dims: Option<Arc<SurfaceDims>>,
|
||||||
|
// Thread-local state must be restored on the same thread.
|
||||||
|
_not_send: std::marker::PhantomData<*const ()>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ThreadGuard {
|
||||||
|
pub fn new(kind: ThreadType, dims: Option<Arc<SurfaceDims>>) -> ThreadGuard {
|
||||||
|
let p = dims.as_ref().map_or(ptr::null(), Arc::as_ptr);
|
||||||
|
let prev = STATE.with(|s| s.replace((kind as u8, p)));
|
||||||
|
ThreadGuard {
|
||||||
|
prev,
|
||||||
|
_dims: dims,
|
||||||
|
_not_send: std::marker::PhantomData,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for ThreadGuard {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
STATE.with(|s| s.set(self.prev));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The surface dimensions of the current thread's crash context, to hand to
|
||||||
|
/// threads it spawns (e.g. the termio read thread).
|
||||||
|
#[must_use]
|
||||||
|
pub fn current_dims() -> Option<Arc<SurfaceDims>> {
|
||||||
|
let (_, p) = STATE.with(Cell::get);
|
||||||
|
if p.is_null() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
// SAFETY: `p` came from `Arc::as_ptr` of an Arc owned by a live
|
||||||
|
// `ThreadGuard` on this thread (the guard restores `STATE` before it
|
||||||
|
// releases the Arc), so the strong count is >= 1 here.
|
||||||
|
unsafe {
|
||||||
|
Arc::increment_strong_count(p);
|
||||||
|
Some(Arc::from_raw(p))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Async-signal-safe read of the current thread state.
|
||||||
|
pub(crate) fn current() -> Option<(ThreadType, Option<DimsSnapshot>)> {
|
||||||
|
let (k, p) = STATE.try_with(Cell::get).ok()?;
|
||||||
|
let kind = ThreadType::from_u8(k)?;
|
||||||
|
let dims = if p.is_null() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
// SAFETY: see `current_dims`; the guard keeps the Arc alive while
|
||||||
|
// `STATE` points at it. Fields are atomics so a racing writer is
|
||||||
|
// fine (values may be stale, which upstream also tolerates).
|
||||||
|
Some(unsafe { (*p).get() })
|
||||||
|
};
|
||||||
|
Some((kind, dims))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
#![allow(clippy::unwrap_used)]
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn guard_sets_and_restores() {
|
||||||
|
assert!(current().is_none());
|
||||||
|
let dims = SurfaceDims::new();
|
||||||
|
dims.set(DimsSnapshot {
|
||||||
|
grid_columns: 80,
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
{
|
||||||
|
let _g = ThreadGuard::new(ThreadType::Renderer, Some(dims));
|
||||||
|
let (k, d) = current().unwrap();
|
||||||
|
assert_eq!(ThreadType::Renderer, k);
|
||||||
|
assert_eq!(80, d.unwrap().grid_columns);
|
||||||
|
assert!(current_dims().is_some());
|
||||||
|
{
|
||||||
|
let _g2 = ThreadGuard::new(ThreadType::Io, None);
|
||||||
|
assert_eq!(ThreadType::Io, current().unwrap().0);
|
||||||
|
}
|
||||||
|
assert_eq!(ThreadType::Renderer, current().unwrap().0);
|
||||||
|
}
|
||||||
|
assert!(current().is_none());
|
||||||
|
assert!(current_dims().is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
//! Crash report directory handling (port of `crash/dir.zig`).
|
||||||
|
|
||||||
|
use std::{
|
||||||
|
fs,
|
||||||
|
io,
|
||||||
|
path::{Path, PathBuf},
|
||||||
|
time::UNIX_EPOCH,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// The file extension of crash reports written by the handler.
|
||||||
|
pub const REPORT_EXTENSION: &str = "ghosttycrash";
|
||||||
|
|
||||||
|
/// Returns a [`Dir`] for the default directory
|
||||||
|
/// (`$XDG_STATE_HOME/ghostty/crash`).
|
||||||
|
pub fn default_dir() -> io::Result<Dir> {
|
||||||
|
let path = ghostty_os::xdg::state(&ghostty_os::xdg::Options {
|
||||||
|
subdir: Some("ghostty/crash"),
|
||||||
|
home: None,
|
||||||
|
})
|
||||||
|
.map_err(|e| io::Error::other(e.to_string()))?;
|
||||||
|
Ok(Dir {
|
||||||
|
path: PathBuf::from(path),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A directory where crash reports are stored.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct Dir {
|
||||||
|
pub path: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Dir {
|
||||||
|
/// Returns an iterator over the crash reports in this directory. The
|
||||||
|
/// iterator may have no reports (including when the directory can't be
|
||||||
|
/// opened).
|
||||||
|
#[must_use]
|
||||||
|
pub fn iterator(&self) -> ReportIterator {
|
||||||
|
ReportIterator {
|
||||||
|
dir: self.path.clone(),
|
||||||
|
it: fs::read_dir(&self.path).ok(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Iterates the regular files of a crash directory.
|
||||||
|
pub struct ReportIterator {
|
||||||
|
dir: PathBuf,
|
||||||
|
it: Option<fs::ReadDir>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ReportIterator {
|
||||||
|
/// Next report, or `Ok(None)` at the end.
|
||||||
|
pub fn next_report(&mut self) -> io::Result<Option<Report>> {
|
||||||
|
let Some(it) = self.it.as_mut() else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
loop {
|
||||||
|
let Some(entry) = it.next() else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
let entry = entry?;
|
||||||
|
if !entry.file_type()?.is_file() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let name = entry.file_name().to_string_lossy().into_owned();
|
||||||
|
// statFile follows symlinks; entries here are regular files.
|
||||||
|
let mtime = fs::metadata(self.dir.join(entry.file_name()))?.modified()?;
|
||||||
|
let mtime = match mtime.duration_since(UNIX_EPOCH) {
|
||||||
|
Ok(d) => i128::try_from(d.as_nanos()).unwrap_or(i128::MAX),
|
||||||
|
Err(e) => -i128::try_from(e.duration().as_nanos()).unwrap_or(i128::MAX),
|
||||||
|
};
|
||||||
|
return Ok(Some(Report { name, mtime }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Iterator for ReportIterator {
|
||||||
|
type Item = io::Result<Report>;
|
||||||
|
fn next(&mut self) -> Option<Self::Item> {
|
||||||
|
self.next_report().transpose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A crash report file.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct Report {
|
||||||
|
pub name: String,
|
||||||
|
/// Modification time in nanoseconds since the unix epoch.
|
||||||
|
pub mtime: i128,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convenience used by the CLI: all reports of `dir`.
|
||||||
|
pub fn list_reports(dir: &Path) -> io::Result<Vec<Report>> {
|
||||||
|
Dir {
|
||||||
|
path: dir.to_path_buf(),
|
||||||
|
}
|
||||||
|
.iterator()
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
#![allow(clippy::unwrap_used)]
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn missing_dir_is_empty() {
|
||||||
|
let d = Dir {
|
||||||
|
path: "/nonexistent/ghostty/crash".into(),
|
||||||
|
};
|
||||||
|
assert!(d.iterator().next().is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lists_only_files() {
|
||||||
|
let t = tempfile::tempdir().unwrap();
|
||||||
|
fs::write(t.path().join("a.ghosttycrash"), "x").unwrap();
|
||||||
|
fs::create_dir(t.path().join("sub")).unwrap();
|
||||||
|
let r = list_reports(t.path()).unwrap();
|
||||||
|
assert_eq!(1, r.len());
|
||||||
|
assert_eq!("a.ghosttycrash", r[0].name);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn default_dir_uses_xdg_state() {
|
||||||
|
let d = default_dir().unwrap();
|
||||||
|
assert!(d.path.ends_with("ghostty/crash"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,526 @@
|
|||||||
|
//! The Sentry Envelope format (port of `crash/sentry_envelope.zig`):
|
||||||
|
//! <https://develop.sentry.dev/sdk/envelopes/>
|
||||||
|
//!
|
||||||
|
//! The envelope is our primary crash report format. It is an open,
|
||||||
|
//! publicly documented format so reports can be consumed by any system.
|
||||||
|
//!
|
||||||
|
//! This implements reading and writing the envelope format. It is
|
||||||
|
//! incomplete; only what Ghostty needs is implemented.
|
||||||
|
|
||||||
|
use std::io::{self, BufRead, Read, Write};
|
||||||
|
|
||||||
|
use serde_json::{Map, Value};
|
||||||
|
|
||||||
|
/// A JSON object preserving insertion order (Zig: `std.json.ObjectMap`).
|
||||||
|
pub type ObjectMap = Map<String, Value>;
|
||||||
|
|
||||||
|
/// Limit of header lines (1MB, arbitrary choice upstream).
|
||||||
|
const LINE_LIMIT: u64 = 1024 * 1024;
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum Error {
|
||||||
|
#[error("io: {0}")]
|
||||||
|
Io(#[from] io::Error),
|
||||||
|
#[error("invalid json: {0}")]
|
||||||
|
Json(#[from] serde_json::Error),
|
||||||
|
#[error("EnvelopeMalformedHeaders")]
|
||||||
|
MalformedHeaders,
|
||||||
|
#[error("EnvelopeItemMalformedHeaders")]
|
||||||
|
ItemMalformedHeaders,
|
||||||
|
#[error("EnvelopeItemTypeMissing")]
|
||||||
|
ItemTypeMissing,
|
||||||
|
#[error("EnvelopeItemLengthMalformed")]
|
||||||
|
ItemLengthMalformed,
|
||||||
|
#[error("EnvelopeItemPayloadTooShort")]
|
||||||
|
ItemPayloadTooShort,
|
||||||
|
#[error("EnvelopeItemPayloadNoNewline")]
|
||||||
|
ItemPayloadNoNewline,
|
||||||
|
/// The header line exceeded the limit (Zig: `StreamTooLong`).
|
||||||
|
#[error("StreamTooLong")]
|
||||||
|
StreamTooLong,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
|
||||||
|
pub enum DecodeError {
|
||||||
|
#[error("MissingRequiredField")]
|
||||||
|
MissingRequiredField,
|
||||||
|
#[error("InvalidFieldType")]
|
||||||
|
InvalidFieldType,
|
||||||
|
#[error("UnsupportedType")]
|
||||||
|
UnsupportedType,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The various item types that can be in an envelope. Unknown types take
|
||||||
|
/// the `Unknown` value.
|
||||||
|
///
|
||||||
|
/// <https://develop.sentry.dev/sdk/envelopes/#data-model>
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum ItemType {
|
||||||
|
Unknown,
|
||||||
|
Event,
|
||||||
|
Transaction,
|
||||||
|
Attachment,
|
||||||
|
Session,
|
||||||
|
Sessions,
|
||||||
|
Statsd,
|
||||||
|
MetricMeta,
|
||||||
|
UserFeedback,
|
||||||
|
ClientReport,
|
||||||
|
ReplayEvent,
|
||||||
|
ReplayRecording,
|
||||||
|
Profile,
|
||||||
|
CheckIn,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ItemType {
|
||||||
|
/// Parse the wire name (Zig `std.meta.stringToEnum`, so the snake_case
|
||||||
|
/// field names, including "unknown").
|
||||||
|
#[must_use]
|
||||||
|
pub fn from_name(s: &str) -> Option<ItemType> {
|
||||||
|
Some(match s {
|
||||||
|
"unknown" => ItemType::Unknown,
|
||||||
|
"event" => ItemType::Event,
|
||||||
|
"transaction" => ItemType::Transaction,
|
||||||
|
"attachment" => ItemType::Attachment,
|
||||||
|
"session" => ItemType::Session,
|
||||||
|
"sessions" => ItemType::Sessions,
|
||||||
|
"statsd" => ItemType::Statsd,
|
||||||
|
"metric_meta" => ItemType::MetricMeta,
|
||||||
|
"user_feedback" => ItemType::UserFeedback,
|
||||||
|
"client_report" => ItemType::ClientReport,
|
||||||
|
"replay_event" => ItemType::ReplayEvent,
|
||||||
|
"replay_recording" => ItemType::ReplayRecording,
|
||||||
|
"profile" => ItemType::Profile,
|
||||||
|
"check_in" => ItemType::CheckIn,
|
||||||
|
_ => return None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An encoded item: payload is bytes, headers are decoded to a JSON object.
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub struct EncodedItem {
|
||||||
|
pub headers: ObjectMap,
|
||||||
|
pub item_type: ItemType,
|
||||||
|
pub payload: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An arbitrary file attachment.
|
||||||
|
///
|
||||||
|
/// <https://develop.sentry.dev/sdk/envelopes/#attachment>
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub struct Attachment {
|
||||||
|
/// The name of the uploaded file without a path component.
|
||||||
|
pub filename: String,
|
||||||
|
/// A special "type" associated with the attachment (`attachment_type`).
|
||||||
|
pub attachment_type: Option<String>,
|
||||||
|
/// Additional headers for the attachment.
|
||||||
|
pub headers_extra: ObjectMap,
|
||||||
|
/// The data for the attachment.
|
||||||
|
pub payload: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Attachment {
|
||||||
|
/// A fresh attachment with no extra headers.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(filename: impl Into<String>, payload: Vec<u8>) -> Attachment {
|
||||||
|
Attachment {
|
||||||
|
filename: filename.into(),
|
||||||
|
attachment_type: None,
|
||||||
|
headers_extra: ObjectMap::new(),
|
||||||
|
payload,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn decode(item: &EncodedItem) -> Result<Attachment, DecodeError> {
|
||||||
|
let filename = match item.headers.get("filename") {
|
||||||
|
Some(Value::String(s)) => s.clone(),
|
||||||
|
Some(_) => return Err(DecodeError::InvalidFieldType),
|
||||||
|
None => return Err(DecodeError::MissingRequiredField),
|
||||||
|
};
|
||||||
|
let attachment_type = match item.headers.get("attachment_type") {
|
||||||
|
Some(Value::String(s)) => Some(s.clone()),
|
||||||
|
Some(_) => return Err(DecodeError::InvalidFieldType),
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
Ok(Attachment {
|
||||||
|
filename,
|
||||||
|
attachment_type,
|
||||||
|
headers_extra: item.headers.clone(),
|
||||||
|
payload: item.payload.clone(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn encode(&mut self) -> EncodedItem {
|
||||||
|
// Deviation: upstream never writes the required "type" header for
|
||||||
|
// attachments built in memory (only parsed ones carry it), which
|
||||||
|
// yields an unparseable item. We add it when absent.
|
||||||
|
if !self.headers_extra.contains_key("type") {
|
||||||
|
self.headers_extra
|
||||||
|
.insert("type".into(), Value::String("attachment".into()));
|
||||||
|
}
|
||||||
|
self.headers_extra
|
||||||
|
.insert("filename".into(), Value::String(self.filename.clone()));
|
||||||
|
if let Some(v) = &self.attachment_type {
|
||||||
|
self.headers_extra
|
||||||
|
.insert("attachment_type".into(), Value::String(v.clone()));
|
||||||
|
} else {
|
||||||
|
self.headers_extra.swap_remove("attachment_type");
|
||||||
|
}
|
||||||
|
EncodedItem {
|
||||||
|
headers: self.headers_extra.clone(),
|
||||||
|
item_type: ItemType::Attachment,
|
||||||
|
payload: self.payload.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An item in the envelope, either encoded or decoded.
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub enum Item {
|
||||||
|
Encoded(EncodedItem),
|
||||||
|
Attachment(Attachment),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Item {
|
||||||
|
/// Convert the item to an encoded item, modifying it in place.
|
||||||
|
pub fn encode(&mut self) -> &EncodedItem {
|
||||||
|
if let Item::Attachment(a) = self {
|
||||||
|
let enc = a.encode();
|
||||||
|
*self = Item::Encoded(enc);
|
||||||
|
}
|
||||||
|
match self {
|
||||||
|
Item::Encoded(e) => e,
|
||||||
|
Item::Attachment(_) => unreachable!("just encoded"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The type of item, whether it is encoded or not.
|
||||||
|
#[must_use]
|
||||||
|
pub fn item_type(&self) -> ItemType {
|
||||||
|
match self {
|
||||||
|
Item::Encoded(e) => e.item_type,
|
||||||
|
Item::Attachment(_) => ItemType::Attachment,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decode the item if it is encoded (modifies itself). On failure the
|
||||||
|
/// encoded item remains unmodified.
|
||||||
|
pub fn decode(&mut self) -> Result<(), DecodeError> {
|
||||||
|
let Item::Encoded(enc) = self else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
match enc.item_type {
|
||||||
|
ItemType::Attachment => {
|
||||||
|
let a = Attachment::decode(enc)?;
|
||||||
|
*self = Item::Attachment(a);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
_ => Err(DecodeError::UnsupportedType),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A parsed or constructed envelope.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Default)]
|
||||||
|
pub struct Envelope {
|
||||||
|
/// The headers of the envelope decoded into a JSON object.
|
||||||
|
pub headers: ObjectMap,
|
||||||
|
/// The items in the envelope in the order they're encoded.
|
||||||
|
pub items: Vec<Item>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read up to the next `\n` (exclusive), limited to `LINE_LIMIT` bytes
|
||||||
|
/// (Zig: `streamDelimiterLimit` + `discardDelimiterInclusive`). Returns
|
||||||
|
/// `StreamTooLong` if the limit is hit before a delimiter or end of stream.
|
||||||
|
fn read_line<R: BufRead>(r: &mut R) -> Result<Vec<u8>, Error> {
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
// Read one byte past the limit... Zig errors only if the limit is
|
||||||
|
// reached and the next byte isn't the delimiter/EOF.
|
||||||
|
r.by_ref().take(LINE_LIMIT).read_until(b'\n', &mut buf)?;
|
||||||
|
if buf.last() == Some(&b'\n') {
|
||||||
|
buf.pop();
|
||||||
|
return Ok(buf);
|
||||||
|
}
|
||||||
|
if buf.len() as u64 == LINE_LIMIT {
|
||||||
|
// Exactly at the limit: fine only if the stream ends or the next
|
||||||
|
// byte is the delimiter.
|
||||||
|
let next = r.fill_buf()?;
|
||||||
|
match next.first() {
|
||||||
|
None => {}
|
||||||
|
Some(b'\n') => r.consume(1),
|
||||||
|
Some(_) => return Err(Error::StreamTooLong),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(buf)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn trim_ws(b: &[u8]) -> &[u8] {
|
||||||
|
let is_ws = |c: &u8| *c == b' ' || *c == b'\t';
|
||||||
|
let start = b.iter().position(|c| !is_ws(c)).unwrap_or(b.len());
|
||||||
|
let end = b.iter().rposition(|c| !is_ws(c)).map_or(start, |i| i + 1);
|
||||||
|
&b[start..end]
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Envelope {
|
||||||
|
/// Parse an envelope from a reader. The full envelope is read into memory.
|
||||||
|
pub fn parse<R: BufRead>(reader: &mut R) -> Result<Envelope, Error> {
|
||||||
|
let headers = Self::parse_header(reader)?;
|
||||||
|
let items = Self::parse_items(reader)?;
|
||||||
|
Ok(Envelope { headers, items })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convenience for parsing from a byte slice.
|
||||||
|
pub fn parse_slice(mut data: &[u8]) -> Result<Envelope, Error> {
|
||||||
|
Self::parse(&mut data)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_header<R: BufRead>(reader: &mut R) -> Result<ObjectMap, Error> {
|
||||||
|
let line = read_line(reader)?;
|
||||||
|
match serde_json::from_slice::<Value>(&line)? {
|
||||||
|
Value::Object(m) => Ok(m),
|
||||||
|
_ => Err(Error::MalformedHeaders),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_items<R: BufRead>(reader: &mut R) -> Result<Vec<Item>, Error> {
|
||||||
|
let mut items = Vec::new();
|
||||||
|
while let Some(item) = Self::parse_one_item(reader)? {
|
||||||
|
items.push(item);
|
||||||
|
}
|
||||||
|
Ok(items)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_one_item<R: BufRead>(reader: &mut R) -> Result<Option<Item>, Error> {
|
||||||
|
// The next item must start with a header.
|
||||||
|
let line = match read_line(reader) {
|
||||||
|
Ok(l) => l,
|
||||||
|
Err(Error::StreamTooLong) => return Ok(None),
|
||||||
|
Err(e) => return Err(e),
|
||||||
|
};
|
||||||
|
let line = trim_ws(&line);
|
||||||
|
if line.is_empty() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let Value::Object(headers) = serde_json::from_slice::<Value>(line)? else {
|
||||||
|
return Err(Error::ItemMalformedHeaders);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get the item type
|
||||||
|
let typ = match headers.get("type") {
|
||||||
|
Some(Value::String(s)) => ItemType::from_name(s).unwrap_or(ItemType::Unknown),
|
||||||
|
_ => return Err(Error::ItemTypeMissing),
|
||||||
|
};
|
||||||
|
|
||||||
|
// The length is not required. If absent the payload is the next line.
|
||||||
|
let len: Option<u64> = match headers.get("length") {
|
||||||
|
Some(Value::Number(n)) => match n.as_i64() {
|
||||||
|
Some(i) => Some(u64::try_from(i).map_err(|_| Error::ItemLengthMalformed)?),
|
||||||
|
None => return Err(Error::ItemLengthMalformed),
|
||||||
|
},
|
||||||
|
Some(_) => return Err(Error::ItemLengthMalformed),
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let payload = if let Some(len) = len {
|
||||||
|
let mut payload = Vec::new();
|
||||||
|
let n = reader.by_ref().take(len).read_to_end(&mut payload)?;
|
||||||
|
if (n as u64) < len {
|
||||||
|
return Err(Error::ItemPayloadTooShort);
|
||||||
|
}
|
||||||
|
// The next byte must be a newline (or end of stream).
|
||||||
|
let next = reader.fill_buf()?;
|
||||||
|
match next.first() {
|
||||||
|
None => {}
|
||||||
|
Some(b'\n') => reader.consume(1),
|
||||||
|
Some(_) => return Err(Error::ItemPayloadNoNewline),
|
||||||
|
}
|
||||||
|
payload
|
||||||
|
} else {
|
||||||
|
match read_line(reader) {
|
||||||
|
Ok(p) => p,
|
||||||
|
Err(Error::StreamTooLong) => return Err(Error::ItemPayloadTooShort),
|
||||||
|
Err(e) => return Err(e),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Some(Item::Encoded(EncodedItem {
|
||||||
|
headers,
|
||||||
|
item_type: typ,
|
||||||
|
payload,
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Serialize the envelope to the given writer. This converts all decoded
|
||||||
|
/// items to encoded items.
|
||||||
|
pub fn serialize<W: Write>(&mut self, w: &mut W) -> Result<(), Error> {
|
||||||
|
// Header line first. Minified: all JSON headers must be one line.
|
||||||
|
serde_json::to_writer(&mut *w, &self.headers)?;
|
||||||
|
w.write_all(b"\n")?;
|
||||||
|
|
||||||
|
for (idx, item) in self.items.iter_mut().enumerate() {
|
||||||
|
if idx > 0 {
|
||||||
|
w.write_all(b"\n")?;
|
||||||
|
}
|
||||||
|
let enc = item.encode();
|
||||||
|
serde_json::to_writer(&mut *w, &enc.headers)?;
|
||||||
|
w.write_all(b"\n")?;
|
||||||
|
w.write_all(&enc.payload)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Serialize into a byte vector.
|
||||||
|
pub fn to_bytes(&mut self) -> Result<Vec<u8>, Error> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
self.serialize(&mut out)?;
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
#![allow(clippy::unwrap_used)]
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
const SESSION: &str = r#"{"init":true,"sid":"c148cc2f-5f9f-4231-575c-2e85504d6434","status":"abnormal","errors":0,"started":"2024-08-29T02:38:57.607016Z","duration":0.000343,"attrs":{"release":"0.1.0-HEAD+d37b7d09","environment":"production"}}"#;
|
||||||
|
|
||||||
|
fn session_env() -> String {
|
||||||
|
format!("{{}}\n{{\"type\":\"session\",\"length\":218}}\n{SESSION}")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn encoded(item: &Item) -> &EncodedItem {
|
||||||
|
match item {
|
||||||
|
Item::Encoded(e) => e,
|
||||||
|
Item::Attachment(_) => panic!("expected encoded"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn envelope_parse() {
|
||||||
|
Envelope::parse_slice(b"{}").unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn envelope_parse_session() {
|
||||||
|
let v = Envelope::parse_slice(session_env().as_bytes()).unwrap();
|
||||||
|
assert_eq!(1, v.items.len());
|
||||||
|
assert_eq!(ItemType::Session, encoded(&v.items[0]).item_type);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn envelope_parse_multiple() {
|
||||||
|
let s = format!(
|
||||||
|
"{}\n{}\nABCD",
|
||||||
|
session_env(),
|
||||||
|
r#"{"type":"attachment","length":4,"filename":"test.txt"}"#
|
||||||
|
);
|
||||||
|
let v = Envelope::parse_slice(s.as_bytes()).unwrap();
|
||||||
|
assert_eq!(2, v.items.len());
|
||||||
|
assert_eq!(ItemType::Session, encoded(&v.items[0]).item_type);
|
||||||
|
assert_eq!(ItemType::Attachment, encoded(&v.items[1]).item_type);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn envelope_parse_multiple_no_length() {
|
||||||
|
let s = "{}\n{\"type\":\"session\"}\n{}\n{\"type\":\"attachment\",\"length\":4,\"filename\":\"test.txt\"}\nABCD";
|
||||||
|
let v = Envelope::parse_slice(s.as_bytes()).unwrap();
|
||||||
|
assert_eq!(2, v.items.len());
|
||||||
|
assert_eq!(ItemType::Session, encoded(&v.items[0]).item_type);
|
||||||
|
assert_eq!(ItemType::Attachment, encoded(&v.items[1]).item_type);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn envelope_parse_end_in_new_line() {
|
||||||
|
let s = format!("{}\n", session_env());
|
||||||
|
let v = Envelope::parse_slice(s.as_bytes()).unwrap();
|
||||||
|
assert_eq!(1, v.items.len());
|
||||||
|
assert_eq!(ItemType::Session, encoded(&v.items[0]).item_type);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn envelope_parse_attachment() {
|
||||||
|
let s = "{}\n{\"type\":\"attachment\",\"length\":4,\"filename\":\"test.txt\"}\nABCD";
|
||||||
|
let mut v = Envelope::parse_slice(s.as_bytes()).unwrap();
|
||||||
|
assert_eq!(1, v.items.len());
|
||||||
|
assert_eq!(ItemType::Attachment, encoded(&v.items[0]).item_type);
|
||||||
|
v.items[0].decode().unwrap();
|
||||||
|
let Item::Attachment(a) = &v.items[0] else {
|
||||||
|
panic!("not decoded");
|
||||||
|
};
|
||||||
|
assert_eq!("test.txt", a.filename);
|
||||||
|
|
||||||
|
// Serialization test
|
||||||
|
let out = v.to_bytes().unwrap();
|
||||||
|
assert_eq!(s, String::from_utf8(out).unwrap().trim_matches('\n'));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn envelope_serialize_empty() {
|
||||||
|
let mut v = Envelope::parse_slice(b"{}").unwrap();
|
||||||
|
let out = v.to_bytes().unwrap();
|
||||||
|
assert_eq!("{}", String::from_utf8(out).unwrap().trim_matches('\n'));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn envelope_serialize_session() {
|
||||||
|
let s = session_env();
|
||||||
|
let mut v = Envelope::parse_slice(s.as_bytes()).unwrap();
|
||||||
|
let out = v.to_bytes().unwrap();
|
||||||
|
assert_eq!(s, String::from_utf8(out).unwrap().trim_matches('\n'));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_errors() {
|
||||||
|
assert!(matches!(
|
||||||
|
Envelope::parse_slice(b"[]"),
|
||||||
|
Err(Error::MalformedHeaders)
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
Envelope::parse_slice(b"{}\n{\"length\":1}\nx"),
|
||||||
|
Err(Error::ItemTypeMissing)
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
Envelope::parse_slice(b"{}\n{\"type\":\"event\",\"length\":-1}\nx"),
|
||||||
|
Err(Error::ItemLengthMalformed)
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
Envelope::parse_slice(b"{}\n{\"type\":\"event\",\"length\":9}\nabc"),
|
||||||
|
Err(Error::ItemPayloadTooShort)
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
Envelope::parse_slice(b"{}\n{\"type\":\"event\",\"length\":1}\nabc"),
|
||||||
|
Err(Error::ItemPayloadNoNewline)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unknown_type_and_decode_errors() {
|
||||||
|
let mut v = Envelope::parse_slice(b"{}\n{\"type\":\"nope\"}\nx").unwrap();
|
||||||
|
assert_eq!(ItemType::Unknown, v.items[0].item_type());
|
||||||
|
assert_eq!(Err(DecodeError::UnsupportedType), v.items[0].decode());
|
||||||
|
let mut v = Envelope::parse_slice(b"{}\n{\"type\":\"attachment\"}\nx").unwrap();
|
||||||
|
assert_eq!(Err(DecodeError::MissingRequiredField), v.items[0].decode());
|
||||||
|
// The failed decode leaves the encoded item untouched.
|
||||||
|
assert!(matches!(v.items[0], Item::Encoded(_)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fresh_attachment_round_trip() {
|
||||||
|
let mut a = Attachment::new("log.txt", b"hello".to_vec());
|
||||||
|
a.attachment_type = Some("event.attachment".into());
|
||||||
|
let mut env = Envelope {
|
||||||
|
items: vec![Item::Attachment(a)],
|
||||||
|
..Envelope::default()
|
||||||
|
};
|
||||||
|
let bytes = env.to_bytes().unwrap();
|
||||||
|
let mut back = Envelope::parse_slice(&bytes).unwrap();
|
||||||
|
back.items[0].decode().unwrap();
|
||||||
|
let Item::Attachment(a) = &back.items[0] else {
|
||||||
|
panic!()
|
||||||
|
};
|
||||||
|
assert_eq!("log.txt", a.filename);
|
||||||
|
assert_eq!(Some("event.attachment"), a.attachment_type.as_deref());
|
||||||
|
assert_eq!(b"hello", a.payload.as_slice());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
//! Local crash handling (port of `ghostty/src/crash`).
|
||||||
|
//!
|
||||||
|
//! This covers setting up the process to catch crashes, writing crash
|
||||||
|
//! reports (Sentry envelopes) to disk, and introspecting them. Nothing is
|
||||||
|
//! ever sent over the network.
|
||||||
|
|
||||||
|
// Fatal-signal handling does not exist on Windows (Zig skips crash capture
|
||||||
|
// there), so parts of the shared report writer are unused.
|
||||||
|
#![cfg_attr(windows, allow(dead_code))]
|
||||||
|
|
||||||
|
pub mod context;
|
||||||
|
pub mod dir;
|
||||||
|
pub mod envelope;
|
||||||
|
pub mod sentry;
|
||||||
|
#[cfg(unix)]
|
||||||
|
mod signal;
|
||||||
|
#[cfg(windows)]
|
||||||
|
#[path = "signal_windows.rs"]
|
||||||
|
mod signal;
|
||||||
|
|
||||||
|
pub use context::{DimsSnapshot, SurfaceDims, ThreadGuard, ThreadType, current_dims};
|
||||||
|
pub use dir::{Dir, Report, ReportIterator, default_dir};
|
||||||
|
pub use envelope::Envelope;
|
||||||
|
pub use sentry::{Options, deinit, init, install};
|
||||||
@@ -0,0 +1,488 @@
|
|||||||
|
//! Crash capture (port of `crash/sentry.zig`).
|
||||||
|
//!
|
||||||
|
//! PRIVACY NOTE: Ghostty by default does NOT send any data over the network.
|
||||||
|
//! Crash reports are only stored locally, as Sentry envelopes in
|
||||||
|
//! `$XDG_STATE_HOME/ghostty/crash/<uuid>.ghosttycrash`. It is up to the user
|
||||||
|
//! to grab them and send them to someone if they want to.
|
||||||
|
//!
|
||||||
|
//! Upstream uses the Sentry native SDK with a custom transport that writes
|
||||||
|
//! the envelope to disk. We have no such SDK, so the equivalent event and
|
||||||
|
//! envelope are produced by hand from a panic hook and a fatal-signal
|
||||||
|
//! handler (see `signal.rs`). The envelope layout is the same: a header line
|
||||||
|
//! followed by an `event` item (with `release`, the build tags, the
|
||||||
|
//! `thread-type` tag and the `Dimensions` context) and, for panics, a
|
||||||
|
//! backtrace attachment.
|
||||||
|
|
||||||
|
use std::{
|
||||||
|
fmt::{self, Write as _},
|
||||||
|
io,
|
||||||
|
path::PathBuf,
|
||||||
|
sync::{
|
||||||
|
OnceLock,
|
||||||
|
atomic::{AtomicBool, Ordering},
|
||||||
|
},
|
||||||
|
time::{SystemTime, UNIX_EPOCH},
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
context,
|
||||||
|
dir::{self, REPORT_EXTENSION},
|
||||||
|
envelope::{Attachment, Envelope, Item, ObjectMap},
|
||||||
|
signal,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Whether this build captures crashes (Zig: `build_options.sentry`).
|
||||||
|
pub const ENABLED: bool = cfg!(feature = "sentry");
|
||||||
|
|
||||||
|
/// Build metadata recorded as tags on every event (Zig: `sentry.setTag`).
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Options {
|
||||||
|
/// `build_config.version_string`.
|
||||||
|
pub release: String,
|
||||||
|
pub build_mode: String,
|
||||||
|
pub app_runtime: String,
|
||||||
|
pub font_backend: String,
|
||||||
|
pub renderer: String,
|
||||||
|
/// Override the crash directory; `None` uses [`dir::default_dir`].
|
||||||
|
pub dir: Option<PathBuf>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Options {
|
||||||
|
fn default() -> Options {
|
||||||
|
Options {
|
||||||
|
release: "1.3.1".into(),
|
||||||
|
build_mode: if cfg!(debug_assertions) {
|
||||||
|
"Debug"
|
||||||
|
} else {
|
||||||
|
"ReleaseFast"
|
||||||
|
}
|
||||||
|
.into(),
|
||||||
|
app_runtime: "gtk".into(),
|
||||||
|
font_backend: "fontconfig_freetype".into(),
|
||||||
|
renderer: "opengl".into(),
|
||||||
|
dir: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum InitError {
|
||||||
|
#[error("crash dir: {0}")]
|
||||||
|
Dir(#[from] io::Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Immutable process-wide state, prepared at init so that the signal handler
|
||||||
|
/// does not need to allocate or consult the environment.
|
||||||
|
pub(crate) struct State {
|
||||||
|
/// The crash directory as bytes (no trailing slash).
|
||||||
|
pub dir: Vec<u8>,
|
||||||
|
/// Pre-rendered `"release":"..","environment":"production","tags":{...`
|
||||||
|
/// (the tags object is left open so `thread-type` can be appended).
|
||||||
|
pub prefix: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
static STATE: OnceLock<State> = OnceLock::new();
|
||||||
|
static ACTIVE: AtomicBool = AtomicBool::new(false);
|
||||||
|
|
||||||
|
pub(crate) fn state() -> Option<&'static State> {
|
||||||
|
if ACTIVE.load(Ordering::Acquire) {
|
||||||
|
STATE.get()
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Process-wide initialization of crash capture; a no-op unless the `sentry`
|
||||||
|
/// feature is enabled (like upstream's `-Dsentry=false`).
|
||||||
|
///
|
||||||
|
/// Call from one thread; [`deinit`] should be called from the same thread.
|
||||||
|
pub fn init(opts: &Options) -> Result<(), InitError> {
|
||||||
|
// Not supported on Windows currently, doesn't build (same as Zig).
|
||||||
|
if !ENABLED || cfg!(windows) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
install(opts)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Install the panic hook and fatal-signal handlers regardless of the
|
||||||
|
/// `sentry` feature. [`init`] is the feature-gated entry point.
|
||||||
|
pub fn install(opts: &Options) -> Result<(), InitError> {
|
||||||
|
if STATE.get().is_some() {
|
||||||
|
// Must only start once; re-activate if previously deinitialized.
|
||||||
|
ACTIVE.store(true, Ordering::Release);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let dir = match &opts.dir {
|
||||||
|
Some(d) => d.clone(),
|
||||||
|
None => dir::default_dir()?.path,
|
||||||
|
};
|
||||||
|
let mut prefix = String::new();
|
||||||
|
let _ = write!(prefix, "\"release\":");
|
||||||
|
let _ = json_str(&mut prefix, &opts.release);
|
||||||
|
let _ = write!(prefix, ",\"environment\":\"production\",\"tags\":{{");
|
||||||
|
for (i, (k, v)) in [
|
||||||
|
("build-mode", &opts.build_mode),
|
||||||
|
("app-runtime", &opts.app_runtime),
|
||||||
|
("font-backend", &opts.font_backend),
|
||||||
|
("renderer", &opts.renderer),
|
||||||
|
]
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
{
|
||||||
|
if i > 0 {
|
||||||
|
prefix.push(',');
|
||||||
|
}
|
||||||
|
let _ = json_str(&mut prefix, k);
|
||||||
|
prefix.push(':');
|
||||||
|
let _ = json_str(&mut prefix, v);
|
||||||
|
}
|
||||||
|
|
||||||
|
let state = State {
|
||||||
|
dir: dir.as_os_str().as_encoded_bytes().to_vec(),
|
||||||
|
prefix,
|
||||||
|
};
|
||||||
|
if STATE.set(state).is_err() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
ACTIVE.store(true, Ordering::Release);
|
||||||
|
|
||||||
|
install_panic_hook();
|
||||||
|
signal::install_handlers();
|
||||||
|
log::debug!("crash capture initialized dir={}", dir.display());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Process-wide deinitialization. Handlers stay registered but become
|
||||||
|
/// inert (a process that is shutting down must not write reports).
|
||||||
|
pub fn deinit() {
|
||||||
|
ACTIVE.store(false, Ordering::Release);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Event building. Must stay allocation-free: the signal handler uses it.
|
||||||
|
|
||||||
|
/// Escape `s` as a JSON string (including quotes).
|
||||||
|
pub(crate) fn json_str<W: fmt::Write>(w: &mut W, s: &str) -> fmt::Result {
|
||||||
|
w.write_char('"')?;
|
||||||
|
for c in s.chars() {
|
||||||
|
match c {
|
||||||
|
'"' => w.write_str("\\\"")?,
|
||||||
|
'\\' => w.write_str("\\\\")?,
|
||||||
|
'\n' => w.write_str("\\n")?,
|
||||||
|
'\r' => w.write_str("\\r")?,
|
||||||
|
'\t' => w.write_str("\\t")?,
|
||||||
|
c if (c as u32) < 0x20 => write!(w, "\\u{:04x}", c as u32)?,
|
||||||
|
c => w.write_char(c)?,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
w.write_char('"')
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Days-since-epoch to (year, month, day) (Howard Hinnant's algorithm).
|
||||||
|
fn civil(days: i64) -> (i64, u32, u32) {
|
||||||
|
let z = days + 719_468;
|
||||||
|
let era = z.div_euclid(146_097);
|
||||||
|
let doe = z.rem_euclid(146_097);
|
||||||
|
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
|
||||||
|
let y = yoe + era * 400;
|
||||||
|
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||||
|
let mp = (5 * doy + 2) / 153;
|
||||||
|
let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
|
||||||
|
let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
|
||||||
|
(if m <= 2 { y + 1 } else { y }, m, d)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `2024-08-29T02:38:57.607016Z`
|
||||||
|
pub(crate) fn write_rfc3339<W: fmt::Write>(w: &mut W, secs: i64, micros: u32) -> fmt::Result {
|
||||||
|
let (y, mo, d) = civil(secs.div_euclid(86_400));
|
||||||
|
let t = secs.rem_euclid(86_400);
|
||||||
|
write!(
|
||||||
|
w,
|
||||||
|
"{y:04}-{mo:02}-{d:02}T{:02}:{:02}:{:02}.{micros:06}Z",
|
||||||
|
t / 3600,
|
||||||
|
t % 3600 / 60,
|
||||||
|
t % 60
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn now() -> (i64, u32) {
|
||||||
|
match SystemTime::now().duration_since(UNIX_EPOCH) {
|
||||||
|
Ok(d) => (d.as_secs() as i64, d.subsec_micros()),
|
||||||
|
Err(_) => (0, 0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Format 16 random bytes as a v4 UUID (`8-4-4-4-12`, lowercase).
|
||||||
|
pub(crate) fn uuid_string(mut b: [u8; 16], out: &mut [u8; 36]) {
|
||||||
|
const HEX: &[u8; 16] = b"0123456789abcdef";
|
||||||
|
b[6] = (b[6] & 0x0f) | 0x40;
|
||||||
|
b[8] = (b[8] & 0x3f) | 0x80;
|
||||||
|
let mut o = 0;
|
||||||
|
for (i, byte) in b.iter().enumerate() {
|
||||||
|
if matches!(i, 4 | 6 | 8 | 10) {
|
||||||
|
out[o] = b'-';
|
||||||
|
o += 1;
|
||||||
|
}
|
||||||
|
out[o] = HEX[(byte >> 4) as usize];
|
||||||
|
out[o + 1] = HEX[(byte & 15) as usize];
|
||||||
|
o += 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What went wrong.
|
||||||
|
pub(crate) struct Crash<'a> {
|
||||||
|
/// UUID with dashes.
|
||||||
|
pub uuid: &'a str,
|
||||||
|
pub secs: i64,
|
||||||
|
pub micros: u32,
|
||||||
|
/// Exception type: `panic` or the signal name.
|
||||||
|
pub kind: &'a str,
|
||||||
|
/// Human readable description.
|
||||||
|
pub value: &'a str,
|
||||||
|
/// Sentry mechanism type: `panic` or `signalhandler`.
|
||||||
|
pub mechanism: &'a str,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write the event JSON (single line). Mirrors what Sentry produces after
|
||||||
|
/// Ghostty's `beforeSend`: the `thread-type` tag and `Dimensions` context
|
||||||
|
/// are only present when the crashing thread has a thread state.
|
||||||
|
pub(crate) fn write_event<W: fmt::Write>(w: &mut W, st: &State, c: &Crash<'_>) -> fmt::Result {
|
||||||
|
w.write_str("{\"event_id\":\"")?;
|
||||||
|
for ch in c.uuid.chars().filter(|c| *c != '-') {
|
||||||
|
w.write_char(ch)?;
|
||||||
|
}
|
||||||
|
w.write_str("\",\"timestamp\":\"")?;
|
||||||
|
write_rfc3339(w, c.secs, c.micros)?;
|
||||||
|
w.write_str("\",\"platform\":\"native\",\"level\":\"fatal\",")?;
|
||||||
|
w.write_str(&st.prefix)?;
|
||||||
|
|
||||||
|
let ctx = context::current();
|
||||||
|
if let Some((kind, _)) = &ctx {
|
||||||
|
write!(w, ",\"thread-type\":\"{}\"", kind.as_str())?;
|
||||||
|
}
|
||||||
|
w.write_str("},\"contexts\":{")?;
|
||||||
|
if let Some((_, Some(d))) = &ctx {
|
||||||
|
let i = |v: u32| i32::try_from(v).unwrap_or(-1);
|
||||||
|
write!(
|
||||||
|
w,
|
||||||
|
"\"Dimensions\":{{\"screen-width\":{},\"screen-height\":{},\"grid-columns\":{},\"grid-rows\":{},\"cell-width\":{},\"cell-height\":{}}}",
|
||||||
|
i(d.screen_width),
|
||||||
|
i(d.screen_height),
|
||||||
|
i(d.grid_columns),
|
||||||
|
i(d.grid_rows),
|
||||||
|
i(d.cell_width),
|
||||||
|
i(d.cell_height)
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
w.write_str("},\"exception\":{\"values\":[{\"type\":")?;
|
||||||
|
json_str(w, c.kind)?;
|
||||||
|
w.write_str(",\"value\":")?;
|
||||||
|
json_str(w, c.value)?;
|
||||||
|
w.write_str(",\"mechanism\":{\"type\":")?;
|
||||||
|
json_str(w, c.mechanism)?;
|
||||||
|
w.write_str(",\"handled\":false}}]}}")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write the envelope header line and the event item header line (the event
|
||||||
|
/// payload follows). `len` is the event payload length.
|
||||||
|
pub(crate) fn write_envelope_head<W: fmt::Write>(
|
||||||
|
w: &mut W,
|
||||||
|
c: &Crash<'_>,
|
||||||
|
len: usize,
|
||||||
|
) -> fmt::Result {
|
||||||
|
w.write_str("{\"event_id\":\"")?;
|
||||||
|
for ch in c.uuid.chars().filter(|c| *c != '-') {
|
||||||
|
w.write_char(ch)?;
|
||||||
|
}
|
||||||
|
w.write_str("\",\"sent_at\":\"")?;
|
||||||
|
write_rfc3339(w, c.secs, c.micros)?;
|
||||||
|
write!(w, "\"}}\n{{\"type\":\"event\",\"length\":{len}}}\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn report_file_name(uuid: &str) -> String {
|
||||||
|
format!("{uuid}.{REPORT_EXTENSION}")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Panic path (may allocate).
|
||||||
|
|
||||||
|
thread_local! {
|
||||||
|
static IN_PANIC_REPORT: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
|
||||||
|
}
|
||||||
|
|
||||||
|
fn install_panic_hook() {
|
||||||
|
let prev = std::panic::take_hook();
|
||||||
|
std::panic::set_hook(Box::new(move |info| {
|
||||||
|
if !IN_PANIC_REPORT.with(|f| f.replace(true)) {
|
||||||
|
if let Some(st) = state() {
|
||||||
|
let msg = if let Some(s) = info.payload().downcast_ref::<&str>() {
|
||||||
|
(*s).to_owned()
|
||||||
|
} else if let Some(s) = info.payload().downcast_ref::<String>() {
|
||||||
|
s.clone()
|
||||||
|
} else {
|
||||||
|
"Box<dyn Any>".to_owned()
|
||||||
|
};
|
||||||
|
let value = match info.location() {
|
||||||
|
Some(l) => format!("{msg} ({}:{}:{})", l.file(), l.line(), l.column()),
|
||||||
|
None => msg,
|
||||||
|
};
|
||||||
|
let backtrace = std::backtrace::Backtrace::force_capture().to_string();
|
||||||
|
match write_report(st, "panic", &value, "panic", Some(&backtrace)) {
|
||||||
|
Ok(path) => {
|
||||||
|
log::warn!("crash report written to disk path={}", path.display());
|
||||||
|
}
|
||||||
|
Err(err) => log::warn!("failed to persist crash report err={err}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
IN_PANIC_REPORT.with(|f| f.set(false));
|
||||||
|
}
|
||||||
|
prev(info);
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the envelope and write it to the crash dir (Zig: `Transport.sendInternal`).
|
||||||
|
fn write_report(
|
||||||
|
st: &State,
|
||||||
|
kind: &str,
|
||||||
|
value: &str,
|
||||||
|
mechanism: &str,
|
||||||
|
backtrace: Option<&str>,
|
||||||
|
) -> io::Result<PathBuf> {
|
||||||
|
let mut raw = [0u8; 16];
|
||||||
|
signal::random_bytes(&mut raw);
|
||||||
|
let mut u = [0u8; 36];
|
||||||
|
uuid_string(raw, &mut u);
|
||||||
|
let uuid = std::str::from_utf8(&u).unwrap_or_default();
|
||||||
|
let (secs, micros) = now();
|
||||||
|
let crash = Crash {
|
||||||
|
uuid,
|
||||||
|
secs,
|
||||||
|
micros,
|
||||||
|
kind,
|
||||||
|
value,
|
||||||
|
mechanism,
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut event = String::new();
|
||||||
|
write_event(&mut event, st, &crash).map_err(io::Error::other)?;
|
||||||
|
let mut head = String::new();
|
||||||
|
write_envelope_head(&mut head, &crash, event.len()).map_err(io::Error::other)?;
|
||||||
|
let mut data = head.into_bytes();
|
||||||
|
data.extend_from_slice(event.as_bytes());
|
||||||
|
if let Some(bt) = backtrace {
|
||||||
|
let mut a = Attachment::new("backtrace.txt", bt.as_bytes().to_vec());
|
||||||
|
a.attachment_type = Some("event.attachment".into());
|
||||||
|
let mut env = Envelope {
|
||||||
|
headers: ObjectMap::new(),
|
||||||
|
items: vec![Item::Attachment(a)],
|
||||||
|
};
|
||||||
|
let enc = env.items[0].encode().clone();
|
||||||
|
data.push(b'\n');
|
||||||
|
let mut h = enc.headers;
|
||||||
|
h.insert("length".into(), enc.payload.len().into());
|
||||||
|
data.extend_from_slice(serde_json::to_string(&h)?.as_bytes());
|
||||||
|
data.push(b'\n');
|
||||||
|
data.extend_from_slice(&enc.payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
let dir = {
|
||||||
|
use std::os::unix::ffi::OsStrExt as _;
|
||||||
|
std::path::Path::new(std::ffi::OsStr::from_bytes(&st.dir))
|
||||||
|
};
|
||||||
|
// Windows paths are not arbitrary bytes; the dir was UTF-8 in practice.
|
||||||
|
#[cfg(not(unix))]
|
||||||
|
let dir_buf = PathBuf::from(String::from_utf8_lossy(&st.dir).into_owned());
|
||||||
|
#[cfg(not(unix))]
|
||||||
|
let dir = dir_buf.as_path();
|
||||||
|
std::fs::create_dir_all(dir)?;
|
||||||
|
let path = dir.join(report_file_name(uuid));
|
||||||
|
std::fs::write(&path, data)?;
|
||||||
|
Ok(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
#![allow(clippy::unwrap_used)]
|
||||||
|
use super::*;
|
||||||
|
use crate::envelope::ItemType;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn civil_dates() {
|
||||||
|
let mut s = String::new();
|
||||||
|
write_rfc3339(&mut s, 1_724_899_137, 607_016).unwrap();
|
||||||
|
assert_eq!("2024-08-29T02:38:57.607016Z", s);
|
||||||
|
let mut s = String::new();
|
||||||
|
write_rfc3339(&mut s, 0, 0).unwrap();
|
||||||
|
assert_eq!("1970-01-01T00:00:00.000000Z", s);
|
||||||
|
let mut s = String::new();
|
||||||
|
write_rfc3339(&mut s, 951_782_400, 1).unwrap(); // leap day 2000
|
||||||
|
assert_eq!("2000-02-29T00:00:00.000001Z", s);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn uuid_format() {
|
||||||
|
let mut out = [0u8; 36];
|
||||||
|
uuid_string([0xff; 16], &mut out);
|
||||||
|
assert_eq!(
|
||||||
|
"ffffffff-ffff-4fff-bfff-ffffffffffff",
|
||||||
|
std::str::from_utf8(&out).unwrap()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn json_escape() {
|
||||||
|
let mut s = String::new();
|
||||||
|
json_str(&mut s, "a\"b\\c\n\u{1}").unwrap();
|
||||||
|
assert_eq!("\"a\\\"b\\\\c\\n\\u0001\"", s);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn event_and_report_parse_back() {
|
||||||
|
let t = tempfile::tempdir().unwrap();
|
||||||
|
let opts = Options {
|
||||||
|
dir: Some(t.path().join("crash")),
|
||||||
|
..Options::default()
|
||||||
|
};
|
||||||
|
// Do not install global handlers in unit tests; build state only.
|
||||||
|
let st = State {
|
||||||
|
dir: opts
|
||||||
|
.dir
|
||||||
|
.as_ref()
|
||||||
|
.unwrap()
|
||||||
|
.as_os_str()
|
||||||
|
.as_encoded_bytes()
|
||||||
|
.to_vec(),
|
||||||
|
prefix: "\"release\":\"1.3.1\",\"environment\":\"production\",\"tags\":{\"renderer\":\"opengl\"".into(),
|
||||||
|
};
|
||||||
|
let dims = context::SurfaceDims::new();
|
||||||
|
dims.set(context::DimsSnapshot {
|
||||||
|
screen_width: 800,
|
||||||
|
screen_height: 600,
|
||||||
|
grid_columns: 80,
|
||||||
|
grid_rows: 24,
|
||||||
|
cell_width: 10,
|
||||||
|
cell_height: 25,
|
||||||
|
});
|
||||||
|
let _g = context::ThreadGuard::new(context::ThreadType::Renderer, Some(dims));
|
||||||
|
let path = write_report(&st, "panic", "boom \"x\"", "panic", Some("bt\nline")).unwrap();
|
||||||
|
assert!(path.to_string_lossy().ends_with(".ghosttycrash"));
|
||||||
|
let data = std::fs::read(&path).unwrap();
|
||||||
|
let mut env = Envelope::parse_slice(&data).unwrap();
|
||||||
|
assert_eq!(2, env.items.len());
|
||||||
|
assert_eq!(ItemType::Event, env.items[0].item_type());
|
||||||
|
let Item::Encoded(ev) = &env.items[0] else {
|
||||||
|
panic!()
|
||||||
|
};
|
||||||
|
let v: serde_json::Value = serde_json::from_slice(&ev.payload).unwrap();
|
||||||
|
assert_eq!("renderer", v["tags"]["thread-type"]);
|
||||||
|
assert_eq!(80, v["contexts"]["Dimensions"]["grid-columns"]);
|
||||||
|
assert_eq!("boom \"x\"", v["exception"]["values"][0]["value"]);
|
||||||
|
env.items[1].decode().unwrap();
|
||||||
|
let Item::Attachment(a) = &env.items[1] else {
|
||||||
|
panic!()
|
||||||
|
};
|
||||||
|
assert_eq!(b"bt\nline", a.payload.as_slice());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,266 @@
|
|||||||
|
//! Fatal-signal handling for crash capture.
|
||||||
|
//!
|
||||||
|
//! This is best effort, like the Sentry native handler it replaces: the
|
||||||
|
//! process is in an undefined state when this runs. We therefore avoid heap
|
||||||
|
//! allocation and only use async-signal-safe libc calls (`open`, `write`,
|
||||||
|
//! `mkdir`, `getrandom`, `raise`, ...). All formatting goes into a static
|
||||||
|
//! buffer; a flag makes sure only one crash is written at a time.
|
||||||
|
//!
|
||||||
|
//! Unsafe is needed for `sigaction`/`sigaltstack` and the static buffer.
|
||||||
|
#![allow(unsafe_code)]
|
||||||
|
|
||||||
|
use std::{
|
||||||
|
cell::UnsafeCell,
|
||||||
|
fmt,
|
||||||
|
sync::atomic::{AtomicBool, Ordering},
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::sentry::{self, Crash};
|
||||||
|
|
||||||
|
const SIGNALS: [(libc::c_int, &str, &str); 5] = [
|
||||||
|
(libc::SIGSEGV, "SIGSEGV", "Segmentation fault"),
|
||||||
|
(libc::SIGBUS, "SIGBUS", "Bus error"),
|
||||||
|
(libc::SIGILL, "SIGILL", "Illegal instruction"),
|
||||||
|
(libc::SIGFPE, "SIGFPE", "Arithmetic exception"),
|
||||||
|
(libc::SIGABRT, "SIGABRT", "Aborted"),
|
||||||
|
];
|
||||||
|
|
||||||
|
const EVENT_CAP: usize = 6 * 1024;
|
||||||
|
const HEAD_CAP: usize = 512;
|
||||||
|
const PATH_CAP: usize = 4096 + 64;
|
||||||
|
|
||||||
|
/// Static scratch space for the handler.
|
||||||
|
struct Scratch {
|
||||||
|
event: [u8; EVENT_CAP],
|
||||||
|
head: [u8; HEAD_CAP],
|
||||||
|
path: [u8; PATH_CAP],
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ScratchCell(UnsafeCell<Scratch>);
|
||||||
|
// SAFETY: access is serialized by `BUSY`.
|
||||||
|
unsafe impl Sync for ScratchCell {}
|
||||||
|
|
||||||
|
static SCRATCH: ScratchCell = ScratchCell(UnsafeCell::new(Scratch {
|
||||||
|
event: [0; EVENT_CAP],
|
||||||
|
head: [0; HEAD_CAP],
|
||||||
|
path: [0; PATH_CAP],
|
||||||
|
}));
|
||||||
|
static BUSY: AtomicBool = AtomicBool::new(false);
|
||||||
|
|
||||||
|
/// A `fmt::Write` into a fixed byte slice; errors when full.
|
||||||
|
struct SliceWriter<'a> {
|
||||||
|
buf: &'a mut [u8],
|
||||||
|
len: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Write for SliceWriter<'_> {
|
||||||
|
fn write_str(&mut self, s: &str) -> fmt::Result {
|
||||||
|
let b = s.as_bytes();
|
||||||
|
let end = self.len.checked_add(b.len()).ok_or(fmt::Error)?;
|
||||||
|
if end > self.buf.len() {
|
||||||
|
return Err(fmt::Error);
|
||||||
|
}
|
||||||
|
self.buf[self.len..end].copy_from_slice(b);
|
||||||
|
self.len = end;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fill `out` with random bytes using only async-signal-safe calls, falling
|
||||||
|
/// back to a time/pid mix if the kernel refuses.
|
||||||
|
pub(crate) fn random_bytes(out: &mut [u8; 16]) {
|
||||||
|
#[cfg(not(target_vendor = "apple"))]
|
||||||
|
{
|
||||||
|
// SAFETY: `out` is a valid writable buffer of the given length.
|
||||||
|
let n = unsafe { libc::getrandom(out.as_mut_ptr().cast(), out.len(), libc::GRND_NONBLOCK) };
|
||||||
|
if usize::try_from(n).is_ok_and(|n| n == out.len()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Darwin has no getrandom(2); getentropy(2) fills up to 256 bytes.
|
||||||
|
#[cfg(target_vendor = "apple")]
|
||||||
|
{
|
||||||
|
// SAFETY: `out` is a valid writable buffer of the given length.
|
||||||
|
if unsafe { libc::getentropy(out.as_mut_ptr().cast(), out.len()) } == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let (s, us) = sentry::now();
|
||||||
|
// SAFETY: getpid has no preconditions.
|
||||||
|
let pid = unsafe { libc::getpid() };
|
||||||
|
let mut x = (s as u64) ^ (u64::from(us) << 20) ^ (u64::from(pid.unsigned_abs()) << 40);
|
||||||
|
for chunk in out.chunks_mut(8) {
|
||||||
|
// splitmix64
|
||||||
|
x = x.wrapping_add(0x9e37_79b9_7f4a_7c15);
|
||||||
|
let mut z = x;
|
||||||
|
z = (z ^ (z >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
|
||||||
|
z = (z ^ (z >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
|
||||||
|
z ^= z >> 31;
|
||||||
|
chunk.copy_from_slice(&z.to_le_bytes()[..chunk.len()]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_all(fd: libc::c_int, mut data: &[u8]) -> bool {
|
||||||
|
while !data.is_empty() {
|
||||||
|
// SAFETY: `data` is a valid readable buffer.
|
||||||
|
let n = unsafe { libc::write(fd, data.as_ptr().cast(), data.len()) };
|
||||||
|
if n < 0 {
|
||||||
|
if std::io::Error::last_os_error().kind() == std::io::ErrorKind::Interrupted {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if n == 0 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
data = &data[n as usize..];
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `mkdir -p` on a NUL-terminated path in `path[..len]` (mutated temporarily).
|
||||||
|
fn mkdir_p(path: &mut [u8], len: usize) {
|
||||||
|
for i in 1..=len {
|
||||||
|
if i == len || path[i] == b'/' {
|
||||||
|
let saved = path[i];
|
||||||
|
path[i] = 0;
|
||||||
|
// SAFETY: path[..=i] is NUL terminated.
|
||||||
|
unsafe { libc::mkdir(path.as_ptr().cast(), 0o755) };
|
||||||
|
path[i] = saved;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write a crash report for `sig`. Returns without doing anything if the
|
||||||
|
/// crash handler isn't active or another crash is being written.
|
||||||
|
fn write_signal_report(name: &str, desc: &str) {
|
||||||
|
let Some(st) = sentry::state() else { return };
|
||||||
|
if BUSY.swap(true, Ordering::Acquire) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// SAFETY: BUSY gives us exclusive access to the scratch space (and it is
|
||||||
|
// never released, since the process is about to die).
|
||||||
|
let sc = unsafe { &mut *SCRATCH.0.get() };
|
||||||
|
|
||||||
|
let mut raw = [0u8; 16];
|
||||||
|
random_bytes(&mut raw);
|
||||||
|
let mut u = [0u8; 36];
|
||||||
|
sentry::uuid_string(raw, &mut u);
|
||||||
|
let Ok(uuid) = std::str::from_utf8(&u) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let (secs, micros) = sentry::now();
|
||||||
|
let crash = Crash {
|
||||||
|
uuid,
|
||||||
|
secs,
|
||||||
|
micros,
|
||||||
|
kind: name,
|
||||||
|
value: desc,
|
||||||
|
mechanism: "signalhandler",
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut ev = SliceWriter {
|
||||||
|
buf: &mut sc.event,
|
||||||
|
len: 0,
|
||||||
|
};
|
||||||
|
if sentry::write_event(&mut ev, st, &crash).is_err() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let ev_len = ev.len;
|
||||||
|
let mut head = SliceWriter {
|
||||||
|
buf: &mut sc.head,
|
||||||
|
len: 0,
|
||||||
|
};
|
||||||
|
if sentry::write_envelope_head(&mut head, &crash, ev_len).is_err() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let head_len = head.len;
|
||||||
|
|
||||||
|
// <dir>/<uuid>.ghosttycrash\0
|
||||||
|
let ext = b".ghosttycrash";
|
||||||
|
let total = st.dir.len() + 1 + u.len() + ext.len();
|
||||||
|
if total + 1 > PATH_CAP {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let mut o = 0;
|
||||||
|
for part in [st.dir.as_slice(), b"/", &u, ext] {
|
||||||
|
sc.path[o..o + part.len()].copy_from_slice(part);
|
||||||
|
o += part.len();
|
||||||
|
}
|
||||||
|
sc.path[o] = 0;
|
||||||
|
|
||||||
|
// The directory must exist for writing to work.
|
||||||
|
mkdir_p(&mut sc.path, st.dir.len());
|
||||||
|
// SAFETY: path is NUL terminated.
|
||||||
|
let fd = unsafe {
|
||||||
|
libc::open(
|
||||||
|
sc.path.as_ptr().cast(),
|
||||||
|
libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC,
|
||||||
|
0o644,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
if fd < 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let ok = write_all(fd, &sc.head[..head_len]) && write_all(fd, &sc.event[..ev_len]);
|
||||||
|
// SAFETY: fd is open and owned by us.
|
||||||
|
unsafe { libc::close(fd) };
|
||||||
|
if ok {
|
||||||
|
write_all(2, b"warning(sentry): crash report written to disk path=");
|
||||||
|
write_all(2, &sc.path[..o]);
|
||||||
|
write_all(2, b"\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extern "C" fn handler(sig: libc::c_int, _info: *mut libc::siginfo_t, _ctx: *mut libc::c_void) {
|
||||||
|
if let Some((_, name, desc)) = SIGNALS.iter().find(|(s, ..)| *s == sig) {
|
||||||
|
write_signal_report(name, desc);
|
||||||
|
}
|
||||||
|
// Restore the default action and re-raise so the process dies with the
|
||||||
|
// original signal (core dump, exit status) as if we were never here.
|
||||||
|
// SAFETY: signal and raise are async-signal-safe.
|
||||||
|
unsafe {
|
||||||
|
libc::signal(sig, libc::SIG_DFL);
|
||||||
|
libc::raise(sig);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Make sure the calling thread has an alternate signal stack so that stack
|
||||||
|
/// overflows can be handled. Rust's runtime already installs one for the main
|
||||||
|
/// thread and threads it spawns, so this is usually a no-op.
|
||||||
|
fn ensure_altstack() {
|
||||||
|
// SAFETY: sigaltstack is called with valid pointers; the new stack is
|
||||||
|
// leaked so it lives for the whole process.
|
||||||
|
unsafe {
|
||||||
|
let mut cur: libc::stack_t = std::mem::zeroed();
|
||||||
|
if libc::sigaltstack(std::ptr::null(), &raw mut cur) == 0
|
||||||
|
&& cur.ss_flags & libc::SS_DISABLE == 0
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let size = 64 * 1024;
|
||||||
|
let stack: &'static mut [u8] = Box::leak(vec![0u8; size].into_boxed_slice());
|
||||||
|
let ss = libc::stack_t {
|
||||||
|
ss_sp: stack.as_mut_ptr().cast(),
|
||||||
|
ss_flags: 0,
|
||||||
|
ss_size: size,
|
||||||
|
};
|
||||||
|
libc::sigaltstack(&raw const ss, std::ptr::null_mut());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Install handlers for the fatal signals.
|
||||||
|
pub(crate) fn install_handlers() {
|
||||||
|
ensure_altstack();
|
||||||
|
for (sig, ..) in SIGNALS {
|
||||||
|
// SAFETY: a zeroed sigaction is valid to fill in; the handler is an
|
||||||
|
// `extern "C"` fn with the SA_SIGINFO signature.
|
||||||
|
unsafe {
|
||||||
|
let mut sa: libc::sigaction = std::mem::zeroed();
|
||||||
|
sa.sa_sigaction = handler as *const () as usize;
|
||||||
|
sa.sa_flags = libc::SA_SIGINFO | libc::SA_ONSTACK | libc::SA_NODEFER;
|
||||||
|
libc::sigemptyset(&raw mut sa.sa_mask);
|
||||||
|
libc::sigaction(sig, &raw const sa, std::ptr::null_mut());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
//! Windows stand-in for `signal.rs`: Zig's `crash/sentry.zig` does not
|
||||||
|
//! initialise crash capture on Windows, so there are no fatal-signal
|
||||||
|
//! handlers. Only the helpers the report writer shares are provided.
|
||||||
|
|
||||||
|
use std::hash::{BuildHasher, Hasher, RandomState};
|
||||||
|
|
||||||
|
/// Fill `out` with random bytes (used for the event UUID). The standard
|
||||||
|
/// library's per-instance random hasher keys are good enough for an id.
|
||||||
|
pub(crate) fn random_bytes(out: &mut [u8; 16]) {
|
||||||
|
for chunk in out.chunks_mut(8) {
|
||||||
|
let mut h = RandomState::new().build_hasher();
|
||||||
|
h.write_usize(chunk.len());
|
||||||
|
chunk.copy_from_slice(&h.finish().to_ne_bytes()[..chunk.len()]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// No fatal-signal handlers on Windows.
|
||||||
|
pub(crate) fn install_handlers() {}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# Crash handling (CR)
|
||||||
|
|
||||||
|
New crate `crates/ghostty-crash` (workspace member; `unsafe_code = "deny"`, allowed only in `signal.rs` and `context.rs` with SAFETY comments).
|
||||||
|
|
||||||
|
## Ported
|
||||||
|
- `crash/dir.zig` -> `dir.rs` (`default_dir`, `Dir`, `ReportIterator`, `Report`). `ghostty-cli` `+crash-report` now lists via it (`crash_report::list_reports/default_dir` delegate).
|
||||||
|
- `crash/sentry_envelope.zig` -> `envelope.rs` (parse/serialize/Attachment/Item/ItemType); all 9 Zig tests ported + error/decode/round-trip tests.
|
||||||
|
- `crash/sentry.zig` -> `sentry.rs` + `signal.rs` + `context.rs`: `init`/`deinit`, crash dir (`$XDG_STATE_HOME/ghostty/crash`), `<uuid-v4>.ghosttycrash` filename, event with release/tags (build-mode, app-runtime, font-backend, renderer, thread-type) and `Dimensions` context (screen/grid/cell as i32, -1 on overflow), panic hook, SIGSEGV/SIGBUS/SIGILL/SIGFPE/SIGABRT handlers.
|
||||||
|
- `crash/main.zig` -> `lib.rs` re-exports.
|
||||||
|
- Hooks: `Surface::crash_guard` (Main) in the CoreSurface callbacks + occlusion/refresh/text; renderer `Thread::thread_main` (Renderer); termio `Thread::thread_main` and the exec read thread (Io, as in Zig); `global::init` -> `ghostty_crash::init`, `global::deinit` (called from `main`).
|
||||||
|
|
||||||
|
## Design
|
||||||
|
- Feature `sentry` (default off, like `-Dsentry`) on the root binary -> `ghostty-crash/sentry`. `ghostty_crash::init()` is a no-op without it. `ghostty_crash::install()` always installs (used by tests).
|
||||||
|
- Panic path may allocate: envelope = header line + `event` item + `backtrace.txt` attachment. Signal path: no allocation, static scratch buffer, only `open/write/mkdir/getrandom/raise`; writes header + event item (no attachment), then restores SIG_DFL and re-raises so exit status/core dump are unchanged. Uses the thread's existing altstack (Rust installs one) or a leaked 64K one.
|
||||||
|
- Surface "pointer" -> `Arc<SurfaceDims>` (atomics) that the surface refreshes on every callback/resize and shares with the renderer and IO threads (`Thread::crash_dims` pub field) and the read thread (`current_dims()`).
|
||||||
|
|
||||||
|
## Deviations
|
||||||
|
- Zig's `Attachment.encode` never writes the `type` header (unparseable output); we add `"type":"attachment"` if absent.
|
||||||
|
- Crash dir is resolved at init (not at crash time) since the signal path can't read the environment.
|
||||||
|
- Panic reports are written on any panic (Zig panics abort and are caught by sentry as a signal). No sentry DB/cache dir, no sessions, no macOS cache dir, no init thread.
|
||||||
|
- Read thread tag is `io` (Zig behaviour), not a separate `read` type.
|
||||||
|
- Signal-targeted thread context is only exact for synchronous faults/abort; process-directed signals may hit another thread.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
`cargo test -p ghostty-crash` (envelope, dir, event/report parse-back, context) and root `tests/crash.rs` (child process: panic, SIGSEGV, stack-overflow fault on altstack, abort; checks envelope contents and that `ghostty +crash-report` lists the file).
|
||||||
|
|
||||||
|
## Known gaps
|
||||||
|
No minidump/native stack in signal reports; no `+crash-report` viewing (upstream doesn't either).
|
||||||
+191
@@ -0,0 +1,191 @@
|
|||||||
|
//! Subprocess tests for crash capture: a child process installs the crash
|
||||||
|
//! handler, crashes (panic / SIGSEGV / abort), and the parent verifies that a
|
||||||
|
//! valid `.ghosttycrash` envelope appeared and that `ghostty +crash-report`
|
||||||
|
//! lists it.
|
||||||
|
//!
|
||||||
|
//! The child is this test executable re-run with `GHOSTTY_CRASH_CHILD`.
|
||||||
|
#![allow(clippy::unwrap_used)]
|
||||||
|
|
||||||
|
use std::{os::unix::process::ExitStatusExt, path::Path, process::Command};
|
||||||
|
|
||||||
|
use ghostty_crash::{
|
||||||
|
DimsSnapshot,
|
||||||
|
Options,
|
||||||
|
SurfaceDims,
|
||||||
|
ThreadGuard,
|
||||||
|
ThreadType,
|
||||||
|
envelope::{Envelope, Item, ItemType},
|
||||||
|
};
|
||||||
|
|
||||||
|
fn child_main(mode: &str) {
|
||||||
|
let dir = std::env::var("GHOSTTY_CRASH_DIR").unwrap();
|
||||||
|
ghostty_crash::install(&Options {
|
||||||
|
dir: Some(dir.into()),
|
||||||
|
..Options::default()
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
let dims = SurfaceDims::new();
|
||||||
|
dims.set(DimsSnapshot {
|
||||||
|
screen_width: 1024,
|
||||||
|
screen_height: 768,
|
||||||
|
grid_columns: 100,
|
||||||
|
grid_rows: 40,
|
||||||
|
cell_width: 10,
|
||||||
|
cell_height: 19,
|
||||||
|
});
|
||||||
|
let _g = ThreadGuard::new(ThreadType::Renderer, Some(dims));
|
||||||
|
match mode {
|
||||||
|
"panic" => {
|
||||||
|
// Panic on a spawned thread, like a renderer-thread crash.
|
||||||
|
let d = ghostty_crash::current_dims();
|
||||||
|
let r = std::thread::spawn(move || {
|
||||||
|
let _g = ThreadGuard::new(ThreadType::Renderer, d);
|
||||||
|
panic!("intentional test panic")
|
||||||
|
})
|
||||||
|
.join();
|
||||||
|
assert!(r.is_err());
|
||||||
|
// Make the process die like a real crash would.
|
||||||
|
std::process::exit(101);
|
||||||
|
}
|
||||||
|
"segv" => {
|
||||||
|
let me = rustix::process::getpid();
|
||||||
|
rustix::process::kill_process(me, rustix::process::Signal::SEGV).unwrap();
|
||||||
|
// The handler re-raises with the default action; never get here.
|
||||||
|
std::thread::sleep(std::time::Duration::from_secs(30));
|
||||||
|
}
|
||||||
|
// A real fault on the guard page, handled on the alternate stack.
|
||||||
|
"overflow" => {
|
||||||
|
// Unbounded on purpose: overflowing the stack is the test.
|
||||||
|
#[allow(unconditional_recursion)]
|
||||||
|
fn recurse(n: u64) -> u64 {
|
||||||
|
let pad = [n as u8; 256];
|
||||||
|
std::hint::black_box(&pad);
|
||||||
|
recurse(n + 1) + u64::from(pad[0])
|
||||||
|
}
|
||||||
|
std::process::exit(i32::from(recurse(0) == 1));
|
||||||
|
}
|
||||||
|
"abort" => std::process::abort(),
|
||||||
|
_ => unreachable!(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_child(mode: &str, dir: &Path) -> std::process::ExitStatus {
|
||||||
|
let exe = std::env::current_exe().unwrap();
|
||||||
|
Command::new(exe)
|
||||||
|
.args(["--exact", "crash_child", "--nocapture", "--test-threads=1"])
|
||||||
|
.env("GHOSTTY_CRASH_CHILD", mode)
|
||||||
|
.env("GHOSTTY_CRASH_DIR", dir)
|
||||||
|
.env("RUST_BACKTRACE", "0")
|
||||||
|
.status()
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Entry point of the child process; a no-op in a normal test run.
|
||||||
|
#[test]
|
||||||
|
fn crash_child() {
|
||||||
|
if let Ok(mode) = std::env::var("GHOSTTY_CRASH_CHILD") {
|
||||||
|
child_main(&mode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reports(dir: &Path) -> Vec<std::path::PathBuf> {
|
||||||
|
let Ok(rd) = std::fs::read_dir(dir) else {
|
||||||
|
return vec![];
|
||||||
|
};
|
||||||
|
rd.map(|e| e.unwrap().path()).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse the single report in `dir` and check its contents.
|
||||||
|
///
|
||||||
|
/// `thread_type` is `None` when the signal can't be targeted at the thread
|
||||||
|
/// with the crash context (process-directed signals).
|
||||||
|
fn check_report(dir: &Path, kind: &str, thread_type: Option<&str>) {
|
||||||
|
let files = reports(dir);
|
||||||
|
assert_eq!(1, files.len(), "{files:?}");
|
||||||
|
let f = &files[0];
|
||||||
|
let name = f.file_name().unwrap().to_str().unwrap();
|
||||||
|
let stem = name.strip_suffix(".ghosttycrash").unwrap();
|
||||||
|
assert_eq!(36, stem.len(), "{name}");
|
||||||
|
assert_eq!(Some('4'), stem.chars().nth(14));
|
||||||
|
|
||||||
|
let data = std::fs::read(f).unwrap();
|
||||||
|
let env = Envelope::parse_slice(&data).unwrap();
|
||||||
|
assert!(env.headers.contains_key("event_id"));
|
||||||
|
let Some(Item::Encoded(ev)) = env.items.first() else {
|
||||||
|
panic!("no items")
|
||||||
|
};
|
||||||
|
assert_eq!(ItemType::Event, ev.item_type);
|
||||||
|
let v: serde_json::Value = serde_json::from_slice(&ev.payload).unwrap();
|
||||||
|
assert_eq!("1.3.1", v["release"]);
|
||||||
|
assert_eq!("fatal", v["level"]);
|
||||||
|
if let Some(t) = thread_type {
|
||||||
|
assert_eq!(t, v["tags"]["thread-type"]);
|
||||||
|
assert_eq!(100, v["contexts"]["Dimensions"]["grid-columns"]);
|
||||||
|
assert_eq!(768, v["contexts"]["Dimensions"]["screen-height"]);
|
||||||
|
assert_eq!(19, v["contexts"]["Dimensions"]["cell-height"]);
|
||||||
|
}
|
||||||
|
assert_eq!("opengl", v["tags"]["renderer"]);
|
||||||
|
assert_eq!(kind, v["exception"]["values"][0]["type"]);
|
||||||
|
|
||||||
|
// +crash-report lists the file.
|
||||||
|
let out = Command::new(env!("CARGO_BIN_EXE_ghostty"))
|
||||||
|
.arg("+crash-report")
|
||||||
|
.env("XDG_STATE_HOME", dir.parent().unwrap().parent().unwrap())
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
assert!(out.status.success(), "{out:?}");
|
||||||
|
let stdout = String::from_utf8(out.stdout).unwrap();
|
||||||
|
assert!(stdout.starts_with(name), "{stdout:?}");
|
||||||
|
assert!(
|
||||||
|
stdout.contains(" ago)") || stdout.contains("(now)"),
|
||||||
|
"{stdout:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A temp state home with the crash dir path `<state>/ghostty/crash`.
|
||||||
|
fn state_dirs() -> (tempfile::TempDir, std::path::PathBuf) {
|
||||||
|
let t = tempfile::tempdir().unwrap();
|
||||||
|
let dir = t.path().join("ghostty").join("crash");
|
||||||
|
(t, dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn panic_writes_report() {
|
||||||
|
let (_t, dir) = state_dirs();
|
||||||
|
let st = run_child("panic", &dir);
|
||||||
|
assert_eq!(Some(101), st.code());
|
||||||
|
check_report(&dir, "panic", Some("renderer"));
|
||||||
|
// The panic report carries a backtrace attachment.
|
||||||
|
let data = std::fs::read(&reports(&dir)[0]).unwrap();
|
||||||
|
let mut env = Envelope::parse_slice(&data).unwrap();
|
||||||
|
assert_eq!(2, env.items.len());
|
||||||
|
env.items[1].decode().unwrap();
|
||||||
|
let Item::Attachment(a) = &env.items[1] else {
|
||||||
|
panic!()
|
||||||
|
};
|
||||||
|
assert_eq!("backtrace.txt", a.filename);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sigsegv_writes_report() {
|
||||||
|
let (_t, dir) = state_dirs();
|
||||||
|
let st = run_child("segv", &dir);
|
||||||
|
assert_eq!(Some(11), st.signal());
|
||||||
|
check_report(&dir, "SIGSEGV", None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stack_overflow_writes_report() {
|
||||||
|
let (_t, dir) = state_dirs();
|
||||||
|
let st = run_child("overflow", &dir);
|
||||||
|
assert_eq!(Some(11), st.signal());
|
||||||
|
check_report(&dir, "SIGSEGV", Some("renderer"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn abort_writes_report() {
|
||||||
|
let (_t, dir) = state_dirs();
|
||||||
|
let st = run_child("abort", &dir);
|
||||||
|
assert_eq!(Some(6), st.signal());
|
||||||
|
check_report(&dir, "SIGABRT", Some("renderer"));
|
||||||
|
}
|
||||||
Reference in new issue
Block a user