Skip to main content

hermit/
backend_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//! Hermit-side enablement and reporting for backend statistics.
10
11use std::fmt;
12
13use reverie::BackendStatsRequest;
14use reverie::BackendStatsSnapshot;
15use reverie::BackendStatsSource;
16use reverie::DispatchStats;
17
18use crate::Backend;
19
20const TARGET: &str = "hermit::backend_stats";
21
22/// Collect when something will read the record: the DEBUG report, or the
23/// dispatch record in a requested `--summary-json`.
24pub fn request(summary_json: &Option<std::path::PathBuf>) -> BackendStatsRequest {
25    BackendStatsRequest::new(
26        summary_json.is_some() || tracing::enabled!(target: TARGET, tracing::Level::DEBUG),
27    )
28}
29
30/// Report the selected backend's own run statistics.
31///
32/// ⚠️ THIS RECORD IS EMITTED AT DEBUG, NOT INFO, AND THAT IS LOAD-BEARING.
33/// `ComparedLogScope::Info` is the `BitwiseInfoV1` parity envelope — "every INFO
34/// message, exactly" — so anything emitted here at INFO is compared between two
35/// runs as though it were guest behaviour. This record is not guest behaviour:
36/// it is hermit describing its own harness, and BOTH its fields identify the
37/// harness rather than the guest. `backend` is the backend's name, and `stats`
38/// is that backend's own instrumentation (ptrace activity counters for ptrace,
39/// patch-shape counters for e9patch, and so on).
40///
41/// Returns the backend's dispatch record for the run summary. It is printed
42/// here at DEBUG with the snapshot, for the same reason: dispatch counts
43/// describe the harness, and differ between backends by design.
44///
45/// The source must be the one of the runtime that executes the selected
46/// backend ([`Backend::runtime_name`]). `in-guest-trap` runs the in-guest
47/// LiteInst runtime, so its source is LiteInst's; the returned record is
48/// relabelled with the selected backend's name, `in-guest-trap`, so a reader
49/// of the summary can tell the two columns apart. Its counters are LiteInst's
50/// own, unchanged: with site patching off, `sites.patched` is 0 and every
51/// dispatch is a `signal_traps` delivery (no `patched_direct_calls`).
52///
53/// Measured on a real `ptrace` run before this change: of 303 INFO records, this
54/// was the ONE record naming a backend. So two backends executing an identical
55/// guest could never agree under the Info envelope, however correct the backends
56/// were — a divergence by construction rather than a behavioural difference, and
57/// the only such record in the stream.
58///
59/// Same-backend verification prints the same values on both sides, so nothing
60/// was red; the ceiling was on any future cross-backend comparison. DEBUG keeps
61/// the record fully available to `--log debug` while removing it from the
62/// envelope that is compared, which is the narrow fix. Widening or renaming the
63/// Info scope itself would change what a published parity claim MEANS, and that
64/// is an owner decision, not this one.
65pub(crate) fn report<S>(
66    selected_backend: Backend,
67    request: BackendStatsRequest,
68    source: &S,
69) -> Option<DispatchStats>
70where
71    S: BackendStatsSource,
72{
73    report_with_names(
74        selected_backend.as_str(),
75        selected_backend.runtime_name(),
76        request,
77        source,
78    )
79}
80
81/// The same reporting owner for a statically selected physical extension.
82/// Both names are the extension's immutable selected/runtime declaration;
83/// the actual source's own BACKEND_NAME is checked even with collection off.
84/// This reports instrumentation and supplies no verification verdict.
85pub fn report_with_names<S>(
86    selected_backend: &str,
87    runtime_backend: &str,
88    request: BackendStatsRequest,
89    source: &S,
90) -> Option<DispatchStats>
91where
92    S: BackendStatsSource,
93{
94    // ⚠️ HOISTED ABOVE THE COLLECTION GATE ON PURPOSE, AND IT MUST STAY THERE.
95    // This asks "is the stats source wired to the backend we actually selected?"
96    // -- a wiring invariant that is true or false regardless of whether anyone
97    // asked for the record. Below the `else { return; }` it only ran when the
98    // level probe happened to be on, so a LOG-LEVEL CHANGE COULD DISABLE A
99    // CORRECTNESS CHECK: it did not start failing, it stopped executing, and a
100    // check that stops executing reports nothing. `BACKEND_NAME` is an
101    // associated const, so this needs no snapshot and costs nothing to keep
102    // unconditional. Reporting and checking are two different questions; only
103    // the first belongs behind the gate.
104    assert_eq!(
105        runtime_backend,
106        S::Snapshot::BACKEND_NAME,
107        "backend statistics source does not match selected backend"
108    );
109    let snapshot = request.collect(source)?;
110    let dispatch = snapshot.dispatch_stats().map(|mut record| {
111        record.backend = selected_backend.to_owned();
112        record
113    });
114    tracing::debug!(
115        target: TARGET,
116        backend = %selected_backend,
117        stats = %snapshot,
118        dispatch = %DisplayDispatch(dispatch.as_ref()),
119        "backend run complete",
120    );
121    dispatch
122}
123
124/// [`report`] for a backend whose source exists only when collection was
125/// enabled, such as the ptrace tracer's.
126pub(crate) fn report_if_collected<S>(
127    selected_backend: Backend,
128    request: BackendStatsRequest,
129    source: Option<&S>,
130) -> Option<DispatchStats>
131where
132    S: BackendStatsSource,
133{
134    report(selected_backend, request, source?)
135}
136
137struct DisplayDispatch<'a>(Option<&'a DispatchStats>);
138
139impl fmt::Display for DisplayDispatch<'_> {
140    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
141        match self.0 {
142            Some(record) => record.fmt(formatter),
143            None => formatter.write_str("none"),
144        }
145    }
146}
147
148#[cfg(test)]
149mod tests {
150    use std::cell::Cell;
151
152    use super::*;
153
154    struct CountingSource {
155        snapshots: Cell<usize>,
156    }
157
158    struct CountingSnapshot;
159
160    impl fmt::Display for CountingSnapshot {
161        fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
162            formatter.write_str("counting")
163        }
164    }
165
166    impl BackendStatsSnapshot for CountingSnapshot {
167        const BACKEND_NAME: &'static str = "ptrace";
168
169        fn dispatch_stats(&self) -> Option<DispatchStats> {
170            Some(DispatchStats::new(
171                Self::BACKEND_NAME,
172                reverie::DispatchCounters {
173                    ptrace_seccomp_stops: Some(7),
174                    ..reverie::DispatchCounters::ZERO
175                },
176                reverie::SiteCounters::NONE_PATCHED,
177            ))
178        }
179    }
180
181    impl BackendStatsSource for CountingSource {
182        type Snapshot = CountingSnapshot;
183
184        fn backend_stats(&self) -> Self::Snapshot {
185            self.snapshots.set(self.snapshots.get() + 1);
186            CountingSnapshot
187        }
188    }
189
190    #[test]
191    fn disabled_report_does_not_snapshot_backend() {
192        let source = CountingSource {
193            snapshots: Cell::new(0),
194        };
195
196        assert_eq!(
197            report(Backend::Ptrace, BackendStatsRequest::DISABLED, &source),
198            None
199        );
200        assert_eq!(source.snapshots.get(), 0);
201        let record = report(Backend::Ptrace, BackendStatsRequest::ENABLED, &source)
202            .expect("an enabled report returns the backend's dispatch record");
203        assert_eq!(source.snapshots.get(), 1);
204        assert_eq!(record.backend, "ptrace");
205        assert_eq!(record.counters.dispatches(), Some(7));
206    }
207
208    #[test]
209    fn a_requested_summary_collects_without_debug_logging() {
210        assert!(request(&Some(std::path::PathBuf::from("/summary.json"))).is_enabled());
211    }
212
213    #[test]
214    #[should_panic(expected = "backend statistics source does not match selected backend")]
215    fn report_rejects_a_mismatched_backend_source_in_release_builds() {
216        let source = CountingSource {
217            snapshots: Cell::new(0),
218        };
219
220        report(Backend::Liteinst, BackendStatsRequest::ENABLED, &source);
221    }
222
223    /// The check must READ the source's own `BACKEND_NAME`, not compare against a
224    /// hardcoded `"ptrace"`.
225    ///
226    /// ⚠️ WHY A SECOND SOURCE EXISTS PURELY FOR THIS. Every other test in this
227    /// module uses `CountingSource`, whose `BACKEND_NAME` is `"ptrace"` — so
228    /// replacing the right-hand side of the assert with the literal `"ptrace"`
229    /// leaves all of them green. Review found exactly that mutation surviving.
230    /// A matched NON-ptrace pair is the only shape that distinguishes "reads the
231    /// source's declared name" from "happens to equal ptrace".
232    struct LiteinstLikeSource;
233
234    struct LiteinstLikeSnapshot;
235
236    impl fmt::Display for LiteinstLikeSnapshot {
237        fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
238            formatter.write_str("liteinst-like")
239        }
240    }
241
242    impl BackendStatsSnapshot for LiteinstLikeSnapshot {
243        const BACKEND_NAME: &'static str = "liteinst";
244
245        fn dispatch_stats(&self) -> Option<DispatchStats> {
246            None
247        }
248    }
249
250    impl BackendStatsSource for LiteinstLikeSource {
251        type Snapshot = LiteinstLikeSnapshot;
252
253        fn backend_stats(&self) -> Self::Snapshot {
254            LiteinstLikeSnapshot
255        }
256    }
257
258    #[test]
259    fn report_accepts_a_matched_non_ptrace_source() {
260        // Both gate states, because the hoist means the assert now runs in both.
261        report(
262            Backend::Liteinst,
263            BackendStatsRequest::DISABLED,
264            &LiteinstLikeSource,
265        );
266        report(
267            Backend::Liteinst,
268            BackendStatsRequest::ENABLED,
269            &LiteinstLikeSource,
270        );
271    }
272
273    /// The wiring check must fire even when NOBODY ASKED FOR THE RECORD.
274    ///
275    /// ⚠️ THIS IS THE REGRESSION IT GUARDS, and it is a whole class. The assert
276    /// used to sit BELOW `request.collect(..)`'s early return, so it ran only
277    /// when the level probe was on. #2587 moved that probe from INFO to DEBUG
278    /// for an unrelated and correct reason — keeping harness output out of the
279    /// parity envelope — and the check silently stopped executing across the CI
280    /// corpus, which injects `--log info` and never `--log debug`. It did not
281    /// fail. It stopped running, and a check that stops running reports nothing.
282    ///
283    /// The sibling test above passes `ENABLED`, so it could not have caught
284    /// this: it exercises the path where the gate is already open. This one
285    /// passes `DISABLED`, which is what CI actually does.
286    #[test]
287    #[should_panic(expected = "backend statistics source does not match selected backend")]
288    fn report_checks_backend_wiring_even_when_stats_are_not_collected() {
289        let source = CountingSource {
290            snapshots: Cell::new(0),
291        };
292
293        report(Backend::Liteinst, BackendStatsRequest::DISABLED, &source);
294    }
295
296    /// A LiteInst-named source that returns a dispatch record: one patch
297    /// candidate measured and none patched, as an in-guest-trap run reports.
298    struct LiteinstRecordSource;
299
300    struct LiteinstRecordSnapshot;
301
302    impl fmt::Display for LiteinstRecordSnapshot {
303        fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
304            formatter.write_str("liteinst-record")
305        }
306    }
307
308    impl BackendStatsSnapshot for LiteinstRecordSnapshot {
309        const BACKEND_NAME: &'static str = "liteinst";
310
311        fn dispatch_stats(&self) -> Option<DispatchStats> {
312            Some(DispatchStats::new(
313                Self::BACKEND_NAME,
314                reverie::DispatchCounters {
315                    signal_traps: Some(5),
316                    ..reverie::DispatchCounters::ZERO
317                },
318                reverie::SiteCounters::from_rewrite(1, 0),
319            ))
320        }
321    }
322
323    impl BackendStatsSource for LiteinstRecordSource {
324        type Snapshot = LiteinstRecordSnapshot;
325
326        fn backend_stats(&self) -> Self::Snapshot {
327            LiteinstRecordSnapshot
328        }
329    }
330
331    /// `in-guest-trap` runs the in-guest LiteInst runtime: Hermit accepts that
332    /// runtime's source for it and names the record after the selected
333    /// backend, keeping LiteInst's counters as they are. `liteinst` keeps its
334    /// own name.
335    #[test]
336    fn in_guest_trap_reports_the_liteinst_source_under_its_own_name() {
337        let record = report(
338            Backend::InGuestTrap,
339            BackendStatsRequest::ENABLED,
340            &LiteinstRecordSource,
341        )
342        .expect("an enabled report returns the runtime's dispatch record");
343        assert_eq!(record.backend, "in-guest-trap");
344        assert_eq!(record.counters.signal_traps, Some(5));
345        assert_eq!(record.sites.patched, Some(0));
346        assert_eq!(record.sites.candidates, Some(1));
347        let liteinst = report(
348            Backend::Liteinst,
349            BackendStatsRequest::ENABLED,
350            &LiteinstRecordSource,
351        )
352        .expect("an enabled report returns the runtime's dispatch record");
353        assert_eq!(liteinst.backend, "liteinst");
354        assert_eq!(liteinst.counters, record.counters);
355    }
356
357    /// `in-guest-trap` does not accept the source of a runtime other than
358    /// in-guest LiteInst.
359    #[test]
360    #[should_panic(expected = "backend statistics source does not match selected backend")]
361    fn in_guest_trap_rejects_a_source_of_another_runtime() {
362        let source = CountingSource {
363            snapshots: Cell::new(0),
364        };
365
366        report(Backend::InGuestTrap, BackendStatsRequest::DISABLED, &source);
367    }
368}