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
95#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
96pub(crate) struct RootMemoryRollup {
97 pub(crate) status: &'static str,
98 pub(crate) attributed_bytes: u64,
99 pub(crate) busy_subsystems: usize,
100 pub(crate) not_estimated_subsystems: usize,
101 #[serde(skip_serializing_if = "Option::is_none")]
105 pub(crate) standing: Option<bool>,
106}
107
108impl RootMemoryRollup {
109 pub(crate) fn from_estimates(estimates: &[&MemoryEstimate]) -> Self {
110 let attributed_bytes = estimates
111 .iter()
112 .filter_map(|estimate| estimate.estimated_bytes)
113 .fold(0u64, u64::saturating_add);
114 let busy_subsystems = estimates
115 .iter()
116 .filter(|estimate| estimate.status == "busy")
117 .count();
118 let not_estimated_subsystems = estimates
119 .iter()
120 .filter(|estimate| estimate.estimated_bytes.is_none())
121 .count();
122 Self {
123 status: if busy_subsystems > 0 { "busy" } else { "ready" },
124 attributed_bytes,
125 busy_subsystems,
126 not_estimated_subsystems,
127 standing: None,
128 }
129 }
130
131 pub(crate) fn with_standing(mut self) -> Self {
133 self.standing = Some(true);
134 self
135 }
136}
137
138impl RootMemorySnapshot {
139 pub fn new(
140 semantic: MemoryEstimate,
141 trigram: MemoryEstimate,
142 symbols: MemoryEstimate,
143 callgraph: MemoryEstimate,
144 callgraph_projection: MemoryEstimate,
145 inspect: MemoryEstimate,
146 bash: MemoryEstimate,
147 lsp: MemoryEstimate,
148 parser_pool: MemoryEstimate,
149 ) -> Self {
150 let rollup = RootMemoryRollup::from_estimates(&[
151 &semantic,
152 &trigram,
153 &symbols,
154 &callgraph,
155 &callgraph_projection,
156 &inspect,
157 &bash,
158 &lsp,
159 &parser_pool,
160 ]);
161 Self {
162 status: rollup.status,
163 attributed_bytes: rollup.attributed_bytes,
164 semantic,
165 trigram,
166 symbols,
167 callgraph,
168 callgraph_projection,
169 inspect,
170 bash,
171 lsp,
172 parser_pool,
173 }
174 }
175
176 fn rollup(&self) -> RootMemoryRollup {
177 RootMemoryRollup::from_estimates(&[
178 &self.semantic,
179 &self.trigram,
180 &self.symbols,
181 &self.callgraph,
182 &self.callgraph_projection,
183 &self.inspect,
184 &self.bash,
185 &self.lsp,
186 &self.parser_pool,
187 ])
188 }
189
190 pub fn busy_subsystem_count(&self) -> usize {
191 self.rollup().busy_subsystems
192 }
193
194 pub fn not_estimated_subsystem_count(&self) -> usize {
195 self.rollup().not_estimated_subsystems
196 }
197}
198
199#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
200pub struct SqliteMemorySnapshot {
201 pub status: &'static str,
202 pub memory_used_bytes: u64,
203 pub memory_highwater_bytes: u64,
204}
205
206impl SqliteMemorySnapshot {
207 fn measure() -> Self {
208 let memory_used = unsafe { rusqlite::ffi::sqlite3_memory_used() };
211 let memory_highwater = unsafe { rusqlite::ffi::sqlite3_memory_highwater(0) };
212 Self {
213 status: "measured",
214 memory_used_bytes: nonnegative_i64_to_u64(memory_used),
215 memory_highwater_bytes: nonnegative_i64_to_u64(memory_highwater),
216 }
217 }
218}
219
220#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
221pub struct AllocatorMemorySnapshot {
222 pub status: &'static str,
223 pub bytes_in_use: Option<u64>,
224 pub size_allocated: Option<u64>,
225 pub retained_slack_bytes: Option<u64>,
226 #[serde(skip_serializing_if = "Option::is_none")]
227 pub not_estimated: Option<&'static str>,
228}
229
230impl AllocatorMemorySnapshot {
231 #[cfg(any(target_os = "macos", all(target_os = "linux", target_env = "gnu")))]
232 fn measured(bytes_in_use: u64, size_allocated: u64) -> Self {
233 Self {
234 status: "measured",
235 bytes_in_use: Some(bytes_in_use),
236 size_allocated: Some(size_allocated),
237 retained_slack_bytes: Some(size_allocated.saturating_sub(bytes_in_use)),
238 not_estimated: None,
239 }
240 }
241
242 #[cfg_attr(target_os = "macos", allow(dead_code))]
246 fn not_estimated(reason: &'static str) -> Self {
247 Self {
248 status: "not_estimated_on_this_platform",
249 bytes_in_use: None,
250 size_allocated: None,
251 retained_slack_bytes: None,
252 not_estimated: Some(reason),
253 }
254 }
255}
256
257#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
258pub struct ProcessMemorySnapshot {
259 pub rss_status: &'static str,
260 pub rss_bytes: Option<u64>,
261 #[serde(skip_serializing_if = "Option::is_none")]
270 pub phys_footprint_bytes: Option<u64>,
271 #[serde(skip_serializing_if = "Option::is_none")]
272 pub rss_not_estimated: Option<&'static str>,
273 pub sqlite: SqliteMemorySnapshot,
274 pub allocator: AllocatorMemorySnapshot,
277 pub total_attributed_bytes: u64,
278 pub unattributed_bytes: Option<i64>,
279 pub root_count: usize,
280 pub busy_subsystems: usize,
281 pub not_estimated_subsystems: usize,
282}
283
284#[derive(Debug, Clone, PartialEq, Eq)]
285pub struct AllocatorPressureRelief {
286 pub bytes_released: u64,
287 pub rss_before_bytes: Option<u64>,
288 pub rss_after_bytes: Option<u64>,
289 pub allocator_before: AllocatorMemorySnapshot,
290 pub allocator_after: AllocatorMemorySnapshot,
291}
292
293impl ProcessMemorySnapshot {
294 pub fn from_roots(
295 roots: &BTreeMap<String, RootMemorySnapshot>,
296 shared_semantic_bases: &MemoryEstimate,
297 ) -> Self {
298 let rollups: Vec<_> = roots.values().map(RootMemorySnapshot::rollup).collect();
299 Self::from_root_rollups(rollups.iter(), roots.len(), shared_semantic_bases)
300 }
301
302 fn from_root_rollups<'a>(
303 roots: impl Iterator<Item = &'a RootMemoryRollup>,
304 root_count: usize,
305 shared_semantic_bases: &MemoryEstimate,
306 ) -> Self {
307 let mut root_attributed_bytes = 0u64;
308 let mut busy_subsystems = 0usize;
309 let mut not_estimated_subsystems = 0usize;
310 for root in roots {
311 root_attributed_bytes = root_attributed_bytes.saturating_add(root.attributed_bytes);
312 busy_subsystems = busy_subsystems.saturating_add(root.busy_subsystems);
313 not_estimated_subsystems =
314 not_estimated_subsystems.saturating_add(root.not_estimated_subsystems);
315 }
316 let sqlite = SqliteMemorySnapshot::measure();
317 let allocator = allocator_memory_snapshot();
318 let total_attributed_bytes = root_attributed_bytes
319 .saturating_add(shared_semantic_bases.estimated_bytes.unwrap_or(0))
320 .saturating_add(sqlite.memory_used_bytes);
321 let rss_bytes = process_rss_bytes();
322 let phys_footprint_bytes = process_phys_footprint_bytes();
323 let unattributed_basis = phys_footprint_bytes.or(rss_bytes);
327 let unattributed_bytes =
328 unattributed_basis.map(|held| signed_difference(held, total_attributed_bytes));
329 Self {
330 rss_status: if rss_bytes.is_some() {
331 "estimated"
332 } else {
333 "not_estimated_on_this_platform"
334 },
335 rss_bytes,
336 phys_footprint_bytes,
337 rss_not_estimated: rss_bytes
338 .is_none()
339 .then_some("platform_process_rss_unavailable"),
340 sqlite,
341 allocator,
342 total_attributed_bytes,
343 unattributed_bytes,
344 root_count,
345 busy_subsystems,
346 not_estimated_subsystems,
347 }
348 }
349}
350
351pub const MEMORY_SNAPSHOT_ROOT_DETAIL_CAP: usize = 8;
358
359#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
360pub struct MemorySnapshot {
361 pub roots_status: &'static str,
362 pub roots: BTreeMap<String, RootMemorySnapshot>,
366 pub roots_total: usize,
368 pub roots_omitted: usize,
370 pub roots_omitted_bytes: u64,
373 pub shared_semantic_bases: MemoryEstimate,
375 pub process: ProcessMemorySnapshot,
376}
377
378#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
379pub(crate) struct MemoryRollupSnapshot {
380 pub(crate) roots_status: &'static str,
381 pub(crate) roots: BTreeMap<String, RootMemoryRollup>,
382 pub(crate) roots_total: usize,
383 pub(crate) roots_omitted: usize,
384 pub(crate) roots_omitted_bytes: u64,
385 pub(crate) process: ProcessMemorySnapshot,
386}
387
388impl MemoryRollupSnapshot {
389 pub(crate) fn new(
390 roots_status: &'static str,
391 roots: BTreeMap<String, RootMemoryRollup>,
392 ) -> Self {
393 let shared_semantic_bases = crate::semantic_index::shared_semantic_bases_memory();
394 let process = ProcessMemorySnapshot::from_root_rollups(
395 roots.values(),
396 roots.len(),
397 &shared_semantic_bases,
398 );
399 let roots_total = roots.len();
400 let (roots, roots_omitted, roots_omitted_bytes) =
401 cap_root_rollups(roots, MEMORY_SNAPSHOT_ROOT_DETAIL_CAP);
402 Self {
403 roots_status,
404 roots,
405 roots_total,
406 roots_omitted,
407 roots_omitted_bytes,
408 process,
409 }
410 }
411}
412
413impl MemorySnapshot {
414 pub fn new(roots_status: &'static str, roots: BTreeMap<String, RootMemorySnapshot>) -> Self {
415 let shared_semantic_bases = crate::semantic_index::shared_semantic_bases_memory();
416 let process = ProcessMemorySnapshot::from_roots(&roots, &shared_semantic_bases);
418 let roots_total = roots.len();
419 let (roots, roots_omitted, roots_omitted_bytes) =
420 cap_root_detail(roots, MEMORY_SNAPSHOT_ROOT_DETAIL_CAP);
421 Self {
422 roots_status,
423 roots,
424 roots_total,
425 roots_omitted,
426 roots_omitted_bytes,
427 shared_semantic_bases,
428 process,
429 }
430 }
431}
432
433fn cap_roots<T>(
434 roots: BTreeMap<String, T>,
435 cap: usize,
436 attributed_bytes: impl Fn(&T) -> u64,
437) -> (BTreeMap<String, T>, usize, u64) {
438 if roots.len() <= cap {
439 return (roots, 0, 0);
440 }
441 let mut entries: Vec<_> = roots.into_iter().collect();
442 entries.sort_by(|a, b| {
443 attributed_bytes(&b.1)
444 .cmp(&attributed_bytes(&a.1))
445 .then_with(|| a.0.cmp(&b.0))
446 });
447 let omitted = entries.split_off(cap);
448 let omitted_bytes = omitted
449 .iter()
450 .map(|(_, snapshot)| attributed_bytes(snapshot))
451 .fold(0u64, u64::saturating_add);
452 (entries.into_iter().collect(), omitted.len(), omitted_bytes)
453}
454
455fn cap_root_rollups(
456 roots: BTreeMap<String, RootMemoryRollup>,
457 cap: usize,
458) -> (BTreeMap<String, RootMemoryRollup>, usize, u64) {
459 cap_roots(roots, cap, |snapshot| snapshot.attributed_bytes)
460}
461
462fn cap_root_detail(
465 roots: BTreeMap<String, RootMemorySnapshot>,
466 cap: usize,
467) -> (BTreeMap<String, RootMemorySnapshot>, usize, u64) {
468 cap_roots(roots, cap, |snapshot| snapshot.attributed_bytes)
469}
470
471#[cfg(test)]
472mod snapshot_cap_tests {
473 use super::*;
474
475 fn root_with_bytes(bytes: u64) -> RootMemorySnapshot {
476 let estimate = MemoryEstimate::estimated;
477 RootMemorySnapshot::new(
478 estimate(bytes),
479 estimate(0),
480 estimate(0),
481 estimate(0),
482 estimate(0),
483 estimate(0),
484 estimate(0),
485 estimate(0),
486 estimate(0),
487 )
488 }
489
490 #[test]
491 fn detail_map_capped_but_totals_cover_all_roots() {
492 let mut roots = BTreeMap::new();
493 for i in 0..(MEMORY_SNAPSHOT_ROOT_DETAIL_CAP + 4) {
494 roots.insert(
496 format!("/root/{i:02}"),
497 root_with_bytes((i as u64 + 1) * 1000),
498 );
499 }
500 let snapshot = MemorySnapshot::new("ready", roots);
501
502 assert_eq!(snapshot.roots.len(), MEMORY_SNAPSHOT_ROOT_DETAIL_CAP);
503 assert_eq!(snapshot.roots_total, MEMORY_SNAPSHOT_ROOT_DETAIL_CAP + 4);
504 assert_eq!(snapshot.roots_omitted, 4);
505 assert_eq!(snapshot.roots_omitted_bytes, 1000 + 2000 + 3000 + 4000);
507 assert!(snapshot
509 .roots
510 .values()
511 .all(|root| root.attributed_bytes > 4000));
512 let expected_total: u64 = (1..=(MEMORY_SNAPSHOT_ROOT_DETAIL_CAP as u64 + 4))
514 .map(|i| i * 1000)
515 .sum();
516 assert!(snapshot.process.total_attributed_bytes >= expected_total);
517 }
518
519 #[test]
520 fn rollup_cap_keeps_only_top_roots_while_totals_cover_all() {
521 let mut roots = BTreeMap::new();
522 for i in 0..(MEMORY_SNAPSHOT_ROOT_DETAIL_CAP + 4) {
523 roots.insert(
524 format!("/rollup/{i:02}"),
525 root_with_bytes((i as u64 + 1) * 1000).rollup(),
526 );
527 }
528 let snapshot = MemoryRollupSnapshot::new("ready", roots);
529
530 assert_eq!(snapshot.roots.len(), MEMORY_SNAPSHOT_ROOT_DETAIL_CAP);
531 assert_eq!(snapshot.roots_total, MEMORY_SNAPSHOT_ROOT_DETAIL_CAP + 4);
532 assert_eq!(snapshot.roots_omitted, 4);
533 assert_eq!(snapshot.roots_omitted_bytes, 1000 + 2000 + 3000 + 4000);
534 let expected_total: u64 = (1..=(MEMORY_SNAPSHOT_ROOT_DETAIL_CAP as u64 + 4))
535 .map(|i| i * 1000)
536 .sum();
537 assert!(snapshot.process.total_attributed_bytes >= expected_total);
538 }
539
540 #[test]
541 fn standing_rollup_uses_the_shared_top_eight_cap() {
542 let mut roots = BTreeMap::new();
543 for i in 0..=MEMORY_SNAPSHOT_ROOT_DETAIL_CAP {
544 let rollup = root_with_bytes((i as u64 + 1) * 1000).rollup();
545 roots.insert(format!("/root/{i:02}"), rollup);
546 }
547 roots.insert(
548 "standing-artifact-key".to_string(),
549 root_with_bytes(20_000).rollup().with_standing(),
550 );
551
552 let snapshot = MemoryRollupSnapshot::new("ready", roots);
553 assert_eq!(snapshot.roots.len(), MEMORY_SNAPSHOT_ROOT_DETAIL_CAP);
554 assert_eq!(snapshot.roots_total, MEMORY_SNAPSHOT_ROOT_DETAIL_CAP + 2);
555 assert_eq!(snapshot.roots_omitted, 2);
556 assert_eq!(
557 snapshot.roots["standing-artifact-key"].standing,
558 Some(true),
559 "standing memory stays in the ordinary per-root table"
560 );
561 }
562
563 #[test]
564 fn under_cap_keeps_everything_with_zero_omitted() {
565 let mut roots = BTreeMap::new();
566 roots.insert("/a".to_string(), root_with_bytes(10));
567 roots.insert("/b".to_string(), root_with_bytes(20));
568 let snapshot = MemorySnapshot::new("ready", roots);
569 assert_eq!(snapshot.roots.len(), 2);
570 assert_eq!(snapshot.roots_total, 2);
571 assert_eq!(snapshot.roots_omitted, 0);
572 assert_eq!(snapshot.roots_omitted_bytes, 0);
573 }
574}
575
576pub fn path_bytes(path: &Path) -> u64 {
577 #[cfg(unix)]
578 {
579 use std::os::unix::ffi::OsStrExt;
580 usize_to_u64(path.as_os_str().as_bytes().len())
581 }
582 #[cfg(windows)]
583 {
584 use std::os::windows::ffi::OsStrExt;
585 usize_to_u64(path.as_os_str().encode_wide().count())
586 .saturating_mul(std::mem::size_of::<u16>() as u64)
587 }
588 #[cfg(not(any(unix, windows)))]
589 {
590 usize_to_u64(path.to_string_lossy().len())
591 }
592}
593
594pub fn usize_to_u64(value: usize) -> u64 {
595 u64::try_from(value).unwrap_or(u64::MAX)
596}
597
598pub fn estimated_json_bytes(value: &Value) -> u64 {
599 match value {
600 Value::Null => 0,
601 Value::Bool(_) => std::mem::size_of::<bool>() as u64,
602 Value::Number(_) => std::mem::size_of::<serde_json::Number>() as u64,
603 Value::String(value) => usize_to_u64(value.len()),
604 Value::Array(values) => values
605 .iter()
606 .map(estimated_json_bytes)
607 .fold(0u64, u64::saturating_add),
608 Value::Object(values) => values.iter().fold(0u64, |bytes, (key, value)| {
609 bytes
610 .saturating_add(usize_to_u64(key.len()))
611 .saturating_add(estimated_json_bytes(value))
612 }),
613 }
614}
615
616fn signed_difference(lhs: u64, rhs: u64) -> i64 {
617 let difference = i128::from(lhs) - i128::from(rhs);
618 difference.clamp(i128::from(i64::MIN), i128::from(i64::MAX)) as i64
619}
620
621fn nonnegative_i64_to_u64(value: i64) -> u64 {
622 u64::try_from(value).unwrap_or(0)
623}
624
625#[cfg(target_os = "macos")]
626fn allocator_memory_snapshot() -> AllocatorMemorySnapshot {
627 let mut statistics = std::mem::MaybeUninit::<libc::malloc_statistics_t>::zeroed();
628 unsafe {
629 libc::malloc_zone_statistics(libc::malloc_default_zone(), statistics.as_mut_ptr());
630 }
631 let statistics = unsafe { statistics.assume_init() };
632 AllocatorMemorySnapshot::measured(
633 usize_to_u64(statistics.size_in_use),
634 usize_to_u64(statistics.size_allocated),
635 )
636}
637
638#[cfg(all(target_os = "linux", target_env = "gnu"))]
639fn allocator_memory_snapshot() -> AllocatorMemorySnapshot {
640 use std::sync::OnceLock;
646 type Mallinfo2Fn = unsafe extern "C" fn() -> libc::mallinfo2;
647 static MALLINFO2: OnceLock<Option<Mallinfo2Fn>> = OnceLock::new();
648 let resolved = MALLINFO2.get_or_init(|| {
649 let symbol = unsafe { libc::dlsym(libc::RTLD_DEFAULT, c"mallinfo2".as_ptr()) };
650 if symbol.is_null() {
651 None
652 } else {
653 Some(unsafe { std::mem::transmute::<*mut libc::c_void, Mallinfo2Fn>(symbol) })
656 }
657 });
658 let Some(mallinfo2) = resolved else {
659 return AllocatorMemorySnapshot::not_estimated("mallinfo2_requires_glibc_2_33");
660 };
661 let statistics = unsafe { mallinfo2() };
662 let mapped_bytes = statistics.hblkhd as u64;
663 let bytes_in_use = (statistics.uordblks as u64).saturating_add(mapped_bytes);
664 let size_allocated = (statistics.arena as u64).saturating_add(mapped_bytes);
665 AllocatorMemorySnapshot::measured(bytes_in_use, size_allocated)
666}
667
668#[cfg(all(target_os = "linux", target_env = "gnu"))]
669type MallocTrimFn = unsafe extern "C" fn(libc::size_t) -> libc::c_int;
670
671#[cfg(all(target_os = "linux", target_env = "gnu"))]
674fn resolved_malloc_trim() -> Option<MallocTrimFn> {
675 use std::sync::OnceLock;
676 static MALLOC_TRIM: OnceLock<Option<MallocTrimFn>> = OnceLock::new();
677 MALLOC_TRIM
678 .get_or_init(|| {
679 let symbol = unsafe { libc::dlsym(libc::RTLD_DEFAULT, c"malloc_trim".as_ptr()) };
680 if symbol.is_null() {
681 None
682 } else {
683 Some(unsafe { std::mem::transmute::<*mut libc::c_void, MallocTrimFn>(symbol) })
686 }
687 })
688 .as_ref()
689 .copied()
690}
691
692#[cfg(not(any(target_os = "macos", all(target_os = "linux", target_env = "gnu"))))]
693fn allocator_memory_snapshot() -> AllocatorMemorySnapshot {
694 AllocatorMemorySnapshot::not_estimated("platform_allocator_statistics_unavailable")
695}
696
697#[cfg(target_os = "macos")]
698unsafe extern "C" {
699 fn malloc_zone_pressure_relief(zone: *mut libc::malloc_zone_t, goal: usize) -> usize;
700}
701
702pub const ALLOCATOR_SLACK_RELIEF_THRESHOLD_BYTES: u64 = 1024 * 1024 * 1024;
705
706pub const ALLOCATOR_SLACK_RELIEF_MIN_INTERVAL: std::time::Duration =
709 std::time::Duration::from_secs(300);
710
711pub fn allocator_slack_relief_due(
717 retained_slack_bytes: Option<u64>,
718 last_relief: Option<std::time::Instant>,
719 now: std::time::Instant,
720) -> bool {
721 let Some(slack) = retained_slack_bytes else {
722 return false;
723 };
724 if slack < ALLOCATOR_SLACK_RELIEF_THRESHOLD_BYTES {
725 return false;
726 }
727 match last_relief {
728 None => true,
729 Some(at) => now.duration_since(at) >= ALLOCATOR_SLACK_RELIEF_MIN_INTERVAL,
730 }
731}
732
733#[cfg(any(target_os = "macos", target_os = "linux"))]
743pub fn spawn_allocator_slack_relief_if_due(
744 last_relief: Option<std::time::Instant>,
745 now: std::time::Instant,
746) -> bool {
747 let slack = allocator_memory_snapshot().retained_slack_bytes;
748 if !allocator_slack_relief_due(slack, last_relief, now) {
749 return false;
750 }
751 std::thread::Builder::new()
752 .name("aft-mem-relief".to_string())
753 .spawn(|| {
754 let relief = relieve_allocator_pressure();
755 log::info!(
756 "allocator slack relief: released={} allocator_slack_bytes_before={:?} allocator_slack_bytes_after={:?} rss_bytes_before={:?} rss_bytes_after={:?}",
757 relief.bytes_released,
758 relief.allocator_before.retained_slack_bytes,
759 relief.allocator_after.retained_slack_bytes,
760 relief.rss_before_bytes,
761 relief.rss_after_bytes,
762 );
763 })
764 .is_ok()
765}
766
767#[cfg(target_os = "macos")]
772pub fn relieve_allocator_pressure() -> AllocatorPressureRelief {
773 let rss_before_bytes = process_rss_bytes();
774 let allocator_before = allocator_memory_snapshot();
775 let bytes_released = unsafe { malloc_zone_pressure_relief(std::ptr::null_mut(), 0) };
776 let allocator_after = allocator_memory_snapshot();
777 let rss_after_bytes = process_rss_bytes();
778 AllocatorPressureRelief {
779 bytes_released: usize_to_u64(bytes_released),
780 rss_before_bytes,
781 rss_after_bytes,
782 allocator_before,
783 allocator_after,
784 }
785}
786
787#[cfg(target_os = "linux")]
788pub fn relieve_allocator_pressure() -> AllocatorPressureRelief {
789 let rss_before_bytes = process_rss_bytes();
790 let allocator_before = allocator_memory_snapshot();
791 #[cfg(target_env = "gnu")]
792 if let Some(malloc_trim) = resolved_malloc_trim() {
793 unsafe { malloc_trim(0) };
796 }
797 let allocator_after = allocator_memory_snapshot();
798 let rss_after_bytes = process_rss_bytes();
799 let bytes_released = allocator_before
800 .size_allocated
801 .zip(allocator_after.size_allocated)
802 .map(|(before, after)| before.saturating_sub(after))
803 .unwrap_or(0);
804 AllocatorPressureRelief {
805 bytes_released,
806 rss_before_bytes,
807 rss_after_bytes,
808 allocator_before,
809 allocator_after,
810 }
811}
812
813#[cfg(target_os = "macos")]
814fn process_rss_bytes() -> Option<u64> {
815 let mut info = std::mem::MaybeUninit::<libc::proc_taskinfo>::zeroed();
816 let size = std::mem::size_of::<libc::proc_taskinfo>();
817 let written = unsafe {
818 libc::proc_pidinfo(
819 libc::getpid(),
820 libc::PROC_PIDTASKINFO,
821 0,
822 info.as_mut_ptr().cast(),
823 i32::try_from(size).ok()?,
824 )
825 };
826 if written != i32::try_from(size).ok()? {
827 return None;
828 }
829 Some(unsafe { info.assume_init() }.pti_resident_size)
830}
831
832#[cfg(target_os = "macos")]
835fn process_phys_footprint_bytes() -> Option<u64> {
836 let mut usage = std::mem::MaybeUninit::<libc::rusage_info_v4>::zeroed();
837 let rc = unsafe {
838 libc::proc_pid_rusage(
839 libc::getpid(),
840 libc::RUSAGE_INFO_V4,
841 usage.as_mut_ptr().cast(),
842 )
843 };
844 if rc != 0 {
845 return None;
846 }
847 Some(unsafe { usage.assume_init() }.ri_phys_footprint)
848}
849
850#[cfg(not(target_os = "macos"))]
851fn process_phys_footprint_bytes() -> Option<u64> {
852 None
853}
854
855#[cfg(target_os = "linux")]
856fn process_rss_bytes() -> Option<u64> {
857 let statm = std::fs::read_to_string("/proc/self/statm").ok()?;
858 let resident_pages = statm.split_whitespace().nth(1)?.parse::<u64>().ok()?;
859 let page_size = unsafe { libc::sysconf(libc::_SC_PAGESIZE) };
860 if page_size <= 0 {
861 return None;
862 }
863 resident_pages.checked_mul(page_size as u64)
864}
865
866#[cfg(not(any(target_os = "macos", target_os = "linux")))]
867fn process_rss_bytes() -> Option<u64> {
868 None
869}
870
871#[cfg(test)]
872mod tests {
873 use super::*;
874
875 #[test]
876 fn process_snapshot_preserves_negative_residuals() {
877 assert_eq!(signed_difference(5, 8), -3);
878 }
879
880 #[test]
881 fn slack_relief_fires_on_large_slack_and_respects_spacing() {
882 use std::time::{Duration, Instant};
883 let now = Instant::now();
884 let big = Some(ALLOCATOR_SLACK_RELIEF_THRESHOLD_BYTES);
885 assert!(!allocator_slack_relief_due(None, None, now));
887 assert!(!allocator_slack_relief_due(
889 Some(ALLOCATOR_SLACK_RELIEF_THRESHOLD_BYTES - 1),
890 None,
891 now
892 ));
893 assert!(allocator_slack_relief_due(big, None, now));
895 let recent = now - Duration::from_secs(10);
897 assert!(!allocator_slack_relief_due(big, Some(recent), now));
898 let stale = now - ALLOCATOR_SLACK_RELIEF_MIN_INTERVAL;
900 assert!(allocator_slack_relief_due(big, Some(stale), now));
901 }
902
903 #[test]
904 fn json_estimator_scales_with_payload_content() {
905 let empty = estimated_json_bytes(&serde_json::json!({}));
906 let populated = estimated_json_bytes(&serde_json::json!({"message": "hello"}));
907 assert_eq!(empty, 0);
908 assert!(populated >= 12);
909 }
910
911 #[test]
912 fn process_snapshot_exposes_sqlite_and_allocator_sections() {
913 let shared = MemoryEstimate::estimated(7);
914 let snapshot = ProcessMemorySnapshot::from_roots(&BTreeMap::new(), &shared);
915 assert_eq!(snapshot.sqlite.status, "measured");
916 assert!(snapshot.sqlite.memory_highwater_bytes >= snapshot.sqlite.memory_used_bytes);
917 assert_eq!(
918 snapshot.total_attributed_bytes,
919 snapshot.sqlite.memory_used_bytes.saturating_add(7)
920 );
921
922 let serialized = serde_json::to_value(&snapshot).expect("serialize process memory");
923 assert!(serialized["sqlite"]["memory_used_bytes"].is_u64());
924 assert!(serialized["allocator"].get("bytes_in_use").is_some());
925 assert!(serialized["allocator"].get("size_allocated").is_some());
926 assert!(serialized["allocator"]
927 .get("retained_slack_bytes")
928 .is_some());
929 }
930
931 #[cfg(any(target_os = "macos", all(target_os = "linux", target_env = "gnu")))]
932 #[test]
933 fn allocator_snapshot_reports_measured_slack() {
934 let allocator = allocator_memory_snapshot();
935 if allocator.status == "measured" {
936 let in_use = allocator.bytes_in_use.expect("allocator bytes in use");
937 let allocated = allocator.size_allocated.expect("allocator size allocated");
938 assert_eq!(
939 allocator.retained_slack_bytes,
940 Some(allocated.saturating_sub(in_use))
941 );
942 } else {
943 assert_eq!(allocator.status, "not_estimated_on_this_platform");
944 assert_eq!(allocator.bytes_in_use, None);
945 assert_eq!(allocator.size_allocated, None);
946 assert_eq!(allocator.retained_slack_bytes, None);
947 assert_eq!(
948 allocator.not_estimated,
949 Some("mallinfo2_requires_glibc_2_33")
950 );
951 }
952 }
953
954 #[cfg(target_os = "linux")]
955 #[test]
956 fn linux_allocator_pressure_relief_smoke() {
957 let mut allocation = vec![0u8; 32 * 1024 * 1024];
958 for byte in allocation.iter_mut().step_by(4096) {
959 *byte = 1;
960 }
961 std::hint::black_box(&allocation);
962 drop(allocation);
963
964 let relief = relieve_allocator_pressure();
965 std::hint::black_box(relief);
966
967 #[cfg(target_env = "gnu")]
968 assert!(
969 resolved_malloc_trim().is_some(),
970 "glibc malloc_trim must be available for the Linux relief path"
971 );
972 }
973
974 #[cfg(not(any(target_os = "macos", all(target_os = "linux", target_env = "gnu"))))]
975 #[test]
976 fn allocator_snapshot_is_honest_when_platform_counters_are_unavailable() {
977 let allocator = allocator_memory_snapshot();
978 assert_eq!(allocator.status, "not_estimated_on_this_platform");
979 assert_eq!(allocator.bytes_in_use, None);
980 assert_eq!(allocator.size_allocated, None);
981 assert_eq!(allocator.retained_slack_bytes, None);
982 assert_eq!(
983 allocator.not_estimated,
984 Some("platform_allocator_statistics_unavailable")
985 );
986 }
987
988 #[cfg(target_os = "macos")]
989 #[test]
990 #[ignore = "bounded live RSS experiment; run explicitly after allocator changes"]
991 fn allocator_pressure_relief_warm_then_idle_measurement() {
992 let warm_pages = (0..16 * 1024)
993 .map(|seed| {
994 let mut page = Box::new([0u8; 4096]);
995 page[0] = seed as u8;
996 page
997 })
998 .collect::<Vec<_>>();
999 std::hint::black_box(&warm_pages);
1000 drop(warm_pages);
1001
1002 let relief = relieve_allocator_pressure();
1003 let sqlite = SqliteMemorySnapshot::measure();
1004 eprintln!(
1005 "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={}",
1006 relief.rss_before_bytes,
1007 relief.rss_after_bytes,
1008 relief.allocator_before.bytes_in_use,
1009 relief.allocator_after.bytes_in_use,
1010 relief.allocator_before.size_allocated,
1011 relief.allocator_after.size_allocated,
1012 relief.allocator_before.retained_slack_bytes,
1013 relief.allocator_after.retained_slack_bytes,
1014 relief.bytes_released,
1015 sqlite.memory_used_bytes,
1016 sqlite.memory_highwater_bytes,
1017 );
1018 assert_eq!(relief.allocator_before.status, "measured");
1019 assert_eq!(relief.allocator_after.status, "measured");
1020 }
1021}