par_term/session/crash_guard.rs
1//! A panic boundary that preserves the user's tabs.
2//!
3//! # Why this exists
4//!
5//! par-term ships reachable panics. Six of them — column indices used as UTF-8
6//! byte offsets — were fixed the same day this module was written, and they were
7//! reachable with an emoji, a CJK character or an accented letter. When one
8//! fires, the process dies and takes every window's tab list, working directories
9//! and split layout with it, because the only session save runs from
10//! `WindowManager::close_window` on a clean exit.
11//!
12//! This module narrows that loss. It does **not** make par-term survive a panic.
13//!
14//! # Design: snapshot, not live serialization
15//!
16//! A panic hook runs on the panicking thread, before unwinding starts, with the
17//! program in an unknown state. Serializing the live `WindowManager` from there
18//! is not an option and would not be safe if it were:
19//!
20//! - The hook is a `'static` closure. It cannot hold a reference to the
21//! `WindowManager`, which lives on the event-loop thread's stack; reaching it
22//! would require a global raw pointer aliasing `&mut self`.
23//! - The panic may have happened *mid-mutation* of the very state we would walk.
24//! A `Vec<Tab>` observed between a `set_len` and its element writes serializes
25//! garbage or faults.
26//! - `capture_session` takes locks and calls into winit. Both can block or
27//! re-panic on a broken thread.
28//!
29//! So the event loop **publishes** a snapshot at a known-good point — a point
30//! where no par-term data structure is mid-update — and the snapshot is already
31//! serialized to YAML at that moment. The hook's entire job is then to copy a
32//! `String` that is guaranteed self-consistent to a file. It runs no serde code,
33//! walks no live structure and takes no par-term lock.
34//!
35//! Nothing is preserved until something calls
36//! `WindowManager::publish_crash_snapshot` — the hook can only write what has
37//! been published, and reports "no snapshot to write" otherwise. See that
38//! method's own documentation for where the call belongs.
39//!
40//! The cost is staleness: the recovered session is as old as the last publish
41//! (see `WindowManager::publish_crash_snapshot`). Session state changes rarely —
42//! opening a tab, changing directory, moving a window — so a few seconds of
43//! staleness costs at most one tab. Losing every tab is the alternative.
44//!
45//! # What this covers, and what it does not
46//!
47//! Covered — the crash file is written:
48//!
49//! - An ordinary `panic!`, `unwrap`, `expect`, slice-index or integer-overflow
50//! panic on the event-loop thread, including one raised deep inside a callback.
51//! This is the class the audit found reachable from keyboard input.
52//! - A panic that will subsequently `abort` because it tries to unwind through a
53//! foreign (Objective-C, C) frame. The hook runs *before* unwinding begins, so
54//! it still gets its save. A `catch_unwind` boundary would not.
55//! - A build compiled with `panic = "abort"`, for the same reason.
56//!
57//! **Not** covered — the crash file is not written, or may be incomplete:
58//!
59//! - **Aborts that never run a hook**: `SIGSEGV`, `SIGBUS`, stack overflow,
60//! `SIGKILL`, a failed allocation, or a double panic (a panic raised while a
61//! panic is already being processed aborts inside the runtime before the hook
62//! is re-entered).
63//! - **Non-main-thread panics.** A panicking spawned thread does not end the
64//! process under `panic = "unwind"`; tokio catches worker panics as a
65//! `JoinError` and the app keeps running. Writing a crash file for each of
66//! those would manufacture crash recoveries out of survivable faults. Such
67//! panics are reported to the debug log and nothing more.
68//! - **A panic taken inside the debug logger.** The report is dropped rather
69//! than written. `parking_lot` does not make the logger's mutex reentrant, so
70//! every ordinary entry point — the `debug_*!` macros and the `log` crate
71//! bridge alike, both of which end in `get_logger().lock()` — would block the
72//! hook forever here. `report_fields` therefore goes through
73//! `crate::debug::try_logf`, which walks away from a lock it cannot take.
74//! The save has already run and the chained default hook has already put the
75//! panic on stderr, so what is lost is the debug-log copy of a report the user
76//! can still see. That chained hook is somebody else's code and could block on
77//! its own account; what this module guarantees is that its *own* report step
78//! does not.
79//! - **State that is not in the snapshot.** Scrollback, shell history, running
80//! processes and anything typed but not yet run are gone. What survives is
81//! window geometry, the tab list, per-tab working directories and titles, and
82//! split-pane layout — i.e. exactly what `SessionState` holds.
83//! - **A panic before the first publish.** Nothing has been captured yet, so
84//! there is nothing to write. Startup panics lose the (empty) session.
85//! - **The panic report survives exactly one more launch.** `src/debug.rs` rolls
86//! the previous log aside to `<log>.1` before truncating, so the restart after
87//! a crash — usually seconds later — moves the report out of the way instead
88//! of erasing it. Only one generation is kept, so the launch after that does
89//! erase it. Two cases skip the roll, and neither loses a report: an empty log
90//! (`make run-debug` pipes through `tee`, which truncates the path before
91//! par-term opens it) holds nothing to preserve, and a log owned by another
92//! user is one `open_log_file` refuses to write at all (SEC-016), so no report
93//! of ours ever reached it — rolling it away would quietly convert that
94//! refusal into "log anyway, into a fresh file". The recovered *session* is
95//! unaffected either way: it lives in its own file.
96//!
97//! # The panic is not swallowed
98//!
99//! There is deliberately no `catch_unwind`. After a panic mid-render the GPU
100//! queue, the terminal grid and every lock invariant are indeterminate;
101//! `AssertUnwindSafe` would assert a property that is false. The hook saves,
102//! chains to the previous hook so the panic is still reported, and returns — and
103//! the process dies exactly as it would have.
104
105use super::SessionState;
106use arc_swap::ArcSwapOption;
107use par_term_config::Config;
108use std::panic::PanicHookInfo;
109use std::path::{Path, PathBuf};
110use std::sync::OnceLock;
111use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
112use std::thread::ThreadId;
113use std::time::{Duration, Instant};
114
115/// Name of the file the hook writes.
116///
117/// Deliberately **not** `last_session.yaml`. A process that has just panicked
118/// must never overwrite the good session file: if the snapshot were somehow bad,
119/// doing so would destroy the state it is trying to rescue. A separate name also
120/// makes "this session came back after a crash" detectable at startup without
121/// storing a flag inside the file.
122const CRASH_SESSION_FILE: &str = "crash_session.yaml";
123
124/// Debug-log category for everything this module emits.
125const CATEGORY: &str = "PANIC";
126
127/// The last known-good session, already serialized to YAML.
128///
129/// `ArcSwapOption` rather than a `Mutex`: the hook must never block. A mutex
130/// here could be held by the very thread that panicked, and the hook would
131/// deadlock instead of saving — turning a crash into a hang.
132static SNAPSHOT: ArcSwapOption<String> = ArcSwapOption::const_empty();
133
134/// The thread that called [`install`] — the winit event-loop thread.
135static MAIN_THREAD: OnceLock<ThreadId> = OnceLock::new();
136
137/// Crash-file path, resolved once at install time so the hook never has to.
138static CRASH_PATH: OnceLock<PathBuf> = OnceLock::new();
139
140/// Latch so the event-loop thread gets exactly one preservation attempt.
141static SAVE_CLAIMED: AtomicBool = AtomicBool::new(false);
142
143/// Reference point for [`LAST_PUBLISH_MS`]; set on the first publish.
144static PUBLISH_EPOCH: OnceLock<Instant> = OnceLock::new();
145
146/// Milliseconds since [`PUBLISH_EPOCH`] at the last publish; `u64::MAX` = never.
147static LAST_PUBLISH_MS: AtomicU64 = AtomicU64::new(u64::MAX);
148
149/// What the hook managed to do with the snapshot. Reported to the debug log so a
150/// crash report says whether the user's tabs were rescued.
151#[derive(Debug, Clone, Copy, PartialEq, Eq)]
152pub enum SaveOutcome {
153 /// The crash session file was written.
154 Saved,
155 /// No snapshot had been published yet (panic before the first publish, or
156 /// `restore_session` is disabled so nothing is ever captured).
157 NoSnapshot,
158 /// An earlier panic already preserved the session; this one left it alone.
159 AlreadySaved,
160 /// A snapshot existed but the write failed (disk full, read-only config dir).
161 WriteFailed,
162}
163
164impl std::fmt::Display for SaveOutcome {
165 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
166 f.write_str(match self {
167 Self::Saved => "session snapshot written",
168 Self::NoSnapshot => "no snapshot to write",
169 Self::AlreadySaved => "session already preserved by an earlier panic",
170 Self::WriteFailed => "snapshot write FAILED",
171 })
172 }
173}
174
175/// Path of the crash session file.
176pub fn crash_session_path() -> PathBuf {
177 CRASH_PATH
178 .get()
179 .cloned()
180 .unwrap_or_else(|| Config::config_dir().join(CRASH_SESSION_FILE))
181}
182
183/// Install the panic hook. Call once from `main`, after logging is initialized.
184///
185/// The calling thread is recorded as the event-loop thread: only a panic on that
186/// thread triggers a save (see the module docs for why).
187pub fn install() {
188 // Idempotent. A second call would chain our own hook to itself and report
189 // every panic twice.
190 static INSTALLED: AtomicBool = AtomicBool::new(false);
191 if !claim(&INSTALLED) {
192 return;
193 }
194
195 MAIN_THREAD.get_or_init(|| std::thread::current().id());
196 // Resolve the path now so the hook does no directory lookup and no `format!`
197 // on a broken thread.
198 CRASH_PATH.get_or_init(|| Config::config_dir().join(CRASH_SESSION_FILE));
199
200 let previous = std::panic::take_hook();
201 std::panic::set_hook(Box::new(move |info| {
202 let on_event_loop = MAIN_THREAD.get() == Some(&std::thread::current().id());
203
204 // Step 1 — preserve, before anything that can block. If the report step
205 // below hangs (see the module docs on the logger mutex), the user's tabs
206 // are already on disk.
207 let outcome = on_event_loop.then(save_crash_session);
208
209 // Step 2 — chain to the hook we replaced. The default hook writes the
210 // panic message and, when `RUST_BACKTRACE` is set, a backtrace to stderr.
211 // It runs before our own logging so the user always sees the panic even
212 // if the debug log is unreachable.
213 previous(info);
214
215 // Step 3 — the same report into the debug log, which is where crash
216 // reports are expected to be found and which survives the terminal
217 // window closing.
218 report(info, outcome);
219 }));
220
221 // Deliberately not "armed": whether a panic can actually preserve anything
222 // depends on a snapshot having been published, which this function cannot
223 // know. The hook reports the real outcome per panic instead.
224 log::info!(
225 "Panic hook installed; crash session file is {:?}",
226 crash_session_path()
227 );
228}
229
230/// Claim a one-shot latch. `true` for the first caller only.
231///
232/// The re-entrancy guard. It stops a *second* event-loop panic from overwriting
233/// the crash file — on some winit backends a panic is caught out of the platform
234/// run loop and the loop keeps dispatching, so the hook can genuinely run twice.
235/// The first snapshot is the one to keep: everything after the first panic was
236/// produced by a program in an unknown state.
237///
238/// It cannot guard a panic raised *inside* the hook. The runtime aborts on a
239/// double panic before the hook is entered again, which is why every step of the
240/// hook is kept infallible rather than relying on this.
241///
242/// Split out as a free function over an explicit latch so the behaviour is
243/// testable without provoking a real panic.
244fn claim(latch: &AtomicBool) -> bool {
245 !latch.swap(true, Ordering::AcqRel)
246}
247
248/// Record a known-good session snapshot.
249///
250/// Serializes on the caller's thread — the whole point is that no serde code
251/// runs inside the hook. Call only from a point where no par-term structure is
252/// mid-update.
253pub fn publish(state: &SessionState) {
254 let yaml = match serde_yaml_ng::to_string(state) {
255 Ok(yaml) => yaml,
256 Err(e) => {
257 // Keep the previous snapshot: a stale one beats none.
258 log::warn!("Crash guard: failed to serialize session snapshot: {e}");
259 return;
260 }
261 };
262 SNAPSHOT.store(Some(std::sync::Arc::new(yaml)));
263
264 let epoch = PUBLISH_EPOCH.get_or_init(Instant::now);
265 LAST_PUBLISH_MS.store(epoch.elapsed().as_millis() as u64, Ordering::Release);
266}
267
268/// Whether `min_interval` has elapsed since the last [`publish`].
269///
270/// `true` when nothing has been published yet.
271pub fn snapshot_is_due(min_interval: Duration) -> bool {
272 let last = LAST_PUBLISH_MS.load(Ordering::Acquire);
273 if last == u64::MAX {
274 return true;
275 }
276 let Some(epoch) = PUBLISH_EPOCH.get() else {
277 return true;
278 };
279 epoch.elapsed().as_millis() as u64 >= last.saturating_add(min_interval.as_millis() as u64)
280}
281
282/// Write the published snapshot to the crash file. Called only from the hook.
283fn save_crash_session() -> SaveOutcome {
284 // Check for a snapshot *before* claiming the latch. A panic that has nothing
285 // to save must not consume the one attempt: winit catches and re-raises
286 // panics out of the platform run loop on some backends, so an early panic —
287 // one raised before the event loop ever published — can precede the panic
288 // that actually has the user's tabs open. Burning the latch there would lose
289 // exactly the case this module exists for.
290 let Some(yaml) = SNAPSHOT.load_full() else {
291 return SaveOutcome::NoSnapshot;
292 };
293 if !claim(&SAVE_CLAIMED) {
294 return SaveOutcome::AlreadySaved;
295 }
296 write_crash_session(&crash_session_path(), &yaml)
297}
298
299/// The file-writing half of [`save_crash_session`], with the path injected.
300///
301/// Goes through `atomic_save` — the same staged-write-then-rename path every
302/// other par-term save uses. Writing from a panicking process is exactly the
303/// case a truncating write gets wrong: an interrupted `write` leaves a partial
304/// file, and every session loader reads a partial file back as "no saved
305/// session", so the user loses the data with no diagnostic at all.
306fn write_crash_session(path: &Path, yaml: &str) -> SaveOutcome {
307 match crate::atomic_save::save_string_atomic(path, yaml) {
308 Ok(()) => SaveOutcome::Saved,
309 Err(_) => SaveOutcome::WriteFailed,
310 }
311}
312
313/// Extract a panic payload without allocating.
314fn payload_str<'a>(info: &'a PanicHookInfo<'_>) -> &'a str {
315 let payload = info.payload();
316 if let Some(s) = payload.downcast_ref::<&'static str>() {
317 s
318 } else if let Some(s) = payload.downcast_ref::<String>() {
319 s.as_str()
320 } else {
321 "<non-string panic payload>"
322 }
323}
324
325/// The panic facts the hook reports.
326///
327/// Lifted out of [`PanicHookInfo`], which cannot be constructed outside a real
328/// panic, so [`report_fields`] is exercisable from a test — the same split as
329/// [`claim`], [`write_crash_session`] and [`disarm_at`].
330pub(crate) struct PanicReport<'a> {
331 pub(crate) thread_name: &'a str,
332 pub(crate) file: &'a str,
333 pub(crate) line: u32,
334 pub(crate) column: u32,
335 pub(crate) payload: &'a str,
336 /// `None` when no save was attempted (a panic off the event-loop thread, or
337 /// a second panic after the latch was claimed).
338 pub(crate) outcome: Option<SaveOutcome>,
339}
340
341/// Write the panic to the debug log.
342fn report(info: &PanicHookInfo<'_>, outcome: Option<SaveOutcome>) {
343 let thread = std::thread::current();
344 let (file, line, column) = info
345 .location()
346 .map(|l| (l.file(), l.line(), l.column()))
347 .unwrap_or(("<unknown>", 0, 0));
348
349 report_fields(PanicReport {
350 thread_name: thread.name().unwrap_or("<unnamed>"),
351 file,
352 line,
353 column,
354 payload: payload_str(info),
355 outcome,
356 });
357}
358
359/// The reporting half of [`report`], over plain data.
360///
361/// Every line goes through [`crate::debug::try_logf`], and that is the whole
362/// point: the ordinary entry points — the `debug_*!` macros *and* `log::error!`,
363/// which reaches the same place through the bridge in `src/debug.rs` — all end
364/// in `get_logger().lock()`, which `parking_lot` does not make reentrant. A
365/// panic taken inside the logger would hang the hook on any one of them.
366/// `try_logf` drops the line instead.
367///
368/// Two consequences worth stating rather than papering over. The `log` crate
369/// destination is gone from this path, so a panic no longer reaches a `log`
370/// subscriber; the chained default hook has already written the panic to stderr
371/// by the time this runs, which is what the user actually sees. And a report
372/// that loses its race for the mutex is simply not written — dropping a
373/// diagnostic beats hanging a process that has already saved the session.
374pub(crate) fn report_fields(r: PanicReport<'_>) {
375 let PanicReport {
376 thread_name,
377 file,
378 line,
379 column,
380 payload,
381 outcome,
382 } = r;
383
384 crate::debug::try_logf(
385 crate::debug::DebugLevel::Error,
386 CATEGORY,
387 format_args!("PANIC on thread '{thread_name}' at {file}:{line}:{column}: {payload}"),
388 );
389
390 match outcome {
391 Some(outcome) => {
392 crate::debug::try_logf(
393 crate::debug::DebugLevel::Error,
394 CATEGORY,
395 format_args!(
396 "Panic boundary: {outcome} ({:?}). Restart par-term to recover the session.",
397 crash_session_path()
398 ),
399 );
400 }
401 None => {
402 crate::debug::try_logf(
403 crate::debug::DebugLevel::Error,
404 CATEGORY,
405 format_args!(
406 "Panic boundary: no save attempted — this panic is off the event-loop \
407 thread, and a spawned thread's panic does not end the process"
408 ),
409 );
410 }
411 }
412
413 // Opt-in via RUST_BACKTRACE, matching the default hook. `force_capture`
414 // would allocate and symbolize on every panic, which is the opposite of what
415 // a hook on a broken thread should do.
416 let backtrace = std::backtrace::Backtrace::capture();
417 if backtrace.status() == std::backtrace::BacktraceStatus::Captured {
418 crate::debug::try_logf(
419 crate::debug::DebugLevel::Error,
420 CATEGORY,
421 format_args!("PANIC backtrace:\n{backtrace}"),
422 );
423 }
424}
425
426/// Load the crash session file and delete it.
427///
428/// Returns `None` when there is no crash file, when it holds no windows, or when
429/// it cannot be parsed. The file is removed in every case: a crash file that
430/// fails to parse must not be retried on every subsequent launch.
431///
432/// A `Some` always carries at least one window, so a caller can treat it as
433/// strictly better than the normal session file and fall back cleanly otherwise.
434pub fn take_crash_session() -> Option<SessionState> {
435 take_crash_session_from(&crash_session_path())
436}
437
438/// [`take_crash_session`] with the path injected, for tests.
439fn take_crash_session_from(path: &Path) -> Option<SessionState> {
440 if !path.exists() {
441 return None;
442 }
443
444 let loaded = super::storage::load_session_from(path.to_path_buf());
445
446 if let Err(e) = std::fs::remove_file(path) {
447 log::warn!("Failed to remove crash session file {path:?}: {e}");
448 }
449
450 match loaded {
451 // A window-less crash file restores nothing, so report it as absent and
452 // let the caller fall back to the normal session file. `publish` never
453 // writes one; a hand-edited or truncated file still can.
454 Ok(session) => session.filter(|s| !s.windows.is_empty()),
455 Err(e) => {
456 log::warn!("Crash session file {path:?} could not be parsed, discarding: {e:#}");
457 None
458 }
459 }
460}
461
462/// Drop the snapshot and remove any crash file. Call on a clean exit.
463///
464/// Without this a crash file written during one run would still be on disk after
465/// the next clean shutdown, and the launch after that would announce a crash
466/// recovery that never happened.
467///
468/// Refuses to delete a crash file that *this* run's panic hook wrote. Reaching a
469/// clean exit is not proof no panic happened: winit catches panics out of the
470/// platform run loop on some backends, so `EventLoop::run_app` can return
471/// normally after the hook has already preserved the session. Deleting the file
472/// there would destroy the rescue on exactly the path this module exists for.
473pub fn disarm() {
474 SNAPSHOT.store(None);
475 disarm_at(&crash_session_path(), SAVE_CLAIMED.load(Ordering::Acquire));
476}
477
478/// [`disarm`] with the path and the "a panic was preserved" flag injected.
479fn disarm_at(path: &Path, panic_preserved: bool) {
480 if panic_preserved {
481 log::warn!(
482 "Clean exit after a preserved panic: keeping {path:?} so the next \
483 launch can recover the session"
484 );
485 return;
486 }
487
488 if path.exists()
489 && let Err(e) = std::fs::remove_file(path)
490 {
491 log::warn!("Failed to remove crash session file {path:?} on clean exit: {e}");
492 }
493}
494
495#[cfg(test)]
496mod tests {
497 use super::*;
498 use crate::session::{SessionTab, SessionWindow};
499 use par_term_config::snapshot_types::TabSnapshot;
500 use tempfile::tempdir;
501
502 fn sample_session(cwd: &str) -> SessionState {
503 SessionState {
504 saved_at: "2026-07-30T12:00:00Z".to_string(),
505 windows: vec![SessionWindow {
506 position: (10, 20),
507 size: (1280, 800),
508 tabs: vec![SessionTab {
509 snapshot: TabSnapshot {
510 cwd: Some(cwd.to_string()),
511 title: "work".to_string(),
512 custom_color: None,
513 user_title: None,
514 custom_icon: None,
515 },
516 pane_layout: None,
517 }],
518 active_tab_index: 0,
519 tmux_session_name: None,
520 }],
521 }
522 }
523
524 /// The latch the hook uses: the first caller gets the save, nobody else.
525 #[test]
526 fn claim_admits_exactly_one_caller() {
527 let latch = AtomicBool::new(false);
528 assert!(claim(&latch), "first claim must succeed");
529 assert!(!claim(&latch), "second claim must be refused");
530 assert!(!claim(&latch), "and stay refused");
531 }
532
533 /// The latch is a real atomic swap, not a load-then-store that two racing
534 /// callers could both win.
535 #[test]
536 fn claim_admits_one_caller_under_contention() {
537 use std::sync::Arc;
538 use std::sync::atomic::AtomicUsize;
539
540 let latch = Arc::new(AtomicBool::new(false));
541 let winners = Arc::new(AtomicUsize::new(0));
542
543 let handles: Vec<_> = (0..8)
544 .map(|_| {
545 let latch = Arc::clone(&latch);
546 let winners = Arc::clone(&winners);
547 std::thread::spawn(move || {
548 if claim(&latch) {
549 winners.fetch_add(1, Ordering::Relaxed);
550 }
551 })
552 })
553 .collect();
554 for h in handles {
555 h.join().expect("worker thread");
556 }
557
558 assert_eq!(winners.load(Ordering::Relaxed), 1);
559 }
560
561 /// The whole preservation path with the path injected: what the hook writes
562 /// is a real session file that the normal loader accepts.
563 #[test]
564 fn hook_write_produces_a_loadable_session_file() {
565 let temp = tempdir().expect("tempdir");
566 let path = temp.path().join(CRASH_SESSION_FILE);
567
568 let state = sample_session("/home/user/project");
569 let yaml = serde_yaml_ng::to_string(&state).expect("serialize");
570
571 assert_eq!(write_crash_session(&path, &yaml), SaveOutcome::Saved);
572 assert!(path.exists());
573
574 let loaded = crate::session::storage::load_session_from(path)
575 .expect("load")
576 .expect("some session");
577 assert_eq!(loaded.windows.len(), 1);
578 assert_eq!(loaded.windows[0].size, (1280, 800));
579 assert_eq!(
580 loaded.windows[0].tabs[0].snapshot.cwd.as_deref(),
581 Some("/home/user/project")
582 );
583 }
584
585 /// The crash file records working directories, so it lands at 0600 like
586 /// every other par-term save regardless of umask.
587 #[cfg(unix)]
588 #[test]
589 fn crash_file_is_owner_only() {
590 use std::os::unix::fs::PermissionsExt;
591
592 let temp = tempdir().expect("tempdir");
593 let path = temp.path().join(CRASH_SESSION_FILE);
594 let yaml = serde_yaml_ng::to_string(&sample_session("/tmp")).expect("serialize");
595
596 assert_eq!(write_crash_session(&path, &yaml), SaveOutcome::Saved);
597
598 let mode = std::fs::metadata(&path)
599 .expect("metadata")
600 .permissions()
601 .mode()
602 & 0o777;
603 assert_eq!(mode, 0o600, "expected 0600, got {mode:o}");
604 }
605
606 /// A write to an unwritable path is reported, not panicked on — the hook
607 /// must stay infallible.
608 #[test]
609 fn write_failure_is_reported_not_panicked() {
610 let temp = tempdir().expect("tempdir");
611 // A directory where the file should be makes the rename fail.
612 let path = temp.path().join("blocked");
613 std::fs::create_dir(&path).expect("blocking directory");
614
615 assert_eq!(
616 write_crash_session(&path, "saved_at: x\nwindows: []\n"),
617 SaveOutcome::WriteFailed
618 );
619 }
620
621 #[test]
622 fn take_consumes_the_crash_file() {
623 let temp = tempdir().expect("tempdir");
624 let path = temp.path().join(CRASH_SESSION_FILE);
625 let yaml = serde_yaml_ng::to_string(&sample_session("/home/user/x")).expect("serialize");
626 write_crash_session(&path, &yaml);
627
628 let taken = take_crash_session_from(&path).expect("crash session");
629 assert_eq!(
630 taken.windows[0].tabs[0].snapshot.cwd.as_deref(),
631 Some("/home/user/x")
632 );
633 assert!(
634 !path.exists(),
635 "crash file must be consumed, not left behind"
636 );
637 }
638
639 #[test]
640 fn take_returns_none_when_there_is_no_crash_file() {
641 let temp = tempdir().expect("tempdir");
642 assert!(take_crash_session_from(&temp.path().join("absent.yaml")).is_none());
643 }
644
645 /// A window-less crash file must read as "no crash session" so the caller
646 /// falls back to the normal session file instead of restoring nothing.
647 #[test]
648 fn take_treats_a_window_less_crash_file_as_absent() {
649 let temp = tempdir().expect("tempdir");
650 let path = temp.path().join(CRASH_SESSION_FILE);
651 std::fs::write(&path, "saved_at: '2026-07-30T12:00:00Z'\nwindows: []\n").expect("write");
652
653 assert!(take_crash_session_from(&path).is_none());
654 assert!(!path.exists());
655 }
656
657 /// A crash file that cannot be parsed is discarded rather than retried on
658 /// every launch for the rest of time.
659 #[test]
660 fn take_discards_and_removes_a_corrupt_crash_file() {
661 let temp = tempdir().expect("tempdir");
662 let path = temp.path().join(CRASH_SESSION_FILE);
663 std::fs::write(&path, "not: valid: yaml: [[[").expect("write");
664
665 assert!(take_crash_session_from(&path).is_none());
666 assert!(!path.exists(), "a corrupt crash file must still be removed");
667 }
668
669 #[test]
670 fn disarm_removes_a_crash_file_left_by_an_earlier_run() {
671 let temp = tempdir().expect("tempdir");
672 let path = temp.path().join(CRASH_SESSION_FILE);
673 std::fs::write(&path, "saved_at: x\nwindows: []\n").expect("write");
674
675 disarm_at(&path, false);
676 assert!(!path.exists());
677
678 // Idempotent: a clean exit with no crash file must not error.
679 disarm_at(&path, false);
680 }
681
682 /// The case that would silently undo the whole feature: winit can return
683 /// from `run_app` after a panic the hook already preserved, and the clean-exit
684 /// path must not then delete the rescue.
685 #[test]
686 fn disarm_keeps_a_crash_file_this_run_wrote() {
687 let temp = tempdir().expect("tempdir");
688 let path = temp.path().join(CRASH_SESSION_FILE);
689 std::fs::write(&path, "saved_at: x\nwindows: []\n").expect("write");
690
691 disarm_at(&path, true);
692 assert!(
693 path.exists(),
694 "a crash file written by this run's panic hook must survive a later clean exit"
695 );
696 }
697
698 /// The publish → snapshot → write chain over the real globals, plus the
699 /// throttle that gates it.
700 ///
701 /// The only test that touches the process-wide `SNAPSHOT` and
702 /// `LAST_PUBLISH_MS`; keeping it to one test is what makes the others
703 /// order-independent under the default parallel runner.
704 #[test]
705 fn publishing_arms_the_hook_and_throttles_the_next_capture() {
706 let temp = tempdir().expect("tempdir");
707 let path = temp.path().join(CRASH_SESSION_FILE);
708
709 // Nothing captured yet is always due, whatever the interval — a long
710 // interval must not starve the very first capture.
711 assert!(snapshot_is_due(Duration::from_secs(3600)) || SNAPSHOT.load().is_some());
712
713 publish(&sample_session("/home/user/published"));
714
715 let yaml = SNAPSHOT.load_full().expect("a snapshot was published");
716 assert_eq!(write_crash_session(&path, &yaml), SaveOutcome::Saved);
717
718 let loaded = crate::session::storage::load_session_from(path)
719 .expect("load")
720 .expect("some session");
721 assert_eq!(
722 loaded.windows[0].tabs[0].snapshot.cwd.as_deref(),
723 Some("/home/user/published")
724 );
725
726 assert!(
727 !snapshot_is_due(Duration::from_secs(3600)),
728 "an hour-long interval must suppress a capture taken just now"
729 );
730 assert!(
731 snapshot_is_due(Duration::ZERO),
732 "a zero interval is always due"
733 );
734 }
735}