memscope_rs/auto_export.rs
1//! Auto-export configuration for memscope-rs.
2//!
3//! Defines the configuration types that control automatic export of tracking
4//! data on program exit (normal return, panic, Ctrl-C, SIGTERM) and optional
5//! periodic background flushing for long-running services.
6//!
7//! This is module 1 of the 4-module auto-export feature. It is intentionally
8//! dependency-light: only `std` and `serde` (both already in the crate graph)
9//! are used. Every type here is plain data with no I/O or global state, so the
10//! module can be unit-tested in isolation and downstream modules (signal
11//! installation, exit hooks, background flusher) can build on these contracts.
12
13use std::path::PathBuf;
14use std::time::Duration;
15
16use serde::{Deserialize, Serialize};
17
18/// Which signals trigger an automatic export before termination.
19///
20/// The policy is consulted by the signal-installing layer (module 2); this
21/// type only carries the user's intent so the wiring code can decide which
22/// handlers to register. Keeping it as a plain enum (rather than a bitfield)
23/// makes the supported combinations explicit and exhaustive.
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
25pub enum SignalPolicy {
26 /// No signal handler installed. Only Drop + panic-hook cover exit paths.
27 Off,
28 /// Catch SIGINT (Ctrl-C) only. This is the default policy.
29 #[default]
30 CtrlC,
31 /// Catch both SIGINT and SIGTERM.
32 ///
33 /// # Current limitation
34 ///
35 /// SIGTERM is NOT yet wired up — only SIGINT is caught by the underlying
36 /// `ctrlc` crate. SIGTERM support requires `signal_hook` (or a raw libc
37 /// handler) and will be added when that dependency is approved. Selecting
38 /// this policy currently behaves identically to [`SignalPolicy::CtrlC`]
39 /// from the perspective of which signals actually trigger an export.
40 CtrlCAndTerm,
41}
42
43/// Bitflags-style set of export formats. Uses a raw `u8` to avoid adding the
44/// `bitflags` crate as a dependency.
45///
46/// # Concurrency
47///
48/// `ExportFormatSet` is `Copy` and every accessor is `const fn`, so values
49/// carry no interior mutability and no shared mutable state. The type is
50/// trivially `Sync`: multiple threads may read the same value (or copies of
51/// it) without locking. It is therefore safe to pass across thread boundaries
52/// by value or behind an `Arc` for read-only sharing — no mutex is required.
53#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
54pub struct ExportFormatSet(u8);
55
56impl ExportFormatSet {
57 /// HTML dashboard only.
58 pub const HTML: Self = Self(0b01);
59 /// JSON data files only.
60 pub const JSON: Self = Self(0b10);
61 /// Both HTML and JSON (the recommended default).
62 pub const HTML_JSON: Self = Self(0b11);
63
64 /// Construct from raw bits.
65 pub const fn from_bits(bits: u8) -> Self {
66 Self(bits)
67 }
68 /// Read raw bits.
69 pub const fn to_bits(self) -> u8 {
70 self.0
71 }
72 /// True when no format is selected.
73 pub const fn is_empty(self) -> bool {
74 self.0 == 0
75 }
76 /// True when the HTML format is selected.
77 pub const fn contains_html(self) -> bool {
78 (self.0 & Self::HTML.0) != 0
79 }
80 /// True when the JSON format is selected.
81 pub const fn contains_json(self) -> bool {
82 (self.0 & Self::JSON.0) != 0
83 }
84 /// Set union (`self | other`).
85 pub const fn insert(self, other: Self) -> Self {
86 Self(self.0 | other.0)
87 }
88 /// Set difference (`self & !other`).
89 pub const fn remove(self, other: Self) -> Self {
90 Self(self.0 & !other.0)
91 }
92}
93
94impl Default for ExportFormatSet {
95 fn default() -> Self {
96 Self::HTML_JSON
97 }
98}
99
100/// Configuration for the automatic export subsystem.
101///
102/// Controls WHERE reports are written, WHICH formats, and WHICH exit paths
103/// trigger an export. All fields have sensible defaults via `Default`, so a
104/// plain `AutoExportConfig::default()` yields a working "export HTML+JSON to
105/// `./memscope-report` on any exit" setup.
106#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
107pub struct AutoExportConfig {
108 /// Directory to write reports into. Created if missing.
109 /// Default: `./memscope-report`.
110 pub output_path: PathBuf,
111 /// Which output formats to produce. Default: HTML + JSON.
112 pub formats: ExportFormatSet,
113 /// Export on normal program return (Drop guard fires). Default: `true`.
114 pub on_exit: bool,
115 /// Export on panic (panic-hook chaining). Critical for
116 /// `panic = "abort"` release builds. Default: `true`.
117 pub on_panic: bool,
118 /// Which signals trigger an export. Default: `CtrlC`.
119 pub on_signal: SignalPolicy,
120 /// If `Some(interval)`, spawn a background worker that flushes every
121 /// `interval`. If `None`, no background flushing (export only on exit).
122 /// Default: `None`.
123 pub flush_interval: Option<Duration>,
124 /// Max time the shutdown path waits for an in-flight export to finish
125 /// before giving up. Default: 5s.
126 pub exit_timeout: Duration,
127}
128
129impl Default for AutoExportConfig {
130 fn default() -> Self {
131 Self {
132 output_path: PathBuf::from("./memscope-report"),
133 formats: ExportFormatSet::default(),
134 on_exit: true,
135 on_panic: true,
136 on_signal: SignalPolicy::default(),
137 flush_interval: None,
138 exit_timeout: Duration::from_secs(5),
139 }
140 }
141}
142
143impl AutoExportConfig {
144 /// Builder-style: set output path.
145 pub fn with_output_path(mut self, path: impl Into<PathBuf>) -> Self {
146 self.output_path = path.into();
147 self
148 }
149 /// Builder-style: set formats.
150 pub fn with_formats(mut self, formats: ExportFormatSet) -> Self {
151 self.formats = formats;
152 self
153 }
154 /// Builder-style: enable periodic flushing.
155 pub fn with_flush_interval(mut self, interval: Duration) -> Self {
156 self.flush_interval = Some(interval);
157 self
158 }
159 /// Builder-style: set signal policy.
160 pub fn with_signal_policy(mut self, policy: SignalPolicy) -> Self {
161 self.on_signal = policy;
162 self
163 }
164 /// Builder-style: set the `on_exit` flag (export on normal Drop).
165 pub fn with_on_exit(mut self, on_exit: bool) -> Self {
166 self.on_exit = on_exit;
167 self
168 }
169 /// Builder-style: set the `on_panic` flag (export on panic).
170 pub fn with_on_panic(mut self, on_panic: bool) -> Self {
171 self.on_panic = on_panic;
172 self
173 }
174 /// Builder-style: set the shutdown timeout for in-flight exports.
175 pub fn with_exit_timeout(mut self, timeout: Duration) -> Self {
176 self.exit_timeout = timeout;
177 self
178 }
179 /// True if any export path is enabled (exit, panic, or signal != `Off`).
180 pub fn is_auto_export_enabled(&self) -> bool {
181 self.on_exit || self.on_panic || self.on_signal != SignalPolicy::Off
182 }
183 /// True if the HTML format is selected.
184 pub fn wants_html(&self) -> bool {
185 self.formats.contains_html()
186 }
187 /// True if the JSON format is selected.
188 pub fn wants_json(&self) -> bool {
189 self.formats.contains_json()
190 }
191}
192
193/// Top-level configuration for `memscope_rs::start_with`.
194///
195/// Combines the auto-export subsystem config with the existing
196/// `GlobalTrackerConfig` (tracking sampling, passport, etc.) so callers can
197/// configure both halves of a `start_with` call from a single value.
198#[derive(Debug, Clone, Default)]
199pub struct MemScopeConfig {
200 /// Auto-export subsystem configuration.
201 pub auto_export: AutoExportConfig,
202 /// Underlying global tracker configuration (sampling, passport tracker, etc.).
203 pub tracker: crate::capture::backends::global_tracking::GlobalTrackerConfig,
204}
205
206impl MemScopeConfig {
207 /// Builder-style: set auto-export config.
208 pub fn with_auto_export(mut self, cfg: AutoExportConfig) -> Self {
209 self.auto_export = cfg;
210 self
211 }
212 /// Builder-style: set tracker config.
213 pub fn with_tracker(
214 mut self,
215 cfg: crate::capture::backends::global_tracking::GlobalTrackerConfig,
216 ) -> Self {
217 self.tracker = cfg;
218 self
219 }
220}
221
222#[cfg(test)]
223mod tests {
224 use super::*;
225 use proptest::prelude::*;
226 use std::sync::Arc;
227 use std::thread;
228
229 // ===================== Positive tests (happy path) ====================
230
231 /// Objective: Verify `AutoExportConfig::default()` populates every field
232 /// with the documented default value.
233 /// Invariants: No field is left at an accidental zero/unset state.
234 #[test]
235 fn default_config_has_all_documented_defaults() {
236 let cfg = AutoExportConfig::default();
237
238 assert_eq!(
239 cfg.output_path,
240 PathBuf::from("./memscope-report"),
241 "default output_path must be ./memscope-report per the field doc"
242 );
243 assert_eq!(
244 cfg.formats,
245 ExportFormatSet::HTML_JSON,
246 "default formats must be HTML+JSON (the recommended default)"
247 );
248 assert!(
249 cfg.on_exit,
250 "on_exit defaults to true so the Drop-guard export fires"
251 );
252 assert!(
253 cfg.on_panic,
254 "on_panic defaults to true to cover panic=abort release builds"
255 );
256 assert_eq!(
257 cfg.on_signal,
258 SignalPolicy::CtrlC,
259 "default signal policy is CtrlC per SignalPolicy::default"
260 );
261 assert_eq!(
262 cfg.flush_interval, None,
263 "no background flushing by default; export only on exit"
264 );
265 assert_eq!(
266 cfg.exit_timeout,
267 Duration::from_secs(5),
268 "shutdown path waits up to 5s for an in-flight export"
269 );
270 }
271
272 /// Objective: Verify the combined format constant reports both flags set.
273 /// Invariants: HTML_JSON must contain HTML and JSON simultaneously.
274 #[test]
275 fn html_json_format_contains_both_html_and_json() {
276 let both = ExportFormatSet::HTML_JSON;
277 assert!(
278 both.contains_html(),
279 "HTML_JSON must select the HTML format"
280 );
281 assert!(
282 both.contains_json(),
283 "HTML_JSON must select the JSON format"
284 );
285 assert!(!both.is_empty(), "HTML_JSON must not be empty");
286 }
287
288 /// Objective: Verify builder methods chain without clobbering prior fields.
289 /// Invariants: Each chained with_* call sets exactly its own field and
290 /// leaves the rest at their defaults.
291 #[test]
292 fn builder_methods_chain_correctly() {
293 let cfg = AutoExportConfig::default()
294 .with_output_path("/tmp/x")
295 .with_flush_interval(Duration::from_secs(10));
296
297 assert_eq!(
298 cfg.output_path,
299 PathBuf::from("/tmp/x"),
300 "with_output_path must override the default directory"
301 );
302 assert_eq!(
303 cfg.flush_interval,
304 Some(Duration::from_secs(10)),
305 "with_flush_interval must enable periodic flushing at 10s"
306 );
307 // Untouched fields keep their defaults.
308 assert_eq!(
309 cfg.formats,
310 ExportFormatSet::HTML_JSON,
311 "chaining must not reset previously-defaulted formats"
312 );
313 assert!(cfg.on_exit, "chaining must not reset on_exit");
314 assert_eq!(
315 cfg.on_signal,
316 SignalPolicy::CtrlC,
317 "chaining must not reset on_signal"
318 );
319 }
320
321 /// Objective: Verify MemScopeConfig builder round-trips an auto-export cfg.
322 /// Invariants: with_auto_export stores exactly the provided config.
323 #[test]
324 fn memscope_config_with_auto_export_round_trips() {
325 let inner = AutoExportConfig::default().with_output_path("/tmp/round");
326 let outer = MemScopeConfig::default().with_auto_export(inner.clone());
327
328 assert_eq!(
329 outer.auto_export.output_path,
330 PathBuf::from("/tmp/round"),
331 "with_auto_export must store the provided config verbatim"
332 );
333 assert_eq!(
334 outer.auto_export, inner,
335 "round-trip must be lossless for AutoExportConfig"
336 );
337 }
338
339 /// Objective: Verify `with_tracker` stores the provided tracker config.
340 /// Invariants: The tracker field is replaced wholesale by the builder.
341 #[test]
342 fn memscope_config_with_tracker_round_trips() {
343 let outer = MemScopeConfig::default().with_tracker(
344 crate::capture::backends::global_tracking::GlobalTrackerConfig::default(),
345 );
346 // GlobalTrackerConfig derives Default; the builder must accept it and
347 // leave the auto_export half untouched.
348 assert_eq!(
349 outer.auto_export,
350 AutoExportConfig::default(),
351 "with_tracker must not disturb the auto_export config"
352 );
353 }
354
355 /// Objective: Verify is_auto_export_enabled is true for the default config.
356 /// Invariants: Default has on_exit=true, on_panic=true, signal=CtrlC, so
357 /// at least three independent triggers are armed.
358 #[test]
359 fn default_config_is_auto_export_enabled() {
360 let cfg = AutoExportConfig::default();
361 assert!(
362 cfg.is_auto_export_enabled(),
363 "default config (exit+panic+CtrlC) must enable auto-export"
364 );
365 }
366
367 // ===================== Negative tests (edge cases) ====================
368
369 /// Objective: Verify a zero-bits set is considered empty.
370 /// Invariants: from_bits(0) yields no selected formats.
371 #[test]
372 fn zero_bits_is_empty() {
373 let empty = ExportFormatSet::from_bits(0u8);
374 assert!(
375 empty.is_empty(),
376 "from_bits(0) must report empty since no format bits are set"
377 );
378 }
379
380 /// Objective: Verify zero-bits does not claim HTML.
381 /// Invariants: contains_html must be false when the HTML bit is clear.
382 #[test]
383 fn zero_bits_does_not_contain_html() {
384 let empty = ExportFormatSet::from_bits(0u8);
385 assert!(
386 !empty.contains_html(),
387 "from_bits(0) must not report HTML selected"
388 );
389 }
390
391 /// Objective: Verify SignalPolicy::Off alone disables auto-export ONLY
392 /// when on_exit and on_panic are also false; flipping any single trigger
393 /// back on re-enables it.
394 /// Invariants: is_auto_export_enabled is the OR of the three triggers.
395 #[test]
396 fn off_signal_disables_only_when_all_triggers_off() {
397 let mut cfg = AutoExportConfig {
398 on_exit: false,
399 on_panic: false,
400 on_signal: SignalPolicy::Off,
401 ..Default::default()
402 };
403 assert!(
404 !cfg.is_auto_export_enabled(),
405 "all three triggers off must disable auto-export entirely"
406 );
407
408 // Flipping on_exit alone back on re-enables auto-export.
409 cfg.on_exit = true;
410 assert!(
411 cfg.is_auto_export_enabled(),
412 "re-enabling on_exit alone must re-enable auto-export"
413 );
414
415 // Flipping on_panic alone back on re-enables auto-export.
416 cfg.on_exit = false;
417 cfg.on_panic = true;
418 assert!(
419 cfg.is_auto_export_enabled(),
420 "re-enabling on_panic alone must re-enable auto-export"
421 );
422
423 // A non-Off signal policy alone re-enables auto-export.
424 cfg.on_panic = false;
425 cfg.on_signal = SignalPolicy::CtrlC;
426 assert!(
427 cfg.is_auto_export_enabled(),
428 "a non-Off signal policy alone must re-enable auto-export"
429 );
430 }
431
432 /// Objective: Verify remove(self, self) clears the set.
433 /// Invariants: HTML.remove(HTML) leaves no bits set.
434 #[test]
435 fn remove_self_clears_set() {
436 let cleared = ExportFormatSet::HTML.remove(ExportFormatSet::HTML);
437 assert!(
438 cleared.is_empty(),
439 "removing a format from itself must yield an empty set"
440 );
441 }
442
443 /// Objective: Verify removing HTML from HTML_JSON leaves JSON only.
444 /// Invariants: After removal, JSON is set and HTML is clear.
445 #[test]
446 fn remove_html_from_both_leaves_json_only() {
447 let json_only = ExportFormatSet::HTML_JSON.remove(ExportFormatSet::HTML);
448 assert!(
449 !json_only.contains_html(),
450 "removing HTML must clear the HTML bit"
451 );
452 assert!(
453 json_only.contains_json(),
454 "removing HTML must preserve the JSON bit"
455 );
456 assert_eq!(
457 json_only,
458 ExportFormatSet::JSON,
459 "HTML_JSON - HTML must equal JSON"
460 );
461 }
462
463 /// Objective: Verify a config built with empty formats reports neither
464 /// HTML nor JSON desired.
465 /// Invariants: wants_html/wants_json follow the formats bits exactly.
466 #[test]
467 fn empty_formats_means_no_formats_wanted() {
468 let cfg = AutoExportConfig::default().with_formats(ExportFormatSet::from_bits(0u8));
469 assert!(
470 !cfg.wants_html(),
471 "with_formats(0) must disable HTML output"
472 );
473 assert!(
474 !cfg.wants_json(),
475 "with_formats(0) must disable JSON output"
476 );
477 }
478
479 // ===================== Stress tests (concurrency) =====================
480
481 /// Objective: Verify ExportFormatSet is safe to share across 50 threads
482 /// with no locking; all const-fn operations must be consistent.
483 /// Invariants: Because the type is Copy with no interior mutability,
484 /// every thread observes identical results for the same input bits.
485 #[test]
486 fn export_format_set_is_lock_free_safe_across_threads() {
487 const THREAD_COUNT: usize = 50;
488 // Share one read-only value via Arc; threads also make local copies.
489 let shared = Arc::new(ExportFormatSet::HTML_JSON);
490 let mut handles = Vec::with_capacity(THREAD_COUNT);
491
492 for _ in 0..THREAD_COUNT {
493 let snapshot = Arc::clone(&shared);
494 handles.push(thread::spawn(move || {
495 // Read the shared value: consistent because Copy + Sync.
496 let base = *snapshot;
497 // Local pure computations on a copy — no shared mutation.
498 let with_extra = base.insert(ExportFormatSet::JSON);
499 let stripped = base.remove(ExportFormatSet::HTML);
500 // Every thread must see the same deterministic results.
501 (base, with_extra, stripped)
502 }));
503 }
504
505 let mut checked = 0usize;
506 for handle in handles {
507 // join().expect() is infallible here: the closure performs only
508 // Copy bit-twiddling (insert/remove/contains), none of which can
509 // panic, so the thread cannot have panicked. A panic would itself
510 // be a regression we want to surface rather than swallow.
511 let (base, with_extra, stripped) = handle
512 .join()
513 .expect("worker thread must not panic on pure Copy bit ops");
514 assert_eq!(
515 base,
516 ExportFormatSet::HTML_JSON,
517 "every thread must read the same shared base value"
518 );
519 assert_eq!(
520 with_extra,
521 ExportFormatSet::HTML_JSON,
522 "inserting an already-set bit must be idempotent across threads"
523 );
524 assert_eq!(
525 stripped,
526 ExportFormatSet::JSON,
527 "removing HTML from HTML_JSON must yield JSON in every thread"
528 );
529 checked += 1;
530 }
531 assert_eq!(
532 checked, THREAD_COUNT,
533 "all 50 worker threads must report consistent results"
534 );
535 }
536
537 // ===================== proptest (1000 cases each) =====================
538
539 proptest! {
540 #![proptest_config(ProptestConfig {
541 cases: 1000,
542 ..ProptestConfig::default()
543 })]
544
545 /// Property: from_bits is a perfect inverse of to_bits.
546 /// Why: the format set is a transparent newtype over u8, so any u8
547 /// must round-trip without loss.
548 #[test]
549 fn bits_round_trip(b in any::<u8>()) {
550 prop_assert_eq!(
551 ExportFormatSet::from_bits(b).to_bits(),
552 b,
553 "from_bits(b).to_bits() must equal b for every u8"
554 );
555 }
556
557 /// Property: insert computes set union.
558 /// Why: insert is documented as self | other.
559 #[test]
560 fn insert_is_union(a in any::<u8>(), b in any::<u8>()) {
561 let got = ExportFormatSet::from_bits(a)
562 .insert(ExportFormatSet::from_bits(b))
563 .to_bits();
564 prop_assert_eq!(
565 got,
566 a | b,
567 "from_bits(a).insert(from_bits(b)) must equal (a | b)"
568 );
569 }
570
571 /// Property: remove computes set difference.
572 /// Why: remove is documented as self & !other.
573 #[test]
574 fn remove_is_difference(a in any::<u8>(), b in any::<u8>()) {
575 let got = ExportFormatSet::from_bits(a)
576 .remove(ExportFormatSet::from_bits(b))
577 .to_bits();
578 prop_assert_eq!(
579 got,
580 a & !b,
581 "from_bits(a).remove(from_bits(b)) must equal (a & !b)"
582 );
583 }
584
585 /// Property: contains_html tracks the low bit exactly.
586 /// Why: HTML is defined as 0b01, so contains_html is (b & 0b01) != 0.
587 #[test]
588 fn contains_html_tracks_low_bit(b in any::<u8>()) {
589 let got = ExportFormatSet::from_bits(b).contains_html();
590 prop_assert_eq!(
591 got,
592 (b & 0b01) != 0,
593 "contains_html must equal ((b & 0b01) != 0) for every u8"
594 );
595 }
596 }
597}