From b3145ad7c1d7e29e702a87baef5e0cb5115683dc Mon Sep 17 00:00:00 2001 From: ddidderr Date: Mon, 12 Oct 2026 06:02:28 +0200 Subject: [PATCH] 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 (.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) --- crates/ghostty-cli/src/crash_report.rs | 43 +- crates/ghostty-crash/Cargo.toml | 57 +++ crates/ghostty-crash/src/context.rs | 199 ++++++++ crates/ghostty-crash/src/dir.rs | 129 +++++ crates/ghostty-crash/src/envelope.rs | 526 +++++++++++++++++++++ crates/ghostty-crash/src/lib.rs | 24 + crates/ghostty-crash/src/sentry.rs | 488 +++++++++++++++++++ crates/ghostty-crash/src/signal.rs | 266 +++++++++++ crates/ghostty-crash/src/signal_windows.rs | 18 + docs/rewrite/notes/crash-CR.md | 28 ++ tests/crash.rs | 191 ++++++++ 11 files changed, 1933 insertions(+), 36 deletions(-) create mode 100644 crates/ghostty-crash/Cargo.toml create mode 100644 crates/ghostty-crash/src/context.rs create mode 100644 crates/ghostty-crash/src/dir.rs create mode 100644 crates/ghostty-crash/src/envelope.rs create mode 100644 crates/ghostty-crash/src/lib.rs create mode 100644 crates/ghostty-crash/src/sentry.rs create mode 100644 crates/ghostty-crash/src/signal.rs create mode 100644 crates/ghostty-crash/src/signal_windows.rs create mode 100644 docs/rewrite/notes/crash-CR.md create mode 100644 tests/crash.rs diff --git a/crates/ghostty-cli/src/crash_report.rs b/crates/ghostty-cli/src/crash_report.rs index e1da471..f3a1c12 100644 --- a/crates/ghostty-cli/src/crash_report.rs +++ b/crates/ghostty-cli/src/crash_report.rs @@ -11,52 +11,23 @@ use std::{ }; use ghostty_config::types_b::Duration; +pub use ghostty_crash::Report; use crate::error::Error; 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`): /// `$XDG_STATE_HOME/ghostty/crash`. pub fn default_dir() -> Result { - let dir = ghostty_os::xdg::state(&ghostty_os::xdg::Options { - subdir: Some("ghostty/crash"), - home: None, - }) - .map_err(|e| Error::Other(e.to_string()))?; - Ok(PathBuf::from(dir)) + let dir = ghostty_crash::default_dir().map_err(|e| Error::Other(e.to_string()))?; + Ok(dir.path) } -/// List the crash reports (regular files) in `dir`. A directory that cannot -/// be opened yields no reports. +/// List the crash reports (regular files) in `dir`, i.e. the +/// `.ghosttycrash` envelopes written by `ghostty-crash`. A directory that +/// cannot be opened yields no reports. pub fn list_reports(dir: &std::path::Path) -> Result, Error> { - let Ok(rd) = std::fs::read_dir(dir) else { - 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) + Ok(ghostty_crash::dir::list_reports(dir)?) } /// The `crash-report` command is used to inspect and send crash reports. diff --git a/crates/ghostty-crash/Cargo.toml b/crates/ghostty-crash/Cargo.toml new file mode 100644 index 0000000..68b4706 --- /dev/null +++ b/crates/ghostty-crash/Cargo.toml @@ -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" diff --git a/crates/ghostty-crash/src/context.rs b/crates/ghostty-crash/src/context.rs new file mode 100644 index 0000000..852a064 --- /dev/null +++ b/crates/ghostty-crash/src/context.rs @@ -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 { + 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 { + 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>, + // 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>) -> 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> { + 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)> { + 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()); + } +} diff --git a/crates/ghostty-crash/src/dir.rs b/crates/ghostty-crash/src/dir.rs new file mode 100644 index 0000000..d599ba8 --- /dev/null +++ b/crates/ghostty-crash/src/dir.rs @@ -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 { + 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, +} + +impl ReportIterator { + /// Next report, or `Ok(None)` at the end. + pub fn next_report(&mut self) -> io::Result> { + 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; + fn next(&mut self) -> Option { + 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> { + 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")); + } +} diff --git a/crates/ghostty-crash/src/envelope.rs b/crates/ghostty-crash/src/envelope.rs new file mode 100644 index 0000000..f5d9921 --- /dev/null +++ b/crates/ghostty-crash/src/envelope.rs @@ -0,0 +1,526 @@ +//! The Sentry Envelope format (port of `crash/sentry_envelope.zig`): +//! +//! +//! 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; + +/// 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. +/// +/// +#[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 { + 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, +} + +/// An arbitrary file 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, + /// Additional headers for the attachment. + pub headers_extra: ObjectMap, + /// The data for the attachment. + pub payload: Vec, +} + +impl Attachment { + /// A fresh attachment with no extra headers. + #[must_use] + pub fn new(filename: impl Into, payload: Vec) -> Attachment { + Attachment { + filename: filename.into(), + attachment_type: None, + headers_extra: ObjectMap::new(), + payload, + } + } + + pub fn decode(item: &EncodedItem) -> Result { + 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, +} + +/// 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: &mut R) -> Result, 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(reader: &mut R) -> Result { + 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 { + Self::parse(&mut data) + } + + fn parse_header(reader: &mut R) -> Result { + let line = read_line(reader)?; + match serde_json::from_slice::(&line)? { + Value::Object(m) => Ok(m), + _ => Err(Error::MalformedHeaders), + } + } + + fn parse_items(reader: &mut R) -> Result, Error> { + let mut items = Vec::new(); + while let Some(item) = Self::parse_one_item(reader)? { + items.push(item); + } + Ok(items) + } + + fn parse_one_item(reader: &mut R) -> Result, 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::(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 = 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(&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, 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()); + } +} diff --git a/crates/ghostty-crash/src/lib.rs b/crates/ghostty-crash/src/lib.rs new file mode 100644 index 0000000..c07214f --- /dev/null +++ b/crates/ghostty-crash/src/lib.rs @@ -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}; diff --git a/crates/ghostty-crash/src/sentry.rs b/crates/ghostty-crash/src/sentry.rs new file mode 100644 index 0000000..5701faa --- /dev/null +++ b/crates/ghostty-crash/src/sentry.rs @@ -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/.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, +} + +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, + /// Pre-rendered `"release":"..","environment":"production","tags":{...` + /// (the tags object is left open so `thread-type` can be appended). + pub prefix: String, +} + +static STATE: OnceLock = 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: &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: &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: &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: &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 = 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::() { + s.clone() + } else { + "Box".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 { + 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()); + } +} diff --git a/crates/ghostty-crash/src/signal.rs b/crates/ghostty-crash/src/signal.rs new file mode 100644 index 0000000..800c96d --- /dev/null +++ b/crates/ghostty-crash/src/signal.rs @@ -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); +// 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; + + // /.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()); + } + } +} diff --git a/crates/ghostty-crash/src/signal_windows.rs b/crates/ghostty-crash/src/signal_windows.rs new file mode 100644 index 0000000..0fb5942 --- /dev/null +++ b/crates/ghostty-crash/src/signal_windows.rs @@ -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() {} diff --git a/docs/rewrite/notes/crash-CR.md b/docs/rewrite/notes/crash-CR.md new file mode 100644 index 0000000..a4f49ed --- /dev/null +++ b/docs/rewrite/notes/crash-CR.md @@ -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`), `.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` (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). diff --git a/tests/crash.rs b/tests/crash.rs new file mode 100644 index 0000000..c7dac03 --- /dev/null +++ b/tests/crash.rs @@ -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 { + 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 `/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")); +}