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, ExportFormatSet, 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    let _suppression = crate::core::allocator::TrackingGuard::acquire();
166    // Read tracker + cfg WITHOUT holding the read guards during the export.
167    // Cloning an Arc is a single atomic increment; cloning the config is a
168    // small allocation that is acceptable here and keeps the critical section
169    // minimal.
170    let tracker = TRACKER_HANDLE.read().clone();
171    let cfg = AUTO_EXPORT_CFG.read().clone();
172    let (Some(tracker), Some(cfg)) = (tracker, cfg) else {
173        // Not installed — nothing to export. Do NOT set EXPORTED so a later
174        // install + export can still proceed.
175        return false;
176    };
177    // Check the per-path enable flag BEFORE the CAS. A disabled path must not
178    // consume the one-shot latch, otherwise a later enabled path would see
179    // EXPORTED==true and skip its own export.
180    let enabled = match reason {
181        ExportReason::Drop => cfg.on_exit,
182        ExportReason::Panic => cfg.on_panic,
183        // On-demand and signal paths are always allowed. The signal policy is
184        // checked separately inside the ctrlc handler before it reaches here.
185        ExportReason::OnDemand | ExportReason::Signal => true,
186    };
187    if !enabled {
188        return false;
189    }
190    // CAS the idempotency guard. swap returns the OLD value; if old was true,
191    // someone else already exported.
192    if EXPORTED.swap(true, Ordering::SeqCst) {
193        return false;
194    }
195    // Serialize the actual file I/O so a concurrent `trigger_export_now`
196    // (which resets EXPORTED) cannot overlap a second `do_export` on the same
197    // output files. The mutex is held only across do_export; CAS losers never
198    // reach here, so no-op callers never block.
199    let _export_guard = EXPORT_MUTEX.lock();
200    do_export(&tracker, &cfg)
201}
202
203/// On-demand export from anywhere in user code. Resets [`EXPORTED`] then calls
204/// [`export_once`], so it always exports AND re-arms the exit-path idempotency
205/// guard (a later panic/Ctrl-C will produce a fresh report).
206///
207/// Returns `true` if the export ran.
208pub fn trigger_export_now() -> bool {
209    // Re-arm the latch first so the subsequent export_once CAS wins.
210    EXPORTED.store(false, Ordering::SeqCst);
211    export_once()
212}
213
214/// In-memory JSON snapshot (no disk write). Builds an [`AnalysisReport`] via
215/// [`Analyzer`] and serializes it. Intended for HTTP endpoints that want live
216/// metrics without touching the filesystem.
217///
218/// # Errors
219///
220/// Returns [`MemScopeError`] if no tracker is installed or serialization fails.
221///
222/// [`AnalysisReport`]: crate::analyzer::AnalysisReport
223/// [`Analyzer`]: crate::analyzer::Analyzer
224pub fn snapshot_json() -> MemScopeResult<String> {
225    let _suppression = crate::core::allocator::TrackingGuard::acquire();
226    let tracker = TRACKER_HANDLE.read().clone();
227    let Some(tracker) = tracker else {
228        return Err(MemScopeError::error(
229            "lifecycle",
230            "snapshot_json",
231            "No tracker installed; call start() or install() first",
232        ));
233    };
234    // The analyzer pipeline (MemoryView -> Analyzer -> analyze) produces an
235    // AnalysisReport that derives serde::Serialize, so we can serialize it
236    // directly without any disk I/O. This avoids the tempdir+export_json
237    // fallback entirely and keeps snapshot_json side-effect free.
238    let mut analyzer = crate::analyzer::Analyzer::from_tracker(&tracker);
239    let report = analyzer.analyze();
240    serde_json::to_string(&report).map_err(|e| {
241        MemScopeError::error(
242            "lifecycle",
243            "snapshot_json",
244            format!("Failed to serialize analysis report: {e}"),
245        )
246    })
247}
248
249// =========================================================================
250// Private helpers
251// =========================================================================
252
253/// Core export logic, separated so it can be unit-tested with a real tracker
254/// + tempdir WITHOUT touching the global statics.
255///
256/// Returns `true` iff at least one selected format was written successfully.
257/// If `cfg.formats` is empty, returns `false` immediately without touching the
258/// filesystem. If the output directory cannot be created, returns `false`
259/// after logging (no format can write).
260fn do_export(tracker: &GlobalTracker, cfg: &AutoExportConfig) -> bool {
261    let _suppression = crate::core::allocator::TrackingGuard::acquire();
262    // Nothing to do if no formats are selected. Returning early here keeps
263    // the filesystem untouched and avoids creating empty report directories.
264    if cfg.formats.is_empty() {
265        return false;
266    }
267    let output = &cfg.output_path;
268    // Pre-create the output directory. `create_dir_all` is idempotent (it
269    // treats AlreadyExists as success), so this is safe even if the export
270    // functions also create it. If creation fails — e.g. a path component is
271    // a regular file rather than a directory — no format can write, so log
272    // and bail gracefully instead of letting each export fail individually.
273    if let Err(e) = std::fs::create_dir_all(output) {
274        error!(
275            target: "memscope::lifecycle",
276            error = %e,
277            path = ?output,
278            "failed to create output directory; skipping export",
279        );
280        return false;
281    }
282    dispatch_formats(cfg.formats, |format| {
283        let result = if format == ExportFormatSet::HTML {
284            tracker.export_html(output)
285        } else {
286            tracker.export_json(output)
287        };
288        if let Err(error) = result {
289            error!(target: "memscope::lifecycle", ?format, %error, "export failed");
290            return false;
291        }
292        true
293    })
294}
295
296fn dispatch_formats(
297    formats: ExportFormatSet,
298    mut write: impl FnMut(ExportFormatSet) -> bool,
299) -> bool {
300    let mut any_ok = false;
301    for (selected, format) in [
302        (formats.contains_html(), ExportFormatSet::HTML),
303        (formats.contains_json(), ExportFormatSet::JSON),
304    ] {
305        if selected {
306            any_ok |= write(format);
307        }
308    }
309    any_ok
310}
311
312/// Install a panic hook that calls [`export_for_reason`]`(ExportReason::Panic)`
313/// then chains to the previous hook. Runs even under `panic = "abort"` because
314/// the Rust runtime invokes the panic hook before aborting.
315///
316/// The export is wrapped in [`catch_unwind`] so that a panic raised by the
317/// export itself cannot propagate as a double-panic (which would skip the
318/// chained hook). A thread-local re-entrancy guard ([`IN_PANIC_HOOK`]) ensures
319/// the recursive hook invocation — triggered by a panic inside the export —
320/// skips the body entirely, so the chained `prev` hook is called exactly once
321/// (with the user's original panic, not the export panic).
322fn install_panic_hook() {
323    let prev = std::panic::take_hook();
324    std::panic::set_hook(Box::new(move |info| {
325        // Re-entrancy guard: if we are already inside this hook (i.e. our own
326        // export_for_reason call panicked, re-entering the hook), skip the
327        // body entirely. The outer invocation will call prev(info) with the
328        // user's ORIGINAL panic — the one they care about.
329        if IN_PANIC_HOOK.replace(true) {
330            return;
331        }
332        // Best-effort export. catch_unwind contains any panic raised by the
333        // export path so the chained (default) hook still prints the message.
334        let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
335            let _ = export_for_reason(ExportReason::Panic);
336        }));
337        IN_PANIC_HOOK.set(false);
338        // Chain to the previous hook (default prints the panic location/message).
339        prev(info);
340    }));
341}
342
343/// Install a ctrlc handler that calls [`export_for_reason`]`(ExportReason::Signal)`
344/// on SIGINT. Degrades gracefully if a handler is already installed by the host
345/// application.
346///
347/// The policy is read **inside** the handler (at signal time) rather than
348/// at installation time, so a second [`install`] that updates
349/// [`AUTO_EXPORT_CFG`] but is skipped by the `ONCE` guard still takes
350/// effect from the next signal onward.
351///
352/// # Termination behaviour
353///
354/// After running the export, the handler acquires [`EXPORT_MUTEX`] (with a
355/// timeout of [`AutoExportConfig::exit_timeout`]) so any in-flight `do_export`
356/// on another thread can finish before `std::process::exit(130)` truncates it.
357/// This is necessary because the `ctrlc` crate REPLACES the OS default SIGINT
358/// handler — once we register a handler, the default "terminate the process"
359/// behaviour no longer fires. Hosts that want to keep running after SIGINT
360/// (e.g. a web server doing graceful shutdown) should set
361/// [`SignalPolicy::Off`] and install their own handler.
362///
363/// If the `atexit` feature is also enabled, the registered `atexit` callback
364/// will still run after `std::process::exit(130)`. The idempotency latch in
365/// [`export_for_reason`] makes this double-invocation safe (the second call is
366/// a no-op).
367#[cfg(feature = "auto-signal")]
368fn install_ctrlc_handler() {
369    // ctrlc::set_handler returns Err if a handler is already registered (for
370    // example by the host application). Degrade gracefully: log a warning and
371    // keep Drop + panic-hook coverage.
372    match ctrlc::set_handler(|| {
373        let (policy, exit_timeout) = AUTO_EXPORT_CFG
374            .read()
375            .as_ref()
376            .map(|c| (c.on_signal, c.exit_timeout))
377            .unwrap_or((SignalPolicy::Off, Duration::from_secs(5)));
378        if policy == SignalPolicy::Off {
379            return;
380        }
381        let _ = export_for_reason(ExportReason::Signal);
382        // Wait for any in-flight do_export on another thread to finish before
383        // yanking the rug out from under it with process::exit. try_lock_for
384        // returns None on timeout; in that case we exit anyway (best-effort)
385        // rather than hang forever. This turns a "truncated file" race into a
386        // "complete file, then exit". The guard is held until the (noreturn)
387        // process::exit call below, which is fine because the process is
388        // terminating anyway.
389        let _export_lock = EXPORT_MUTEX.try_lock_for(exit_timeout);
390        // The ctrlc crate REPLACES the OS default SIGINT handler, so the
391        // process would otherwise keep running. Exit with 130 (the
392        // conventional 128+SIGINT code) so the host sees a SIGINT-style exit
393        // and the shell reports "Interrupt: 13" / exit code 130. export_once
394        // is idempotent, so the Drop guard (if it ever runs) is a no-op.
395        std::process::exit(130);
396    }) {
397        Ok(()) => info!(
398            target: "memscope::lifecycle",
399            "ctrlc handler installed for auto-export",
400        ),
401        Err(e) => warn!(
402            target: "memscope::lifecycle",
403            error = %e,
404            "failed to install ctrlc handler (host may have one already); \
405             auto-export on Ctrl-C disabled, Drop + panic-hook still active",
406        ),
407    }
408}
409
410/// Register an `atexit` handler that calls [`export_for_reason`]`(Drop) on
411/// `std::process::exit`. This is the ONLY way to catch `std::process::exit`
412/// because Rust destructors do not run on explicit exit, but C `atexit`
413/// handlers do.
414///
415/// Unlike the old implementation which called [`export_once`] (using
416/// `ExportReason::OnDemand`), this now uses `ExportReason::Drop` so the
417/// `on_exit` config flag is honored: a user who set `on_exit = false` will
418/// NOT get an automatic export from the atexit callback. This fixes the bug
419/// where `on_exit = false` was bypassed, causing export attempts during TLS
420/// destruction which led to panic → abort.
421///
422/// # Safety
423///
424/// `libc::atexit` is an unsafe FFI call. The registered function must match
425/// the `extern "C" fn()` signature and must not capture state. `on_exit`
426/// reads only from process-global `static`s which outlive the call, and the
427/// process is exiting anyway, so the (non-async-signal-safe) file I/O inside
428/// [`export_for_reason`] is acceptable here.
429#[cfg(feature = "atexit")]
430fn install_atexit() {
431    extern "C" fn on_exit() {
432        // Use ExportReason::Drop so the on_exit config flag is consulted.
433        // When on_exit=false, this returns false immediately without touching
434        // the filesystem, TLS, or rendering.
435        //
436        // When on_exit=true, the export runs on a **dedicated thread** with
437        // fresh, guaranteed-valid thread-local storage. During process teardown
438        // (atexit), the current thread's TLS destructors may have already run,
439        // so calling `do_export` directly on the atexit thread risks panics
440        // from accessing destroyed TLS (tracing fmt layer, system_info CString
441        // caches, etc.). `catch_unwind` alone was insufficient because the
442        // rendering code also uses non-tracing TLS (e.g. `CString` thread
443        // locals in `system_info.rs`) that is not covered by `NoSubscriber`.
444        //
445        // The dedicated-thread approach is the E01-b TLS-safe execution path:
446        //   1. Read tracker + config + check enable flag + CAS the latch
447        //      (all on the atexit thread, touching only process-global
448        //      statics, no TLS with destructors).
449        //   2. Spawn a new OS thread to run `do_export`.
450        //   3. Join the thread so the export completes before atexit returns.
451        //
452        // Report completeness is verified by checking file existence after
453        // the export, not just the exit code.
454        let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
455            // Pre-check: read tracker + config on this thread (no TLS access).
456            let tracker = TRACKER_HANDLE.read().clone();
457            let cfg = AUTO_EXPORT_CFG.read().clone();
458            let (Some(tracker), Some(cfg)) = (tracker, cfg) else {
459                return;
460            };
461            if !cfg.on_exit {
462                return;
463            }
464            if EXPORTED.swap(true, Ordering::SeqCst) {
465                return;
466            }
467
468            // Run the export on a dedicated thread with fresh TLS.
469            //
470            // The export thread:
471            //   1. Suppresses allocator tracking (TrackingGuard) so its own
472            //      allocations don't enter the event stream or acquire
473            //      capture locks — this was the root cause of the deadlock
474            //      where export/watcher threads blocked on capture locks
475            //      held by the exiting thread.
476            //   2. Tries to acquire EXPORT_MUTEX with a bounded timeout.
477            //   3. Runs do_export.
478            //
479            // The join is bounded: after exit_timeout, we stop waiting and
480            // detach the export thread. We do NOT call process::exit(0)
481            // because that would overwrite the user's original exit code
482            // (e.g. exit(7) must preserve 7).
483            let exit_timeout = cfg.exit_timeout;
484            let handle = std::thread::Builder::new()
485                .name("memscope-atexit-export".to_string())
486                .spawn(move || {
487                    // Suppress allocator tracking during export to prevent
488                    // self-recording and capture-lock contention.
489                    let _suppression = crate::core::allocator::TrackingGuard::acquire();
490                    // Bounded wait on EXPORT_MUTEX.
491                    let Some(_export_guard) = EXPORT_MUTEX.try_lock_for(exit_timeout) else {
492                        tracing::warn!(
493                            "EXPORT_MUTEX could not be acquired within {:?}, skipping atexit export",
494                            exit_timeout
495                        );
496                        return;
497                    };
498                    do_export(&tracker, &cfg);
499                });
500
501            if let Ok(handle) = handle {
502                // Bounded join: poll the handle with short sleeps. After
503                // exit_timeout, stop waiting and detach the export thread.
504                // We do NOT call process::exit so the user's original exit
505                // code is preserved.
506                let deadline = std::time::Instant::now() + exit_timeout;
507                loop {
508                    if handle.is_finished() {
509                        let _ = handle.join();
510                        break;
511                    }
512                    if std::time::Instant::now() >= deadline {
513                        // Do NOT use tracing here: atexit runs after TLS
514                        // destructors may have fired, and tracing::warn!
515                        // accesses TLS (subscriber, fmt layer). Use eprintln
516                        // which touches only process-global stderr.
517                        eprintln!(
518                            "memscope: atexit export did not complete within {:?}, detaching export thread",
519                            exit_timeout
520                        );
521                        break;
522                    }
523                    std::thread::sleep(Duration::from_millis(10));
524                }
525            }
526        }));
527    }
528    // SAFETY: `on_exit` is an `extern "C" fn` with the correct signature for
529    // `libc::atexit` (`extern "C" fn()`). It captures no state and only reads
530    // process-global statics. The call registers the callback and returns 0 on
531    // success or non-zero on failure (e.g. too many handlers registered).
532    let rc = unsafe { libc::atexit(on_exit) };
533    if rc != 0 {
534        warn!(
535            target: "memscope::lifecycle",
536            rc,
537            "libc::atexit registration failed; auto-export on std::process::exit disabled",
538        );
539    }
540}
541
542/// Test-only reset of the global statics. NOT available in non-test builds.
543/// Use `#[serial]` (serial_test) on any test that calls this so concurrent
544/// tests do not clobber each other's state.
545#[cfg(test)]
546pub(crate) fn reset_for_test() {
547    EXPORTED.store(false, Ordering::SeqCst);
548    *TRACKER_HANDLE.write() = None;
549    *AUTO_EXPORT_CFG.write() = None;
550}
551
552// =========================================================================
553// Tests
554// =========================================================================
555
556#[cfg(test)]
557mod tests {
558    use super::*;
559    use crate::auto_export::ExportFormatSet;
560    use serial_test::serial;
561    use std::sync::atomic::AtomicUsize;
562    use tempfile::TempDir;
563
564    /// Build a fresh `GlobalTracker` wrapped in an `Arc`. Centralized so every
565    /// test starts from a known-clean tracker. `GlobalTracker::new()` also
566    /// registers a global async-tracker singleton; tests are `#[serial]` so
567    /// this side effect cannot race with another test.
568    fn fresh_tracker() -> Arc<GlobalTracker> {
569        Arc::new(GlobalTracker::new())
570    }
571
572    // ===================== Positive tests (happy path) ====================
573
574    /// Objective: Verify `do_export` writes both HTML and JSON artifacts when
575    /// `HTML_JSON` is selected, using a real tracker and a tempdir.
576    /// Invariants: `dashboard_unified_dashboard.html` and `memory_analysis.json`
577    /// exist after the call, and `do_export` returns `true`.
578    #[test]
579    #[serial]
580    fn do_export_writes_both_formats() {
581        let tracker = fresh_tracker();
582        let dir = TempDir::new().expect("tempdir creation must succeed in tests");
583        let cfg = AutoExportConfig::default().with_output_path(dir.path());
584        let cfg = cfg.with_formats(ExportFormatSet::HTML_JSON);
585
586        let did = do_export(&tracker, &cfg);
587        assert!(did, "do_export must return true when both formats succeed");
588
589        let html = dir.path().join("dashboard_unified_dashboard.html");
590        let json = dir.path().join("memory_analysis.json");
591        assert!(
592            html.exists(),
593            "HTML dashboard file must exist at {html:?} after a successful HTML export",
594        );
595        assert!(
596            json.exists(),
597            "memory_analysis.json must exist at {json:?} after a successful JSON export",
598        );
599    }
600
601    /// Objective: Verify `export_once` is idempotent — the first call after
602    /// `install` exports, the second is a no-op.
603    /// Invariants: First call returns `true`, second returns `false`; the
604    /// output directory is written exactly once.
605    #[test]
606    #[serial]
607    fn export_once_is_idempotent() {
608        reset_for_test();
609        let tracker = fresh_tracker();
610        let dir = TempDir::new().expect("tempdir creation must succeed in tests");
611        let cfg = AutoExportConfig::default().with_output_path(dir.path());
612        install(cfg, tracker).expect("install must not fail with parking_lot locks");
613
614        let first = export_once();
615        assert!(
616            first,
617            "first export_once after install must perform the export",
618        );
619        let second = export_once();
620        assert!(
621            !second,
622            "second export_once must be a no-op because EXPORTED is already set",
623        );
624
625        let html = dir.path().join("dashboard_unified_dashboard.html");
626        assert!(
627            html.exists(),
628            "the single export must have written the HTML dashboard",
629        );
630    }
631
632    /// Objective: Verify `trigger_export_now` re-arms the idempotency latch
633    /// and produces a fresh export, even after a prior `export_once`.
634    /// Invariants: `trigger_export_now` returns `true`; a subsequent
635    /// `export_once` returns `false` (latch re-set by trigger).
636    #[test]
637    #[serial]
638    fn trigger_export_now_re_arms_latch() {
639        reset_for_test();
640        let tracker = fresh_tracker();
641        let dir = TempDir::new().expect("tempdir creation must succeed in tests");
642        let cfg = AutoExportConfig::default().with_output_path(dir.path());
643        install(cfg, tracker).expect("install must succeed");
644
645        // Consume the one-shot export so EXPORTED is true.
646        let _ = export_once();
647
648        let triggered = trigger_export_now();
649        assert!(
650            triggered,
651            "trigger_export_now must reset EXPORTED and run a fresh export",
652        );
653        // After trigger, the latch is set again, so a plain export_once is a no-op.
654        let after = export_once();
655        assert!(
656            !after,
657            "export_once after trigger_export_now must be a no-op until the latch is reset again",
658        );
659    }
660
661    /// Objective: Verify `snapshot_json` returns a non-empty serialized report
662    /// containing the allocation-count field when a tracker is installed.
663    /// Invariants: Result is `Ok`, string is non-empty and contains
664    /// `"allocation_count"` (a field on `MemoryStatsReport`).
665    #[test]
666    #[serial]
667    fn snapshot_json_returns_serialized_report() {
668        reset_for_test();
669        let tracker = fresh_tracker();
670        let cfg = AutoExportConfig::default();
671        install(cfg, tracker).expect("install must succeed");
672
673        let snap = snapshot_json();
674        assert!(
675            snap.is_ok(),
676            "snapshot_json must succeed when a tracker is installed: {:?}",
677            snap.err(),
678        );
679        let snap = snap.expect("checked Ok above");
680        assert!(
681            !snap.is_empty(),
682            "serialized snapshot must not be the empty string",
683        );
684        assert!(
685            snap.contains("allocation_count"),
686            "snapshot must contain the allocation_count field; got: {snap}",
687        );
688    }
689
690    // ===================== Negative tests (edge cases) ====================
691
692    /// Objective: Verify `export_once` is safe when nothing is installed.
693    /// Invariants: Returns `false`, does not panic, and does NOT set EXPORTED
694    /// (so a later install+export can still proceed).
695    #[test]
696    #[serial]
697    fn export_once_without_install_returns_false() {
698        reset_for_test();
699        let did = export_once();
700        assert!(
701            !did,
702            "export_once with no tracker/cfg installed must return false",
703        );
704        assert!(
705            !EXPORTED.load(Ordering::SeqCst),
706            "EXPORTED must remain false so a later install can still export",
707        );
708    }
709
710    /// Objective: Verify `do_export` does nothing when no formats are selected.
711    /// Invariants: Returns `false` and writes no files to the output directory.
712    #[test]
713    #[serial]
714    fn do_export_empty_formats_writes_nothing() {
715        let tracker = fresh_tracker();
716        let dir = TempDir::new().expect("tempdir creation must succeed in tests");
717        let cfg = AutoExportConfig {
718            output_path: dir.path().to_path_buf(),
719            formats: ExportFormatSet::from_bits(0),
720            ..AutoExportConfig::default()
721        };
722
723        let did = do_export(&tracker, &cfg);
724        assert!(
725            !did,
726            "do_export with empty formats must return false (nothing to do)",
727        );
728        let entries = std::fs::read_dir(dir.path())
729            .expect("output dir must be readable")
730            .count();
731        assert_eq!(entries, 0, "no files must be written when formats is empty",);
732    }
733
734    /// Objective: Verify `do_export` creates a deeply nested output path via
735    /// `create_dir_all` and then writes the report into it.
736    /// Invariants: Returns `true`; the nested directory and both report files
737    /// exist after the call.
738    #[test]
739    #[serial]
740    fn do_export_creates_deeply_nested_output_path() {
741        let tracker = fresh_tracker();
742        let dir = TempDir::new().expect("tempdir creation must succeed in tests");
743        let deep = dir.path().join("a/b/c/d/e/report");
744        let cfg = AutoExportConfig::default().with_output_path(deep.clone());
745
746        let did = do_export(&tracker, &cfg);
747        assert!(
748            did,
749            "do_export must succeed after creating the nested output directory",
750        );
751        assert!(
752            deep.is_dir(),
753            "the nested output directory must have been created",
754        );
755        assert!(
756            deep.join("dashboard_unified_dashboard.html").exists(),
757            "HTML dashboard must exist inside the nested output path",
758        );
759        assert!(
760            deep.join("memory_analysis.json").exists(),
761            "memory_analysis.json must exist inside the nested output path",
762        );
763    }
764
765    /// Objective: Verify `do_export` fails gracefully (no panic) when the
766    /// output path cannot be created because a path component is a regular
767    /// file rather than a directory.
768    /// Invariants: Returns `false`; no panic; the blocker file is untouched.
769    #[test]
770    #[serial]
771    fn do_export_path_under_a_file_fails_gracefully() {
772        let tracker = fresh_tracker();
773        let dir = TempDir::new().expect("tempdir creation must succeed in tests");
774        // Create a regular file, then try to use a path underneath it as a dir.
775        let blocker = dir.path().join("blocker");
776        std::fs::write(&blocker, b"not a directory").expect("blocker file write must succeed");
777        let bad_output = blocker.join("sub");
778
779        let cfg = AutoExportConfig {
780            output_path: bad_output.clone(),
781            formats: ExportFormatSet::HTML_JSON,
782            ..AutoExportConfig::default()
783        };
784
785        let did = do_export(&tracker, &cfg);
786        assert!(
787            !did,
788            "do_export must return false when create_dir_all fails (path under a file)",
789        );
790        // The blocker file must still be a file (not clobbered into a directory).
791        assert!(
792            blocker.is_file(),
793            "the blocking file must remain a file after the failed export",
794        );
795    }
796
797    /// Objective: Verify `snapshot_json` returns `Err` (not a panic) when no
798    /// tracker is installed.
799    /// Invariants: Result is `Err` with a lifecycle error category.
800    #[test]
801    #[serial]
802    fn snapshot_json_without_tracker_errors() {
803        reset_for_test();
804        let res = snapshot_json();
805        let err = res.expect_err("snapshot_json must return Err when no tracker is installed");
806        assert_eq!(
807            err.category(),
808            "analysis",
809            "snapshot_json error must classify as an analysis error (module 'lifecycle')",
810        );
811    }
812
813    // ===================== Stress / concurrency tests =====================
814
815    /// Objective: Verify that 50 concurrent `export_once` calls result in
816    /// EXACTLY one export (idempotency latch holds under contention).
817    /// Invariants: Exactly one thread returns `true`; exactly one HTML file
818    /// and the primary JSON file exist after all threads join.
819    #[test]
820    #[serial]
821    fn stress_50_concurrent_exports_one_wins() {
822        reset_for_test();
823        let tracker = fresh_tracker();
824        let dir = TempDir::new().expect("tempdir creation must succeed in tests");
825        let cfg = AutoExportConfig::default().with_output_path(dir.path());
826        install(cfg, tracker).expect("install must succeed");
827
828        let wins = Arc::new(AtomicUsize::new(0));
829        let mut handles = Vec::with_capacity(50);
830        for _ in 0..50 {
831            let wins = wins.clone();
832            handles.push(std::thread::spawn(move || {
833                if export_once() {
834                    wins.fetch_add(1, Ordering::SeqCst);
835                }
836            }));
837        }
838        for h in handles {
839            h.join()
840                .expect("worker threads must not panic during the stress test");
841        }
842
843        assert_eq!(
844            wins.load(Ordering::SeqCst),
845            1,
846            "exactly one of the 50 concurrent calls must perform the export",
847        );
848
849        // Count HTML files: export_html writes exactly one (dashboard_unified_dashboard.html),
850        // so a count of 1 confirms no duplicate concurrent writes.
851        let html_count = std::fs::read_dir(dir.path())
852            .expect("output dir must be readable")
853            .filter_map(Result::ok)
854            .filter(|e| {
855                e.path()
856                    .extension()
857                    .map(|ext| ext == "html")
858                    .unwrap_or(false)
859            })
860            .count();
861        assert_eq!(
862            html_count, 1,
863            "exactly one HTML file must exist after the concurrent exports",
864        );
865        assert!(
866            dir.path().join("memory_analysis.json").exists(),
867            "the primary JSON file must exist after the winning export",
868        );
869    }
870
871    /// Objective: Verify that 20 concurrent `trigger_export_now` calls (which
872    /// reset the idempotency latch) do NOT produce corrupted/truncated report
873    /// files. This is the regression test for the `EXPORT_MUTEX` fix: without
874    /// the mutex serializing `do_export`, two concurrent triggers would reset
875    /// EXPORTED mid-flight and overlap two `do_export` calls on the same files.
876    /// Invariants: All threads join; the HTML file is non-empty and contains
877    /// the `memscope` template marker (proving it is not truncated).
878    #[test]
879    #[serial]
880    fn stress_concurrent_trigger_export_now_no_corrupted_writes() {
881        reset_for_test();
882        let tracker = fresh_tracker();
883        let dir = TempDir::new().expect("tempdir creation must succeed in tests");
884        let cfg = AutoExportConfig::default().with_output_path(dir.path());
885        install(cfg, tracker).expect("install must succeed");
886
887        let mut handles = Vec::with_capacity(20);
888        for _ in 0..20 {
889            handles.push(std::thread::spawn(|| {
890                let _ = trigger_export_now();
891            }));
892        }
893        for h in handles {
894            h.join().expect("trigger_export_now worker must not panic");
895        }
896
897        // After all concurrent triggers settle, the HTML file must be valid
898        // (non-empty and not truncated by a concurrent writer). A truncated
899        // write would either be empty or missing the template marker.
900        let html = dir.path().join("dashboard_unified_dashboard.html");
901        assert!(
902            html.exists(),
903            "HTML dashboard must exist after concurrent trigger_export_now calls",
904        );
905        let content = std::fs::read_to_string(&html)
906            .expect("HTML dashboard must be readable after concurrent exports");
907        assert!(
908            !content.is_empty(),
909            "HTML must not be empty/truncated by a concurrent writer",
910        );
911        assert!(
912            content.contains("memscope"),
913            "HTML must contain the 'memscope' template marker (not truncated mid-write)",
914        );
915    }
916
917    // ============== Per-path flag gating (on_exit / on_panic) ==============
918
919    /// Objective: Verify `export_for_reason(Drop)` honors `on_exit: false` —
920    /// the Drop path is skipped and does NOT consume the idempotency latch, so
921    /// a later `trigger_export_now` can still export.
922    /// Invariants: `export_for_reason(Drop)` returns `false`; `EXPORTED` stays
923    /// `false`; `trigger_export_now` then returns `true`.
924    #[test]
925    #[serial]
926    fn drop_reason_respects_on_exit_false() {
927        reset_for_test();
928        let tracker = fresh_tracker();
929        let dir = TempDir::new().expect("tempdir creation must succeed in tests");
930        let cfg = AutoExportConfig::default()
931            .with_output_path(dir.path())
932            .with_on_exit(false);
933        install(cfg, tracker).expect("install must succeed");
934
935        let did = export_for_reason(ExportReason::Drop);
936        assert!(
937            !did,
938            "export_for_reason(Drop) must return false when on_exit is false",
939        );
940        assert!(
941            !EXPORTED.load(Ordering::SeqCst),
942            "EXPORTED must remain false so a disabled path does not block later exports",
943        );
944
945        // A subsequent on-demand export must still succeed because the latch
946        // was not consumed.
947        let on_demand = trigger_export_now();
948        assert!(
949            on_demand,
950            "trigger_export_now must succeed after the disabled Drop path",
951        );
952    }
953
954    /// Objective: Verify `export_for_reason(Panic)` honors `on_panic: false` —
955    /// the Panic path is skipped and does NOT consume the idempotency latch.
956    /// Invariants: `export_for_reason(Panic)` returns `false`; `EXPORTED`
957    /// stays `false`.
958    #[test]
959    #[serial]
960    fn panic_reason_respects_on_panic_false() {
961        reset_for_test();
962        let tracker = fresh_tracker();
963        let dir = TempDir::new().expect("tempdir creation must succeed in tests");
964        let cfg = AutoExportConfig::default()
965            .with_output_path(dir.path())
966            .with_on_panic(false);
967        install(cfg, tracker).expect("install must succeed");
968
969        let did = export_for_reason(ExportReason::Panic);
970        assert!(
971            !did,
972            "export_for_reason(Panic) must return false when on_panic is false",
973        );
974        assert!(
975            !EXPORTED.load(Ordering::SeqCst),
976            "EXPORTED must remain false so the disabled panic path does not block later exports",
977        );
978    }
979}
980
981#[cfg(test)]
982mod acceptance_tests;