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