memscope-rs 0.2.6

A memory tracking library for Rust applications.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
//! Lifecycle orchestration for the auto-export subsystem.
//!
//! This is module 3 of the 4-module auto-export feature. It provides the
//! single idempotent [`export_once`] entry point that every exit path (the
//! Drop guard, the panic hook, the Ctrl-C handler, and the `atexit` callback)
//! funnels into, plus the one-time installation logic for those hooks.
//!
//! # Why this module is critical for `panic = "abort"` release builds
//!
//! `memscope-rs` release profiles set `panic = "abort"`. Under abort semantics
//! Rust destructors do NOT run when a panic occurs, so a plain Drop guard
//! cannot flush a report on panic. The Rust runtime DOES invoke the registered
//! panic hook before aborting, however, so installing a panic hook that calls
//! [`export_once`] is the only mechanism that produces a report on a release
//! panic. The hook is therefore installed unconditionally (not gated behind a
//! feature flag) and is carefully re-entrancy-safe: [`export_once`] swaps
//! [`EXPORTED`] to `true` BEFORE doing any work, so a panic raised by the
//! export itself cannot cause the re-entrant panic-hook invocation to loop.
//!
//! # Concurrency model
//!
//! - [`EXPORTED`] is an [`AtomicBool`] used as a once-only latch. It is set
//!   via `swap(true, SeqCst)` before the export work begins so that re-entrant
//!   invocations (e.g. a panic inside the panic hook) see it already set and
//!   return immediately.
//! - [`TRACKER_HANDLE`] and [`AUTO_EXPORT_CFG`] live in `parking_lot::RwLock`
//!   (not `OnceLock`) so tests can reset them between cases. The exit-path
//!   handlers clone the values out and drop the read guards before the
//!   potentially slow file I/O, so no lock is held across the export.
//! - [`HOOKS_INSTALLED`] is a `std::sync::Once` that guarantees the panic /
//!   ctrlc / atexit hooks are registered exactly once per process.

use std::cell::Cell;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Once};
use std::time::Duration;

use parking_lot::{Mutex, RwLock};
use tracing::{error, info, warn};

use crate::auto_export::{AutoExportConfig, ExportFormatSet, SignalPolicy};
use crate::capture::backends::global_tracking::GlobalTracker;
use crate::core::{MemScopeError, MemScopeResult};

/// Idempotency guard: once an exit-path export has run, no other exit path
/// re-exports. Set to `true` BEFORE the export work begins (via `swap`) so a
/// panic during export does not clear it and cause re-entrant export inside
/// the panic hook (which would recurse without this guard).
static EXPORTED: AtomicBool = AtomicBool::new(false);

/// Serializes the actual [`do_export`] work so that a [`trigger_export_now`]
/// resetting the idempotency latch while an export is in-flight cannot cause
/// two concurrent `do_export` calls to clobber the same output files. The
/// lock is held ONLY across `do_export` (not across the CAS or the tracker/cfg
/// reads), so no-op callers (which lose the CAS) never block on this mutex.
static EXPORT_MUTEX: Mutex<()> = Mutex::new(());

/// The tracker to export. Stored in a `RwLock<Option<...>>` (not `OnceLock`)
/// so tests can reset it between cases. `Arc<GlobalTracker>` because the
/// panic/ctrlc/atexit handlers need to read it without holding a borrow across
/// the (long, I/O-bound) export.
static TRACKER_HANDLE: RwLock<Option<Arc<GlobalTracker>>> = RwLock::new(None);

/// The config captured at [`install`] time. Read by the signal/panic/atexit
/// handlers which have no other way to receive arguments.
static AUTO_EXPORT_CFG: RwLock<Option<AutoExportConfig>> = RwLock::new(None);

/// Guards one-time installation of the panic/ctrlc/atexit hooks. `Once` is
/// appropriate because re-installing a panic hook would chain onto the
/// already-chained hook (harmless but wasteful), and `ctrlc::set_handler`
/// returns `Err` if a handler is already set.
static HOOKS_INSTALLED: Once = Once::new();

// Thread-local re-entrancy guard for the panic hook. When `true`, the current
// thread is already inside `install_panic_hook`'s closure (i.e. its own
// `export_for_reason` call panicked, re-entering the hook). The recursive
// invocation skips the body entirely so the chained `prev` hook is called
// exactly once — with the user's original panic, not the export panic.
thread_local! {
    static IN_PANIC_HOOK: Cell<bool> = const { Cell::new(false) };
}

/// Which exit path triggered an export. Used by [`export_for_reason`] to
/// consult the per-path enable flags (`on_exit`, `on_panic`) in
/// [`AutoExportConfig`] BEFORE consuming the one-shot idempotency latch, so a
/// disabled path does not prevent a later enabled path from exporting.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ExportReason {
    /// Triggered by `MemScopeGuard::drop` (normal return from `main`).
    /// Gated by [`AutoExportConfig::on_exit`].
    Drop,
    /// Triggered by the panic hook. Gated by [`AutoExportConfig::on_panic`].
    Panic,
    /// Triggered by `trigger_export_now` (on-demand or periodic flusher).
    /// Always allowed — the user explicitly asked for an export.
    OnDemand,
    /// Triggered by a signal handler (Ctrl-C / SIGTERM). The signal policy
    /// is checked separately inside the ctrlc handler, so this is always
    /// allowed once the handler has decided to proceed.
    Signal,
}

// =========================================================================
// Public API
// =========================================================================

/// Store the tracker + config and install all enabled exit hooks (panic hook
/// always; ctrlc handler if the `auto-signal` feature is enabled; atexit hook
/// if the `atexit` feature is enabled).
///
/// Idempotent: calling twice is safe — the tracker/cfg are overwritten (last
/// wins) and [`HOOKS_INSTALLED`] ensures hooks install only once per process.
///
/// # Errors
///
/// Returns [`MemScopeError`] only if storing the tracker/cfg fails. With
/// `parking_lot` (which never poisons) this does not happen in practice, but
/// the `Result` is preserved so callers can use `?` uniformly.
pub fn install(cfg: AutoExportConfig, tracker: Arc<GlobalTracker>) -> MemScopeResult<()> {
    // 1. Write cfg + tracker into the RwLocks. parking_lot write guards never
    //    poison, so these assignments are effectively infallible.
    *AUTO_EXPORT_CFG.write() = Some(cfg);
    *TRACKER_HANDLE.write() = Some(tracker);
    // 2. Install hooks exactly once per process. Re-running install() after
    //    the first call skips this block entirely.
    HOOKS_INSTALLED.call_once(|| {
        install_panic_hook();
        #[cfg(feature = "auto-signal")]
        install_ctrlc_handler();
        #[cfg(feature = "atexit")]
        install_atexit();
    });
    Ok(())
}

/// Idempotent export: writes HTML and/or JSON to the configured output path.
/// Called by the on-demand path (`trigger_export_now`, periodic flusher).
///
/// Equivalent to [`export_for_reason`]`(ExportReason::OnDemand)`. The exit
/// paths (Drop, panic, signal) call [`export_for_reason`] directly so their
/// per-path enable flags (`on_exit`, `on_panic`) are consulted.
///
/// Returns `true` if THIS call performed the export; `false` if a prior call
/// already exported, or if no tracker/cfg is installed.
///
/// # Re-entrancy safety
///
/// [`EXPORTED`] is set to `true` via `swap` BEFORE the export work begins. If
/// the export itself panics, the re-entrant panic-hook invocation sees
/// [`EXPORTED`] already `true` and returns `false` — no infinite recursion.
pub fn export_once() -> bool {
    export_for_reason(ExportReason::OnDemand)
}

/// Reason-aware idempotent export. Like [`export_once`] but consults the
/// per-path enable flag in [`AutoExportConfig`] before consuming the
/// idempotency latch, so a disabled exit path (e.g. `on_exit: false`) does NOT
/// prevent a later enabled path (e.g. `on_panic: true`) from exporting.
///
/// # Returns
///
/// `true` if THIS call performed the export; `false` if a prior call already
/// exported, the path is disabled, or no tracker/cfg is installed.
pub fn export_for_reason(reason: ExportReason) -> bool {
    let _suppression = crate::core::allocator::TrackingGuard::acquire();
    // Read tracker + cfg WITHOUT holding the read guards during the export.
    // Cloning an Arc is a single atomic increment; cloning the config is a
    // small allocation that is acceptable here and keeps the critical section
    // minimal.
    let tracker = TRACKER_HANDLE.read().clone();
    let cfg = AUTO_EXPORT_CFG.read().clone();
    let (Some(tracker), Some(cfg)) = (tracker, cfg) else {
        // Not installed — nothing to export. Do NOT set EXPORTED so a later
        // install + export can still proceed.
        return false;
    };
    // Check the per-path enable flag BEFORE the CAS. A disabled path must not
    // consume the one-shot latch, otherwise a later enabled path would see
    // EXPORTED==true and skip its own export.
    let enabled = match reason {
        ExportReason::Drop => cfg.on_exit,
        ExportReason::Panic => cfg.on_panic,
        // On-demand and signal paths are always allowed. The signal policy is
        // checked separately inside the ctrlc handler before it reaches here.
        ExportReason::OnDemand | ExportReason::Signal => true,
    };
    if !enabled {
        return false;
    }
    // CAS the idempotency guard. swap returns the OLD value; if old was true,
    // someone else already exported.
    if EXPORTED.swap(true, Ordering::SeqCst) {
        return false;
    }
    // Serialize the actual file I/O so a concurrent `trigger_export_now`
    // (which resets EXPORTED) cannot overlap a second `do_export` on the same
    // output files. The mutex is held only across do_export; CAS losers never
    // reach here, so no-op callers never block.
    let _export_guard = EXPORT_MUTEX.lock();
    do_export(&tracker, &cfg)
}

/// On-demand export from anywhere in user code. Resets [`EXPORTED`] then calls
/// [`export_once`], so it always exports AND re-arms the exit-path idempotency
/// guard (a later panic/Ctrl-C will produce a fresh report).
///
/// Returns `true` if the export ran.
pub fn trigger_export_now() -> bool {
    // Re-arm the latch first so the subsequent export_once CAS wins.
    EXPORTED.store(false, Ordering::SeqCst);
    export_once()
}

/// In-memory JSON snapshot (no disk write). Builds an [`AnalysisReport`] via
/// [`Analyzer`] and serializes it. Intended for HTTP endpoints that want live
/// metrics without touching the filesystem.
///
/// # Errors
///
/// Returns [`MemScopeError`] if no tracker is installed or serialization fails.
///
/// [`AnalysisReport`]: crate::analyzer::AnalysisReport
/// [`Analyzer`]: crate::analyzer::Analyzer
pub fn snapshot_json() -> MemScopeResult<String> {
    let _suppression = crate::core::allocator::TrackingGuard::acquire();
    let tracker = TRACKER_HANDLE.read().clone();
    let Some(tracker) = tracker else {
        return Err(MemScopeError::error(
            "lifecycle",
            "snapshot_json",
            "No tracker installed; call start() or install() first",
        ));
    };
    // The analyzer pipeline (MemoryView -> Analyzer -> analyze) produces an
    // AnalysisReport that derives serde::Serialize, so we can serialize it
    // directly without any disk I/O. This avoids the tempdir+export_json
    // fallback entirely and keeps snapshot_json side-effect free.
    let mut analyzer = crate::analyzer::Analyzer::from_tracker(&tracker);
    let report = analyzer.analyze();
    serde_json::to_string(&report).map_err(|e| {
        MemScopeError::error(
            "lifecycle",
            "snapshot_json",
            format!("Failed to serialize analysis report: {e}"),
        )
    })
}

// =========================================================================
// Private helpers
// =========================================================================

/// Core export logic, separated so it can be unit-tested with a real tracker
/// + tempdir WITHOUT touching the global statics.
///
/// Returns `true` iff at least one selected format was written successfully.
/// If `cfg.formats` is empty, returns `false` immediately without touching the
/// filesystem. If the output directory cannot be created, returns `false`
/// after logging (no format can write).
fn do_export(tracker: &GlobalTracker, cfg: &AutoExportConfig) -> bool {
    let _suppression = crate::core::allocator::TrackingGuard::acquire();
    // Nothing to do if no formats are selected. Returning early here keeps
    // the filesystem untouched and avoids creating empty report directories.
    if cfg.formats.is_empty() {
        return false;
    }
    let output = &cfg.output_path;
    // Pre-create the output directory. `create_dir_all` is idempotent (it
    // treats AlreadyExists as success), so this is safe even if the export
    // functions also create it. If creation fails — e.g. a path component is
    // a regular file rather than a directory — no format can write, so log
    // and bail gracefully instead of letting each export fail individually.
    if let Err(e) = std::fs::create_dir_all(output) {
        error!(
            target: "memscope::lifecycle",
            error = %e,
            path = ?output,
            "failed to create output directory; skipping export",
        );
        return false;
    }
    dispatch_formats(cfg.formats, |format| {
        let result = if format == ExportFormatSet::HTML {
            tracker.export_html(output)
        } else {
            tracker.export_json(output)
        };
        if let Err(error) = result {
            error!(target: "memscope::lifecycle", ?format, %error, "export failed");
            return false;
        }
        true
    })
}

fn dispatch_formats(
    formats: ExportFormatSet,
    mut write: impl FnMut(ExportFormatSet) -> bool,
) -> bool {
    let mut any_ok = false;
    for (selected, format) in [
        (formats.contains_html(), ExportFormatSet::HTML),
        (formats.contains_json(), ExportFormatSet::JSON),
    ] {
        if selected {
            any_ok |= write(format);
        }
    }
    any_ok
}

/// Install a panic hook that calls [`export_for_reason`]`(ExportReason::Panic)`
/// then chains to the previous hook. Runs even under `panic = "abort"` because
/// the Rust runtime invokes the panic hook before aborting.
///
/// The export is wrapped in [`catch_unwind`] so that a panic raised by the
/// export itself cannot propagate as a double-panic (which would skip the
/// chained hook). A thread-local re-entrancy guard ([`IN_PANIC_HOOK`]) ensures
/// the recursive hook invocation — triggered by a panic inside the export —
/// skips the body entirely, so the chained `prev` hook is called exactly once
/// (with the user's original panic, not the export panic).
fn install_panic_hook() {
    let prev = std::panic::take_hook();
    std::panic::set_hook(Box::new(move |info| {
        // Re-entrancy guard: if we are already inside this hook (i.e. our own
        // export_for_reason call panicked, re-entering the hook), skip the
        // body entirely. The outer invocation will call prev(info) with the
        // user's ORIGINAL panic — the one they care about.
        if IN_PANIC_HOOK.replace(true) {
            return;
        }
        // Best-effort export. catch_unwind contains any panic raised by the
        // export path so the chained (default) hook still prints the message.
        let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
            let _ = export_for_reason(ExportReason::Panic);
        }));
        IN_PANIC_HOOK.set(false);
        // Chain to the previous hook (default prints the panic location/message).
        prev(info);
    }));
}

/// Install a ctrlc handler that calls [`export_for_reason`]`(ExportReason::Signal)`
/// on SIGINT. Degrades gracefully if a handler is already installed by the host
/// application.
///
/// The policy is read **inside** the handler (at signal time) rather than
/// at installation time, so a second [`install`] that updates
/// [`AUTO_EXPORT_CFG`] but is skipped by the `ONCE` guard still takes
/// effect from the next signal onward.
///
/// # Termination behaviour
///
/// After running the export, the handler acquires [`EXPORT_MUTEX`] (with a
/// timeout of [`AutoExportConfig::exit_timeout`]) so any in-flight `do_export`
/// on another thread can finish before `std::process::exit(130)` truncates it.
/// This is necessary because the `ctrlc` crate REPLACES the OS default SIGINT
/// handler — once we register a handler, the default "terminate the process"
/// behaviour no longer fires. Hosts that want to keep running after SIGINT
/// (e.g. a web server doing graceful shutdown) should set
/// [`SignalPolicy::Off`] and install their own handler.
///
/// If the `atexit` feature is also enabled, the registered `atexit` callback
/// will still run after `std::process::exit(130)`. The idempotency latch in
/// [`export_for_reason`] makes this double-invocation safe (the second call is
/// a no-op).
#[cfg(feature = "auto-signal")]
fn install_ctrlc_handler() {
    // ctrlc::set_handler returns Err if a handler is already registered (for
    // example by the host application). Degrade gracefully: log a warning and
    // keep Drop + panic-hook coverage.
    match ctrlc::set_handler(|| {
        let (policy, exit_timeout) = AUTO_EXPORT_CFG
            .read()
            .as_ref()
            .map(|c| (c.on_signal, c.exit_timeout))
            .unwrap_or((SignalPolicy::Off, Duration::from_secs(5)));
        if policy == SignalPolicy::Off {
            return;
        }
        let _ = export_for_reason(ExportReason::Signal);
        // Wait for any in-flight do_export on another thread to finish before
        // yanking the rug out from under it with process::exit. try_lock_for
        // returns None on timeout; in that case we exit anyway (best-effort)
        // rather than hang forever. This turns a "truncated file" race into a
        // "complete file, then exit". The guard is held until the (noreturn)
        // process::exit call below, which is fine because the process is
        // terminating anyway.
        let _export_lock = EXPORT_MUTEX.try_lock_for(exit_timeout);
        // The ctrlc crate REPLACES the OS default SIGINT handler, so the
        // process would otherwise keep running. Exit with 130 (the
        // conventional 128+SIGINT code) so the host sees a SIGINT-style exit
        // and the shell reports "Interrupt: 13" / exit code 130. export_once
        // is idempotent, so the Drop guard (if it ever runs) is a no-op.
        std::process::exit(130);
    }) {
        Ok(()) => info!(
            target: "memscope::lifecycle",
            "ctrlc handler installed for auto-export",
        ),
        Err(e) => warn!(
            target: "memscope::lifecycle",
            error = %e,
            "failed to install ctrlc handler (host may have one already); \
             auto-export on Ctrl-C disabled, Drop + panic-hook still active",
        ),
    }
}

/// Register an `atexit` handler that calls [`export_for_reason`]`(Drop) on
/// `std::process::exit`. This is the ONLY way to catch `std::process::exit`
/// because Rust destructors do not run on explicit exit, but C `atexit`
/// handlers do.
///
/// Unlike the old implementation which called [`export_once`] (using
/// `ExportReason::OnDemand`), this now uses `ExportReason::Drop` so the
/// `on_exit` config flag is honored: a user who set `on_exit = false` will
/// NOT get an automatic export from the atexit callback. This fixes the bug
/// where `on_exit = false` was bypassed, causing export attempts during TLS
/// destruction which led to panic → abort.
///
/// # Safety
///
/// `libc::atexit` is an unsafe FFI call. The registered function must match
/// the `extern "C" fn()` signature and must not capture state. `on_exit`
/// reads only from process-global `static`s which outlive the call, and the
/// process is exiting anyway, so the (non-async-signal-safe) file I/O inside
/// [`export_for_reason`] is acceptable here.
#[cfg(feature = "atexit")]
fn install_atexit() {
    extern "C" fn on_exit() {
        // Use ExportReason::Drop so the on_exit config flag is consulted.
        // When on_exit=false, this returns false immediately without touching
        // the filesystem, TLS, or rendering.
        //
        // When on_exit=true, the export runs on a **dedicated thread** with
        // fresh, guaranteed-valid thread-local storage. During process teardown
        // (atexit), the current thread's TLS destructors may have already run,
        // so calling `do_export` directly on the atexit thread risks panics
        // from accessing destroyed TLS (tracing fmt layer, system_info CString
        // caches, etc.). `catch_unwind` alone was insufficient because the
        // rendering code also uses non-tracing TLS (e.g. `CString` thread
        // locals in `system_info.rs`) that is not covered by `NoSubscriber`.
        //
        // The dedicated-thread approach is the E01-b TLS-safe execution path:
        //   1. Read tracker + config + check enable flag + CAS the latch
        //      (all on the atexit thread, touching only process-global
        //      statics, no TLS with destructors).
        //   2. Spawn a new OS thread to run `do_export`.
        //   3. Join the thread so the export completes before atexit returns.
        //
        // Report completeness is verified by checking file existence after
        // the export, not just the exit code.
        let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
            // Pre-check: read tracker + config on this thread (no TLS access).
            let tracker = TRACKER_HANDLE.read().clone();
            let cfg = AUTO_EXPORT_CFG.read().clone();
            let (Some(tracker), Some(cfg)) = (tracker, cfg) else {
                return;
            };
            if !cfg.on_exit {
                return;
            }
            if EXPORTED.swap(true, Ordering::SeqCst) {
                return;
            }

            // Run the export on a dedicated thread with fresh TLS.
            //
            // The export thread:
            //   1. Suppresses allocator tracking (TrackingGuard) so its own
            //      allocations don't enter the event stream or acquire
            //      capture locks — this was the root cause of the deadlock
            //      where export/watcher threads blocked on capture locks
            //      held by the exiting thread.
            //   2. Tries to acquire EXPORT_MUTEX with a bounded timeout.
            //   3. Runs do_export.
            //
            // The join is bounded: after exit_timeout, we stop waiting and
            // detach the export thread. We do NOT call process::exit(0)
            // because that would overwrite the user's original exit code
            // (e.g. exit(7) must preserve 7).
            let exit_timeout = cfg.exit_timeout;
            let handle = std::thread::Builder::new()
                .name("memscope-atexit-export".to_string())
                .spawn(move || {
                    // Suppress allocator tracking during export to prevent
                    // self-recording and capture-lock contention.
                    let _suppression = crate::core::allocator::TrackingGuard::acquire();
                    // Bounded wait on EXPORT_MUTEX.
                    let Some(_export_guard) = EXPORT_MUTEX.try_lock_for(exit_timeout) else {
                        tracing::warn!(
                            "EXPORT_MUTEX could not be acquired within {:?}, skipping atexit export",
                            exit_timeout
                        );
                        return;
                    };
                    do_export(&tracker, &cfg);
                });

            if let Ok(handle) = handle {
                // Bounded join: poll the handle with short sleeps. After
                // exit_timeout, stop waiting and detach the export thread.
                // We do NOT call process::exit so the user's original exit
                // code is preserved.
                let deadline = std::time::Instant::now() + exit_timeout;
                loop {
                    if handle.is_finished() {
                        let _ = handle.join();
                        break;
                    }
                    if std::time::Instant::now() >= deadline {
                        // Do NOT use tracing here: atexit runs after TLS
                        // destructors may have fired, and tracing::warn!
                        // accesses TLS (subscriber, fmt layer). Use eprintln
                        // which touches only process-global stderr.
                        eprintln!(
                            "memscope: atexit export did not complete within {:?}, detaching export thread",
                            exit_timeout
                        );
                        break;
                    }
                    std::thread::sleep(Duration::from_millis(10));
                }
            }
        }));
    }
    // SAFETY: `on_exit` is an `extern "C" fn` with the correct signature for
    // `libc::atexit` (`extern "C" fn()`). It captures no state and only reads
    // process-global statics. The call registers the callback and returns 0 on
    // success or non-zero on failure (e.g. too many handlers registered).
    let rc = unsafe { libc::atexit(on_exit) };
    if rc != 0 {
        warn!(
            target: "memscope::lifecycle",
            rc,
            "libc::atexit registration failed; auto-export on std::process::exit disabled",
        );
    }
}

/// Test-only reset of the global statics. NOT available in non-test builds.
/// Use `#[serial]` (serial_test) on any test that calls this so concurrent
/// tests do not clobber each other's state.
#[cfg(test)]
pub(crate) fn reset_for_test() {
    EXPORTED.store(false, Ordering::SeqCst);
    *TRACKER_HANDLE.write() = None;
    *AUTO_EXPORT_CFG.write() = None;
}

// =========================================================================
// Tests
// =========================================================================

#[cfg(test)]
mod tests {
    use super::*;
    use crate::auto_export::ExportFormatSet;
    use serial_test::serial;
    use std::sync::atomic::AtomicUsize;
    use tempfile::TempDir;

    /// Build a fresh `GlobalTracker` wrapped in an `Arc`. Centralized so every
    /// test starts from a known-clean tracker. `GlobalTracker::new()` also
    /// registers a global async-tracker singleton; tests are `#[serial]` so
    /// this side effect cannot race with another test.
    fn fresh_tracker() -> Arc<GlobalTracker> {
        Arc::new(GlobalTracker::new())
    }

    // ===================== Positive tests (happy path) ====================

    /// Objective: Verify `do_export` writes both HTML and JSON artifacts when
    /// `HTML_JSON` is selected, using a real tracker and a tempdir.
    /// Invariants: `dashboard_unified_dashboard.html` and `memory_analysis.json`
    /// exist after the call, and `do_export` returns `true`.
    #[test]
    #[serial]
    fn do_export_writes_both_formats() {
        let tracker = fresh_tracker();
        let dir = TempDir::new().expect("tempdir creation must succeed in tests");
        let cfg = AutoExportConfig::default().with_output_path(dir.path());
        let cfg = cfg.with_formats(ExportFormatSet::HTML_JSON);

        let did = do_export(&tracker, &cfg);
        assert!(did, "do_export must return true when both formats succeed");

        let html = dir.path().join("dashboard_unified_dashboard.html");
        let json = dir.path().join("memory_analysis.json");
        assert!(
            html.exists(),
            "HTML dashboard file must exist at {html:?} after a successful HTML export",
        );
        assert!(
            json.exists(),
            "memory_analysis.json must exist at {json:?} after a successful JSON export",
        );
    }

    /// Objective: Verify `export_once` is idempotent — the first call after
    /// `install` exports, the second is a no-op.
    /// Invariants: First call returns `true`, second returns `false`; the
    /// output directory is written exactly once.
    #[test]
    #[serial]
    fn export_once_is_idempotent() {
        reset_for_test();
        let tracker = fresh_tracker();
        let dir = TempDir::new().expect("tempdir creation must succeed in tests");
        let cfg = AutoExportConfig::default().with_output_path(dir.path());
        install(cfg, tracker).expect("install must not fail with parking_lot locks");

        let first = export_once();
        assert!(
            first,
            "first export_once after install must perform the export",
        );
        let second = export_once();
        assert!(
            !second,
            "second export_once must be a no-op because EXPORTED is already set",
        );

        let html = dir.path().join("dashboard_unified_dashboard.html");
        assert!(
            html.exists(),
            "the single export must have written the HTML dashboard",
        );
    }

    /// Objective: Verify `trigger_export_now` re-arms the idempotency latch
    /// and produces a fresh export, even after a prior `export_once`.
    /// Invariants: `trigger_export_now` returns `true`; a subsequent
    /// `export_once` returns `false` (latch re-set by trigger).
    #[test]
    #[serial]
    fn trigger_export_now_re_arms_latch() {
        reset_for_test();
        let tracker = fresh_tracker();
        let dir = TempDir::new().expect("tempdir creation must succeed in tests");
        let cfg = AutoExportConfig::default().with_output_path(dir.path());
        install(cfg, tracker).expect("install must succeed");

        // Consume the one-shot export so EXPORTED is true.
        let _ = export_once();

        let triggered = trigger_export_now();
        assert!(
            triggered,
            "trigger_export_now must reset EXPORTED and run a fresh export",
        );
        // After trigger, the latch is set again, so a plain export_once is a no-op.
        let after = export_once();
        assert!(
            !after,
            "export_once after trigger_export_now must be a no-op until the latch is reset again",
        );
    }

    /// Objective: Verify `snapshot_json` returns a non-empty serialized report
    /// containing the allocation-count field when a tracker is installed.
    /// Invariants: Result is `Ok`, string is non-empty and contains
    /// `"allocation_count"` (a field on `MemoryStatsReport`).
    #[test]
    #[serial]
    fn snapshot_json_returns_serialized_report() {
        reset_for_test();
        let tracker = fresh_tracker();
        let cfg = AutoExportConfig::default();
        install(cfg, tracker).expect("install must succeed");

        let snap = snapshot_json();
        assert!(
            snap.is_ok(),
            "snapshot_json must succeed when a tracker is installed: {:?}",
            snap.err(),
        );
        let snap = snap.expect("checked Ok above");
        assert!(
            !snap.is_empty(),
            "serialized snapshot must not be the empty string",
        );
        assert!(
            snap.contains("allocation_count"),
            "snapshot must contain the allocation_count field; got: {snap}",
        );
    }

    // ===================== Negative tests (edge cases) ====================

    /// Objective: Verify `export_once` is safe when nothing is installed.
    /// Invariants: Returns `false`, does not panic, and does NOT set EXPORTED
    /// (so a later install+export can still proceed).
    #[test]
    #[serial]
    fn export_once_without_install_returns_false() {
        reset_for_test();
        let did = export_once();
        assert!(
            !did,
            "export_once with no tracker/cfg installed must return false",
        );
        assert!(
            !EXPORTED.load(Ordering::SeqCst),
            "EXPORTED must remain false so a later install can still export",
        );
    }

    /// Objective: Verify `do_export` does nothing when no formats are selected.
    /// Invariants: Returns `false` and writes no files to the output directory.
    #[test]
    #[serial]
    fn do_export_empty_formats_writes_nothing() {
        let tracker = fresh_tracker();
        let dir = TempDir::new().expect("tempdir creation must succeed in tests");
        let cfg = AutoExportConfig {
            output_path: dir.path().to_path_buf(),
            formats: ExportFormatSet::from_bits(0),
            ..AutoExportConfig::default()
        };

        let did = do_export(&tracker, &cfg);
        assert!(
            !did,
            "do_export with empty formats must return false (nothing to do)",
        );
        let entries = std::fs::read_dir(dir.path())
            .expect("output dir must be readable")
            .count();
        assert_eq!(entries, 0, "no files must be written when formats is empty",);
    }

    /// Objective: Verify `do_export` creates a deeply nested output path via
    /// `create_dir_all` and then writes the report into it.
    /// Invariants: Returns `true`; the nested directory and both report files
    /// exist after the call.
    #[test]
    #[serial]
    fn do_export_creates_deeply_nested_output_path() {
        let tracker = fresh_tracker();
        let dir = TempDir::new().expect("tempdir creation must succeed in tests");
        let deep = dir.path().join("a/b/c/d/e/report");
        let cfg = AutoExportConfig::default().with_output_path(deep.clone());

        let did = do_export(&tracker, &cfg);
        assert!(
            did,
            "do_export must succeed after creating the nested output directory",
        );
        assert!(
            deep.is_dir(),
            "the nested output directory must have been created",
        );
        assert!(
            deep.join("dashboard_unified_dashboard.html").exists(),
            "HTML dashboard must exist inside the nested output path",
        );
        assert!(
            deep.join("memory_analysis.json").exists(),
            "memory_analysis.json must exist inside the nested output path",
        );
    }

    /// Objective: Verify `do_export` fails gracefully (no panic) when the
    /// output path cannot be created because a path component is a regular
    /// file rather than a directory.
    /// Invariants: Returns `false`; no panic; the blocker file is untouched.
    #[test]
    #[serial]
    fn do_export_path_under_a_file_fails_gracefully() {
        let tracker = fresh_tracker();
        let dir = TempDir::new().expect("tempdir creation must succeed in tests");
        // Create a regular file, then try to use a path underneath it as a dir.
        let blocker = dir.path().join("blocker");
        std::fs::write(&blocker, b"not a directory").expect("blocker file write must succeed");
        let bad_output = blocker.join("sub");

        let cfg = AutoExportConfig {
            output_path: bad_output.clone(),
            formats: ExportFormatSet::HTML_JSON,
            ..AutoExportConfig::default()
        };

        let did = do_export(&tracker, &cfg);
        assert!(
            !did,
            "do_export must return false when create_dir_all fails (path under a file)",
        );
        // The blocker file must still be a file (not clobbered into a directory).
        assert!(
            blocker.is_file(),
            "the blocking file must remain a file after the failed export",
        );
    }

    /// Objective: Verify `snapshot_json` returns `Err` (not a panic) when no
    /// tracker is installed.
    /// Invariants: Result is `Err` with a lifecycle error category.
    #[test]
    #[serial]
    fn snapshot_json_without_tracker_errors() {
        reset_for_test();
        let res = snapshot_json();
        let err = res.expect_err("snapshot_json must return Err when no tracker is installed");
        assert_eq!(
            err.category(),
            "analysis",
            "snapshot_json error must classify as an analysis error (module 'lifecycle')",
        );
    }

    // ===================== Stress / concurrency tests =====================

    /// Objective: Verify that 50 concurrent `export_once` calls result in
    /// EXACTLY one export (idempotency latch holds under contention).
    /// Invariants: Exactly one thread returns `true`; exactly one HTML file
    /// and the primary JSON file exist after all threads join.
    #[test]
    #[serial]
    fn stress_50_concurrent_exports_one_wins() {
        reset_for_test();
        let tracker = fresh_tracker();
        let dir = TempDir::new().expect("tempdir creation must succeed in tests");
        let cfg = AutoExportConfig::default().with_output_path(dir.path());
        install(cfg, tracker).expect("install must succeed");

        let wins = Arc::new(AtomicUsize::new(0));
        let mut handles = Vec::with_capacity(50);
        for _ in 0..50 {
            let wins = wins.clone();
            handles.push(std::thread::spawn(move || {
                if export_once() {
                    wins.fetch_add(1, Ordering::SeqCst);
                }
            }));
        }
        for h in handles {
            h.join()
                .expect("worker threads must not panic during the stress test");
        }

        assert_eq!(
            wins.load(Ordering::SeqCst),
            1,
            "exactly one of the 50 concurrent calls must perform the export",
        );

        // Count HTML files: export_html writes exactly one (dashboard_unified_dashboard.html),
        // so a count of 1 confirms no duplicate concurrent writes.
        let html_count = std::fs::read_dir(dir.path())
            .expect("output dir must be readable")
            .filter_map(Result::ok)
            .filter(|e| {
                e.path()
                    .extension()
                    .map(|ext| ext == "html")
                    .unwrap_or(false)
            })
            .count();
        assert_eq!(
            html_count, 1,
            "exactly one HTML file must exist after the concurrent exports",
        );
        assert!(
            dir.path().join("memory_analysis.json").exists(),
            "the primary JSON file must exist after the winning export",
        );
    }

    /// Objective: Verify that 20 concurrent `trigger_export_now` calls (which
    /// reset the idempotency latch) do NOT produce corrupted/truncated report
    /// files. This is the regression test for the `EXPORT_MUTEX` fix: without
    /// the mutex serializing `do_export`, two concurrent triggers would reset
    /// EXPORTED mid-flight and overlap two `do_export` calls on the same files.
    /// Invariants: All threads join; the HTML file is non-empty and contains
    /// the `memscope` template marker (proving it is not truncated).
    #[test]
    #[serial]
    fn stress_concurrent_trigger_export_now_no_corrupted_writes() {
        reset_for_test();
        let tracker = fresh_tracker();
        let dir = TempDir::new().expect("tempdir creation must succeed in tests");
        let cfg = AutoExportConfig::default().with_output_path(dir.path());
        install(cfg, tracker).expect("install must succeed");

        let mut handles = Vec::with_capacity(20);
        for _ in 0..20 {
            handles.push(std::thread::spawn(|| {
                let _ = trigger_export_now();
            }));
        }
        for h in handles {
            h.join().expect("trigger_export_now worker must not panic");
        }

        // After all concurrent triggers settle, the HTML file must be valid
        // (non-empty and not truncated by a concurrent writer). A truncated
        // write would either be empty or missing the template marker.
        let html = dir.path().join("dashboard_unified_dashboard.html");
        assert!(
            html.exists(),
            "HTML dashboard must exist after concurrent trigger_export_now calls",
        );
        let content = std::fs::read_to_string(&html)
            .expect("HTML dashboard must be readable after concurrent exports");
        assert!(
            !content.is_empty(),
            "HTML must not be empty/truncated by a concurrent writer",
        );
        assert!(
            content.contains("memscope"),
            "HTML must contain the 'memscope' template marker (not truncated mid-write)",
        );
    }

    // ============== Per-path flag gating (on_exit / on_panic) ==============

    /// Objective: Verify `export_for_reason(Drop)` honors `on_exit: false` —
    /// the Drop path is skipped and does NOT consume the idempotency latch, so
    /// a later `trigger_export_now` can still export.
    /// Invariants: `export_for_reason(Drop)` returns `false`; `EXPORTED` stays
    /// `false`; `trigger_export_now` then returns `true`.
    #[test]
    #[serial]
    fn drop_reason_respects_on_exit_false() {
        reset_for_test();
        let tracker = fresh_tracker();
        let dir = TempDir::new().expect("tempdir creation must succeed in tests");
        let cfg = AutoExportConfig::default()
            .with_output_path(dir.path())
            .with_on_exit(false);
        install(cfg, tracker).expect("install must succeed");

        let did = export_for_reason(ExportReason::Drop);
        assert!(
            !did,
            "export_for_reason(Drop) must return false when on_exit is false",
        );
        assert!(
            !EXPORTED.load(Ordering::SeqCst),
            "EXPORTED must remain false so a disabled path does not block later exports",
        );

        // A subsequent on-demand export must still succeed because the latch
        // was not consumed.
        let on_demand = trigger_export_now();
        assert!(
            on_demand,
            "trigger_export_now must succeed after the disabled Drop path",
        );
    }

    /// Objective: Verify `export_for_reason(Panic)` honors `on_panic: false` —
    /// the Panic path is skipped and does NOT consume the idempotency latch.
    /// Invariants: `export_for_reason(Panic)` returns `false`; `EXPORTED`
    /// stays `false`.
    #[test]
    #[serial]
    fn panic_reason_respects_on_panic_false() {
        reset_for_test();
        let tracker = fresh_tracker();
        let dir = TempDir::new().expect("tempdir creation must succeed in tests");
        let cfg = AutoExportConfig::default()
            .with_output_path(dir.path())
            .with_on_panic(false);
        install(cfg, tracker).expect("install must succeed");

        let did = export_for_reason(ExportReason::Panic);
        assert!(
            !did,
            "export_for_reason(Panic) must return false when on_panic is false",
        );
        assert!(
            !EXPORTED.load(Ordering::SeqCst),
            "EXPORTED must remain false so the disabled panic path does not block later exports",
        );
    }
}

#[cfg(test)]
mod acceptance_tests;