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::atomic::{AtomicBool, AtomicU64, AtomicU8, Ordering};
9use std::sync::Arc;
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/// Recorded peak RSS since process start.
74pub fn peak_rss_bytes() -> u64 {
75    PEAK_RSS.load(Ordering::Relaxed)
76}
77
78/// Snapshot of current memory state for diagnostics.
79#[derive(Debug, Clone, serde::Serialize)]
80pub struct MemorySnapshot {
81    pub rss_bytes: u64,
82    pub peak_rss_bytes: u64,
83    pub system_ram_bytes: u64,
84    pub rss_limit_bytes: u64,
85    pub rss_percent: f64,
86    pub pressure_level: PressureLevel,
87}
88
89#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, serde::Serialize)]
90#[serde(rename_all = "lowercase")]
91#[repr(u8)]
92pub enum PressureLevel {
93    Normal = 0,
94    Soft = 1,
95    Medium = 2,
96    Hard = 3,
97    Critical = 4,
98}
99
100impl PressureLevel {
101    fn from_u8(v: u8) -> Self {
102        match v {
103            1 => Self::Soft,
104            2 => Self::Medium,
105            3 => Self::Hard,
106            4 => Self::Critical,
107            _ => Self::Normal,
108        }
109    }
110}
111
112impl MemorySnapshot {
113    /// Capture memory snapshot of the **current** process.
114    pub fn capture() -> Option<Self> {
115        Self::capture_impl(get_rss_bytes()?)
116    }
117
118    /// Capture memory snapshot for the **daemon** process (by PID).
119    /// Falls back to the current process if the PID is dead or unreadable.
120    pub fn capture_for_pid(pid: u32) -> Option<Self> {
121        let rss = get_rss_bytes_for_pid(pid).or_else(get_rss_bytes)?;
122        Self::capture_impl(rss)
123    }
124
125    fn capture_impl(rss: u64) -> Option<Self> {
126        let sys = get_system_ram_bytes()?;
127        let limit = rss_limit_bytes()?;
128        let pct = if sys > 0 {
129            (rss as f64 / sys as f64) * 100.0
130        } else {
131            0.0
132        };
133
134        PEAK_RSS.fetch_max(rss, Ordering::Relaxed);
135
136        let cfg = super::config::Config::load();
137        let guard_cfg = super::config::MemoryGuardConfig::effective(&cfg);
138        let base = f64::from(guard_cfg.max_ram_percent);
139
140        let level = if pct > base * 3.0 {
141            PressureLevel::Critical
142        } else if pct > base * 2.0 {
143            PressureLevel::Hard
144        } else if pct > base * 1.4 {
145            PressureLevel::Medium
146        } else if pct > base {
147            PressureLevel::Soft
148        } else {
149            PressureLevel::Normal
150        };
151
152        Some(Self {
153            rss_bytes: rss,
154            peak_rss_bytes: PEAK_RSS.load(Ordering::Relaxed),
155            system_ram_bytes: sys,
156            rss_limit_bytes: limit,
157            rss_percent: pct,
158            pressure_level: level,
159        })
160    }
161}
162
163/// Force-purge all jemalloc arenas to return memory to the OS.
164/// Uses `MALLCTL_ARENAS_ALL` (value 4096) which is the jemalloc sentinel
165/// for "all arenas". Logs errors instead of silently swallowing them.
166pub fn jemalloc_purge() {
167    #[cfg(all(feature = "jemalloc", not(windows)))]
168    {
169        use tikv_jemalloc_ctl::raw;
170        let purge_mib = b"arena.4096.purge\0";
171        // SAFETY: `purge_mib` is a static, NUL-terminated jemalloc MIB name and
172        // the value type (`u64`) matches the `arena.<i>.purge` ctl; `raw::write`
173        // validates the name and surfaces errors via `Result`.
174        unsafe {
175            if let Err(e) = raw::write(purge_mib, 0u64) {
176                tracing::debug!("[memory_guard] jemalloc purge failed: {e}");
177            }
178        }
179    }
180}
181
182/// Returns `true` if the guardian has requested background tasks to abort.
183pub fn abort_requested() -> bool {
184    ABORT_REQUESTED.load(Ordering::Relaxed)
185}
186
187/// Quick, non-allocating memory pressure check for hot loops (scanners, indexers).
188/// Reads the cached atomic flag set by the guardian thread — O(1), no syscalls.
189pub fn is_under_pressure() -> bool {
190    current_pressure() >= PressureLevel::Soft
191}
192
193/// Returns the current pressure level as last observed by the guardian thread.
194pub fn current_pressure() -> PressureLevel {
195    PressureLevel::from_u8(CURRENT_PRESSURE.load(Ordering::Relaxed))
196}
197
198/// Start the background memory guardian task (idempotent).
199/// Polls every 3s (normal) or 1s (under pressure). At Critical level, performs
200/// aggressive eviction and signals background tasks to abort — never exits the process.
201pub fn start_guard(eviction_callback: Arc<dyn Fn(PressureLevel) + Send + Sync>) {
202    // The guardian is a long-lived background monitor for the running
203    // server/daemon. Under `cargo test` a single OS process executes the entire
204    // suite, so its RSS routinely exceeds the per-operation pressure threshold
205    // (default 5% of system RAM). A test that constructs a server (e.g. the
206    // `http_server` tests via `new_shared_with_context`) would start this thread,
207    // which then flips the process-global `CURRENT_PRESSURE` / `ABORT_REQUESTED`
208    // flags. Unrelated later tests in the same binary read those flags and skip
209    // work — notably `graph_index::build_edges_with_cache` aborts edge-building
210    // under pressure, leaving indexed files with no edges. That manifested as an
211    // intermittent, macOS-only flake ("No files depend on Base.gd"). The guardian
212    // has no purpose inside the test harness, so never start it there. Production
213    // and the daemon compile without `cfg!(test)` and are unaffected.
214    if cfg!(test) {
215        return;
216    }
217    if GUARD_RUNNING.swap(true, Ordering::SeqCst) {
218        return;
219    }
220    std::thread::Builder::new()
221        .name("memory-guard".into())
222        .spawn(move || {
223            let mut poll_secs = 3u64;
224            loop {
225                std::thread::sleep(std::time::Duration::from_secs(poll_secs));
226                let Some(snap) = MemorySnapshot::capture() else {
227                    continue;
228                };
229
230                CURRENT_PRESSURE.store(snap.pressure_level as u8, Ordering::Relaxed);
231
232                if snap.pressure_level == PressureLevel::Critical {
233                    tracing::error!(
234                        "[memory_guard] CRITICAL: RSS={:.0}MB ({:.1}% of {:.0}GB) — \
235                         aggressive eviction to prevent OS OOM kill",
236                        snap.rss_bytes as f64 / 1_048_576.0,
237                        snap.rss_percent,
238                        snap.system_ram_bytes as f64 / 1_073_741_824.0,
239                    );
240                    ABORT_REQUESTED.store(true, Ordering::SeqCst);
241                    (eviction_callback)(PressureLevel::Critical);
242                    jemalloc_purge();
243
244                    for attempt in 1..=3 {
245                        std::thread::sleep(std::time::Duration::from_secs(2));
246                        (eviction_callback)(PressureLevel::Critical);
247                        jemalloc_purge();
248                        if let Some(recheck) = MemorySnapshot::capture() {
249                            if recheck.pressure_level < PressureLevel::Hard {
250                                tracing::info!(
251                                    "[memory_guard] eviction attempt {attempt} succeeded — \
252                                     RSS={:.0}MB, pressure={:?}",
253                                    recheck.rss_bytes as f64 / 1_048_576.0,
254                                    recheck.pressure_level,
255                                );
256                                break;
257                            }
258                            tracing::error!(
259                                "[memory_guard] eviction attempt {attempt}/3 — still {:?} \
260                                 (RSS={:.0}MB)",
261                                recheck.pressure_level,
262                                recheck.rss_bytes as f64 / 1_048_576.0,
263                            );
264                        }
265                    }
266                }
267
268                if snap.pressure_level >= PressureLevel::Soft {
269                    poll_secs = 1;
270                    ABORT_REQUESTED
271                        .store(snap.pressure_level >= PressureLevel::Hard, Ordering::SeqCst);
272                    tracing::warn!(
273                        "[memory_guard] pressure={:?} RSS={:.0}MB limit={:.0}MB ({:.1}% of {:.0}GB)",
274                        snap.pressure_level,
275                        snap.rss_bytes as f64 / 1_048_576.0,
276                        snap.rss_limit_bytes as f64 / 1_048_576.0,
277                        snap.rss_percent,
278                        snap.system_ram_bytes as f64 / 1_073_741_824.0,
279                    );
280                    (eviction_callback)(snap.pressure_level);
281
282                    if snap.pressure_level >= PressureLevel::Hard {
283                        jemalloc_purge();
284                    }
285                } else {
286                    poll_secs = 3;
287                    if ABORT_REQUESTED.load(Ordering::Relaxed) {
288                        ABORT_REQUESTED.store(false, Ordering::SeqCst);
289                        tracing::info!("[memory_guard] pressure normalized, clearing abort flag");
290                    }
291                }
292            }
293        })
294        .ok();
295}
296
297/// Force immediate purge of all caches and jemalloc arenas.
298pub fn force_purge() {
299    jemalloc_purge();
300    tracing::info!("[memory_guard] force_purge completed");
301}
302
303// --- Platform-specific implementations ---
304
305#[cfg(target_os = "linux")]
306fn linux_rss() -> Option<u64> {
307    linux_rss_for_pid(std::process::id())
308}
309
310#[cfg(target_os = "linux")]
311fn linux_rss_for_pid(pid: u32) -> Option<u64> {
312    let path = format!("/proc/{pid}/status");
313    let status = std::fs::read_to_string(path).ok()?;
314    for line in status.lines() {
315        if let Some(val) = line.strip_prefix("VmRSS:") {
316            let kb: u64 = val.trim().trim_end_matches(" kB").trim().parse().ok()?;
317            return Some(kb * 1024);
318        }
319    }
320    None
321}
322
323#[cfg(target_os = "linux")]
324fn linux_memtotal() -> Option<u64> {
325    let info = std::fs::read_to_string("/proc/meminfo").ok()?;
326    for line in info.lines() {
327        if let Some(val) = line.strip_prefix("MemTotal:") {
328            let kb: u64 = val.trim().trim_end_matches(" kB").trim().parse().ok()?;
329            return Some(kb * 1024);
330        }
331    }
332    None
333}
334
335#[cfg(target_os = "macos")]
336#[allow(deprecated, clippy::borrow_as_ptr, clippy::ptr_as_ptr)]
337fn macos_rss() -> Option<u64> {
338    use std::mem;
339    // SAFETY: `mach_task_basic_info_data_t` is a plain C struct for which an
340    // all-zero bit pattern is a valid initial value.
341    let mut info: libc::mach_task_basic_info_data_t = unsafe { mem::zeroed() };
342    let mut count = (mem::size_of::<libc::mach_task_basic_info_data_t>()
343        / mem::size_of::<libc::natural_t>()) as libc::mach_msg_type_number_t;
344    // SAFETY: `mach_task_self()` returns the current task port; `info` and
345    // `count` are live stack locals passed as out-pointers, sized to match the
346    // requested `MACH_TASK_BASIC_INFO` flavour.
347    let kr = unsafe {
348        libc::task_info(
349            libc::mach_task_self(),
350            libc::MACH_TASK_BASIC_INFO,
351            std::ptr::from_mut(&mut info).cast::<i32>(),
352            std::ptr::from_mut(&mut count),
353        )
354    };
355    if kr == libc::KERN_SUCCESS {
356        Some(info.resident_size)
357    } else {
358        None
359    }
360}
361
362#[cfg(target_os = "macos")]
363fn macos_rss_for_pid(pid: u32) -> Option<u64> {
364    // Use `ps -o rss= -p <pid>` as a portable fallback.
365    // `task_for_pid` requires root/entitlements, `proc_pid_rusage` is private API.
366    let output = std::process::Command::new("ps")
367        .args(["-o", "rss=", "-p", &pid.to_string()])
368        .output()
369        .ok()?;
370    if !output.status.success() {
371        return None;
372    }
373    let text = String::from_utf8_lossy(&output.stdout);
374    let kb: u64 = text.trim().parse().ok()?;
375    Some(kb * 1024)
376}
377
378#[cfg(target_os = "macos")]
379#[allow(clippy::borrow_as_ptr, clippy::ptr_as_ptr)]
380fn macos_memsize() -> Option<u64> {
381    use std::mem;
382    let mut memsize: u64 = 0;
383    let mut len = mem::size_of::<u64>();
384    let name = b"hw.memsize\0";
385    // SAFETY: `name` is a static, NUL-terminated sysctl name; `memsize` and
386    // `len` are live stack out-pointers whose sizes match the queried value.
387    let ret = unsafe {
388        libc::sysctlbyname(
389            name.as_ptr().cast(),
390            std::ptr::from_mut(&mut memsize).cast::<libc::c_void>(),
391            std::ptr::from_mut(&mut len),
392            std::ptr::null_mut(),
393            0,
394        )
395    };
396    if ret == 0 {
397        Some(memsize)
398    } else {
399        None
400    }
401}
402
403#[cfg(test)]
404mod tests {
405    use super::*;
406
407    #[test]
408    fn rss_returns_some_on_supported_os() {
409        if cfg!(any(target_os = "linux", target_os = "macos")) {
410            let rss = get_rss_bytes();
411            assert!(rss.is_some(), "RSS should be readable");
412            assert!(rss.unwrap() > 0, "RSS should be > 0");
413        }
414    }
415
416    #[test]
417    fn system_ram_returns_some_on_supported_os() {
418        if cfg!(any(target_os = "linux", target_os = "macos")) {
419            let ram = get_system_ram_bytes();
420            assert!(ram.is_some(), "System RAM should be readable");
421            assert!(ram.unwrap() > 1_000_000, "System RAM should be > 1MB");
422        }
423    }
424
425    #[test]
426    fn snapshot_captures_correctly() {
427        if cfg!(any(target_os = "linux", target_os = "macos")) {
428            let snap = MemorySnapshot::capture();
429            assert!(snap.is_some());
430            let s = snap.unwrap();
431            assert!(s.rss_bytes > 0);
432            assert!(s.system_ram_bytes > s.rss_bytes);
433            assert!(s.rss_percent > 0.0 && s.rss_percent < 100.0);
434        }
435    }
436
437    #[test]
438    fn peak_rss_tracks_maximum() {
439        PEAK_RSS.store(0, Ordering::Relaxed);
440        PEAK_RSS.fetch_max(100, Ordering::Relaxed);
441        PEAK_RSS.fetch_max(50, Ordering::Relaxed);
442        assert_eq!(PEAK_RSS.load(Ordering::Relaxed), 100);
443    }
444
445    #[test]
446    fn pressure_level_roundtrip() {
447        for level in [
448            PressureLevel::Normal,
449            PressureLevel::Soft,
450            PressureLevel::Medium,
451            PressureLevel::Hard,
452            PressureLevel::Critical,
453        ] {
454            assert_eq!(PressureLevel::from_u8(level as u8), level);
455        }
456    }
457
458    #[test]
459    fn atomic_pressure_defaults_to_normal() {
460        assert_eq!(current_pressure(), PressureLevel::Normal);
461    }
462
463    #[test]
464    fn start_guard_is_noop_under_test() {
465        // Regression guard: the background guardian must never run inside the
466        // test harness. If it did, its 3s poll would observe the suite's large
467        // RSS, flip the global pressure/abort flags, and silently make unrelated
468        // tests (e.g. graph edge-building) skip work — an order/timing-dependent
469        // flake. `start_guard` must be a no-op under `cfg!(test)`.
470        let fired = Arc::new(AtomicBool::new(false));
471        let fired_cb = fired.clone();
472        start_guard(Arc::new(move |_| fired_cb.store(true, Ordering::SeqCst)));
473
474        assert!(
475            !GUARD_RUNNING.load(Ordering::Relaxed),
476            "guardian thread must not start under cfg!(test)"
477        );
478        assert_eq!(current_pressure(), PressureLevel::Normal);
479        assert!(!abort_requested());
480        assert!(
481            !fired.load(Ordering::Relaxed),
482            "eviction callback must never fire in tests"
483        );
484    }
485
486    #[test]
487    fn rss_for_own_pid_matches_self() {
488        if cfg!(any(target_os = "linux", target_os = "macos")) {
489            let self_rss = get_rss_bytes().unwrap();
490            let pid_rss = get_rss_bytes_for_pid(std::process::id()).unwrap();
491            let ratio = self_rss as f64 / pid_rss as f64;
492            assert!(
493                (0.5..2.0).contains(&ratio),
494                "self RSS ({self_rss}) and pid-based RSS ({pid_rss}) should be within 2x"
495            );
496        }
497    }
498
499    #[test]
500    fn rss_for_dead_pid_returns_none() {
501        let dead_pid = 999_999_999u32;
502        assert!(get_rss_bytes_for_pid(dead_pid).is_none());
503    }
504
505    #[test]
506    fn capture_for_pid_falls_back_on_dead_pid() {
507        if cfg!(any(target_os = "linux", target_os = "macos")) {
508            let snap = MemorySnapshot::capture_for_pid(999_999_999);
509            assert!(snap.is_some(), "should fall back to self RSS");
510        }
511    }
512}