Escaped config strings (ghostty_os::osbytes) kept reaching OS boundaries undecoded, and decoding was occasionally applied to plain text. The root cause was that both were plain `String`s. Now: - `EncodedOs` is a newtype (no Display/AsRef<Path>) that decodes only via to_os/to_path/to_bytes/to_wide; plain text enters via from_plain_text. OS helpers (env, home, xdg, passwd, resources dir, path expansion), config::Path, WorkingDirectory, theme/file_load/edit paths, terminal pwd, OpenUrl/Pwd actions and termio cwd use it, so the compiler forces decoding at every boundary. os::open takes &OsStr. Config output is unchanged. Fixes all rows of docs/rewrite/notes/review-rv5-codex.md (final-Z7.md): screen-file copy; regex/OSC 8 URLs no longer decoded (regression); lossless Windows WTF-8/WTF-16 for args, env and CreateProcessW; Windows resources dir/TERMINFO; color OSC request lists and X11 color lookup no longer allocate globally (C zero-allocation test extended); macOS Mach handler resumes all threads before KERN_FAILURE (hang regression) and SIGABRT minidumps go through the exception thread; plus open-config, background images, crash dir, ssh cache, exe dir/PATH, fontconfig filenames, file links, kitty TMPDIR, .hushlogin and benchmark paths. The typing also surfaced and fixed gtk-custom-css, bell audio, bash HISTFILE, OSC 52 writes and the C config getters. Test Plan: - cargo test --workspace (3376 passed) - just check-macos (both), just check-windows, just check-wasm - diff <(ghostty-rs +show-config --default --docs) \ <(ghostty +show-config --default --docs)
170 lines
6.2 KiB
Rust
170 lines
6.2 KiB
Rust
//! Windows crash capture, mirroring Breakpad's in-process `ExceptionHandler`
|
|
//! (which sentry-native's breakpad backend uses upstream): an unhandled
|
|
//! exception filter hands the exception to a pre-started handler thread, which
|
|
//! writes a minidump of the process with `minidump-writer`
|
|
//! (`MiniDumpWriteDump`) and attaches it to the envelope (`event.minidump`).
|
|
//! Running the dump on a separate thread with its own stack keeps it working
|
|
//! after a stack overflow. After writing, the filter returns
|
|
//! `EXCEPTION_CONTINUE_SEARCH` so the normal termination (and Windows Error
|
|
//! Reporting) proceeds.
|
|
//!
|
|
//! Faults raised by `__fastfail` (Rust's `abort`) bypass exception filters
|
|
//! and are not captured; panics are covered by the panic hook.
|
|
#![allow(unsafe_code)]
|
|
|
|
use std::{
|
|
fs::File,
|
|
hash::{BuildHasher, Hasher, RandomState},
|
|
sync::atomic::{AtomicBool, AtomicIsize, AtomicPtr, Ordering},
|
|
};
|
|
|
|
use minidump_writer::{crash_context::CrashContext, minidump_writer::MinidumpWriter};
|
|
use windows_sys::Win32::{
|
|
Foundation::{CloseHandle, FALSE, HANDLE, WAIT_OBJECT_0},
|
|
System::{
|
|
Diagnostics::Debug::{EXCEPTION_POINTERS, SetUnhandledExceptionFilter},
|
|
Threading::{
|
|
CreateEventW,
|
|
GetCurrentProcessId,
|
|
GetCurrentThreadId,
|
|
INFINITE,
|
|
SetEvent,
|
|
WaitForSingleObject,
|
|
},
|
|
},
|
|
};
|
|
|
|
use crate::{exception, sentry};
|
|
|
|
/// How long the faulting thread waits for the dump.
|
|
const TIMEOUT_MS: u32 = 30_000;
|
|
|
|
/// `EXCEPTION_CONTINUE_SEARCH`.
|
|
const CONTINUE_SEARCH: i32 = 0;
|
|
|
|
static BUSY: AtomicBool = AtomicBool::new(false);
|
|
static REQUEST: AtomicIsize = AtomicIsize::new(0);
|
|
static DONE: AtomicIsize = AtomicIsize::new(0);
|
|
static POINTERS: AtomicPtr<EXCEPTION_POINTERS> = AtomicPtr::new(std::ptr::null_mut());
|
|
static FAULT_THREAD: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
|
|
|
|
/// 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()]);
|
|
}
|
|
}
|
|
|
|
/// Start the dump thread and register the unhandled exception filter.
|
|
pub(crate) fn install_handlers() {
|
|
// SAFETY: plain Win32 calls; the events live for the whole process.
|
|
let (request, done) = unsafe {
|
|
(
|
|
CreateEventW(std::ptr::null(), FALSE, FALSE, std::ptr::null()),
|
|
CreateEventW(std::ptr::null(), FALSE, FALSE, std::ptr::null()),
|
|
)
|
|
};
|
|
if request.is_null() || done.is_null() {
|
|
return;
|
|
}
|
|
REQUEST.store(request as isize, Ordering::Release);
|
|
DONE.store(done as isize, Ordering::Release);
|
|
let spawned = std::thread::Builder::new()
|
|
.name("ghostty-exception-handler".into())
|
|
.spawn(dump_thread);
|
|
if spawned.is_err() {
|
|
// SAFETY: the handles were just created and are unused.
|
|
unsafe {
|
|
CloseHandle(request);
|
|
CloseHandle(done);
|
|
}
|
|
return;
|
|
}
|
|
// SAFETY: `filter` has the required signature and lives forever.
|
|
unsafe { SetUnhandledExceptionFilter(Some(filter)) };
|
|
}
|
|
|
|
unsafe extern "system" fn filter(info: *const EXCEPTION_POINTERS) -> i32 {
|
|
if sentry::state().is_some() && !BUSY.swap(true, Ordering::AcqRel) {
|
|
POINTERS.store(info.cast_mut(), Ordering::Release);
|
|
// SAFETY: trivial Win32 calls with handles created at install.
|
|
unsafe {
|
|
FAULT_THREAD.store(GetCurrentThreadId(), Ordering::Release);
|
|
SetEvent(REQUEST.load(Ordering::Acquire) as HANDLE);
|
|
let r = WaitForSingleObject(DONE.load(Ordering::Acquire) as HANDLE, TIMEOUT_MS);
|
|
let _ = r == WAIT_OBJECT_0;
|
|
}
|
|
}
|
|
CONTINUE_SEARCH
|
|
}
|
|
|
|
fn dump_thread() {
|
|
loop {
|
|
// SAFETY: handle created at install.
|
|
let r = unsafe { WaitForSingleObject(REQUEST.load(Ordering::Acquire) as HANDLE, INFINITE) };
|
|
if r != WAIT_OBJECT_0 {
|
|
return;
|
|
}
|
|
if let Some(st) = sentry::state() {
|
|
let info = POINTERS.load(Ordering::Acquire);
|
|
write_report(st, info, FAULT_THREAD.load(Ordering::Acquire));
|
|
}
|
|
// SAFETY: handle created at install.
|
|
unsafe { SetEvent(DONE.load(Ordering::Acquire) as HANDLE) };
|
|
}
|
|
}
|
|
|
|
fn write_report(st: &sentry::State, info: *mut EXCEPTION_POINTERS, tid: u32) {
|
|
// SAFETY: the faulting thread is blocked in the filter, so the pointers
|
|
// it passed stay valid.
|
|
let code = unsafe { info.as_ref().and_then(|i| i.ExceptionRecord.as_ref()) }
|
|
.map_or(0, |r| r.ExceptionCode) as u32;
|
|
let (kind, desc) = exception::windows_exception(code);
|
|
|
|
let dir = std::path::PathBuf::from(ghostty_os::osbytes::os_from_bytes(st.dir.clone()));
|
|
let dump = (|| -> Result<Vec<u8>, String> {
|
|
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
|
|
let tmp = dir.join(format!("minidump-{}.tmp", std::process::id()));
|
|
let mut file = File::create(&tmp).map_err(|e| e.to_string())?;
|
|
let cc = CrashContext {
|
|
exception_pointers: info.cast_const().cast(),
|
|
// SAFETY: trivial Win32 call.
|
|
process_id: unsafe { GetCurrentProcessId() },
|
|
thread_id: tid,
|
|
exception_code: code as i32,
|
|
};
|
|
let r = MinidumpWriter::dump_crash_context(&cc, None, &mut file).map_err(|e| e.to_string());
|
|
drop(file);
|
|
let data = r.and_then(|()| std::fs::read(&tmp).map_err(|e| e.to_string()));
|
|
let _ = std::fs::remove_file(&tmp);
|
|
data
|
|
})();
|
|
let dump = match dump {
|
|
Ok(d) => Some(d),
|
|
Err(e) => {
|
|
log::warn!("minidump failed err={e}");
|
|
None
|
|
}
|
|
};
|
|
|
|
let value = format!("{desc} (code=0x{code:08x})");
|
|
match sentry::write_report_ext(
|
|
st,
|
|
kind,
|
|
&value,
|
|
"exceptionfilter",
|
|
None,
|
|
None,
|
|
dump.as_deref(),
|
|
) {
|
|
Ok(path) => eprintln!(
|
|
"warning(sentry): crash report with minidump written to disk path={}",
|
|
path.display()
|
|
),
|
|
Err(e) => eprintln!("warning(sentry): failed to persist crash report err={e}"),
|
|
}
|
|
}
|