Skip to main content

znippy_plugin_git/
memguard.rs

1//! **The indexer must never take the box down.**
2//!
3//! MEASURED 2026-08-29 21:10 UTC on the gunnar appliance (16 GB, 4 vCPU, no
4//! swap, PID 1 = gunnar-server): a `push --mirror` of torvalds/linux (8.12 GiB
5//! pack, 13 948 244 objects, 11 215 872 deltas) was appended and acked, then the
6//! post-ack indexer grew past 16 GB and the kernel said
7//! `Kernel panic - not syncing: System is deadlocked on memory`. PID 1 was the
8//! only process, so the OOM killer had nothing to kill — and a kernel that cannot
9//! kill anything panics.
10//!
11//! This module is the guard that makes that impossible by construction rather
12//! than by hoping the pack is small: **before every batch and every few thousand
13//! objects inside one**, the worker reads `MemAvailable` out of `/proc/meminfo`
14//! and, below a budget, *flushes what it holds and waits*. The kernel's own
15//! estimate is used because it already accounts for reclaimable page cache —
16//! the mmapped pack ([`crate::archive_map`]) costs nothing against it, only the
17//! anonymous heap this process really owns does.
18//!
19//! | knob | default | what |
20//! |---|---:|---|
21//! | `ZNIPPY_GIT_MEM_BUDGET_PCT` | `20` | pause when `MemAvailable` < this % of `MemTotal` |
22//! | `ZNIPPY_GIT_MEM_BUDGET_BYTES` | — | absolute override of the same floor |
23//! | `ZNIPPY_GIT_MEMINFO` | `/proc/meminfo` | the file read — a test hands in a fake |
24//!
25//! The pause is **one WARN line** when it starts (`index.paused`, which the
26//! gunnar dashboard renders as `index paused: 2.9G available < 3.2G budget`),
27//! a re-check every 250 ms, and **one INFO line** when it resumes. Nothing is
28//! logged per poll.
29//!
30//! What "flush what it holds" means is the caller's: [`MemWatchdog::pause_until_ok`]
31//! takes the flush as a closure, because the resolver's droppable state (its
32//! held delta bases, [`crate::resolve`]) and the sink's buffered rows are not
33//! this module's to know about.
34
35use std::path::{Path, PathBuf};
36use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
37use std::sync::{Arc, OnceLock, RwLock};
38use std::time::{Duration, Instant};
39
40use anyhow::Result;
41
42/// The default floor: pause when less than this share of `MemTotal` is available.
43pub const DEFAULT_BUDGET_PCT: u64 = 20;
44/// Re-check cadence while paused.
45pub const POLL: Duration = Duration::from_millis(250);
46/// Objects between two reads of `/proc/meminfo` inside one pack. A read is a
47/// few microseconds; 4096 objects of inflate+hash is milliseconds.
48pub const CHECK_EVERY_OBJECTS: u64 = 4096;
49/// While paused, the flush is run again this often: what a reader rebuilt in
50/// the meantime (its scanned pack, the exploded oid tree) is the owner's to
51/// drop again. MEASURED on the 8 GB rig 2026-08-30: a pause that flushed once
52/// sat for two hours at 92 % used, holding a 2 GB table a reader built AFTER
53/// the flush.
54pub const REFLUSH_EVERY: Duration = Duration::from_secs(10);
55/// Resume once this much MORE than the floor is available — **an absolute
56/// margin, not a share of the budget**.
57///
58/// It was `budget / 8` and that is the livelock lane P2 measured on the rig
59/// 2026-08-30. The pause opened on a **75 MB (2.2 %)** shortfall at 16 GB and
60/// a **1.65 MB (0.1 %)** shortfall at 8 GB, and then demanded 12.5 % of the
61/// floor — 410 MB and 200 MB — before it would leave. It never left: 1 pause
62/// and 0 resumes in 31m44s and 1h45m41s, and on the 8 GB box `MemAvailable`
63/// had FALLEN by the time the repository was deleted out from under it.
64///
65/// The margin exists to stop flapping (14 pause/resume pairs in one minute on
66/// the 8 GB rig), and 64 MiB stops that just as well as an eighth of the floor
67/// does, without making the exit condition scale with the box.
68pub const RESUME_MARGIN_BYTES: u64 = 64 << 20;
69
70/// **A pause has a deadline.** [`MemWatchdog::pause_until_ok`] had none, which
71/// is why the two rig boxes parked until somebody deleted the repository. Past
72/// this the wait ends with [`PauseEnd::Deadline`] and the caller must act —
73/// proceed, shed, or fail the pack BY NAME — rather than sleep again.
74pub const PAUSE_DEADLINE: Duration = Duration::from_secs(300);
75
76/// A pause whose flush frees **nothing** this many times running gives up
77/// before the deadline. `index.spilled` fired 190 and 627 times with
78/// `scanned_rows_dropped=0` on every single one: the spill had nothing to give,
79/// because the RAM was held by the resolver's live O(pack) structures. More
80/// waiting cannot fix that, and pretending otherwise is what cost two hours.
81pub const GIVE_UP_AFTER_EMPTY_FLUSHES: u32 = 3;
82
83/// `(MemTotal, MemAvailable)` in bytes out of `/proc/meminfo` text.
84pub fn parse_meminfo(text: &str) -> Option<(u64, u64)> {
85    let mut total = None;
86    let mut avail = None;
87    for line in text.lines() {
88        let Some((key, rest)) = line.split_once(':') else { continue };
89        let Some(kib) = rest.split_ascii_whitespace().next().and_then(|v| v.parse::<u64>().ok())
90        else {
91            continue;
92        };
93        match key {
94            "MemTotal" => total = Some(kib * 1024),
95            "MemAvailable" => avail = Some(kib * 1024),
96            _ => {}
97        }
98        if total.is_some() && avail.is_some() {
99            break;
100        }
101    }
102    Some((total?, avail?))
103}
104
105/// `1.5G` / `512M` / `4K` — the dashboard's spelling, so the WARN row and the
106/// header agree on what a number looks like.
107pub fn fmt_bytes(n: u64) -> String {
108    const K: u64 = 1024;
109    if n >= K * K * K {
110        format!("{:.1}G", n as f64 / (K * K * K) as f64)
111    } else if n >= K * K {
112        format!("{}M", n / (K * K))
113    } else if n >= K {
114        format!("{}K", n / K)
115    } else {
116        format!("{n}B")
117    }
118}
119
120/// The watchdog: a meminfo path, a floor, and the counters that say what it did.
121pub struct MemWatchdog {
122    meminfo: PathBuf,
123    total: u64,
124    budget: u64,
125    poll: Duration,
126    /// Times a pause began.
127    pauses: AtomicU64,
128    /// Milliseconds spent paused, summed.
129    paused_ms: AtomicU64,
130    /// `true` between the WARN and the INFO.
131    paused_now: AtomicBool,
132    /// The absolute resume margin (see [`RESUME_MARGIN_BYTES`]).
133    resume_margin: u64,
134    /// The ceiling on one pause; `Duration::ZERO` disables it.
135    deadline: Duration,
136    /// How often the flush is run again while paused ([`REFLUSH_EVERY`]).
137    reflush: Duration,
138    /// Pauses that ended because they ran out of time or out of things to drop.
139    give_ups: AtomicU64,
140}
141
142/// **How a pause ended**, which is the fact the caller has to branch on.
143///
144/// Before 2026-08-30 there was only one way out — memory came back — and no way
145/// to say "it did not". Lane P2's two rig boxes took the missing branch for
146/// 31m44s and 1h45m41s each and the clients never got an ack.
147#[derive(Debug, Clone, Copy, PartialEq, Eq)]
148pub enum PauseEnd {
149    /// `MemAvailable` climbed back over the floor plus the margin.
150    Recovered,
151    /// The cancel flag was raised (the repository is being deleted).
152    Cancelled,
153    /// [`MemWatchdog::pause_deadline`] expired.
154    Deadline,
155    /// The flush freed nothing [`GIVE_UP_AFTER_EMPTY_FLUSHES`] times running
156    /// and memory did not move: there is nothing left to give on this box.
157    NothingToGive,
158}
159
160impl PauseEnd {
161    pub fn as_str(self) -> &'static str {
162        match self {
163            PauseEnd::Recovered => "recovered",
164            PauseEnd::Cancelled => "cancelled",
165            PauseEnd::Deadline => "deadline",
166            PauseEnd::NothingToGive => "nothing-to-give",
167        }
168    }
169
170    /// Did the wait end **without** the memory coming back? Then the caller
171    /// must not go back to sleep: it proceeds, sheds, or fails the pack by name.
172    pub fn gave_up(self) -> bool {
173        matches!(self, PauseEnd::Deadline | PauseEnd::NothingToGive)
174    }
175}
176
177/// What one pause did — milliseconds, how it ended, and where memory stood.
178#[derive(Debug, Clone, Copy, PartialEq, Eq)]
179pub struct Pause {
180    pub ms: u64,
181    pub end: PauseEnd,
182    pub available: u64,
183    pub budget: u64,
184    /// Flushes during this pause that freed nothing at all.
185    pub empty_flushes: u32,
186}
187
188impl MemWatchdog {
189    /// From the environment: the real `/proc/meminfo` and the knobs above.
190    pub fn from_env() -> Self {
191        let meminfo = crate::arms::read_env(crate::arms::ENV_MEMINFO)
192            .map(PathBuf::from)
193            .unwrap_or_else(|| PathBuf::from("/proc/meminfo"));
194        let total = std::fs::read_to_string(&meminfo)
195            .ok()
196            .and_then(|t| parse_meminfo(&t))
197            .map(|(t, _)| t)
198            .unwrap_or(0);
199        let budget = match crate::arms::read_env(crate::arms::ENV_MEM_BUDGET_BYTES)
200            .and_then(|v| v.parse::<u64>().ok())
201        {
202            Some(b) => b,
203            None => {
204                let pct = crate::arms::read_env(crate::arms::ENV_MEM_BUDGET_PCT)
205                    .and_then(|v| v.parse::<u64>().ok())
206                    .unwrap_or(DEFAULT_BUDGET_PCT)
207                    .min(100);
208                total / 100 * pct
209            }
210        };
211        let margin = crate::arms::read_env(crate::arms::ENV_MEM_RESUME_MARGIN_BYTES)
212            .and_then(|v| v.parse::<u64>().ok())
213            .unwrap_or(RESUME_MARGIN_BYTES);
214        let deadline = crate::arms::read_env(crate::arms::ENV_MEM_PAUSE_MAX_MS)
215            .and_then(|v| v.parse::<u64>().ok())
216            .map(Duration::from_millis)
217            .unwrap_or(PAUSE_DEADLINE);
218        Self::with(&meminfo, budget, POLL).bounded(margin, deadline)
219    }
220
221    /// An explicit watchdog: `meminfo` is what is read, `budget` the floor in
222    /// bytes, `poll` the re-check cadence. What a test builds.
223    pub fn with(meminfo: &Path, budget: u64, poll: Duration) -> Self {
224        let total = std::fs::read_to_string(meminfo)
225            .ok()
226            .and_then(|t| parse_meminfo(&t))
227            .map(|(t, _)| t)
228            .unwrap_or(0);
229        Self {
230            meminfo: meminfo.to_path_buf(),
231            total,
232            budget,
233            poll,
234            pauses: AtomicU64::new(0),
235            paused_ms: AtomicU64::new(0),
236            paused_now: AtomicBool::new(false),
237            resume_margin: RESUME_MARGIN_BYTES,
238            deadline: PAUSE_DEADLINE,
239            reflush: REFLUSH_EVERY,
240            give_ups: AtomicU64::new(0),
241        }
242    }
243
244    /// The two bounds that make a pause end: the absolute resume margin and the
245    /// deadline. `Duration::ZERO` disables the deadline — the pre-2026-08-30
246    /// shape, which only a test asks for.
247    pub fn bounded(mut self, resume_margin: u64, deadline: Duration) -> Self {
248        self.resume_margin = resume_margin;
249        self.deadline = deadline;
250        self
251    }
252
253    /// How often the flush runs again during one pause. A test shortens it so
254    /// that the empty-flush arm can be asserted without 20 s of wall clock;
255    /// production leaves it at [`REFLUSH_EVERY`].
256    pub fn with_reflush(mut self, every: Duration) -> Self {
257        self.reflush = every;
258        self
259    }
260
261    /// A watchdog that never pauses (budget 0). For callers that opt out.
262    pub fn disabled() -> Self {
263        Self::with(Path::new("/nonexistent"), 0, POLL)
264    }
265
266    pub fn budget(&self) -> u64 {
267        self.budget
268    }
269
270    pub fn total(&self) -> u64 {
271        self.total
272    }
273
274    /// `MemAvailable` right now, or `None` if the file cannot be read — and
275    /// `None` never pauses: a box with no `/proc` is not a box short of memory.
276    pub fn available(&self) -> Option<u64> {
277        std::fs::read_to_string(&self.meminfo)
278            .ok()
279            .and_then(|t| parse_meminfo(&t))
280            .map(|(_, a)| a)
281    }
282
283    /// `Some((available, budget))` when the floor is breached.
284    pub fn breached(&self) -> Option<(u64, u64)> {
285        if self.budget == 0 {
286            return None;
287        }
288        let avail = self.available()?;
289        (avail < self.budget).then_some((avail, self.budget))
290    }
291
292    /// The absolute resume margin this watchdog uses — [`RESUME_MARGIN_BYTES`],
293    /// or `ZNIPPY_GIT_MEM_RESUME_MARGIN_BYTES`. Never more than the floor
294    /// itself: a margin bigger than the budget would be the old bug in a new
295    /// spelling.
296    pub fn resume_margin(&self) -> u64 {
297        self.resume_margin.min(self.budget.max(1))
298    }
299
300    /// The ceiling on one pause — [`PAUSE_DEADLINE`], or
301    /// `ZNIPPY_GIT_MEM_PAUSE_MAX_MS`. `0` means "no deadline", which is what a
302    /// test asks for when it wants the old shape on purpose.
303    pub fn pause_deadline(&self) -> Duration {
304        self.deadline
305    }
306
307    /// Is there room to resume — the floor plus the margin?
308    fn recovered(&self) -> bool {
309        match self.available() {
310            None => true,
311            Some(avail) => avail >= self.budget.saturating_add(self.resume_margin()),
312        }
313    }
314
315    /// How many times a pause began.
316    pub fn pauses(&self) -> u64 {
317        self.pauses.load(Ordering::Relaxed)
318    }
319
320    /// Milliseconds spent paused in total.
321    pub fn paused_ms(&self) -> u64 {
322        self.paused_ms.load(Ordering::Relaxed)
323    }
324
325    /// Is a pause in progress on some worker?
326    pub fn is_paused(&self) -> bool {
327        self.paused_now.load(Ordering::Relaxed)
328    }
329
330    /// Pauses that ended on the deadline or with nothing left to drop.
331    pub fn give_ups(&self) -> u64 {
332        self.give_ups.load(Ordering::Relaxed)
333    }
334
335    /// **The check.** Returns immediately when the floor holds. Otherwise runs
336    /// `flush` (the caller drops what it can, and says how much it dropped),
337    /// then waits — re-reading every `poll`, re-flushing every
338    /// [`REFLUSH_EVERY`] — until one of the four things in [`PauseEnd`]
339    /// happens.
340    ///
341    /// `flush` returns **what it freed**: rows, bytes, entries, whatever the
342    /// caller counts — the number is only ever compared with zero. Three zeros
343    /// in a row ([`GIVE_UP_AFTER_EMPTY_FLUSHES`]) with memory no better than it
344    /// started means there is nothing on this box left to give, and the wait
345    /// ends with [`PauseEnd::NothingToGive`] instead of continuing. So does
346    /// [`MemWatchdog::pause_deadline`] expiring.
347    ///
348    /// **A pause that cannot end is worse than a pack that is refused**, which
349    /// is the whole lesson of lane P2's two rig boxes: they parked for 31m44s
350    /// and 1h45m41s, emitted 190 and 627 spills that freed nothing, and never
351    /// resumed. The caller decides what to do with a give-up; this decides that
352    /// there IS one.
353    ///
354    /// `label` names the worker in the log lines (`alice/linux`).
355    pub fn pause_until_ok(
356        &self,
357        label: &str,
358        cancel: Option<&AtomicBool>,
359        flush: &mut dyn FnMut() -> Result<u64>,
360    ) -> Result<Pause> {
361        let Some((avail, budget)) = self.breached() else {
362            return Ok(Pause {
363                ms: 0,
364                end: PauseEnd::Recovered,
365                available: self.available().unwrap_or(0),
366                budget: self.budget,
367                empty_flushes: 0,
368            });
369        };
370        self.pauses.fetch_add(1, Ordering::Relaxed);
371        self.paused_now.store(true, Ordering::Relaxed);
372        let margin = self.resume_margin();
373        tracing::warn!(
374            target: "gunnar::index",
375            event = "index.paused",
376            repo = label,
377            available = avail,
378            budget = budget,
379            resume_at = budget.saturating_add(margin),
380            deadline_ms = self.deadline.as_millis() as u64,
381            "index paused: {} available < {} budget; resumes at {}, gives up after {} s",
382            fmt_bytes(avail),
383            fmt_bytes(budget),
384            fmt_bytes(budget.saturating_add(margin)),
385            self.deadline.as_secs()
386        );
387        let started = Instant::now();
388        // Drop what can be dropped BEFORE waiting: the wait is for the rest of
389        // the box, the flush is our own share of the problem. And AGAIN every
390        // `REFLUSH_EVERY` while still short — what a reader rebuilt since is
391        // ours to drop as well.
392        let mut empty_flushes = 0u32;
393        let mut flush_err: Option<anyhow::Error> = None;
394        match flush() {
395            Ok(0) => empty_flushes = 1,
396            Ok(_) => {}
397            Err(e) => flush_err = Some(e),
398        }
399        let mut last_flush = Instant::now();
400        let end = loop {
401            if cancel.is_some_and(|c| c.load(Ordering::Relaxed)) {
402                break PauseEnd::Cancelled;
403            }
404            if self.recovered() {
405                break PauseEnd::Recovered;
406            }
407            if !self.deadline.is_zero() && started.elapsed() >= self.deadline {
408                break PauseEnd::Deadline;
409            }
410            if empty_flushes >= GIVE_UP_AFTER_EMPTY_FLUSHES
411                && self.available().is_some_and(|a| a <= avail)
412            {
413                break PauseEnd::NothingToGive;
414            }
415            if last_flush.elapsed() >= self.reflush {
416                match flush() {
417                    Ok(0) => empty_flushes += 1,
418                    Ok(_) => empty_flushes = 0,
419                    Err(e) => {
420                        if flush_err.is_none() {
421                            flush_err = Some(e);
422                        }
423                    }
424                }
425                last_flush = Instant::now();
426            }
427            std::thread::sleep(self.poll);
428        };
429        let ms = started.elapsed().as_millis() as u64;
430        self.paused_ms.fetch_add(ms, Ordering::Relaxed);
431        self.paused_now.store(false, Ordering::Relaxed);
432        if end.gave_up() {
433            self.give_ups.fetch_add(1, Ordering::Relaxed);
434        }
435        let now = self.available().unwrap_or(0);
436        if end.gave_up() {
437            tracing::warn!(
438                target: "gunnar::index",
439                event = "index.gave_up",
440                repo = label,
441                available = now,
442                budget = budget,
443                paused_ms = ms,
444                empty_flushes = empty_flushes,
445                reason = end.as_str(),
446                "index gave up waiting for memory after {ms} ms ({}): {} available, {} needed. \
447                 The caller decides — proceed, shed, or refuse the pack by name — but nobody \
448                 waits here again.",
449                end.as_str(),
450                fmt_bytes(now),
451                fmt_bytes(budget.saturating_add(margin))
452            );
453        } else {
454            tracing::info!(
455                target: "gunnar::index",
456                event = "index.resumed",
457                repo = label,
458                available = now,
459                budget = budget,
460                paused_ms = ms,
461                reason = end.as_str(),
462                "index resumed after {ms} ms ({}): {} available",
463                end.as_str(),
464                fmt_bytes(now)
465            );
466        }
467        if let Some(e) = flush_err {
468            return Err(e);
469        }
470        Ok(Pause { ms, end, available: now, budget, empty_flushes })
471    }
472}
473
474fn global_slot() -> &'static RwLock<Arc<MemWatchdog>> {
475    static G: OnceLock<RwLock<Arc<MemWatchdog>>> = OnceLock::new();
476    G.get_or_init(|| RwLock::new(Arc::new(MemWatchdog::from_env())))
477}
478
479/// The process-wide watchdog, built from the environment on first use. One per
480/// process because the floor is a property of the box, not of a repository.
481/// An `Arc` so a worker mid-pack keeps the one it started with if a test swaps
482/// the global underneath it.
483pub fn global() -> Arc<MemWatchdog> {
484    global_slot().read().unwrap_or_else(|e| e.into_inner()).clone()
485}
486
487/// Replace the process-wide watchdog — what a test does to put a fake
488/// `/proc/meminfo` under a real store. Returns the previous one so the test
489/// can put it back.
490pub fn set_global(dog: Arc<MemWatchdog>) -> Arc<MemWatchdog> {
491    std::mem::replace(&mut *global_slot().write().unwrap_or_else(|e| e.into_inner()), dog)
492}
493
494/// This process's peak resident set (`VmHWM` off `/proc/self/status`) in
495/// bytes, or 0 when unreadable. The audit line carries it: on the appliance
496/// the server is PID 1, so its high-water mark is the box's.
497pub fn peak_rss_bytes() -> u64 {
498    std::fs::read_to_string("/proc/self/status")
499        .ok()
500        .and_then(|t| {
501            t.lines().find_map(|l| {
502                l.strip_prefix("VmHWM:")
503                    .and_then(|r| r.split_ascii_whitespace().next())
504                    .and_then(|v| v.parse::<u64>().ok())
505                    .map(|kib| kib * 1024)
506            })
507        })
508        .unwrap_or(0)
509}
510
511/// `MemTotal` in bytes, or 0 when unreadable. What byte budgets expressed as a
512/// share of the box are derived from.
513pub fn mem_total() -> u64 {
514    global().total()
515}
516
517#[cfg(test)]
518mod tests {
519    use super::*;
520    use std::sync::Arc;
521    use std::sync::atomic::AtomicU64;
522
523    fn fake_meminfo(dir: &Path, total_kib: u64, avail_kib: u64) -> PathBuf {
524        let p = dir.join("meminfo");
525        std::fs::write(
526            &p,
527            format!(
528                "MemTotal:       {total_kib} kB\nMemFree:        {avail_kib} kB\nMemAvailable:   {avail_kib} kB\nBuffers:            0 kB\n"
529            ),
530        )
531        .unwrap();
532        p
533    }
534
535    fn scratch(name: &str) -> PathBuf {
536        let d = std::env::temp_dir().join(format!("memguard-{}-{name}", std::process::id()));
537        let _ = std::fs::remove_dir_all(&d);
538        std::fs::create_dir_all(&d).unwrap();
539        d
540    }
541
542    #[test]
543    fn parses_the_two_fields_and_ignores_the_rest() {
544        let (t, a) = parse_meminfo(
545            "MemTotal:       16000000 kB\nMemFree:  1 kB\nMemAvailable:    3000000 kB\nSwapTotal: 0 kB\n",
546        )
547        .unwrap();
548        assert_eq!(t, 16_000_000 * 1024);
549        assert_eq!(a, 3_000_000 * 1024);
550        assert!(parse_meminfo("MemTotal: 1 kB\n").is_none(), "no MemAvailable = no answer");
551        assert!(parse_meminfo("garbage").is_none());
552    }
553
554    #[test]
555    fn above_the_floor_costs_nothing_and_flushes_nothing() {
556        let d = scratch("above");
557        let p = fake_meminfo(&d, 16_000_000, 8_000_000);
558        let w = MemWatchdog::with(&p, 3_200_000 * 1024, Duration::from_millis(1));
559        let mut flushed = 0;
560        let p = w.pause_until_ok("t", None, &mut || {
561            flushed += 1;
562            Ok(1)
563        })
564        .unwrap();
565        assert_eq!(p.ms, 0);
566        assert_eq!(p.end, PauseEnd::Recovered);
567        assert_eq!(flushed, 0);
568        assert_eq!(w.pauses(), 0);
569        assert!(w.breached().is_none());
570    }
571
572    /// The property the panic needs: under the floor the worker FLUSHES ONCE
573    /// and then WAITS until the kernel says memory is back — and it does wait,
574    /// which is asserted by a fake meminfo that is only raised by a second
575    /// thread after a measurable delay.
576    #[test]
577    fn below_the_floor_flushes_once_then_waits_for_memory_to_come_back() {
578        let d = scratch("below");
579        let p = fake_meminfo(&d, 16_000_000, 2_900_000);
580        let w = Arc::new(MemWatchdog::with(&p, 3_200_000 * 1024, Duration::from_millis(5)));
581        assert_eq!(w.breached(), Some((2_900_000 * 1024, 3_200_000 * 1024)));
582        let raise_at = Arc::new(AtomicU64::new(0));
583        let (p2, r2) = (p.clone(), raise_at.clone());
584        let t = std::thread::spawn(move || {
585            std::thread::sleep(Duration::from_millis(120));
586            r2.store(1, Ordering::SeqCst);
587            fake_meminfo(p2.parent().unwrap(), 16_000_000, 9_000_000);
588        });
589        let mut flushed = 0;
590        let paused = w
591            .pause_until_ok("t", None, &mut || {
592                flushed += 1;
593                Ok(1)
594            })
595            .unwrap();
596        let ms = paused.ms;
597        t.join().unwrap();
598        assert_eq!(paused.end, PauseEnd::Recovered);
599        assert_eq!(flushed, 1, "flush runs exactly once per pause");
600        assert_eq!(raise_at.load(Ordering::SeqCst), 1, "the wait outlasted the raise");
601        assert!(ms >= 100, "waited {ms} ms; must have waited for the raise (~120 ms)");
602        assert_eq!(w.pauses(), 1);
603        assert!(w.paused_ms() >= 100);
604        assert!(!w.is_paused());
605    }
606
607    #[test]
608    fn a_cancel_ends_the_wait_without_memory_coming_back() {
609        let d = scratch("cancel");
610        let p = fake_meminfo(&d, 16_000_000, 100);
611        let w = Arc::new(MemWatchdog::with(&p, 3_200_000 * 1024, Duration::from_millis(5)));
612        let cancel = Arc::new(AtomicBool::new(false));
613        let c2 = cancel.clone();
614        let t = std::thread::spawn(move || {
615            std::thread::sleep(Duration::from_millis(60));
616            c2.store(true, Ordering::SeqCst);
617        });
618        let paused = w.pause_until_ok("t", Some(&cancel), &mut || Ok(1)).unwrap();
619        t.join().unwrap();
620        assert_eq!(paused.end, PauseEnd::Cancelled);
621        assert!(
622            (50..2000).contains(&paused.ms),
623            "ended by the cancel, not by memory: {} ms",
624            paused.ms
625        );
626    }
627
628    /// While paused the flush runs again every `REFLUSH_EVERY`, and the pause
629    /// does not end a few bytes over the floor: the fake sits at floor + 1 %
630    /// (still paused, margin not met) until it is raised past the margin.
631    #[test]
632    fn a_long_pause_reflushes_and_resumes_only_past_the_margin() {
633        let d = scratch("reflush");
634        let p = fake_meminfo(&d, 16_000_000, 1_000_000);
635        let budget = 3_200_000 * 1024;
636        let w = Arc::new(MemWatchdog::with(&p, budget, Duration::from_millis(5)));
637        let p2 = p.clone();
638        let t = std::thread::spawn(move || {
639            std::thread::sleep(Duration::from_millis(60));
640            // Just over the floor: not enough.
641            fake_meminfo(p2.parent().unwrap(), 16_000_000, 3_232_000);
642            std::thread::sleep(Duration::from_millis(120));
643            fake_meminfo(p2.parent().unwrap(), 16_000_000, 9_000_000);
644        });
645        let mut flushes = 0;
646        let paused = w
647            .pause_until_ok("t", None, &mut || {
648                flushes += 1;
649                Ok(1)
650            })
651            .unwrap();
652        t.join().unwrap();
653        let ms = paused.ms;
654        assert_eq!(paused.end, PauseEnd::Recovered);
655        assert!(
656            ms >= 170,
657            "resumed 32 MB over the floor after {ms} ms; the margin is {}",
658            fmt_bytes(w.resume_margin())
659        );
660        assert!(flushes >= 1);
661        // REFLUSH_EVERY is 10 s in production; this pause was shorter, so
662        // exactly the one flush at the start — the cadence itself is a constant.
663        assert_eq!(flushes, 1);
664    }
665
666    /// **The livelock, as a test.** Lane P2's 16 GB rig paused on a 75 MB
667    /// shortfall — 2.2 % — and the old `budget / 8` then demanded 410 MB of
668    /// headroom to leave. The margin is absolute now, so the same shortfall
669    /// clearing to 100 MB over the floor RESUMES.
670    #[test]
671    fn a_two_percent_shortfall_does_not_demand_twelve_percent_of_headroom() {
672        let d = scratch("livelock");
673        let budget = 3_361_581_460u64; // the rig's own 16 GB floor, to the byte
674        // available = budget - 75 MB, which is where the rig paused.
675        let p = fake_meminfo(&d, 16_000_000, (budget - 75_000_000) / 1024);
676        let w = Arc::new(MemWatchdog::with(&p, budget, Duration::from_millis(5)));
677        assert!(w.breached().is_some());
678        assert_eq!(w.resume_margin(), RESUME_MARGIN_BYTES);
679        assert!(
680            budget / 8 > 400_000_000,
681            "the old margin really was 410 MB on this box: {}",
682            fmt_bytes(budget / 8)
683        );
684        let p2 = p.clone();
685        let t = std::thread::spawn(move || {
686            std::thread::sleep(Duration::from_millis(40));
687            // 100 MB over the floor: under the OLD eighth, over the new margin.
688            fake_meminfo(p2.parent().unwrap(), 16_000_000, (budget + 100_000_000) / 1024);
689        });
690        let paused = w.pause_until_ok("t", None, &mut || Ok(0)).unwrap();
691        t.join().unwrap();
692        assert_eq!(
693            paused.end,
694            PauseEnd::Recovered,
695            "100 MB over a 3.1G floor is a resume; an eighth of the floor would still be waiting"
696        );
697        assert_eq!(w.give_ups(), 0);
698    }
699
700    /// **A pause has a deadline.** Memory never comes back, the flush frees
701    /// something every time (so the nothing-to-give arm cannot fire), and the
702    /// wait still ends — which it never did before 2026-08-30.
703    #[test]
704    fn a_pause_that_never_recovers_ends_on_its_deadline() {
705        let d = scratch("deadline");
706        let p = fake_meminfo(&d, 16_000_000, 100);
707        let w = MemWatchdog::with(&p, 3_200_000 * 1024, Duration::from_millis(5))
708            .bounded(RESUME_MARGIN_BYTES, Duration::from_millis(120));
709        let started = Instant::now();
710        let paused = w.pause_until_ok("t", None, &mut || Ok(4096)).unwrap();
711        assert_eq!(paused.end, PauseEnd::Deadline);
712        assert!(paused.end.gave_up());
713        assert!(started.elapsed() >= Duration::from_millis(100));
714        assert!(paused.ms < 5_000, "the deadline is the bound, not a suggestion");
715        assert_eq!(w.give_ups(), 1);
716    }
717
718    /// **A spill that frees nothing is not a reason to keep waiting.** This is
719    /// `scanned_rows_dropped=0`, 627 times, on the 8 GB rig: the caller had
720    /// nothing droppable left, so the pause ends BY NAME — and long before the
721    /// deadline, which here is ten minutes.
722    #[test]
723    fn three_flushes_that_free_nothing_end_the_pause_by_name() {
724        let d = scratch("nothing");
725        let p = fake_meminfo(&d, 16_000_000, 100);
726        let w = MemWatchdog::with(&p, 3_200_000 * 1024, Duration::from_millis(1))
727            .bounded(RESUME_MARGIN_BYTES, Duration::from_secs(600))
728            .with_reflush(Duration::from_millis(5));
729        let mut flushes = 0u32;
730        let started = Instant::now();
731        let paused = w
732            .pause_until_ok("t", None, &mut || {
733                flushes += 1;
734                Ok(0)
735            })
736            .unwrap();
737        assert_eq!(paused.end, PauseEnd::NothingToGive);
738        assert!(paused.end.gave_up());
739        assert_eq!(paused.empty_flushes, GIVE_UP_AFTER_EMPTY_FLUSHES);
740        assert_eq!(flushes, GIVE_UP_AFTER_EMPTY_FLUSHES, "one at the top, then the re-flushes");
741        assert!(started.elapsed() < Duration::from_secs(30), "the deadline was ten minutes");
742        assert_eq!(w.give_ups(), 1);
743    }
744
745    /// ...and a flush that DOES free something keeps the pause alive, because
746    /// the caller is still making the box better. Same fake, same never-moving
747    /// memory; only the flush's answer differs, and this one ends on the clock.
748    #[test]
749    fn a_flush_that_frees_something_is_not_a_give_up_by_name() {
750        let d = scratch("something");
751        let p = fake_meminfo(&d, 16_000_000, 100);
752        let w = MemWatchdog::with(&p, 3_200_000 * 1024, Duration::from_millis(1))
753            .bounded(RESUME_MARGIN_BYTES, Duration::from_millis(200))
754            .with_reflush(Duration::from_millis(5));
755        let paused = w.pause_until_ok("t", None, &mut || Ok(1)).unwrap();
756        assert_eq!(paused.end, PauseEnd::Deadline);
757        assert_eq!(paused.empty_flushes, 0);
758    }
759
760    #[test]
761    fn a_zero_budget_never_pauses_and_an_unreadable_file_never_pauses() {
762        let w = MemWatchdog::disabled();
763        assert!(w.breached().is_none());
764        let d = scratch("unreadable");
765        let w = MemWatchdog::with(&d.join("missing"), 1 << 40, Duration::from_millis(1));
766        assert!(w.breached().is_none(), "no /proc = no opinion, never a stall");
767    }
768
769    #[test]
770    fn the_floor_is_a_share_of_memtotal_by_default() {
771        let d = scratch("pct");
772        let p = fake_meminfo(&d, 16_000_000, 8_000_000);
773        // Same arithmetic `from_env` uses, checked against the fake.
774        let total = parse_meminfo(&std::fs::read_to_string(&p).unwrap()).unwrap().0;
775        assert_eq!(total / 100 * DEFAULT_BUDGET_PCT, 3_276_800_000);
776        assert_eq!(fmt_bytes(3_276_800_000), "3.1G");
777        assert_eq!(fmt_bytes(2_900_000 * 1024), "2.8G");
778        assert_eq!(fmt_bytes(512 << 20), "512M");
779    }
780}