Skip to main content

reverie/
dispatch_stats.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//! One backend-neutral record of how intercepted guest events were delivered.
10//!
11//! Every backend keeps its own detailed statistics snapshot. This record is the
12//! common projection of those snapshots, so runs on different backends (or with
13//! different patching strategies) can be compared counter by counter. Backends
14//! build it from the counters they already maintain; it is never a second place
15//! where an event is counted.
16//!
17//! A counter is `None` when the backend does not measure it. `Some(0)` means
18//! the backend measured it, or the route is structurally impossible for that
19//! backend (for example, plain ptrace never takes a patched direct call).
20
21use std::fmt;
22
23use serde::Deserialize;
24use serde::Serialize;
25
26/// Version of the serialized [`DispatchStats`] layout.
27///
28/// Bump this whenever a field is renamed, removed, or changes meaning. Adding
29/// an optional field that older readers can ignore does not require a bump.
30pub const DISPATCH_STATS_SCHEMA_VERSION: u32 = 1;
31
32/// Counts of intercepted guest events by the route that delivered them.
33///
34/// The first five fields are dispatch routes, and they count physical
35/// deliveries (a signal, a patched-call entry, a ptrace stop), which is what a
36/// patching strategy pays for. They are not a count of syscalls or of tool
37/// callbacks:
38///
39/// - one syscall can be delivered more than once: a lazily patched site's
40///   first call takes a signal that installs the patch and then enters through
41///   it, and a restarted syscall traps again;
42/// - a delivery the backend completes without a tool callback still counts,
43///   such as an unsubscribed syscall that the filter traps or a syscall the
44///   tool itself makes.
45///
46/// The ptrace syscall-entry and syscall-exit stops are tracer overhead, not
47/// routes, and refusals are attempts that never reached ordinary tool dispatch.
48#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
49pub struct DispatchCounters {
50    /// Events delivered to an in-guest handler by a seccomp `SIGSYS` signal.
51    pub signal_traps: Option<u64>,
52    /// Events delivered through a patched direct jump or call, with neither a
53    /// signal nor a ptrace stop.
54    pub patched_direct_calls: Option<u64>,
55    /// Events delivered to a ptrace tracer by a seccomp trace stop.
56    pub ptrace_seccomp_stops: Option<u64>,
57    /// Events at patched sites delivered to a ptrace tracer by a `SIGTRAP` stop.
58    pub ptrace_sigtrap_stops: Option<u64>,
59    /// Events delivered to an in-guest handler by a `SIGILL` marker written
60    /// over the original instruction.
61    pub sigill_marker_hits: Option<u64>,
62    /// `PTRACE_SYSCALL` syscall-entry stops observed by the tracer.
63    pub ptrace_syscall_entry_stops: Option<u64>,
64    /// `PTRACE_SYSCALL` syscall-exit stops observed by the tracer.
65    pub ptrace_syscall_exit_stops: Option<u64>,
66    /// Intercepted events the backend refused instead of dispatching.
67    pub refusals: Option<u64>,
68}
69
70impl DispatchCounters {
71    /// Every counter set to `Some(0)`.
72    pub const ZERO: Self = Self {
73        signal_traps: Some(0),
74        patched_direct_calls: Some(0),
75        ptrace_seccomp_stops: Some(0),
76        ptrace_sigtrap_stops: Some(0),
77        sigill_marker_hits: Some(0),
78        ptrace_syscall_entry_stops: Some(0),
79        ptrace_syscall_exit_stops: Some(0),
80        refusals: Some(0),
81    };
82
83    /// Total deliveries by any route (see [`DispatchCounters`] for why this is
84    /// not the number of syscalls).
85    ///
86    /// `None` when any route is unmeasured: a partial sum would read as a
87    /// total.
88    pub fn dispatches(&self) -> Option<u64> {
89        sum_all([self.patched_direct_calls, self.trapped_dispatches()])
90    }
91
92    /// Deliveries through a signal or a ptrace stop rather than a patched
93    /// direct call; `None` when any of those routes is unmeasured.
94    pub fn trapped_dispatches(&self) -> Option<u64> {
95        sum_all([
96            self.signal_traps,
97            self.ptrace_seccomp_stops,
98            self.ptrace_sigtrap_stops,
99            self.sigill_marker_hits,
100        ])
101    }
102
103    fn fields(&self) -> [(&'static str, Option<u64>); 8] {
104        [
105            ("signal_traps", self.signal_traps),
106            ("patched_direct_calls", self.patched_direct_calls),
107            ("ptrace_seccomp_stops", self.ptrace_seccomp_stops),
108            ("ptrace_sigtrap_stops", self.ptrace_sigtrap_stops),
109            ("sigill_marker_hits", self.sigill_marker_hits),
110            (
111                "ptrace_syscall_entry_stops",
112                self.ptrace_syscall_entry_stops,
113            ),
114            ("ptrace_syscall_exit_stops", self.ptrace_syscall_exit_stops),
115            ("refusals", self.refusals),
116        ]
117    }
118}
119
120fn sum_all<const N: usize>(values: [Option<u64>; N]) -> Option<u64> {
121    values
122        .into_iter()
123        .try_fold(0_u64, |total, value| value.map(|value| total + value))
124}
125
126/// Counts of distinct instrumentation sites.
127///
128/// What a candidate is depends on the backend's strategy, so compare site
129/// counts across backends with care: e9patch counts the sites it found in the
130/// root executable before the run, SaBRe the sites it decoded in each image
131/// as it was loaded, and LiteInst only the sites the guest actually executed,
132/// because it patches lazily on a site's first trap.
133#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
134pub struct SiteCounters {
135    /// Distinct sites the backend considered for patching.
136    pub candidates: Option<u64>,
137    /// Candidates that were patched.
138    pub patched: Option<u64>,
139    /// Candidates left unpatched and serviced by a fallback route instead.
140    pub fell_back: Option<u64>,
141}
142
143impl SiteCounters {
144    /// Sites for a backend that never patches: every count is zero.
145    pub const NONE_PATCHED: Self = Self {
146        candidates: Some(0),
147        patched: Some(0),
148        fell_back: Some(0),
149    };
150
151    /// Sites of a rewrite that measured `candidates` sites and patched
152    /// `patched` of them, where every candidate it did not patch fell back.
153    pub fn from_rewrite(candidates: u64, patched: u64) -> Self {
154        Self {
155            candidates: Some(candidates),
156            patched: Some(patched),
157            fell_back: Some(candidates.saturating_sub(patched)),
158        }
159    }
160}
161
162/// The counters attributed to one guest process.
163#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
164pub struct ProcessDispatchStats {
165    /// Backend-assigned process index in first-observed order; 0 is the root.
166    ///
167    /// Host process ids are deliberately not recorded: they differ between
168    /// otherwise identical runs, and this record is meant to be compared.
169    pub process: u32,
170    /// Counters attributed to this process.
171    pub counters: DispatchCounters,
172}
173
174/// The shared end-of-run dispatch record for one backend run.
175#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
176pub struct DispatchStats {
177    /// Always [`DISPATCH_STATS_SCHEMA_VERSION`] for records built by this crate.
178    pub schema_version: u32,
179    /// Canonical backend name, as in `BackendStatsSnapshot::BACKEND_NAME`.
180    pub backend: String,
181    /// Aggregate counters for the whole run.
182    pub counters: DispatchCounters,
183    /// Aggregate instrumentation-site counts.
184    pub sites: SiteCounters,
185    /// Per-process counters, when the backend attributes events to processes.
186    ///
187    /// Sorted by `process`. Only the counters a backend attributes per process are
188    /// `Some` here; each such counter sums to the matching aggregate.
189    pub per_process: Option<Vec<ProcessDispatchStats>>,
190}
191
192impl DispatchStats {
193    /// Creates an aggregate-only record.
194    pub fn new(backend: &str, counters: DispatchCounters, sites: SiteCounters) -> Self {
195        Self {
196            schema_version: DISPATCH_STATS_SCHEMA_VERSION,
197            backend: backend.to_owned(),
198            counters,
199            sites,
200            per_process: None,
201        }
202    }
203
204    /// Attaches per-process counters, sorted by process index.
205    pub fn with_per_process(
206        mut self,
207        processes: impl IntoIterator<Item = ProcessDispatchStats>,
208    ) -> Self {
209        let mut processes: Vec<_> = processes.into_iter().collect();
210        processes.sort_by_key(|process| process.process);
211        self.per_process = Some(processes);
212        self
213    }
214
215    /// Returns every internal inconsistency in this record.
216    ///
217    /// An empty result means: the schema version is current, patched and
218    /// fallback sites do not exceed candidates, process indices are
219    /// unique, and every counter reported per process sums to its aggregate.
220    pub fn inconsistencies(&self) -> Vec<String> {
221        let mut problems = Vec::new();
222        if self.schema_version != DISPATCH_STATS_SCHEMA_VERSION {
223            problems.push(format!(
224                "schema_version {} is not {DISPATCH_STATS_SCHEMA_VERSION}",
225                self.schema_version
226            ));
227        }
228        if let Some(candidates) = self.sites.candidates {
229            let patched = self.sites.patched.unwrap_or(0);
230            let fell_back = self.sites.fell_back.unwrap_or(0);
231            if patched + fell_back > candidates {
232                problems.push(format!(
233                    "sites: patched {patched} + fell_back {fell_back} exceeds candidates {candidates}"
234                ));
235            }
236        }
237        if let Some(processes) = &self.per_process {
238            if processes
239                .windows(2)
240                .any(|pair| pair[0].process >= pair[1].process)
241            {
242                problems.push("per_process indices are not unique and sorted".to_owned());
243            }
244            for (index, (name, aggregate)) in self.counters.fields().into_iter().enumerate() {
245                let attributed: Vec<_> = processes
246                    .iter()
247                    .map(|process| process.counters.fields()[index].1)
248                    .collect();
249                if attributed.iter().all(Option::is_none) {
250                    continue;
251                }
252                let Some(sum) = attributed
253                    .iter()
254                    .try_fold(0_u64, |total, value| value.map(|value| total + value))
255                else {
256                    problems.push(format!("{name} is attributed to only some processes"));
257                    continue;
258                };
259                if aggregate != Some(sum) {
260                    problems.push(format!(
261                        "{name}: per-process sum {sum} does not match aggregate {}",
262                        Measured(aggregate)
263                    ));
264                }
265            }
266        }
267        problems
268    }
269}
270
271/// Displays an optional counter as its value or `n/a`.
272struct Measured(Option<u64>);
273
274impl fmt::Display for Measured {
275    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
276        match self.0 {
277            Some(value) => write!(formatter, "{value}"),
278            None => formatter.write_str("n/a"),
279        }
280    }
281}
282
283impl fmt::Display for DispatchCounters {
284    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
285        for (index, (name, value)) in self.fields().into_iter().enumerate() {
286            if index != 0 {
287                formatter.write_str(" ")?;
288            }
289            write!(formatter, "{name}={}", Measured(value))?;
290        }
291        Ok(())
292    }
293}
294
295impl fmt::Display for DispatchStats {
296    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
297        write!(
298            formatter,
299            "dispatch stats v{} backend={} dispatches={} trapped={} direct={} [{}] sites[candidates={} patched={} fell_back={}]",
300            self.schema_version,
301            self.backend,
302            Measured(self.counters.dispatches()),
303            Measured(self.counters.trapped_dispatches()),
304            Measured(self.counters.patched_direct_calls),
305            self.counters,
306            Measured(self.sites.candidates),
307            Measured(self.sites.patched),
308            Measured(self.sites.fell_back),
309        )?;
310        match &self.per_process {
311            Some(processes) => write!(formatter, " processes={}", processes.len()),
312            None => formatter.write_str(" processes=n/a"),
313        }
314    }
315}
316
317#[cfg(test)]
318mod tests {
319    use super::*;
320
321    fn process(process: u32, seccomp: u64) -> ProcessDispatchStats {
322        ProcessDispatchStats {
323            process,
324            counters: DispatchCounters {
325                ptrace_seccomp_stops: Some(seccomp),
326                ..DispatchCounters::default()
327            },
328        }
329    }
330
331    fn ptrace_like(seccomp: u64) -> DispatchStats {
332        DispatchStats::new(
333            "ptrace",
334            DispatchCounters {
335                ptrace_seccomp_stops: Some(seccomp),
336                ..DispatchCounters::ZERO
337            },
338            SiteCounters::NONE_PATCHED,
339        )
340    }
341
342    #[test]
343    fn dispatch_totals_separate_trapped_from_direct_routes() {
344        let counters = DispatchCounters {
345            signal_traps: Some(3),
346            patched_direct_calls: Some(40),
347            ptrace_seccomp_stops: Some(2),
348            ptrace_sigtrap_stops: Some(0),
349            sigill_marker_hits: Some(1),
350            ptrace_syscall_entry_stops: Some(100),
351            ptrace_syscall_exit_stops: Some(100),
352            refusals: Some(5),
353        };
354        assert_eq!(counters.trapped_dispatches(), Some(6));
355        assert_eq!(counters.dispatches(), Some(46));
356        assert_eq!(DispatchCounters::default().dispatches(), None);
357        let partial = DispatchCounters {
358            ptrace_sigtrap_stops: None,
359            ..counters
360        };
361        assert_eq!(partial.trapped_dispatches(), None);
362        assert_eq!(partial.dispatches(), None);
363        assert_eq!(partial.patched_direct_calls, Some(40));
364    }
365
366    #[test]
367    fn unmeasured_counters_render_distinctly_from_zero() {
368        let record = DispatchStats::new(
369            "e9patch",
370            DispatchCounters::default(),
371            SiteCounters {
372                candidates: Some(0),
373                patched: Some(0),
374                fell_back: Some(0),
375            },
376        );
377        let rendered = record.to_string();
378        assert!(rendered.contains("signal_traps=n/a"), "{rendered}");
379        assert!(rendered.contains("dispatches=n/a"), "{rendered}");
380        assert!(rendered.contains("candidates=0"), "{rendered}");
381        assert!(rendered.ends_with("processes=n/a"), "{rendered}");
382    }
383
384    #[test]
385    fn json_round_trip_keeps_unmeasured_counters_as_null() {
386        let record = ptrace_like(7).with_per_process([process(20, 4), process(10, 3)]);
387        let json = serde_json::to_value(&record).unwrap();
388        assert_eq!(json["schema_version"], DISPATCH_STATS_SCHEMA_VERSION);
389        assert_eq!(json["counters"]["ptrace_seccomp_stops"], 7);
390        assert_eq!(json["per_process"][0]["process"], 10);
391        assert!(json["per_process"][0]["counters"]["signal_traps"].is_null());
392        let decoded: DispatchStats = serde_json::from_value(json).unwrap();
393        assert_eq!(decoded, record);
394    }
395
396    #[test]
397    fn consistent_record_reports_no_problems() {
398        let record = ptrace_like(7).with_per_process([process(10, 3), process(20, 4)]);
399        assert_eq!(record.inconsistencies(), Vec::<String>::new());
400    }
401
402    #[test]
403    fn per_process_sum_mismatch_is_reported() {
404        let record = ptrace_like(8).with_per_process([process(10, 3), process(20, 4)]);
405        assert_eq!(
406            record.inconsistencies(),
407            ["ptrace_seccomp_stops: per-process sum 7 does not match aggregate 8"]
408        );
409    }
410
411    #[test]
412    fn partially_attributed_counter_is_reported() {
413        let mut unattributed = process(20, 4);
414        unattributed.counters.ptrace_seccomp_stops = None;
415        let record = ptrace_like(7).with_per_process([process(10, 3), unattributed]);
416        assert_eq!(
417            record.inconsistencies(),
418            ["ptrace_seccomp_stops is attributed to only some processes"]
419        );
420    }
421
422    #[test]
423    fn duplicate_process_and_excess_sites_are_reported() {
424        let mut record = ptrace_like(7).with_per_process([process(10, 3), process(10, 4)]);
425        record.sites = SiteCounters {
426            candidates: Some(2),
427            patched: Some(2),
428            fell_back: Some(1),
429        };
430        assert_eq!(
431            record.inconsistencies(),
432            [
433                "sites: patched 2 + fell_back 1 exceeds candidates 2",
434                "per_process indices are not unique and sorted",
435            ]
436        );
437    }
438}