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
a07b224869
commit
a714fd9a65
11 files changed
+1933
-36
No files matched your search
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user