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