Skip to main content

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}