navian_memcheck/lib.rs
1//! # navian-memcheck
2//!
3//! **Resource-leak / soak testing as a `cargo test` assertion.**
4//!
5//! The bug that took down a 52 GB process in production was not a leak in the
6//! classic sense: every byte was *reachable*. A per-session map simply grew
7//! without bound because nothing ever evicted it. `LeakSanitizer` and Valgrind
8//! `memcheck` are blind to this — the memory is still referenced, so to them it
9//! is "in use", not "lost". A heap profiler would show it, but only if a human
10//! sat and eyeballed a flamegraph.
11//!
12//! `navian-memcheck` asserts the *property that was actually violated*: **after
13//! a warmup period, live memory PLATEAUS.** You drive a workload under sustained
14//! load; the crate samples live heap on a fixed cadence, fits a line through the
15//! back half of the run, and fails if the slope is still climbing or a hard cap
16//! is breached. It is a pass/fail check you drop into a test — no profiler, no
17//! flamegraph, no platform.
18//!
19//! ```no_run
20//! use navian_memcheck::{soak, SoakConfig};
21//!
22//! # fn process_one_event(_: u64) {}
23//! let report = soak(&SoakConfig::iterations(200_000), |i| {
24//! process_one_event(i); // your real per-event work
25//! });
26//! report.assert(); // panics with a readable summary if memory kept growing
27//! ```
28//!
29//! ## Activation: one dependency, one test, zero code changes
30//!
31//! Add the crate and write one test. Nothing in your production code changes.
32//!
33//! ```toml
34//! [dev-dependencies]
35//! navian-memcheck = "0.1"
36//! ```
37//!
38//! The default [`RssSampler`] reads the OS, so there is no allocator to install
39//! and no global state to set up.
40//!
41//! ## Two surfaces
42//!
43//! - **In-process** ([`soak`], [`assert_bounded`], [`assert_linear_in`]) — drive a
44//! workload closure and watch *this* process's memory. Runs in your test suite.
45//! - **Out-of-process** — the `navian-memcheck` CLI soaks *any* command's RSS over
46//! a duration and gates CI on the same plateau property, with no code at all. See
47//! the `navian-memcheck-cli` crate.
48//!
49//! ## Sampling precision (optional)
50//!
51//! The default reads process RSS — coarser (page-granular, includes allocator
52//! retention) but zero-setup. For a cleaner in-process signal, enable the
53//! `jemalloc` feature and pass `JemallocSampler` to [`soak_with`]; it reads
54//! jemalloc `stats.allocated` (live bytes, no page noise). That requires jemalloc
55//! to be the global allocator — one line, and free for services already on it:
56//!
57//! ```ignore
58//! #[global_allocator]
59//! static ALLOC: tikv_jemallocator::Jemalloc = tikv_jemallocator::Jemalloc;
60//! ```
61//!
62//! ## Determinism
63//!
64//! Memory boundedness is a property, and like any property it is only trustworthy
65//! if the run that checks it is reproducible. Drive your workload from a seeded
66//! RNG (or under [`navian-dst`](https://github.com/TheFuturePlutus/navian-dst)) so
67//! that a soak which fails on seed *N* fails again, identically, on seed *N* — and
68//! so you can bisect the growth to the event that caused it.
69
70#![forbid(unsafe_code)]
71#![warn(missing_docs)]
72
73use std::fmt;
74
75// ─────────────────────────────────────────────────────────────────────────────
76// Sampler
77// ─────────────────────────────────────────────────────────────────────────────
78
79/// A source of "live memory, in bytes, right now".
80///
81/// Implementors return a monotonic-ish estimate of the memory the process is
82/// currently holding. Two implementations ship with the crate: [`RssSampler`]
83/// (always available) and `JemallocSampler` (with the `jemalloc` feature).
84pub trait Sampler {
85 /// Current live memory in bytes, or `None` if the reading FAILED (the platform
86 /// API was unavailable this call). `None` is NOT the same as `Some(0)`: a failed
87 /// read must not be recorded as zero bytes, which would depress peak/slope and
88 /// bias the run toward a false pass. A failed sample is skipped; if none ever
89 /// succeed the run is reported inconclusive. Should be cheap enough to call
90 /// thousands of times per run.
91 fn sample(&self) -> Option<u64>;
92 /// Short human label for what is being measured (shown in reports).
93 fn kind(&self) -> &'static str;
94}
95
96/// Process resident-set-size sampler. Always available, no allocator requirement,
97/// but coarser than jemalloc: page-granular and inflated by allocator retention.
98#[derive(Debug, Default, Clone, Copy)]
99pub struct RssSampler;
100
101impl Sampler for RssSampler {
102 fn sample(&self) -> Option<u64> {
103 // None (not 0) when the platform RSS API is unavailable, so a failed read
104 // is skipped rather than injected as a spurious zero.
105 memory_stats::memory_stats().map(|m| m.physical_mem as u64)
106 }
107 fn kind(&self) -> &'static str {
108 "rss"
109 }
110}
111
112/// In-process live-heap sampler backed by jemalloc `stats.allocated`.
113///
114/// Requires jemalloc to be the active global allocator (see the crate docs). The
115/// reading is the number of bytes currently handed out by the allocator — the
116/// cleanest signal for "did my data structures stop growing".
117#[cfg(feature = "jemalloc")]
118#[derive(Debug, Default, Clone, Copy)]
119pub struct JemallocSampler;
120
121#[cfg(feature = "jemalloc")]
122impl Sampler for JemallocSampler {
123 fn sample(&self) -> Option<u64> {
124 use tikv_jemalloc_ctl::{epoch, stats};
125 // `allocated` is only refreshed when the epoch is advanced.
126 let _ = epoch::advance();
127 // None (not 0) on a ctl read error, so a failed read is skipped.
128 stats::allocated::read().ok().map(|v| v as u64)
129 }
130 fn kind(&self) -> &'static str {
131 "jemalloc/allocated"
132 }
133}
134
135/// The sampler [`soak`] and friends use when you don't pass one explicitly.
136///
137/// This is [`RssSampler`] — it reads the OS and needs no allocator setup, so the
138/// zero-config path is just `cargo add navian-memcheck` plus one soak test. For a
139/// cleaner in-process signal, enable the `jemalloc` feature and pass
140/// `JemallocSampler` to [`soak_with`].
141pub type DefaultSampler = RssSampler;
142
143// ─────────────────────────────────────────────────────────────────────────────
144// Linear fit (ordinary least squares)
145// ─────────────────────────────────────────────────────────────────────────────
146
147/// Result of an ordinary-least-squares fit of `y = slope * x + intercept`.
148#[derive(Debug, Clone, Copy, PartialEq)]
149pub struct Fit {
150 /// Change in `y` per unit `x`.
151 pub slope: f64,
152 /// `y` at `x = 0`.
153 pub intercept: f64,
154 /// Coefficient of determination in `[0, 1]`; `1.0` is a perfect line.
155 pub r2: f64,
156}
157
158/// Least-squares fit of `ys` against `xs`. Returns a zero-slope fit when there
159/// are fewer than two points or `xs` has no spread.
160pub fn linear_fit(xs: &[f64], ys: &[f64]) -> Fit {
161 let n = xs.len().min(ys.len());
162 if n < 2 {
163 return Fit {
164 slope: 0.0,
165 intercept: ys.first().copied().unwrap_or(0.0),
166 r2: 1.0,
167 };
168 }
169 let nf = n as f64;
170 let mean_x = xs[..n].iter().sum::<f64>() / nf;
171 let mean_y = ys[..n].iter().sum::<f64>() / nf;
172 let mut sxx = 0.0;
173 let mut sxy = 0.0;
174 let mut syy = 0.0;
175 for i in 0..n {
176 let dx = xs[i] - mean_x;
177 let dy = ys[i] - mean_y;
178 sxx += dx * dx;
179 sxy += dx * dy;
180 syy += dy * dy;
181 }
182 if sxx == 0.0 {
183 return Fit {
184 slope: 0.0,
185 intercept: mean_y,
186 r2: 1.0,
187 };
188 }
189 let slope = sxy / sxx;
190 let intercept = mean_y - slope * mean_x;
191 // r2 = explained / total variance; guard the flat-y case.
192 let r2 = if syy == 0.0 {
193 1.0
194 } else {
195 (sxy * sxy) / (sxx * syy)
196 };
197 Fit {
198 slope,
199 intercept,
200 r2: r2.clamp(0.0, 1.0),
201 }
202}
203
204// ─────────────────────────────────────────────────────────────────────────────
205// Soak
206// ─────────────────────────────────────────────────────────────────────────────
207
208/// How to run a soak: how long, how often to sample, and what "still growing"
209/// and "too big" mean.
210#[derive(Debug, Clone, Copy)]
211pub struct SoakConfig {
212 /// Number of times the workload closure is invoked.
213 pub iterations: u64,
214 /// Sample live memory every `sample_every` iterations. Must be `>= 1`.
215 pub sample_every: u64,
216 /// Fraction of the run (by sample count) to *discard* as warmup before
217 /// measuring the plateau slope. `0.5` fits the line through the back half.
218 pub warmup_frac: f64,
219 /// Optional hard ceiling on peak live bytes. `None` disables the cap check.
220 pub max_bytes: Option<u64>,
221 /// Plateau budget: the largest back-half slope, in **bytes per sample**, that
222 /// still counts as "leveled off". Anything above this fails as `StillGrowing`.
223 pub slope_bytes_per_sample: f64,
224 /// Optional minimum r² of the back-half fit required before an over-budget
225 /// slope is treated as growth. **Off by default (`0.0`).**
226 ///
227 /// Raising it suppresses false `StillGrowing` on jittery RSS — but it is *not*
228 /// on by default and should be used with care: a slow leak buried in page-noise
229 /// also has a low r², so a high floor can mask the very bug this tool exists to
230 /// catch. Prefer tuning [`slope_bytes_per_sample`](Self::slope_bytes_per_sample)
231 /// above your platform's noise floor, or use the jemalloc sampler for a clean
232 /// signal, over relying on this.
233 pub min_r2_for_growth: f64,
234 /// Minimum bytes the live-memory reading must span (`peak - trough`) over the
235 /// run before a plateau is trusted. **Off by default (`0`).**
236 ///
237 /// A flat series is ambiguous: it is what a bounded workload looks like, but
238 /// also what a workload that never allocated — or a stuck sampler — looks like.
239 /// The tool can't tell which from the series, so by default it passes a flat
240 /// series and merely reports [`SoakReport::moved_bytes`]. Set this to the amount
241 /// you KNOW your workload should churn (domain knowledge the tool doesn't have),
242 /// and a run that moved less is reported [`Verdict::InsufficientSamples`] instead
243 /// of a hollow pass.
244 pub min_movement_bytes: u64,
245}
246
247impl SoakConfig {
248 /// A sensible default soak of `iterations` events: 200 samples, back-half
249 /// plateau check, ~4 KB/sample slope budget, no hard cap.
250 pub fn iterations(iterations: u64) -> Self {
251 let sample_every = (iterations / 200).max(1);
252 SoakConfig {
253 iterations,
254 sample_every,
255 warmup_frac: 0.5,
256 max_bytes: None,
257 slope_bytes_per_sample: 4096.0,
258 min_r2_for_growth: 0.0, // r² floor off by default — slope alone decides
259 min_movement_bytes: 0, // movement enforcement off by default
260 }
261 }
262 /// Set the plateau slope budget in bytes per sample.
263 #[must_use]
264 pub fn slope_budget(mut self, bytes_per_sample: f64) -> Self {
265 self.slope_bytes_per_sample = bytes_per_sample;
266 self
267 }
268 /// Require the run to move at least `bytes` (`peak - trough`) before a plateau is
269 /// trusted; a run that moved less is reported [`Verdict::InsufficientSamples`].
270 /// See [`min_movement_bytes`](Self::min_movement_bytes).
271 #[must_use]
272 pub fn require_movement(mut self, bytes: u64) -> Self {
273 self.min_movement_bytes = bytes;
274 self
275 }
276 /// Set a hard ceiling on peak live bytes.
277 #[must_use]
278 pub fn max_bytes(mut self, cap: u64) -> Self {
279 self.max_bytes = Some(cap);
280 self
281 }
282 /// Set how many iterations pass between samples.
283 #[must_use]
284 pub fn sample_every(mut self, every: u64) -> Self {
285 self.sample_every = every.max(1);
286 self
287 }
288 /// Set the warmup fraction discarded before the plateau fit. The value is
289 /// clamped to `0.0..=0.95`.
290 #[must_use]
291 pub fn warmup_frac(mut self, frac: f64) -> Self {
292 self.warmup_frac = frac.clamp(0.0, 0.95);
293 self
294 }
295 /// Set the optional back-half r² floor required before an over-budget slope
296 /// counts as growth (clamped to `0.0..=1.0`; `0.0`, the default, disables it).
297 /// Advanced: see the caveat on [`SoakConfig::min_r2_for_growth`] — a high floor
298 /// can mask a slow, noisy leak.
299 #[must_use]
300 pub fn min_r2(mut self, r2: f64) -> Self {
301 self.min_r2_for_growth = r2.clamp(0.0, 1.0);
302 self
303 }
304}
305
306/// Why a soak passed or failed.
307#[derive(Debug, Clone, Copy, PartialEq)]
308pub enum Verdict {
309 /// Memory plateaued and stayed under any configured cap.
310 Pass,
311 /// Peak live bytes exceeded the configured hard cap.
312 ExceededCap {
313 /// Observed peak.
314 peak: u64,
315 /// Configured ceiling.
316 cap: u64,
317 },
318 /// Back-half slope exceeded the plateau budget — memory was still climbing.
319 StillGrowing {
320 /// Observed back-half slope, bytes per sample.
321 slope: f64,
322 /// Configured budget, bytes per sample.
323 budget: f64,
324 },
325 /// The run produced too little signal to judge — fewer than two samples, or
326 /// every sample read zero bytes (sampler unsupported on this platform). This
327 /// is **not** a pass; treat it as a failed run to investigate.
328 InsufficientSamples {
329 /// Human-readable reason the run was inconclusive.
330 reason: &'static str,
331 },
332}
333
334/// The outcome of a [`soak`] run: the raw samples plus the computed verdict.
335#[derive(Debug, Clone)]
336pub struct SoakReport {
337 /// `(iteration, live_bytes)` pairs, in order.
338 pub samples: Vec<(u64, u64)>,
339 /// Live bytes at the first sample (rough baseline).
340 pub baseline: u64,
341 /// Peak live bytes observed across the whole run.
342 pub peak: u64,
343 /// Total spread of live bytes observed: `peak - trough`. This is the evidence
344 /// that the run actually exercised memory. When it is `0` every sample read the
345 /// same value — the workload may never allocate (or never hit the suspected
346 /// path), or the sampler may be stuck — and a flat "plateau" then proves
347 /// nothing, so the verdict is [`Verdict::InsufficientSamples`], not a pass.
348 pub moved_bytes: u64,
349 /// Back-half slope in bytes per sample (the plateau signal).
350 pub back_half_slope: f64,
351 /// R² of the back-half fit (how line-like the tail was).
352 pub back_half_r2: f64,
353 /// What the sampler measured.
354 pub sampler_kind: &'static str,
355 /// Pass / fail and why.
356 pub verdict: Verdict,
357 /// Advisory warnings surfaced on a PASS that may be hollow — e.g. the run
358 /// barely moved, the slope budget is loose enough to hide a real leak, or the
359 /// plateau rests on too few samples. They do NOT change the verdict (defaults
360 /// keep the tool zero-config), but each names the knob to set so a green result
361 /// is earned, not assumed. Empty on a fail or a clearly-earned pass.
362 pub trust_warnings: Vec<String>,
363}
364
365impl SoakReport {
366 /// `true` if the verdict is [`Verdict::Pass`].
367 pub fn passed(&self) -> bool {
368 matches!(self.verdict, Verdict::Pass)
369 }
370
371 /// Panic with [`summary`](Self::summary) if the soak did not pass. The
372 /// idiomatic last line of a `#[test]`.
373 ///
374 /// # Panics
375 ///
376 /// Panics if the verdict is anything other than [`Verdict::Pass`] — i.e. the
377 /// memory kept growing, breached a cap, or the run was inconclusive.
378 pub fn assert(&self) {
379 assert!(self.passed(), "navian-memcheck: {}", self.summary());
380 }
381
382 /// One-line, human-readable result summary.
383 pub fn summary(&self) -> String {
384 let mb = |b: u64| b as f64 / (1024.0 * 1024.0);
385 let verdict = match self.verdict {
386 Verdict::Pass => "PASS — memory plateaued".to_string(),
387 Verdict::ExceededCap { peak, cap } => {
388 format!("FAIL — peak {:.1} MB exceeded cap {:.1} MB", mb(peak), mb(cap))
389 }
390 Verdict::StillGrowing { slope, budget } => format!(
391 "FAIL — still growing: back-half slope {slope:.0} B/sample > budget {budget:.0} B/sample"
392 ),
393 Verdict::InsufficientSamples { reason } => {
394 format!("INCONCLUSIVE — {reason}")
395 }
396 };
397 let mut out = format!(
398 "{verdict} [sampler={}, samples={}, baseline={:.1} MB, peak={:.1} MB, moved={:.1} MB, slope={:.0} B/sample, r2={:.2}]",
399 self.sampler_kind,
400 self.samples.len(),
401 mb(self.baseline),
402 mb(self.peak),
403 mb(self.moved_bytes),
404 self.back_half_slope,
405 self.back_half_r2,
406 );
407 for warn in &self.trust_warnings {
408 out.push_str("\n ⚠ trust: ");
409 out.push_str(warn);
410 }
411 out
412 }
413}
414
415impl fmt::Display for SoakReport {
416 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
417 f.write_str(&self.summary())
418 }
419}
420
421/// Run `work` for `cfg.iterations`, sampling live heap with the [`DefaultSampler`],
422/// and return a [`SoakReport`]. See [`soak_with`] to supply a sampler.
423pub fn soak(cfg: &SoakConfig, work: impl FnMut(u64)) -> SoakReport {
424 soak_with(cfg, DefaultSampler::default(), work)
425}
426
427/// One-line convenience: soak `iterations` of `work` with default settings and
428/// assert the result plateaus. Equivalent to
429/// `soak(&SoakConfig::iterations(iterations), work).assert()`.
430///
431/// ```no_run
432/// # fn process_one_event(_: u64) {}
433/// navian_memcheck::assert_plateau(200_000, |i| process_one_event(i));
434/// ```
435///
436/// # Panics
437///
438/// Panics if memory did not plateau (see [`SoakReport::assert`]).
439pub fn assert_plateau(iterations: u64, work: impl FnMut(u64)) {
440 soak(&SoakConfig::iterations(iterations), work).assert();
441}
442
443/// Like [`soak`] but with an explicit [`Sampler`].
444// The sampler impls are zero-sized; taking one by value reads naturally at the
445// call site (`soak_with(cfg, RssSampler, work)`) and costs nothing.
446#[allow(clippy::needless_pass_by_value)]
447pub fn soak_with<S: Sampler>(
448 cfg: &SoakConfig,
449 sampler: S,
450 mut work: impl FnMut(u64),
451) -> SoakReport {
452 let every = cfg.sample_every.max(1);
453 let mut samples: Vec<(u64, u64)> = Vec::with_capacity((cfg.iterations / every) as usize + 1);
454
455 for i in 0..cfg.iterations {
456 work(i);
457 // Skip a FAILED read rather than record it as 0 — a spurious zero would
458 // depress peak/slope and bias the run toward a false pass.
459 if (i + 1) % every == 0 {
460 if let Some(bytes) = sampler.sample() {
461 samples.push((i + 1, bytes));
462 }
463 }
464 }
465 // Guarantee at least one tail sample even if iterations isn't a multiple of `every`
466 // (only when the tail read succeeds).
467 if cfg.iterations > 0 && samples.last().map(|(it, _)| *it) != Some(cfg.iterations) {
468 if let Some(bytes) = sampler.sample() {
469 samples.push((cfg.iterations, bytes));
470 }
471 }
472
473 finalize(cfg, samples, sampler.kind())
474}
475
476/// Build a [`SoakReport`] from samples collected *outside* this process — e.g.
477/// the CLI polling another process's RSS. Pass `(tick, bytes)` pairs with one
478/// tick per sample and set `cfg.sample_every == 1` so the slope is reported in
479/// bytes-per-sample.
480pub fn report_from_samples(
481 cfg: &SoakConfig,
482 samples: Vec<(u64, u64)>,
483 sampler_kind: &'static str,
484) -> SoakReport {
485 finalize(cfg, samples, sampler_kind)
486}
487
488/// Compute peak, back-half slope, and verdict from collected samples.
489fn finalize(cfg: &SoakConfig, samples: Vec<(u64, u64)>, sampler_kind: &'static str) -> SoakReport {
490 let baseline = samples.first().map_or(0, |(_, b)| *b);
491 let peak = samples.iter().map(|(_, b)| *b).max().unwrap_or(0);
492 let trough = samples.iter().map(|(_, b)| *b).min().unwrap_or(0);
493 // Spread over the WHOLE run — the evidence the workload exercised memory at all.
494 let moved_bytes = peak.saturating_sub(trough);
495
496 // Guard the DEGENERATE runs that can't be fit at all. Everything else (cap
497 // breach, growth, movement) is decided AFTER the line fit below, in priority
498 // order, so the fitted slope/r² is real on every non-degenerate report and the
499 // movement guard can only ever downgrade a would-be PASS — never a detected
500 // leak (`StillGrowing`) or a cap breach.
501 let inconclusive_reason = if samples.len() < 2 {
502 Some("fewer than 2 samples collected")
503 } else if peak == 0 {
504 Some("every sample read 0 bytes — is the sampler supported on this platform?")
505 } else {
506 None
507 };
508 if let Some(reason) = inconclusive_reason {
509 return SoakReport {
510 samples,
511 baseline,
512 peak,
513 moved_bytes,
514 back_half_slope: 0.0,
515 back_half_r2: 0.0,
516 sampler_kind,
517 verdict: Verdict::InsufficientSamples { reason },
518 trust_warnings: Vec::new(),
519 };
520 }
521
522 // Sanitize thresholds here (not just in the builders) so a struct-literal
523 // SoakConfig can never *disable* detection with a non-finite value.
524 let budget = if cfg.slope_bytes_per_sample.is_finite() {
525 cfg.slope_bytes_per_sample
526 } else {
527 0.0 // a non-finite budget errs toward FAIL, never toward a silent pass
528 };
529 let min_r2 = if cfg.min_r2_for_growth.is_finite() {
530 cfg.min_r2_for_growth.clamp(0.0, 1.0)
531 } else {
532 0.0
533 };
534 let warmup = if cfg.warmup_frac.is_finite() {
535 cfg.warmup_frac.clamp(0.0, 0.95)
536 } else {
537 0.5
538 };
539 // Fit a line through the back half (or configured tail) of the samples.
540 let n = samples.len();
541 let start = ((n as f64) * warmup).floor() as usize;
542 let start = start.min(n.saturating_sub(2)); // keep at least 2 points if we can
543 let tail = &samples[start..];
544 // Fit against sample INDEX (0,1,2,…), NOT the caller-supplied ticks. For the
545 // uniform cadence of an internal soak this is identical to fitting the ticks and
546 // rescaling to bytes-per-sample, but it is robust on the public sample-based API:
547 // duplicate ticks (a zero x-range) would fit slope 0 and FALSE-PASS growing
548 // memory, and non-monotonic ticks would underflow the unsigned offset. Index x
549 // sidesteps both — and bytes-per-sample is exactly the cadence-independent metric
550 // we want.
551 let xs: Vec<f64> = (0..tail.len()).map(|i| i as f64).collect();
552 let ys: Vec<f64> = tail.iter().map(|(_, b)| *b as f64).collect();
553 let fit = linear_fit(&xs, &ys);
554 let slope_per_sample = fit.slope;
555
556 // Verdict in PRIORITY order, so a definitive failure always beats "inconclusive":
557 // 1. cap breach — too much memory, a FAIL regardless of shape or movement;
558 // 2. growth — slope over budget is a detected leak (a FAIL), and must NOT be
559 // downgraded to "inconclusive" just because total movement was small;
560 // 3. insufficient movement — only NOW, for a would-be PASS, does the opt-in
561 // `require_movement` guard apply: a flat run that never moved enough proves
562 // nothing, so it is inconclusive rather than a hollow pass;
563 // 4. otherwise the memory plateaued → PASS.
564 //
565 // Growth uses the optional r² floor (off by default): a slow leak buried in
566 // page-noise has a low r², so gating on r² by default would mask the exact bug
567 // this tool exists to catch. The budget is the noise knob; for a clean signal
568 // use the jemalloc sampler.
569 let verdict = if let Some(cap) = cfg.max_bytes.filter(|&c| peak > c) {
570 Verdict::ExceededCap { peak, cap }
571 } else if slope_per_sample > budget && fit.r2 >= min_r2 {
572 Verdict::StillGrowing {
573 slope: slope_per_sample,
574 budget,
575 }
576 } else if cfg.min_movement_bytes > 0 && moved_bytes < cfg.min_movement_bytes {
577 Verdict::InsufficientSamples {
578 reason: "memory moved less than the required minimum — the workload may \
579 not allocate (or never hit the suspected path), or the sampler \
580 may be stuck. A flat series that never moved proves nothing.",
581 }
582 } else {
583 Verdict::Pass
584 };
585
586 // On a PASS, flag the ways it might be hollow so a green is earned, not assumed.
587 // These are advisory (the verdict stands) but each names the knob to set.
588 let trust_warnings = if matches!(verdict, Verdict::Pass) {
589 pass_trust_warnings(peak, moved_bytes, cfg.min_movement_bytes, budget, tail.len())
590 } else {
591 Vec::new()
592 };
593
594 SoakReport {
595 samples,
596 baseline,
597 peak,
598 moved_bytes,
599 back_half_slope: slope_per_sample,
600 back_half_r2: fit.r2,
601 sampler_kind,
602 verdict,
603 trust_warnings,
604 }
605}
606
607/// Warnings for a PASS that may not be trustworthy. Each fires on a heuristic that
608/// a green could be hollow, and names the exact knob to make it meaningful. All are
609/// advisory — the defaults keep the tool zero-config; these push you to earn the
610/// pass. `back_half_len` is the number of samples the plateau slope was fit over.
611fn pass_trust_warnings(
612 peak: u64,
613 moved_bytes: u64,
614 min_movement_bytes: u64,
615 budget: f64,
616 back_half_len: usize,
617) -> Vec<String> {
618 let mb = |b: f64| b / (1024.0 * 1024.0);
619 let mut w = Vec::new();
620
621 // 1) Barely moved: a flat run can hide a workload that never allocated or a
622 // stuck sampler. Only nag when the caller has NOT already set the hard knob.
623 if min_movement_bytes == 0 && moved_bytes.saturating_mul(20) < peak {
624 w.push(format!(
625 "memory moved only {:.1} MB across the run ({:.1} MB peak) — a nearly-flat \
626 run can hide a workload that never exercised the leak path, or a stuck \
627 sampler. Set require_movement / --min-movement to the churn you expect.",
628 mb(moved_bytes as f64),
629 mb(peak as f64),
630 ));
631 }
632
633 // 2) Loose budget: the tolerated cumulative growth over the fitted window is as
634 // large as the whole peak, so a real leak up to that size would still pass.
635 // (Threshold is the full peak, not half, to avoid crying wolf on every pass.)
636 let tolerated = budget * back_half_len as f64;
637 if peak > 0 && tolerated >= peak as f64 {
638 w.push(format!(
639 "slope budget tolerates ~{:.1} MB of growth over this window (~{:.0}% of the \
640 {:.1} MB peak) — a leak up to that size would still pass. Tighten \
641 slope_budget / --slope-budget toward your platform's noise floor.",
642 mb(tolerated),
643 (tolerated / peak as f64) * 100.0,
644 mb(peak as f64),
645 ));
646 }
647
648 // 3) Too few points: a short tail fits a line trivially (2 points → r²=1) and a
649 // slow leak may not have ramped yet.
650 if back_half_len < 8 {
651 w.push(format!(
652 "plateau rests on only {back_half_len} back-half sample(s) — too few to \
653 trust; a slow leak may not have ramped yet. Soak longer or sample more \
654 (more iterations / smaller --interval) so the tail has >= 8 points.",
655 ));
656 }
657
658 // NOTE: a step/sawtooth leak that sits flat WITHIN the fitted tail between steps
659 // is not flagged here — a clean heuristic is elusive (a legitimate plateau also
660 // ends at its peak level, and ties make peak-position useless). The whole-run
661 // `moved_bytes` is reported so a large spread under a flat slope is at least
662 // visible for inspection; a robust monotonic-trend statistic is a future add.
663
664 w
665}
666
667// ─────────────────────────────────────────────────────────────────────────────
668// Bounded-state assertions
669// ─────────────────────────────────────────────────────────────────────────────
670
671/// Assert that a size metric never exceeds `cap` as a driver sweeps over
672/// `drivers`. Use this to prove a structure is bounded regardless of input scale
673/// — e.g. "no matter how many distinct sessions arrive, the session map holds at
674/// most `cap` entries."
675///
676/// # Panics
677///
678/// Panics on the first driver value whose measurement exceeds `cap`.
679pub fn assert_bounded<I>(cap: u64, drivers: I, mut measure: impl FnMut(u64) -> u64)
680where
681 I: IntoIterator<Item = u64>,
682{
683 for d in drivers {
684 let got = measure(d);
685 assert!(
686 got <= cap,
687 "navian-memcheck: bound violated at driver={d}: measured {got} > cap {cap}"
688 );
689 }
690}
691
692/// Fit `(driver, bytes)` growth points and return the [`Fit`]. `slope` is bytes
693/// per unit of driver; a bounded-per-item structure has a small, stable slope and
694/// high `r2`.
695pub fn fit_growth(points: &[(u64, u64)]) -> Fit {
696 // Offset x by the MINIMUM driver value (not the first) to preserve f64 precision
697 // on large inputs AND stay panic-free on unsorted/non-monotonic drivers — a first
698 // offset would underflow the unsigned subtraction (debug panic / release wrap to a
699 // huge x, corrupting the fit) whenever a later driver is smaller than the first.
700 let x0 = points.iter().map(|(d, _)| *d).min().unwrap_or(0);
701 let xs: Vec<f64> = points.iter().map(|(d, _)| (d - x0) as f64).collect();
702 let ys: Vec<f64> = points.iter().map(|(_, b)| *b as f64).collect();
703 linear_fit(&xs, &ys)
704}
705
706/// Assert that memory grows *at most linearly* in a driver and no faster than
707/// `max_bytes_per_unit`. Catches super-linear blowups (e.g. an accidental O(n²)
708/// retained buffer) that a single-point check would miss.
709///
710/// # Panics
711///
712/// Panics if the fitted slope exceeds `max_bytes_per_unit`.
713pub fn assert_linear_in(points: &[(u64, u64)], max_bytes_per_unit: f64) -> Fit {
714 // A line needs ≥2 points; fewer can't measure a slope, and a non-finite or
715 // negative budget would silently DISABLE the assertion. Reject both loudly rather
716 // than let a misuse turn into a false pass.
717 assert!(
718 points.len() >= 2,
719 "navian-memcheck: assert_linear_in needs at least 2 points, got {}",
720 points.len()
721 );
722 // Distinct driver values are required, not just ≥2 points: with no x-spread the
723 // least-squares slope collapses to 0 and would FALSE-PASS any growth. Reject it.
724 let x_min = points.iter().map(|(d, _)| *d).min().unwrap();
725 let x_max = points.iter().map(|(d, _)| *d).max().unwrap();
726 assert!(
727 x_max > x_min,
728 "navian-memcheck: assert_linear_in needs at least two distinct driver values (all were {x_min})"
729 );
730 assert!(
731 max_bytes_per_unit.is_finite() && max_bytes_per_unit >= 0.0,
732 "navian-memcheck: max_bytes_per_unit must be finite and non-negative, got {max_bytes_per_unit}"
733 );
734 let fit = fit_growth(points);
735 assert!(
736 fit.slope <= max_bytes_per_unit,
737 "navian-memcheck: growth too steep: {:.1} B/unit > budget {:.1} B/unit (r2={:.2})",
738 fit.slope,
739 max_bytes_per_unit,
740 fit.r2
741 );
742 fit
743}
744
745#[cfg(test)]
746mod tests {
747 use super::*;
748
749 #[test]
750 fn linear_fit_recovers_slope() {
751 let xs = [0.0, 1.0, 2.0, 3.0, 4.0];
752 let ys = [1.0, 3.0, 5.0, 7.0, 9.0]; // y = 2x + 1
753 let fit = linear_fit(&xs, &ys);
754 assert!((fit.slope - 2.0).abs() < 1e-9);
755 assert!((fit.intercept - 1.0).abs() < 1e-9);
756 assert!((fit.r2 - 1.0).abs() < 1e-9);
757 }
758
759 #[test]
760 fn duplicate_ticks_do_not_hide_growth() {
761 // Every sample carries the SAME tick (0). Fitting against ticks would give a
762 // zero x-range → slope 0 → FALSE PASS. Fitting against sample index catches it.
763 let samples: Vec<(u64, u64)> = (0..12)
764 .map(|i| (0u64, 100_000_000 + i * 10_000_000))
765 .collect();
766 let r = report_from_samples(&SoakConfig::iterations(200), samples, "test");
767 assert!(
768 matches!(r.verdict, Verdict::StillGrowing { .. }),
769 "duplicate ticks must not mask growth, got {:?}",
770 r.verdict
771 );
772 }
773
774 #[test]
775 fn non_monotonic_ticks_do_not_panic_and_still_detect() {
776 // Decreasing/unsorted ticks previously underflowed the unsigned offset
777 // (debug panic / release wrap). Index-based fit is immune; growth by index
778 // is still flagged.
779 let samples: Vec<(u64, u64)> = vec![
780 (100, 100_000_000),
781 (50, 110_000_000),
782 (200, 120_000_000),
783 (10, 130_000_000),
784 (150, 140_000_000),
785 (5, 150_000_000),
786 ];
787 let r = report_from_samples(&SoakConfig::iterations(200), samples, "test");
788 assert!(matches!(r.verdict, Verdict::StillGrowing { .. }));
789 }
790
791 #[test]
792 fn flat_after_warmup_passes() {
793 // Guard against a false FAIL: a series that RISES during warmup then levels
794 // off is a genuine plateau (moved > 0, back-half slope ~0) → PASS.
795 let mut samples: Vec<(u64, u64)> = vec![(0, 90_000_000)];
796 samples.extend((1..12).map(|i| (i, 100_000_000)));
797 let r = report_from_samples(&SoakConfig::iterations(200), samples, "test");
798 assert!(matches!(r.verdict, Verdict::Pass), "flat-after-warmup must pass, got {:?}", r.verdict);
799 }
800
801 #[test]
802 fn constant_series_passes_by_default_but_reports_zero_movement() {
803 // By default a flat constant series PASSES (a bounded workload looks flat),
804 // but its zero movement is visible so a green isn't blindly trusted.
805 let samples: Vec<(u64, u64)> = (0..50).map(|i| (i, 10_000_000)).collect();
806 let r = report_from_samples(&SoakConfig::iterations(50), samples.clone(), "test");
807 assert!(matches!(r.verdict, Verdict::Pass), "flat passes by default, got {:?}", r.verdict);
808 assert_eq!(r.moved_bytes, 0, "but zero movement is reported");
809 }
810
811 #[test]
812 fn hollow_pass_raises_trust_warnings() {
813 // A flat, near-zero-movement series over a short tail PASSES by default but
814 // must carry advisory warnings pointing at the knobs.
815 let samples: Vec<(u64, u64)> = (0..12).map(|i| (i, 10_000_000)).collect();
816 let r = report_from_samples(&SoakConfig::iterations(12).sample_every(1), samples, "test");
817 assert!(matches!(r.verdict, Verdict::Pass));
818 assert!(!r.trust_warnings.is_empty(), "hollow pass must warn");
819 // moved 0 → the movement warning fires.
820 assert!(r.trust_warnings.iter().any(|w| w.contains("moved only")));
821 }
822
823 #[test]
824 fn earned_pass_has_no_trust_warnings() {
825 // Real movement, tight budget, plenty of samples → a clean pass, no nags.
826 let mut samples: Vec<(u64, u64)> = (0..20).map(|i| (i, 5_000_000 + i * 250_000)).collect();
827 samples.extend((20..80).map(|i| (i, 10_000_000)));
828 let cfg = SoakConfig::iterations(80).sample_every(1).slope_budget(1024.0);
829 let r = report_from_samples(&cfg, samples, "test");
830 assert!(matches!(r.verdict, Verdict::Pass), "{}", r.summary());
831 assert!(
832 r.trust_warnings.is_empty(),
833 "earned pass should not warn, got: {:?}",
834 r.trust_warnings
835 );
836 }
837
838 #[test]
839 fn fail_carries_no_trust_warnings() {
840 // Warnings are a PASS concept; a growing series just fails.
841 let samples: Vec<(u64, u64)> = (0..80).map(|i| (i, 1_000_000 + i * 500_000)).collect();
842 let cfg = SoakConfig::iterations(80).sample_every(1).slope_budget(4096.0);
843 let r = report_from_samples(&cfg, samples, "test");
844 assert!(matches!(r.verdict, Verdict::StillGrowing { .. }));
845 assert!(r.trust_warnings.is_empty());
846 }
847
848 #[test]
849 fn a_detected_leak_is_not_downgraded_by_require_movement() {
850 // A clearly-growing series whose TOTAL spread is below the movement floor
851 // must still FAIL as StillGrowing — a detected leak is never downgraded to
852 // "inconclusive". (Growth is decided before the movement guard.)
853 let samples: Vec<(u64, u64)> = (0..200).map(|i| (i, 1_000_000 + i * 400_000)).collect();
854 let cfg = SoakConfig::iterations(200)
855 .sample_every(1)
856 .slope_budget(4096.0)
857 .require_movement(10_000_000_000); // absurdly high floor
858 let r = report_from_samples(&cfg, samples, "test");
859 assert!(
860 matches!(r.verdict, Verdict::StillGrowing { .. }),
861 "a leak must fail, not go inconclusive; got {:?}",
862 r.verdict
863 );
864 }
865
866 #[test]
867 fn cap_breach_carries_real_slope_and_beats_growth() {
868 // A rising, over-cap series → ExceededCap (cap beats growth), and the report
869 // now carries the real fitted slope (not a hard-coded 0).
870 let samples: Vec<(u64, u64)> = (0..50).map(|i| (i, 20_000_000 + i * 1_000_000)).collect();
871 let cfg = SoakConfig::iterations(50).sample_every(1).max_bytes(10_000_000);
872 let r = report_from_samples(&cfg, samples, "test");
873 assert!(matches!(r.verdict, Verdict::ExceededCap { .. }));
874 assert!(r.back_half_slope > 0.0, "cap report should carry the real slope");
875 }
876
877 #[test]
878 fn single_over_cap_sample_is_inconclusive_not_cap() {
879 // A 1-sample run can't be fit; the degenerate guard wins over the cap check
880 // (restores the pre-reorder behavior for sub-2-sample runs).
881 let samples = vec![(0u64, 50_000_000u64)];
882 let cfg = SoakConfig::iterations(1).max_bytes(10_000_000);
883 let r = report_from_samples(&cfg, samples, "test");
884 assert!(matches!(r.verdict, Verdict::InsufficientSamples { .. }), "got {:?}", r.verdict);
885 }
886
887 #[test]
888 fn require_movement_makes_a_flat_run_inconclusive() {
889 // Opt in: a run that moved less than required is inconclusive, not a hollow
890 // pass — the domain threshold is the caller's to set.
891 let samples: Vec<(u64, u64)> = (0..50).map(|i| (i, 10_000_000)).collect();
892 let cfg = SoakConfig::iterations(50).require_movement(1_000_000);
893 let r = report_from_samples(&cfg, samples, "test");
894 assert!(
895 matches!(r.verdict, Verdict::InsufficientSamples { .. }),
896 "require_movement must make a zero-movement run inconclusive, got {:?}",
897 r.verdict
898 );
899 }
900
901 #[test]
902 fn fit_growth_handles_unsorted_drivers() {
903 // Must not panic on unsorted/non-monotonic drivers (min-offset, not first).
904 let fit = fit_growth(&[(100, 1), (1, 2), (50, 3), (0, 4)]);
905 assert!(fit.slope.is_finite());
906 }
907
908 #[test]
909 #[should_panic(expected = "at least 2 points")]
910 fn assert_linear_in_rejects_too_few_points() {
911 assert_linear_in(&[(1, 100)], 1.0);
912 }
913
914 #[test]
915 #[should_panic(expected = "finite and non-negative")]
916 fn assert_linear_in_rejects_infinite_budget() {
917 assert_linear_in(&[(1, 100), (2, 200)], f64::INFINITY);
918 }
919
920 #[test]
921 #[should_panic(expected = "distinct driver")]
922 fn assert_linear_in_rejects_no_driver_spread() {
923 // Two points, same driver → zero x-spread → slope 0 → would false-pass.
924 assert_linear_in(&[(7, 0), (7, 1_000_000_000)], 0.0);
925 }
926
927 #[test]
928 fn flat_after_warmup_is_a_plateau() {
929 // Ramps for the first 10 samples, then flat for 90 — a real plateau with
930 // movement, back-half slope ~0.
931 let mut samples: Vec<(u64, u64)> = (0..10).map(|i| (i, 9_000_000 + i * 100_000)).collect();
932 samples.extend((10..100).map(|i| (i, 10_000_000)));
933 let cfg = SoakConfig::iterations(100);
934 let report = finalize(&cfg, samples, "test");
935 assert!(report.passed(), "{}", report.summary());
936 assert!(report.back_half_slope.abs() < 1.0);
937 assert!(report.moved_bytes > 0);
938 }
939
940 #[test]
941 fn rising_series_still_growing() {
942 // +100 KB per sample, forever.
943 let samples: Vec<(u64, u64)> = (0..100).map(|i| (i, 1_000_000 + i * 100_000)).collect();
944 let cfg = SoakConfig::iterations(100)
945 .sample_every(1)
946 .slope_budget(4096.0);
947 let report = finalize(&cfg, samples, "test");
948 assert!(!report.passed());
949 matches!(report.verdict, Verdict::StillGrowing { .. });
950 }
951
952 #[test]
953 fn cap_breach_beats_slope_check() {
954 let samples: Vec<(u64, u64)> = (0..50).map(|i| (i, 50_000_000)).collect();
955 let cfg = SoakConfig::iterations(50).max_bytes(10_000_000);
956 let report = finalize(&cfg, samples, "test");
957 assert!(matches!(report.verdict, Verdict::ExceededCap { .. }));
958 }
959
960 #[test]
961 fn assert_linear_accepts_bounded_growth() {
962 // 64 bytes per entry, exactly linear.
963 let pts: Vec<(u64, u64)> = (0..1000).step_by(50).map(|n| (n, n * 64)).collect();
964 let fit = assert_linear_in(&pts, 128.0);
965 assert!((fit.slope - 64.0).abs() < 1.0);
966 }
967
968 #[test]
969 #[should_panic(expected = "bound violated")]
970 fn assert_bounded_catches_unbounded() {
971 // measured == driver, so it blows past a fixed cap.
972 assert_bounded(500, (0..1000u64).step_by(100), |d| d);
973 }
974}