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}