Skip to main content

memscope_rs/
lifecycle.rs

1//! Lifecycle orchestration for the auto-export subsystem.
2//!
3//! This is module 3 of the 4-module auto-export feature. It provides the
4//! single idempotent [`export_once`] entry point that every exit path (the
5//! Drop guard, the panic hook, the Ctrl-C handler, and the `atexit` callback)
6//! funnels into, plus the one-time installation logic for those hooks.
7//!
8//! # Why this module is critical for `panic = "abort"` release builds
9//!
10//! `memscope-rs` release profiles set `panic = "abort"`. Under abort semantics
11//! Rust destructors do NOT run when a panic occurs, so a plain Drop guard
12//! cannot flush a report on panic. The Rust runtime DOES invoke the registered
13//! panic hook before aborting, however, so installing a panic hook that calls
14//! [`export_once`] is the only mechanism that produces a report on a release
15//! panic. The hook is therefore installed unconditionally (not gated behind a
16//! feature flag) and is carefully re-entrancy-safe: [`export_once`] swaps
17//! [`EXPORTED`] to `true` BEFORE doing any work, so a panic raised by the
18//! export itself cannot cause the re-entrant panic-hook invocation to loop.
19//!
20//! # Concurrency model
21//!
22//! - [`EXPORTED`] is an [`AtomicBool`] used as a once-only latch. It is set
23//!   via `swap(true, SeqCst)` before the export work begins so that re-entrant
24//!   invocations (e.g. a panic inside the panic hook) see it already set and
25//!   return immediately.
26//! - [`TRACKER_HANDLE`] and [`AUTO_EXPORT_CFG`] live in `parking_lot::RwLock`
27//!   (not `OnceLock`) so tests can reset them between cases. The exit-path
28//!   handlers clone the values out and drop the read guards before the
29//!   potentially slow file I/O, so no lock is held across the export.
30//! - [`HOOKS_INSTALLED`] is a `std::sync::Once` that guarantees the panic /
31//!   ctrlc / atexit hooks are registered exactly once per process.
32
33use std::cell::Cell;
34use std::sync::atomic::{AtomicBool, Ordering};
35use std::sync::{Arc, Once};
36use std::time::Duration;
37
38use parking_lot::{Mutex, RwLock};
39use tracing::{error, info, warn};
40
41use crate::auto_export::{AutoExportConfig, SignalPolicy};
42use crate::capture::backends::global_tracking::GlobalTracker;
43use crate::core::{MemScopeError, MemScopeResult};
44
45/// Idempotency guard: once an exit-path export has run, no other exit path
46/// re-exports. Set to `true` BEFORE the export work begins (via `swap`) so a
47/// panic during export does not clear it and cause re-entrant export inside
48/// the panic hook (which would recurse without this guard).
49static EXPORTED: AtomicBool = AtomicBool::new(false);
50
51/// Serializes the actual [`do_export`] work so that a [`trigger_export_now`]
52/// resetting the idempotency latch while an export is in-flight cannot cause
53/// two concurrent `do_export` calls to clobber the same output files. The
54/// lock is held ONLY across `do_export` (not across the CAS or the tracker/cfg
55/// reads), so no-op callers (which lose the CAS) never block on this mutex.
56static EXPORT_MUTEX: Mutex<()> = Mutex::new(());
57
58/// The tracker to export. Stored in a `RwLock<Option<...>>` (not `OnceLock`)
59/// so tests can reset it between cases. `Arc<GlobalTracker>` because the
60/// panic/ctrlc/atexit handlers need to read it without holding a borrow across
61/// the (long, I/O-bound) export.
62static TRACKER_HANDLE: RwLock<Option<Arc<GlobalTracker>>> = RwLock::new(None);
63
64/// The config captured at [`install`] time. Read by the signal/panic/atexit
65/// handlers which have no other way to receive arguments.
66static AUTO_EXPORT_CFG: RwLock<Option<AutoExportConfig>> = RwLock::new(None);
67
68/// Guards one-time installation of the panic/ctrlc/atexit hooks. `Once` is
69/// appropriate because re-installing a panic hook would chain onto the
70/// already-chained hook (harmless but wasteful), and `ctrlc::set_handler`
71/// returns `Err` if a handler is already set.
72static HOOKS_INSTALLED: Once = Once::new();
73
74// Thread-local re-entrancy guard for the panic hook. When `true`, the current
75// thread is already inside `install_panic_hook`'s closure (i.e. its own
76// `export_for_reason` call panicked, re-entering the hook). The recursive
77// invocation skips the body entirely so the chained `prev` hook is called
78// exactly once — with the user's original panic, not the export panic.
79thread_local! {
80    static IN_PANIC_HOOK: Cell<bool> = const { Cell::new(false) };
81}
82
83/// Which exit path triggered an export. Used by [`export_for_reason`] to
84/// consult the per-path enable flags (`on_exit`, `on_panic`) in
85/// [`AutoExportConfig`] BEFORE consuming the one-shot idempotency latch, so a
86/// disabled path does not prevent a later enabled path from exporting.
87#[derive(Debug, Clone, Copy, PartialEq, Eq)]
88pub enum ExportReason {
89    /// Triggered by `MemScopeGuard::drop` (normal return from `main`).
90    /// Gated by [`AutoExportConfig::on_exit`].
91    Drop,
92    /// Triggered by the panic hook. Gated by [`AutoExportConfig::on_panic`].
93    Panic,
94    /// Triggered by `trigger_export_now` (on-demand or periodic flusher).
95    /// Always allowed — the user explicitly asked for an export.
96    OnDemand,
97    /// Triggered by a signal handler (Ctrl-C / SIGTERM). The signal policy
98    /// is checked separately inside the ctrlc handler, so this is always
99    /// allowed once the handler has decided to proceed.
100    Signal,
101}
102
103// =========================================================================
104// Public API
105// =========================================================================
106
107/// Store the tracker + config and install all enabled exit hooks (panic hook
108/// always; ctrlc handler if the `auto-signal` feature is enabled; atexit hook
109/// if the `atexit` feature is enabled).
110///
111/// Idempotent: calling twice is safe — the tracker/cfg are overwritten (last
112/// wins) and [`HOOKS_INSTALLED`] ensures hooks install only once per process.
113///
114/// # Errors
115///
116/// Returns [`MemScopeError`] only if storing the tracker/cfg fails. With
117/// `parking_lot` (which never poisons) this does not happen in practice, but
118/// the `Result` is preserved so callers can use `?` uniformly.
119pub fn install(cfg: AutoExportConfig, tracker: Arc<GlobalTracker>) -> MemScopeResult<()> {
120    // 1. Write cfg + tracker into the RwLocks. parking_lot write guards never
121    //    poison, so these assignments are effectively infallible.
122    *AUTO_EXPORT_CFG.write() = Some(cfg);
123    *TRACKER_HANDLE.write() = Some(tracker);
124    // 2. Install hooks exactly once per process. Re-running install() after
125    //    the first call skips this block entirely.
126    HOOKS_INSTALLED.call_once(|| {
127        install_panic_hook();
128        #[cfg(feature = "auto-signal")]
129        install_ctrlc_handler();
130        #[cfg(feature = "atexit")]
131        install_atexit();
132    });
133    Ok(())
134}
135
136/// Idempotent export: writes HTML and/or JSON to the configured output path.
137/// Called by the on-demand path (`trigger_export_now`, periodic flusher).
138///
139/// Equivalent to [`export_for_reason`]`(ExportReason::OnDemand)`. The exit
140/// paths (Drop, panic, signal) call [`export_for_reason`] directly so their
141/// per-path enable flags (`on_exit`, `on_panic`) are consulted.
142///
143/// Returns `true` if THIS call performed the export; `false` if a prior call
144/// already exported, or if no tracker/cfg is installed.
145///
146/// # Re-entrancy safety
147///
148/// [`EXPORTED`] is set to `true` via `swap` BEFORE the export work begins. If
149/// the export itself panics, the re-entrant panic-hook invocation sees
150/// [`EXPORTED`] already `true` and returns `false` — no infinite recursion.
151pub fn export_once() -> bool {
152    export_for_reason(ExportReason::OnDemand)
153}
154
155/// Reason-aware idempotent export. Like [`export_once`] but consults the
156/// per-path enable flag in [`AutoExportConfig`] before consuming the
157/// idempotency latch, so a disabled exit path (e.g. `on_exit: false`) does NOT
158/// prevent a later enabled path (e.g. `on_panic: true`) from exporting.
159///
160/// # Returns
161///
162/// `true` if THIS call performed the export; `false` if a prior call already
163/// exported, the path is disabled, or no tracker/cfg is installed.
164pub fn export_for_reason(reason: ExportReason) -> bool {
165    // Read tracker + cfg WITHOUT holding the read guards during the export.
166    // Cloning an Arc is a single atomic increment; cloning the config is a
167    // small allocation that is acceptable here and keeps the critical section
168    // minimal.
169    let tracker = TRACKER_HANDLE.read().clone();
170    let cfg = AUTO_EXPORT_CFG.read().clone();
171    let (Some(tracker), Some(cfg)) = (tracker, cfg) else {
172        // Not installed — nothing to export. Do NOT set EXPORTED so a later
173        // install + export can still proceed.
174        return false;
175    };
176    // Check the per-path enable flag BEFORE the CAS. A disabled path must not
177    // consume the one-shot latch, otherwise a later enabled path would see
178    // EXPORTED==true and skip its own export.
179    let enabled = match reason {
180        ExportReason::Drop => cfg.on_exit,
181        ExportReason::Panic => cfg.on_panic,
182        // On-demand and signal paths are always allowed. The signal policy is
183        // checked separately inside the ctrlc handler before it reaches here.
184        ExportReason::OnDemand | ExportReason::Signal => true,
185    };
186    if !enabled {
187        return false;
188    }
189    // CAS the idempotency guard. swap returns the OLD value; if old was true,
190    // someone else already exported.
191    if EXPORTED.swap(true, Ordering::SeqCst) {
192        return false;
193    }
194    // Serialize the actual file I/O so a concurrent `trigger_export_now`
195    // (which resets EXPORTED) cannot overlap a second `do_export` on the same
196    // output files. The mutex is held only across do_export; CAS losers never
197    // reach here, so no-op callers never block.
198    let _export_guard = EXPORT_MUTEX.lock();
199    do_export(&tracker, &cfg)
200}
201
202/// On-demand export from anywhere in user code. Resets [`EXPORTED`] then calls
203/// [`export_once`], so it always exports AND re-arms the exit-path idempotency
204/// guard (a later panic/Ctrl-C will produce a fresh report).
205///
206/// Returns `true` if the export ran.
207pub fn trigger_export_now() -> bool {
208    // Re-arm the latch first so the subsequent export_once CAS wins.
209    EXPORTED.store(false, Ordering::SeqCst);
210    export_once()
211}
212
213/// In-memory JSON snapshot (no disk write). Builds an [`AnalysisReport`] via
214/// [`Analyzer`] and serializes it. Intended for HTTP endpoints that want live
215/// metrics without touching the filesystem.
216///
217/// # Errors
218///
219/// Returns [`MemScopeError`] if no tracker is installed or serialization fails.
220///
221/// [`AnalysisReport`]: crate::analyzer::AnalysisReport
222/// [`Analyzer`]: crate::analyzer::Analyzer
223pub fn snapshot_json() -> MemScopeResult<String> {
224    let tracker = TRACKER_HANDLE.read().clone();
225    let Some(tracker) = tracker else {
226        return Err(MemScopeError::error(
227            "lifecycle",
228            "snapshot_json",
229            "No tracker installed; call start() or install() first",
230        ));
231    };
232    // The analyzer pipeline (MemoryView -> Analyzer -> analyze) produces an
233    // AnalysisReport that derives serde::Serialize, so we can serialize it
234    // directly without any disk I/O. This avoids the tempdir+export_json
235    // fallback entirely and keeps snapshot_json side-effect free.
236    let mut analyzer = crate::analyzer::Analyzer::from_tracker(&tracker);
237    let report = analyzer.analyze();
238    serde_json::to_string(&report).map_err(|e| {
239        MemScopeError::error(
240            "lifecycle",
241            "snapshot_json",
242            format!("Failed to serialize analysis report: {e}"),
243        )
244    })
245}
246
247// =========================================================================
248// Private helpers
249// =========================================================================
250
251/// Core export logic, separated so it can be unit-tested with a real tracker
252/// + tempdir WITHOUT touching the global statics.
253///
254/// Returns `true` iff at least one selected format was written successfully.
255/// If `cfg.formats` is empty, returns `false` immediately without touching the
256/// filesystem. If the output directory cannot be created, returns `false`
257/// after logging (no format can write).
258fn do_export(tracker: &GlobalTracker, cfg: &AutoExportConfig) -> bool {
259    // Nothing to do if no formats are selected. Returning early here keeps
260    // the filesystem untouched and avoids creating empty report directories.
261    if cfg.formats.is_empty() {
262        return false;
263    }
264    let output = &cfg.output_path;
265    // Pre-create the output directory. `create_dir_all` is idempotent (it
266    // treats AlreadyExists as success), so this is safe even if the export
267    // functions also create it. If creation fails — e.g. a path component is
268    // a regular file rather than a directory — no format can write, so log
269    // and bail gracefully instead of letting each export fail individually.
270    if let Err(e) = std::fs::create_dir_all(output) {
271        error!(
272            target: "memscope::lifecycle",
273            error = %e,
274            path = ?output,
275            "failed to create output directory; skipping export",
276        );
277        return false;
278    }
279    let mut ok = false;
280    // HTML first. A failure here is logged but does NOT abort the JSON write,
281    // so a partial report is still produced when one renderer breaks.
282    if cfg.wants_html() {
283        if let Err(e) = tracker.export_html(output) {
284            error!(
285                target: "memscope::lifecycle",
286                error = %e,
287                "HTML export failed; continuing to JSON if requested",
288            );
289        } else {
290            ok = true;
291        }
292    }
293    if cfg.wants_json() {
294        if let Err(e) = tracker.export_json(output) {
295            error!(
296                target: "memscope::lifecycle",
297                error = %e,
298                "JSON export failed",
299            );
300        } else {
301            ok = true;
302        }
303    }
304    ok
305}
306
307/// Install a panic hook that calls [`export_for_reason`]`(ExportReason::Panic)`
308/// then chains to the previous hook. Runs even under `panic = "abort"` because
309/// the Rust runtime invokes the panic hook before aborting.
310///
311/// The export is wrapped in [`catch_unwind`] so that a panic raised by the
312/// export itself cannot propagate as a double-panic (which would skip the
313/// chained hook). A thread-local re-entrancy guard ([`IN_PANIC_HOOK`]) ensures
314/// the recursive hook invocation — triggered by a panic inside the export —
315/// skips the body entirely, so the chained `prev` hook is called exactly once
316/// (with the user's original panic, not the export panic).
317fn install_panic_hook() {
318    let prev = std::panic::take_hook();
319    std::panic::set_hook(Box::new(move |info| {
320        // Re-entrancy guard: if we are already inside this hook (i.e. our own
321        // export_for_reason call panicked, re-entering the hook), skip the
322        // body entirely. The outer invocation will call prev(info) with the
323        // user's ORIGINAL panic — the one they care about.
324        if IN_PANIC_HOOK.replace(true) {
325            return;
326        }
327        // Best-effort export. catch_unwind contains any panic raised by the
328        // export path so the chained (default) hook still prints the message.
329        let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
330            let _ = export_for_reason(ExportReason::Panic);
331        }));
332        IN_PANIC_HOOK.set(false);
333        // Chain to the previous hook (default prints the panic location/message).
334        prev(info);
335    }));
336}
337
338/// Install a ctrlc handler that calls [`export_for_reason`]`(ExportReason::Signal)`
339/// on SIGINT. Degrades gracefully if a handler is already installed by the host
340/// application.
341///
342/// The policy is read **inside** the handler (at signal time) rather than
343/// at installation time, so a second [`install`] that updates
344/// [`AUTO_EXPORT_CFG`] but is skipped by the `ONCE` guard still takes
345/// effect from the next signal onward.
346///
347/// # Termination behaviour
348///
349/// After running the export, the handler acquires [`EXPORT_MUTEX`] (with a
350/// timeout of [`AutoExportConfig::exit_timeout`]) so any in-flight `do_export`
351/// on another thread can finish before `std::process::exit(130)` truncates it.
352/// This is necessary because the `ctrlc` crate REPLACES the OS default SIGINT
353/// handler — once we register a handler, the default "terminate the process"
354/// behaviour no longer fires. Hosts that want to keep running after SIGINT
355/// (e.g. a web server doing graceful shutdown) should set
356/// [`SignalPolicy::Off`] and install their own handler.
357///
358/// If the `atexit` feature is also enabled, the registered `atexit` callback
359/// will still run after `std::process::exit(130)`. The idempotency latch in
360/// [`export_for_reason`] makes this double-invocation safe (the second call is
361/// a no-op).
362#[cfg(feature = "auto-signal")]
363fn install_ctrlc_handler() {
364    // ctrlc::set_handler returns Err if a handler is already registered (for
365    // example by the host application). Degrade gracefully: log a warning and
366    // keep Drop + panic-hook coverage.
367    match ctrlc::set_handler(|| {
368        let (policy, exit_timeout) = AUTO_EXPORT_CFG
369            .read()
370            .as_ref()
371            .map(|c| (c.on_signal, c.exit_timeout))
372            .unwrap_or((SignalPolicy::Off, Duration::from_secs(5)));
373        if policy == SignalPolicy::Off {
374            return;
375        }
376        let _ = export_for_reason(ExportReason::Signal);
377        // Wait for any in-flight do_export on another thread to finish before
378        // yanking the rug out from under it with process::exit. try_lock_for
379        // returns None on timeout; in that case we exit anyway (best-effort)
380        // rather than hang forever. This turns a "truncated file" race into a
381        // "complete file, then exit". The guard is held until the (noreturn)
382        // process::exit call below, which is fine because the process is
383        // terminating anyway.
384        let _export_lock = EXPORT_MUTEX.try_lock_for(exit_timeout);
385        // The ctrlc crate REPLACES the OS default SIGINT handler, so the
386        // process would otherwise keep running. Exit with 130 (the
387        // conventional 128+SIGINT code) so the host sees a SIGINT-style exit
388        // and the shell reports "Interrupt: 13" / exit code 130. export_once
389        // is idempotent, so the Drop guard (if it ever runs) is a no-op.
390        std::process::exit(130);
391    }) {
392        Ok(()) => info!(
393            target: "memscope::lifecycle",
394            "ctrlc handler installed for auto-export",
395        ),
396        Err(e) => warn!(
397            target: "memscope::lifecycle",
398            error = %e,
399            "failed to install ctrlc handler (host may have one already); \
400             auto-export on Ctrl-C disabled, Drop + panic-hook still active",
401        ),
402    }
403}
404
405/// Register an `atexit` handler that calls [`export_once`] on
406/// `std::process::exit`. This is the ONLY way to catch `std::process::exit`
407/// because Rust destructors do not run on explicit exit, but C `atexit`
408/// handlers do.
409///
410/// # Safety
411///
412/// `libc::atexit` is an unsafe FFI call. The registered function must match
413/// the `extern "C" fn()` signature and must not capture state. `on_exit`
414/// reads only from process-global `static`s which outlive the call, and the
415/// process is exiting anyway, so the (non-async-signal-safe) file I/O inside
416/// [`export_once`] is acceptable here.
417#[cfg(feature = "atexit")]
418fn install_atexit() {
419    extern "C" fn on_exit() {
420        let _ = export_once();
421    }
422    // SAFETY: `on_exit` is an `extern "C" fn` with the correct signature for
423    // `libc::atexit` (`extern "C" fn()`). It captures no state and only reads
424    // process-global statics. The call registers the callback and returns 0 on
425    // success or non-zero on failure (e.g. too many handlers registered).
426    let rc = unsafe { libc::atexit(on_exit) };
427    if rc != 0 {
428        warn!(
429            target: "memscope::lifecycle",
430            rc,
431            "libc::atexit registration failed; auto-export on std::process::exit disabled",
432        );
433    }
434}
435
436/// Test-only reset of the global statics. NOT available in non-test builds.
437/// Use `#[serial]` (serial_test) on any test that calls this so concurrent
438/// tests do not clobber each other's state.
439#[cfg(test)]
440pub(crate) fn reset_for_test() {
441    EXPORTED.store(false, Ordering::SeqCst);
442    *TRACKER_HANDLE.write() = None;
443    *AUTO_EXPORT_CFG.write() = None;
444}
445
446// =========================================================================
447// Tests
448// =========================================================================
449
450#[cfg(test)]
451mod tests {
452    use super::*;
453    use crate::auto_export::{ExportFormatSet, SignalPolicy};
454    use proptest::prelude::*;
455    use proptest::test_runner::TestRunner;
456    use serial_test::serial;
457    use std::sync::atomic::AtomicUsize;
458    use std::time::Duration;
459    use tempfile::TempDir;
460
461    /// Build a fresh `GlobalTracker` wrapped in an `Arc`. Centralized so every
462    /// test starts from a known-clean tracker. `GlobalTracker::new()` also
463    /// registers a global async-tracker singleton; tests are `#[serial]` so
464    /// this side effect cannot race with another test.
465    fn fresh_tracker() -> Arc<GlobalTracker> {
466        Arc::new(GlobalTracker::new())
467    }
468
469    // ===================== Positive tests (happy path) ====================
470
471    /// Objective: Verify `do_export` writes both HTML and JSON artifacts when
472    /// `HTML_JSON` is selected, using a real tracker and a tempdir.
473    /// Invariants: `dashboard_unified_dashboard.html` and `memory_analysis.json`
474    /// exist after the call, and `do_export` returns `true`.
475    #[test]
476    #[serial]
477    fn do_export_writes_both_formats() {
478        let tracker = fresh_tracker();
479        let dir = TempDir::new().expect("tempdir creation must succeed in tests");
480        let cfg = AutoExportConfig::default().with_output_path(dir.path());
481        let cfg = cfg.with_formats(ExportFormatSet::HTML_JSON);
482
483        let did = do_export(&tracker, &cfg);
484        assert!(did, "do_export must return true when both formats succeed");
485
486        let html = dir.path().join("dashboard_unified_dashboard.html");
487        let json = dir.path().join("memory_analysis.json");
488        assert!(
489            html.exists(),
490            "HTML dashboard file must exist at {html:?} after a successful HTML export",
491        );
492        assert!(
493            json.exists(),
494            "memory_analysis.json must exist at {json:?} after a successful JSON export",
495        );
496    }
497
498    /// Objective: Verify `export_once` is idempotent — the first call after
499    /// `install` exports, the second is a no-op.
500    /// Invariants: First call returns `true`, second returns `false`; the
501    /// output directory is written exactly once.
502    #[test]
503    #[serial]
504    fn export_once_is_idempotent() {
505        reset_for_test();
506        let tracker = fresh_tracker();
507        let dir = TempDir::new().expect("tempdir creation must succeed in tests");
508        let cfg = AutoExportConfig::default().with_output_path(dir.path());
509        install(cfg, tracker).expect("install must not fail with parking_lot locks");
510
511        let first = export_once();
512        assert!(
513            first,
514            "first export_once after install must perform the export",
515        );
516        let second = export_once();
517        assert!(
518            !second,
519            "second export_once must be a no-op because EXPORTED is already set",
520        );
521
522        let html = dir.path().join("dashboard_unified_dashboard.html");
523        assert!(
524            html.exists(),
525            "the single export must have written the HTML dashboard",
526        );
527    }
528
529    /// Objective: Verify `trigger_export_now` re-arms the idempotency latch
530    /// and produces a fresh export, even after a prior `export_once`.
531    /// Invariants: `trigger_export_now` returns `true`; a subsequent
532    /// `export_once` returns `false` (latch re-set by trigger).
533    #[test]
534    #[serial]
535    fn trigger_export_now_re_arms_latch() {
536        reset_for_test();
537        let tracker = fresh_tracker();
538        let dir = TempDir::new().expect("tempdir creation must succeed in tests");
539        let cfg = AutoExportConfig::default().with_output_path(dir.path());
540        install(cfg, tracker).expect("install must succeed");
541
542        // Consume the one-shot export so EXPORTED is true.
543        let _ = export_once();
544
545        let triggered = trigger_export_now();
546        assert!(
547            triggered,
548            "trigger_export_now must reset EXPORTED and run a fresh export",
549        );
550        // After trigger, the latch is set again, so a plain export_once is a no-op.
551        let after = export_once();
552        assert!(
553            !after,
554            "export_once after trigger_export_now must be a no-op until the latch is reset again",
555        );
556    }
557
558    /// Objective: Verify `snapshot_json` returns a non-empty serialized report
559    /// containing the allocation-count field when a tracker is installed.
560    /// Invariants: Result is `Ok`, string is non-empty and contains
561    /// `"allocation_count"` (a field on `MemoryStatsReport`).
562    #[test]
563    #[serial]
564    fn snapshot_json_returns_serialized_report() {
565        reset_for_test();
566        let tracker = fresh_tracker();
567        let cfg = AutoExportConfig::default();
568        install(cfg, tracker).expect("install must succeed");
569
570        let snap = snapshot_json();
571        assert!(
572            snap.is_ok(),
573            "snapshot_json must succeed when a tracker is installed: {:?}",
574            snap.err(),
575        );
576        let snap = snap.expect("checked Ok above");
577        assert!(
578            !snap.is_empty(),
579            "serialized snapshot must not be the empty string",
580        );
581        assert!(
582            snap.contains("allocation_count"),
583            "snapshot must contain the allocation_count field; got: {snap}",
584        );
585    }
586
587    // ===================== Negative tests (edge cases) ====================
588
589    /// Objective: Verify `export_once` is safe when nothing is installed.
590    /// Invariants: Returns `false`, does not panic, and does NOT set EXPORTED
591    /// (so a later install+export can still proceed).
592    #[test]
593    #[serial]
594    fn export_once_without_install_returns_false() {
595        reset_for_test();
596        let did = export_once();
597        assert!(
598            !did,
599            "export_once with no tracker/cfg installed must return false",
600        );
601        assert!(
602            !EXPORTED.load(Ordering::SeqCst),
603            "EXPORTED must remain false so a later install can still export",
604        );
605    }
606
607    /// Objective: Verify `do_export` does nothing when no formats are selected.
608    /// Invariants: Returns `false` and writes no files to the output directory.
609    #[test]
610    #[serial]
611    fn do_export_empty_formats_writes_nothing() {
612        let tracker = fresh_tracker();
613        let dir = TempDir::new().expect("tempdir creation must succeed in tests");
614        let cfg = AutoExportConfig {
615            output_path: dir.path().to_path_buf(),
616            formats: ExportFormatSet::from_bits(0),
617            ..AutoExportConfig::default()
618        };
619
620        let did = do_export(&tracker, &cfg);
621        assert!(
622            !did,
623            "do_export with empty formats must return false (nothing to do)",
624        );
625        let entries = std::fs::read_dir(dir.path())
626            .expect("output dir must be readable")
627            .count();
628        assert_eq!(entries, 0, "no files must be written when formats is empty",);
629    }
630
631    /// Objective: Verify `do_export` creates a deeply nested output path via
632    /// `create_dir_all` and then writes the report into it.
633    /// Invariants: Returns `true`; the nested directory and both report files
634    /// exist after the call.
635    #[test]
636    #[serial]
637    fn do_export_creates_deeply_nested_output_path() {
638        let tracker = fresh_tracker();
639        let dir = TempDir::new().expect("tempdir creation must succeed in tests");
640        let deep = dir.path().join("a/b/c/d/e/report");
641        let cfg = AutoExportConfig::default().with_output_path(deep.clone());
642
643        let did = do_export(&tracker, &cfg);
644        assert!(
645            did,
646            "do_export must succeed after creating the nested output directory",
647        );
648        assert!(
649            deep.is_dir(),
650            "the nested output directory must have been created",
651        );
652        assert!(
653            deep.join("dashboard_unified_dashboard.html").exists(),
654            "HTML dashboard must exist inside the nested output path",
655        );
656        assert!(
657            deep.join("memory_analysis.json").exists(),
658            "memory_analysis.json must exist inside the nested output path",
659        );
660    }
661
662    /// Objective: Verify `do_export` fails gracefully (no panic) when the
663    /// output path cannot be created because a path component is a regular
664    /// file rather than a directory.
665    /// Invariants: Returns `false`; no panic; the blocker file is untouched.
666    #[test]
667    #[serial]
668    fn do_export_path_under_a_file_fails_gracefully() {
669        let tracker = fresh_tracker();
670        let dir = TempDir::new().expect("tempdir creation must succeed in tests");
671        // Create a regular file, then try to use a path underneath it as a dir.
672        let blocker = dir.path().join("blocker");
673        std::fs::write(&blocker, b"not a directory").expect("blocker file write must succeed");
674        let bad_output = blocker.join("sub");
675
676        let cfg = AutoExportConfig {
677            output_path: bad_output.clone(),
678            formats: ExportFormatSet::HTML_JSON,
679            ..AutoExportConfig::default()
680        };
681
682        let did = do_export(&tracker, &cfg);
683        assert!(
684            !did,
685            "do_export must return false when create_dir_all fails (path under a file)",
686        );
687        // The blocker file must still be a file (not clobbered into a directory).
688        assert!(
689            blocker.is_file(),
690            "the blocking file must remain a file after the failed export",
691        );
692    }
693
694    /// Objective: Verify `snapshot_json` returns `Err` (not a panic) when no
695    /// tracker is installed.
696    /// Invariants: Result is `Err` with a lifecycle error category.
697    #[test]
698    #[serial]
699    fn snapshot_json_without_tracker_errors() {
700        reset_for_test();
701        let res = snapshot_json();
702        let err = res.expect_err("snapshot_json must return Err when no tracker is installed");
703        assert_eq!(
704            err.category(),
705            "analysis",
706            "snapshot_json error must classify as an analysis error (module 'lifecycle')",
707        );
708    }
709
710    // ===================== Stress / concurrency tests =====================
711
712    /// Objective: Verify that 50 concurrent `export_once` calls result in
713    /// EXACTLY one export (idempotency latch holds under contention).
714    /// Invariants: Exactly one thread returns `true`; exactly one HTML file
715    /// and the primary JSON file exist after all threads join.
716    #[test]
717    #[serial]
718    fn stress_50_concurrent_exports_one_wins() {
719        reset_for_test();
720        let tracker = fresh_tracker();
721        let dir = TempDir::new().expect("tempdir creation must succeed in tests");
722        let cfg = AutoExportConfig::default().with_output_path(dir.path());
723        install(cfg, tracker).expect("install must succeed");
724
725        let wins = Arc::new(AtomicUsize::new(0));
726        let mut handles = Vec::with_capacity(50);
727        for _ in 0..50 {
728            let wins = wins.clone();
729            handles.push(std::thread::spawn(move || {
730                if export_once() {
731                    wins.fetch_add(1, Ordering::SeqCst);
732                }
733            }));
734        }
735        for h in handles {
736            h.join()
737                .expect("worker threads must not panic during the stress test");
738        }
739
740        assert_eq!(
741            wins.load(Ordering::SeqCst),
742            1,
743            "exactly one of the 50 concurrent calls must perform the export",
744        );
745
746        // Count HTML files: export_html writes exactly one (dashboard_unified_dashboard.html),
747        // so a count of 1 confirms no duplicate concurrent writes.
748        let html_count = std::fs::read_dir(dir.path())
749            .expect("output dir must be readable")
750            .filter_map(Result::ok)
751            .filter(|e| {
752                e.path()
753                    .extension()
754                    .map(|ext| ext == "html")
755                    .unwrap_or(false)
756            })
757            .count();
758        assert_eq!(
759            html_count, 1,
760            "exactly one HTML file must exist after the concurrent exports",
761        );
762        assert!(
763            dir.path().join("memory_analysis.json").exists(),
764            "the primary JSON file must exist after the winning export",
765        );
766    }
767
768    /// Objective: Verify that 20 concurrent `trigger_export_now` calls (which
769    /// reset the idempotency latch) do NOT produce corrupted/truncated report
770    /// files. This is the regression test for the `EXPORT_MUTEX` fix: without
771    /// the mutex serializing `do_export`, two concurrent triggers would reset
772    /// EXPORTED mid-flight and overlap two `do_export` calls on the same files.
773    /// Invariants: All threads join; the HTML file is non-empty and contains
774    /// the `memscope` template marker (proving it is not truncated).
775    #[test]
776    #[serial]
777    fn stress_concurrent_trigger_export_now_no_corrupted_writes() {
778        reset_for_test();
779        let tracker = fresh_tracker();
780        let dir = TempDir::new().expect("tempdir creation must succeed in tests");
781        let cfg = AutoExportConfig::default().with_output_path(dir.path());
782        install(cfg, tracker).expect("install must succeed");
783
784        let mut handles = Vec::with_capacity(20);
785        for _ in 0..20 {
786            handles.push(std::thread::spawn(|| {
787                let _ = trigger_export_now();
788            }));
789        }
790        for h in handles {
791            h.join().expect("trigger_export_now worker must not panic");
792        }
793
794        // After all concurrent triggers settle, the HTML file must be valid
795        // (non-empty and not truncated by a concurrent writer). A truncated
796        // write would either be empty or missing the template marker.
797        let html = dir.path().join("dashboard_unified_dashboard.html");
798        assert!(
799            html.exists(),
800            "HTML dashboard must exist after concurrent trigger_export_now calls",
801        );
802        let content = std::fs::read_to_string(&html)
803            .expect("HTML dashboard must be readable after concurrent exports");
804        assert!(
805            !content.is_empty(),
806            "HTML must not be empty/truncated by a concurrent writer",
807        );
808        assert!(
809            content.contains("memscope"),
810            "HTML must contain the 'memscope' template marker (not truncated mid-write)",
811        );
812    }
813
814    // ============== Per-path flag gating (on_exit / on_panic) ==============
815
816    /// Objective: Verify `export_for_reason(Drop)` honors `on_exit: false` —
817    /// the Drop path is skipped and does NOT consume the idempotency latch, so
818    /// a later `trigger_export_now` can still export.
819    /// Invariants: `export_for_reason(Drop)` returns `false`; `EXPORTED` stays
820    /// `false`; `trigger_export_now` then returns `true`.
821    #[test]
822    #[serial]
823    fn drop_reason_respects_on_exit_false() {
824        reset_for_test();
825        let tracker = fresh_tracker();
826        let dir = TempDir::new().expect("tempdir creation must succeed in tests");
827        let cfg = AutoExportConfig::default()
828            .with_output_path(dir.path())
829            .with_on_exit(false);
830        install(cfg, tracker).expect("install must succeed");
831
832        let did = export_for_reason(ExportReason::Drop);
833        assert!(
834            !did,
835            "export_for_reason(Drop) must return false when on_exit is false",
836        );
837        assert!(
838            !EXPORTED.load(Ordering::SeqCst),
839            "EXPORTED must remain false so a disabled path does not block later exports",
840        );
841
842        // A subsequent on-demand export must still succeed because the latch
843        // was not consumed.
844        let on_demand = trigger_export_now();
845        assert!(
846            on_demand,
847            "trigger_export_now must succeed after the disabled Drop path",
848        );
849    }
850
851    /// Objective: Verify `export_for_reason(Panic)` honors `on_panic: false` —
852    /// the Panic path is skipped and does NOT consume the idempotency latch.
853    /// Invariants: `export_for_reason(Panic)` returns `false`; `EXPORTED`
854    /// stays `false`.
855    #[test]
856    #[serial]
857    fn panic_reason_respects_on_panic_false() {
858        reset_for_test();
859        let tracker = fresh_tracker();
860        let dir = TempDir::new().expect("tempdir creation must succeed in tests");
861        let cfg = AutoExportConfig::default()
862            .with_output_path(dir.path())
863            .with_on_panic(false);
864        install(cfg, tracker).expect("install must succeed");
865
866        let did = export_for_reason(ExportReason::Panic);
867        assert!(
868            !did,
869            "export_for_reason(Panic) must return false when on_panic is false",
870        );
871        assert!(
872            !EXPORTED.load(Ordering::SeqCst),
873            "EXPORTED must remain false so the disabled panic path does not block later exports",
874        );
875    }
876
877    // ============== Panic-hook chaining (release-panic-abort coverage) ==============
878
879    /// Objective: Verify the installed panic hook (a) calls `export_once` and
880    /// (b) chains to the previously-registered hook. This is the CRITICAL
881    /// coverage for `panic = "abort"` release builds, where the panic hook is
882    /// the only mechanism that runs before abort.
883    ///
884    /// Invariants:
885    /// - The sentinel (previous) hook runs (flag set to true) — chaining works.
886    /// - `EXPORTED` is true after the panic — our hook exported.
887    ///
888    /// # Why we call `install_panic_hook` directly instead of `install`
889    ///
890    /// [`HOOKS_INSTALLED`] is a `Once` and cannot be reset between tests, so
891    /// `install`'s `call_once` body would be a no-op if another test already
892    /// triggered it. Calling `install_panic_hook` directly gives a clean,
893    /// deterministic sentinel -> our_hook chain regardless of `Once` state or
894    /// test execution order.
895    #[test]
896    #[serial]
897    fn panic_hook_chains_and_exports() {
898        reset_for_test();
899
900        // Install a tracker + cfg directly into the statics so export_once
901        // (invoked by the panic hook) has data to export. We bypass install()
902        // here to avoid the Once affecting the hook-under-test. Keep the
903        // TempDir alive for the whole test so it cleans up the directory.
904        let tracker = fresh_tracker();
905        let tempdir = TempDir::new().expect("tempdir creation must succeed in tests");
906        let dir = tempdir.path().to_path_buf();
907        let cfg = AutoExportConfig::default().with_output_path(dir.clone());
908        *TRACKER_HANDLE.write() = Some(tracker);
909        *AUTO_EXPORT_CFG.write() = Some(cfg);
910
911        // 1. Register a sentinel hook that sets a flag, then 2. install our
912        //    hook on top of it. install_panic_hook take_hooks the sentinel and
913        //    chains to it.
914        let sentinel_fired = Arc::new(AtomicBool::new(false));
915        let sf = sentinel_fired.clone();
916        std::panic::set_hook(Box::new(move |_| {
917            sf.store(true, Ordering::SeqCst);
918        }));
919        install_panic_hook();
920
921        // Spawn a thread that panics. Under the test profile (panic=unwind)
922        // join() returns Err; under release (panic=abort) the hook still runs
923        // before abort — that is the behavior this test guards.
924        let handle = std::thread::spawn(|| {
925            panic!("lifecycle test panic");
926        });
927        let join_err = handle.join();
928        assert!(
929            join_err.is_err(),
930            "the panicking thread must propagate the panic to join() as Err",
931        );
932
933        assert!(
934            sentinel_fired.load(Ordering::SeqCst),
935            "the sentinel (previous) hook must have run — panic-hook chaining is broken",
936        );
937        assert!(
938            EXPORTED.load(Ordering::SeqCst),
939            "EXPORTED must be true — our panic hook must have called export_once",
940        );
941        assert!(
942            dir.join("dashboard_unified_dashboard.html").exists(),
943            "the panic-hook export must have written the HTML dashboard",
944        );
945
946        // Restore the default hook so this test does not pollute later tests.
947        // take_hook() returns the current hook (dropped here) and resets the
948        // registered hook to the default.
949        let _ = std::panic::take_hook();
950    }
951
952    // ===================== Property-based test ====================
953
954    /// Objective: Verify `do_export` never panics across many randomly
955    /// generated (but valid) `AutoExportConfig` values.
956    ///
957    /// Invariants: For every generated config, `do_export` returns without
958    /// panicking. A single shared empty `GlobalTracker` is reused across all
959    /// cases (an empty tracker renders quickly, keeping the suite fast).
960    ///
961    /// # Case count
962    ///
963    /// 200 cases instead of 1000: each case that selects HTML renders a full
964    /// dashboard via handlebars. Even for an empty tracker this is a few ms
965    /// per case, and 200 cases keeps the suite comfortably within the ~10s
966    /// budget. The contract under test ("never panic on valid input") is
967    /// fully exercised by 200 random configs.
968    #[test]
969    #[serial]
970    fn proptest_do_export_never_panics_on_valid_configs() {
971        let tracker = GlobalTracker::new();
972        let mut runner = TestRunner::new(ProptestConfig {
973            cases: 200,
974            ..ProptestConfig::default()
975        });
976
977        // Strategy: random format bits 0..=3, random booleans, random signal
978        // policy, random flush interval (None or 1..100ms). The output path
979        // is built per-case from a fresh TempDir inside the closure so the
980        // TempDir outlives the do_export call.
981        let strategy = (
982            0u8..4u8,
983            any::<bool>(),
984            any::<bool>(),
985            prop_oneof![
986                Just(SignalPolicy::Off),
987                Just(SignalPolicy::CtrlC),
988                Just(SignalPolicy::CtrlCAndTerm),
989            ],
990            prop_oneof![
991                Just(None),
992                (1u64..100u64).prop_map(|ms| Some(Duration::from_millis(ms))),
993            ],
994        );
995
996        runner
997            .run(&strategy, |(bits, on_exit, on_panic, on_signal, flush)| {
998                // `run` passes `S::Value` by value; the tuple is `Copy`, so this
999                // destructures the fields out without moving issues.
1000                let tempdir = TempDir::new().expect("per-case tempdir must succeed in proptest");
1001                let cfg = AutoExportConfig {
1002                    output_path: tempdir.path().to_path_buf(),
1003                    formats: ExportFormatSet::from_bits(bits),
1004                    on_exit,
1005                    on_panic,
1006                    on_signal,
1007                    flush_interval: flush,
1008                    exit_timeout: Duration::from_secs(5),
1009                };
1010                let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1011                    do_export(&tracker, &cfg)
1012                }));
1013                prop_assert!(
1014                    result.is_ok(),
1015                    "do_export must never panic on valid configs (bits={})",
1016                    bits,
1017                );
1018                Ok(())
1019            })
1020            .expect("proptest run must complete with no failing cases");
1021    }
1022
1023    /// Objective: Verify `do_export`'s dispatch contract (return value = whether
1024    /// at least one format was written) holds across 1000 random valid configs.
1025    /// This satisfies rules.md §V.3 (proptest with at least 1000 cases) for the
1026    /// dispatch surface, complementing the 200-case render-path proptest above.
1027    ///
1028    /// Invariants: For every config, `do_export` returns without panicking AND
1029    /// the return value equals `!formats.is_empty()` (when the output dir is
1030    /// writable, which it always is here because we reuse a single tempdir).
1031    #[test]
1032    #[serial]
1033    fn proptest_do_export_dispatch_contract_1000_cases() {
1034        let tracker = GlobalTracker::new();
1035        let mut runner = TestRunner::new(ProptestConfig {
1036            cases: 1000,
1037            ..ProptestConfig::default()
1038        });
1039        // Reuse ONE tempdir across all 1000 cases — do_export is idempotent on
1040        // existing dirs and overwrites files, so no per-case cleanup is needed.
1041        // This keeps the 1000-case run fast by avoiding 1000 mkdir/rmdir pairs.
1042        let tempdir = TempDir::new().expect("shared tempdir must succeed for the 1000-case run");
1043        let strategy = (
1044            0u8..4u8,
1045            any::<bool>(),
1046            any::<bool>(),
1047            prop_oneof![
1048                Just(SignalPolicy::Off),
1049                Just(SignalPolicy::CtrlC),
1050                Just(SignalPolicy::CtrlCAndTerm),
1051            ],
1052            prop_oneof![
1053                Just(None),
1054                (1u64..100u64).prop_map(|ms| Some(Duration::from_millis(ms))),
1055            ],
1056        );
1057
1058        runner
1059            .run(&strategy, |(bits, on_exit, on_panic, on_signal, flush)| {
1060                let cfg = AutoExportConfig {
1061                    output_path: tempdir.path().to_path_buf(),
1062                    formats: ExportFormatSet::from_bits(bits),
1063                    on_exit,
1064                    on_panic,
1065                    on_signal,
1066                    flush_interval: flush,
1067                    exit_timeout: Duration::from_secs(5),
1068                };
1069                // We test ONLY the dispatch contract here (no file-content
1070                // inspection), so the 1000-case run stays fast even though
1071                // some cases render HTML. The contract: do_export returns
1072                // true iff at least one format was selected AND succeeded.
1073                let did = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1074                    do_export(&tracker, &cfg)
1075                }));
1076                prop_assert!(
1077                    did.is_ok(),
1078                    "do_export must never panic on valid configs (bits={})",
1079                    bits,
1080                );
1081                let did = did.expect("catch_unwind Ok checked above");
1082                // With a writable tempdir, do_export returns true iff at least
1083                // one format is selected (and export_html/export_json succeed
1084                // for an empty tracker).
1085                prop_assert_eq!(
1086                    did,
1087                    !cfg.formats.is_empty(),
1088                    "do_export return value must track format selection (bits={}, did={})",
1089                    bits,
1090                    did,
1091                );
1092                Ok(())
1093            })
1094            .expect("1000-case proptest must pass with no failing cases");
1095    }
1096
1097    // ===================== Miri coverage for the atexit path ====================
1098
1099    /// Objective: Verify `install_atexit` registers without panicking. This is
1100    /// the unsafe FFI surface (`libc::atexit`) and must be Miri-clean.
1101    ///
1102    /// Invariants: Calling `install_atexit` does not panic and does not
1103    /// trigger UB under Miri.
1104    ///
1105    /// # Miri
1106    ///
1107    /// `cargo +nightly miri test --features atexit lifecycle::` must report 0
1108    /// errors. The registered `on_exit` callback runs at process exit and
1109    /// calls `export_once`; with no tracker installed it returns `false`
1110    /// harmlessly, so the atexit path is exercised end-to-end under Miri.
1111    #[test]
1112    #[cfg(feature = "atexit")]
1113    #[serial]
1114    fn atexit_install_is_safe_and_does_not_panic() {
1115        reset_for_test();
1116        // Direct call: only the registration is under test here. The callback
1117        // runs at process exit, not at registration time.
1118        install_atexit();
1119        // No assertion beyond "did not panic" — the SAFETY block in
1120        // install_atexit is the contract under Miri's scrutiny.
1121    }
1122}