Skip to main content

memscope_rs/
guard.rs

1//! `MemScopeGuard` — the RAII handle returned by `start()` / `start_with()`.
2//!
3//! Holding the guard keeps tracking alive; dropping it triggers the idempotent
4//! exit-path export (`lifecycle::export_once`). Combined with the panic hook,
5//! ctrlc handler, and optional atexit registered by `lifecycle::install`, this
6//! covers every realistic program-exit path.
7//!
8//! # Module role
9//!
10//! This is module 4 (the final module) of the auto-export feature. It ties
11//! modules 1-3 together into the user-facing API:
12//!
13//! - [`start`] / [`start_with`] initialize logging, install the global tracker,
14//!   wire the lifecycle hooks, and optionally spawn a background flusher.
15//! - The returned [`MemScopeGuard`] owns the flusher handle and a clone of the
16//!   tracker `Arc`. Its [`Drop`] impl joins the flusher (with a final flush)
17//!   and then runs the idempotent exit-path export.
18//!
19//! # Drop ordering
20//!
21//! The flusher is dropped BEFORE the final `export_once` call so that the
22//! flusher's shutdown-time final flush is visible to the export latch. The
23//! latch (`lifecycle::EXPORTED`) is set by whichever export runs first; the
24//! second is a no-op, so there is no double-write.
25
26use std::ops::Deref;
27use std::sync::Arc;
28
29use crate::auto_export::{AutoExportConfig, MemScopeConfig};
30use crate::capture::backends::global_tracking::GlobalTracker;
31use crate::core::error::MemScopeResult;
32use crate::lifecycle;
33use crate::periodic_flusher::PeriodicFlusher;
34
35/// RAII handle that keeps the auto-export subsystem alive.
36///
37/// Created by [`start`] / [`start_with`]. Dropping it joins the optional
38/// background flusher and then triggers the idempotent exit-path export via
39/// [`lifecycle::export_once`]. The guard also derefs to [`GlobalTracker`] so
40/// callers can use `track!` / `track_as` / `export_html` directly on it.
41pub struct MemScopeGuard {
42    /// Tracker handle cloned from the global singleton at [`start_with`] time.
43    /// Kept alive by the guard so the tracker survives even if the global
44    /// singleton is later cleared by `reset_global_tracking`.
45    tracker: Arc<GlobalTracker>,
46    /// Optional background flusher. `None` when `flush_interval` is `None`.
47    /// Taken and dropped in [`Drop`] before the final export so the flusher's
48    /// final flush is included in the export window.
49    flusher: Option<PeriodicFlusher>,
50}
51
52impl MemScopeGuard {
53    /// Borrow the underlying tracker handle.
54    ///
55    /// The returned `Arc` is the same one registered with the global singleton
56    /// and the lifecycle hooks, so callers can compare pointers or share it
57    /// across threads without re-fetching the singleton.
58    #[must_use]
59    pub fn tracker(&self) -> &Arc<GlobalTracker> {
60        &self.tracker
61    }
62
63    /// Trigger an immediate export. Resets the idempotency latch first so a
64    /// later panic / Ctrl-C / Drop still produces a fresh report.
65    ///
66    /// # Returns
67    ///
68    /// `true` if the export ran; `false` if no tracker / config is installed
69    /// (e.g. after `reset_for_test`).
70    #[must_use]
71    pub fn export_now(&self) -> bool {
72        lifecycle::trigger_export_now()
73    }
74
75    /// In-memory JSON snapshot (no disk write). Builds an [`AnalysisReport`]
76    /// via the analyzer pipeline and serializes it. Intended for HTTP
77    /// endpoints or ad-hoc inspection without touching the filesystem.
78    ///
79    /// # Errors
80    ///
81    /// Returns [`MemScopeError`] if no tracker is installed (e.g. after
82    /// `reset_global_tracking`) or if serialization of the analysis report
83    /// fails.
84    ///
85    /// [`AnalysisReport`]: crate::analyzer::AnalysisReport
86    /// [`MemScopeError`]: crate::MemScopeError
87    pub fn snapshot_json(&self) -> MemScopeResult<String> {
88        lifecycle::snapshot_json()
89    }
90}
91
92impl Deref for MemScopeGuard {
93    type Target = GlobalTracker;
94    fn deref(&self) -> &Self::Target {
95        &self.tracker
96    }
97}
98
99impl Drop for MemScopeGuard {
100    fn drop(&mut self) {
101        // Drop the flusher first so its shutdown-time final flush runs before
102        // the export latch is consulted. PeriodicFlusher::Drop sends the
103        // shutdown signal, joins the worker, and the worker performs a final
104        // flush before exiting.
105        if let Some(flusher) = self.flusher.take() {
106            drop(flusher);
107        }
108        // Idempotent final export gated by `on_exit`. If the flusher's final
109        // flush already set the latch, this is a no-op. Otherwise this is the
110        // only export. We use `ExportReason::Drop` so the `on_exit` config
111        // flag is honored (a user who set `on_exit: false` gets no Drop export
112        // but may still get a panic/signal export).
113        let _ = lifecycle::export_for_reason(lifecycle::ExportReason::Drop);
114    }
115}
116
117/// Start memscope-rs with the default configuration.
118///
119/// Equivalent to [`start_with`] called with [`MemScopeConfig::default()`]. The
120/// returned guard triggers the exit-path export on drop.
121///
122/// # Errors
123///
124/// Returns `Err(MemScopeError)` if logging init, global tracker init, or
125/// lifecycle hook installation fails. The most common failure is calling
126/// `start()` twice without resetting global tracking in between.
127///
128/// [`MemScopeError`]: crate::MemScopeError
129pub fn start() -> MemScopeResult<MemScopeGuard> {
130    start_with(MemScopeConfig::default())
131}
132
133/// Start memscope-rs with a custom configuration.
134///
135/// This:
136/// 1. Initializes the tracing subscriber (idempotent via `Once`).
137/// 2. Installs the global tracker with the provided `GlobalTrackerConfig`.
138/// 3. Wires the auto-export lifecycle hooks (panic hook always; ctrlc / atexit
139///    behind their respective features) via [`lifecycle::install`].
140/// 4. Optionally spawns a [`PeriodicFlusher`] if `flush_interval` is `Some`.
141///
142/// The returned [`MemScopeGuard`] owns the flusher handle and a clone of the
143/// tracker `Arc`; dropping it triggers the final export.
144///
145/// # Errors
146///
147/// Returns `Err(MemScopeError)` if any of the four setup steps fails. A
148/// repeated `start_with` call without `reset_global_tracking` fails at step 2
149/// because the global singleton is already initialized.
150///
151/// [`MemScopeError`]: crate::MemScopeError
152pub fn start_with(config: MemScopeConfig) -> MemScopeResult<MemScopeGuard> {
153    crate::init_logging()?;
154    crate::init_global_tracking_with_config(config.tracker.clone())?;
155    let tracker = crate::global_tracker()?;
156    lifecycle::install(config.auto_export.clone(), tracker.clone())?;
157    let flusher = spawn_flusher(&config.auto_export);
158    Ok(MemScopeGuard { tracker, flusher })
159}
160
161/// Spawn a [`PeriodicFlusher`] if `cfg.flush_interval` is `Some`.
162///
163/// The flush closure calls [`lifecycle::trigger_export_now`] (not
164/// `export_once`) so each periodic tick resets the idempotency latch and
165/// writes a fresh report. The exit-path export on Drop still fires afterwards
166/// — it just becomes a no-op if the flusher already exported, or runs if the
167/// flusher never ticked.
168fn spawn_flusher(cfg: &AutoExportConfig) -> Option<PeriodicFlusher> {
169    let interval = cfg.flush_interval?;
170    Some(PeriodicFlusher::new(interval, || {
171        let _ = lifecycle::trigger_export_now();
172    }))
173}
174
175// =========================================================================
176// Tests
177// =========================================================================
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182    use crate::auto_export::{AutoExportConfig, MemScopeConfig};
183    use crate::track;
184    use parking_lot::Mutex;
185    use serial_test::serial;
186    use std::sync::Arc;
187    use std::thread;
188    use std::time::Duration;
189    use tempfile::TempDir;
190
191    /// Reset every process-global slot touched by `start` / `start_with` so
192    /// each `#[serial]` test begins from a known-clean state. Callers must
193    /// hold `#[serial]` so concurrent tests cannot clobber each other.
194    fn reset_globals() {
195        crate::capture::backends::global_tracking::reset_global_tracking();
196        lifecycle::reset_for_test();
197    }
198
199    /// Build a `MemScopeConfig` whose auto-export output points at a fresh
200    /// tempdir. The caller must keep the returned `TempDir` alive until after
201    /// the guard is dropped so the directory is not deleted mid-export.
202    fn config_with_tempdir() -> (TempDir, MemScopeConfig) {
203        let dir = TempDir::new().expect("tempdir creation must succeed in tests");
204        let cfg = MemScopeConfig::default()
205            .with_auto_export(AutoExportConfig::default().with_output_path(dir.path()));
206        (dir, cfg)
207    }
208
209    // ===================== Positive tests (happy path) ====================
210
211    /// Objective: Verify `start()` returns a guard whose `tracker()` is a
212    /// non-null `Arc<GlobalTracker>` pointing at the global singleton.
213    /// Invariants: The guard's tracker and the global singleton must be the
214    /// same `Arc` (pointer equality), proving the guard captured the live
215    /// tracker rather than a stale or fresh copy.
216    #[test]
217    #[serial]
218    fn start_returns_guard_with_tracker() {
219        reset_globals();
220        let (_dir, cfg) = config_with_tempdir();
221        let guard = start_with(cfg).expect("start_with with default config must succeed");
222
223        let guard_tracker: &Arc<GlobalTracker> = guard.tracker();
224        let singleton: Arc<GlobalTracker> = crate::global_tracker()
225            .expect("global_tracker() must succeed immediately after start_with()");
226        assert!(
227            Arc::ptr_eq(guard_tracker, &singleton),
228            "guard.tracker() must reference the same Arc<GlobalTracker> as the global singleton"
229        );
230        drop(guard);
231    }
232
233    /// Objective: Verify dropping the guard writes the HTML dashboard to the
234    /// configured output directory via the exit-path export.
235    /// Invariants: `dashboard_unified_dashboard.html` exists after drop, and
236    /// the file is non-empty (the renderer wrote real content).
237    #[test]
238    #[serial]
239    fn drop_guard_writes_html_report() {
240        reset_globals();
241        let dir = TempDir::new().expect("tempdir creation must succeed in tests");
242        let cfg = MemScopeConfig::default()
243            .with_auto_export(AutoExportConfig::default().with_output_path(dir.path()));
244        let guard = start_with(cfg).expect("start_with with tempdir output must succeed");
245
246        drop(guard);
247
248        let html = dir.path().join("dashboard_unified_dashboard.html");
249        assert!(
250            html.exists(),
251            "HTML dashboard must exist at {html:?} after dropping the guard"
252        );
253        let content = std::fs::read_to_string(&html)
254            .expect("HTML dashboard file must be readable after export");
255        assert!(
256            !content.is_empty(),
257            "HTML dashboard content must not be empty"
258        );
259    }
260
261    /// Objective: Verify `start_with` with a `flush_interval` spawns a flusher
262    /// that writes a report within a few tick intervals, and that dropping the
263    /// guard joins the flusher thread without hanging.
264    /// Invariants: After 200ms (4x the 50ms interval) the HTML file exists;
265    /// `drop(guard)` returns promptly (no deadlock).
266    #[test]
267    #[serial]
268    fn start_with_flusher_runs_periodic_export() {
269        reset_globals();
270        let dir = TempDir::new().expect("tempdir creation must succeed in tests");
271        let cfg = MemScopeConfig::default().with_auto_export(
272            AutoExportConfig::default()
273                .with_output_path(dir.path())
274                .with_flush_interval(Duration::from_millis(50)),
275        );
276        let guard =
277            start_with(cfg).expect("start_with with flush_interval must succeed and spawn flusher");
278
279        // 200ms gives the 50ms-interval worker ~3-4 ticks of slack. The first
280        // tick triggers trigger_export_now which writes the dashboard.
281        thread::sleep(Duration::from_millis(200));
282
283        let html = dir.path().join("dashboard_unified_dashboard.html");
284        assert!(
285            html.exists(),
286            "periodic flusher must have written the HTML dashboard within 200ms"
287        );
288
289        // Dropping the guard joins the flusher thread. This must not hang — if
290        // it does, the test will time out and surface the deadlock.
291        drop(guard);
292    }
293
294    /// Objective: Verify `guard.export_now()` returns `true` and produces a
295    /// report file on disk.
296    /// Invariants: `export_now` resets the latch and exports, so it returns
297    /// `true`; the HTML file exists at the configured output path afterwards.
298    #[test]
299    #[serial]
300    fn export_now_returns_true_and_produces_report() {
301        reset_globals();
302        let dir = TempDir::new().expect("tempdir creation must succeed in tests");
303        let cfg = MemScopeConfig::default()
304            .with_auto_export(AutoExportConfig::default().with_output_path(dir.path()));
305        let guard = start_with(cfg).expect("start_with must succeed");
306
307        let did = guard.export_now();
308        assert!(
309            did,
310            "export_now() must return true when tracker and cfg are installed"
311        );
312
313        let html = dir.path().join("dashboard_unified_dashboard.html");
314        assert!(
315            html.exists(),
316            "HTML dashboard must exist at {html:?} after export_now()"
317        );
318        drop(guard);
319    }
320
321    /// Objective: Verify `guard.snapshot_json()` returns a non-empty JSON
322    /// string containing the `total_allocations` field of the analysis report.
323    /// Invariants: The snapshot is built from the installed tracker and
324    /// serialized via serde, so it must be a valid non-empty JSON document.
325    #[test]
326    #[serial]
327    fn snapshot_json_returns_non_empty_string() {
328        reset_globals();
329        let (_dir, cfg) = config_with_tempdir();
330        let guard = start_with(cfg).expect("start_with must succeed");
331
332        // Track something so the report has non-trivial content rather than
333        // an all-zero snapshot.
334        let v: Vec<u64> = vec![1, 2, 3];
335        guard.track(&v);
336
337        let json = guard
338            .snapshot_json()
339            .expect("snapshot_json() must succeed when the tracker is installed");
340        assert!(
341            !json.is_empty(),
342            "snapshot_json() must return a non-empty JSON string"
343        );
344        assert!(
345            json.contains("allocation_count"),
346            "snapshot JSON must contain the allocation_count field, got: {json}"
347        );
348        drop(guard);
349    }
350
351    /// Objective: Verify the `Deref<Target = GlobalTracker>` impl lets callers
352    /// invoke `GlobalTracker::track` directly on the guard without panicking.
353    /// Invariants: After `guard.track(&vec)`, the tracker's stats must show at
354    /// least one recorded allocation.
355    #[test]
356    #[serial]
357    fn deref_to_tracker_allows_track() {
358        reset_globals();
359        let (_dir, cfg) = config_with_tempdir();
360        let guard = start_with(cfg).expect("start_with must succeed");
361
362        let data: Vec<u64> = vec![1, 2, 3];
363        // This call relies on Deref: MemScopeGuard -> &GlobalTracker, then
364        // GlobalTracker::track(&self, &T). If Deref were broken this panics.
365        guard.track(&data);
366
367        let stats = guard.get_stats();
368        assert!(
369            stats.total_allocations > 0,
370            "tracker must have recorded at least one allocation after track() via Deref"
371        );
372        drop(guard);
373    }
374
375    // ===================== Negative tests (edge cases) ====================
376
377    /// Objective: Verify calling `start_with` twice without resetting global
378    /// tracking fails on the second call.
379    /// Invariants: The first call succeeds and returns a guard; the second
380    /// call returns `Err` because `init_global_tracking_with_config` errors
381    /// on double-init. The error message must mention the cause.
382    #[test]
383    #[serial]
384    fn start_twice_second_returns_err() {
385        reset_globals();
386        let (_dir1, cfg1) = config_with_tempdir();
387        let guard1 =
388            start_with(cfg1).expect("first start_with must succeed with a clean global state");
389
390        // The global singleton is now Some(...); a second start_with must fail
391        // at init_global_tracking_with_config before reaching lifecycle::install.
392        let (_dir2, cfg2) = config_with_tempdir();
393        let result2 = start_with(cfg2);
394        let err = match result2 {
395            Err(e) => e,
396            Ok(_) => {
397                panic!("second start_with must fail because global tracking is already initialized")
398            }
399        };
400        let err_msg = format!("{err}");
401        assert!(
402            err_msg.contains("already initialized"),
403            "error must mention 'already initialized', got: {err_msg}"
404        );
405
406        drop(guard1);
407    }
408
409    /// Objective: Verify dropping the guard after `reset_global_tracking`
410    /// (which clears only the global singleton, NOT `lifecycle::TRACKER_HANDLE`)
411    /// still exports successfully — the guard's `Arc` and the lifecycle's
412    /// stored `Arc` keep the tracker alive.
413    /// Invariants: No panic on drop; the HTML file is written because
414    /// `export_once` reads from `TRACKER_HANDLE` (still `Some`) which holds
415    /// its own `Arc` clone independent of the global singleton.
416    #[test]
417    #[serial]
418    fn drop_after_reset_global_tracking_still_succeeds() {
419        reset_globals();
420        let dir = TempDir::new().expect("tempdir creation must succeed in tests");
421        let cfg = MemScopeConfig::default()
422            .with_auto_export(AutoExportConfig::default().with_output_path(dir.path()));
423        let guard = start_with(cfg).expect("start_with must succeed");
424
425        // Clear the global singleton. The guard's `tracker` field and
426        // lifecycle's `TRACKER_HANDLE` both still hold Arc clones, so the
427        // tracker is not dropped and export_once can still proceed.
428        crate::capture::backends::global_tracking::reset_global_tracking();
429
430        // Dropping must not panic, and the export must still write the file
431        // because TRACKER_HANDLE retains the Arc.
432        drop(guard);
433
434        let html = dir.path().join("dashboard_unified_dashboard.html");
435        assert!(
436            html.exists(),
437            "HTML must be written even after reset_global_tracking because TRACKER_HANDLE retains the Arc"
438        );
439    }
440
441    // ===================== Stress test (50 threads) =======================
442
443    /// Objective: Verify 50 threads can each perform the full
444    /// reset -> start -> track -> drop cycle without deadlock or panic.
445    /// Invariants: All 50 threads join successfully. The `parking_lot::Mutex`
446    /// serializes the per-thread cycle so `init_global_tracking` is never
447    /// called concurrently (which would race the singleton check).
448    #[test]
449    #[serial]
450    fn fifty_threads_start_track_drop_no_deadlock() {
451        const THREAD_COUNT: usize = 50;
452        // Shared lock so the reset+start+track+drop cycle runs strictly
453        // sequentially across threads. Without this, two threads racing
454        // init_global_tracking would produce spurious "already initialized"
455        // errors that are not the subject of this stress test.
456        let lock = Arc::new(Mutex::new(()));
457        // Shared tempdir for output. Safe because the lock serializes all
458        // writes — no two threads export concurrently.
459        let dir = Arc::new(TempDir::new().expect("tempdir creation must succeed in tests"));
460
461        let mut handles = Vec::with_capacity(THREAD_COUNT);
462        for _ in 0..THREAD_COUNT {
463            let lock = Arc::clone(&lock);
464            let dir = Arc::clone(&dir);
465            handles.push(thread::spawn(move || {
466                // Hold the lock for the entire cycle so global state is never
467                // observed in a half-initialized state by another thread.
468                let _guard_lock = lock.lock();
469                reset_globals();
470                let cfg = MemScopeConfig::default()
471                    .with_auto_export(AutoExportConfig::default().with_output_path(dir.path()));
472                let guard = start_with(cfg).expect("start_with must succeed in each worker thread");
473                let data: Vec<u64> = vec![42; 8];
474                guard.track(&data);
475                // Drop inside the lock so the export path is also serialized
476                // — no concurrent file writes to the same directory.
477                drop(guard);
478            }));
479        }
480
481        let mut completed = 0usize;
482        for handle in handles {
483            handle
484                .join()
485                .expect("worker thread must not panic during start+track+drop");
486            completed += 1;
487        }
488        assert_eq!(
489            completed, THREAD_COUNT,
490            "all 50 worker threads must complete without deadlock or panic"
491        );
492    }
493
494    // ===================== Integration test (end-to-end) ==================
495
496    /// Objective: Verify the end-to-end flow: `start_with(tempdir)` ->
497    /// `track!` a `Vec<u64>` through the Deref path -> drop guard -> read the
498    /// produced HTML -> assert it contains a known template marker.
499    /// Invariants: The HTML file exists, is non-empty, and contains the
500    /// `memscope` marker from the dashboard template, proving the renderer
501    /// ran against real tracked data.
502    #[test]
503    #[serial]
504    fn end_to_end_start_track_drop_html_contains_marker() {
505        reset_globals();
506        let dir = TempDir::new().expect("tempdir creation must succeed in tests");
507        let cfg = MemScopeConfig::default()
508            .with_auto_export(AutoExportConfig::default().with_output_path(dir.path()));
509        let guard = start_with(cfg).expect("start_with must succeed for end-to-end flow");
510
511        // Track a Vec<u64> through the exported `track!` macro. The macro
512        // expands to `guard.track_as(&vec_data, ...)` which relies on Deref
513        // to reach GlobalTracker::track_as.
514        let vec_data: Vec<u64> = vec![1, 2, 3, 4, 5];
515        track!(guard, vec_data);
516
517        drop(guard);
518
519        let html = dir.path().join("dashboard_unified_dashboard.html");
520        assert!(
521            html.exists(),
522            "HTML dashboard must exist after end-to-end start+track+drop"
523        );
524        let content = std::fs::read_to_string(&html)
525            .expect("HTML dashboard file must be readable after end-to-end export");
526        assert!(
527            !content.is_empty(),
528            "HTML dashboard content must not be empty after end-to-end flow"
529        );
530        // The unified dashboard template hard-codes the "memscope-rs" label
531        // in the side navigation, so the rendered output must contain it.
532        assert!(
533            content.contains("memscope"),
534            "HTML dashboard must contain the 'memscope' marker from the template"
535        );
536    }
537}