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}