use super::SessionState;
use arc_swap::ArcSwapOption;
use par_term_config::Config;
use std::panic::PanicHookInfo;
use std::path::{Path, PathBuf};
use std::sync::OnceLock;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::thread::ThreadId;
use std::time::{Duration, Instant};
const CRASH_SESSION_FILE: &str = "crash_session.yaml";
const CATEGORY: &str = "PANIC";
static SNAPSHOT: ArcSwapOption<String> = ArcSwapOption::const_empty();
static MAIN_THREAD: OnceLock<ThreadId> = OnceLock::new();
static CRASH_PATH: OnceLock<PathBuf> = OnceLock::new();
static SAVE_CLAIMED: AtomicBool = AtomicBool::new(false);
static PUBLISH_EPOCH: OnceLock<Instant> = OnceLock::new();
static LAST_PUBLISH_MS: AtomicU64 = AtomicU64::new(u64::MAX);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SaveOutcome {
Saved,
NoSnapshot,
AlreadySaved,
WriteFailed,
}
impl std::fmt::Display for SaveOutcome {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
Self::Saved => "session snapshot written",
Self::NoSnapshot => "no snapshot to write",
Self::AlreadySaved => "session already preserved by an earlier panic",
Self::WriteFailed => "snapshot write FAILED",
})
}
}
pub fn crash_session_path() -> PathBuf {
CRASH_PATH
.get()
.cloned()
.unwrap_or_else(|| Config::config_dir().join(CRASH_SESSION_FILE))
}
pub fn install() {
static INSTALLED: AtomicBool = AtomicBool::new(false);
if !claim(&INSTALLED) {
return;
}
MAIN_THREAD.get_or_init(|| std::thread::current().id());
CRASH_PATH.get_or_init(|| Config::config_dir().join(CRASH_SESSION_FILE));
let previous = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
let on_event_loop = MAIN_THREAD.get() == Some(&std::thread::current().id());
let outcome = on_event_loop.then(save_crash_session);
previous(info);
report(info, outcome);
}));
log::info!(
"Panic hook installed; crash session file is {:?}",
crash_session_path()
);
}
fn claim(latch: &AtomicBool) -> bool {
!latch.swap(true, Ordering::AcqRel)
}
pub fn publish(state: &SessionState) {
let yaml = match serde_yaml_ng::to_string(state) {
Ok(yaml) => yaml,
Err(e) => {
log::warn!("Crash guard: failed to serialize session snapshot: {e}");
return;
}
};
SNAPSHOT.store(Some(std::sync::Arc::new(yaml)));
let epoch = PUBLISH_EPOCH.get_or_init(Instant::now);
LAST_PUBLISH_MS.store(epoch.elapsed().as_millis() as u64, Ordering::Release);
}
pub fn snapshot_is_due(min_interval: Duration) -> bool {
let last = LAST_PUBLISH_MS.load(Ordering::Acquire);
if last == u64::MAX {
return true;
}
let Some(epoch) = PUBLISH_EPOCH.get() else {
return true;
};
epoch.elapsed().as_millis() as u64 >= last.saturating_add(min_interval.as_millis() as u64)
}
fn save_crash_session() -> SaveOutcome {
let Some(yaml) = SNAPSHOT.load_full() else {
return SaveOutcome::NoSnapshot;
};
if !claim(&SAVE_CLAIMED) {
return SaveOutcome::AlreadySaved;
}
write_crash_session(&crash_session_path(), &yaml)
}
fn write_crash_session(path: &Path, yaml: &str) -> SaveOutcome {
match crate::atomic_save::save_string_atomic(path, yaml) {
Ok(()) => SaveOutcome::Saved,
Err(_) => SaveOutcome::WriteFailed,
}
}
fn payload_str<'a>(info: &'a PanicHookInfo<'_>) -> &'a str {
let payload = info.payload();
if let Some(s) = payload.downcast_ref::<&'static str>() {
s
} else if let Some(s) = payload.downcast_ref::<String>() {
s.as_str()
} else {
"<non-string panic payload>"
}
}
pub(crate) struct PanicReport<'a> {
pub(crate) thread_name: &'a str,
pub(crate) file: &'a str,
pub(crate) line: u32,
pub(crate) column: u32,
pub(crate) payload: &'a str,
pub(crate) outcome: Option<SaveOutcome>,
}
fn report(info: &PanicHookInfo<'_>, outcome: Option<SaveOutcome>) {
let thread = std::thread::current();
let (file, line, column) = info
.location()
.map(|l| (l.file(), l.line(), l.column()))
.unwrap_or(("<unknown>", 0, 0));
report_fields(PanicReport {
thread_name: thread.name().unwrap_or("<unnamed>"),
file,
line,
column,
payload: payload_str(info),
outcome,
});
}
pub(crate) fn report_fields(r: PanicReport<'_>) {
let PanicReport {
thread_name,
file,
line,
column,
payload,
outcome,
} = r;
crate::debug::try_logf(
crate::debug::DebugLevel::Error,
CATEGORY,
format_args!("PANIC on thread '{thread_name}' at {file}:{line}:{column}: {payload}"),
);
match outcome {
Some(outcome) => {
crate::debug::try_logf(
crate::debug::DebugLevel::Error,
CATEGORY,
format_args!(
"Panic boundary: {outcome} ({:?}). Restart par-term to recover the session.",
crash_session_path()
),
);
}
None => {
crate::debug::try_logf(
crate::debug::DebugLevel::Error,
CATEGORY,
format_args!(
"Panic boundary: no save attempted — this panic is off the event-loop \
thread, and a spawned thread's panic does not end the process"
),
);
}
}
let backtrace = std::backtrace::Backtrace::capture();
if backtrace.status() == std::backtrace::BacktraceStatus::Captured {
crate::debug::try_logf(
crate::debug::DebugLevel::Error,
CATEGORY,
format_args!("PANIC backtrace:\n{backtrace}"),
);
}
}
pub fn take_crash_session() -> Option<SessionState> {
take_crash_session_from(&crash_session_path())
}
fn take_crash_session_from(path: &Path) -> Option<SessionState> {
if !path.exists() {
return None;
}
let loaded = super::storage::load_session_from(path.to_path_buf());
if let Err(e) = std::fs::remove_file(path) {
log::warn!("Failed to remove crash session file {path:?}: {e}");
}
match loaded {
Ok(session) => session.filter(|s| !s.windows.is_empty()),
Err(e) => {
log::warn!("Crash session file {path:?} could not be parsed, discarding: {e:#}");
None
}
}
}
pub fn disarm() {
SNAPSHOT.store(None);
disarm_at(&crash_session_path(), SAVE_CLAIMED.load(Ordering::Acquire));
}
fn disarm_at(path: &Path, panic_preserved: bool) {
if panic_preserved {
log::warn!(
"Clean exit after a preserved panic: keeping {path:?} so the next \
launch can recover the session"
);
return;
}
if path.exists()
&& let Err(e) = std::fs::remove_file(path)
{
log::warn!("Failed to remove crash session file {path:?} on clean exit: {e}");
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::session::{SessionTab, SessionWindow};
use par_term_config::snapshot_types::TabSnapshot;
use tempfile::tempdir;
fn sample_session(cwd: &str) -> SessionState {
SessionState {
saved_at: "2026-07-30T12:00:00Z".to_string(),
windows: vec![SessionWindow {
position: (10, 20),
size: (1280, 800),
tabs: vec![SessionTab {
snapshot: TabSnapshot {
cwd: Some(cwd.to_string()),
title: "work".to_string(),
custom_color: None,
user_title: None,
custom_icon: None,
},
pane_layout: None,
}],
active_tab_index: 0,
tmux_session_name: None,
}],
}
}
#[test]
fn claim_admits_exactly_one_caller() {
let latch = AtomicBool::new(false);
assert!(claim(&latch), "first claim must succeed");
assert!(!claim(&latch), "second claim must be refused");
assert!(!claim(&latch), "and stay refused");
}
#[test]
fn claim_admits_one_caller_under_contention() {
use std::sync::Arc;
use std::sync::atomic::AtomicUsize;
let latch = Arc::new(AtomicBool::new(false));
let winners = Arc::new(AtomicUsize::new(0));
let handles: Vec<_> = (0..8)
.map(|_| {
let latch = Arc::clone(&latch);
let winners = Arc::clone(&winners);
std::thread::spawn(move || {
if claim(&latch) {
winners.fetch_add(1, Ordering::Relaxed);
}
})
})
.collect();
for h in handles {
h.join().expect("worker thread");
}
assert_eq!(winners.load(Ordering::Relaxed), 1);
}
#[test]
fn hook_write_produces_a_loadable_session_file() {
let temp = tempdir().expect("tempdir");
let path = temp.path().join(CRASH_SESSION_FILE);
let state = sample_session("/home/user/project");
let yaml = serde_yaml_ng::to_string(&state).expect("serialize");
assert_eq!(write_crash_session(&path, &yaml), SaveOutcome::Saved);
assert!(path.exists());
let loaded = crate::session::storage::load_session_from(path)
.expect("load")
.expect("some session");
assert_eq!(loaded.windows.len(), 1);
assert_eq!(loaded.windows[0].size, (1280, 800));
assert_eq!(
loaded.windows[0].tabs[0].snapshot.cwd.as_deref(),
Some("/home/user/project")
);
}
#[cfg(unix)]
#[test]
fn crash_file_is_owner_only() {
use std::os::unix::fs::PermissionsExt;
let temp = tempdir().expect("tempdir");
let path = temp.path().join(CRASH_SESSION_FILE);
let yaml = serde_yaml_ng::to_string(&sample_session("/tmp")).expect("serialize");
assert_eq!(write_crash_session(&path, &yaml), SaveOutcome::Saved);
let mode = std::fs::metadata(&path)
.expect("metadata")
.permissions()
.mode()
& 0o777;
assert_eq!(mode, 0o600, "expected 0600, got {mode:o}");
}
#[test]
fn write_failure_is_reported_not_panicked() {
let temp = tempdir().expect("tempdir");
let path = temp.path().join("blocked");
std::fs::create_dir(&path).expect("blocking directory");
assert_eq!(
write_crash_session(&path, "saved_at: x\nwindows: []\n"),
SaveOutcome::WriteFailed
);
}
#[test]
fn take_consumes_the_crash_file() {
let temp = tempdir().expect("tempdir");
let path = temp.path().join(CRASH_SESSION_FILE);
let yaml = serde_yaml_ng::to_string(&sample_session("/home/user/x")).expect("serialize");
write_crash_session(&path, &yaml);
let taken = take_crash_session_from(&path).expect("crash session");
assert_eq!(
taken.windows[0].tabs[0].snapshot.cwd.as_deref(),
Some("/home/user/x")
);
assert!(
!path.exists(),
"crash file must be consumed, not left behind"
);
}
#[test]
fn take_returns_none_when_there_is_no_crash_file() {
let temp = tempdir().expect("tempdir");
assert!(take_crash_session_from(&temp.path().join("absent.yaml")).is_none());
}
#[test]
fn take_treats_a_window_less_crash_file_as_absent() {
let temp = tempdir().expect("tempdir");
let path = temp.path().join(CRASH_SESSION_FILE);
std::fs::write(&path, "saved_at: '2026-07-30T12:00:00Z'\nwindows: []\n").expect("write");
assert!(take_crash_session_from(&path).is_none());
assert!(!path.exists());
}
#[test]
fn take_discards_and_removes_a_corrupt_crash_file() {
let temp = tempdir().expect("tempdir");
let path = temp.path().join(CRASH_SESSION_FILE);
std::fs::write(&path, "not: valid: yaml: [[[").expect("write");
assert!(take_crash_session_from(&path).is_none());
assert!(!path.exists(), "a corrupt crash file must still be removed");
}
#[test]
fn disarm_removes_a_crash_file_left_by_an_earlier_run() {
let temp = tempdir().expect("tempdir");
let path = temp.path().join(CRASH_SESSION_FILE);
std::fs::write(&path, "saved_at: x\nwindows: []\n").expect("write");
disarm_at(&path, false);
assert!(!path.exists());
disarm_at(&path, false);
}
#[test]
fn disarm_keeps_a_crash_file_this_run_wrote() {
let temp = tempdir().expect("tempdir");
let path = temp.path().join(CRASH_SESSION_FILE);
std::fs::write(&path, "saved_at: x\nwindows: []\n").expect("write");
disarm_at(&path, true);
assert!(
path.exists(),
"a crash file written by this run's panic hook must survive a later clean exit"
);
}
#[test]
fn publishing_arms_the_hook_and_throttles_the_next_capture() {
let temp = tempdir().expect("tempdir");
let path = temp.path().join(CRASH_SESSION_FILE);
assert!(snapshot_is_due(Duration::from_secs(3600)) || SNAPSHOT.load().is_some());
publish(&sample_session("/home/user/published"));
let yaml = SNAPSHOT.load_full().expect("a snapshot was published");
assert_eq!(write_crash_session(&path, &yaml), SaveOutcome::Saved);
let loaded = crate::session::storage::load_session_from(path)
.expect("load")
.expect("some session");
assert_eq!(
loaded.windows[0].tabs[0].snapshot.cwd.as_deref(),
Some("/home/user/published")
);
assert!(
!snapshot_is_due(Duration::from_secs(3600)),
"an hour-long interval must suppress a capture taken just now"
);
assert!(
snapshot_is_due(Duration::ZERO),
"a zero interval is always due"
);
}
}