Skip to main content

detcore_model/
syscall_histogram.rs

1/*
2 * Copyright (c) Meta Platforms, Inc. and affiliates.
3 * All rights reserved.
4 *
5 * This source code is licensed under the BSD-style license found in the
6 * LICENSE file in the root directory of this source tree.
7 */
8
9//! Per-run syscall histograms: how many times the guest made each syscall,
10//! and what Detcore did with it.
11//!
12//! Detcore counts every syscall entry by number, at the same accounting point
13//! as the run summary's scalar `syscalls` count, and at the end of its handler
14//! records one [`SyscallOutcome`]. Each thread keeps its own
15//! [`SyscallHistogram`] and hands its nonzero slots
16//! ([`SparseSyscallHistogram`]) to the scheduler with its other final
17//! statistics when it deregisters, so a run's histogram covers exactly the
18//! calls of the `syscalls` total. The run summary reports the merged histogram
19//! as a [`SyscallHistogramSummary`]: sparse, ordered by x86-64 syscall number,
20//! each entry carrying the pinned syscall name, together with the
21//! [`SyscallObservationScope`] that says whether the accounting point saw
22//! every syscall the guest made.
23//!
24//! This is the producer the compatibility-website plan asks for
25//! (dev-hermit `ai_docs/compatibility-website-vision-and-implementation-plan-20260830.md`,
26//! "Syscall totals" and Stage 3).
27//!
28//! Counting reads no guest state and changes nothing the guest can observe.
29
30use reverie_syscalls::Sysno;
31use serde::Deserialize;
32use serde::Serialize;
33
34/// What Detcore did with one completed syscall.
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub enum SyscallOutcome {
37    /// Detcore produced the result without handing any syscall to the backend.
38    Detcore,
39    /// Detcore handed at least one syscall to the backend (the host kernel, or
40    /// the backend's own Linux provider) while handling this one.
41    Backend,
42    /// The guest received `ENOSYS`, or a Detcore refusal policy answered the
43    /// call (a fixed-errno refusal, or a backend's typed refusal).
44    Refused,
45}
46
47/// Counts for one syscall.
48///
49/// `calls` counts entries. The three outcomes count completed calls, so
50/// `calls` minus their sum ([`SyscallOutcomes::unfinished`]) is the number of
51/// calls that never returned to Detcore's handler (`exit`, `exit_group`, a
52/// successful `execve`, a thread killed during the call) or whose backend does
53/// not count injections ([`reverie::Guest::injected_syscalls`]).
54#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Default)]
55pub struct SyscallOutcomes {
56    /// Entries into Detcore's syscall handler.
57    pub calls: u64,
58    /// Completed with [`SyscallOutcome::Detcore`].
59    pub detcore: u64,
60    /// Completed with [`SyscallOutcome::Backend`].
61    pub backend: u64,
62    /// Completed with [`SyscallOutcome::Refused`].
63    pub refused: u64,
64}
65
66impl SyscallOutcomes {
67    /// Nothing counted.
68    pub const ZERO: Self = Self {
69        calls: 0,
70        detcore: 0,
71        backend: 0,
72        refused: 0,
73    };
74
75    /// Add another count of the same syscall to this one.
76    pub fn merge(&mut self, other: &SyscallOutcomes) {
77        self.calls += other.calls;
78        self.detcore += other.detcore;
79        self.backend += other.backend;
80        self.refused += other.refused;
81    }
82
83    /// Calls entered but given no outcome.
84    pub fn unfinished(&self) -> u64 {
85        self.calls
86            .saturating_sub(self.detcore + self.backend + self.refused)
87    }
88}
89
90/// Why the accounting point did not see every syscall the guest made.
91#[derive(
92    Debug,
93    Serialize,
94    Deserialize,
95    Clone,
96    Copy,
97    PartialEq,
98    Eq,
99    PartialOrd,
100    Ord
101)]
102#[serde(rename_all = "snake_case")]
103pub enum SyscallObservationGap {
104    /// `--passthru-opt`: syscalls outside the optimized subscription bypass
105    /// Detcore.
106    PassthruOpt,
107    /// The backend's runtime starts inside the guest process after the
108    /// guest's dynamic loader has run, so the loader's syscalls are not
109    /// counted (LiteInst, in-guest-trap, SaBRe).
110    InProcessRuntimeStart,
111}
112
113/// Whether a histogram counts every syscall the guest made.
114#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
115#[serde(tag = "kind", rename_all = "snake_case")]
116pub enum SyscallObservationScope {
117    /// Every syscall the guest made reached the accounting point.
118    Complete,
119    /// Some syscalls bypassed it, for these reasons; the histogram is a lower
120    /// bound and must not be read as complete usage.
121    Partial { gaps: Vec<SyscallObservationGap> },
122}
123
124impl SyscallObservationScope {
125    /// The scope given the known gaps: complete when there are none.
126    pub fn from_gaps(gaps: impl IntoIterator<Item = SyscallObservationGap>) -> Self {
127        let mut gaps: Vec<_> = gaps.into_iter().collect();
128        gaps.sort();
129        gaps.dedup();
130        if gaps.is_empty() {
131            Self::Complete
132        } else {
133            Self::Partial { gaps }
134        }
135    }
136
137    /// Whether every syscall was counted.
138    pub fn is_complete(&self) -> bool {
139        matches!(self, Self::Complete)
140    }
141}
142
143/// One syscall's counts in a [`SyscallHistogramSummary`].
144#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
145pub struct SyscallHistogramEntry {
146    /// The syscall number in the summary's architecture.
147    pub nr: i32,
148    /// The pinned syscall table's name for `nr`, or `syscall_<nr>` for a
149    /// number the table does not name.
150    pub name: String,
151    /// Entries and outcomes.
152    #[serde(flatten)]
153    pub counts: SyscallOutcomes,
154}
155
156/// A run's syscall histogram as the run summary reports it.
157#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
158pub struct SyscallHistogramSummary {
159    /// The syscall table the numbers belong to; always `x86_64` today.
160    pub architecture: String,
161    /// Whether the counts cover every syscall the guest made.
162    pub observation_scope: SyscallObservationScope,
163    /// Every counted syscall, by ascending number; syscalls never made are
164    /// absent.
165    pub syscalls: Vec<SyscallHistogramEntry>,
166}
167
168impl SyscallHistogramSummary {
169    /// The architecture this producer counts in.
170    pub const ARCHITECTURE: &'static str = "x86_64";
171
172    /// Total entries over every syscall: equal to the run summary's
173    /// `syscalls` for a consistent summary.
174    pub fn total_calls(&self) -> u64 {
175        self.syscalls.iter().map(|entry| entry.counts.calls).sum()
176    }
177
178    /// The entry for the syscall named `name`, if it was counted.
179    pub fn get(&self, name: &str) -> Option<&SyscallHistogramEntry> {
180        self.syscalls.iter().find(|entry| entry.name == name)
181    }
182
183    /// The compact machine form of this histogram.
184    pub fn compact(&self) -> CompactSyscallHistogram {
185        CompactSyscallHistogram {
186            architecture: self.architecture.clone(),
187            observation_scope: self.observation_scope.clone(),
188            counts: self
189                .syscalls
190                .iter()
191                .map(|entry| {
192                    let SyscallOutcomes {
193                        calls,
194                        detcore,
195                        backend,
196                        refused,
197                    } = entry.counts;
198                    [
199                        u64::from(entry.nr.unsigned_abs()),
200                        calls,
201                        detcore,
202                        backend,
203                        refused,
204                    ]
205                })
206                .collect(),
207        }
208    }
209}
210
211/// A run's syscall histogram in the compact form machine records keep: the
212/// verification report's `runtime.run1`/`run2`, and from it each cell's
213/// result row and the durable series row (run 1 only). Names are left out:
214/// a reader takes them from the pinned table for `architecture`.
215#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
216#[serde(deny_unknown_fields)]
217pub struct CompactSyscallHistogram {
218    /// The syscall table the numbers belong to; always `x86_64` today.
219    pub architecture: String,
220    /// Whether the counts cover every syscall the guest made.
221    pub observation_scope: SyscallObservationScope,
222    /// `[nr, calls, detcore, backend, refused]` for each counted syscall, by
223    /// ascending number ([`SyscallOutcomes`] names the four counts).
224    pub counts: Vec<[u64; 5]>,
225}
226
227impl CompactSyscallHistogram {
228    /// Total entries over every syscall.
229    pub fn total_calls(&self) -> u64 {
230        self.counts.iter().map(|[_, calls, ..]| calls).sum()
231    }
232
233    /// Why this histogram is malformed, if it is: an architecture other than
234    /// `x86_64`, a number outside the pinned table, numbers out of ascending
235    /// order or repeated, an entry with no calls, or more outcomes than
236    /// calls.
237    pub fn malformation(&self) -> Option<String> {
238        if self.architecture != SyscallHistogramSummary::ARCHITECTURE {
239            return Some(format!(
240                "syscall histogram architecture is {:?}, expected {:?}",
241                self.architecture,
242                SyscallHistogramSummary::ARCHITECTURE
243            ));
244        }
245        let mut previous = None;
246        for [nr, calls, detcore, backend, refused] in &self.counts {
247            let in_table = usize::try_from(*nr)
248                .ok()
249                .and_then(|nr| nr.checked_sub(Sysno::first().id() as usize))
250                .is_some_and(|slot| slot < SYSCALL_TABLE_SIZE);
251            if !in_table {
252                return Some(format!("syscall {nr} is outside the x86_64 table"));
253            }
254            if previous.is_some_and(|previous| previous >= *nr) {
255                return Some(format!("syscall {nr} is out of ascending order"));
256            }
257            previous = Some(*nr);
258            if *calls == 0 {
259                return Some(format!("syscall {nr} has an entry with no calls"));
260            }
261            let outcomes = detcore
262                .checked_add(*backend)
263                .and_then(|sum| sum.checked_add(*refused));
264            if outcomes.is_none_or(|outcomes| outcomes > *calls) {
265                return Some(format!(
266                    "syscall {nr} has more outcomes than its {calls} calls"
267                ));
268            }
269        }
270        None
271    }
272}
273
274/// Slots in a [`SyscallHistogram`]: one per number of the pinned x86-64
275/// syscall table, gaps included.
276pub const SYSCALL_TABLE_SIZE: usize = Sysno::table_size();
277
278/// A histogram indexed by x86-64 syscall number, as Detcore collects it: a
279/// dense array with one [`SyscallOutcomes`] per table slot, allocated once
280/// when the thread's state is created, so counting a syscall is two array
281/// increments with no allocation and no lookup. A thread that exits hands
282/// over only its nonzero slots ([`SparseSyscallHistogram`]), and the run's
283/// total sums those back into one dense histogram.
284///
285/// It serializes sparsely, as `(number, counts)` pairs for the nonzero slots in
286/// ascending order, so the thread-exit message and any saved thread state stay
287/// small.
288#[derive(Clone, PartialEq, Eq)]
289pub struct SyscallHistogram {
290    counts: Box<[SyscallOutcomes; SYSCALL_TABLE_SIZE]>,
291}
292
293impl Default for SyscallHistogram {
294    fn default() -> Self {
295        Self {
296            counts: Box::new([SyscallOutcomes::ZERO; SYSCALL_TABLE_SIZE]),
297        }
298    }
299}
300
301/// The slot of `sysno`: every `Sysno` is in the table.
302fn slot(sysno: Sysno) -> usize {
303    (sysno.id() - Sysno::first().id()) as usize
304}
305
306impl SyscallHistogram {
307    /// Count one entry of `sysno`.
308    pub fn enter(&mut self, sysno: Sysno) {
309        self.counts[slot(sysno)].calls += 1;
310    }
311
312    /// Record the outcome of one completed call of `sysno`.
313    pub fn finish(&mut self, sysno: Sysno, outcome: SyscallOutcome) {
314        let counts = &mut self.counts[slot(sysno)];
315        match outcome {
316            SyscallOutcome::Detcore => counts.detcore += 1,
317            SyscallOutcome::Backend => counts.backend += 1,
318            SyscallOutcome::Refused => counts.refused += 1,
319        }
320    }
321
322    /// Add every count of `other` to this histogram.
323    pub fn merge(&mut self, other: &SyscallHistogram) {
324        for (mine, theirs) in self.counts.iter_mut().zip(other.counts.iter()) {
325            mine.merge(theirs);
326        }
327    }
328
329    /// Total entries over every syscall.
330    pub fn total_calls(&self) -> u64 {
331        self.counts.iter().map(|counts| counts.calls).sum()
332    }
333
334    /// Whether nothing has been counted.
335    pub fn is_empty(&self) -> bool {
336        self.nonzero().next().is_none()
337    }
338
339    /// The counts of one syscall, if it was counted.
340    pub fn get(&self, sysno: Sysno) -> Option<&SyscallOutcomes> {
341        Some(&self.counts[slot(sysno)]).filter(|counts| **counts != SyscallOutcomes::ZERO)
342    }
343
344    /// The nonzero slots as `(number, counts)`, by ascending number.
345    fn nonzero(&self) -> impl Iterator<Item = (i32, &SyscallOutcomes)> {
346        self.counts
347            .iter()
348            .enumerate()
349            .filter(|(_, counts)| **counts != SyscallOutcomes::ZERO)
350            .map(|(index, counts)| (Sysno::first().id() + index as i32, counts))
351    }
352
353    /// The run-summary form of this histogram, with its observation scope:
354    /// only the nonzero slots. A number without a name in the x86-64 table is
355    /// reported as `syscall_<number>`.
356    pub fn summary(&self, observation_scope: SyscallObservationScope) -> SyscallHistogramSummary {
357        SyscallHistogramSummary {
358            architecture: SyscallHistogramSummary::ARCHITECTURE.to_owned(),
359            observation_scope,
360            syscalls: self
361                .nonzero()
362                .map(|(nr, counts)| SyscallHistogramEntry {
363                    nr,
364                    name: syscall_name(nr),
365                    counts: *counts,
366                })
367                .collect(),
368        }
369    }
370}
371
372impl std::fmt::Debug for SyscallHistogram {
373    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
374        f.debug_map().entries(self.nonzero()).finish()
375    }
376}
377
378impl Serialize for SyscallHistogram {
379    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
380        // Collected first: a length-prefixed format (the bincode RPC) needs
381        // the count before the elements.
382        let pairs: Vec<_> = self.nonzero().collect();
383        pairs.serialize(serializer)
384    }
385}
386
387impl<'de> Deserialize<'de> for SyscallHistogram {
388    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
389        let pairs = Vec::<(i32, SyscallOutcomes)>::deserialize(deserializer)?;
390        let mut histogram = Self::default();
391        let mut previous = None;
392        for (nr, counts) in pairs {
393            let index = nr
394                .checked_sub(Sysno::first().id())
395                .and_then(|index| usize::try_from(index).ok())
396                .filter(|index| *index < SYSCALL_TABLE_SIZE)
397                .ok_or_else(|| {
398                    serde::de::Error::custom(format!("syscall {nr} is outside the table"))
399                })?;
400            if previous.is_some_and(|previous| previous >= nr) {
401                return Err(serde::de::Error::custom(format!(
402                    "syscall {nr} is out of ascending order"
403                )));
404            }
405            previous = Some(nr);
406            histogram.counts[index] = counts;
407        }
408        Ok(histogram)
409    }
410}
411
412/// A finished thread's histogram: only its nonzero slots, `(number, counts)`
413/// by ascending number.
414///
415/// A thread counts into a dense [`SyscallHistogram`], which costs a fixed
416/// 14,784 bytes; a thread that has exited reports this form instead, so the
417/// scheduler keeps a few hundred bytes per finished thread rather than the
418/// whole array (a 4,000-thread run kept 59 MB of dense arrays). The run's
419/// total is summed back into one dense histogram
420/// ([`SparseSyscallHistogram::add_to`]).
421#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, Default)]
422pub struct SparseSyscallHistogram {
423    entries: Vec<(i32, SyscallOutcomes)>,
424}
425
426impl From<&SyscallHistogram> for SparseSyscallHistogram {
427    fn from(histogram: &SyscallHistogram) -> Self {
428        Self {
429            entries: histogram
430                .nonzero()
431                .map(|(nr, counts)| (nr, *counts))
432                .collect(),
433        }
434    }
435}
436
437impl SparseSyscallHistogram {
438    /// Add every count of `other` to this histogram, keeping number order.
439    pub fn merge(&mut self, other: &SparseSyscallHistogram) {
440        let mut merged = Vec::with_capacity(self.entries.len() + other.entries.len());
441        let (mut mine, mut theirs) = (
442            self.entries.iter().peekable(),
443            other.entries.iter().peekable(),
444        );
445        loop {
446            let next = match (mine.peek(), theirs.peek()) {
447                (Some((left, _)), Some((right, _))) if left < right => *mine.next().unwrap(),
448                (Some((left, _)), Some((right, _))) if right < left => *theirs.next().unwrap(),
449                (Some(_), Some(_)) => {
450                    let (nr, mut counts) = *mine.next().unwrap();
451                    counts.merge(&theirs.next().unwrap().1);
452                    (nr, counts)
453                }
454                (Some(_), None) => *mine.next().unwrap(),
455                (None, Some(_)) => *theirs.next().unwrap(),
456                (None, None) => break,
457            };
458            merged.push(next);
459        }
460        self.entries = merged;
461    }
462
463    /// Add every count of this histogram to the dense `total`. A number
464    /// outside the pinned table, which no [`SyscallHistogram`] can produce,
465    /// is skipped.
466    pub fn add_to(&self, total: &mut SyscallHistogram) {
467        for (nr, counts) in &self.entries {
468            if let Some(slot) = nr
469                .checked_sub(Sysno::first().id())
470                .and_then(|slot| usize::try_from(slot).ok())
471                .filter(|slot| *slot < SYSCALL_TABLE_SIZE)
472            {
473                total.counts[slot].merge(counts);
474            }
475        }
476    }
477
478    /// Total entries over every syscall.
479    pub fn total_calls(&self) -> u64 {
480        self.entries.iter().map(|(_, counts)| counts.calls).sum()
481    }
482}
483
484/// Every syscall the pinned x86-64 table names, as `(number, name)` by
485/// ascending number. A coverage report takes its never-exercised list from
486/// this, so it names exactly the syscalls a histogram can count.
487pub fn pinned_syscall_table() -> Vec<(i32, &'static str)> {
488    (0..SYSCALL_TABLE_SIZE)
489        .filter_map(|slot| Sysno::new(slot + Sysno::first().id() as usize))
490        .map(|sysno| (sysno.id(), sysno.name()))
491        .collect()
492}
493
494fn syscall_name(nr: i32) -> String {
495    usize::try_from(nr)
496        .ok()
497        .and_then(Sysno::new)
498        .map_or_else(|| format!("syscall_{nr}"), |sysno| sysno.name().to_owned())
499}
500
501#[cfg(test)]
502mod tests {
503    use super::*;
504
505    #[test]
506    fn entries_and_outcomes_are_counted_separately() {
507        let mut histogram = SyscallHistogram::default();
508        histogram.enter(Sysno::read);
509        histogram.finish(Sysno::read, SyscallOutcome::Backend);
510        histogram.enter(Sysno::read);
511        histogram.finish(Sysno::read, SyscallOutcome::Refused);
512        histogram.enter(Sysno::getpid);
513        histogram.finish(Sysno::getpid, SyscallOutcome::Detcore);
514        histogram.enter(Sysno::exit_group);
515
516        assert_eq!(histogram.total_calls(), 4);
517        assert_eq!(
518            histogram.get(Sysno::read),
519            Some(&SyscallOutcomes {
520                calls: 2,
521                detcore: 0,
522                backend: 1,
523                refused: 1,
524            })
525        );
526        let exit_group = histogram.get(Sysno::exit_group).unwrap();
527        assert_eq!(exit_group.calls, 1);
528        assert_eq!(exit_group.unfinished(), 1);
529        assert_eq!(histogram.get(Sysno::read).unwrap().unfinished(), 0);
530        assert_eq!(histogram.get(Sysno::write), None);
531    }
532
533    #[test]
534    fn merge_adds_every_count() {
535        let mut left = SyscallHistogram::default();
536        left.enter(Sysno::read);
537        left.finish(Sysno::read, SyscallOutcome::Backend);
538        let mut right = SyscallHistogram::default();
539        right.enter(Sysno::read);
540        right.finish(Sysno::read, SyscallOutcome::Detcore);
541        right.enter(Sysno::write);
542        left.merge(&right);
543
544        assert_eq!(left.total_calls(), 3);
545        let read = left.get(Sysno::read).unwrap();
546        assert_eq!((read.calls, read.detcore, read.backend), (2, 1, 1));
547        assert_eq!(left.get(Sysno::write).unwrap().calls, 1);
548
549        let mut empty = SyscallHistogram::default();
550        assert!(empty.is_empty());
551        empty.merge(&left);
552        assert_eq!(empty, left);
553    }
554
555    #[test]
556    fn the_summary_is_ordered_by_number_and_carries_names_and_scope() {
557        let mut histogram = SyscallHistogram::default();
558        histogram.enter(Sysno::write);
559        histogram.enter(Sysno::read);
560        histogram.finish(Sysno::read, SyscallOutcome::Backend);
561        let summary = histogram.summary(SyscallObservationScope::Complete);
562        let json = serde_json::to_string(&summary).unwrap();
563        assert_eq!(
564            json,
565            r#"{"architecture":"x86_64","observation_scope":{"kind":"complete"},"syscalls":[{"nr":0,"name":"read","calls":1,"detcore":0,"backend":1,"refused":0},{"nr":1,"name":"write","calls":1,"detcore":0,"backend":0,"refused":0}]}"#
566        );
567        assert_eq!(
568            serde_json::from_str::<SyscallHistogramSummary>(&json).unwrap(),
569            summary
570        );
571        assert_eq!(summary.total_calls(), 2);
572        assert_eq!(summary.get("write").unwrap().nr, 1);
573        assert_eq!(syscall_name(-1), "syscall_-1");
574        assert_eq!(syscall_name(100_000), "syscall_100000");
575    }
576
577    #[test]
578    fn a_scope_with_gaps_is_partial_and_sorted() {
579        assert_eq!(
580            SyscallObservationScope::from_gaps([]),
581            SyscallObservationScope::Complete
582        );
583        let partial = SyscallObservationScope::from_gaps([
584            SyscallObservationGap::InProcessRuntimeStart,
585            SyscallObservationGap::PassthruOpt,
586            SyscallObservationGap::InProcessRuntimeStart,
587        ]);
588        assert!(!partial.is_complete());
589        assert_eq!(
590            serde_json::to_string(&partial).unwrap(),
591            r#"{"kind":"partial","gaps":["passthru_opt","in_process_runtime_start"]}"#
592        );
593    }
594
595    #[test]
596    fn every_table_number_has_a_slot_and_the_wire_form_is_sparse() {
597        let mut histogram = SyscallHistogram::default();
598        // Every number the table names, gaps skipped.
599        for sysno in
600            (0..SYSCALL_TABLE_SIZE).filter_map(|nr| Sysno::new(nr + Sysno::first().id() as usize))
601        {
602            histogram.enter(sysno);
603        }
604        assert_eq!(histogram.total_calls(), Sysno::count() as u64);
605        assert_eq!(histogram.get(Sysno::last()).unwrap().calls, 1);
606
607        let mut sparse = SyscallHistogram::default();
608        sparse.enter(Sysno::write);
609        sparse.finish(Sysno::write, SyscallOutcome::Detcore);
610        assert_eq!(
611            serde_json::to_string(&sparse).unwrap(),
612            r#"[[1,{"calls":1,"detcore":1,"backend":0,"refused":0}]]"#
613        );
614        let out_of_table = format!(
615            r#"[[{SYSCALL_TABLE_SIZE},{{"calls":1,"detcore":0,"backend":0,"refused":0}}]]"#
616        );
617        assert!(serde_json::from_str::<SyscallHistogram>(&out_of_table).is_err());
618        let unordered = r#"[[1,{"calls":1,"detcore":0,"backend":0,"refused":0}],[0,{"calls":1,"detcore":0,"backend":0,"refused":0}]]"#;
619        assert!(serde_json::from_str::<SyscallHistogram>(unordered).is_err());
620    }
621
622    #[test]
623    fn the_pinned_table_names_every_countable_syscall_once() {
624        let table = pinned_syscall_table();
625        assert_eq!(table.len(), Sysno::count());
626        assert_eq!(table.first(), Some(&(0, "read")));
627        assert!(table.windows(2).all(|pair| pair[0].0 < pair[1].0));
628        let names: std::collections::BTreeSet<_> = table.iter().map(|(_, name)| name).collect();
629        assert_eq!(names.len(), table.len());
630    }
631
632    #[test]
633    fn the_compact_form_keeps_numbers_counts_and_scope() {
634        let mut histogram = SyscallHistogram::default();
635        histogram.enter(Sysno::openat);
636        histogram.finish(Sysno::openat, SyscallOutcome::Backend);
637        histogram.enter(Sysno::read);
638        histogram.enter(Sysno::read);
639        histogram.finish(Sysno::read, SyscallOutcome::Detcore);
640        let scope = SyscallObservationScope::from_gaps([SyscallObservationGap::PassthruOpt]);
641        let compact = histogram.summary(scope).compact();
642        assert_eq!(
643            serde_json::to_string(&compact).unwrap(),
644            r#"{"architecture":"x86_64","observation_scope":{"kind":"partial","gaps":["passthru_opt"]},"counts":[[0,2,1,0,0],[257,1,0,1,0]]}"#
645        );
646        assert_eq!(compact.total_calls(), 3);
647        assert_eq!(compact.malformation(), None);
648    }
649
650    #[test]
651    fn a_malformed_compact_histogram_names_its_fault() {
652        let good = CompactSyscallHistogram {
653            architecture: "x86_64".into(),
654            observation_scope: SyscallObservationScope::Complete,
655            counts: vec![[0, 2, 1, 1, 0], [1, 1, 0, 0, 0]],
656        };
657        assert_eq!(good.malformation(), None);
658        let faults = [
659            (
660                CompactSyscallHistogram {
661                    architecture: "aarch64".into(),
662                    ..good.clone()
663                },
664                "architecture",
665            ),
666            (
667                CompactSyscallHistogram {
668                    counts: vec![[SYSCALL_TABLE_SIZE as u64, 1, 0, 0, 0]],
669                    ..good.clone()
670                },
671                "outside",
672            ),
673            (
674                CompactSyscallHistogram {
675                    counts: vec![[1, 1, 0, 0, 0], [1, 1, 0, 0, 0]],
676                    ..good.clone()
677                },
678                "ascending",
679            ),
680            (
681                CompactSyscallHistogram {
682                    counts: vec![[1, 0, 0, 0, 0]],
683                    ..good.clone()
684                },
685                "no calls",
686            ),
687            (
688                CompactSyscallHistogram {
689                    counts: vec![[1, 1, 1, 1, 0]],
690                    ..good.clone()
691                },
692                "more outcomes",
693            ),
694        ];
695        for (histogram, fault) in faults {
696            let message = histogram.malformation().expect("malformed");
697            assert!(message.contains(fault), "{message}");
698        }
699        let unknown = r#"{"architecture":"x86_64","observation_scope":{"kind":"complete"},"counts":[],"names":[]}"#;
700        assert!(serde_json::from_str::<CompactSyscallHistogram>(unknown).is_err());
701    }
702
703    #[test]
704    fn the_finished_form_keeps_only_nonzero_slots_and_sums_back_exactly() {
705        let mut first = SyscallHistogram::default();
706        first.enter(Sysno::read);
707        first.finish(Sysno::read, SyscallOutcome::Backend);
708        first.enter(Sysno::exit);
709        let mut second = SyscallHistogram::default();
710        second.enter(Sysno::read);
711        second.finish(Sysno::read, SyscallOutcome::Detcore);
712        second.enter(Sysno::getpid);
713        second.enter(Sysno::exit);
714
715        let mut sparse = SparseSyscallHistogram::from(&first);
716        assert_eq!(sparse.entries.len(), 2);
717        sparse.merge(&SparseSyscallHistogram::from(&second));
718        assert_eq!(
719            sparse.entries.iter().map(|(nr, _)| *nr).collect::<Vec<_>>(),
720            [Sysno::read.id(), Sysno::getpid.id(), Sysno::exit.id()]
721        );
722        assert_eq!(sparse.total_calls(), 5);
723
724        let mut dense = first.clone();
725        dense.merge(&second);
726        let mut total = SyscallHistogram::default();
727        sparse.add_to(&mut total);
728        assert_eq!(total, dense);
729        // Merging with nothing on either side changes nothing.
730        let mut empty = SparseSyscallHistogram::default();
731        empty.merge(&sparse);
732        assert_eq!(empty, sparse);
733        empty.merge(&SparseSyscallHistogram::default());
734        assert_eq!(empty, sparse);
735    }
736
737    #[test]
738    fn the_per_thread_form_round_trips_through_bincode() {
739        let mut histogram = SyscallHistogram::default();
740        histogram.enter(Sysno::openat);
741        histogram.finish(Sysno::openat, SyscallOutcome::Refused);
742        let config = bincode::config::legacy();
743        let bytes = bincode::serde::encode_to_vec(&histogram, config).unwrap();
744        let (decoded, _): (SyscallHistogram, usize) =
745            bincode::serde::decode_from_slice(&bytes, config).unwrap();
746        assert_eq!(decoded, histogram);
747    }
748}