navian-memcheck 0.1.0

Resource-leak / soak testing as a cargo-test assertion: run a workload under load and prove its memory PLATEAUS after warmup — catching reachable, unbounded growth that leak detectors miss.
Documentation

navian-memcheck

Resource-leak / soak testing as a cargo test assertion. Prove that a workload's memory plateaus after warmup — catching the reachable-but-unbounded growth that leak detectors are built to ignore.

The bug that motivated this took a process to 52 GB and got it OOM-killed — but nothing leaked. Every byte was reachable: a per-session map that grew and never evicted. LeakSanitizer and Valgrind memcheck classify that as "still in use", not a leak. A profiler shows it, but only as a flamegraph a human reads. navian-memcheck asserts the property that was actually violated: after warmup, live memory levels off.

It catches three failure modes leak detectors miss:

  • Temporal — at steady load, memory climbs the longer the process runs.
  • Unbounded — memory scales with load and never comes back down.
  • Type — memory grows faster than its input: the wrong Big-O.

Quickstart — zero code changes

Add the crate and write one test. There's no allocator to install and no setup.

[dev-dependencies]
navian-memcheck = "0.1"
use navian_memcheck::{soak, SoakConfig};

#[test]
fn memory_stays_bounded() {
    let report = soak(&SoakConfig::iterations(200_000), |i| {
        engine.handle_event(next_event(i)); // your real per-event work
    });
    report.assert(); // panics with a readable summary if memory kept climbing
}

Or the one-liner:

navian_memcheck::assert_plateau(200_000, |i| engine.handle_event(next_event(i)));

On a workload that leaks, the failure reads like this:

navian-memcheck: FAIL — still growing: back-half slope 620,389 B/sample > budget 4,096 B/sample

Asserting the load and shape questions too

use navian_memcheck::{assert_bounded, assert_linear_in};

// Unbounded check: memory stays under a cap as the driver scales.
assert_bounded(64 * 1024 * 1024, (1..=10).map(|n| n * 100_000), |load| live_bytes_at(load));

// Type check: memory grows at most linearly in the driver (no O(n²) blowup).
assert_linear_in(&growth_points, /* max bytes per unit */ 128.0);

How it works

It samples live memory on a fixed cadence, fits an ordinary-least-squares line through the back half of the run, and fails if the slope exceeds your budget or a hard cap is breached. RSS is noisy, so the slope budget is your noise knob — set it above your platform's jitter, or use the jemalloc sampler for a clean, low-noise signal. Because reproducing a memory bug doesn't require reproducing production throughput, you drive your event handler directly — bounded by CPU, not I/O — so a climb that takes hours in production surfaces in seconds in CI. Run it from a seed (or under navian-dst) so a failing run reproduces and can be bisected to the event that caused it.

Sampling precision (optional)

The default [RssSampler] reads process RSS — zero-setup, but coarser. For a cleaner in-process signal, enable the jemalloc feature and pass JemallocSampler to soak_with; it reads jemalloc's live-heap counter. That needs jemalloc as the global allocator (one line, and free if your service already uses it).

When to reach for something else

navian-memcheck is a detector, not a localizer. It tells you that memory grew and gates CI on it — it does not name the allocation site. Once it's red and you need to find the exact line, hand off to a heap profiler: dhat, heaptrack, or Valgrind. And for a one-time leak at startup (freed by exit, so it never grows), a sanitizer (-Zsanitizer=leak) is the right tool. This crate owns the gap none of those cover: the always-on gate for unbounded growth under load.

Whole binaries

The navian-memcheck CLI soaks any process's RSS over a duration and exits non-zero for CI — no code required.

License

Apache-2.0. See LICENSE and NOTICE.