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