Skip to main content

attribution_processing/
digest.rs

1// Copyright 2025 The Fuchsia Authors. All rights reserved.
2// Use of this source code is governed by a BSD-style license that can be
3// found in the LICENSE file.
4
5use crate::{ProcessedAttributionData, ZXName};
6use anyhow::Result;
7use bstr::ByteSlice;
8use fidl_fuchsia_kernel_common as fkernel;
9use fidl_fuchsia_memory_attribution_plugin_common as fplugin;
10use regex_lite::Regex;
11use serde::de::Error;
12use serde::{Deserialize, Deserializer, Serialize};
13use std::collections::HashMap;
14use std::collections::hash_map::Entry::Occupied;
15#[cfg(target_os = "fuchsia")]
16use {crate::CATEGORY_MEMORY_CAPTURE, fuchsia_trace::duration};
17
18const UNDIGESTED: &str = "Undigested";
19const ORPHANED: &str = "Orphaned";
20const KERNEL: &str = "Kernel";
21const FREE: &str = "Free";
22const PAGER_TOTAL: &str = "[Addl]PagerTotal";
23const PAGER_NEWEST: &str = "[Addl]PagerNewest";
24const PAGER_OLDEST: &str = "[Addl]PagerOldest";
25const DISCARDABLE_LOCKED: &str = "[Addl]DiscardableLocked";
26const DISCARDABLE_UNLOCKED: &str = "[Addl]DiscardableUnlocked";
27const ZRAM_COMPRESSED_BYTES: &str = "[Addl]ZramCompressedBytes";
28const POPULATED_ANONYMOUS_BYTES: &str = "[Addl]PopulatedAnonymousBytes";
29
30/// Represents a specification for aggregating memory usage in meaningful groups.
31///
32/// `name` represents the meaningful name of the group; grouping is done based on process and VMO
33/// names.
34///
35// Note: This needs to mirror `//src/lib/assembly/memory_buckets/src/memory_buckets.rs`, but cannot
36// reuse it directly because it is an host-only library.
37#[derive(Clone, Debug, Deserialize)]
38pub struct BucketDefinition {
39    pub name: String,
40    #[serde(deserialize_with = "deserialize_regex")]
41    pub process: Option<Regex>,
42    #[serde(deserialize_with = "deserialize_regex")]
43    pub vmo: Option<Regex>,
44    #[serde(default, deserialize_with = "deserialize_regex")]
45    pub principal: Option<Regex>,
46    pub event_code: u64,
47}
48
49impl BucketDefinition {
50    /// Tests whether a process matches this bucket's definition, based on its name.
51    fn process_match(&self, process: &ZXName) -> bool {
52        self.process.as_ref().is_none_or(|process_regex| {
53            process
54                .as_bstr()
55                .to_str()
56                .is_ok_and(|process_name| process_regex.is_match(process_name))
57        })
58    }
59
60    /// Tests whether a VMO matches this bucket's definition, based on its name.
61    fn vmo_match(&self, vmo: &ZXName) -> bool {
62        self.vmo.as_ref().is_none_or(|vmo_regex| {
63            vmo.as_bstr().to_str().is_ok_and(|vmo_name| vmo_regex.is_match(vmo_name))
64        })
65    }
66
67    /// Tests whether any of the specified principal names match this bucket's definition.
68    fn principals_match(&self, principals: &Vec<&str>) -> bool {
69        self.principal.as_ref().is_none_or(|a| principals.iter().any(|name| a.is_match(name)))
70    }
71}
72
73// Teach serde to deserialize an optional regex.
74fn deserialize_regex<'de, D>(d: D) -> Result<Option<Regex>, D::Error>
75where
76    D: Deserializer<'de>,
77{
78    // Deserialize as Option<&str>
79    Option::<String>::deserialize(d)
80        // If the parsing failed, return the error, otherwise transform the value
81        .and_then(|os| {
82            os
83                // If there is a value, try to parse it as a Regex.
84                .map(|s| {
85                    Regex::new(&s)
86                        // If the regex compilation failed, wrap the error in the error type expected
87                        // by serde.
88                        .map_err(D::Error::custom)
89                })
90                // If there was a value but it failed to compile, return an error, otherwise return
91                // the potentially parsed option.
92                .transpose()
93        })
94}
95
96/// Aggregates bytes in categories with human readable names.
97#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
98pub struct Bucket {
99    pub name: String,
100    pub populated_size: u64,
101    pub committed_size: u64,
102    pub vmos: Option<Vec<NamedVmo>>,
103}
104
105/// Contains a view of the system's memory usage, aggregated in groups called buckets, which are
106/// configurable.
107#[derive(Debug, Default, PartialEq, Eq, Serialize)]
108pub struct Digest {
109    pub buckets: Vec<Bucket>,
110}
111
112/// Non-owning structure to keep track of known undigested VMOs.
113struct UndigestedVmo<'a> {
114    populated_size: u64,
115    committed_size: u64,
116    name: &'a ZXName,
117    principals: &'a Vec<&'a str>,
118}
119
120#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
121/// Owning structure to report known VMOs.
122pub struct NamedVmo {
123    pub name: ZXName,
124    pub populated_size: u64,
125    pub committed_size: u64,
126    pub principals: Vec<String>,
127}
128
129impl Digest {
130    /// Given means to query the system for memory usage, and a specification, this function
131    /// aggregates the current memory usage into human displayable units we call buckets.
132    pub fn compute(
133        attribution_data: &ProcessedAttributionData,
134        kmem_stats: &fkernel::MemoryStats,
135        kmem_stats_compression: &fkernel::MemoryStatsCompression,
136        bucket_definitions: &[BucketDefinition],
137        detailed_vmos: bool,
138    ) -> Result<Digest> {
139        #[cfg(target_os = "fuchsia")]
140        duration!(CATEGORY_MEMORY_CAPTURE, c"Digest::compute");
141
142        // Maps resources' (VMO, Process, Job. See Resource) ids
143        // to their owner, i.e. the principal they have been
144        // attributed to.
145        let owners: HashMap<u64, Vec<&str>> = {
146            let koid_to_principal = attribution_data
147                .principals
148                .iter()
149                .flat_map(|(_, p)| p.resources.iter().map(|r| (*r, p.name())));
150
151            let mut owners: HashMap<u64, Vec<_>> = HashMap::new();
152            for (koid, principal) in koid_to_principal {
153                let principals = owners.entry(koid).or_default();
154                principals.push(principal);
155            }
156            owners
157        };
158
159        let no_principals = vec![];
160        let mut populated_reclaimable_bytes = 0;
161        let mut undigested_vmos: HashMap<u64, UndigestedVmo<'_>> = attribution_data
162            .resources
163            .iter()
164            .filter_map(|(koid, r)| match &r.resource.resource_type {
165                fplugin::ResourceType::Vmo(vmo) => {
166                    attribution_data.resource_names.get(r.resource.name_index).and_then(|name| {
167                        let populated_size = vmo.scaled_populated_bytes?;
168                        let committed_size = vmo.scaled_committed_bytes?;
169                        if vmo.flags.map_or(false, |flags| {
170                            flags
171                                & (zx_types::ZX_INFO_VMO_PAGER_BACKED
172                                    | zx_types::ZX_INFO_VMO_DISCARDABLE)
173                                != 0
174                        }) {
175                            populated_reclaimable_bytes += populated_size;
176                        }
177                        Some((
178                            *koid,
179                            UndigestedVmo {
180                                name,
181                                populated_size,
182                                committed_size,
183                                principals: owners.get(koid).unwrap_or(&no_principals),
184                            },
185                        ))
186                    })
187                }
188                _ => None,
189            })
190            .collect();
191        let processes: Vec<(&ZXName, &fplugin::Process)> = attribution_data
192            .resources
193            .values()
194            .filter_map(|r| match &r.resource.resource_type {
195                fplugin::ResourceType::Process(process) => attribution_data
196                    .resource_names
197                    .get(r.resource.name_index)
198                    .map(|name| (name, process)),
199                _ => None,
200            })
201            .collect();
202
203        let mut buckets: Vec<Bucket> = bucket_definitions
204            .iter()
205            .map(|bd| {
206                let mut bucket = Bucket {
207                    name: bd.name.to_owned(),
208                    populated_size: 0,
209                    committed_size: 0,
210                    vmos: None,
211                };
212                processes.iter().for_each(|(process_name, process)| {
213                    if bd.process_match(process_name) {
214                        for koid in process.vmos.iter().flatten() {
215                            let (populated_size, committed_size) = match undigested_vmos
216                                .entry(*koid)
217                            {
218                                Occupied(e) => {
219                                    let UndigestedVmo { name, principals, .. } = e.get();
220                                    if bd.vmo_match(&name) && bd.principals_match(principals) {
221                                        let (_, vmo) = e.remove_entry();
222                                        if detailed_vmos {
223                                            bucket.vmos.get_or_insert_default().push(NamedVmo {
224                                                name: vmo.name.clone(),
225                                                populated_size: vmo.populated_size,
226                                                committed_size: vmo.committed_size,
227                                                principals: vmo
228                                                    .principals
229                                                    .iter()
230                                                    .map(|&name| name.to_owned())
231                                                    .collect(),
232                                            });
233                                        }
234                                        (vmo.populated_size, vmo.committed_size)
235                                    } else {
236                                        (0, 0)
237                                    }
238                                }
239                                _ => (0, 0),
240                            };
241                            bucket.committed_size += committed_size;
242                            bucket.populated_size += populated_size;
243                        }
244                    };
245                });
246                bucket
247            })
248            .collect();
249
250        // This bucket contains the total size of the known VMOs that have not been covered
251        // by any other bucket.
252        let undigested = {
253            let (populated_size, committed_size) = undigested_vmos
254                .values()
255                .map(|UndigestedVmo { populated_size, committed_size, .. }| {
256                    (*populated_size, *committed_size)
257                })
258                .fold((0, 0), |(total_populated, total_committed), (populated, committed)| {
259                    (total_populated + populated, total_committed + committed)
260                });
261
262            Bucket {
263                name: UNDIGESTED.to_string(),
264                populated_size: populated_size,
265                committed_size,
266                vmos: if detailed_vmos {
267                    Some(
268                        undigested_vmos
269                            .values()
270                            .map(|vmo| NamedVmo {
271                                name: vmo.name.clone(),
272                                populated_size: vmo.populated_size,
273                                committed_size: vmo.committed_size,
274                                principals: vmo
275                                    .principals
276                                    .iter()
277                                    .map(|&name| name.to_owned())
278                                    .collect(),
279                            })
280                            .collect(),
281                    )
282                } else {
283                    None
284                },
285            }
286        };
287
288        let total_vmo_size: u64 = undigested.committed_size
289            + buckets.iter().map(|Bucket { committed_size, .. }| committed_size).sum::<u64>();
290
291        // Extend the configured aggregation with a number of additional, occasionally useful meta
292        // aggregations.
293        buckets.extend([
294            undigested,
295            // This bucket accounts for VMO bytes that have been allocated by the kernel, but not
296            // claimed by any VMO (anymore).
297            {
298                let size = kmem_stats.vmo_bytes.unwrap_or(0).saturating_sub(total_vmo_size);
299                Bucket {
300                    name: ORPHANED.to_string(),
301                    populated_size: size,
302                    committed_size: size,
303                    vmos: None,
304                }
305            },
306            // This bucket aggregates overall kernel memory usage.
307            {
308                let size = (|| {
309                    Some(
310                        kmem_stats.wired_bytes?
311                            + kmem_stats.total_heap_bytes?
312                            + kmem_stats.mmu_overhead_bytes?
313                            + kmem_stats.ipc_bytes?
314                            + kmem_stats.other_bytes?
315                            + kmem_stats.slab_bytes?
316                            + kmem_stats.cache_bytes?,
317                    )
318                })()
319                .unwrap_or(0);
320                Bucket {
321                    name: KERNEL.to_string(),
322                    populated_size: size,
323                    committed_size: size,
324                    vmos: None,
325                }
326            },
327            // This bucket contains the amount of free memory in the system.
328            {
329                let size = kmem_stats.free_bytes.unwrap_or(0);
330                Bucket {
331                    name: FREE.to_string(),
332                    populated_size: size,
333                    committed_size: size,
334                    vmos: None,
335                }
336            },
337            // Those buckets contain pager related information.
338            {
339                let size = kmem_stats.vmo_reclaim_total_bytes.unwrap_or(0);
340                Bucket {
341                    name: PAGER_TOTAL.to_string(),
342                    populated_size: size,
343                    committed_size: size,
344                    vmos: None,
345                }
346            },
347            {
348                let size = kmem_stats.vmo_reclaim_newest_bytes.unwrap_or(0);
349                Bucket {
350                    name: PAGER_NEWEST.to_string(),
351                    populated_size: size,
352                    committed_size: size,
353                    vmos: None,
354                }
355            },
356            {
357                let size = kmem_stats.vmo_reclaim_oldest_bytes.unwrap_or(0);
358                Bucket {
359                    name: PAGER_OLDEST.to_string(),
360                    populated_size: size,
361                    committed_size: size,
362                    vmos: None,
363                }
364            },
365            // Those buckets account for discardable memory.
366            {
367                let size = kmem_stats.vmo_discardable_locked_bytes.unwrap_or(0);
368                Bucket {
369                    name: DISCARDABLE_LOCKED.to_string(),
370                    populated_size: size,
371                    committed_size: size,
372                    vmos: None,
373                }
374            },
375            {
376                let size = kmem_stats.vmo_discardable_unlocked_bytes.unwrap_or(0);
377                Bucket {
378                    name: DISCARDABLE_UNLOCKED.to_string(),
379                    populated_size: size,
380                    committed_size: size,
381                    vmos: None,
382                }
383            },
384            // This bucket accounts for compressed memory.
385            {
386                let size = kmem_stats_compression.compressed_storage_bytes.unwrap_or(0);
387                Bucket {
388                    name: ZRAM_COMPRESSED_BYTES.to_string(),
389                    populated_size: size,
390                    committed_size: size,
391                    vmos: None,
392                }
393            },
394            // This bucket accounts for all populated anonymous memory (non-reclaimable).
395            {
396                let size = (kmem_stats.total_bytes.unwrap_or(0)
397                    + kmem_stats_compression.uncompressed_storage_bytes.unwrap_or(0))
398                .saturating_sub(kmem_stats.free_bytes.unwrap_or(0))
399                .saturating_sub(kmem_stats.zram_bytes.unwrap_or(0))
400                .saturating_sub(populated_reclaimable_bytes);
401
402                Bucket {
403                    name: POPULATED_ANONYMOUS_BYTES.to_string(),
404                    populated_size: size,
405                    committed_size: size,
406                    vmos: None,
407                }
408            },
409        ]);
410        Ok(Digest { buckets })
411    }
412}
413
414#[cfg(test)]
415mod tests {
416    use super::*;
417    use crate::{
418        Attribution, AttributionData, GlobalPrincipalIdentifier, Principal, PrincipalDescription,
419        PrincipalType, ProcessedAttributionData, Resource, ResourceReference, attribute_vmos,
420    };
421    use fidl_fuchsia_memory_attribution_plugin_common as fplugin;
422    use regex_lite::Regex;
423
424    fn get_attribution_data() -> ProcessedAttributionData {
425        attribute_vmos(AttributionData {
426            principals_vec: vec![
427                Principal {
428                    identifier: GlobalPrincipalIdentifier::new_for_test(1),
429                    description: Some(PrincipalDescription::Component("principal".to_owned())),
430                    principal_type: PrincipalType::Runnable,
431                    parent: Some(GlobalPrincipalIdentifier::new_for_test(2)),
432                },
433                Principal {
434                    identifier: GlobalPrincipalIdentifier::new_for_test(2),
435                    description: Some(PrincipalDescription::Component("parent".to_owned())),
436                    principal_type: PrincipalType::Runnable,
437                    parent: None,
438                },
439            ],
440            resources_vec: vec![
441                Resource {
442                    koid: 10,
443                    name_index: 0,
444                    resource_type: fplugin::ResourceType::Vmo(fplugin::Vmo {
445                        parent: None,
446                        private_committed_bytes: Some(1024),
447                        private_populated_bytes: Some(2048),
448                        scaled_committed_bytes: Some(512),
449                        scaled_populated_bytes: Some(2048),
450                        total_committed_bytes: Some(1024),
451                        total_populated_bytes: Some(2048),
452                        ..Default::default()
453                    }),
454                },
455                Resource {
456                    koid: 20,
457                    name_index: 1,
458                    resource_type: fplugin::ResourceType::Vmo(fplugin::Vmo {
459                        parent: None,
460                        private_committed_bytes: Some(1024),
461                        private_populated_bytes: Some(2048),
462                        scaled_committed_bytes: Some(512),
463                        scaled_populated_bytes: Some(2048),
464                        total_committed_bytes: Some(1024),
465                        total_populated_bytes: Some(2048),
466                        ..Default::default()
467                    }),
468                },
469                Resource {
470                    koid: 30,
471                    name_index: 1,
472                    resource_type: fplugin::ResourceType::Process(fplugin::Process {
473                        vmos: Some(vec![10, 20]),
474                        ..Default::default()
475                    }),
476                },
477            ],
478            resource_names: vec![
479                ZXName::try_from_bytes(b"resource").unwrap(),
480                ZXName::try_from_bytes(b"matched").unwrap(),
481            ],
482            attributions: vec![Attribution {
483                source: GlobalPrincipalIdentifier::new_for_test(1),
484                subject: GlobalPrincipalIdentifier::new_for_test(1),
485                resources: vec![ResourceReference::KernelObject(20)],
486            }],
487        })
488    }
489
490    fn get_kernel_stats() -> (fkernel::MemoryStats, fkernel::MemoryStatsCompression) {
491        (
492            fkernel::MemoryStats {
493                total_bytes: Some(20),
494                free_bytes: Some(2),
495                wired_bytes: Some(3),
496                total_heap_bytes: Some(4),
497                free_heap_bytes: Some(5),
498                vmo_bytes: Some(10000),
499                mmu_overhead_bytes: Some(7),
500                ipc_bytes: Some(8),
501                other_bytes: Some(9),
502                free_loaned_bytes: Some(10),
503                cache_bytes: Some(11),
504                slab_bytes: Some(12),
505                zram_bytes: Some(13),
506                vmo_reclaim_total_bytes: Some(14),
507                vmo_reclaim_newest_bytes: Some(15),
508                vmo_reclaim_oldest_bytes: Some(16),
509                vmo_reclaim_disabled_bytes: Some(17),
510                vmo_discardable_locked_bytes: Some(18),
511                vmo_discardable_unlocked_bytes: Some(19),
512                ..Default::default()
513            },
514            fkernel::MemoryStatsCompression {
515                uncompressed_storage_bytes: Some(1),
516                compressed_storage_bytes: Some(21),
517                compressed_fragmentation_bytes: Some(22),
518                compression_time: Some(23),
519                decompression_time: Some(24),
520                total_page_compression_attempts: Some(25),
521                failed_page_compression_attempts: Some(26),
522                total_page_decompressions: Some(27),
523                compressed_page_evictions: Some(28),
524                eager_page_compressions: Some(29),
525                memory_pressure_page_compressions: Some(30),
526                critical_memory_page_compressions: Some(31),
527                pages_decompressed_unit_ns: Some(32),
528                pages_decompressed_within_log_time: Some([40, 41, 42, 43, 44, 45, 46, 47]),
529                ..Default::default()
530            },
531        )
532    }
533
534    fn sort_buckets_for_assert(digest: &mut Digest) {
535        for bucket in digest.buckets.iter_mut() {
536            for vmos in bucket.vmos.iter_mut() {
537                vmos.sort_by(|vmo1, vmo2| vmo1.name.cmp(&vmo2.name));
538            }
539        }
540    }
541
542    #[test]
543    fn test_digest_no_definitions() {
544        let (kernel_stats, kernel_stats_compression) = get_kernel_stats();
545        let digest = {
546            let mut digest = Digest::compute(
547                &get_attribution_data(),
548                &kernel_stats,
549                &kernel_stats_compression,
550                &vec![],
551                true,
552            )
553            .unwrap();
554            sort_buckets_for_assert(&mut digest);
555            digest
556        };
557        let expected_buckets = vec![
558            // The two VMOs are unmatched, 512 + 512
559            Bucket {
560                name: UNDIGESTED.to_string(),
561                populated_size: 4096,
562                committed_size: 1024,
563                vmos: Some(vec![
564                    NamedVmo {
565                        name: ZXName::from_string_lossy("matched"),
566                        populated_size: 2048,
567                        committed_size: 512,
568                        principals: vec!["principal".to_string()],
569                    },
570                    NamedVmo {
571                        name: ZXName::from_string_lossy("resource"),
572                        populated_size: 2048,
573                        committed_size: 512,
574                        principals: vec![],
575                    },
576                ]),
577            },
578            // No matched VMOs, one UNDIGESTED VMO => 10000 - 1024 = 8976
579            Bucket {
580                name: ORPHANED.to_string(),
581                populated_size: 8976,
582                committed_size: 8976,
583                vmos: None,
584            },
585            // wired + heap + mmu + ipc + other + slab + cache => 3 + 4 + 7 + 8 + 9 + 12 + 11 = 54
586            Bucket { name: KERNEL.to_string(), populated_size: 54, committed_size: 54, vmos: None },
587            Bucket { name: FREE.to_string(), populated_size: 2, committed_size: 2, vmos: None },
588            Bucket {
589                name: PAGER_TOTAL.to_string(),
590                populated_size: 14,
591                committed_size: 14,
592                vmos: None,
593            },
594            Bucket {
595                name: PAGER_NEWEST.to_string(),
596                populated_size: 15,
597                committed_size: 15,
598                vmos: None,
599            },
600            Bucket {
601                name: PAGER_OLDEST.to_string(),
602                populated_size: 16,
603                committed_size: 16,
604                vmos: None,
605            },
606            Bucket {
607                name: DISCARDABLE_LOCKED.to_string(),
608                populated_size: 18,
609                committed_size: 18,
610                vmos: None,
611            },
612            Bucket {
613                name: DISCARDABLE_UNLOCKED.to_string(),
614                populated_size: 19,
615                committed_size: 19,
616                vmos: None,
617            },
618            Bucket {
619                name: ZRAM_COMPRESSED_BYTES.to_string(),
620                populated_size: 21,
621                committed_size: 21,
622                vmos: None,
623            },
624            Bucket {
625                name: POPULATED_ANONYMOUS_BYTES.to_string(),
626                populated_size: 6,
627                committed_size: 6,
628                vmos: None,
629            },
630        ];
631
632        assert_eq!(digest.buckets, expected_buckets);
633    }
634
635    #[test]
636    fn test_digest_with_matching_vmo() -> Result<(), anyhow::Error> {
637        let (kernel_stats, kernel_stats_compression) = get_kernel_stats();
638        let digest = {
639            let mut digest = Digest::compute(
640                &get_attribution_data(),
641                &kernel_stats,
642                &kernel_stats_compression,
643                &vec![BucketDefinition {
644                    name: "matched".to_string(),
645                    process: None,
646                    vmo: Some(Regex::new("matched")?),
647                    principal: None,
648                    event_code: Default::default(),
649                }],
650                true,
651            )
652            .unwrap();
653            sort_buckets_for_assert(&mut digest);
654            digest
655        };
656        let expected_buckets = vec![
657            // One VMO is matched, the other is not
658            Bucket {
659                name: "matched".to_string(),
660                populated_size: 2048,
661                committed_size: 512,
662                vmos: Some(vec![NamedVmo {
663                    name: ZXName::from_string_lossy("matched"),
664                    populated_size: 2048,
665                    committed_size: 512,
666                    principals: vec!["principal".to_owned()],
667                }]),
668            },
669            // One unmatched VMO
670            Bucket {
671                name: UNDIGESTED.to_string(),
672                populated_size: 2048,
673                committed_size: 512,
674                vmos: Some(vec![NamedVmo {
675                    name: ZXName::from_string_lossy("resource"),
676                    populated_size: 2048,
677                    committed_size: 512,
678                    principals: vec![],
679                }]),
680            },
681            // One matched VMO, one unmatched VMO //=> 10000 - 512 - 512 = 8976
682            Bucket {
683                name: ORPHANED.to_string(),
684                populated_size: 8976,
685                committed_size: 8976,
686                vmos: None,
687            },
688            // wired + heap + mmu + ipc + other + slab + cache => 3 + 4 + 7 + 8 + 9 + 12 + 11 = 54
689            Bucket { name: KERNEL.to_string(), populated_size: 54, committed_size: 54, vmos: None },
690            Bucket { name: FREE.to_string(), populated_size: 2, committed_size: 2, vmos: None },
691            Bucket {
692                name: PAGER_TOTAL.to_string(),
693                populated_size: 14,
694                committed_size: 14,
695                vmos: None,
696            },
697            Bucket {
698                name: PAGER_NEWEST.to_string(),
699                populated_size: 15,
700                committed_size: 15,
701                vmos: None,
702            },
703            Bucket {
704                name: PAGER_OLDEST.to_string(),
705                populated_size: 16,
706                committed_size: 16,
707                vmos: None,
708            },
709            Bucket {
710                name: DISCARDABLE_LOCKED.to_string(),
711                populated_size: 18,
712                committed_size: 18,
713                vmos: None,
714            },
715            Bucket {
716                name: DISCARDABLE_UNLOCKED.to_string(),
717                populated_size: 19,
718                committed_size: 19,
719                vmos: None,
720            },
721            Bucket {
722                name: ZRAM_COMPRESSED_BYTES.to_string(),
723                populated_size: 21,
724                committed_size: 21,
725                vmos: None,
726            },
727            Bucket {
728                name: POPULATED_ANONYMOUS_BYTES.to_string(),
729                populated_size: 6,
730                committed_size: 6,
731                vmos: None,
732            },
733        ];
734
735        assert_eq!(digest.buckets, expected_buckets);
736        Ok(())
737    }
738
739    #[test]
740    fn test_digest_with_matching_process() -> Result<(), anyhow::Error> {
741        let (kernel_stats, kernel_stats_compression) = get_kernel_stats();
742        let digest = {
743            let mut digest = Digest::compute(
744                &get_attribution_data(),
745                &kernel_stats,
746                &kernel_stats_compression,
747                &vec![BucketDefinition {
748                    name: "matched".to_string(),
749                    process: Some(Regex::new("matched")?),
750                    vmo: None,
751                    principal: None,
752                    event_code: Default::default(),
753                }],
754                true,
755            )
756            .unwrap();
757            sort_buckets_for_assert(&mut digest);
758            digest
759        };
760        let expected_buckets = vec![
761            // Both VMOs are matched => 512 + 512 = 1024
762            Bucket {
763                name: "matched".to_string(),
764                populated_size: 4096,
765                committed_size: 1024,
766                vmos: Some(vec![
767                    NamedVmo {
768                        name: ZXName::from_string_lossy("matched"),
769                        populated_size: 2048,
770                        committed_size: 512,
771                        principals: vec!["principal".to_owned()],
772                    },
773                    NamedVmo {
774                        name: ZXName::from_string_lossy("resource"),
775                        populated_size: 2048,
776                        committed_size: 512,
777                        principals: vec![],
778                    },
779                ]),
780            },
781            // No unmatched VMO
782            Bucket {
783                name: UNDIGESTED.to_string(),
784                populated_size: 0,
785                committed_size: 0,
786                vmos: Some(vec![]),
787            },
788            // Two matched VMO => 10000 - 512 - 512 = 8976
789            Bucket {
790                name: ORPHANED.to_string(),
791                populated_size: 8976,
792                committed_size: 8976,
793                vmos: None,
794            },
795            // wired + heap + mmu + ipc + other + slab + cache => 3 + 4 + 7 + 8 + 9 + 12 + 11 = 54
796            Bucket { name: KERNEL.to_string(), populated_size: 54, committed_size: 54, vmos: None },
797            Bucket { name: FREE.to_string(), populated_size: 2, committed_size: 2, vmos: None },
798            Bucket {
799                name: PAGER_TOTAL.to_string(),
800                populated_size: 14,
801                committed_size: 14,
802                vmos: None,
803            },
804            Bucket {
805                name: PAGER_NEWEST.to_string(),
806                populated_size: 15,
807                committed_size: 15,
808                vmos: None,
809            },
810            Bucket {
811                name: PAGER_OLDEST.to_string(),
812                populated_size: 16,
813                committed_size: 16,
814                vmos: None,
815            },
816            Bucket {
817                name: DISCARDABLE_LOCKED.to_string(),
818                populated_size: 18,
819                committed_size: 18,
820                vmos: None,
821            },
822            Bucket {
823                name: DISCARDABLE_UNLOCKED.to_string(),
824                populated_size: 19,
825                committed_size: 19,
826                vmos: None,
827            },
828            Bucket {
829                name: ZRAM_COMPRESSED_BYTES.to_string(),
830                populated_size: 21,
831                committed_size: 21,
832                vmos: None,
833            },
834            Bucket {
835                name: POPULATED_ANONYMOUS_BYTES.to_string(),
836                populated_size: 6,
837                committed_size: 6,
838                vmos: None,
839            },
840        ];
841
842        assert_eq!(digest.buckets, expected_buckets);
843        Ok(())
844    }
845
846    #[test]
847    fn test_digest_with_matching_principal() -> Result<(), anyhow::Error> {
848        let (kernel_stats, kernel_stats_compression) = get_kernel_stats();
849        let digest = {
850            let mut digest = Digest::compute(
851                &get_attribution_data(),
852                &kernel_stats,
853                &kernel_stats_compression,
854                &vec![BucketDefinition {
855                    name: "matched".to_string(),
856                    process: None,
857                    vmo: None,
858                    principal: Some(Regex::new("principal")?),
859                    event_code: Default::default(),
860                }],
861                true,
862            )
863            .unwrap();
864            sort_buckets_for_assert(&mut digest);
865            digest
866        };
867        let expected_buckets = vec![
868            // One VMO is matched, the other is not
869            Bucket {
870                name: "matched".to_string(),
871                populated_size: 2048,
872                committed_size: 512,
873                vmos: Some(vec![NamedVmo {
874                    name: ZXName::from_string_lossy("matched"),
875                    populated_size: 2048,
876                    committed_size: 512,
877                    principals: vec!["principal".to_owned()],
878                }]),
879            },
880            // One unmatched VMO
881            Bucket {
882                name: UNDIGESTED.to_string(),
883                populated_size: 2048,
884                committed_size: 512,
885                vmos: Some(vec![NamedVmo {
886                    name: ZXName::from_string_lossy("resource"),
887                    populated_size: 2048,
888                    committed_size: 512,
889                    principals: vec![],
890                }]),
891            },
892            // One matched VMO, one unmatched VMO //=> 10000 - 512 - 512 = 8976
893            Bucket {
894                name: ORPHANED.to_string(),
895                populated_size: 8976,
896                committed_size: 8976,
897                vmos: None,
898            },
899            // wired + heap + mmu + ipc + other + slab + cache => 3 + 4 + 7 + 8 + 9 + 12 + 11 = 54
900            Bucket { name: KERNEL.to_string(), populated_size: 54, committed_size: 54, vmos: None },
901            Bucket { name: FREE.to_string(), populated_size: 2, committed_size: 2, vmos: None },
902            Bucket {
903                name: PAGER_TOTAL.to_string(),
904                populated_size: 14,
905                committed_size: 14,
906                vmos: None,
907            },
908            Bucket {
909                name: PAGER_NEWEST.to_string(),
910                populated_size: 15,
911                committed_size: 15,
912                vmos: None,
913            },
914            Bucket {
915                name: PAGER_OLDEST.to_string(),
916                populated_size: 16,
917                committed_size: 16,
918                vmos: None,
919            },
920            Bucket {
921                name: DISCARDABLE_LOCKED.to_string(),
922                populated_size: 18,
923                committed_size: 18,
924                vmos: None,
925            },
926            Bucket {
927                name: DISCARDABLE_UNLOCKED.to_string(),
928                populated_size: 19,
929                committed_size: 19,
930                vmos: None,
931            },
932            Bucket {
933                name: ZRAM_COMPRESSED_BYTES.to_string(),
934                populated_size: 21,
935                committed_size: 21,
936                vmos: None,
937            },
938            Bucket {
939                name: POPULATED_ANONYMOUS_BYTES.to_string(),
940                populated_size: 6,
941                committed_size: 6,
942                vmos: None,
943            },
944        ];
945
946        assert_eq!(digest.buckets, expected_buckets);
947        Ok(())
948    }
949
950    #[test]
951    fn test_digest_with_matching_principal_process_and_vmo() -> Result<(), anyhow::Error> {
952        let (kernel_stats, kernel_stats_compression) = get_kernel_stats();
953        let digest = {
954            let mut digest = Digest::compute(
955                &get_attribution_data(),
956                &kernel_stats,
957                &kernel_stats_compression,
958                &vec![BucketDefinition {
959                    name: "matched".to_string(),
960                    process: Some(Regex::new("matched")?),
961                    vmo: Some(Regex::new("matched")?),
962                    principal: Some(Regex::new("principal")?),
963                    event_code: Default::default(),
964                }],
965                true,
966            )
967            .unwrap();
968            sort_buckets_for_assert(&mut digest);
969            digest
970        };
971        let expected_buckets = vec![
972            // One VMO is matched, the other is not
973            Bucket {
974                name: "matched".to_string(),
975                populated_size: 2048,
976                committed_size: 512,
977                vmos: Some(vec![NamedVmo {
978                    name: ZXName::from_string_lossy("matched"),
979                    populated_size: 2048,
980                    committed_size: 512,
981                    principals: vec!["principal".to_owned()],
982                }]),
983            },
984            // One unmatched VMO
985            Bucket {
986                name: UNDIGESTED.to_string(),
987                populated_size: 2048,
988                committed_size: 512,
989                vmos: Some(vec![NamedVmo {
990                    name: ZXName::from_string_lossy("resource"),
991                    populated_size: 2048,
992                    committed_size: 512,
993                    principals: vec![],
994                }]),
995            },
996            // One matched VMO, one unmatched VMO => 10000 - 512 - 512 = 8976
997            Bucket {
998                name: ORPHANED.to_string(),
999                populated_size: 8976,
1000                committed_size: 8976,
1001                vmos: None,
1002            },
1003            // wired + heap + mmu + ipc + other + slab + cache => 3 + 4 + 7 + 8 + 9 + 12 + 11 = 54
1004            Bucket { name: KERNEL.to_string(), populated_size: 54, committed_size: 54, vmos: None },
1005            Bucket { name: FREE.to_string(), populated_size: 2, committed_size: 2, vmos: None },
1006            Bucket {
1007                name: PAGER_TOTAL.to_string(),
1008                populated_size: 14,
1009                committed_size: 14,
1010                vmos: None,
1011            },
1012            Bucket {
1013                name: PAGER_NEWEST.to_string(),
1014                populated_size: 15,
1015                committed_size: 15,
1016                vmos: None,
1017            },
1018            Bucket {
1019                name: PAGER_OLDEST.to_string(),
1020                populated_size: 16,
1021                committed_size: 16,
1022                vmos: None,
1023            },
1024            Bucket {
1025                name: DISCARDABLE_LOCKED.to_string(),
1026                populated_size: 18,
1027                committed_size: 18,
1028                vmos: None,
1029            },
1030            Bucket {
1031                name: DISCARDABLE_UNLOCKED.to_string(),
1032                populated_size: 19,
1033                committed_size: 19,
1034                vmos: None,
1035            },
1036            Bucket {
1037                name: ZRAM_COMPRESSED_BYTES.to_string(),
1038                populated_size: 21,
1039                committed_size: 21,
1040                vmos: None,
1041            },
1042            Bucket {
1043                name: POPULATED_ANONYMOUS_BYTES.to_string(),
1044                populated_size: 6,
1045                committed_size: 6,
1046                vmos: None,
1047            },
1048        ];
1049
1050        assert_eq!(digest.buckets, expected_buckets);
1051        Ok(())
1052    }
1053
1054    /// What is tested: `Digest::compute` with `detailed_vmos: false` (the production periodic
1055    /// monitoring path) and skipping of VMO list population.
1056    ///
1057    /// Expectations verified:
1058    /// - When `detailed_vmos == false`, every bucket in `digest.buckets` (including matched buckets
1059    ///   and `UNDIGESTED`) has `bucket.vmos == None`.
1060    /// - Verifies that `committed_size` and `populated_size` totals match expected values
1061    ///   identically to when `detailed_vmos == true`.
1062    #[test]
1063    fn test_digest_compute_undetailed_vmos_fast_path() -> Result<(), anyhow::Error> {
1064        let (kernel_stats, kernel_stats_compression) = get_kernel_stats();
1065        let digest = Digest::compute(
1066            &get_attribution_data(),
1067            &kernel_stats,
1068            &kernel_stats_compression,
1069            &vec![BucketDefinition {
1070                name: "matched".to_string(),
1071                process: None,
1072                vmo: Some(Regex::new("matched")?),
1073                principal: None,
1074                event_code: Default::default(),
1075            }],
1076            false, // detailed_vmos = false
1077        )?;
1078
1079        for bucket in &digest.buckets {
1080            assert!(bucket.vmos.is_none(), "Bucket '{}' should have vmos == None", bucket.name);
1081        }
1082        let matched_bucket = digest.buckets.iter().find(|b| b.name == "matched").unwrap();
1083        assert_eq!(matched_bucket.committed_size, 512);
1084        assert_eq!(matched_bucket.populated_size, 2048);
1085        let undigested_bucket = digest.buckets.iter().find(|b| b.name == UNDIGESTED).unwrap();
1086        assert_eq!(undigested_bucket.committed_size, 512);
1087        assert_eq!(undigested_bucket.populated_size, 2048);
1088        Ok(())
1089    }
1090
1091    /// What is tested: First-match-wins bucket priority ordering and deduplication across multiple
1092    /// overlapping `BucketDefinition`s in `Digest::compute`.
1093    ///
1094    /// Expectations verified:
1095    /// - When two bucket definitions both match the same VMO (`first_bucket` matches
1096    ///   `vmo="matched"`, and `second_bucket` matches `vmo=".*"`), the earlier bucket claims the
1097    ///   VMO.
1098    /// - Verifies that `second_bucket` does not double-count VMO 20 (`committed_size == 0` for VMO
1099    ///   20), and only claims the remaining unconsumed VMO 10 (`resource`).
1100    #[test]
1101    fn test_digest_bucket_priority_and_deduplication() -> Result<(), anyhow::Error> {
1102        let (kernel_stats, kernel_stats_compression) = get_kernel_stats();
1103        let digest = Digest::compute(
1104            &get_attribution_data(),
1105            &kernel_stats,
1106            &kernel_stats_compression,
1107            &vec![
1108                BucketDefinition {
1109                    name: "first_bucket".to_string(),
1110                    process: None,
1111                    vmo: Some(Regex::new("matched")?),
1112                    principal: None,
1113                    event_code: Default::default(),
1114                },
1115                BucketDefinition {
1116                    name: "second_bucket".to_string(),
1117                    process: None,
1118                    vmo: Some(Regex::new(".*")?),
1119                    principal: None,
1120                    event_code: Default::default(),
1121                },
1122            ],
1123            true,
1124        )?;
1125
1126        let b1 = digest.buckets.iter().find(|b| b.name == "first_bucket").unwrap();
1127        assert_eq!(b1.committed_size, 512); // VMO 20 ("matched")
1128        let b2 = digest.buckets.iter().find(|b| b.name == "second_bucket").unwrap();
1129        assert_eq!(b2.committed_size, 512); // VMO 10 ("resource")
1130        let undigested = digest.buckets.iter().find(|b| b.name == UNDIGESTED).unwrap();
1131        assert_eq!(undigested.committed_size, 0);
1132        Ok(())
1133    }
1134
1135    /// What is tested: Multi-attribute `BucketDefinition` matching where all non-None attributes
1136    /// (`process`, `vmo`, `principal`) must match simultaneously, and partial mismatch fallthrough.
1137    ///
1138    /// Expectations verified:
1139    /// - A bucket with matching `process` ("matched") but non-matching `vmo` ("nonexistent_vmo")
1140    ///   fails to claim the VMO.
1141    /// - Verifies that partial mismatches leave the VMO unclaimed so it is assigned to
1142    ///   `UNDIGESTED`.
1143    #[test]
1144    fn test_digest_multi_attribute_matching_and_partial_mismatch() -> Result<(), anyhow::Error> {
1145        let (kernel_stats, kernel_stats_compression) = get_kernel_stats();
1146        let digest = Digest::compute(
1147            &get_attribution_data(),
1148            &kernel_stats,
1149            &kernel_stats_compression,
1150            &vec![BucketDefinition {
1151                name: "partial_mismatch".to_string(),
1152                process: Some(Regex::new("matched")?),
1153                vmo: Some(Regex::new("nonexistent_vmo")?),
1154                principal: Some(Regex::new("principal")?),
1155                event_code: Default::default(),
1156            }],
1157            true,
1158        )?;
1159
1160        let b = digest.buckets.iter().find(|b| b.name == "partial_mismatch").unwrap();
1161        assert_eq!(b.committed_size, 0);
1162        let undigested = digest.buckets.iter().find(|b| b.name == UNDIGESTED).unwrap();
1163        assert_eq!(undigested.committed_size, 1024); // Both VMO 10 and 20 remain undigested
1164        Ok(())
1165    }
1166
1167    /// What is tested: VMO pager-backed / discardable flags (`ZX_INFO_VMO_PAGER_BACKED` and
1168    /// `ZX_INFO_VMO_DISCARDABLE`) and their impact on `POPULATED_ANONYMOUS_BYTES`.
1169    ///
1170    /// Expectations verified:
1171    /// - VMOs with pager-backed or discardable flags set have their `scaled_populated_bytes`
1172    ///   accumulated in `populated_reclaimable_bytes`.
1173    /// - Verifies that `POPULATED_ANONYMOUS_BYTES` is reduced by the populated reclaimable amount.
1174    #[test]
1175    fn test_digest_compute_reclaimable_vmo_flags_and_anonymous_bytes() -> Result<(), anyhow::Error>
1176    {
1177        use zx_types::{ZX_INFO_VMO_DISCARDABLE, ZX_INFO_VMO_PAGER_BACKED};
1178        let mut attr_data = get_attribution_data();
1179        let vmo_res = attr_data.resources.get_mut(&20).unwrap();
1180        if let fplugin::ResourceType::Vmo(vmo) = &mut vmo_res.resource.resource_type {
1181            vmo.flags = Some(ZX_INFO_VMO_PAGER_BACKED | ZX_INFO_VMO_DISCARDABLE);
1182        }
1183
1184        let (kernel_stats, kernel_stats_compression) = get_kernel_stats();
1185        let digest =
1186            Digest::compute(&attr_data, &kernel_stats, &kernel_stats_compression, &vec![], true)?;
1187
1188        let anon_bucket =
1189            digest.buckets.iter().find(|b| b.name == POPULATED_ANONYMOUS_BYTES).unwrap();
1190        assert_eq!(anon_bucket.populated_size, 0);
1191        Ok(())
1192    }
1193
1194    /// What is tested: `Digest::compute` handling of default/missing (`None`) kernel memory stats
1195    /// and `saturating_sub` underflow protection for the `ORPHANED` bucket.
1196    ///
1197    /// Expectations verified:
1198    /// - Verifies that `Digest::compute` succeeds without panicking when `fkernel::MemoryStats` and
1199    ///   `fkernel::MemoryStatsCompression` have all fields set to `None` (`Default::default()`).
1200    /// - Verifies that `ORPHANED`, `KERNEL`, `FREE`, and all pager/discardable/zram/anonymous
1201    ///   buckets cleanly default to `0` without underflowing.
1202    #[test]
1203    fn test_digest_missing_kernel_stats_and_saturating_orphaned() -> Result<(), anyhow::Error> {
1204        let digest = Digest::compute(
1205            &get_attribution_data(),
1206            &fkernel::MemoryStats::default(),
1207            &fkernel::MemoryStatsCompression::default(),
1208            &vec![],
1209            true,
1210        )?;
1211
1212        let orphaned = digest.buckets.iter().find(|b| b.name == ORPHANED).unwrap();
1213        assert_eq!(orphaned.committed_size, 0);
1214        let kernel = digest.buckets.iter().find(|b| b.name == KERNEL).unwrap();
1215        assert_eq!(kernel.committed_size, 0);
1216        let free = digest.buckets.iter().find(|b| b.name == FREE).unwrap();
1217        assert_eq!(free.committed_size, 0);
1218        let anon = digest.buckets.iter().find(|b| b.name == POPULATED_ANONYMOUS_BYTES).unwrap();
1219        assert_eq!(anon.committed_size, 0);
1220        let zram = digest.buckets.iter().find(|b| b.name == ZRAM_COMPRESSED_BYTES).unwrap();
1221        assert_eq!(zram.committed_size, 0);
1222        Ok(())
1223    }
1224}