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}