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 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    /// Present only for roots declared as standing. Keeping the marker on the
102    /// existing row uses the same top-eight selection and one omitted-roots
103    /// rollup instead of creating a separate standing-memory table.
104    #[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    /// Attribute this existing per-root rollup to a configured standing entry.
132    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        // SQLite's allocator counters are process-wide and internally synchronized.
209        // They intentionally replace per-connection guesses in root estimates.
210        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    // Not cfg-gated to the fallback platforms: linux-gnu also uses this at
243    // RUNTIME when the host glibc predates mallinfo2 (< 2.33), which only
244    // manifests on release binaries built against an old glibc floor.
245    #[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    /// Kernel physical footprint (macOS `phys_footprint`): dirty + compressed +
262    /// IOKit pages, excluding clean/reclaimable ones. This is the number
263    /// Activity Monitor's "Real Memory" and the OOM killer use. RSS counts
264    /// MADV_FREE pages the allocator has already surrendered (the kernel
265    /// reclaims them lazily), so RSS can read gigabytes above what the process
266    /// actually holds — observed 5.1 GB RSS over a 610 MB footprint. None on
267    /// non-macOS platforms (Linux RSS does not have this skew; MADV_FREE'd
268    /// pages leave Linux RSS on reclaim, not on advice).
269    #[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    /// Allocator bytes overlap the attributed subsystem totals and are an
275    /// allocation envelope, not another amount to subtract from RSS.
276    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        // Attribute against the footprint when available: it excludes
324        // already-surrendered pages, so the residual actually means
325        // "held memory we cannot explain" instead of allocator noise.
326        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
351/// Cap on per-root detail entries in serialized snapshots. Process totals
352/// always cover every root; only the per-root breakdown is capped so a
353/// many-root daemon process cannot balloon the status payload past
354/// downstream consumers' size limits (the daemon metrics cache truncates
355/// around 27 KB, and JSON keys serialize alphabetically, so an oversized
356/// `memory.roots` map pushes later sections past the cut).
357pub 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    /// Top roots by attributed bytes, capped at
363    /// [`MEMORY_SNAPSHOT_ROOT_DETAIL_CAP`]; the remainder is summarized by
364    /// `roots_omitted` / `roots_omitted_bytes`.
365    pub roots: BTreeMap<String, RootMemorySnapshot>,
366    /// Total roots attributed (including omitted ones).
367    pub roots_total: usize,
368    /// Roots summarized out of the detail map.
369    pub roots_omitted: usize,
370    /// Attributed bytes carried by the omitted roots (already included in
371    /// `process.total_attributed_bytes`).
372    pub roots_omitted_bytes: u64,
373    /// Immutable borrowed semantic snapshots, attributed once process-wide.
374    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        // Totals cover EVERY root before the detail map is capped.
417        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
462/// Keep the `cap` roots with the highest attributed bytes; report the rest
463/// as an omitted-count + omitted-bytes rollup.
464fn 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            // Distinct sizes so the kept set is deterministic: later roots larger.
495            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        // The four smallest (1000..=4000) are the omitted ones.
506        assert_eq!(snapshot.roots_omitted_bytes, 1000 + 2000 + 3000 + 4000);
507        // Largest roots are the ones kept.
508        assert!(snapshot
509            .roots
510            .values()
511            .all(|root| root.attributed_bytes > 4000));
512        // Process totals include omitted roots' bytes.
513        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    // mallinfo2 exists only in glibc >= 2.33. Release Linux binaries link
641    // against an older glibc floor (cross gnu images, kept old so dlopen and
642    // wide distro compatibility hold), so a link-time reference to the symbol
643    // fails the release build even though native CI (glibc 2.35) links fine.
644    // Resolve it at runtime instead and report honestly when it is absent.
645    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            // SAFETY: glibc declares mallinfo2 as `struct mallinfo2 (*)(void)`;
654            // the signature matches Mallinfo2Fn exactly.
655            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/// Resolve glibc's optional trimming primitive without creating a link-time
672/// dependency on a symbol that musl and alternate allocators do not provide.
673#[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                // SAFETY: glibc declares malloc_trim as `int (size_t)`;
684                // the signature matches MallocTrimFn exactly.
685                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
702/// Allocator slack (mapped-but-unused arena bytes) above which opportunistic
703/// pressure relief is worth the zone-lock contention it briefly causes.
704pub const ALLOCATOR_SLACK_RELIEF_THRESHOLD_BYTES: u64 = 1024 * 1024 * 1024;
705
706/// Minimum spacing between opportunistic relief passes so a workload that
707/// legitimately cycles through large allocations does not thrash the allocator.
708pub const ALLOCATOR_SLACK_RELIEF_MIN_INTERVAL: std::time::Duration =
709    std::time::Duration::from_secs(300);
710
711/// Decide whether an opportunistic allocator relief pass is due.
712///
713/// Pure so the policy is unit-testable: fires only when the allocator reports
714/// at least the threshold of retained slack AND the previous pass is old
715/// enough. Callers own actually measuring the snapshot and running the pass.
716pub 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/// Opportunistically return unused allocator pages when slack is large, even
734/// while sessions are active. The whole-process idle sweep only fires when
735/// every root has been quiet, so one long-lived chatty session used to block
736/// reclamation for the process lifetime (observed: 5.1 GB RSS over ~600 MB of
737/// live data). Runs the relief on a detached thread because allocator trimming
738/// walks allocator state under its lock and must not stall the dispatch loop or
739/// health probes.
740///
741/// Returns true when a pass was spawned (caller records the timestamp).
742#[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/// Ask the platform allocator to return unused pages after a process-wide idle
768/// gate. Callers own that gate because allocator pressure relief can add
769/// latency. Linux invokes glibc's optional `malloc_trim(0)` when the symbol is
770/// available; non-glibc allocators intentionally remain a no-op.
771#[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        // SAFETY: resolved_malloc_trim verifies the symbol and its C ABI
794        // signature before returning the function pointer.
795        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/// Kernel physical footprint via `proc_pid_rusage` (`ri_phys_footprint`).
833/// See `phys_footprint_bytes` for why this, not RSS, is the headline number.
834#[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        // Unknown slack (allocator stats unavailable) never fires.
886        assert!(!allocator_slack_relief_due(None, None, now));
887        // Below threshold never fires.
888        assert!(!allocator_slack_relief_due(
889            Some(ALLOCATOR_SLACK_RELIEF_THRESHOLD_BYTES - 1),
890            None,
891            now
892        ));
893        // At threshold with no prior pass fires.
894        assert!(allocator_slack_relief_due(big, None, now));
895        // A recent pass suppresses the next one...
896        let recent = now - Duration::from_secs(10);
897        assert!(!allocator_slack_relief_due(big, Some(recent), now));
898        // ...until the minimum spacing has elapsed.
899        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}