use std::path::Path;
use znippy_plugin_git::arms::DEFAULT_REDB_CACHE_BYTES;
use znippy_plugin_git::index_layout::{IndexEntry, ObjType, ObjectIndex, OneTableFourColumns};
use znippy_plugin_git::read_stack::{ObjectReadStack, RebuildTriggers};
fn anon_rss_kb() -> u64 {
let s = std::fs::read_to_string("/proc/self/smaps_rollup")
.expect("/proc/self/smaps_rollup (Linux 4.14+)");
for line in s.lines() {
if let Some(rest) = line.strip_prefix("Anonymous:") {
let kb = rest.trim().trim_end_matches("kB").trim();
return kb.parse().expect("Anonymous: is a kB count");
}
}
panic!("no `Anonymous:` line in smaps_rollup");
}
fn mb(kb: u64) -> f64 {
kb as f64 / 1024.0
}
fn mallinfo_kb() -> Option<(u64, u64)> {
#[cfg(all(target_os = "linux", target_env = "gnu"))]
{
let mi = unsafe { libc::mallinfo2() };
let in_use = (mi.uordblks as u64 + mi.hblkhd as u64) / 1024;
let from_kernel = (mi.arena as u64 + mi.hblkhd as u64) / 1024;
return Some((in_use, from_kernel));
}
#[cfg(not(all(target_os = "linux", target_env = "gnu")))]
None
}
fn mallinfo_line(tag: &str) -> String {
match mallinfo_kb() {
Some((used, from_kernel)) => format!(
"{tag}: in-use {:8.1} MB · from-kernel {:8.1} MB · free-list {:8.1} MB",
mb(used),
mb(from_kernel),
mb(from_kernel.saturating_sub(used))
),
None => format!("{tag}: mallinfo2 unavailable (not glibc)"),
}
}
fn malloc_trim() {
unsafe { libc::malloc_trim(0) };
}
struct Phase {
what: String,
base: u64,
live: u64,
dropped: u64,
trimmed: u64,
}
impl Phase {
fn report(&self) -> Self {
let grew = self.live.saturating_sub(self.base);
let after_drop = self.dropped.saturating_sub(self.base);
let after_trim = self.trimmed.saturating_sub(self.base);
eprintln!(
"\n── {} ──\n grew {:8.1} MB\n after drop {:8.1} MB ({:5.1}% still \
resident)\n after trim {:8.1} MB ({:5.1}% still resident)",
self.what,
mb(grew),
mb(after_drop),
100.0 * after_drop as f64 / grew.max(1) as f64,
mb(after_trim),
100.0 * after_trim as f64 / grew.max(1) as f64,
);
Self { what: self.what.clone(), ..*self }
}
fn held_after_drop_pct(&self) -> f64 {
let grew = self.live.saturating_sub(self.base).max(1);
100.0 * self.dropped.saturating_sub(self.base) as f64 / grew as f64
}
fn held_after_trim_pct(&self) -> f64 {
let grew = self.live.saturating_sub(self.base).max(1);
100.0 * self.trimmed.saturating_sub(self.base) as f64 / grew as f64
}
}
fn control_small_blocks(target_mb: usize) -> Phase {
const BLOCK: usize = 4096;
let n = target_mb * 1024 * 1024 / BLOCK;
let base = anon_rss_kb();
let mut v: Vec<Vec<u8>> = Vec::with_capacity(n);
for i in 0..n {
let mut b = vec![0u8; BLOCK];
b[0] = i as u8;
b[BLOCK - 1] = (i >> 8) as u8;
v.push(b);
}
let live = anon_rss_kb();
drop(v);
let dropped = anon_rss_kb();
malloc_trim();
let trimmed = anon_rss_kb();
Phase { what: format!("CONTROL A — {n} x 4 KiB blocks (redb page shape)"), base, live, dropped, trimmed }
}
fn control_large_blocks(target_mb: usize) -> Phase {
const BLOCK: usize = 1024 * 1024;
let n = target_mb;
let base = anon_rss_kb();
let mut v: Vec<Vec<u8>> = Vec::with_capacity(n);
for i in 0..n {
let mut b = vec![0u8; BLOCK];
for p in (0..BLOCK).step_by(4096) {
b[p] = i as u8;
}
v.push(b);
}
let live = anon_rss_kb();
drop(v);
let dropped = anon_rss_kb();
malloc_trim();
let trimmed = anon_rss_kb();
Phase { what: format!("CONTROL B — {n} x 1 MiB blocks (above mmap threshold)"), base, live, dropped, trimmed }
}
fn probe_cache_bytes() -> usize {
match std::env::var("PROBE_REDB_CACHE_BYTES") {
Ok(v) if !v.trim().is_empty() => v.trim().parse::<usize>().unwrap_or_else(|_| {
panic!(
"PROBE_REDB_CACHE_BYTES={v:?} is not a bare byte count. Suffixes are NOT \
parsed — write 8388608, not 8m. Failing loudly rather than measuring a \
ceiling that was never applied."
)
}),
_ => DEFAULT_REDB_CACHE_BYTES,
}
}
fn scratch_root() -> std::path::PathBuf {
let root = std::env::var("PROBE_SCRATCH")
.ok()
.filter(|s| !s.trim().is_empty())
.map(std::path::PathBuf::from)
.unwrap_or_else(std::env::temp_dir);
if is_tmpfs(&root) {
eprintln!(
" WARNING: {} is tmpfs (RAM). The redb fixtures for this run are being written \
into memory. Set PROBE_SCRATCH to a real disk.",
root.display()
);
}
root
}
fn is_tmpfs(p: &Path) -> bool {
let Ok(mounts) = std::fs::read_to_string("/proc/mounts") else { return false };
let mut best: Option<(usize, bool)> = None;
for line in mounts.lines() {
let mut f = line.split_whitespace();
let (_dev, point, fstype) = (f.next(), f.next(), f.next());
let (Some(point), Some(fstype)) = (point, fstype) else { continue };
if p.starts_with(point) {
let len = point.len();
if best.is_none_or(|(b, _)| len > b) {
best = Some((len, fstype == "tmpfs"));
}
}
}
best.is_some_and(|(_, t)| t)
}
fn synthetic(n: usize, seed: u64) -> Vec<IndexEntry> {
let mut s = seed | 1;
let mut out = Vec::with_capacity(n);
let mut offset = 12u64;
for _ in 0..n {
s ^= s << 13;
s ^= s >> 7;
s ^= s << 17;
let mut oid = vec![0u8; 20];
oid[..8].copy_from_slice(&s.to_le_bytes());
oid[8..16].copy_from_slice(&s.rotate_left(21).to_le_bytes());
let len = 64 + (s % 4096);
out.push(IndexEntry {
oid,
offset,
len,
obj_type: ObjType::Blob,
uncompressed_size: len * 3,
delta_base: 0,
});
offset += len;
}
out
}
fn real_stacks(dir: &Path, repos: usize, rows: usize) -> Phase {
let base = anon_rss_kb();
let mut stacks = Vec::with_capacity(repos);
for r in 0..repos {
let path = dir.join(format!("repo{r}.objects.tail.redb"));
let stack = ObjectReadStack::<OneTableFourColumns>::open(
&path,
RebuildTriggers::manual(),
probe_cache_bytes(),
)
.expect("open a read stack");
let entries = synthetic(rows, 0x9E3779B97F4A7C15 ^ (r as u64));
stack.append(&entries).expect("append");
stack.rebuild().expect("rebuild");
for e in entries.iter().step_by(7) {
let _ = stack.lookup(&e.oid);
}
stacks.push(stack);
}
let live = anon_rss_kb();
let held_mb = mb(live.saturating_sub(base));
eprintln!(
" [live] {repos} stacks x {rows} rows = {:.1} MB anonymous, {:.1} MB per stack \
(cache cap {} MiB/db)",
held_mb,
held_mb / repos as f64,
probe_cache_bytes() / (1024 * 1024),
);
eprintln!(" {}", mallinfo_line("[live] "));
drop(stacks);
let dropped = anon_rss_kb();
eprintln!(" {}", mallinfo_line("[dropped]"));
malloc_trim();
let trimmed = anon_rss_kb();
eprintln!(" {}", mallinfo_line("[trimmed]"));
Phase {
what: format!("REAL — {repos} x ObjectReadStack over redb ({rows} rows each)"),
base,
live,
dropped,
trimmed,
}
}
#[test]
#[ignore = "measurement: allocates ~1 GB and must not share a process with other tests"]
fn reachable_or_merely_unfreed() {
eprintln!(
"\n=== RSS RETENTION PROBE ===\nallocator: {} (no #[global_allocator] in \
gunnar-server or znippy ⇒ system malloc)\n",
if cfg!(target_env = "gnu") { "glibc malloc" } else { "NOT glibc — read the verdict with care" }
);
let b = control_large_blocks(700).report();
let a = control_small_blocks(700).report();
let dir = tempfile::Builder::new()
.prefix("znippy-rss-probe-")
.tempdir_in(scratch_root())
.expect("tempdir on the scratch root");
let real = real_stacks(dir.path(), 20, 300_000).report();
eprintln!("\n=== VERDICT ===");
for c in [&b, &a] {
assert!(
c.held_after_drop_pct() < 10.0,
"{}: a control that allocates and frees a known {:.0} MB left {:.1}% resident. \
This probe cannot detect memory being returned, so NOTHING below can be \
attributed — the reading is about the instrument, not the workload.",
c.what,
mb(c.live.saturating_sub(c.base)),
c.held_after_drop_pct()
);
}
eprintln!(
"instrument OK: both controls returned their bytes on drop ({:.1}% / {:.1}% left), \
so a return IS visible to this probe.",
b.held_after_drop_pct(),
a.held_after_drop_pct()
);
let held_drop = real.held_after_drop_pct();
let held_trim = real.held_after_trim_pct();
eprintln!(
"(a) while LIVE: {:.1} MB resident for {} databases — see the [live] in-use line \
above. If in-use tracks resident, these bytes are REACHABLE and deliberately \
held, and the owner is the per-database redb page cache at its \
{} MiB cap.",
mb(real.live.saturating_sub(real.base)),
20,
probe_cache_bytes() / (1024 * 1024),
);
if held_drop < 30.0 {
eprintln!(
"(b) NO: dropping the stacks returned {:.1}% on its own, so releasing a store \
does give RSS back and eviction alone would be a complete fix.",
100.0 - held_drop
);
} else if held_trim < held_drop - 20.0 {
eprintln!(
"(b) YES — VERIFIED. {held_drop:.1}% of the bytes survived dropping EVERY handle, \
and malloc_trim(0) then returned them, leaving {held_trim:.1}%. The program owns \
nothing at that point; glibc was sitting on the pages.\n\
\n\
CONSEQUENCE, and it is the reason two eviction fixes changed nothing: eviction \
and trim are BOTH required. Evicting without trimming frees bytes the OS never \
gets back, so RSS stays flat and the fix looks like it failed. Trimming without \
evicting has nothing to trim, because a bound that never fires never frees."
);
} else {
eprintln!(
"(b) NO: {held_drop:.1}% survived the drop AND {held_trim:.1}% survived \
malloc_trim. Something still owns these bytes after every handle is gone. \
Find the owner."
);
}
eprintln!();
}