1use 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
16pub 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
32pub 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
49pub 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
65pub 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
73pub fn peak_rss_bytes() -> u64 {
75 PEAK_RSS.load(Ordering::Relaxed)
76}
77
78#[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 pub fn capture() -> Option<Self> {
115 Self::capture_impl(get_rss_bytes()?)
116 }
117
118 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 * 2.0 {
146 PressureLevel::Critical
147 } else if pct > base * 1.5 {
148 PressureLevel::Hard
149 } else if pct > base * 1.2 {
150 PressureLevel::Medium
151 } else if pct > base {
152 PressureLevel::Soft
153 } else {
154 PressureLevel::Normal
155 };
156
157 Some(Self {
158 rss_bytes: rss,
159 peak_rss_bytes: PEAK_RSS.load(Ordering::Relaxed),
160 system_ram_bytes: sys,
161 rss_limit_bytes: limit,
162 rss_percent: pct,
163 pressure_level: level,
164 })
165 }
166}
167
168pub fn jemalloc_purge() {
172 #[cfg(all(feature = "jemalloc", not(windows)))]
173 {
174 use tikv_jemalloc_ctl::raw;
175 let purge_mib = b"arena.4096.purge\0";
176 unsafe {
180 if let Err(e) = raw::write(purge_mib, 0u64) {
181 tracing::debug!("[memory_guard] jemalloc purge failed: {e}");
182 }
183 }
184 }
185}
186
187pub fn abort_requested() -> bool {
189 ABORT_REQUESTED.load(Ordering::Relaxed)
190}
191
192pub fn is_under_pressure() -> bool {
195 current_pressure() >= PressureLevel::Soft
196}
197
198pub fn current_pressure() -> PressureLevel {
200 PressureLevel::from_u8(CURRENT_PRESSURE.load(Ordering::Relaxed))
201}
202
203pub fn start_guard(eviction_callback: Arc<dyn Fn(PressureLevel) + Send + Sync>) {
208 if cfg!(test) {
221 return;
222 }
223 if GUARD_RUNNING.swap(true, Ordering::SeqCst) {
224 return;
225 }
226 std::thread::Builder::new()
227 .name("memory-guard".into())
228 .spawn(move || {
229 const CALM_TICKS_BEFORE_BACKOFF: u64 = 5;
236 const IDLE_POLL_SECS: u64 = 15;
237 let mut poll_secs = 3u64;
238 let mut calm_ticks = 0u64;
239
240 if let Some(snap) = MemorySnapshot::capture() {
243 CURRENT_PRESSURE.store(snap.pressure_level as u8, Ordering::Relaxed);
244 if snap.pressure_level >= PressureLevel::Soft {
245 eviction_callback(snap.pressure_level);
246 }
247 }
248
249 loop {
250 std::thread::sleep(std::time::Duration::from_secs(poll_secs));
251 let Some(snap) = MemorySnapshot::capture() else {
252 continue;
253 };
254
255 CURRENT_PRESSURE.store(snap.pressure_level as u8, Ordering::Relaxed);
256
257 if snap.pressure_level == PressureLevel::Critical {
258 tracing::error!(
259 "[memory_guard] CRITICAL: RSS={:.0}MB ({:.1}% of {:.0}GB) — \
260 aggressive eviction to prevent OS OOM kill",
261 snap.rss_bytes as f64 / 1_048_576.0,
262 snap.rss_percent,
263 snap.system_ram_bytes as f64 / 1_073_741_824.0,
264 );
265 ABORT_REQUESTED.store(true, Ordering::SeqCst);
266 (eviction_callback)(PressureLevel::Critical);
267 jemalloc_purge();
268
269 for attempt in 1..=3 {
270 std::thread::sleep(std::time::Duration::from_secs(2));
271 (eviction_callback)(PressureLevel::Critical);
272 jemalloc_purge();
273 if let Some(recheck) = MemorySnapshot::capture() {
274 if recheck.pressure_level < PressureLevel::Hard {
275 tracing::info!(
276 "[memory_guard] eviction attempt {attempt} succeeded — \
277 RSS={:.0}MB, pressure={:?}",
278 recheck.rss_bytes as f64 / 1_048_576.0,
279 recheck.pressure_level,
280 );
281 break;
282 }
283 tracing::error!(
284 "[memory_guard] eviction attempt {attempt}/3 — still {:?} \
285 (RSS={:.0}MB)",
286 recheck.pressure_level,
287 recheck.rss_bytes as f64 / 1_048_576.0,
288 );
289 }
290 }
291 }
292
293 if snap.pressure_level >= PressureLevel::Soft {
294 poll_secs = 1;
295 calm_ticks = 0;
296 ABORT_REQUESTED
297 .store(snap.pressure_level >= PressureLevel::Hard, Ordering::SeqCst);
298 tracing::warn!(
299 "[memory_guard] pressure={:?} RSS={:.0}MB limit={:.0}MB ({:.1}% of {:.0}GB)",
300 snap.pressure_level,
301 snap.rss_bytes as f64 / 1_048_576.0,
302 snap.rss_limit_bytes as f64 / 1_048_576.0,
303 snap.rss_percent,
304 snap.system_ram_bytes as f64 / 1_073_741_824.0,
305 );
306 (eviction_callback)(snap.pressure_level);
307
308 if snap.pressure_level >= PressureLevel::Hard {
309 jemalloc_purge();
310 }
311 } else {
312 calm_ticks = calm_ticks.saturating_add(1);
313 poll_secs = if calm_ticks >= CALM_TICKS_BEFORE_BACKOFF {
314 IDLE_POLL_SECS
315 } else {
316 3
317 };
318 if ABORT_REQUESTED.load(Ordering::Relaxed) {
319 ABORT_REQUESTED.store(false, Ordering::SeqCst);
320 tracing::info!("[memory_guard] pressure normalized, clearing abort flag");
321 }
322 }
323 }
324 })
325 .ok();
326}
327
328pub fn force_purge() {
330 jemalloc_purge();
331 tracing::info!("[memory_guard] force_purge completed");
332}
333
334#[cfg(target_os = "linux")]
337fn linux_rss() -> Option<u64> {
338 linux_rss_for_pid(std::process::id())
339}
340
341#[cfg(target_os = "linux")]
342fn linux_rss_for_pid(pid: u32) -> Option<u64> {
343 let path = format!("/proc/{pid}/status");
344 let status = std::fs::read_to_string(path).ok()?;
345 for line in status.lines() {
346 if let Some(val) = line.strip_prefix("VmRSS:") {
347 let kb: u64 = val.trim().trim_end_matches(" kB").trim().parse().ok()?;
348 return Some(kb * 1024);
349 }
350 }
351 None
352}
353
354#[cfg(target_os = "linux")]
355fn linux_memtotal() -> Option<u64> {
356 let info = std::fs::read_to_string("/proc/meminfo").ok()?;
357 for line in info.lines() {
358 if let Some(val) = line.strip_prefix("MemTotal:") {
359 let kb: u64 = val.trim().trim_end_matches(" kB").trim().parse().ok()?;
360 return Some(kb * 1024);
361 }
362 }
363 None
364}
365
366#[cfg(target_os = "macos")]
367#[allow(deprecated, clippy::borrow_as_ptr, clippy::ptr_as_ptr)]
368fn macos_rss() -> Option<u64> {
369 use std::mem;
370 let mut info: libc::mach_task_basic_info_data_t = unsafe { mem::zeroed() };
373 let mut count = (mem::size_of::<libc::mach_task_basic_info_data_t>()
374 / mem::size_of::<libc::natural_t>()) as libc::mach_msg_type_number_t;
375 let kr = unsafe {
379 libc::task_info(
380 libc::mach_task_self(),
381 libc::MACH_TASK_BASIC_INFO,
382 std::ptr::from_mut(&mut info).cast::<i32>(),
383 std::ptr::from_mut(&mut count),
384 )
385 };
386 if kr == libc::KERN_SUCCESS {
387 Some(info.resident_size)
388 } else {
389 None
390 }
391}
392
393#[cfg(target_os = "macos")]
394fn macos_rss_for_pid(pid: u32) -> Option<u64> {
395 let output = std::process::Command::new("ps")
398 .args(["-o", "rss=", "-p", &pid.to_string()])
399 .output()
400 .ok()?;
401 if !output.status.success() {
402 return None;
403 }
404 let text = String::from_utf8_lossy(&output.stdout);
405 let kb: u64 = text.trim().parse().ok()?;
406 Some(kb * 1024)
407}
408
409#[cfg(target_os = "macos")]
410#[allow(clippy::borrow_as_ptr, clippy::ptr_as_ptr)]
411fn macos_memsize() -> Option<u64> {
412 use std::mem;
413 let mut memsize: u64 = 0;
414 let mut len = mem::size_of::<u64>();
415 let name = b"hw.memsize\0";
416 let ret = unsafe {
419 libc::sysctlbyname(
420 name.as_ptr().cast(),
421 std::ptr::from_mut(&mut memsize).cast::<libc::c_void>(),
422 std::ptr::from_mut(&mut len),
423 std::ptr::null_mut(),
424 0,
425 )
426 };
427 if ret == 0 { Some(memsize) } else { None }
428}
429
430#[cfg(test)]
431mod tests {
432 use super::*;
433
434 #[test]
435 fn rss_returns_some_on_supported_os() {
436 if cfg!(any(target_os = "linux", target_os = "macos")) {
437 let rss = get_rss_bytes();
438 assert!(rss.is_some(), "RSS should be readable");
439 assert!(rss.unwrap() > 0, "RSS should be > 0");
440 }
441 }
442
443 #[test]
444 fn system_ram_returns_some_on_supported_os() {
445 if cfg!(any(target_os = "linux", target_os = "macos")) {
446 let ram = get_system_ram_bytes();
447 assert!(ram.is_some(), "System RAM should be readable");
448 assert!(ram.unwrap() > 1_000_000, "System RAM should be > 1MB");
449 }
450 }
451
452 #[test]
453 fn snapshot_captures_correctly() {
454 if cfg!(any(target_os = "linux", target_os = "macos")) {
455 let snap = MemorySnapshot::capture();
456 assert!(snap.is_some());
457 let s = snap.unwrap();
458 assert!(s.rss_bytes > 0);
459 assert!(s.system_ram_bytes > s.rss_bytes);
460 assert!(s.rss_percent > 0.0 && s.rss_percent < 100.0);
461 }
462 }
463
464 #[test]
465 fn peak_rss_tracks_maximum() {
466 PEAK_RSS.store(0, Ordering::Relaxed);
467 PEAK_RSS.fetch_max(100, Ordering::Relaxed);
468 PEAK_RSS.fetch_max(50, Ordering::Relaxed);
469 assert_eq!(PEAK_RSS.load(Ordering::Relaxed), 100);
470 }
471
472 #[test]
473 fn pressure_level_roundtrip() {
474 for level in [
475 PressureLevel::Normal,
476 PressureLevel::Soft,
477 PressureLevel::Medium,
478 PressureLevel::Hard,
479 PressureLevel::Critical,
480 ] {
481 assert_eq!(PressureLevel::from_u8(level as u8), level);
482 }
483 }
484
485 #[test]
486 fn atomic_pressure_defaults_to_normal() {
487 assert_eq!(current_pressure(), PressureLevel::Normal);
488 }
489
490 #[test]
491 fn start_guard_is_noop_under_test() {
492 let fired = Arc::new(AtomicBool::new(false));
498 let fired_cb = fired.clone();
499 start_guard(Arc::new(move |_| fired_cb.store(true, Ordering::SeqCst)));
500
501 assert!(
502 !GUARD_RUNNING.load(Ordering::Relaxed),
503 "guardian thread must not start under cfg!(test)"
504 );
505 assert_eq!(current_pressure(), PressureLevel::Normal);
506 assert!(!abort_requested());
507 assert!(
508 !fired.load(Ordering::Relaxed),
509 "eviction callback must never fire in tests"
510 );
511 }
512
513 #[test]
514 fn rss_for_own_pid_matches_self() {
515 if cfg!(any(target_os = "linux", target_os = "macos")) {
516 let self_rss = get_rss_bytes().unwrap();
517 let pid_rss = get_rss_bytes_for_pid(std::process::id()).unwrap();
518 let ratio = self_rss as f64 / pid_rss as f64;
519 assert!(
520 (0.5..2.0).contains(&ratio),
521 "self RSS ({self_rss}) and pid-based RSS ({pid_rss}) should be within 2x"
522 );
523 }
524 }
525
526 #[test]
527 fn rss_for_dead_pid_returns_none() {
528 let dead_pid = 999_999_999u32;
529 assert!(get_rss_bytes_for_pid(dead_pid).is_none());
530 }
531
532 #[test]
533 fn capture_for_pid_falls_back_on_dead_pid() {
534 if cfg!(any(target_os = "linux", target_os = "macos")) {
535 let snap = MemorySnapshot::capture_for_pid(999_999_999);
536 assert!(snap.is_some(), "should fall back to self RSS");
537 }
538 }
539}