Skip to main content

memscope_rs/snapshot/
state.rs

1//! Allocation life-cycle state and leak assessment.
2//!
3//! Two different facts about the same allocation are kept apart on purpose:
4//!
5//! - [`AllocationState`] is a **fact** about the physical allocation as far as the
6//!   capture window observed it. It only records what happened inside the window
7//!   (an allocation was seen, a release was seen, or observation simply stopped).
8//! - [`LeakAssessment`] is a **conclusion** computed at a snapshot cut from the
9//!   fact above plus whatever completeness information the capture layer provides.
10//!
11//! Keeping them separate prevents the historical bug where "no deallocation
12//! timestamp" was silently treated as "leaked": a tracker drop, a session stop,
13//! or a missing event never proves that the memory was leaked.
14//!
15//! # Accuracy contract
16//!
17//! * `Live` means "no release event was recorded for this allocation inside the
18//!   window"; it does **not** prove the object is still reachable outside it.
19//! * `Freed` is only produced from a real release observation (allocator hook or
20//!   explicit instrumentation), never from a tracker/guard destruction.
21//! * `Unknown` is used when observation ended before a release could be seen.
22//! * A leak is only reported for `Suspected`/`Confirmed`; the default is
23//!   [`LeakStatus::NotAssessed`], which reports no leak at all.
24
25use serde::{Deserialize, Serialize};
26
27/// Physical allocation state observed at a snapshot cut.
28///
29/// The variant names are serialized as `snake_case` strings so JSON consumers get
30/// stable, readable values (`"live"`, `"freed"`, `"unknown"`).
31#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
32#[serde(rename_all = "snake_case")]
33pub enum AllocationState {
34    /// The allocation was observed in the window and no release was recorded.
35    #[default]
36    Live,
37    /// A release event for this allocation was recorded at `at_ns`.
38    Freed {
39        /// Session monotonic timestamp (nanoseconds) of the release observation.
40        at_ns: u64,
41    },
42    /// Observation stopped before a release could be observed.
43    Unknown {
44        /// Human readable explanation of why no verdict is available.
45        reason: String,
46    },
47}
48
49impl AllocationState {
50    /// Build the "observed, no release recorded" state.
51    #[must_use]
52    pub fn live() -> Self {
53        Self::Live
54    }
55
56    /// Build a released state from the session monotonic timestamp.
57    #[must_use]
58    pub fn freed(at_ns: u64) -> Self {
59        Self::Freed { at_ns }
60    }
61
62    /// Build an undetermined state with the reason observation ended.
63    #[must_use]
64    pub fn unknown(reason: impl Into<String>) -> Self {
65        Self::Unknown {
66            reason: reason.into(),
67        }
68    }
69
70    /// Whether this allocation had no recorded release in the window.
71    #[must_use]
72    pub fn is_live(&self) -> bool {
73        matches!(self, Self::Live)
74    }
75
76    /// Whether a release was actually observed for this allocation.
77    #[must_use]
78    pub fn is_freed(&self) -> bool {
79        matches!(self, Self::Freed { .. })
80    }
81
82    /// Stable machine readable label used by DTOs and JSON envelopes.
83    #[must_use]
84    pub fn as_str(&self) -> &'static str {
85        match self {
86            Self::Live => "live",
87            Self::Freed { .. } => "freed",
88            Self::Unknown { .. } => "unknown",
89        }
90    }
91}
92
93/// How well a leak conclusion is supported by evidence.
94#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
95#[serde(rename_all = "snake_case")]
96pub enum LeakStatus {
97    /// No leak rule was applied to this allocation yet.
98    #[default]
99    NotAssessed,
100    /// Rules ran and found no evidence of a leak.
101    NoEvidence,
102    /// A rule produced a suspicion that still needs corroboration.
103    Suspected,
104    /// Independently explainable evidence proves the leak.
105    Confirmed,
106}
107
108impl LeakStatus {
109    /// Whether this status may be rendered as a leak in reports.
110    ///
111    /// `NotAssessed` and `NoEvidence` must never be displayed as leaks; doing so
112    /// is what produced the historical false positives.
113    #[must_use]
114    pub fn is_reported_leak(self) -> bool {
115        matches!(self, Self::Suspected | Self::Confirmed)
116    }
117
118    /// Stable machine readable label used by DTOs and JSON envelopes.
119    #[must_use]
120    pub fn as_str(self) -> &'static str {
121        match self {
122            Self::NotAssessed => "not_assessed",
123            Self::NoEvidence => "no_evidence",
124            Self::Suspected => "suspected",
125            Self::Confirmed => "confirmed",
126        }
127    }
128}
129
130/// Conclusion about whether a single allocation leaked.
131///
132/// The rule id and reason are only meaningful for `Suspected`/`Confirmed`; they
133/// keep the conclusion traceable back to the rule that produced it.
134#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
135pub struct LeakAssessment {
136    /// Assessment status.
137    pub status: LeakStatus,
138    /// Identifier of the rule that produced the conclusion, if any.
139    pub rule_id: Option<String>,
140    /// Human readable justification, if any.
141    pub reason: Option<String>,
142}
143
144impl LeakAssessment {
145    /// Nothing was assessed yet — the conservative default.
146    #[must_use]
147    pub fn not_assessed() -> Self {
148        Self {
149            status: LeakStatus::NotAssessed,
150            rule_id: None,
151            reason: None,
152        }
153    }
154
155    /// Rules ran and found no leak evidence.
156    #[must_use]
157    pub fn no_evidence() -> Self {
158        Self {
159            status: LeakStatus::NoEvidence,
160            rule_id: None,
161            reason: None,
162        }
163    }
164
165    /// A rule raised a suspicion; the reason records why.
166    #[must_use]
167    pub fn suspected(rule_id: impl Into<String>, reason: impl Into<String>) -> Self {
168        Self {
169            status: LeakStatus::Suspected,
170            rule_id: Some(rule_id.into()),
171            reason: Some(reason.into()),
172        }
173    }
174
175    /// Proof exists; the reason records the evidence.
176    #[must_use]
177    pub fn confirmed(rule_id: impl Into<String>, reason: impl Into<String>) -> Self {
178        Self {
179            status: LeakStatus::Confirmed,
180            rule_id: Some(rule_id.into()),
181            reason: Some(reason.into()),
182        }
183    }
184
185    /// Whether this assessment may be rendered as a leak.
186    #[must_use]
187    pub fn is_reported_leak(&self) -> bool {
188        self.status.is_reported_leak()
189    }
190}
191
192#[cfg(test)]
193mod tests {
194    use super::*;
195
196    /// Objective: Verify that a plain observation is reported as `Live` and never
197    /// as a leak.
198    /// Invariants: `Live` is not free, is not a reported leak, and serializes to
199    /// the stable `"live"` label.
200    #[test]
201    fn live_state_is_not_a_leak() {
202        let state = AllocationState::live();
203        assert!(state.is_live(), "fresh observation must be Live");
204        assert!(!state.is_freed(), "Live must not claim a release");
205        assert_eq!(state.as_str(), "live");
206
207        let assessment = LeakAssessment::not_assessed();
208        assert!(
209            !assessment.is_reported_leak(),
210            "an unassessed allocation must never be reported as leaked"
211        );
212        assert_eq!(assessment.status.as_str(), "not_assessed");
213    }
214
215    /// Objective: Verify that only a real release observation produces `Freed`
216    /// and preserves the observation timestamp.
217    /// Invariants: `Freed` keeps `at_ns` untouched and is not live.
218    #[test]
219    fn freed_state_preserves_release_timestamp() {
220        let state = AllocationState::freed(4_242);
221        assert!(!state.is_live(), "a released allocation must not be Live");
222        match state {
223            AllocationState::Freed { at_ns } => {
224                assert_eq!(at_ns, 4_242, "release timestamp must be preserved");
225            }
226            other => panic!("expected Freed state, got {other:?}"),
227        }
228        assert_eq!(state.as_str(), "freed");
229    }
230
231    /// Objective: Verify that missing observation yields `Unknown` with the
232    /// supplied reason instead of a fabricated release.
233    /// Invariants: The reason string is retained verbatim.
234    #[test]
235    fn unknown_state_keeps_reason() {
236        let state = AllocationState::unknown("capture stopped before release");
237        assert!(!state.is_live(), "unknown observations are not live");
238        assert!(!state.is_freed(), "unknown observations are not freed");
239        match &state {
240            AllocationState::Unknown { reason } => assert_eq!(
241                reason, "capture stopped before release",
242                "unknown state must keep the diagnostic reason"
243            ),
244            other => panic!("expected Unknown state, got {other:?}"),
245        }
246        assert_eq!(state.as_str(), "unknown");
247    }
248
249    /// Objective: Verify the leak status gate that reports use.
250    /// Invariants: Only Suspected/Confirmed pass the gate; NotAssessed/NoEvidence
251    /// never do.
252    #[test]
253    fn only_suspected_and_confirmed_are_reported() {
254        assert!(!LeakStatus::NotAssessed.is_reported_leak());
255        assert!(!LeakStatus::NoEvidence.is_reported_leak());
256        assert!(LeakStatus::Suspected.is_reported_leak());
257        assert!(LeakStatus::Confirmed.is_reported_leak());
258    }
259
260    /// Objective: Verify that rule-backed assessments keep their provenance.
261    /// Invariants: rule id and reason are present and the status is a reported
262    /// leak for both `suspected` and `confirmed`.
263    #[test]
264    fn assessments_keep_rule_provenance() {
265        let suspected = LeakAssessment::suspected("leak.unreleased_at_cut", "still live at cut");
266        assert!(suspected.is_reported_leak());
267        assert_eq!(suspected.rule_id.as_deref(), Some("leak.unreleased_at_cut"));
268        assert_eq!(suspected.reason.as_deref(), Some("still live at cut"));
269
270        let confirmed = LeakAssessment::confirmed("leak.proof", "independent evidence");
271        assert!(confirmed.is_reported_leak());
272        assert_eq!(confirmed.status, LeakStatus::Confirmed);
273
274        let no_evidence = LeakAssessment::no_evidence();
275        assert!(!no_evidence.is_reported_leak());
276        assert!(
277            no_evidence.rule_id.is_none(),
278            "no-evidence assessments carry no rule id"
279        );
280    }
281
282    /// Objective: Verify that the JSON contract for these types is stable for
283    /// dashboard and envelope consumers.
284    /// Invariants: Round-tripping through serde preserves the state and status.
285    #[test]
286    fn states_round_trip_through_json() {
287        let state = AllocationState::freed(7);
288        let json = serde_json::to_string(&state).expect("state must serialize");
289        assert!(
290            json.contains("freed"),
291            "serialized state must use snake_case label, got {json}"
292        );
293        let back: AllocationState = serde_json::from_str(&json).expect("state must deserialize");
294        assert_eq!(back, state, "round-trip must preserve the state");
295
296        let status = LeakStatus::NoEvidence;
297        let json = serde_json::to_string(&status).expect("status must serialize");
298        assert_eq!(json, "\"no_evidence\"", "status label must be stable");
299
300        let default_state = AllocationState::default();
301        assert!(
302            default_state.is_live(),
303            "default allocation state must be Live, not freed"
304        );
305        assert_eq!(
306            LeakStatus::default(),
307            LeakStatus::NotAssessed,
308            "default leak status must be NotAssessed"
309        );
310    }
311}