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: Within a bounded wait the HTML file is complete;
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        let html = dir.path().join("dashboard_unified_dashboard.html");
280        let deadline = std::time::Instant::now() + Duration::from_secs(5);
281        while std::time::Instant::now() < deadline {
282            if std::fs::read_to_string(&html).is_ok_and(|s| s.contains("</html>")) {
283                break;
284            }
285            thread::sleep(Duration::from_millis(10));
286        }
287        assert!(
288            std::fs::read_to_string(&html).is_ok_and(|s| s.contains("</html>")),
289            "periodic flusher must finish HTML within five seconds"
290        );
291
292        // Dropping the guard joins the flusher thread. This must not hang — if
293        // it does, the test will time out and surface the deadlock.
294        drop(guard);
295    }
296
297    /// Objective: Verify `guard.export_now()` returns `true` and produces a
298    /// report file on disk.
299    /// Invariants: `export_now` resets the latch and exports, so it returns
300    /// `true`; the HTML file exists at the configured output path afterwards.
301    #[test]
302    #[serial]
303    fn export_now_returns_true_and_produces_report() {
304        reset_globals();
305        let dir = TempDir::new().expect("tempdir creation must succeed in tests");
306        let cfg = MemScopeConfig::default()
307            .with_auto_export(AutoExportConfig::default().with_output_path(dir.path()));
308        let guard = start_with(cfg).expect("start_with must succeed");
309
310        let did = guard.export_now();
311        assert!(
312            did,
313            "export_now() must return true when tracker and cfg are installed"
314        );
315
316        let html = dir.path().join("dashboard_unified_dashboard.html");
317        assert!(
318            html.exists(),
319            "HTML dashboard must exist at {html:?} after export_now()"
320        );
321        drop(guard);
322    }
323
324    /// Objective: Verify `guard.snapshot_json()` returns a non-empty JSON
325    /// string containing the `total_allocations` field of the analysis report.
326    /// Invariants: The snapshot is built from the installed tracker and
327    /// serialized via serde, so it must be a valid non-empty JSON document.
328    #[test]
329    #[serial]
330    fn snapshot_json_returns_non_empty_string() {
331        reset_globals();
332        let (_dir, cfg) = config_with_tempdir();
333        let guard = start_with(cfg).expect("start_with must succeed");
334
335        // Track something so the report has non-trivial content rather than
336        // an all-zero snapshot.
337        let v: Vec<u64> = vec![1, 2, 3];
338        guard.track(&v);
339
340        let json = guard
341            .snapshot_json()
342            .expect("snapshot_json() must succeed when the tracker is installed");
343        assert!(
344            !json.is_empty(),
345            "snapshot_json() must return a non-empty JSON string"
346        );
347        assert!(
348            json.contains("allocation_count"),
349            "snapshot JSON must contain the allocation_count field, got: {json}"
350        );
351        drop(guard);
352    }
353
354    /// Objective: Verify the `Deref<Target = GlobalTracker>` impl lets callers
355    /// invoke `GlobalTracker::track` directly on the guard without panicking.
356    /// Invariants: After `guard.track(&vec)`, the tracker's stats must show at
357    /// least one recorded allocation.
358    #[test]
359    #[serial]
360    fn deref_to_tracker_allows_track() {
361        reset_globals();
362        let (_dir, cfg) = config_with_tempdir();
363        let guard = start_with(cfg).expect("start_with must succeed");
364
365        let data: Vec<u64> = vec![1, 2, 3];
366        // This call relies on Deref: MemScopeGuard -> &GlobalTracker, then
367        // GlobalTracker::track(&self, &T). If Deref were broken this panics.
368        guard.track(&data);
369
370        let stats = guard.get_stats();
371        assert!(
372            stats.total_allocations > 0,
373            "tracker must have recorded at least one allocation after track() via Deref"
374        );
375        drop(guard);
376    }
377
378    // ===================== Negative tests (edge cases) ====================
379
380    /// Objective: Verify calling `start_with` twice without resetting global
381    /// tracking fails on the second call.
382    /// Invariants: The first call succeeds and returns a guard; the second
383    /// call returns `Err` because `init_global_tracking_with_config` errors
384    /// on double-init. The error message must mention the cause.
385    #[test]
386    #[serial]
387    fn start_twice_second_returns_err() {
388        reset_globals();
389        let (_dir1, cfg1) = config_with_tempdir();
390        let guard1 =
391            start_with(cfg1).expect("first start_with must succeed with a clean global state");
392
393        // The global singleton is now Some(...); a second start_with must fail
394        // at init_global_tracking_with_config before reaching lifecycle::install.
395        let (_dir2, cfg2) = config_with_tempdir();
396        let result2 = start_with(cfg2);
397        let err = match result2 {
398            Err(e) => e,
399            Ok(_) => {
400                panic!("second start_with must fail because global tracking is already initialized")
401            }
402        };
403        let err_msg = format!("{err}");
404        assert!(
405            err_msg.contains("already initialized"),
406            "error must mention 'already initialized', got: {err_msg}"
407        );
408
409        drop(guard1);
410    }
411
412    /// Objective: Verify dropping the guard after `reset_global_tracking`
413    /// (which clears only the global singleton, NOT `lifecycle::TRACKER_HANDLE`)
414    /// still exports successfully — the guard's `Arc` and the lifecycle's
415    /// stored `Arc` keep the tracker alive.
416    /// Invariants: No panic on drop; the HTML file is written because
417    /// `export_once` reads from `TRACKER_HANDLE` (still `Some`) which holds
418    /// its own `Arc` clone independent of the global singleton.
419    #[test]
420    #[serial]
421    fn drop_after_reset_global_tracking_still_succeeds() {
422        reset_globals();
423        let dir = TempDir::new().expect("tempdir creation must succeed in tests");
424        let cfg = MemScopeConfig::default()
425            .with_auto_export(AutoExportConfig::default().with_output_path(dir.path()));
426        let guard = start_with(cfg).expect("start_with must succeed");
427
428        // Clear the global singleton. The guard's `tracker` field and
429        // lifecycle's `TRACKER_HANDLE` both still hold Arc clones, so the
430        // tracker is not dropped and export_once can still proceed.
431        crate::capture::backends::global_tracking::reset_global_tracking();
432
433        // Dropping must not panic, and the export must still write the file
434        // because TRACKER_HANDLE retains the Arc.
435        drop(guard);
436
437        let html = dir.path().join("dashboard_unified_dashboard.html");
438        assert!(
439            html.exists(),
440            "HTML must be written even after reset_global_tracking because TRACKER_HANDLE retains the Arc"
441        );
442    }
443
444    // ===================== Stress test (50 threads) =======================
445
446    /// Objective: Verify 50 threads can each perform the full
447    /// reset -> start -> track -> drop cycle without deadlock or panic.
448    /// Invariants: All 50 threads join successfully. The `parking_lot::Mutex`
449    /// serializes the per-thread cycle so `init_global_tracking` is never
450    /// called concurrently (which would race the singleton check).
451    #[test]
452    #[serial]
453    fn fifty_threads_start_track_drop_no_deadlock() {
454        const THREAD_COUNT: usize = 50;
455        // Shared lock so the reset+start+track+drop cycle runs strictly
456        // sequentially across threads. Without this, two threads racing
457        // init_global_tracking would produce spurious "already initialized"
458        // errors that are not the subject of this stress test.
459        let lock = Arc::new(Mutex::new(()));
460        // Shared tempdir for output. Safe because the lock serializes all
461        // writes — no two threads export concurrently.
462        let dir = Arc::new(TempDir::new().expect("tempdir creation must succeed in tests"));
463
464        let mut handles = Vec::with_capacity(THREAD_COUNT);
465        for _ in 0..THREAD_COUNT {
466            let lock = Arc::clone(&lock);
467            let dir = Arc::clone(&dir);
468            handles.push(thread::spawn(move || {
469                // Hold the lock for the entire cycle so global state is never
470                // observed in a half-initialized state by another thread.
471                let _guard_lock = lock.lock();
472                reset_globals();
473                let cfg = MemScopeConfig::default()
474                    .with_auto_export(AutoExportConfig::default().with_output_path(dir.path()));
475                let guard = start_with(cfg).expect("start_with must succeed in each worker thread");
476                let data: Vec<u64> = vec![42; 8];
477                guard.track(&data);
478                // Drop inside the lock so the export path is also serialized
479                // — no concurrent file writes to the same directory.
480                drop(guard);
481            }));
482        }
483
484        let mut completed = 0usize;
485        for handle in handles {
486            handle
487                .join()
488                .expect("worker thread must not panic during start+track+drop");
489            completed += 1;
490        }
491        assert_eq!(
492            completed, THREAD_COUNT,
493            "all 50 worker threads must complete without deadlock or panic"
494        );
495    }
496
497    // ===================== Exit export accounting =========================
498
499    /// Objective: Verify the exit-path export runs exactly once for a guard drop
500    /// when no periodic/manual export happened before (plan §8.1).
501    /// Invariants: the report exists after the drop, and a second `Drop`-reason
502    /// export attempt reports `false` because the idempotency latch is consumed —
503    /// proving the batch is not written twice.
504    #[test]
505    #[serial]
506    fn drop_guard_exports_exactly_once() {
507        reset_globals();
508        let dir = TempDir::new().expect("tempdir creation must succeed in tests");
509        let cfg = MemScopeConfig::default()
510            .with_auto_export(AutoExportConfig::default().with_output_path(dir.path()));
511        let guard = start_with(cfg).expect("start_with must succeed for the once-only check");
512
513        let data: Vec<u64> = vec![1, 2, 3];
514        guard.track(&data);
515        drop(guard);
516
517        let html = dir.path().join("dashboard_unified_dashboard.html");
518        assert!(
519            html.exists(),
520            "the guard drop must produce one report batch at {html:?}"
521        );
522        assert!(
523            !lifecycle::export_for_reason(lifecycle::ExportReason::Drop),
524            "a second exit-path export must be skipped by the idempotency latch"
525        );
526    }
527
528    /// Objective: Verify `on_exit = false` suppresses the guard-drop export and
529    /// does not consume the latch of the other exit paths.
530    /// Invariants: no report file is written by the drop; a later enabled export
531    /// path still runs and produces the report.
532    #[test]
533    #[serial]
534    fn on_exit_false_skips_drop_export_and_keeps_latch_free() {
535        reset_globals();
536        let dir = TempDir::new().expect("tempdir creation must succeed in tests");
537        let mut auto_export = AutoExportConfig::default().with_output_path(dir.path());
538        auto_export.on_exit = false;
539        let cfg = MemScopeConfig::default().with_auto_export(auto_export);
540        let guard = start_with(cfg).expect("start_with must succeed with on_exit disabled");
541
542        let data: Vec<u64> = vec![4, 5, 6];
543        guard.track(&data);
544        drop(guard);
545
546        let html = dir.path().join("dashboard_unified_dashboard.html");
547        assert!(
548            !html.exists(),
549            "on_exit=false must not write a report on guard drop, found {html:?}"
550        );
551        assert!(
552            lifecycle::trigger_export_now(),
553            "a disabled exit path must not consume the latch of later export paths"
554        );
555        assert!(
556            html.exists(),
557            "the explicitly enabled export must still produce the report"
558        );
559    }
560
561    // ===================== Integration test (end-to-end) ==================
562
563    /// Objective: Verify the end-to-end flow: `start_with(tempdir)` ->
564    /// `track!` a `Vec<u64>` through the Deref path -> drop guard -> read the
565    /// produced HTML -> assert it contains a known template marker.
566    /// Invariants: The HTML file exists, is non-empty, and contains the
567    /// `memscope` marker from the dashboard template, proving the renderer
568    /// ran against real tracked data.
569    #[test]
570    #[serial]
571    fn end_to_end_start_track_drop_html_contains_marker() {
572        reset_globals();
573        let dir = TempDir::new().expect("tempdir creation must succeed in tests");
574        let cfg = MemScopeConfig::default()
575            .with_auto_export(AutoExportConfig::default().with_output_path(dir.path()));
576        let guard = start_with(cfg).expect("start_with must succeed for end-to-end flow");
577
578        // Track a Vec<u64> through the exported `track!` macro. The macro
579        // expands to `guard.track_as(&vec_data, ...)` which relies on Deref
580        // to reach GlobalTracker::track_as.
581        let vec_data: Vec<u64> = vec![1, 2, 3, 4, 5];
582        track!(guard, vec_data);
583
584        drop(guard);
585
586        let html = dir.path().join("dashboard_unified_dashboard.html");
587        assert!(
588            html.exists(),
589            "HTML dashboard must exist after end-to-end start+track+drop"
590        );
591        let content = std::fs::read_to_string(&html)
592            .expect("HTML dashboard file must be readable after end-to-end export");
593        assert!(
594            !content.is_empty(),
595            "HTML dashboard content must not be empty after end-to-end flow"
596        );
597        // The unified dashboard template hard-codes the "memscope-rs" label
598        // in the side navigation, so the rendered output must contain it.
599        assert!(
600            content.contains("memscope"),
601            "HTML dashboard must contain the 'memscope' marker from the template"
602        );
603    }
604}