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    /// Bytes released by `evict_idle_artifacts`: retained index handles, symbol
199    /// data, and inspect caches. This deliberately shares the already-collected
200    /// estimates instead of taking a second estimate on the reply path.
201    pub fn evictable_bytes(&self) -> u64 {
202        [
203            &self.trigram,
204            &self.semantic,
205            &self.symbols,
206            &self.callgraph,
207            &self.inspect,
208        ]
209        .into_iter()
210        .filter_map(|estimate| estimate.estimated_bytes)
211        .fold(0, u64::saturating_add)
212    }
213}
214
215#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
216pub struct SqliteMemorySnapshot {
217    pub status: &'static str,
218    pub memory_used_bytes: u64,
219    pub memory_highwater_bytes: u64,
220}
221
222impl SqliteMemorySnapshot {
223    fn measure() -> Self {
224        // SQLite's allocator counters are process-wide and internally synchronized.
225        // They intentionally replace per-connection guesses in root estimates.
226        let memory_used = unsafe { rusqlite::ffi::sqlite3_memory_used() };
227        let memory_highwater = unsafe { rusqlite::ffi::sqlite3_memory_highwater(0) };
228        Self {
229            status: "measured",
230            memory_used_bytes: nonnegative_i64_to_u64(memory_used),
231            memory_highwater_bytes: nonnegative_i64_to_u64(memory_highwater),
232        }
233    }
234}
235
236#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
237pub struct AllocatorMemorySnapshot {
238    pub status: &'static str,
239    pub bytes_in_use: Option<u64>,
240    pub size_allocated: Option<u64>,
241    pub retained_slack_bytes: Option<u64>,
242    #[serde(skip_serializing_if = "Option::is_none")]
243    pub not_estimated: Option<&'static str>,
244}
245
246impl AllocatorMemorySnapshot {
247    #[cfg(any(target_os = "macos", all(target_os = "linux", target_env = "gnu")))]
248    fn measured(bytes_in_use: u64, size_allocated: u64) -> Self {
249        Self {
250            status: "measured",
251            bytes_in_use: Some(bytes_in_use),
252            size_allocated: Some(size_allocated),
253            retained_slack_bytes: Some(size_allocated.saturating_sub(bytes_in_use)),
254            not_estimated: None,
255        }
256    }
257
258    // Not cfg-gated to the fallback platforms: linux-gnu also uses this at
259    // RUNTIME when the host glibc predates mallinfo2 (< 2.33), which only
260    // manifests on release binaries built against an old glibc floor.
261    #[cfg_attr(target_os = "macos", allow(dead_code))]
262    fn not_estimated(reason: &'static str) -> Self {
263        Self {
264            status: "not_estimated_on_this_platform",
265            bytes_in_use: None,
266            size_allocated: None,
267            retained_slack_bytes: None,
268            not_estimated: Some(reason),
269        }
270    }
271}
272
273#[derive(Debug, Clone, Copy, PartialEq, Eq)]
274pub enum AllocatorSource {
275    /// Use the process-wide observation published by a sampler or health refresh.
276    Cached,
277    /// Walk allocator statistics now and publish the result for future cached reads.
278    Measure,
279}
280
281#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
282pub struct ProcessMemorySnapshot {
283    pub rss_status: &'static str,
284    pub rss_bytes: Option<u64>,
285    /// Kernel physical footprint (macOS `phys_footprint`): dirty + compressed +
286    /// IOKit pages, excluding clean/reclaimable ones. This is the number
287    /// Activity Monitor's "Real Memory" and the OOM killer use. RSS counts
288    /// MADV_FREE pages the allocator has already surrendered (the kernel
289    /// reclaims them lazily), so RSS can read gigabytes above what the process
290    /// actually holds — observed 5.1 GB RSS over a 610 MB footprint. None on
291    /// non-macOS platforms (Linux RSS does not have this skew; MADV_FREE'd
292    /// pages leave Linux RSS on reclaim, not on advice).
293    #[serde(skip_serializing_if = "Option::is_none")]
294    pub phys_footprint_bytes: Option<u64>,
295    #[serde(skip_serializing_if = "Option::is_none")]
296    pub rss_not_estimated: Option<&'static str>,
297    pub sqlite: SqliteMemorySnapshot,
298    /// Allocator bytes overlap the attributed subsystem totals and are an
299    /// allocation envelope, not another amount to subtract from RSS.
300    pub allocator: AllocatorMemorySnapshot,
301    /// Whether allocator slack came from an available allocator measurement.
302    pub allocator_slack_measured: bool,
303    /// Age of the cached allocator observation. A direct measurement is age zero;
304    /// a request-path read is null until the first process-wide observation exists.
305    pub allocator_observation_age_ms: Option<u64>,
306    pub total_attributed_bytes: u64,
307    pub unattributed_bytes: Option<i64>,
308    pub root_count: usize,
309    pub busy_subsystems: usize,
310    pub not_estimated_subsystems: usize,
311}
312
313#[derive(Debug, Clone, PartialEq, Eq)]
314pub struct AllocatorPressureRelief {
315    pub bytes_released: u64,
316    pub rss_before_bytes: Option<u64>,
317    pub rss_after_bytes: Option<u64>,
318    pub allocator_before: AllocatorMemorySnapshot,
319    pub allocator_after: AllocatorMemorySnapshot,
320}
321
322impl ProcessMemorySnapshot {
323    pub fn from_roots(
324        roots: &BTreeMap<String, RootMemorySnapshot>,
325        shared_semantic_bases: &MemoryEstimate,
326    ) -> Self {
327        let rollups: Vec<_> = roots.values().map(RootMemorySnapshot::rollup).collect();
328        Self::from_root_rollups(
329            rollups.iter(),
330            roots.len(),
331            shared_semantic_bases,
332            AllocatorSource::Measure,
333        )
334    }
335
336    fn from_root_rollups<'a>(
337        roots: impl Iterator<Item = &'a RootMemoryRollup>,
338        root_count: usize,
339        shared_semantic_bases: &MemoryEstimate,
340        allocator_source: AllocatorSource,
341    ) -> Self {
342        let mut root_attributed_bytes = 0u64;
343        let mut busy_subsystems = 0usize;
344        let mut not_estimated_subsystems = 0usize;
345        for root in roots {
346            root_attributed_bytes = root_attributed_bytes.saturating_add(root.attributed_bytes);
347            busy_subsystems = busy_subsystems.saturating_add(root.busy_subsystems);
348            not_estimated_subsystems =
349                not_estimated_subsystems.saturating_add(root.not_estimated_subsystems);
350        }
351        let sqlite = SqliteMemorySnapshot::measure();
352        let (allocator, allocator_observation_age_ms) = allocator_observation(allocator_source);
353        let total_attributed_bytes = root_attributed_bytes
354            .saturating_add(shared_semantic_bases.estimated_bytes.unwrap_or(0))
355            .saturating_add(sqlite.memory_used_bytes);
356        let rss_bytes = process_rss_bytes();
357        let phys_footprint_bytes = process_phys_footprint_bytes();
358        // Attribute against the footprint when available: it excludes
359        // already-surrendered pages, so the residual actually means
360        // "held memory we cannot explain" instead of allocator noise.
361        let unattributed_basis = phys_footprint_bytes.or(rss_bytes);
362        let unattributed_bytes =
363            unattributed_basis.map(|held| signed_difference(held, total_attributed_bytes));
364        Self {
365            rss_status: if rss_bytes.is_some() {
366                "estimated"
367            } else {
368                "not_estimated_on_this_platform"
369            },
370            rss_bytes,
371            phys_footprint_bytes,
372            rss_not_estimated: rss_bytes
373                .is_none()
374                .then_some("platform_process_rss_unavailable"),
375            sqlite,
376            allocator_slack_measured: allocator.retained_slack_bytes.is_some(),
377            allocator,
378            allocator_observation_age_ms,
379            total_attributed_bytes,
380            unattributed_bytes,
381            root_count,
382            busy_subsystems,
383            not_estimated_subsystems,
384        }
385    }
386}
387
388/// Cap on per-root detail entries in serialized snapshots. Process totals
389/// always cover every root; only the per-root breakdown is capped so a
390/// many-root daemon process cannot balloon the status payload past
391/// downstream consumers' size limits (the daemon metrics cache truncates
392/// around 27 KB, and JSON keys serialize alphabetically, so an oversized
393/// `memory.roots` map pushes later sections past the cut).
394pub const MEMORY_SNAPSHOT_ROOT_DETAIL_CAP: usize = 8;
395
396#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
397pub struct MemorySnapshot {
398    pub roots_status: &'static str,
399    /// Top roots by attributed bytes, capped at
400    /// [`MEMORY_SNAPSHOT_ROOT_DETAIL_CAP`]; the remainder is summarized by
401    /// `roots_omitted` / `roots_omitted_bytes`.
402    pub roots: BTreeMap<String, RootMemorySnapshot>,
403    /// Total roots attributed (including omitted ones).
404    pub roots_total: usize,
405    /// Roots summarized out of the detail map.
406    pub roots_omitted: usize,
407    /// Attributed bytes carried by the omitted roots (already included in
408    /// `process.total_attributed_bytes`).
409    pub roots_omitted_bytes: u64,
410    /// Immutable borrowed semantic snapshots, attributed once process-wide.
411    pub shared_semantic_bases: MemoryEstimate,
412    pub process: ProcessMemorySnapshot,
413}
414
415#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
416pub(crate) struct MemoryRollupSnapshot {
417    pub(crate) roots_status: &'static str,
418    pub(crate) roots: BTreeMap<String, RootMemoryRollup>,
419    pub(crate) roots_total: usize,
420    pub(crate) roots_omitted: usize,
421    pub(crate) roots_omitted_bytes: u64,
422    pub(crate) process: ProcessMemorySnapshot,
423}
424
425impl MemoryRollupSnapshot {
426    pub(crate) fn new(
427        roots_status: &'static str,
428        roots: BTreeMap<String, RootMemoryRollup>,
429    ) -> Self {
430        let shared_semantic_bases = crate::semantic_index::shared_semantic_bases_memory();
431        let process = ProcessMemorySnapshot::from_root_rollups(
432            roots.values(),
433            roots.len(),
434            &shared_semantic_bases,
435            AllocatorSource::Measure,
436        );
437        let roots_total = roots.len();
438        let (roots, roots_omitted, roots_omitted_bytes) =
439            cap_root_rollups(roots, MEMORY_SNAPSHOT_ROOT_DETAIL_CAP);
440        Self {
441            roots_status,
442            roots,
443            roots_total,
444            roots_omitted,
445            roots_omitted_bytes,
446            process,
447        }
448    }
449}
450
451impl MemorySnapshot {
452    pub fn new(roots_status: &'static str, roots: BTreeMap<String, RootMemorySnapshot>) -> Self {
453        Self::with_cap(roots_status, roots, true, AllocatorSource::Cached)
454    }
455
456    /// Build the uncapped form used by the explicit memory census operation.
457    pub fn new_uncapped(
458        roots_status: &'static str,
459        roots: BTreeMap<String, RootMemorySnapshot>,
460    ) -> Self {
461        Self::with_cap(roots_status, roots, false, AllocatorSource::Measure)
462    }
463
464    fn with_cap(
465        roots_status: &'static str,
466        roots: BTreeMap<String, RootMemorySnapshot>,
467        cap_detail: bool,
468        allocator_source: AllocatorSource,
469    ) -> Self {
470        let shared_semantic_bases = crate::semantic_index::shared_semantic_bases_memory();
471        // Totals cover EVERY root before the detail map is capped.
472        let rollups: Vec<_> = roots.values().map(RootMemorySnapshot::rollup).collect();
473        let process = ProcessMemorySnapshot::from_root_rollups(
474            rollups.iter(),
475            roots.len(),
476            &shared_semantic_bases,
477            allocator_source,
478        );
479        let roots_total = roots.len();
480        let (roots, roots_omitted, roots_omitted_bytes) = if cap_detail {
481            cap_root_detail(roots, MEMORY_SNAPSHOT_ROOT_DETAIL_CAP)
482        } else {
483            (roots, 0, 0)
484        };
485        Self {
486            roots_status,
487            roots,
488            roots_total,
489            roots_omitted,
490            roots_omitted_bytes,
491            shared_semantic_bases,
492            process,
493        }
494    }
495}
496
497fn cap_roots<T>(
498    roots: BTreeMap<String, T>,
499    cap: usize,
500    attributed_bytes: impl Fn(&T) -> u64,
501) -> (BTreeMap<String, T>, usize, u64) {
502    if roots.len() <= cap {
503        return (roots, 0, 0);
504    }
505    let mut entries: Vec<_> = roots.into_iter().collect();
506    entries.sort_by(|a, b| {
507        attributed_bytes(&b.1)
508            .cmp(&attributed_bytes(&a.1))
509            .then_with(|| a.0.cmp(&b.0))
510    });
511    let omitted = entries.split_off(cap);
512    let omitted_bytes = omitted
513        .iter()
514        .map(|(_, snapshot)| attributed_bytes(snapshot))
515        .fold(0u64, u64::saturating_add);
516    (entries.into_iter().collect(), omitted.len(), omitted_bytes)
517}
518
519fn cap_root_rollups(
520    roots: BTreeMap<String, RootMemoryRollup>,
521    cap: usize,
522) -> (BTreeMap<String, RootMemoryRollup>, usize, u64) {
523    cap_roots(roots, cap, |snapshot| snapshot.attributed_bytes)
524}
525
526/// Keep the `cap` roots with the highest attributed bytes; report the rest
527/// as an omitted-count + omitted-bytes rollup.
528fn cap_root_detail(
529    roots: BTreeMap<String, RootMemorySnapshot>,
530    cap: usize,
531) -> (BTreeMap<String, RootMemorySnapshot>, usize, u64) {
532    cap_roots(roots, cap, |snapshot| snapshot.attributed_bytes)
533}
534
535#[cfg(test)]
536mod snapshot_cap_tests {
537    use super::*;
538
539    fn root_with_bytes(bytes: u64) -> RootMemorySnapshot {
540        let estimate = MemoryEstimate::estimated;
541        RootMemorySnapshot::new(
542            estimate(bytes),
543            estimate(0),
544            estimate(0),
545            estimate(0),
546            estimate(0),
547            estimate(0),
548            estimate(0),
549            estimate(0),
550            estimate(0),
551        )
552    }
553
554    #[test]
555    fn detail_map_capped_but_totals_cover_all_roots() {
556        let mut roots = BTreeMap::new();
557        for i in 0..(MEMORY_SNAPSHOT_ROOT_DETAIL_CAP + 4) {
558            // Distinct sizes so the kept set is deterministic: later roots larger.
559            roots.insert(
560                format!("/root/{i:02}"),
561                root_with_bytes((i as u64 + 1) * 1000),
562            );
563        }
564        let snapshot = MemorySnapshot::new("ready", roots);
565
566        assert_eq!(snapshot.roots.len(), MEMORY_SNAPSHOT_ROOT_DETAIL_CAP);
567        assert_eq!(snapshot.roots_total, MEMORY_SNAPSHOT_ROOT_DETAIL_CAP + 4);
568        assert_eq!(snapshot.roots_omitted, 4);
569        // The four smallest (1000..=4000) are the omitted ones.
570        assert_eq!(snapshot.roots_omitted_bytes, 1000 + 2000 + 3000 + 4000);
571        // Largest roots are the ones kept.
572        assert!(snapshot
573            .roots
574            .values()
575            .all(|root| root.attributed_bytes > 4000));
576        // Process totals include omitted roots' bytes.
577        let expected_total: u64 = (1..=(MEMORY_SNAPSHOT_ROOT_DETAIL_CAP as u64 + 4))
578            .map(|i| i * 1000)
579            .sum();
580        assert!(snapshot.process.total_attributed_bytes >= expected_total);
581    }
582
583    #[test]
584    fn rollup_cap_keeps_only_top_roots_while_totals_cover_all() {
585        let mut roots = BTreeMap::new();
586        for i in 0..(MEMORY_SNAPSHOT_ROOT_DETAIL_CAP + 4) {
587            roots.insert(
588                format!("/rollup/{i:02}"),
589                root_with_bytes((i as u64 + 1) * 1000).rollup(),
590            );
591        }
592        let snapshot = MemoryRollupSnapshot::new("ready", roots);
593
594        assert_eq!(snapshot.roots.len(), MEMORY_SNAPSHOT_ROOT_DETAIL_CAP);
595        assert_eq!(snapshot.roots_total, MEMORY_SNAPSHOT_ROOT_DETAIL_CAP + 4);
596        assert_eq!(snapshot.roots_omitted, 4);
597        assert_eq!(snapshot.roots_omitted_bytes, 1000 + 2000 + 3000 + 4000);
598        let expected_total: u64 = (1..=(MEMORY_SNAPSHOT_ROOT_DETAIL_CAP as u64 + 4))
599            .map(|i| i * 1000)
600            .sum();
601        assert!(snapshot.process.total_attributed_bytes >= expected_total);
602    }
603
604    #[test]
605    fn standing_rollup_uses_the_shared_top_eight_cap() {
606        let mut roots = BTreeMap::new();
607        for i in 0..=MEMORY_SNAPSHOT_ROOT_DETAIL_CAP {
608            let rollup = root_with_bytes((i as u64 + 1) * 1000).rollup();
609            roots.insert(format!("/root/{i:02}"), rollup);
610        }
611        roots.insert(
612            "standing-artifact-key".to_string(),
613            root_with_bytes(20_000).rollup().with_standing(),
614        );
615
616        let snapshot = MemoryRollupSnapshot::new("ready", roots);
617        assert_eq!(snapshot.roots.len(), MEMORY_SNAPSHOT_ROOT_DETAIL_CAP);
618        assert_eq!(snapshot.roots_total, MEMORY_SNAPSHOT_ROOT_DETAIL_CAP + 2);
619        assert_eq!(snapshot.roots_omitted, 2);
620        assert_eq!(
621            snapshot.roots["standing-artifact-key"].standing,
622            Some(true),
623            "standing memory stays in the ordinary per-root table"
624        );
625    }
626
627    #[test]
628    fn under_cap_keeps_everything_with_zero_omitted() {
629        let mut roots = BTreeMap::new();
630        roots.insert("/a".to_string(), root_with_bytes(10));
631        roots.insert("/b".to_string(), root_with_bytes(20));
632        let snapshot = MemorySnapshot::new("ready", roots);
633        assert_eq!(snapshot.roots.len(), 2);
634        assert_eq!(snapshot.roots_total, 2);
635        assert_eq!(snapshot.roots_omitted, 0);
636        assert_eq!(snapshot.roots_omitted_bytes, 0);
637    }
638}
639
640pub fn path_bytes(path: &Path) -> u64 {
641    #[cfg(unix)]
642    {
643        use std::os::unix::ffi::OsStrExt;
644        usize_to_u64(path.as_os_str().as_bytes().len())
645    }
646    #[cfg(windows)]
647    {
648        use std::os::windows::ffi::OsStrExt;
649        usize_to_u64(path.as_os_str().encode_wide().count())
650            .saturating_mul(std::mem::size_of::<u16>() as u64)
651    }
652    #[cfg(not(any(unix, windows)))]
653    {
654        usize_to_u64(path.to_string_lossy().len())
655    }
656}
657
658pub fn usize_to_u64(value: usize) -> u64 {
659    u64::try_from(value).unwrap_or(u64::MAX)
660}
661
662pub fn estimated_json_bytes(value: &Value) -> u64 {
663    match value {
664        Value::Null => 0,
665        Value::Bool(_) => std::mem::size_of::<bool>() as u64,
666        Value::Number(_) => std::mem::size_of::<serde_json::Number>() as u64,
667        Value::String(value) => usize_to_u64(value.len()),
668        Value::Array(values) => values
669            .iter()
670            .map(estimated_json_bytes)
671            .fold(0u64, u64::saturating_add),
672        Value::Object(values) => values.iter().fold(0u64, |bytes, (key, value)| {
673            bytes
674                .saturating_add(usize_to_u64(key.len()))
675                .saturating_add(estimated_json_bytes(value))
676        }),
677    }
678}
679
680fn signed_difference(lhs: u64, rhs: u64) -> i64 {
681    let difference = i128::from(lhs) - i128::from(rhs);
682    difference.clamp(i128::from(i64::MIN), i128::from(i64::MAX)) as i64
683}
684
685fn nonnegative_i64_to_u64(value: i64) -> u64 {
686    u64::try_from(value).unwrap_or(0)
687}
688
689#[cfg(target_os = "macos")]
690fn allocator_memory_snapshot_impl() -> AllocatorMemorySnapshot {
691    let mut statistics = std::mem::MaybeUninit::<libc::malloc_statistics_t>::zeroed();
692    unsafe {
693        libc::malloc_zone_statistics(libc::malloc_default_zone(), statistics.as_mut_ptr());
694    }
695    let statistics = unsafe { statistics.assume_init() };
696    AllocatorMemorySnapshot::measured(
697        usize_to_u64(statistics.size_in_use),
698        usize_to_u64(statistics.size_allocated),
699    )
700}
701
702#[cfg(all(target_os = "linux", target_env = "gnu"))]
703fn allocator_memory_snapshot_impl() -> AllocatorMemorySnapshot {
704    // mallinfo2 exists only in glibc >= 2.33. Release Linux binaries link
705    // against an older glibc floor (cross gnu images, kept old so dlopen and
706    // wide distro compatibility hold), so a link-time reference to the symbol
707    // fails the release build even though native CI (glibc 2.35) links fine.
708    // Resolve it at runtime instead and report honestly when it is absent.
709    use std::sync::OnceLock;
710    type Mallinfo2Fn = unsafe extern "C" fn() -> libc::mallinfo2;
711    static MALLINFO2: OnceLock<Option<Mallinfo2Fn>> = OnceLock::new();
712    let resolved = MALLINFO2.get_or_init(|| {
713        let symbol = unsafe { libc::dlsym(libc::RTLD_DEFAULT, c"mallinfo2".as_ptr()) };
714        if symbol.is_null() {
715            None
716        } else {
717            // SAFETY: glibc declares mallinfo2 as `struct mallinfo2 (*)(void)`;
718            // the signature matches Mallinfo2Fn exactly.
719            Some(unsafe { std::mem::transmute::<*mut libc::c_void, Mallinfo2Fn>(symbol) })
720        }
721    });
722    let Some(mallinfo2) = resolved else {
723        return AllocatorMemorySnapshot::not_estimated("mallinfo2_requires_glibc_2_33");
724    };
725    let statistics = unsafe { mallinfo2() };
726    let mapped_bytes = statistics.hblkhd as u64;
727    let bytes_in_use = (statistics.uordblks as u64).saturating_add(mapped_bytes);
728    let size_allocated = (statistics.arena as u64).saturating_add(mapped_bytes);
729    AllocatorMemorySnapshot::measured(bytes_in_use, size_allocated)
730}
731
732#[cfg(all(target_os = "linux", target_env = "gnu"))]
733type MallocTrimFn = unsafe extern "C" fn(libc::size_t) -> libc::c_int;
734
735/// Resolve glibc's optional trimming primitive without creating a link-time
736/// dependency on a symbol that musl and alternate allocators do not provide.
737#[cfg(all(target_os = "linux", target_env = "gnu"))]
738fn resolved_malloc_trim() -> Option<MallocTrimFn> {
739    use std::sync::OnceLock;
740    static MALLOC_TRIM: OnceLock<Option<MallocTrimFn>> = OnceLock::new();
741    MALLOC_TRIM
742        .get_or_init(|| {
743            let symbol = unsafe { libc::dlsym(libc::RTLD_DEFAULT, c"malloc_trim".as_ptr()) };
744            if symbol.is_null() {
745                None
746            } else {
747                // SAFETY: glibc declares malloc_trim as `int (size_t)`;
748                // the signature matches MallocTrimFn exactly.
749                Some(unsafe { std::mem::transmute::<*mut libc::c_void, MallocTrimFn>(symbol) })
750            }
751        })
752        .as_ref()
753        .copied()
754}
755
756#[cfg(not(any(target_os = "macos", all(target_os = "linux", target_env = "gnu"))))]
757fn allocator_memory_snapshot_impl() -> AllocatorMemorySnapshot {
758    AllocatorMemorySnapshot::not_estimated("platform_allocator_statistics_unavailable")
759}
760
761/// Read allocator-wide statistics. Callers on the dispatch path must use the
762/// cached slack scheduler below instead: macOS walks allocator zones under
763/// their locks while producing this snapshot.
764fn allocator_memory_snapshot() -> AllocatorMemorySnapshot {
765    #[cfg(test)]
766    {
767        THREAD_ALLOCATOR_SNAPSHOT_CALLS.with(|calls| calls.set(calls.get() + 1));
768        let name = std::thread::current()
769            .name()
770            .unwrap_or("unnamed")
771            .to_string();
772        ALLOCATOR_SNAPSHOT_THREADS
773            .lock()
774            .expect("allocator snapshot test thread log")
775            .push(name);
776    }
777    allocator_memory_snapshot_impl()
778}
779
780fn allocator_observation(source: AllocatorSource) -> (AllocatorMemorySnapshot, Option<u64>) {
781    match source {
782        AllocatorSource::Cached => cached_allocator_observation()
783            .map(|(snapshot, age_ms)| (snapshot, Some(age_ms)))
784            .unwrap_or_else(|| {
785                (
786                    AllocatorMemorySnapshot::not_estimated("allocator_observation_unavailable"),
787                    None,
788                )
789            }),
790        AllocatorSource::Measure => measure_allocator_observation(),
791    }
792}
793
794fn measure_allocator_observation() -> (AllocatorMemorySnapshot, Option<u64>) {
795    // Only the platforms that publish an observation stamp its start; the
796    // Windows deny-warnings lane rejects the unused binding otherwise.
797    #[cfg(any(target_os = "macos", target_os = "linux"))]
798    let sampled_at = std::time::Instant::now();
799    let snapshot = allocator_memory_snapshot();
800    #[cfg(any(target_os = "macos", target_os = "linux"))]
801    {
802        let sampled_at_ms = allocator_slack_elapsed_ms(sampled_at);
803        publish_allocator_slack(&snapshot, sampled_at_ms);
804        let age_ms =
805            allocator_slack_elapsed_ms(std::time::Instant::now()).saturating_sub(sampled_at_ms);
806        (snapshot, Some(age_ms))
807    }
808    #[cfg(not(any(target_os = "macos", target_os = "linux")))]
809    {
810        (snapshot, None)
811    }
812}
813
814#[cfg(target_os = "macos")]
815unsafe extern "C" {
816    fn malloc_zone_pressure_relief(zone: *mut libc::malloc_zone_t, goal: usize) -> usize;
817}
818
819/// Allocator slack (mapped-but-unused arena bytes) above which opportunistic
820/// pressure relief is worth the zone-lock contention it briefly causes.
821pub const ALLOCATOR_SLACK_RELIEF_THRESHOLD_BYTES: u64 = 1024 * 1024 * 1024;
822
823/// Minimum spacing between opportunistic relief passes so a workload that
824/// legitimately cycles through large allocations does not thrash the allocator.
825pub const ALLOCATOR_SLACK_RELIEF_MIN_INTERVAL: std::time::Duration =
826    std::time::Duration::from_secs(300);
827
828#[cfg(any(target_os = "macos", target_os = "linux"))]
829/// The module loop ticks every 250ms. Sampling at this cadence would repeatedly
830/// take allocator-zone locks, so cache a process-wide observation for 60s.
831/// This bounds allocator statistics walks to one per idle minute, or two for a
832/// relief pass that uses its before/after snapshots.
833const ALLOCATOR_SLACK_SAMPLE_INTERVAL: std::time::Duration = std::time::Duration::from_secs(60);
834#[cfg(any(target_os = "macos", target_os = "linux"))]
835const UNKNOWN_ALLOCATOR_SLACK: u64 = u64::MAX;
836#[cfg(any(target_os = "macos", target_os = "linux"))]
837const UNSAMPLED_AT_MS: u64 = u64::MAX;
838#[cfg(any(target_os = "macos", target_os = "linux"))]
839const ALLOCATOR_OBSERVATION_UNSAMPLED: u8 = 0;
840#[cfg(any(target_os = "macos", target_os = "linux"))]
841const ALLOCATOR_OBSERVATION_MEASURED: u8 = 1;
842#[cfg(any(target_os = "macos", target_os = "linux"))]
843const ALLOCATOR_OBSERVATION_NOT_ESTIMATED: u8 = 2;
844#[cfg(any(target_os = "macos", target_os = "linux"))]
845static ALLOCATOR_SLACK_CACHE_ORIGIN: std::sync::OnceLock<std::time::Instant> =
846    std::sync::OnceLock::new();
847#[cfg(any(target_os = "macos", target_os = "linux"))]
848static LAST_OBSERVED_ALLOCATOR_SLACK_BYTES: std::sync::atomic::AtomicU64 =
849    std::sync::atomic::AtomicU64::new(UNKNOWN_ALLOCATOR_SLACK);
850#[cfg(any(target_os = "macos", target_os = "linux"))]
851static LAST_OBSERVED_ALLOCATOR_BYTES_IN_USE: std::sync::atomic::AtomicU64 =
852    std::sync::atomic::AtomicU64::new(UNKNOWN_ALLOCATOR_SLACK);
853#[cfg(any(target_os = "macos", target_os = "linux"))]
854static LAST_OBSERVED_ALLOCATOR_SIZE_ALLOCATED: std::sync::atomic::AtomicU64 =
855    std::sync::atomic::AtomicU64::new(UNKNOWN_ALLOCATOR_SLACK);
856#[cfg(any(target_os = "macos", target_os = "linux"))]
857static LAST_ALLOCATOR_OBSERVATION_STATUS: std::sync::atomic::AtomicU8 =
858    std::sync::atomic::AtomicU8::new(ALLOCATOR_OBSERVATION_UNSAMPLED);
859#[cfg(any(target_os = "macos", target_os = "linux"))]
860static LAST_ALLOCATOR_OBSERVATION_AT_MS: std::sync::atomic::AtomicU64 =
861    std::sync::atomic::AtomicU64::new(UNSAMPLED_AT_MS);
862#[cfg(any(target_os = "macos", target_os = "linux"))]
863static ALLOCATOR_OBSERVATION_SEQUENCE: std::sync::atomic::AtomicU64 =
864    std::sync::atomic::AtomicU64::new(0);
865#[cfg(any(target_os = "macos", target_os = "linux"))]
866static ALLOCATOR_OBSERVATION_PUBLISH_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
867#[cfg(any(target_os = "macos", target_os = "linux"))]
868static LAST_ALLOCATOR_SLACK_SAMPLE_AT_MS: std::sync::atomic::AtomicU64 =
869    std::sync::atomic::AtomicU64::new(UNSAMPLED_AT_MS);
870#[cfg(any(target_os = "macos", target_os = "linux"))]
871static LAST_ALLOCATOR_SLACK_RELIEF_AT_MS: std::sync::atomic::AtomicU64 =
872    std::sync::atomic::AtomicU64::new(UNSAMPLED_AT_MS);
873#[cfg(any(target_os = "macos", target_os = "linux"))]
874static LAST_ALLOCATOR_SLACK_RELIEF_FREED_BYTES: std::sync::atomic::AtomicU64 =
875    std::sync::atomic::AtomicU64::new(0);
876
877#[cfg(test)]
878static ALLOCATOR_SNAPSHOT_THREADS: std::sync::Mutex<Vec<String>> =
879    std::sync::Mutex::new(Vec::new());
880
881#[cfg(test)]
882thread_local! {
883    /// Per-thread count of allocator statistics walks. Tests that assert a
884    /// synchronous render path took no walk read this: the walk, if it
885    /// happened, ran on the calling thread. A process-global counter is
886    /// wrong for that assertion - sibling tests and the health thread walk
887    /// concurrently, and a parallel run read a delta of 1 for a render that
888    /// walked nothing. Which-thread questions use the thread-name log.
889    static THREAD_ALLOCATOR_SNAPSHOT_CALLS: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
890}
891
892#[cfg(test)]
893pub(crate) fn allocator_snapshot_calls_for_test() -> u64 {
894    THREAD_ALLOCATOR_SNAPSHOT_CALLS.with(|calls| calls.get())
895}
896
897#[cfg(test)]
898fn allocator_observation_test_mutex() -> &'static std::sync::Mutex<()> {
899    static LOCK: std::sync::OnceLock<std::sync::Mutex<()>> = std::sync::OnceLock::new();
900    LOCK.get_or_init(|| std::sync::Mutex::new(()))
901}
902
903#[cfg(test)]
904pub(crate) fn allocator_observation_test_lock() -> std::sync::MutexGuard<'static, ()> {
905    allocator_observation_test_mutex()
906        .lock()
907        .unwrap_or_else(std::sync::PoisonError::into_inner)
908}
909
910#[cfg(test)]
911pub(crate) fn reset_allocator_observation_for_test() {
912    #[cfg(any(target_os = "macos", target_os = "linux"))]
913    {
914        ALLOCATOR_OBSERVATION_SEQUENCE.store(0, std::sync::atomic::Ordering::Release);
915        LAST_ALLOCATOR_OBSERVATION_STATUS.store(
916            ALLOCATOR_OBSERVATION_UNSAMPLED,
917            std::sync::atomic::Ordering::Release,
918        );
919        LAST_OBSERVED_ALLOCATOR_BYTES_IN_USE.store(
920            UNKNOWN_ALLOCATOR_SLACK,
921            std::sync::atomic::Ordering::Release,
922        );
923        LAST_OBSERVED_ALLOCATOR_SIZE_ALLOCATED.store(
924            UNKNOWN_ALLOCATOR_SLACK,
925            std::sync::atomic::Ordering::Release,
926        );
927        LAST_OBSERVED_ALLOCATOR_SLACK_BYTES.store(
928            UNKNOWN_ALLOCATOR_SLACK,
929            std::sync::atomic::Ordering::Release,
930        );
931        LAST_ALLOCATOR_OBSERVATION_AT_MS
932            .store(UNSAMPLED_AT_MS, std::sync::atomic::Ordering::Release);
933        LAST_ALLOCATOR_SLACK_SAMPLE_AT_MS
934            .store(UNSAMPLED_AT_MS, std::sync::atomic::Ordering::Release);
935    }
936}
937
938#[cfg(any(target_os = "macos", target_os = "linux"))]
939fn allocator_slack_elapsed_ms(now: std::time::Instant) -> u64 {
940    let origin = ALLOCATOR_SLACK_CACHE_ORIGIN.get_or_init(|| now);
941    now.duration_since(*origin)
942        .as_millis()
943        .min(u128::from(u64::MAX)) as u64
944}
945
946#[cfg(any(target_os = "macos", target_os = "linux"))]
947fn cached_allocator_slack() -> Option<u64> {
948    let slack = LAST_OBSERVED_ALLOCATOR_SLACK_BYTES.load(std::sync::atomic::Ordering::Acquire);
949    (slack != UNKNOWN_ALLOCATOR_SLACK).then_some(slack)
950}
951
952#[cfg(any(target_os = "macos", target_os = "linux"))]
953fn cached_allocator_not_estimated_reason() -> &'static str {
954    #[cfg(all(target_os = "linux", target_env = "gnu"))]
955    {
956        "mallinfo2_requires_glibc_2_33"
957    }
958    #[cfg(not(all(target_os = "linux", target_env = "gnu")))]
959    {
960        "platform_allocator_statistics_unavailable"
961    }
962}
963
964/// Return the last process-wide allocator observation without walking allocator
965/// zones. The observation timestamp is separate from the sampler reservation so
966/// a status request never mistakes an in-progress sample for a fresh reading.
967pub fn cached_allocator_observation() -> Option<(AllocatorMemorySnapshot, u64)> {
968    #[cfg(any(target_os = "macos", target_os = "linux"))]
969    {
970        loop {
971            let sequence =
972                ALLOCATOR_OBSERVATION_SEQUENCE.load(std::sync::atomic::Ordering::Acquire);
973            if sequence % 2 != 0 {
974                std::hint::spin_loop();
975                continue;
976            }
977            let sampled_at_ms =
978                LAST_ALLOCATOR_OBSERVATION_AT_MS.load(std::sync::atomic::Ordering::Acquire);
979            if sampled_at_ms == UNSAMPLED_AT_MS {
980                return None;
981            }
982            let status =
983                LAST_ALLOCATOR_OBSERVATION_STATUS.load(std::sync::atomic::Ordering::Acquire);
984            let bytes_in_use =
985                LAST_OBSERVED_ALLOCATOR_BYTES_IN_USE.load(std::sync::atomic::Ordering::Acquire);
986            let size_allocated =
987                LAST_OBSERVED_ALLOCATOR_SIZE_ALLOCATED.load(std::sync::atomic::Ordering::Acquire);
988            let retained_slack =
989                LAST_OBSERVED_ALLOCATOR_SLACK_BYTES.load(std::sync::atomic::Ordering::Acquire);
990            let sequence_after =
991                ALLOCATOR_OBSERVATION_SEQUENCE.load(std::sync::atomic::Ordering::Acquire);
992            if sequence != sequence_after || sequence_after % 2 != 0 {
993                continue;
994            }
995            let snapshot = match status {
996                ALLOCATOR_OBSERVATION_MEASURED
997                    if bytes_in_use != UNKNOWN_ALLOCATOR_SLACK
998                        && size_allocated != UNKNOWN_ALLOCATOR_SLACK
999                        && retained_slack != UNKNOWN_ALLOCATOR_SLACK =>
1000                {
1001                    AllocatorMemorySnapshot {
1002                        status: "measured",
1003                        bytes_in_use: Some(bytes_in_use),
1004                        size_allocated: Some(size_allocated),
1005                        retained_slack_bytes: Some(retained_slack),
1006                        not_estimated: None,
1007                    }
1008                }
1009                ALLOCATOR_OBSERVATION_NOT_ESTIMATED => {
1010                    AllocatorMemorySnapshot::not_estimated(cached_allocator_not_estimated_reason())
1011                }
1012                _ => return None,
1013            };
1014            let age_ms =
1015                allocator_slack_elapsed_ms(std::time::Instant::now()).saturating_sub(sampled_at_ms);
1016            return Some((snapshot, age_ms));
1017        }
1018    }
1019    #[cfg(not(any(target_os = "macos", target_os = "linux")))]
1020    {
1021        None
1022    }
1023}
1024
1025#[cfg(any(target_os = "macos", target_os = "linux"))]
1026fn publish_allocator_slack(snapshot: &AllocatorMemorySnapshot, sampled_at_ms: u64) {
1027    let _publish_guard = ALLOCATOR_OBSERVATION_PUBLISH_LOCK
1028        .lock()
1029        .unwrap_or_else(std::sync::PoisonError::into_inner);
1030    ALLOCATOR_OBSERVATION_SEQUENCE.fetch_add(1, std::sync::atomic::Ordering::AcqRel);
1031    LAST_ALLOCATOR_OBSERVATION_STATUS.store(
1032        if snapshot.status == "measured" {
1033            ALLOCATOR_OBSERVATION_MEASURED
1034        } else {
1035            ALLOCATOR_OBSERVATION_NOT_ESTIMATED
1036        },
1037        std::sync::atomic::Ordering::Relaxed,
1038    );
1039    LAST_OBSERVED_ALLOCATOR_BYTES_IN_USE.store(
1040        snapshot.bytes_in_use.unwrap_or(UNKNOWN_ALLOCATOR_SLACK),
1041        std::sync::atomic::Ordering::Relaxed,
1042    );
1043    LAST_OBSERVED_ALLOCATOR_SIZE_ALLOCATED.store(
1044        snapshot.size_allocated.unwrap_or(UNKNOWN_ALLOCATOR_SLACK),
1045        std::sync::atomic::Ordering::Relaxed,
1046    );
1047    LAST_OBSERVED_ALLOCATOR_SLACK_BYTES.store(
1048        snapshot
1049            .retained_slack_bytes
1050            .unwrap_or(UNKNOWN_ALLOCATOR_SLACK),
1051        std::sync::atomic::Ordering::Relaxed,
1052    );
1053    LAST_ALLOCATOR_OBSERVATION_AT_MS.store(sampled_at_ms, std::sync::atomic::Ordering::Relaxed);
1054    ALLOCATOR_OBSERVATION_SEQUENCE.fetch_add(1, std::sync::atomic::Ordering::Release);
1055    LAST_ALLOCATOR_SLACK_SAMPLE_AT_MS.store(sampled_at_ms, std::sync::atomic::Ordering::Release);
1056}
1057
1058#[cfg(any(target_os = "macos", target_os = "linux"))]
1059fn sample_is_stale(now_ms: u64) -> bool {
1060    let sampled_at = LAST_ALLOCATOR_SLACK_SAMPLE_AT_MS.load(std::sync::atomic::Ordering::Acquire);
1061    sampled_at == UNSAMPLED_AT_MS
1062        || now_ms.saturating_sub(sampled_at) >= ALLOCATOR_SLACK_SAMPLE_INTERVAL.as_millis() as u64
1063}
1064
1065#[cfg(any(target_os = "macos", target_os = "linux"))]
1066fn reserve_sample(now_ms: u64) -> Option<u64> {
1067    let previous = LAST_ALLOCATOR_SLACK_SAMPLE_AT_MS.load(std::sync::atomic::Ordering::Acquire);
1068    if !sample_is_stale(now_ms) {
1069        return None;
1070    }
1071    LAST_ALLOCATOR_SLACK_SAMPLE_AT_MS
1072        .compare_exchange(
1073            previous,
1074            now_ms,
1075            std::sync::atomic::Ordering::AcqRel,
1076            std::sync::atomic::Ordering::Acquire,
1077        )
1078        .ok()
1079}
1080
1081#[cfg(any(target_os = "macos", target_os = "linux"))]
1082fn reserve_relief(now_ms: u64) -> Option<u64> {
1083    let previous = LAST_ALLOCATOR_SLACK_RELIEF_AT_MS.load(std::sync::atomic::Ordering::Acquire);
1084    let due = cached_allocator_slack().is_some_and(|slack| {
1085        slack >= ALLOCATOR_SLACK_RELIEF_THRESHOLD_BYTES
1086            && (previous == UNSAMPLED_AT_MS
1087                || now_ms.saturating_sub(previous)
1088                    >= ALLOCATOR_SLACK_RELIEF_MIN_INTERVAL.as_millis() as u64)
1089    });
1090    due.then(|| {
1091        LAST_ALLOCATOR_SLACK_RELIEF_AT_MS
1092            .compare_exchange(
1093                previous,
1094                now_ms,
1095                std::sync::atomic::Ordering::AcqRel,
1096                std::sync::atomic::Ordering::Acquire,
1097            )
1098            .ok()
1099    })?
1100}
1101
1102#[cfg(any(target_os = "macos", target_os = "linux"))]
1103fn log_allocator_relief(relief: AllocatorPressureRelief) {
1104    log::info!(
1105        "allocator slack relief: released={} allocator_slack_bytes_before={:?} allocator_slack_bytes_after={:?} rss_bytes_before={:?} rss_bytes_after={:?}",
1106        relief.bytes_released,
1107        relief.allocator_before.retained_slack_bytes,
1108        relief.allocator_after.retained_slack_bytes,
1109        relief.rss_before_bytes,
1110        relief.rss_after_bytes,
1111    );
1112}
1113
1114pub fn last_allocator_relief_at_ms() -> Option<u64> {
1115    #[cfg(any(target_os = "macos", target_os = "linux"))]
1116    {
1117        let value = LAST_ALLOCATOR_SLACK_RELIEF_AT_MS.load(std::sync::atomic::Ordering::Acquire);
1118        return (value != UNSAMPLED_AT_MS).then_some(value);
1119    }
1120    #[cfg(not(any(target_os = "macos", target_os = "linux")))]
1121    {
1122        None
1123    }
1124}
1125
1126pub fn last_allocator_relief_freed_bytes() -> u64 {
1127    #[cfg(any(target_os = "macos", target_os = "linux"))]
1128    {
1129        return LAST_ALLOCATOR_SLACK_RELIEF_FREED_BYTES.load(std::sync::atomic::Ordering::Acquire);
1130    }
1131    #[cfg(not(any(target_os = "macos", target_os = "linux")))]
1132    {
1133        0
1134    }
1135}
1136
1137/// Decide whether an opportunistic allocator relief pass is due.
1138#[cfg(any(target_os = "macos", target_os = "linux"))]
1139pub fn allocator_slack_relief_due(
1140    retained_slack_bytes: Option<u64>,
1141    last_relief: Option<std::time::Instant>,
1142    now: std::time::Instant,
1143) -> bool {
1144    let Some(slack) = retained_slack_bytes else {
1145        return false;
1146    };
1147    if slack < ALLOCATOR_SLACK_RELIEF_THRESHOLD_BYTES {
1148        return false;
1149    }
1150    match last_relief {
1151        None => true,
1152        Some(at) => now.duration_since(at) >= ALLOCATOR_SLACK_RELIEF_MIN_INTERVAL,
1153    }
1154}
1155
1156/// Opportunistically return unused allocator pages without reading allocator
1157/// statistics on the module loop. The loop only reads cached atomics. When the
1158/// cached observation is stale, one sampler takes an initial snapshot; if it
1159/// finds enough slack, the page-return operation reuses it and takes only one
1160/// additional after snapshot, limiting the pass to two statistics walks.
1161///
1162/// `phys_footprint_bytes` cannot make this decision: it includes file mappings
1163/// and other resident memory, so it cannot identify mapped-but-unused allocator
1164/// arenas without allocator-zone statistics.
1165#[cfg(any(target_os = "macos", target_os = "linux"))]
1166pub fn spawn_allocator_slack_relief_if_due(now: std::time::Instant) -> bool {
1167    let now_ms = allocator_slack_elapsed_ms(now);
1168    if sample_is_stale(now_ms) {
1169        let Some(previous_sample) = reserve_sample(now_ms) else {
1170            return false;
1171        };
1172        let spawned = std::thread::Builder::new()
1173            .name("aft-mem-slack-sample".to_string())
1174            .spawn(move || {
1175                let before = allocator_memory_snapshot();
1176                publish_allocator_slack(&before, now_ms);
1177                if reserve_relief(now_ms).is_some() {
1178                    let relief = relieve_allocator_pressure_from_snapshot(before);
1179                    publish_allocator_slack(
1180                        &relief.allocator_after,
1181                        allocator_slack_elapsed_ms(std::time::Instant::now()),
1182                    );
1183                    LAST_ALLOCATOR_SLACK_RELIEF_FREED_BYTES
1184                        .store(relief.bytes_released, std::sync::atomic::Ordering::Release);
1185                    log_allocator_relief(relief);
1186                }
1187            })
1188            .is_ok();
1189        if !spawned {
1190            let _ = LAST_ALLOCATOR_SLACK_SAMPLE_AT_MS.compare_exchange(
1191                now_ms,
1192                previous_sample,
1193                std::sync::atomic::Ordering::AcqRel,
1194                std::sync::atomic::Ordering::Acquire,
1195            );
1196        }
1197        return false;
1198    }
1199
1200    let Some(previous_relief) = reserve_relief(now_ms) else {
1201        return false;
1202    };
1203    let spawned = std::thread::Builder::new()
1204        .name("aft-mem-relief".to_string())
1205        .spawn(move || {
1206            let relief = relieve_allocator_pressure();
1207            publish_allocator_slack(
1208                &relief.allocator_after,
1209                allocator_slack_elapsed_ms(std::time::Instant::now()),
1210            );
1211            LAST_ALLOCATOR_SLACK_RELIEF_FREED_BYTES
1212                .store(relief.bytes_released, std::sync::atomic::Ordering::Release);
1213            log_allocator_relief(relief);
1214        })
1215        .is_ok();
1216    if !spawned {
1217        let _ = LAST_ALLOCATOR_SLACK_RELIEF_AT_MS.compare_exchange(
1218            now_ms,
1219            previous_relief,
1220            std::sync::atomic::Ordering::AcqRel,
1221            std::sync::atomic::Ordering::Acquire,
1222        );
1223    }
1224    spawned
1225}
1226
1227/// No allocator-statistics API is wired on this target; the loop-side decision
1228/// is a no-op and the cache statics above stay at their unsampled sentinels.
1229#[cfg(not(any(target_os = "macos", target_os = "linux")))]
1230pub fn spawn_allocator_slack_relief_if_due(_now: std::time::Instant) -> bool {
1231    false
1232}
1233
1234/// Ask the platform allocator to return unused pages after a process-wide idle
1235/// gate. Callers own that gate because allocator pressure relief can add
1236/// latency. Linux invokes glibc's optional `malloc_trim(0)` when the symbol is
1237/// available; non-glibc allocators intentionally remain a no-op.
1238#[cfg(target_os = "macos")]
1239fn relieve_allocator_pressure_from_snapshot(
1240    allocator_before: AllocatorMemorySnapshot,
1241) -> AllocatorPressureRelief {
1242    let rss_before_bytes = process_rss_bytes();
1243    let bytes_released = unsafe { malloc_zone_pressure_relief(std::ptr::null_mut(), 0) };
1244    let allocator_after = allocator_memory_snapshot();
1245    let rss_after_bytes = process_rss_bytes();
1246    AllocatorPressureRelief {
1247        bytes_released: usize_to_u64(bytes_released),
1248        rss_before_bytes,
1249        rss_after_bytes,
1250        allocator_before,
1251        allocator_after,
1252    }
1253}
1254
1255#[cfg(target_os = "macos")]
1256pub fn relieve_allocator_pressure() -> AllocatorPressureRelief {
1257    relieve_allocator_pressure_from_snapshot(allocator_memory_snapshot())
1258}
1259
1260#[cfg(target_os = "linux")]
1261fn relieve_allocator_pressure_from_snapshot(
1262    allocator_before: AllocatorMemorySnapshot,
1263) -> AllocatorPressureRelief {
1264    let rss_before_bytes = process_rss_bytes();
1265    #[cfg(target_env = "gnu")]
1266    if let Some(malloc_trim) = resolved_malloc_trim() {
1267        // SAFETY: resolved_malloc_trim verifies the symbol and its C ABI
1268        // signature before returning the function pointer.
1269        unsafe { malloc_trim(0) };
1270    }
1271    let allocator_after = allocator_memory_snapshot();
1272    let rss_after_bytes = process_rss_bytes();
1273    let bytes_released = allocator_before
1274        .size_allocated
1275        .zip(allocator_after.size_allocated)
1276        .map(|(before, after)| before.saturating_sub(after))
1277        .unwrap_or(0);
1278    AllocatorPressureRelief {
1279        bytes_released,
1280        rss_before_bytes,
1281        rss_after_bytes,
1282        allocator_before,
1283        allocator_after,
1284    }
1285}
1286
1287#[cfg(target_os = "linux")]
1288pub fn relieve_allocator_pressure() -> AllocatorPressureRelief {
1289    relieve_allocator_pressure_from_snapshot(allocator_memory_snapshot())
1290}
1291
1292#[cfg(target_os = "macos")]
1293fn process_rss_bytes() -> Option<u64> {
1294    let mut info = std::mem::MaybeUninit::<libc::proc_taskinfo>::zeroed();
1295    let size = std::mem::size_of::<libc::proc_taskinfo>();
1296    let written = unsafe {
1297        libc::proc_pidinfo(
1298            libc::getpid(),
1299            libc::PROC_PIDTASKINFO,
1300            0,
1301            info.as_mut_ptr().cast(),
1302            i32::try_from(size).ok()?,
1303        )
1304    };
1305    if written != i32::try_from(size).ok()? {
1306        return None;
1307    }
1308    Some(unsafe { info.assume_init() }.pti_resident_size)
1309}
1310
1311/// Kernel physical footprint via `proc_pid_rusage` (`ri_phys_footprint`).
1312/// See `phys_footprint_bytes` for why this, not RSS, is the headline number.
1313#[cfg(target_os = "macos")]
1314fn process_phys_footprint_bytes() -> Option<u64> {
1315    let mut usage = std::mem::MaybeUninit::<libc::rusage_info_v4>::zeroed();
1316    let rc = unsafe {
1317        libc::proc_pid_rusage(
1318            libc::getpid(),
1319            libc::RUSAGE_INFO_V4,
1320            usage.as_mut_ptr().cast(),
1321        )
1322    };
1323    if rc != 0 {
1324        return None;
1325    }
1326    Some(unsafe { usage.assume_init() }.ri_phys_footprint)
1327}
1328
1329#[cfg(not(target_os = "macos"))]
1330fn process_phys_footprint_bytes() -> Option<u64> {
1331    None
1332}
1333
1334#[cfg(target_os = "linux")]
1335fn process_rss_bytes() -> Option<u64> {
1336    let statm = std::fs::read_to_string("/proc/self/statm").ok()?;
1337    let resident_pages = statm.split_whitespace().nth(1)?.parse::<u64>().ok()?;
1338    let page_size = unsafe { libc::sysconf(libc::_SC_PAGESIZE) };
1339    if page_size <= 0 {
1340        return None;
1341    }
1342    resident_pages.checked_mul(page_size as u64)
1343}
1344
1345#[cfg(not(any(target_os = "macos", target_os = "linux")))]
1346fn process_rss_bytes() -> Option<u64> {
1347    None
1348}
1349
1350pub(crate) fn rss_bytes() -> Option<u64> {
1351    process_rss_bytes()
1352}
1353
1354#[cfg(test)]
1355mod tests {
1356    use super::*;
1357
1358    #[test]
1359    fn process_snapshot_preserves_negative_residuals() {
1360        assert_eq!(signed_difference(5, 8), -3);
1361    }
1362
1363    #[cfg(any(target_os = "macos", target_os = "linux"))]
1364    #[test]
1365    fn loop_slack_decision_never_reads_allocator_statistics() {
1366        LAST_OBSERVED_ALLOCATOR_SLACK_BYTES.store(
1367            UNKNOWN_ALLOCATOR_SLACK,
1368            std::sync::atomic::Ordering::Release,
1369        );
1370        LAST_ALLOCATOR_SLACK_SAMPLE_AT_MS
1371            .store(UNSAMPLED_AT_MS, std::sync::atomic::Ordering::Release);
1372        LAST_ALLOCATOR_SLACK_RELIEF_AT_MS
1373            .store(UNSAMPLED_AT_MS, std::sync::atomic::Ordering::Release);
1374        ALLOCATOR_SNAPSHOT_THREADS
1375            .lock()
1376            .expect("clear allocator snapshot thread log")
1377            .clear();
1378
1379        for _ in 0..1_000 {
1380            assert!(!spawn_allocator_slack_relief_if_due(
1381                std::time::Instant::now()
1382            ));
1383        }
1384        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
1385        let sampler_ran = || {
1386            ALLOCATOR_SNAPSHOT_THREADS
1387                .lock()
1388                .expect("read allocator snapshot thread log")
1389                .iter()
1390                .any(|name| name == "aft-mem-slack-sample")
1391        };
1392        while !sampler_ran() {
1393            assert!(std::time::Instant::now() < deadline, "sampler did not run");
1394            std::thread::yield_now();
1395        }
1396        // The thread log is process-global and other tests in the parallel suite
1397        // read allocator statistics on their own threads; the property under
1398        // test is only that THIS thread (the loop stand-in) never did.
1399        let loop_thread = std::thread::current()
1400            .name()
1401            .unwrap_or("unnamed")
1402            .to_string();
1403        let threads = ALLOCATOR_SNAPSHOT_THREADS
1404            .lock()
1405            .expect("read allocator snapshot thread log")
1406            .clone();
1407        assert!(
1408            !threads.iter().any(|name| *name == loop_thread),
1409            "the loop-side decision must never read allocator statistics: {threads:?}"
1410        );
1411    }
1412
1413    #[cfg(any(target_os = "macos", target_os = "linux"))]
1414    #[test]
1415    fn slack_relief_fires_on_large_slack_and_respects_spacing() {
1416        use std::time::{Duration, Instant};
1417        let now = Instant::now();
1418        let big = Some(ALLOCATOR_SLACK_RELIEF_THRESHOLD_BYTES);
1419        // Unknown slack (allocator stats unavailable) never fires.
1420        assert!(!allocator_slack_relief_due(None, None, now));
1421        // Below threshold never fires.
1422        assert!(!allocator_slack_relief_due(
1423            Some(ALLOCATOR_SLACK_RELIEF_THRESHOLD_BYTES - 1),
1424            None,
1425            now
1426        ));
1427        // At threshold with no prior pass fires.
1428        assert!(allocator_slack_relief_due(big, None, now));
1429        // A recent pass suppresses the next one...
1430        let recent = now - Duration::from_secs(10);
1431        assert!(!allocator_slack_relief_due(big, Some(recent), now));
1432        // ...until the minimum spacing has elapsed.
1433        let stale = now - ALLOCATOR_SLACK_RELIEF_MIN_INTERVAL;
1434        assert!(allocator_slack_relief_due(big, Some(stale), now));
1435    }
1436
1437    #[test]
1438    fn json_estimator_scales_with_payload_content() {
1439        let empty = estimated_json_bytes(&serde_json::json!({}));
1440        let populated = estimated_json_bytes(&serde_json::json!({"message": "hello"}));
1441        assert_eq!(empty, 0);
1442        assert!(populated >= 12);
1443    }
1444
1445    #[test]
1446    fn process_snapshot_exposes_sqlite_and_allocator_sections() {
1447        let shared = MemoryEstimate::estimated(7);
1448        let snapshot = ProcessMemorySnapshot::from_roots(&BTreeMap::new(), &shared);
1449        assert_eq!(snapshot.sqlite.status, "measured");
1450        assert!(snapshot.sqlite.memory_highwater_bytes >= snapshot.sqlite.memory_used_bytes);
1451        assert_eq!(
1452            snapshot.total_attributed_bytes,
1453            snapshot.sqlite.memory_used_bytes.saturating_add(7)
1454        );
1455
1456        let serialized = serde_json::to_value(&snapshot).expect("serialize process memory");
1457        assert!(serialized["sqlite"]["memory_used_bytes"].is_u64());
1458        assert!(serialized["allocator"].get("bytes_in_use").is_some());
1459        assert!(serialized["allocator"].get("size_allocated").is_some());
1460        assert!(serialized["allocator"]
1461            .get("retained_slack_bytes")
1462            .is_some());
1463    }
1464
1465    #[cfg(any(target_os = "macos", all(target_os = "linux", target_env = "gnu")))]
1466    #[test]
1467    fn allocator_snapshot_reports_measured_slack() {
1468        let allocator = allocator_memory_snapshot();
1469        if allocator.status == "measured" {
1470            let in_use = allocator.bytes_in_use.expect("allocator bytes in use");
1471            let allocated = allocator.size_allocated.expect("allocator size allocated");
1472            assert_eq!(
1473                allocator.retained_slack_bytes,
1474                Some(allocated.saturating_sub(in_use))
1475            );
1476        } else {
1477            assert_eq!(allocator.status, "not_estimated_on_this_platform");
1478            assert_eq!(allocator.bytes_in_use, None);
1479            assert_eq!(allocator.size_allocated, None);
1480            assert_eq!(allocator.retained_slack_bytes, None);
1481            assert_eq!(
1482                allocator.not_estimated,
1483                Some("mallinfo2_requires_glibc_2_33")
1484            );
1485        }
1486    }
1487
1488    #[cfg(target_os = "linux")]
1489    #[test]
1490    fn linux_allocator_pressure_relief_smoke() {
1491        let mut allocation = vec![0u8; 32 * 1024 * 1024];
1492        for byte in allocation.iter_mut().step_by(4096) {
1493            *byte = 1;
1494        }
1495        std::hint::black_box(&allocation);
1496        drop(allocation);
1497
1498        let relief = relieve_allocator_pressure();
1499        std::hint::black_box(relief);
1500
1501        #[cfg(target_env = "gnu")]
1502        assert!(
1503            resolved_malloc_trim().is_some(),
1504            "glibc malloc_trim must be available for the Linux relief path"
1505        );
1506    }
1507
1508    #[cfg(not(any(target_os = "macos", all(target_os = "linux", target_env = "gnu"))))]
1509    #[test]
1510    fn allocator_snapshot_is_honest_when_platform_counters_are_unavailable() {
1511        let allocator = allocator_memory_snapshot();
1512        assert_eq!(allocator.status, "not_estimated_on_this_platform");
1513        assert_eq!(allocator.bytes_in_use, None);
1514        assert_eq!(allocator.size_allocated, None);
1515        assert_eq!(allocator.retained_slack_bytes, None);
1516        assert_eq!(
1517            allocator.not_estimated,
1518            Some("platform_allocator_statistics_unavailable")
1519        );
1520    }
1521
1522    #[cfg(target_os = "macos")]
1523    #[test]
1524    #[ignore = "bounded live RSS experiment; run explicitly after allocator changes"]
1525    fn allocator_pressure_relief_warm_then_idle_measurement() {
1526        let warm_pages = (0..16 * 1024)
1527            .map(|seed| {
1528                let mut page = Box::new([0u8; 4096]);
1529                page[0] = seed as u8;
1530                page
1531            })
1532            .collect::<Vec<_>>();
1533        std::hint::black_box(&warm_pages);
1534        drop(warm_pages);
1535
1536        let relief = relieve_allocator_pressure();
1537        let sqlite = SqliteMemorySnapshot::measure();
1538        eprintln!(
1539            "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={}",
1540            relief.rss_before_bytes,
1541            relief.rss_after_bytes,
1542            relief.allocator_before.bytes_in_use,
1543            relief.allocator_after.bytes_in_use,
1544            relief.allocator_before.size_allocated,
1545            relief.allocator_after.size_allocated,
1546            relief.allocator_before.retained_slack_bytes,
1547            relief.allocator_after.retained_slack_bytes,
1548            relief.bytes_released,
1549            sqlite.memory_used_bytes,
1550            sqlite.memory_highwater_bytes,
1551        );
1552        assert_eq!(relief.allocator_before.status, "measured");
1553        assert_eq!(relief.allocator_after.status, "measured");
1554    }
1555}