pub mod actions;
pub mod rescue;
pub mod report;
use std::path::PathBuf;
use std::sync::{Mutex, OnceLock};
pub use actions::{ActionRecord, ActionRing};
#[allow(unused_imports)]
pub use rescue::{DirtyMirror, RescueOutcome};
pub use report::CrashReport;
pub const ACTION_RING_CAP: usize = 50;
pub struct CrashContext {
inner: Mutex<CrashState>,
}
#[derive(Default, Debug, Clone)]
pub struct CrashState {
pub project_path: Option<PathBuf>,
pub open_book: Option<String>,
pub open_paragraph: Option<String>,
pub open_paragraph_rel_path: Option<String>,
pub actions: ActionRing,
pub dirty_buffers: std::collections::HashMap<String, DirtyMirror>,
}
static CONTEXT: OnceLock<CrashContext> = OnceLock::new();
pub fn context() -> &'static CrashContext {
CONTEXT.get_or_init(|| CrashContext {
inner: Mutex::new(CrashState::default()),
})
}
impl CrashContext {
pub fn set_project(&self, path: PathBuf) {
if let Ok(mut s) = self.inner.lock() {
s.project_path = Some(path);
}
}
pub fn set_open_paragraph(
&self,
book: Option<String>,
paragraph: Option<String>,
rel_path: Option<String>,
) {
if let Ok(mut s) = self.inner.lock() {
s.open_book = book;
s.open_paragraph = paragraph;
s.open_paragraph_rel_path = rel_path;
}
}
pub fn push_action(&self, action: ActionRecord) {
if let Ok(mut s) = self.inner.lock() {
s.actions.push(action);
}
}
pub fn mirror_buffer(&self, rel_path: String, mirror: DirtyMirror) {
if let Ok(mut s) = self.inner.lock() {
s.dirty_buffers.insert(rel_path, mirror);
}
}
pub fn clear_mirror(&self, rel_path: &str) {
if let Ok(mut s) = self.inner.lock() {
s.dirty_buffers.remove(rel_path);
}
}
pub fn snapshot(&self) -> Option<CrashState> {
self.inner.lock().ok().map(|s| s.clone())
}
}
type TerminalRestore = Box<dyn Fn() + Send + Sync + 'static>;
static TERMINAL_RESTORE: OnceLock<Mutex<Option<TerminalRestore>>> = OnceLock::new();
fn terminal_restore_slot() -> &'static Mutex<Option<TerminalRestore>> {
TERMINAL_RESTORE.get_or_init(|| Mutex::new(None))
}
pub fn set_terminal_restore(restore: Option<TerminalRestore>) {
if let Ok(mut slot) = terminal_restore_slot().lock() {
*slot = restore;
}
}
pub(crate) fn is_broken_pipe_panic(msg: &str) -> bool {
msg.contains("failed printing to std")
&& (msg.contains("Broken pipe") || msg.contains("os error 32"))
}
pub fn install_panic_hook() {
let previous = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
let payload = info.payload();
let msg = payload
.downcast_ref::<&str>()
.copied()
.or_else(|| payload.downcast_ref::<String>().map(String::as_str))
.unwrap_or("");
if is_broken_pipe_panic(msg) {
if let Ok(slot) = terminal_restore_slot().lock() {
if let Some(restore) = slot.as_ref() {
restore();
}
}
std::process::exit(0);
}
if let Ok(slot) = terminal_restore_slot().lock() {
if let Some(restore) = slot.as_ref() {
restore();
}
}
let state = context().snapshot().unwrap_or_default();
let rescue_outcomes =
rescue::flush_dirty_buffers(state.project_path.as_deref(), &state.dirty_buffers);
let report = CrashReport::capture(info, &state, &rescue_outcomes);
let report_path = report_target_path();
let write_result = report.write_atomic(&report_path);
match write_result {
Ok(()) => {
eprintln!(
"\ninkhaven crashed — crash report written to {}",
report_path.display()
);
if !rescue_outcomes.is_empty() {
eprintln!(
" {} unsaved buffer(s) rescued. Run `inkhaven recover {}` to restore.",
rescue_outcomes.len(),
report_path.display()
);
}
}
Err(e) => {
eprintln!(
"\ninkhaven crashed — could not write crash report ({e})",
);
}
}
previous(info);
}));
}
fn report_target_path() -> PathBuf {
let stem = format!(
"inkhaven-crash-{}.hjson",
chrono::Utc::now().format("%Y%m%dT%H%M%S"),
);
std::env::current_dir()
.unwrap_or_else(|_| std::env::temp_dir())
.join(stem)
}
pub(crate) fn write_atomic(target: &std::path::Path, body: &[u8]) -> std::io::Result<()> {
crate::io_atomic::write(target, body)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn broken_pipe_panic_detected_narrowly() {
assert!(is_broken_pipe_panic(
"failed printing to stdout: Broken pipe (os error 32)"
));
assert!(is_broken_pipe_panic(
"failed printing to stderr: Broken pipe (os error 32)"
));
assert!(!is_broken_pipe_panic(
"failed printing to stdout: No space left on device (os error 28)"
));
assert!(!is_broken_pipe_panic(
"index out of bounds: the len is 3 but the index is 5"
));
assert!(!is_broken_pipe_panic("subprocess died: Broken pipe"));
}
#[test]
fn context_starts_empty() {
let c = context();
let _ = c.snapshot();
}
#[test]
fn set_project_persists_in_snapshot() {
let c = context();
c.set_project(PathBuf::from("/tmp/inkhaven-test-project"));
let snap = c.snapshot().expect("snapshot succeeds");
assert_eq!(
snap.project_path.as_deref(),
Some(std::path::Path::new("/tmp/inkhaven-test-project"))
);
}
#[test]
fn write_atomic_creates_target_and_removes_tmp() {
let tmp_dir = std::env::temp_dir().join(format!(
"inkhaven-crash-test-{}",
std::process::id()
));
std::fs::create_dir_all(&tmp_dir).unwrap();
let target = tmp_dir.join("hello.txt");
super::write_atomic(&target, b"hello world\n").expect("atomic write succeeds");
assert!(target.exists(), "target file should exist");
assert!(
!target.with_extension("txt.tmp").exists(),
"tmp file should have been renamed away"
);
let body = std::fs::read_to_string(&target).unwrap();
assert_eq!(body, "hello world\n");
let _ = std::fs::remove_dir_all(&tmp_dir);
}
#[test]
fn target_path_has_inkhaven_crash_prefix() {
let p = super::report_target_path();
let name = p.file_name().unwrap().to_string_lossy().into_owned();
assert!(
name.starts_with("inkhaven-crash-"),
"name = {name}"
);
assert!(name.ends_with(".hjson"), "name = {name}");
}
}