Skip to main content

lean_ctx/core/
memory_guard.rs

1//! Process-level RAM guardian with adaptive eviction and hard OOM protection.
2//!
3//! Monitors RSS via platform-specific APIs and triggers tiered cache eviction
4//! when memory usage exceeds configurable thresholds (default: 5% of system RAM).
5//! At critical levels, performs aggressive eviction and signals background tasks
6//! to abort. It never exits the process — recovery is always via eviction.
7
8use std::sync::Arc;
9use std::sync::atomic::{AtomicBool, AtomicU8, AtomicU64, Ordering};
10
11static PEAK_RSS: AtomicU64 = AtomicU64::new(0);
12static GUARD_RUNNING: AtomicBool = AtomicBool::new(false);
13static ABORT_REQUESTED: AtomicBool = AtomicBool::new(false);
14static CURRENT_PRESSURE: AtomicU8 = AtomicU8::new(0);
15
16/// Current process RSS in bytes, or `None` if unavailable.
17pub fn get_rss_bytes() -> Option<u64> {
18    #[cfg(target_os = "linux")]
19    {
20        linux_rss()
21    }
22    #[cfg(target_os = "macos")]
23    {
24        macos_rss()
25    }
26    #[cfg(not(any(target_os = "linux", target_os = "macos")))]
27    {
28        None
29    }
30}
31
32/// RSS of an arbitrary process by PID, or `None` if unavailable/dead.
33pub fn get_rss_bytes_for_pid(pid: u32) -> Option<u64> {
34    #[cfg(target_os = "linux")]
35    {
36        linux_rss_for_pid(pid)
37    }
38    #[cfg(target_os = "macos")]
39    {
40        macos_rss_for_pid(pid)
41    }
42    #[cfg(not(any(target_os = "linux", target_os = "macos")))]
43    {
44        let _ = pid;
45        None
46    }
47}
48
49/// Total physical RAM in bytes, or `None` if unavailable.
50pub fn get_system_ram_bytes() -> Option<u64> {
51    #[cfg(target_os = "linux")]
52    {
53        linux_memtotal()
54    }
55    #[cfg(target_os = "macos")]
56    {
57        macos_memsize()
58    }
59    #[cfg(not(any(target_os = "linux", target_os = "macos")))]
60    {
61        None
62    }
63}
64
65/// Returns the RSS limit in bytes based on `max_ram_percent` config.
66pub fn rss_limit_bytes() -> Option<u64> {
67    let sys_ram = get_system_ram_bytes()?;
68    let cfg = super::config::Config::load();
69    let pct = super::config::MemoryGuardConfig::effective(&cfg).max_ram_percent;
70    Some(sys_ram / 100 * u64::from(pct))
71}
72
73/// Computes a memory-bounded work batch from current RSS and the guardian's
74/// hard threshold (1.5× the configured base limit). Falls back to `max_files`
75/// when RSS is unavailable. The lower bound keeps progress deterministic while
76/// allowing callers to reduce in-flight work substantially under low headroom.
77pub(crate) fn adaptive_batch_size(
78    min_files: usize,
79    max_files: usize,
80    estimated_bytes_per_file: u64,
81) -> usize {
82    let headroom = match (rss_limit_bytes(), get_rss_bytes()) {
83        (Some(limit), Some(rss)) => hard_headroom_bytes(limit, rss),
84        _ => return max_files.max(1),
85    };
86    batch_size_for_headroom(headroom, min_files, max_files, estimated_bytes_per_file)
87}
88
89fn hard_headroom_bytes(base_limit: u64, rss: u64) -> u64 {
90    base_limit
91        .saturating_mul(3)
92        .saturating_div(2)
93        .saturating_sub(rss)
94}
95
96fn batch_size_for_headroom(
97    headroom_bytes: u64,
98    min_files: usize,
99    max_files: usize,
100    estimated_bytes_per_file: u64,
101) -> usize {
102    let min_files = min_files.max(1);
103    let max_files = max_files.max(min_files);
104    let estimate = estimated_bytes_per_file.max(1);
105    let by_headroom = (headroom_bytes / estimate).min(max_files as u64) as usize;
106    by_headroom.clamp(min_files, max_files)
107}
108
109#[cfg(test)]
110mod adaptive_batch_tests {
111    use super::{batch_size_for_headroom, hard_headroom_bytes};
112
113    #[test]
114    fn hard_headroom_uses_guardian_hard_threshold() {
115        assert_eq!(hard_headroom_bytes(1_000, 1_100), 400);
116        assert_eq!(hard_headroom_bytes(1_000, 1_500), 0);
117        assert_eq!(hard_headroom_bytes(1_000, 2_000), 0);
118    }
119
120    #[test]
121    fn batch_size_tracks_headroom_and_bounds() {
122        assert_eq!(batch_size_for_headroom(1_000, 1, 500, 100), 10);
123        assert_eq!(batch_size_for_headroom(0, 1, 500, 100), 1);
124        assert_eq!(batch_size_for_headroom(100_000, 1, 500, 100), 500);
125    }
126
127    #[test]
128    fn batch_size_sanitizes_zero_bounds_and_estimate() {
129        assert_eq!(batch_size_for_headroom(0, 0, 0, 0), 1);
130    }
131}
132
133/// Recorded peak RSS since process start.
134pub fn peak_rss_bytes() -> u64 {
135    PEAK_RSS.load(Ordering::Relaxed)
136}
137
138/// Snapshot of current memory state for diagnostics.
139#[derive(Debug, Clone, serde::Serialize)]
140pub struct MemorySnapshot {
141    pub rss_bytes: u64,
142    pub peak_rss_bytes: u64,
143    pub system_ram_bytes: u64,
144    pub rss_limit_bytes: u64,
145    pub rss_percent: f64,
146    pub pressure_level: PressureLevel,
147}
148
149#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, serde::Serialize)]
150#[serde(rename_all = "lowercase")]
151#[repr(u8)]
152pub enum PressureLevel {
153    Normal = 0,
154    Soft = 1,
155    Medium = 2,
156    Hard = 3,
157    Critical = 4,
158}
159
160impl PressureLevel {
161    fn from_u8(v: u8) -> Self {
162        match v {
163            1 => Self::Soft,
164            2 => Self::Medium,
165            3 => Self::Hard,
166            4 => Self::Critical,
167            _ => Self::Normal,
168        }
169    }
170}
171
172impl MemorySnapshot {
173    /// Capture memory snapshot of the **current** process.
174    pub fn capture() -> Option<Self> {
175        Self::capture_impl(get_rss_bytes()?)
176    }
177
178    /// Capture memory snapshot for the **daemon** process (by PID).
179    /// Falls back to the current process if the PID is dead or unreadable.
180    pub fn capture_for_pid(pid: u32) -> Option<Self> {
181        let rss = get_rss_bytes_for_pid(pid).or_else(get_rss_bytes)?;
182        Self::capture_impl(rss)
183    }
184
185    fn capture_impl(rss: u64) -> Option<Self> {
186        let sys = get_system_ram_bytes()?;
187        let limit = rss_limit_bytes()?;
188        let pct = if sys > 0 {
189            (rss as f64 / sys as f64) * 100.0
190        } else {
191            0.0
192        };
193
194        PEAK_RSS.fetch_max(rss, Ordering::Relaxed);
195
196        let cfg = super::config::Config::load();
197        let guard_cfg = super::config::MemoryGuardConfig::effective(&cfg);
198        let base = f64::from(guard_cfg.max_ram_percent);
199
200        // #790: tightened multipliers so ABORT fires earlier:
201        // - Critical: 2× (was 3×) — e.g. 10% config on 64 GB → 12.8 GB (was 19.2 GB)
202        // - Hard:     1.5× (was 2×) — 9.6 GB (was 12.8 GB)
203        // - Medium:   1.2× (was 1.4×)
204        // Users expect max_ram_percent to be a meaningful cap, not a 3× suggestion.
205        let level = if pct > base * 2.0 {
206            PressureLevel::Critical
207        } else if pct > base * 1.5 {
208            PressureLevel::Hard
209        } else if pct > base * 1.2 {
210            PressureLevel::Medium
211        } else if pct > base {
212            PressureLevel::Soft
213        } else {
214            PressureLevel::Normal
215        };
216
217        Some(Self {
218            rss_bytes: rss,
219            peak_rss_bytes: PEAK_RSS.load(Ordering::Relaxed),
220            system_ram_bytes: sys,
221            rss_limit_bytes: limit,
222            rss_percent: pct,
223            pressure_level: level,
224        })
225    }
226}
227
228/// Force-purge all jemalloc arenas to return memory to the OS.
229/// Uses `MALLCTL_ARENAS_ALL` (value 4096) which is the jemalloc sentinel
230/// for "all arenas". Logs errors instead of silently swallowing them.
231pub fn jemalloc_purge() {
232    #[cfg(all(feature = "jemalloc", not(windows)))]
233    {
234        use tikv_jemalloc_ctl::raw;
235        let purge_mib = b"arena.4096.purge\0";
236        // SAFETY: `purge_mib` is a static, NUL-terminated jemalloc MIB name and
237        // the value type (`u64`) matches the `arena.<i>.purge` ctl; `raw::write`
238        // validates the name and surfaces errors via `Result`.
239        unsafe {
240            if let Err(e) = raw::write(purge_mib, 0u64) {
241                tracing::debug!("[memory_guard] jemalloc purge failed: {e}");
242            }
243        }
244    }
245}
246
247/// Returns `true` if the guardian has requested background tasks to abort.
248pub fn abort_requested() -> bool {
249    ABORT_REQUESTED.load(Ordering::Relaxed)
250}
251
252/// Quick, non-allocating memory pressure check for hot loops (scanners, indexers).
253/// Reads the cached atomic flag set by the guardian thread — O(1), no syscalls.
254pub fn is_under_pressure() -> bool {
255    current_pressure() >= PressureLevel::Soft
256}
257
258/// Returns the current pressure level as last observed by the guardian thread.
259pub fn current_pressure() -> PressureLevel {
260    PressureLevel::from_u8(CURRENT_PRESSURE.load(Ordering::Relaxed))
261}
262
263#[inline]
264const fn pressure_requests_abort(level: PressureLevel) -> bool {
265    level as u8 >= PressureLevel::Hard as u8
266}
267
268#[inline]
269fn publish_pressure(level: PressureLevel) {
270    CURRENT_PRESSURE.store(level as u8, Ordering::Relaxed);
271    ABORT_REQUESTED.store(pressure_requests_abort(level), Ordering::SeqCst);
272}
273
274/// Start the background memory guardian task (idempotent).
275/// Polls every 3s (normal), 1s (under pressure), or up to 15s once RSS has been
276/// stably calm (idle backoff). At Critical level, performs aggressive eviction
277/// and signals background tasks to abort — never exits the process.
278pub fn start_guard(eviction_callback: Arc<dyn Fn(PressureLevel) + Send + Sync>) {
279    // The guardian is a long-lived background monitor for the running
280    // server/daemon. Under `cargo test` a single OS process executes the entire
281    // suite, so its RSS routinely exceeds the per-operation pressure threshold
282    // (default 5% of system RAM). A test that constructs a server (e.g. the
283    // `http_server` tests via `new_shared_with_context`) would start this thread,
284    // which then flips the process-global `CURRENT_PRESSURE` / `ABORT_REQUESTED`
285    // flags. Unrelated later tests in the same binary read those flags and skip
286    // work — notably `graph_index::build_edges_with_cache` aborts edge-building
287    // under pressure, leaving indexed files with no edges. That manifested as an
288    // intermittent, macOS-only flake ("No files depend on Base.gd"). The guardian
289    // has no purpose inside the test harness, so never start it there. Production
290    // and the daemon compile without `cfg!(test)` and are unaffected.
291    if cfg!(test) {
292        return;
293    }
294    if GUARD_RUNNING.swap(true, Ordering::SeqCst) {
295        return;
296    }
297    std::thread::Builder::new()
298        .name("memory-guard".into())
299        .spawn(move || {
300            // Idle backoff: once RSS has stayed below the Soft threshold for
301            // CALM_TICKS_BEFORE_BACKOFF consecutive samples, stretch the poll
302            // interval to IDLE_POLL_SECS. An idle server allocates nothing, so 3s
303            // RSS sampling is just wasted wakeups; any pressure resets the cadence
304            // instantly (below), leaving OOM reaction time during real work
305            // unchanged (#453 idle hygiene).
306            const CALM_TICKS_BEFORE_BACKOFF: u64 = 5;
307            const IDLE_POLL_SECS: u64 = 15;
308            let mut poll_secs = 3u64;
309            let mut calm_ticks = 0u64;
310
311            // #790: immediate first sample — close the 3s blind window so
312            // builders that start right after start_guard() see real pressure.
313            if let Some(snap) = MemorySnapshot::capture() {
314                publish_pressure(snap.pressure_level);
315                if snap.pressure_level >= PressureLevel::Soft {
316                    eviction_callback(snap.pressure_level);
317                }
318            }
319
320            loop {
321                std::thread::sleep(std::time::Duration::from_secs(poll_secs));
322                let Some(snap) = MemorySnapshot::capture() else {
323                    continue;
324                };
325
326                publish_pressure(snap.pressure_level);
327
328                if snap.pressure_level == PressureLevel::Critical {
329                    tracing::error!(
330                        "[memory_guard] CRITICAL: RSS={:.0}MB ({:.1}% of {:.0}GB) — \
331                         aggressive eviction to prevent OS OOM kill",
332                        snap.rss_bytes as f64 / 1_048_576.0,
333                        snap.rss_percent,
334                        snap.system_ram_bytes as f64 / 1_073_741_824.0,
335                    );
336                    (eviction_callback)(PressureLevel::Critical);
337                    jemalloc_purge();
338
339                    for attempt in 1..=3 {
340                        std::thread::sleep(std::time::Duration::from_secs(2));
341                        (eviction_callback)(PressureLevel::Critical);
342                        jemalloc_purge();
343                        if let Some(recheck) = MemorySnapshot::capture() {
344                            if recheck.pressure_level < PressureLevel::Hard {
345                                tracing::info!(
346                                    "[memory_guard] eviction attempt {attempt} succeeded — \
347                                     RSS={:.0}MB, pressure={:?}",
348                                    recheck.rss_bytes as f64 / 1_048_576.0,
349                                    recheck.pressure_level,
350                                );
351                                break;
352                            }
353                            tracing::error!(
354                                "[memory_guard] eviction attempt {attempt}/3 — still {:?} \
355                                 (RSS={:.0}MB)",
356                                recheck.pressure_level,
357                                recheck.rss_bytes as f64 / 1_048_576.0,
358                            );
359                        }
360                    }
361                }
362
363                if snap.pressure_level >= PressureLevel::Soft {
364                    poll_secs = 1;
365                    calm_ticks = 0;
366                    tracing::warn!(
367                        "[memory_guard] pressure={:?} RSS={:.0}MB limit={:.0}MB ({:.1}% of {:.0}GB)",
368                        snap.pressure_level,
369                        snap.rss_bytes as f64 / 1_048_576.0,
370                        snap.rss_limit_bytes as f64 / 1_048_576.0,
371                        snap.rss_percent,
372                        snap.system_ram_bytes as f64 / 1_073_741_824.0,
373                    );
374                    (eviction_callback)(snap.pressure_level);
375
376                    if snap.pressure_level >= PressureLevel::Hard {
377                        jemalloc_purge();
378                    }
379                } else {
380                    calm_ticks = calm_ticks.saturating_add(1);
381                    poll_secs = if calm_ticks >= CALM_TICKS_BEFORE_BACKOFF {
382                        IDLE_POLL_SECS
383                    } else {
384                        3
385                    };
386                }
387            }
388        })
389        .ok();
390}
391
392/// Force immediate purge of all caches and jemalloc arenas.
393pub fn force_purge() {
394    jemalloc_purge();
395    tracing::info!("[memory_guard] force_purge completed");
396}
397
398// --- Platform-specific implementations ---
399
400#[cfg(target_os = "linux")]
401fn linux_rss() -> Option<u64> {
402    linux_rss_for_pid(std::process::id())
403}
404
405#[cfg(target_os = "linux")]
406fn linux_rss_for_pid(pid: u32) -> Option<u64> {
407    let path = format!("/proc/{pid}/status");
408    let status = std::fs::read_to_string(path).ok()?;
409    for line in status.lines() {
410        if let Some(val) = line.strip_prefix("VmRSS:") {
411            let kb: u64 = val.trim().trim_end_matches(" kB").trim().parse().ok()?;
412            return Some(kb * 1024);
413        }
414    }
415    None
416}
417
418#[cfg(target_os = "linux")]
419fn linux_memtotal() -> Option<u64> {
420    let info = std::fs::read_to_string("/proc/meminfo").ok()?;
421    for line in info.lines() {
422        if let Some(val) = line.strip_prefix("MemTotal:") {
423            let kb: u64 = val.trim().trim_end_matches(" kB").trim().parse().ok()?;
424            return Some(kb * 1024);
425        }
426    }
427    None
428}
429
430#[cfg(target_os = "macos")]
431#[allow(deprecated, clippy::borrow_as_ptr, clippy::ptr_as_ptr)]
432fn macos_rss() -> Option<u64> {
433    use std::mem;
434    // SAFETY: `mach_task_basic_info_data_t` is a plain C struct for which an
435    // all-zero bit pattern is a valid initial value.
436    let mut info: libc::mach_task_basic_info_data_t = unsafe { mem::zeroed() };
437    let mut count = (mem::size_of::<libc::mach_task_basic_info_data_t>()
438        / mem::size_of::<libc::natural_t>()) as libc::mach_msg_type_number_t;
439    // SAFETY: `mach_task_self()` returns the current task port; `info` and
440    // `count` are live stack locals passed as out-pointers, sized to match the
441    // requested `MACH_TASK_BASIC_INFO` flavour.
442    let kr = unsafe {
443        libc::task_info(
444            libc::mach_task_self(),
445            libc::MACH_TASK_BASIC_INFO,
446            std::ptr::from_mut(&mut info).cast::<i32>(),
447            std::ptr::from_mut(&mut count),
448        )
449    };
450    if kr == libc::KERN_SUCCESS {
451        Some(info.resident_size)
452    } else {
453        None
454    }
455}
456
457#[cfg(target_os = "macos")]
458fn macos_rss_for_pid(pid: u32) -> Option<u64> {
459    // Use `ps -o rss= -p <pid>` as a portable fallback.
460    // `task_for_pid` requires root/entitlements, `proc_pid_rusage` is private API.
461    let output = std::process::Command::new("ps")
462        .args(["-o", "rss=", "-p", &pid.to_string()])
463        .output()
464        .ok()?;
465    if !output.status.success() {
466        return None;
467    }
468    let text = String::from_utf8_lossy(&output.stdout);
469    let kb: u64 = text.trim().parse().ok()?;
470    Some(kb * 1024)
471}
472
473#[cfg(target_os = "macos")]
474#[allow(clippy::borrow_as_ptr, clippy::ptr_as_ptr)]
475fn macos_memsize() -> Option<u64> {
476    use std::mem;
477    let mut memsize: u64 = 0;
478    let mut len = mem::size_of::<u64>();
479    let name = b"hw.memsize\0";
480    // SAFETY: `name` is a static, NUL-terminated sysctl name; `memsize` and
481    // `len` are live stack out-pointers whose sizes match the queried value.
482    let ret = unsafe {
483        libc::sysctlbyname(
484            name.as_ptr().cast(),
485            std::ptr::from_mut(&mut memsize).cast::<libc::c_void>(),
486            std::ptr::from_mut(&mut len),
487            std::ptr::null_mut(),
488            0,
489        )
490    };
491    if ret == 0 { Some(memsize) } else { None }
492}
493
494#[cfg(test)]
495mod tests {
496    use super::*;
497
498    #[test]
499    fn rss_returns_some_on_supported_os() {
500        if cfg!(any(target_os = "linux", target_os = "macos")) {
501            let rss = get_rss_bytes();
502            assert!(rss.is_some(), "RSS should be readable");
503            assert!(rss.unwrap() > 0, "RSS should be > 0");
504        }
505    }
506
507    #[test]
508    fn system_ram_returns_some_on_supported_os() {
509        if cfg!(any(target_os = "linux", target_os = "macos")) {
510            let ram = get_system_ram_bytes();
511            assert!(ram.is_some(), "System RAM should be readable");
512            assert!(ram.unwrap() > 1_000_000, "System RAM should be > 1MB");
513        }
514    }
515
516    #[test]
517    fn snapshot_captures_correctly() {
518        if cfg!(any(target_os = "linux", target_os = "macos")) {
519            let snap = MemorySnapshot::capture();
520            assert!(snap.is_some());
521            let s = snap.unwrap();
522            assert!(s.rss_bytes > 0);
523            assert!(s.system_ram_bytes > s.rss_bytes);
524            assert!(s.rss_percent > 0.0 && s.rss_percent < 100.0);
525        }
526    }
527
528    #[test]
529    fn peak_rss_tracks_maximum() {
530        PEAK_RSS.store(0, Ordering::Relaxed);
531        PEAK_RSS.fetch_max(100, Ordering::Relaxed);
532        PEAK_RSS.fetch_max(50, Ordering::Relaxed);
533        assert_eq!(PEAK_RSS.load(Ordering::Relaxed), 100);
534    }
535
536    #[test]
537    fn pressure_level_roundtrip() {
538        for level in [
539            PressureLevel::Normal,
540            PressureLevel::Soft,
541            PressureLevel::Medium,
542            PressureLevel::Hard,
543            PressureLevel::Critical,
544        ] {
545            assert_eq!(PressureLevel::from_u8(level as u8), level);
546        }
547    }
548
549    #[test]
550    fn hard_pressure_requests_abort_immediately() {
551        assert!(!pressure_requests_abort(PressureLevel::Normal));
552        assert!(!pressure_requests_abort(PressureLevel::Soft));
553        assert!(!pressure_requests_abort(PressureLevel::Medium));
554        assert!(pressure_requests_abort(PressureLevel::Hard));
555        assert!(pressure_requests_abort(PressureLevel::Critical));
556    }
557
558    #[test]
559    fn atomic_pressure_defaults_to_normal() {
560        assert_eq!(current_pressure(), PressureLevel::Normal);
561    }
562
563    #[test]
564    fn start_guard_is_noop_under_test() {
565        // Regression guard: the background guardian must never run inside the
566        // test harness. If it did, its 3s poll would observe the suite's large
567        // RSS, flip the global pressure/abort flags, and silently make unrelated
568        // tests (e.g. graph edge-building) skip work — an order/timing-dependent
569        // flake. `start_guard` must be a no-op under `cfg!(test)`.
570        let fired = Arc::new(AtomicBool::new(false));
571        let fired_cb = fired.clone();
572        start_guard(Arc::new(move |_| fired_cb.store(true, Ordering::SeqCst)));
573
574        assert!(
575            !GUARD_RUNNING.load(Ordering::Relaxed),
576            "guardian thread must not start under cfg!(test)"
577        );
578        assert_eq!(current_pressure(), PressureLevel::Normal);
579        assert!(!abort_requested());
580        assert!(
581            !fired.load(Ordering::Relaxed),
582            "eviction callback must never fire in tests"
583        );
584    }
585
586    #[test]
587    fn rss_for_own_pid_matches_self() {
588        if cfg!(any(target_os = "linux", target_os = "macos")) {
589            let self_rss = get_rss_bytes().unwrap();
590            let pid_rss = get_rss_bytes_for_pid(std::process::id()).unwrap();
591            let ratio = self_rss as f64 / pid_rss as f64;
592            assert!(
593                (0.5..2.0).contains(&ratio),
594                "self RSS ({self_rss}) and pid-based RSS ({pid_rss}) should be within 2x"
595            );
596        }
597    }
598
599    #[test]
600    fn rss_for_dead_pid_returns_none() {
601        let dead_pid = 999_999_999u32;
602        assert!(get_rss_bytes_for_pid(dead_pid).is_none());
603    }
604
605    #[test]
606    fn capture_for_pid_falls_back_on_dead_pid() {
607        if cfg!(any(target_os = "linux", target_os = "macos")) {
608            let snap = MemorySnapshot::capture_for_pid(999_999_999);
609            assert!(snap.is_some(), "should fall back to self RSS");
610        }
611    }
612}