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, SignalPolicy};
use crate::capture::backends::global_tracking::GlobalTracker;
use crate::core::{MemScopeError, MemScopeResult};
static EXPORTED: AtomicBool = AtomicBool::new(false);
static EXPORT_MUTEX: Mutex<()> = Mutex::new(());
static TRACKER_HANDLE: RwLock<Option<Arc<GlobalTracker>>> = RwLock::new(None);
static AUTO_EXPORT_CFG: RwLock<Option<AutoExportConfig>> = RwLock::new(None);
static HOOKS_INSTALLED: Once = Once::new();
thread_local! {
static IN_PANIC_HOOK: Cell<bool> = const { Cell::new(false) };
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ExportReason {
Drop,
Panic,
OnDemand,
Signal,
}
pub fn install(cfg: AutoExportConfig, tracker: Arc<GlobalTracker>) -> MemScopeResult<()> {
*AUTO_EXPORT_CFG.write() = Some(cfg);
*TRACKER_HANDLE.write() = Some(tracker);
HOOKS_INSTALLED.call_once(|| {
install_panic_hook();
#[cfg(feature = "auto-signal")]
install_ctrlc_handler();
#[cfg(feature = "atexit")]
install_atexit();
});
Ok(())
}
pub fn export_once() -> bool {
export_for_reason(ExportReason::OnDemand)
}
pub fn export_for_reason(reason: ExportReason) -> bool {
let tracker = TRACKER_HANDLE.read().clone();
let cfg = AUTO_EXPORT_CFG.read().clone();
let (Some(tracker), Some(cfg)) = (tracker, cfg) else {
return false;
};
let enabled = match reason {
ExportReason::Drop => cfg.on_exit,
ExportReason::Panic => cfg.on_panic,
ExportReason::OnDemand | ExportReason::Signal => true,
};
if !enabled {
return false;
}
if EXPORTED.swap(true, Ordering::SeqCst) {
return false;
}
let _export_guard = EXPORT_MUTEX.lock();
do_export(&tracker, &cfg)
}
pub fn trigger_export_now() -> bool {
EXPORTED.store(false, Ordering::SeqCst);
export_once()
}
pub fn snapshot_json() -> MemScopeResult<String> {
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",
));
};
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}"),
)
})
}
fn do_export(tracker: &GlobalTracker, cfg: &AutoExportConfig) -> bool {
if cfg.formats.is_empty() {
return false;
}
let output = &cfg.output_path;
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;
}
let mut ok = false;
if cfg.wants_html() {
if let Err(e) = tracker.export_html(output) {
error!(
target: "memscope::lifecycle",
error = %e,
"HTML export failed; continuing to JSON if requested",
);
} else {
ok = true;
}
}
if cfg.wants_json() {
if let Err(e) = tracker.export_json(output) {
error!(
target: "memscope::lifecycle",
error = %e,
"JSON export failed",
);
} else {
ok = true;
}
}
ok
}
fn install_panic_hook() {
let prev = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
if IN_PANIC_HOOK.replace(true) {
return;
}
let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let _ = export_for_reason(ExportReason::Panic);
}));
IN_PANIC_HOOK.set(false);
prev(info);
}));
}
#[cfg(feature = "auto-signal")]
fn install_ctrlc_handler() {
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);
let _export_lock = EXPORT_MUTEX.try_lock_for(exit_timeout);
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",
),
}
}
#[cfg(feature = "atexit")]
fn install_atexit() {
extern "C" fn on_exit() {
let _ = export_once();
}
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",
);
}
}
#[cfg(test)]
pub(crate) fn reset_for_test() {
EXPORTED.store(false, Ordering::SeqCst);
*TRACKER_HANDLE.write() = None;
*AUTO_EXPORT_CFG.write() = None;
}
#[cfg(test)]
mod tests {
use super::*;
use crate::auto_export::{ExportFormatSet, SignalPolicy};
use proptest::prelude::*;
use proptest::test_runner::TestRunner;
use serial_test::serial;
use std::sync::atomic::AtomicUsize;
use std::time::Duration;
use tempfile::TempDir;
fn fresh_tracker() -> Arc<GlobalTracker> {
Arc::new(GlobalTracker::new())
}
#[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",
);
}
#[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",
);
}
#[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");
let _ = export_once();
let triggered = trigger_export_now();
assert!(
triggered,
"trigger_export_now must reset EXPORTED and run a fresh export",
);
let after = export_once();
assert!(
!after,
"export_once after trigger_export_now must be a no-op until the latch is reset again",
);
}
#[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}",
);
}
#[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",
);
}
#[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",);
}
#[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",
);
}
#[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");
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)",
);
assert!(
blocker.is_file(),
"the blocking file must remain a file after the failed export",
);
}
#[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')",
);
}
#[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",
);
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",
);
}
#[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");
}
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)",
);
}
#[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",
);
let on_demand = trigger_export_now();
assert!(
on_demand,
"trigger_export_now must succeed after the disabled Drop path",
);
}
#[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",
);
}
#[test]
#[serial]
fn panic_hook_chains_and_exports() {
reset_for_test();
let tracker = fresh_tracker();
let tempdir = TempDir::new().expect("tempdir creation must succeed in tests");
let dir = tempdir.path().to_path_buf();
let cfg = AutoExportConfig::default().with_output_path(dir.clone());
*TRACKER_HANDLE.write() = Some(tracker);
*AUTO_EXPORT_CFG.write() = Some(cfg);
let sentinel_fired = Arc::new(AtomicBool::new(false));
let sf = sentinel_fired.clone();
std::panic::set_hook(Box::new(move |_| {
sf.store(true, Ordering::SeqCst);
}));
install_panic_hook();
let handle = std::thread::spawn(|| {
panic!("lifecycle test panic");
});
let join_err = handle.join();
assert!(
join_err.is_err(),
"the panicking thread must propagate the panic to join() as Err",
);
assert!(
sentinel_fired.load(Ordering::SeqCst),
"the sentinel (previous) hook must have run — panic-hook chaining is broken",
);
assert!(
EXPORTED.load(Ordering::SeqCst),
"EXPORTED must be true — our panic hook must have called export_once",
);
assert!(
dir.join("dashboard_unified_dashboard.html").exists(),
"the panic-hook export must have written the HTML dashboard",
);
let _ = std::panic::take_hook();
}
#[test]
#[serial]
fn proptest_do_export_never_panics_on_valid_configs() {
let tracker = GlobalTracker::new();
let mut runner = TestRunner::new(ProptestConfig {
cases: 200,
..ProptestConfig::default()
});
let strategy = (
0u8..4u8,
any::<bool>(),
any::<bool>(),
prop_oneof![
Just(SignalPolicy::Off),
Just(SignalPolicy::CtrlC),
Just(SignalPolicy::CtrlCAndTerm),
],
prop_oneof![
Just(None),
(1u64..100u64).prop_map(|ms| Some(Duration::from_millis(ms))),
],
);
runner
.run(&strategy, |(bits, on_exit, on_panic, on_signal, flush)| {
let tempdir = TempDir::new().expect("per-case tempdir must succeed in proptest");
let cfg = AutoExportConfig {
output_path: tempdir.path().to_path_buf(),
formats: ExportFormatSet::from_bits(bits),
on_exit,
on_panic,
on_signal,
flush_interval: flush,
exit_timeout: Duration::from_secs(5),
};
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
do_export(&tracker, &cfg)
}));
prop_assert!(
result.is_ok(),
"do_export must never panic on valid configs (bits={})",
bits,
);
Ok(())
})
.expect("proptest run must complete with no failing cases");
}
#[test]
#[serial]
fn proptest_do_export_dispatch_contract_1000_cases() {
let tracker = GlobalTracker::new();
let mut runner = TestRunner::new(ProptestConfig {
cases: 1000,
..ProptestConfig::default()
});
let tempdir = TempDir::new().expect("shared tempdir must succeed for the 1000-case run");
let strategy = (
0u8..4u8,
any::<bool>(),
any::<bool>(),
prop_oneof![
Just(SignalPolicy::Off),
Just(SignalPolicy::CtrlC),
Just(SignalPolicy::CtrlCAndTerm),
],
prop_oneof![
Just(None),
(1u64..100u64).prop_map(|ms| Some(Duration::from_millis(ms))),
],
);
runner
.run(&strategy, |(bits, on_exit, on_panic, on_signal, flush)| {
let cfg = AutoExportConfig {
output_path: tempdir.path().to_path_buf(),
formats: ExportFormatSet::from_bits(bits),
on_exit,
on_panic,
on_signal,
flush_interval: flush,
exit_timeout: Duration::from_secs(5),
};
let did = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
do_export(&tracker, &cfg)
}));
prop_assert!(
did.is_ok(),
"do_export must never panic on valid configs (bits={})",
bits,
);
let did = did.expect("catch_unwind Ok checked above");
prop_assert_eq!(
did,
!cfg.formats.is_empty(),
"do_export return value must track format selection (bits={}, did={})",
bits,
did,
);
Ok(())
})
.expect("1000-case proptest must pass with no failing cases");
}
#[test]
#[cfg(feature = "atexit")]
#[serial]
fn atexit_install_is_safe_and_does_not_panic() {
reset_for_test();
install_atexit();
}
}