Skip to main content

aft/
memory.rs

1use std::collections::BTreeMap;
2use std::path::Path;
3
4use serde::Serialize;
5use serde_json::Value;
6
7/// A cold-path estimate of memory AFT can attribute without allocator hooks.
8///
9/// `estimated_bytes` is `None` when a subsystem is busy or its resident bytes
10/// are not cheaply observable. Counts remain available in those cases so the
11/// status response never substitutes a fabricated byte estimate.
12#[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 inspect: MemoryEstimate,
89    pub bash: MemoryEstimate,
90    pub lsp: MemoryEstimate,
91    pub parser_pool: MemoryEstimate,
92}
93
94impl RootMemorySnapshot {
95    pub fn new(
96        semantic: MemoryEstimate,
97        trigram: MemoryEstimate,
98        symbols: MemoryEstimate,
99        callgraph: MemoryEstimate,
100        inspect: MemoryEstimate,
101        bash: MemoryEstimate,
102        lsp: MemoryEstimate,
103        parser_pool: MemoryEstimate,
104    ) -> Self {
105        let estimates = [
106            &semantic,
107            &trigram,
108            &symbols,
109            &callgraph,
110            &inspect,
111            &bash,
112            &lsp,
113            &parser_pool,
114        ];
115        let attributed_bytes = estimates
116            .iter()
117            .filter_map(|estimate| estimate.estimated_bytes)
118            .fold(0u64, u64::saturating_add);
119        let status = if estimates.iter().any(|estimate| estimate.status == "busy") {
120            "busy"
121        } else {
122            "ready"
123        };
124        Self {
125            status,
126            attributed_bytes,
127            semantic,
128            trigram,
129            symbols,
130            callgraph,
131            inspect,
132            bash,
133            lsp,
134            parser_pool,
135        }
136    }
137
138    pub fn busy_subsystem_count(&self) -> usize {
139        self.estimates()
140            .iter()
141            .filter(|estimate| estimate.status == "busy")
142            .count()
143    }
144
145    pub fn not_estimated_subsystem_count(&self) -> usize {
146        self.estimates()
147            .iter()
148            .filter(|estimate| estimate.estimated_bytes.is_none())
149            .count()
150    }
151
152    fn estimates(&self) -> [&MemoryEstimate; 8] {
153        [
154            &self.semantic,
155            &self.trigram,
156            &self.symbols,
157            &self.callgraph,
158            &self.inspect,
159            &self.bash,
160            &self.lsp,
161            &self.parser_pool,
162        ]
163    }
164}
165
166#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
167pub struct SqliteMemorySnapshot {
168    pub status: &'static str,
169    pub memory_used_bytes: u64,
170    pub memory_highwater_bytes: u64,
171}
172
173impl SqliteMemorySnapshot {
174    fn measure() -> Self {
175        // SQLite's allocator counters are process-wide and internally synchronized.
176        // They intentionally replace per-connection guesses in root estimates.
177        let memory_used = unsafe { rusqlite::ffi::sqlite3_memory_used() };
178        let memory_highwater = unsafe { rusqlite::ffi::sqlite3_memory_highwater(0) };
179        Self {
180            status: "measured",
181            memory_used_bytes: nonnegative_i64_to_u64(memory_used),
182            memory_highwater_bytes: nonnegative_i64_to_u64(memory_highwater),
183        }
184    }
185}
186
187#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
188pub struct AllocatorMemorySnapshot {
189    pub status: &'static str,
190    pub bytes_in_use: Option<u64>,
191    pub size_allocated: Option<u64>,
192    pub retained_slack_bytes: Option<u64>,
193    #[serde(skip_serializing_if = "Option::is_none")]
194    pub not_estimated: Option<&'static str>,
195}
196
197impl AllocatorMemorySnapshot {
198    #[cfg(any(target_os = "macos", all(target_os = "linux", target_env = "gnu")))]
199    fn measured(bytes_in_use: u64, size_allocated: u64) -> Self {
200        Self {
201            status: "measured",
202            bytes_in_use: Some(bytes_in_use),
203            size_allocated: Some(size_allocated),
204            retained_slack_bytes: Some(size_allocated.saturating_sub(bytes_in_use)),
205            not_estimated: None,
206        }
207    }
208
209    // Not cfg-gated to the fallback platforms: linux-gnu also uses this at
210    // RUNTIME when the host glibc predates mallinfo2 (< 2.33), which only
211    // manifests on release binaries built against an old glibc floor.
212    #[cfg_attr(target_os = "macos", allow(dead_code))]
213    fn not_estimated(reason: &'static str) -> Self {
214        Self {
215            status: "not_estimated_on_this_platform",
216            bytes_in_use: None,
217            size_allocated: None,
218            retained_slack_bytes: None,
219            not_estimated: Some(reason),
220        }
221    }
222}
223
224#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
225pub struct ProcessMemorySnapshot {
226    pub rss_status: &'static str,
227    pub rss_bytes: Option<u64>,
228    /// Kernel physical footprint (macOS `phys_footprint`): dirty + compressed
229    /// + IOKit pages, excluding clean/reclaimable ones. This is the number
230    /// Activity Monitor's "Real Memory" and the OOM killer use. RSS counts
231    /// MADV_FREE pages the allocator has already surrendered (the kernel
232    /// reclaims them lazily), so RSS can read gigabytes above what the
233    /// process actually holds — observed 5.1 GB RSS over a 610 MB footprint.
234    /// None on non-macOS platforms (Linux RSS does not have this skew;
235    /// MADV_FREE'd pages leave Linux RSS on reclaim, not on advice).
236    #[serde(skip_serializing_if = "Option::is_none")]
237    pub phys_footprint_bytes: Option<u64>,
238    #[serde(skip_serializing_if = "Option::is_none")]
239    pub rss_not_estimated: Option<&'static str>,
240    pub sqlite: SqliteMemorySnapshot,
241    /// Allocator bytes overlap the attributed subsystem totals and are an
242    /// allocation envelope, not another amount to subtract from RSS.
243    pub allocator: AllocatorMemorySnapshot,
244    pub total_attributed_bytes: u64,
245    pub unattributed_bytes: Option<i64>,
246    pub root_count: usize,
247    pub busy_subsystems: usize,
248    pub not_estimated_subsystems: usize,
249}
250
251#[derive(Debug, Clone, PartialEq, Eq)]
252pub struct AllocatorPressureRelief {
253    pub bytes_released: u64,
254    pub rss_before_bytes: Option<u64>,
255    pub rss_after_bytes: Option<u64>,
256    pub allocator_before: AllocatorMemorySnapshot,
257    pub allocator_after: AllocatorMemorySnapshot,
258}
259
260impl ProcessMemorySnapshot {
261    pub fn from_roots(
262        roots: &BTreeMap<String, RootMemorySnapshot>,
263        shared_semantic_bases: &MemoryEstimate,
264    ) -> Self {
265        let sqlite = SqliteMemorySnapshot::measure();
266        let allocator = allocator_memory_snapshot();
267        let total_attributed_bytes = roots
268            .values()
269            .map(|root| root.attributed_bytes)
270            .fold(0u64, u64::saturating_add)
271            .saturating_add(shared_semantic_bases.estimated_bytes.unwrap_or(0))
272            .saturating_add(sqlite.memory_used_bytes);
273        let busy_subsystems = roots
274            .values()
275            .map(RootMemorySnapshot::busy_subsystem_count)
276            .sum();
277        let not_estimated_subsystems = roots
278            .values()
279            .map(RootMemorySnapshot::not_estimated_subsystem_count)
280            .sum();
281        let rss_bytes = process_rss_bytes();
282        let phys_footprint_bytes = process_phys_footprint_bytes();
283        // Attribute against the footprint when available: it excludes
284        // already-surrendered pages, so the residual actually means
285        // "held memory we cannot explain" instead of allocator noise.
286        let unattributed_basis = phys_footprint_bytes.or(rss_bytes);
287        let unattributed_bytes =
288            unattributed_basis.map(|held| signed_difference(held, total_attributed_bytes));
289        Self {
290            rss_status: if rss_bytes.is_some() {
291                "estimated"
292            } else {
293                "not_estimated_on_this_platform"
294            },
295            rss_bytes,
296            phys_footprint_bytes,
297            rss_not_estimated: rss_bytes
298                .is_none()
299                .then_some("platform_process_rss_unavailable"),
300            sqlite,
301            allocator,
302            total_attributed_bytes,
303            unattributed_bytes,
304            root_count: roots.len(),
305            busy_subsystems,
306            not_estimated_subsystems,
307        }
308    }
309}
310
311/// Cap on per-root detail entries in serialized snapshots. Process totals
312/// always cover every root; only the per-root breakdown is capped so a
313/// many-root daemon process cannot balloon the status payload past
314/// downstream consumers' size limits (the daemon metrics cache truncates
315/// around 27 KB, and JSON keys serialize alphabetically, so an oversized
316/// `memory.roots` map pushes later sections past the cut).
317pub const MEMORY_SNAPSHOT_ROOT_DETAIL_CAP: usize = 8;
318
319#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
320pub struct MemorySnapshot {
321    pub roots_status: &'static str,
322    /// Top roots by attributed bytes, capped at
323    /// [`MEMORY_SNAPSHOT_ROOT_DETAIL_CAP`]; the remainder is summarized by
324    /// `roots_omitted` / `roots_omitted_bytes`.
325    pub roots: BTreeMap<String, RootMemorySnapshot>,
326    /// Total roots attributed (including omitted ones).
327    pub roots_total: usize,
328    /// Roots summarized out of the detail map.
329    pub roots_omitted: usize,
330    /// Attributed bytes carried by the omitted roots (already included in
331    /// `process.total_attributed_bytes`).
332    pub roots_omitted_bytes: u64,
333    /// Immutable borrowed semantic snapshots, attributed once process-wide.
334    pub shared_semantic_bases: MemoryEstimate,
335    pub process: ProcessMemorySnapshot,
336}
337
338impl MemorySnapshot {
339    pub fn new(roots_status: &'static str, roots: BTreeMap<String, RootMemorySnapshot>) -> Self {
340        let shared_semantic_bases = crate::semantic_index::shared_semantic_bases_memory();
341        // Totals cover EVERY root before the detail map is capped.
342        let process = ProcessMemorySnapshot::from_roots(&roots, &shared_semantic_bases);
343        let roots_total = roots.len();
344        let (roots, roots_omitted, roots_omitted_bytes) =
345            cap_root_detail(roots, MEMORY_SNAPSHOT_ROOT_DETAIL_CAP);
346        Self {
347            roots_status,
348            roots,
349            roots_total,
350            roots_omitted,
351            roots_omitted_bytes,
352            shared_semantic_bases,
353            process,
354        }
355    }
356}
357
358/// Keep the `cap` roots with the highest attributed bytes; report the rest
359/// as an omitted-count + omitted-bytes rollup.
360fn cap_root_detail(
361    roots: BTreeMap<String, RootMemorySnapshot>,
362    cap: usize,
363) -> (BTreeMap<String, RootMemorySnapshot>, usize, u64) {
364    if roots.len() <= cap {
365        return (roots, 0, 0);
366    }
367    let mut entries: Vec<(String, RootMemorySnapshot)> = roots.into_iter().collect();
368    // Sort by attributed bytes descending; ties keep path order for determinism.
369    entries.sort_by(|a, b| {
370        b.1.attributed_bytes
371            .cmp(&a.1.attributed_bytes)
372            .then_with(|| a.0.cmp(&b.0))
373    });
374    let omitted: Vec<(String, RootMemorySnapshot)> = entries.split_off(cap);
375    let omitted_bytes = omitted
376        .iter()
377        .map(|(_, snapshot)| snapshot.attributed_bytes)
378        .fold(0u64, u64::saturating_add);
379    (entries.into_iter().collect(), omitted.len(), omitted_bytes)
380}
381
382#[cfg(test)]
383mod snapshot_cap_tests {
384    use super::*;
385
386    fn root_with_bytes(bytes: u64) -> RootMemorySnapshot {
387        let estimate = MemoryEstimate::estimated;
388        RootMemorySnapshot::new(
389            estimate(bytes),
390            estimate(0),
391            estimate(0),
392            estimate(0),
393            estimate(0),
394            estimate(0),
395            estimate(0),
396            estimate(0),
397        )
398    }
399
400    #[test]
401    fn detail_map_capped_but_totals_cover_all_roots() {
402        let mut roots = BTreeMap::new();
403        for i in 0..(MEMORY_SNAPSHOT_ROOT_DETAIL_CAP + 4) {
404            // Distinct sizes so the kept set is deterministic: later roots larger.
405            roots.insert(
406                format!("/root/{i:02}"),
407                root_with_bytes((i as u64 + 1) * 1000),
408            );
409        }
410        let snapshot = MemorySnapshot::new("ready", roots);
411
412        assert_eq!(snapshot.roots.len(), MEMORY_SNAPSHOT_ROOT_DETAIL_CAP);
413        assert_eq!(snapshot.roots_total, MEMORY_SNAPSHOT_ROOT_DETAIL_CAP + 4);
414        assert_eq!(snapshot.roots_omitted, 4);
415        // The four smallest (1000..=4000) are the omitted ones.
416        assert_eq!(snapshot.roots_omitted_bytes, 1000 + 2000 + 3000 + 4000);
417        // Largest roots are the ones kept.
418        assert!(snapshot
419            .roots
420            .values()
421            .all(|root| root.attributed_bytes > 4000));
422        // Process totals include omitted roots' bytes.
423        let expected_total: u64 = (1..=(MEMORY_SNAPSHOT_ROOT_DETAIL_CAP as u64 + 4))
424            .map(|i| i * 1000)
425            .sum();
426        assert!(snapshot.process.total_attributed_bytes >= expected_total);
427    }
428
429    #[test]
430    fn under_cap_keeps_everything_with_zero_omitted() {
431        let mut roots = BTreeMap::new();
432        roots.insert("/a".to_string(), root_with_bytes(10));
433        roots.insert("/b".to_string(), root_with_bytes(20));
434        let snapshot = MemorySnapshot::new("ready", roots);
435        assert_eq!(snapshot.roots.len(), 2);
436        assert_eq!(snapshot.roots_total, 2);
437        assert_eq!(snapshot.roots_omitted, 0);
438        assert_eq!(snapshot.roots_omitted_bytes, 0);
439    }
440}
441
442pub fn path_bytes(path: &Path) -> u64 {
443    #[cfg(unix)]
444    {
445        use std::os::unix::ffi::OsStrExt;
446        usize_to_u64(path.as_os_str().as_bytes().len())
447    }
448    #[cfg(windows)]
449    {
450        use std::os::windows::ffi::OsStrExt;
451        usize_to_u64(path.as_os_str().encode_wide().count())
452            .saturating_mul(std::mem::size_of::<u16>() as u64)
453    }
454    #[cfg(not(any(unix, windows)))]
455    {
456        usize_to_u64(path.to_string_lossy().len())
457    }
458}
459
460pub fn usize_to_u64(value: usize) -> u64 {
461    u64::try_from(value).unwrap_or(u64::MAX)
462}
463
464pub fn estimated_json_bytes(value: &Value) -> u64 {
465    match value {
466        Value::Null => 0,
467        Value::Bool(_) => std::mem::size_of::<bool>() as u64,
468        Value::Number(_) => std::mem::size_of::<serde_json::Number>() as u64,
469        Value::String(value) => usize_to_u64(value.len()),
470        Value::Array(values) => values
471            .iter()
472            .map(estimated_json_bytes)
473            .fold(0u64, u64::saturating_add),
474        Value::Object(values) => values.iter().fold(0u64, |bytes, (key, value)| {
475            bytes
476                .saturating_add(usize_to_u64(key.len()))
477                .saturating_add(estimated_json_bytes(value))
478        }),
479    }
480}
481
482fn signed_difference(lhs: u64, rhs: u64) -> i64 {
483    let difference = i128::from(lhs) - i128::from(rhs);
484    difference.clamp(i128::from(i64::MIN), i128::from(i64::MAX)) as i64
485}
486
487fn nonnegative_i64_to_u64(value: i64) -> u64 {
488    u64::try_from(value).unwrap_or(0)
489}
490
491#[cfg(target_os = "macos")]
492fn allocator_memory_snapshot() -> AllocatorMemorySnapshot {
493    let mut statistics = std::mem::MaybeUninit::<libc::malloc_statistics_t>::zeroed();
494    unsafe {
495        libc::malloc_zone_statistics(libc::malloc_default_zone(), statistics.as_mut_ptr());
496    }
497    let statistics = unsafe { statistics.assume_init() };
498    AllocatorMemorySnapshot::measured(
499        usize_to_u64(statistics.size_in_use),
500        usize_to_u64(statistics.size_allocated),
501    )
502}
503
504#[cfg(all(target_os = "linux", target_env = "gnu"))]
505fn allocator_memory_snapshot() -> AllocatorMemorySnapshot {
506    // mallinfo2 exists only in glibc >= 2.33. Release Linux binaries link
507    // against an older glibc floor (cross gnu images, kept old so dlopen and
508    // wide distro compatibility hold), so a link-time reference to the symbol
509    // fails the release build even though native CI (glibc 2.35) links fine.
510    // Resolve it at runtime instead and report honestly when it is absent.
511    use std::sync::OnceLock;
512    type Mallinfo2Fn = unsafe extern "C" fn() -> libc::mallinfo2;
513    static MALLINFO2: OnceLock<Option<Mallinfo2Fn>> = OnceLock::new();
514    let resolved = MALLINFO2.get_or_init(|| {
515        let symbol = unsafe { libc::dlsym(libc::RTLD_DEFAULT, c"mallinfo2".as_ptr()) };
516        if symbol.is_null() {
517            None
518        } else {
519            // SAFETY: glibc declares mallinfo2 as `struct mallinfo2 (*)(void)`;
520            // the signature matches Mallinfo2Fn exactly.
521            Some(unsafe { std::mem::transmute::<*mut libc::c_void, Mallinfo2Fn>(symbol) })
522        }
523    });
524    let Some(mallinfo2) = resolved else {
525        return AllocatorMemorySnapshot::not_estimated("mallinfo2_requires_glibc_2_33");
526    };
527    let statistics = unsafe { mallinfo2() };
528    let mapped_bytes = statistics.hblkhd as u64;
529    let bytes_in_use = (statistics.uordblks as u64).saturating_add(mapped_bytes);
530    let size_allocated = (statistics.arena as u64).saturating_add(mapped_bytes);
531    AllocatorMemorySnapshot::measured(bytes_in_use, size_allocated)
532}
533
534#[cfg(not(any(target_os = "macos", all(target_os = "linux", target_env = "gnu"))))]
535fn allocator_memory_snapshot() -> AllocatorMemorySnapshot {
536    AllocatorMemorySnapshot::not_estimated("platform_allocator_statistics_unavailable")
537}
538
539#[cfg(target_os = "macos")]
540unsafe extern "C" {
541    fn malloc_zone_pressure_relief(zone: *mut libc::malloc_zone_t, goal: usize) -> usize;
542}
543
544/// Allocator slack (mapped-but-unused arena bytes) above which opportunistic
545/// pressure relief is worth the zone-lock contention it briefly causes.
546pub const ALLOCATOR_SLACK_RELIEF_THRESHOLD_BYTES: u64 = 1024 * 1024 * 1024;
547
548/// Minimum spacing between opportunistic relief passes so a workload that
549/// legitimately cycles through large allocations does not thrash the allocator.
550pub const ALLOCATOR_SLACK_RELIEF_MIN_INTERVAL: std::time::Duration =
551    std::time::Duration::from_secs(300);
552
553/// Decide whether an opportunistic allocator relief pass is due.
554///
555/// Pure so the policy is unit-testable: fires only when the allocator reports
556/// at least the threshold of retained slack AND the previous pass is old
557/// enough. Callers own actually measuring the snapshot and running the pass.
558pub fn allocator_slack_relief_due(
559    retained_slack_bytes: Option<u64>,
560    last_relief: Option<std::time::Instant>,
561    now: std::time::Instant,
562) -> bool {
563    let Some(slack) = retained_slack_bytes else {
564        return false;
565    };
566    if slack < ALLOCATOR_SLACK_RELIEF_THRESHOLD_BYTES {
567        return false;
568    }
569    match last_relief {
570        None => true,
571        Some(at) => now.duration_since(at) >= ALLOCATOR_SLACK_RELIEF_MIN_INTERVAL,
572    }
573}
574
575/// Opportunistically return unused allocator pages when slack is large, even
576/// while sessions are active. The whole-process idle sweep only fires when
577/// every root has been quiet, so one long-lived chatty session used to block
578/// reclamation for the process lifetime (observed: 5.1 GB RSS over ~600 MB of
579/// live data). Runs the relief on a detached thread because
580/// `malloc_zone_pressure_relief` walks every zone under its lock and must not
581/// stall the dispatch loop or health probes.
582///
583/// Returns true when a pass was spawned (caller records the timestamp).
584#[cfg(target_os = "macos")]
585pub fn spawn_allocator_slack_relief_if_due(
586    last_relief: Option<std::time::Instant>,
587    now: std::time::Instant,
588) -> bool {
589    let slack = allocator_memory_snapshot().retained_slack_bytes;
590    if !allocator_slack_relief_due(slack, last_relief, now) {
591        return false;
592    }
593    std::thread::Builder::new()
594        .name("aft-mem-relief".to_string())
595        .spawn(|| {
596            let relief = relieve_allocator_pressure();
597            log::info!(
598                "allocator slack relief: released={} rss {} -> {}",
599                relief.bytes_released,
600                relief
601                    .rss_before_bytes
602                    .map(|b| b.to_string())
603                    .unwrap_or_else(|| "?".to_string()),
604                relief
605                    .rss_after_bytes
606                    .map(|b| b.to_string())
607                    .unwrap_or_else(|| "?".to_string()),
608            );
609        })
610        .is_ok()
611}
612
613/// Ask the macOS allocator to return unused pages after a process-wide idle gate.
614/// Callers own that gate because allocator pressure relief can add latency.
615#[cfg(target_os = "macos")]
616pub fn relieve_allocator_pressure() -> AllocatorPressureRelief {
617    let rss_before_bytes = process_rss_bytes();
618    let allocator_before = allocator_memory_snapshot();
619    let bytes_released = unsafe { malloc_zone_pressure_relief(std::ptr::null_mut(), 0) };
620    let allocator_after = allocator_memory_snapshot();
621    let rss_after_bytes = process_rss_bytes();
622    AllocatorPressureRelief {
623        bytes_released: usize_to_u64(bytes_released),
624        rss_before_bytes,
625        rss_after_bytes,
626        allocator_before,
627        allocator_after,
628    }
629}
630
631#[cfg(target_os = "macos")]
632fn process_rss_bytes() -> Option<u64> {
633    let mut info = std::mem::MaybeUninit::<libc::proc_taskinfo>::zeroed();
634    let size = std::mem::size_of::<libc::proc_taskinfo>();
635    let written = unsafe {
636        libc::proc_pidinfo(
637            libc::getpid(),
638            libc::PROC_PIDTASKINFO,
639            0,
640            info.as_mut_ptr().cast(),
641            i32::try_from(size).ok()?,
642        )
643    };
644    if written != i32::try_from(size).ok()? {
645        return None;
646    }
647    Some(unsafe { info.assume_init() }.pti_resident_size)
648}
649
650/// Kernel physical footprint via `proc_pid_rusage` (`ri_phys_footprint`).
651/// See `phys_footprint_bytes` for why this, not RSS, is the headline number.
652#[cfg(target_os = "macos")]
653fn process_phys_footprint_bytes() -> Option<u64> {
654    let mut usage = std::mem::MaybeUninit::<libc::rusage_info_v4>::zeroed();
655    let rc = unsafe {
656        libc::proc_pid_rusage(
657            libc::getpid(),
658            libc::RUSAGE_INFO_V4,
659            usage.as_mut_ptr().cast(),
660        )
661    };
662    if rc != 0 {
663        return None;
664    }
665    Some(unsafe { usage.assume_init() }.ri_phys_footprint)
666}
667
668#[cfg(not(target_os = "macos"))]
669fn process_phys_footprint_bytes() -> Option<u64> {
670    None
671}
672
673#[cfg(target_os = "linux")]
674fn process_rss_bytes() -> Option<u64> {
675    let statm = std::fs::read_to_string("/proc/self/statm").ok()?;
676    let resident_pages = statm.split_whitespace().nth(1)?.parse::<u64>().ok()?;
677    let page_size = unsafe { libc::sysconf(libc::_SC_PAGESIZE) };
678    if page_size <= 0 {
679        return None;
680    }
681    resident_pages.checked_mul(page_size as u64)
682}
683
684#[cfg(not(any(target_os = "macos", target_os = "linux")))]
685fn process_rss_bytes() -> Option<u64> {
686    None
687}
688
689#[cfg(test)]
690mod tests {
691    use super::*;
692
693    #[test]
694    fn process_snapshot_preserves_negative_residuals() {
695        assert_eq!(signed_difference(5, 8), -3);
696    }
697
698    #[test]
699    fn slack_relief_fires_on_large_slack_and_respects_spacing() {
700        use std::time::{Duration, Instant};
701        let now = Instant::now();
702        let big = Some(ALLOCATOR_SLACK_RELIEF_THRESHOLD_BYTES);
703        // Unknown slack (allocator stats unavailable) never fires.
704        assert!(!allocator_slack_relief_due(None, None, now));
705        // Below threshold never fires.
706        assert!(!allocator_slack_relief_due(
707            Some(ALLOCATOR_SLACK_RELIEF_THRESHOLD_BYTES - 1),
708            None,
709            now
710        ));
711        // At threshold with no prior pass fires.
712        assert!(allocator_slack_relief_due(big, None, now));
713        // A recent pass suppresses the next one...
714        let recent = now - Duration::from_secs(10);
715        assert!(!allocator_slack_relief_due(big, Some(recent), now));
716        // ...until the minimum spacing has elapsed.
717        let stale = now - ALLOCATOR_SLACK_RELIEF_MIN_INTERVAL;
718        assert!(allocator_slack_relief_due(big, Some(stale), now));
719    }
720
721    #[test]
722    fn json_estimator_scales_with_payload_content() {
723        let empty = estimated_json_bytes(&serde_json::json!({}));
724        let populated = estimated_json_bytes(&serde_json::json!({"message": "hello"}));
725        assert_eq!(empty, 0);
726        assert!(populated >= 12);
727    }
728
729    #[test]
730    fn process_snapshot_exposes_sqlite_and_allocator_sections() {
731        let shared = MemoryEstimate::estimated(7);
732        let snapshot = ProcessMemorySnapshot::from_roots(&BTreeMap::new(), &shared);
733        assert_eq!(snapshot.sqlite.status, "measured");
734        assert!(snapshot.sqlite.memory_highwater_bytes >= snapshot.sqlite.memory_used_bytes);
735        assert_eq!(
736            snapshot.total_attributed_bytes,
737            snapshot.sqlite.memory_used_bytes.saturating_add(7)
738        );
739
740        let serialized = serde_json::to_value(&snapshot).expect("serialize process memory");
741        assert!(serialized["sqlite"]["memory_used_bytes"].is_u64());
742        assert!(serialized["allocator"].get("bytes_in_use").is_some());
743        assert!(serialized["allocator"].get("size_allocated").is_some());
744        assert!(serialized["allocator"]
745            .get("retained_slack_bytes")
746            .is_some());
747    }
748
749    #[cfg(any(target_os = "macos", all(target_os = "linux", target_env = "gnu")))]
750    #[test]
751    fn allocator_snapshot_reports_measured_slack() {
752        let allocator = allocator_memory_snapshot();
753        assert_eq!(allocator.status, "measured");
754        let in_use = allocator.bytes_in_use.expect("allocator bytes in use");
755        let allocated = allocator.size_allocated.expect("allocator size allocated");
756        assert_eq!(
757            allocator.retained_slack_bytes,
758            Some(allocated.saturating_sub(in_use))
759        );
760    }
761
762    #[cfg(not(any(target_os = "macos", all(target_os = "linux", target_env = "gnu"))))]
763    #[test]
764    fn allocator_snapshot_is_honest_when_platform_counters_are_unavailable() {
765        let allocator = allocator_memory_snapshot();
766        assert_eq!(allocator.status, "not_estimated_on_this_platform");
767        assert_eq!(allocator.bytes_in_use, None);
768        assert_eq!(allocator.size_allocated, None);
769        assert_eq!(allocator.retained_slack_bytes, None);
770        assert_eq!(
771            allocator.not_estimated,
772            Some("platform_allocator_statistics_unavailable")
773        );
774    }
775
776    #[cfg(target_os = "macos")]
777    #[test]
778    #[ignore = "bounded live RSS experiment; run explicitly after allocator changes"]
779    fn allocator_pressure_relief_warm_then_idle_measurement() {
780        let warm_pages = (0..16 * 1024)
781            .map(|seed| {
782                let mut page = Box::new([0u8; 4096]);
783                page[0] = seed as u8;
784                page
785            })
786            .collect::<Vec<_>>();
787        std::hint::black_box(&warm_pages);
788        drop(warm_pages);
789
790        let relief = relieve_allocator_pressure();
791        let sqlite = SqliteMemorySnapshot::measure();
792        eprintln!(
793            "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={}",
794            relief.rss_before_bytes,
795            relief.rss_after_bytes,
796            relief.allocator_before.bytes_in_use,
797            relief.allocator_after.bytes_in_use,
798            relief.allocator_before.size_allocated,
799            relief.allocator_after.size_allocated,
800            relief.allocator_before.retained_slack_bytes,
801            relief.allocator_after.retained_slack_bytes,
802            relief.bytes_released,
803            sqlite.memory_used_bytes,
804            sqlite.memory_highwater_bytes,
805        );
806        assert_eq!(relief.allocator_before.status, "measured");
807        assert_eq!(relief.allocator_after.status, "measured");
808    }
809}