1use std::collections::BTreeMap;
2use std::path::Path;
3
4use serde::Serialize;
5use serde_json::Value;
6
7#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
13pub struct MemoryEstimate {
14 pub status: &'static str,
15 pub bytes_status: &'static str,
16 pub estimated_bytes: Option<u64>,
17 #[serde(skip_serializing_if = "Vec::is_empty")]
18 pub not_estimated: Vec<String>,
19 #[serde(flatten)]
20 pub counts: BTreeMap<String, u64>,
21}
22
23impl MemoryEstimate {
24 pub fn estimated(bytes: u64) -> Self {
25 Self {
26 status: "ready",
27 bytes_status: "estimated",
28 estimated_bytes: Some(bytes),
29 not_estimated: Vec::new(),
30 counts: BTreeMap::new(),
31 }
32 }
33
34 pub fn partial(bytes: u64) -> Self {
35 Self {
36 status: "ready",
37 bytes_status: "partial",
38 estimated_bytes: Some(bytes),
39 not_estimated: Vec::new(),
40 counts: BTreeMap::new(),
41 }
42 }
43
44 pub fn not_estimated() -> Self {
45 Self {
46 status: "ready",
47 bytes_status: "not_estimated",
48 estimated_bytes: None,
49 not_estimated: Vec::new(),
50 counts: BTreeMap::new(),
51 }
52 }
53
54 pub fn busy() -> Self {
55 Self {
56 status: "busy",
57 bytes_status: "not_estimated",
58 estimated_bytes: None,
59 not_estimated: Vec::new(),
60 counts: BTreeMap::new(),
61 }
62 }
63
64 pub fn count(mut self, name: impl Into<String>, value: usize) -> Self {
65 self.counts.insert(name.into(), usize_to_u64(value));
66 self
67 }
68
69 pub fn count_u64(mut self, name: impl Into<String>, value: u64) -> Self {
70 self.counts.insert(name.into(), value);
71 self
72 }
73
74 pub fn gap(mut self, name: impl Into<String>) -> Self {
75 self.not_estimated.push(name.into());
76 self
77 }
78}
79
80#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
81pub struct RootMemorySnapshot {
82 pub status: &'static str,
83 pub attributed_bytes: u64,
84 pub semantic: MemoryEstimate,
85 pub trigram: MemoryEstimate,
86 pub symbols: MemoryEstimate,
87 pub callgraph: MemoryEstimate,
88 pub callgraph_projection: MemoryEstimate,
89 pub inspect: MemoryEstimate,
90 pub bash: MemoryEstimate,
91 pub lsp: MemoryEstimate,
92 pub parser_pool: MemoryEstimate,
93}
94
95impl RootMemorySnapshot {
96 pub fn new(
97 semantic: MemoryEstimate,
98 trigram: MemoryEstimate,
99 symbols: MemoryEstimate,
100 callgraph: MemoryEstimate,
101 callgraph_projection: MemoryEstimate,
102 inspect: MemoryEstimate,
103 bash: MemoryEstimate,
104 lsp: MemoryEstimate,
105 parser_pool: MemoryEstimate,
106 ) -> Self {
107 let estimates = [
108 &semantic,
109 &trigram,
110 &symbols,
111 &callgraph,
112 &callgraph_projection,
113 &inspect,
114 &bash,
115 &lsp,
116 &parser_pool,
117 ];
118 let attributed_bytes = estimates
119 .iter()
120 .filter_map(|estimate| estimate.estimated_bytes)
121 .fold(0u64, u64::saturating_add);
122 let status = if estimates.iter().any(|estimate| estimate.status == "busy") {
123 "busy"
124 } else {
125 "ready"
126 };
127 Self {
128 status,
129 attributed_bytes,
130 semantic,
131 trigram,
132 symbols,
133 callgraph,
134 callgraph_projection,
135 inspect,
136 bash,
137 lsp,
138 parser_pool,
139 }
140 }
141
142 pub fn busy_subsystem_count(&self) -> usize {
143 self.estimates()
144 .iter()
145 .filter(|estimate| estimate.status == "busy")
146 .count()
147 }
148
149 pub fn not_estimated_subsystem_count(&self) -> usize {
150 self.estimates()
151 .iter()
152 .filter(|estimate| estimate.estimated_bytes.is_none())
153 .count()
154 }
155
156 fn estimates(&self) -> [&MemoryEstimate; 9] {
157 [
158 &self.semantic,
159 &self.trigram,
160 &self.symbols,
161 &self.callgraph,
162 &self.callgraph_projection,
163 &self.inspect,
164 &self.bash,
165 &self.lsp,
166 &self.parser_pool,
167 ]
168 }
169}
170
171#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
172pub struct SqliteMemorySnapshot {
173 pub status: &'static str,
174 pub memory_used_bytes: u64,
175 pub memory_highwater_bytes: u64,
176}
177
178impl SqliteMemorySnapshot {
179 fn measure() -> Self {
180 let memory_used = unsafe { rusqlite::ffi::sqlite3_memory_used() };
183 let memory_highwater = unsafe { rusqlite::ffi::sqlite3_memory_highwater(0) };
184 Self {
185 status: "measured",
186 memory_used_bytes: nonnegative_i64_to_u64(memory_used),
187 memory_highwater_bytes: nonnegative_i64_to_u64(memory_highwater),
188 }
189 }
190}
191
192#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
193pub struct AllocatorMemorySnapshot {
194 pub status: &'static str,
195 pub bytes_in_use: Option<u64>,
196 pub size_allocated: Option<u64>,
197 pub retained_slack_bytes: Option<u64>,
198 #[serde(skip_serializing_if = "Option::is_none")]
199 pub not_estimated: Option<&'static str>,
200}
201
202impl AllocatorMemorySnapshot {
203 #[cfg(any(target_os = "macos", all(target_os = "linux", target_env = "gnu")))]
204 fn measured(bytes_in_use: u64, size_allocated: u64) -> Self {
205 Self {
206 status: "measured",
207 bytes_in_use: Some(bytes_in_use),
208 size_allocated: Some(size_allocated),
209 retained_slack_bytes: Some(size_allocated.saturating_sub(bytes_in_use)),
210 not_estimated: None,
211 }
212 }
213
214 #[cfg_attr(target_os = "macos", allow(dead_code))]
218 fn not_estimated(reason: &'static str) -> Self {
219 Self {
220 status: "not_estimated_on_this_platform",
221 bytes_in_use: None,
222 size_allocated: None,
223 retained_slack_bytes: None,
224 not_estimated: Some(reason),
225 }
226 }
227}
228
229#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
230pub struct ProcessMemorySnapshot {
231 pub rss_status: &'static str,
232 pub rss_bytes: Option<u64>,
233 #[serde(skip_serializing_if = "Option::is_none")]
242 pub phys_footprint_bytes: Option<u64>,
243 #[serde(skip_serializing_if = "Option::is_none")]
244 pub rss_not_estimated: Option<&'static str>,
245 pub sqlite: SqliteMemorySnapshot,
246 pub allocator: AllocatorMemorySnapshot,
249 pub total_attributed_bytes: u64,
250 pub unattributed_bytes: Option<i64>,
251 pub root_count: usize,
252 pub busy_subsystems: usize,
253 pub not_estimated_subsystems: usize,
254}
255
256#[derive(Debug, Clone, PartialEq, Eq)]
257pub struct AllocatorPressureRelief {
258 pub bytes_released: u64,
259 pub rss_before_bytes: Option<u64>,
260 pub rss_after_bytes: Option<u64>,
261 pub allocator_before: AllocatorMemorySnapshot,
262 pub allocator_after: AllocatorMemorySnapshot,
263}
264
265impl ProcessMemorySnapshot {
266 pub fn from_roots(
267 roots: &BTreeMap<String, RootMemorySnapshot>,
268 shared_semantic_bases: &MemoryEstimate,
269 ) -> Self {
270 let sqlite = SqliteMemorySnapshot::measure();
271 let allocator = allocator_memory_snapshot();
272 let total_attributed_bytes = roots
273 .values()
274 .map(|root| root.attributed_bytes)
275 .fold(0u64, u64::saturating_add)
276 .saturating_add(shared_semantic_bases.estimated_bytes.unwrap_or(0))
277 .saturating_add(sqlite.memory_used_bytes);
278 let busy_subsystems = roots
279 .values()
280 .map(RootMemorySnapshot::busy_subsystem_count)
281 .sum();
282 let not_estimated_subsystems = roots
283 .values()
284 .map(RootMemorySnapshot::not_estimated_subsystem_count)
285 .sum();
286 let rss_bytes = process_rss_bytes();
287 let phys_footprint_bytes = process_phys_footprint_bytes();
288 let unattributed_basis = phys_footprint_bytes.or(rss_bytes);
292 let unattributed_bytes =
293 unattributed_basis.map(|held| signed_difference(held, total_attributed_bytes));
294 Self {
295 rss_status: if rss_bytes.is_some() {
296 "estimated"
297 } else {
298 "not_estimated_on_this_platform"
299 },
300 rss_bytes,
301 phys_footprint_bytes,
302 rss_not_estimated: rss_bytes
303 .is_none()
304 .then_some("platform_process_rss_unavailable"),
305 sqlite,
306 allocator,
307 total_attributed_bytes,
308 unattributed_bytes,
309 root_count: roots.len(),
310 busy_subsystems,
311 not_estimated_subsystems,
312 }
313 }
314}
315
316pub const MEMORY_SNAPSHOT_ROOT_DETAIL_CAP: usize = 8;
323
324#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
325pub struct MemorySnapshot {
326 pub roots_status: &'static str,
327 pub roots: BTreeMap<String, RootMemorySnapshot>,
331 pub roots_total: usize,
333 pub roots_omitted: usize,
335 pub roots_omitted_bytes: u64,
338 pub shared_semantic_bases: MemoryEstimate,
340 pub process: ProcessMemorySnapshot,
341}
342
343impl MemorySnapshot {
344 pub fn new(roots_status: &'static str, roots: BTreeMap<String, RootMemorySnapshot>) -> Self {
345 let shared_semantic_bases = crate::semantic_index::shared_semantic_bases_memory();
346 let process = ProcessMemorySnapshot::from_roots(&roots, &shared_semantic_bases);
348 let roots_total = roots.len();
349 let (roots, roots_omitted, roots_omitted_bytes) =
350 cap_root_detail(roots, MEMORY_SNAPSHOT_ROOT_DETAIL_CAP);
351 Self {
352 roots_status,
353 roots,
354 roots_total,
355 roots_omitted,
356 roots_omitted_bytes,
357 shared_semantic_bases,
358 process,
359 }
360 }
361}
362
363fn cap_root_detail(
366 roots: BTreeMap<String, RootMemorySnapshot>,
367 cap: usize,
368) -> (BTreeMap<String, RootMemorySnapshot>, usize, u64) {
369 if roots.len() <= cap {
370 return (roots, 0, 0);
371 }
372 let mut entries: Vec<(String, RootMemorySnapshot)> = roots.into_iter().collect();
373 entries.sort_by(|a, b| {
375 b.1.attributed_bytes
376 .cmp(&a.1.attributed_bytes)
377 .then_with(|| a.0.cmp(&b.0))
378 });
379 let omitted: Vec<(String, RootMemorySnapshot)> = entries.split_off(cap);
380 let omitted_bytes = omitted
381 .iter()
382 .map(|(_, snapshot)| snapshot.attributed_bytes)
383 .fold(0u64, u64::saturating_add);
384 (entries.into_iter().collect(), omitted.len(), omitted_bytes)
385}
386
387#[cfg(test)]
388mod snapshot_cap_tests {
389 use super::*;
390
391 fn root_with_bytes(bytes: u64) -> RootMemorySnapshot {
392 let estimate = MemoryEstimate::estimated;
393 RootMemorySnapshot::new(
394 estimate(bytes),
395 estimate(0),
396 estimate(0),
397 estimate(0),
398 estimate(0),
399 estimate(0),
400 estimate(0),
401 estimate(0),
402 estimate(0),
403 )
404 }
405
406 #[test]
407 fn detail_map_capped_but_totals_cover_all_roots() {
408 let mut roots = BTreeMap::new();
409 for i in 0..(MEMORY_SNAPSHOT_ROOT_DETAIL_CAP + 4) {
410 roots.insert(
412 format!("/root/{i:02}"),
413 root_with_bytes((i as u64 + 1) * 1000),
414 );
415 }
416 let snapshot = MemorySnapshot::new("ready", roots);
417
418 assert_eq!(snapshot.roots.len(), MEMORY_SNAPSHOT_ROOT_DETAIL_CAP);
419 assert_eq!(snapshot.roots_total, MEMORY_SNAPSHOT_ROOT_DETAIL_CAP + 4);
420 assert_eq!(snapshot.roots_omitted, 4);
421 assert_eq!(snapshot.roots_omitted_bytes, 1000 + 2000 + 3000 + 4000);
423 assert!(snapshot
425 .roots
426 .values()
427 .all(|root| root.attributed_bytes > 4000));
428 let expected_total: u64 = (1..=(MEMORY_SNAPSHOT_ROOT_DETAIL_CAP as u64 + 4))
430 .map(|i| i * 1000)
431 .sum();
432 assert!(snapshot.process.total_attributed_bytes >= expected_total);
433 }
434
435 #[test]
436 fn under_cap_keeps_everything_with_zero_omitted() {
437 let mut roots = BTreeMap::new();
438 roots.insert("/a".to_string(), root_with_bytes(10));
439 roots.insert("/b".to_string(), root_with_bytes(20));
440 let snapshot = MemorySnapshot::new("ready", roots);
441 assert_eq!(snapshot.roots.len(), 2);
442 assert_eq!(snapshot.roots_total, 2);
443 assert_eq!(snapshot.roots_omitted, 0);
444 assert_eq!(snapshot.roots_omitted_bytes, 0);
445 }
446}
447
448pub fn path_bytes(path: &Path) -> u64 {
449 #[cfg(unix)]
450 {
451 use std::os::unix::ffi::OsStrExt;
452 usize_to_u64(path.as_os_str().as_bytes().len())
453 }
454 #[cfg(windows)]
455 {
456 use std::os::windows::ffi::OsStrExt;
457 usize_to_u64(path.as_os_str().encode_wide().count())
458 .saturating_mul(std::mem::size_of::<u16>() as u64)
459 }
460 #[cfg(not(any(unix, windows)))]
461 {
462 usize_to_u64(path.to_string_lossy().len())
463 }
464}
465
466pub fn usize_to_u64(value: usize) -> u64 {
467 u64::try_from(value).unwrap_or(u64::MAX)
468}
469
470pub fn estimated_json_bytes(value: &Value) -> u64 {
471 match value {
472 Value::Null => 0,
473 Value::Bool(_) => std::mem::size_of::<bool>() as u64,
474 Value::Number(_) => std::mem::size_of::<serde_json::Number>() as u64,
475 Value::String(value) => usize_to_u64(value.len()),
476 Value::Array(values) => values
477 .iter()
478 .map(estimated_json_bytes)
479 .fold(0u64, u64::saturating_add),
480 Value::Object(values) => values.iter().fold(0u64, |bytes, (key, value)| {
481 bytes
482 .saturating_add(usize_to_u64(key.len()))
483 .saturating_add(estimated_json_bytes(value))
484 }),
485 }
486}
487
488fn signed_difference(lhs: u64, rhs: u64) -> i64 {
489 let difference = i128::from(lhs) - i128::from(rhs);
490 difference.clamp(i128::from(i64::MIN), i128::from(i64::MAX)) as i64
491}
492
493fn nonnegative_i64_to_u64(value: i64) -> u64 {
494 u64::try_from(value).unwrap_or(0)
495}
496
497#[cfg(target_os = "macos")]
498fn allocator_memory_snapshot() -> AllocatorMemorySnapshot {
499 let mut statistics = std::mem::MaybeUninit::<libc::malloc_statistics_t>::zeroed();
500 unsafe {
501 libc::malloc_zone_statistics(libc::malloc_default_zone(), statistics.as_mut_ptr());
502 }
503 let statistics = unsafe { statistics.assume_init() };
504 AllocatorMemorySnapshot::measured(
505 usize_to_u64(statistics.size_in_use),
506 usize_to_u64(statistics.size_allocated),
507 )
508}
509
510#[cfg(all(target_os = "linux", target_env = "gnu"))]
511fn allocator_memory_snapshot() -> AllocatorMemorySnapshot {
512 use std::sync::OnceLock;
518 type Mallinfo2Fn = unsafe extern "C" fn() -> libc::mallinfo2;
519 static MALLINFO2: OnceLock<Option<Mallinfo2Fn>> = OnceLock::new();
520 let resolved = MALLINFO2.get_or_init(|| {
521 let symbol = unsafe { libc::dlsym(libc::RTLD_DEFAULT, c"mallinfo2".as_ptr()) };
522 if symbol.is_null() {
523 None
524 } else {
525 Some(unsafe { std::mem::transmute::<*mut libc::c_void, Mallinfo2Fn>(symbol) })
528 }
529 });
530 let Some(mallinfo2) = resolved else {
531 return AllocatorMemorySnapshot::not_estimated("mallinfo2_requires_glibc_2_33");
532 };
533 let statistics = unsafe { mallinfo2() };
534 let mapped_bytes = statistics.hblkhd as u64;
535 let bytes_in_use = (statistics.uordblks as u64).saturating_add(mapped_bytes);
536 let size_allocated = (statistics.arena as u64).saturating_add(mapped_bytes);
537 AllocatorMemorySnapshot::measured(bytes_in_use, size_allocated)
538}
539
540#[cfg(all(target_os = "linux", target_env = "gnu"))]
541type MallocTrimFn = unsafe extern "C" fn(libc::size_t) -> libc::c_int;
542
543#[cfg(all(target_os = "linux", target_env = "gnu"))]
546fn resolved_malloc_trim() -> Option<MallocTrimFn> {
547 use std::sync::OnceLock;
548 static MALLOC_TRIM: OnceLock<Option<MallocTrimFn>> = OnceLock::new();
549 MALLOC_TRIM
550 .get_or_init(|| {
551 let symbol = unsafe { libc::dlsym(libc::RTLD_DEFAULT, c"malloc_trim".as_ptr()) };
552 if symbol.is_null() {
553 None
554 } else {
555 Some(unsafe { std::mem::transmute::<*mut libc::c_void, MallocTrimFn>(symbol) })
558 }
559 })
560 .as_ref()
561 .copied()
562}
563
564#[cfg(not(any(target_os = "macos", all(target_os = "linux", target_env = "gnu"))))]
565fn allocator_memory_snapshot() -> AllocatorMemorySnapshot {
566 AllocatorMemorySnapshot::not_estimated("platform_allocator_statistics_unavailable")
567}
568
569#[cfg(target_os = "macos")]
570unsafe extern "C" {
571 fn malloc_zone_pressure_relief(zone: *mut libc::malloc_zone_t, goal: usize) -> usize;
572}
573
574pub const ALLOCATOR_SLACK_RELIEF_THRESHOLD_BYTES: u64 = 1024 * 1024 * 1024;
577
578pub const ALLOCATOR_SLACK_RELIEF_MIN_INTERVAL: std::time::Duration =
581 std::time::Duration::from_secs(300);
582
583pub fn allocator_slack_relief_due(
589 retained_slack_bytes: Option<u64>,
590 last_relief: Option<std::time::Instant>,
591 now: std::time::Instant,
592) -> bool {
593 let Some(slack) = retained_slack_bytes else {
594 return false;
595 };
596 if slack < ALLOCATOR_SLACK_RELIEF_THRESHOLD_BYTES {
597 return false;
598 }
599 match last_relief {
600 None => true,
601 Some(at) => now.duration_since(at) >= ALLOCATOR_SLACK_RELIEF_MIN_INTERVAL,
602 }
603}
604
605#[cfg(any(target_os = "macos", target_os = "linux"))]
615pub fn spawn_allocator_slack_relief_if_due(
616 last_relief: Option<std::time::Instant>,
617 now: std::time::Instant,
618) -> bool {
619 let slack = allocator_memory_snapshot().retained_slack_bytes;
620 if !allocator_slack_relief_due(slack, last_relief, now) {
621 return false;
622 }
623 std::thread::Builder::new()
624 .name("aft-mem-relief".to_string())
625 .spawn(|| {
626 let relief = relieve_allocator_pressure();
627 log::info!(
628 "allocator slack relief: released={} allocator_slack_bytes_before={:?} allocator_slack_bytes_after={:?} rss_bytes_before={:?} rss_bytes_after={:?}",
629 relief.bytes_released,
630 relief.allocator_before.retained_slack_bytes,
631 relief.allocator_after.retained_slack_bytes,
632 relief.rss_before_bytes,
633 relief.rss_after_bytes,
634 );
635 })
636 .is_ok()
637}
638
639#[cfg(target_os = "macos")]
644pub fn relieve_allocator_pressure() -> AllocatorPressureRelief {
645 let rss_before_bytes = process_rss_bytes();
646 let allocator_before = allocator_memory_snapshot();
647 let bytes_released = unsafe { malloc_zone_pressure_relief(std::ptr::null_mut(), 0) };
648 let allocator_after = allocator_memory_snapshot();
649 let rss_after_bytes = process_rss_bytes();
650 AllocatorPressureRelief {
651 bytes_released: usize_to_u64(bytes_released),
652 rss_before_bytes,
653 rss_after_bytes,
654 allocator_before,
655 allocator_after,
656 }
657}
658
659#[cfg(target_os = "linux")]
660pub fn relieve_allocator_pressure() -> AllocatorPressureRelief {
661 let rss_before_bytes = process_rss_bytes();
662 let allocator_before = allocator_memory_snapshot();
663 #[cfg(target_env = "gnu")]
664 if let Some(malloc_trim) = resolved_malloc_trim() {
665 unsafe { malloc_trim(0) };
668 }
669 let allocator_after = allocator_memory_snapshot();
670 let rss_after_bytes = process_rss_bytes();
671 let bytes_released = allocator_before
672 .size_allocated
673 .zip(allocator_after.size_allocated)
674 .map(|(before, after)| before.saturating_sub(after))
675 .unwrap_or(0);
676 AllocatorPressureRelief {
677 bytes_released,
678 rss_before_bytes,
679 rss_after_bytes,
680 allocator_before,
681 allocator_after,
682 }
683}
684
685#[cfg(target_os = "macos")]
686fn process_rss_bytes() -> Option<u64> {
687 let mut info = std::mem::MaybeUninit::<libc::proc_taskinfo>::zeroed();
688 let size = std::mem::size_of::<libc::proc_taskinfo>();
689 let written = unsafe {
690 libc::proc_pidinfo(
691 libc::getpid(),
692 libc::PROC_PIDTASKINFO,
693 0,
694 info.as_mut_ptr().cast(),
695 i32::try_from(size).ok()?,
696 )
697 };
698 if written != i32::try_from(size).ok()? {
699 return None;
700 }
701 Some(unsafe { info.assume_init() }.pti_resident_size)
702}
703
704#[cfg(target_os = "macos")]
707fn process_phys_footprint_bytes() -> Option<u64> {
708 let mut usage = std::mem::MaybeUninit::<libc::rusage_info_v4>::zeroed();
709 let rc = unsafe {
710 libc::proc_pid_rusage(
711 libc::getpid(),
712 libc::RUSAGE_INFO_V4,
713 usage.as_mut_ptr().cast(),
714 )
715 };
716 if rc != 0 {
717 return None;
718 }
719 Some(unsafe { usage.assume_init() }.ri_phys_footprint)
720}
721
722#[cfg(not(target_os = "macos"))]
723fn process_phys_footprint_bytes() -> Option<u64> {
724 None
725}
726
727#[cfg(target_os = "linux")]
728fn process_rss_bytes() -> Option<u64> {
729 let statm = std::fs::read_to_string("/proc/self/statm").ok()?;
730 let resident_pages = statm.split_whitespace().nth(1)?.parse::<u64>().ok()?;
731 let page_size = unsafe { libc::sysconf(libc::_SC_PAGESIZE) };
732 if page_size <= 0 {
733 return None;
734 }
735 resident_pages.checked_mul(page_size as u64)
736}
737
738#[cfg(not(any(target_os = "macos", target_os = "linux")))]
739fn process_rss_bytes() -> Option<u64> {
740 None
741}
742
743#[cfg(test)]
744mod tests {
745 use super::*;
746
747 #[test]
748 fn process_snapshot_preserves_negative_residuals() {
749 assert_eq!(signed_difference(5, 8), -3);
750 }
751
752 #[test]
753 fn slack_relief_fires_on_large_slack_and_respects_spacing() {
754 use std::time::{Duration, Instant};
755 let now = Instant::now();
756 let big = Some(ALLOCATOR_SLACK_RELIEF_THRESHOLD_BYTES);
757 assert!(!allocator_slack_relief_due(None, None, now));
759 assert!(!allocator_slack_relief_due(
761 Some(ALLOCATOR_SLACK_RELIEF_THRESHOLD_BYTES - 1),
762 None,
763 now
764 ));
765 assert!(allocator_slack_relief_due(big, None, now));
767 let recent = now - Duration::from_secs(10);
769 assert!(!allocator_slack_relief_due(big, Some(recent), now));
770 let stale = now - ALLOCATOR_SLACK_RELIEF_MIN_INTERVAL;
772 assert!(allocator_slack_relief_due(big, Some(stale), now));
773 }
774
775 #[test]
776 fn json_estimator_scales_with_payload_content() {
777 let empty = estimated_json_bytes(&serde_json::json!({}));
778 let populated = estimated_json_bytes(&serde_json::json!({"message": "hello"}));
779 assert_eq!(empty, 0);
780 assert!(populated >= 12);
781 }
782
783 #[test]
784 fn process_snapshot_exposes_sqlite_and_allocator_sections() {
785 let shared = MemoryEstimate::estimated(7);
786 let snapshot = ProcessMemorySnapshot::from_roots(&BTreeMap::new(), &shared);
787 assert_eq!(snapshot.sqlite.status, "measured");
788 assert!(snapshot.sqlite.memory_highwater_bytes >= snapshot.sqlite.memory_used_bytes);
789 assert_eq!(
790 snapshot.total_attributed_bytes,
791 snapshot.sqlite.memory_used_bytes.saturating_add(7)
792 );
793
794 let serialized = serde_json::to_value(&snapshot).expect("serialize process memory");
795 assert!(serialized["sqlite"]["memory_used_bytes"].is_u64());
796 assert!(serialized["allocator"].get("bytes_in_use").is_some());
797 assert!(serialized["allocator"].get("size_allocated").is_some());
798 assert!(serialized["allocator"]
799 .get("retained_slack_bytes")
800 .is_some());
801 }
802
803 #[cfg(any(target_os = "macos", all(target_os = "linux", target_env = "gnu")))]
804 #[test]
805 fn allocator_snapshot_reports_measured_slack() {
806 let allocator = allocator_memory_snapshot();
807 if allocator.status == "measured" {
808 let in_use = allocator.bytes_in_use.expect("allocator bytes in use");
809 let allocated = allocator.size_allocated.expect("allocator size allocated");
810 assert_eq!(
811 allocator.retained_slack_bytes,
812 Some(allocated.saturating_sub(in_use))
813 );
814 } else {
815 assert_eq!(allocator.status, "not_estimated_on_this_platform");
816 assert_eq!(allocator.bytes_in_use, None);
817 assert_eq!(allocator.size_allocated, None);
818 assert_eq!(allocator.retained_slack_bytes, None);
819 assert_eq!(
820 allocator.not_estimated,
821 Some("mallinfo2_requires_glibc_2_33")
822 );
823 }
824 }
825
826 #[cfg(target_os = "linux")]
827 #[test]
828 fn linux_allocator_pressure_relief_smoke() {
829 let mut allocation = vec![0u8; 32 * 1024 * 1024];
830 for byte in allocation.iter_mut().step_by(4096) {
831 *byte = 1;
832 }
833 std::hint::black_box(&allocation);
834 drop(allocation);
835
836 let relief = relieve_allocator_pressure();
837 std::hint::black_box(relief);
838
839 #[cfg(target_env = "gnu")]
840 assert!(
841 resolved_malloc_trim().is_some(),
842 "glibc malloc_trim must be available for the Linux relief path"
843 );
844 }
845
846 #[cfg(not(any(target_os = "macos", all(target_os = "linux", target_env = "gnu"))))]
847 #[test]
848 fn allocator_snapshot_is_honest_when_platform_counters_are_unavailable() {
849 let allocator = allocator_memory_snapshot();
850 assert_eq!(allocator.status, "not_estimated_on_this_platform");
851 assert_eq!(allocator.bytes_in_use, None);
852 assert_eq!(allocator.size_allocated, None);
853 assert_eq!(allocator.retained_slack_bytes, None);
854 assert_eq!(
855 allocator.not_estimated,
856 Some("platform_allocator_statistics_unavailable")
857 );
858 }
859
860 #[cfg(target_os = "macos")]
861 #[test]
862 #[ignore = "bounded live RSS experiment; run explicitly after allocator changes"]
863 fn allocator_pressure_relief_warm_then_idle_measurement() {
864 let warm_pages = (0..16 * 1024)
865 .map(|seed| {
866 let mut page = Box::new([0u8; 4096]);
867 page[0] = seed as u8;
868 page
869 })
870 .collect::<Vec<_>>();
871 std::hint::black_box(&warm_pages);
872 drop(warm_pages);
873
874 let relief = relieve_allocator_pressure();
875 let sqlite = SqliteMemorySnapshot::measure();
876 eprintln!(
877 "warm-then-idle pressure relief: rss_before={:?} rss_after={:?} allocator_in_use_before={:?} allocator_in_use_after={:?} allocator_allocated_before={:?} allocator_allocated_after={:?} allocator_slack_before={:?} allocator_slack_after={:?} allocator_reported_released={} sqlite_used={} sqlite_highwater={}",
878 relief.rss_before_bytes,
879 relief.rss_after_bytes,
880 relief.allocator_before.bytes_in_use,
881 relief.allocator_after.bytes_in_use,
882 relief.allocator_before.size_allocated,
883 relief.allocator_after.size_allocated,
884 relief.allocator_before.retained_slack_bytes,
885 relief.allocator_after.retained_slack_bytes,
886 relief.bytes_released,
887 sqlite.memory_used_bytes,
888 sqlite.memory_highwater_bytes,
889 );
890 assert_eq!(relief.allocator_before.status, "measured");
891 assert_eq!(relief.allocator_after.status, "measured");
892 }
893}