Skip to main content

detcore_model/
summary.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//! Summaries of complete hermit runs.
10
11use std::collections::BTreeMap;
12use std::fmt;
13use std::time::Duration;
14
15/// The backend dispatch record a run summary carries, re-exported so readers of
16/// summaries and verification reports need no direct Reverie dependency.
17pub use reverie::DispatchStats;
18use serde::Deserialize;
19use serde::Serialize;
20
21use crate::host_input::HostInputChange;
22use crate::host_input::HostInputSet;
23use crate::pid::DetTid;
24use crate::syscall_histogram::SyscallHistogramSummary;
25use crate::time::LogicalTime;
26
27/// Per-execution SaBRe path evidence written as one JSON object per line.
28///
29/// The Hermit SaBRe runner produces this record at the path named by
30/// `HERMIT_SABRE_PATH_EVIDENCE`; the manifest runner reads the same type before
31/// deciding whether a SaBRe cell exercised the intended path. Keep the wire
32/// fields here rather than independently defining the producer and reader
33/// shapes: a missing or renamed counter changes whether a cell is accepted.
34#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
35#[serde(deny_unknown_fields)]
36pub struct PathEvidence {
37    pub schema: u8,
38    pub guest_rpc_observed: bool,
39    pub ptrace_fallback_sites: usize,
40    pub trusted_shared_object_sites: usize,
41    pub trusted_shared_objects: Vec<String>,
42}
43
44impl PathEvidence {
45    pub const SCHEMA: u8 = 1;
46}
47
48// AUTONOMOUS-BOT-IMPLEMENTED
49// TODO-HUMAN-REVIEW(#252): Confirm the overflow and rounding policy for long-running guests.
50/// Running distribution statistics over timeslice durations, measured in virtual
51/// nanoseconds. A "timeslice" is the span of virtual time a thread runs between
52/// two consecutive scheduler yields (i.e. between `end_of_timeslice` resets).
53#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Default)]
54pub struct TimesliceStats {
55    /// Number of completed timeslices recorded.
56    pub count: u64,
57    /// Sum of all timeslice durations, in virtual nanoseconds.
58    pub sum_ns: u64,
59    /// Smallest timeslice duration observed, in virtual nanoseconds (valid when `count > 0`).
60    pub min_ns: u64,
61    /// Largest timeslice duration observed, in virtual nanoseconds (valid when `count > 0`).
62    pub max_ns: u64,
63}
64
65impl TimesliceStats {
66    /// Record one completed timeslice of `ns` virtual nanoseconds.
67    pub fn record(&mut self, ns: u64) {
68        if self.count == 0 {
69            self.min_ns = ns;
70            self.max_ns = ns;
71        } else {
72            self.min_ns = self.min_ns.min(ns);
73            self.max_ns = self.max_ns.max(ns);
74        }
75        self.sum_ns += ns;
76        self.count += 1;
77    }
78
79    /// Fold another distribution into this one.
80    pub fn merge(&mut self, other: &TimesliceStats) {
81        if other.count == 0 {
82            return;
83        }
84        if self.count == 0 {
85            *self = *other;
86            return;
87        }
88        self.min_ns = self.min_ns.min(other.min_ns);
89        self.max_ns = self.max_ns.max(other.max_ns);
90        self.sum_ns += other.sum_ns;
91        self.count += other.count;
92    }
93
94    /// Mean timeslice duration in virtual nanoseconds (0 when no slices recorded).
95    pub fn mean_ns(&self) -> u64 {
96        self.sum_ns.checked_div(self.count).unwrap_or(0)
97    }
98
99    /// Whether any timeslices have been recorded.
100    pub fn is_empty(&self) -> bool {
101        self.count == 0
102    }
103}
104
105/// Configured timeslice and the runtime's preemption timer mechanism.
106///
107/// This describes configuration and capability, not whether a hardware timer
108/// is currently armed or any timer event was delivered.
109#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq)]
110pub struct PreemptionSummary {
111    /// Configured maximum in virtual nanoseconds, or `None` when disabled.
112    pub max_timeslice_ns: Option<u64>,
113    /// Whether the configured maximum has a runtime timer mechanism.
114    /// `None` means an older producer did not record that fact.
115    #[serde(default)]
116    pub timer_enabled: Option<bool>,
117    /// Timeslice ends processed without stopping the guest because no other
118    /// thread could run (`Config::eager_lone_preemptions`), or `None` where
119    /// that is off or does not apply.
120    #[serde(default, skip_serializing_if = "Option::is_none")]
121    pub stopless_slice_ends: Option<u64>,
122}
123
124impl fmt::Display for PreemptionSummary {
125    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
126        match self.max_timeslice_ns {
127            Some(maximum) => write!(f, "Configured maximum timeslice: {maximum} virtual ns")?,
128            None => write!(f, "Configured maximum timeslice: disabled")?,
129        };
130        let timer = match self.timer_enabled {
131            Some(false) => "none",
132            Some(true) => "enabled",
133            None => "unknown",
134        };
135        write!(f, "; timer: {timer}")?;
136        if let Some(ends) = self.stopless_slice_ends {
137            write!(
138                f,
139                "; slice ends taken without a stop (no other thread could run): {ends}"
140            )?;
141        }
142        Ok(())
143    }
144}
145
146/// Statistics that summarize a hermit run.
147#[derive(Debug, Serialize, Deserialize, Clone, Default)]
148pub struct RunSummary {
149    /// Internal number of steps taken by the scheduler.
150    pub sched_turns: u64,
151
152    /// **Trace replay:** SchedEvents read and replayed from the input recording.
153    pub schedevent_replayed: u64,
154    /// **Trace replay:** SchedEvents recorded to disk during execution.
155    pub schedevent_recorded: u64,
156    /// **Trace replay:** Desync events that occurred while replaying SchedEvents.
157    pub schedevent_desynced: u64,
158
159    /// A human-readable summary of the desyncs that occurred.
160    pub desync_descrip: Option<String>,
161
162    /// A summary of when threads where preempted and reprioritized (for --chaos mode), e.g. --record-preemptions-to.
163    pub reprio_descrip: Option<String>,
164
165    /// A summary of the thread topology spawned by the guest.
166    pub threads_descrip: String,
167
168    /// The number of threads that were group leaders, i.e. processes.
169    pub num_processes: u64,
170    /// The number of total system threads that were created during the execution.
171    pub num_threads: u64,
172
173    /// Total syscalls completed by all guest threads.
174    #[serde(default, skip_serializing_if = "Option::is_none")]
175    pub syscalls: Option<u64>,
176
177    /// Deterministic virtual nanoseconds elapsed while computing.
178    pub virttime_elapsed: u64,
179    /// Absolute (virtual) time in nanoseconds since epoch at program completion.
180    pub virttime_final: u64,
181
182    /// **Nondeterministic:** Realtime in nanoseconds, i.e. wall-clock time elapsed.
183    pub realtime_elapsed: Option<Duration>,
184
185    /// Aggregate distribution of scheduler timeslice durations (virtual ns),
186    /// summed over all threads.
187    pub timeslice_stats: TimesliceStats,
188
189    /// Per-thread timeslice distributions, sorted by `DetTid` for deterministic
190    /// output.
191    pub per_thread_timeslice: Vec<(DetTid, TimesliceStats)>,
192
193    /// Configured maximum and timer mechanism from the shared Detcore config.
194    /// Absent for older summaries or producers that did not record it; a present
195    /// object with a null maximum explicitly records a disabled maximum. Timer
196    /// availability is configuration/capability metadata, not measured delivery.
197    /// Shown in the full report, but not the INFO guest-behavior view used by
198    /// `--verify`.
199    #[serde(default, skip_serializing_if = "Option::is_none")]
200    pub preemption: Option<PreemptionSummary>,
201
202    /// How the backend dispatched guest syscalls: through signals, patched
203    /// direct calls, or ptrace stops. The record carries its own schema
204    /// version. Absent when the run did not collect it or the backend has no
205    /// such dispatch (KVM). Deliberately not part of the human-readable
206    /// summary, whose INFO view is compared by `--verify`.
207    #[serde(default, skip_serializing_if = "Option::is_none")]
208    pub dispatch_stats: Option<DispatchStats>,
209
210    /// Accepted findings that make this faithful preemption replay incomplete.
211    /// Absent for ordinary runs and older JSON summaries; not part of Display
212    /// or the INFO view compared by verification.
213    #[serde(default, skip_serializing_if = "Option::is_none")]
214    pub replay_departure: Option<crate::replay_departure::Summary>,
215
216    /// Per-syscall entries and outcomes over all guest threads, with the
217    /// scope of what was observed ([`crate::syscall_histogram`]); its `calls`
218    /// sum to `syscalls` ([`RunSummary::syscall_histogram_is_consistent`]).
219    /// Absent from summaries written before it existed. Not part of the
220    /// human-readable summary, whose INFO view is compared by `--verify`.
221    #[serde(default, skip_serializing_if = "Option::is_none")]
222    pub syscall_histogram: Option<SyscallHistogramSummary>,
223    /// The host files the guest opened, with the identity each path's first
224    /// open found and the set's digest (see [`crate::host_input`]). Two
225    /// separate runs whose digests differ read different host files, which
226    /// Hermit does not make deterministic. Absent when the run did not
227    /// record them. Not part of the human-readable summary, whose INFO view
228    /// `--verify` compares.
229    #[serde(default, skip_serializing_if = "Option::is_none")]
230    pub host_inputs: Option<HostInputSet>,
231
232    /// The host files a schedule replay found changed since the recording it
233    /// replays, in the order the guest first opened them (see
234    /// [`crate::host_input::HostInputChange`]). Empty when nothing changed or
235    /// the run replayed nothing. Not part of the human-readable summary.
236    #[serde(default, skip_serializing_if = "Vec::is_empty")]
237    pub host_inputs_changed: Vec<HostInputChange>,
238    /// The run ended on a deadlock under Hermit's model: every remaining thread
239    /// was blocked with no wake Detcore models. Its status is then
240    /// `HERMIT_GUEST_DEADLOCK_EXIT`, not one the guest exited with. Absent
241    /// (false) for every other run and in older summaries.
242    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
243    pub guest_deadlock: bool,
244
245    /// The run ended because its virtual clock passed `--virtual-timeout`,
246    /// whose bound in virtual nanoseconds this is: the scheduler granted no
247    /// turn past it. Its status is then 124, not one the guest exited with.
248    /// Absent for every other run and in older summaries.
249    #[serde(default, skip_serializing_if = "Option::is_none")]
250    pub virtual_timeout_ns: Option<u64>,
251
252    /// aarch64: the load/store exclusive pairs (LL/SC retry loops) in the
253    /// guest's executable file mappings, by guest path. A function of the
254    /// files' bytes, so it is in the INFO view. Empty elsewhere.
255    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
256    pub exclusive_pairs: BTreeMap<String, u64>,
257
258    /// **Nondeterministic:** aarch64, in runs that record or replay
259    /// preemptions: the store-exclusives the guest failed, each of which
260    /// retried its loop and added retired branches, and those it executed.
261    /// Host interrupts decide them, so they are shown in the full report but
262    /// not in the INFO view `--verify` compares. Absent where not counted.
263    #[serde(default, skip_serializing_if = "Option::is_none")]
264    pub store_exclusives: Option<StoreExclusiveCounts>,
265
266    /// aarch64: why `store_exclusives` could not be counted in a run that
267    /// records or replays preemptions. Full report only.
268    #[serde(default, skip_serializing_if = "Option::is_none")]
269    pub store_exclusives_unavailable: Option<String>,
270
271    /// How the guest's process and thread IDs were numbered, for a run
272    /// without a PID namespace of its own (`--no-namespace`): chosen from a
273    /// base, or the host's. Absent for a run its PID namespace numbered. Shown
274    /// in the full report as launch configuration, not in the INFO view.
275    #[serde(default, skip_serializing_if = "Option::is_none")]
276    pub guest_ids: Option<crate::pid::GuestIds>,
277
278    /// How many host-ID reports were accepted while IDs were to be chosen
279    /// (`guest_ids`): nonzero means chosen IDs are not guaranteed. Shown in
280    /// the full report beside `guest_ids`. This includes conservative reports
281    /// before vfork calls, whose parents return only once the child has
282    /// exec'd or exited. Failed and restarted attempts can therefore be
283    /// counted too; this is not a count of distinct created tasks.
284    #[serde(default, skip_serializing_if = "is_zero")]
285    pub host_id_tasks: u64,
286}
287
288fn is_zero(count: &u64) -> bool {
289    *count == 0
290}
291
292/// The store-exclusives a run's guest speculatively executed that failed,
293/// and all it executed (aarch64 `STREX_FAIL_SPEC` and `STREX_SPEC`).
294#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq)]
295pub struct StoreExclusiveCounts {
296    /// Store-exclusives that failed.
297    pub failed: u64,
298    /// Store-exclusives executed, where the core counts them.
299    #[serde(default, skip_serializing_if = "Option::is_none")]
300    pub executed: Option<u64>,
301    /// Whether the counters were on a PMU counter for the whole run: not
302    /// when the PMU multiplexed them, or the guest ran on CPUs whose PMU
303    /// does not count these events. Then the counts cover only part of it.
304    pub fully_counted: bool,
305}
306
307impl fmt::Display for StoreExclusiveCounts {
308    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
309        let StoreExclusiveCounts {
310            failed,
311            executed,
312            fully_counted,
313        } = self;
314        let executed = executed.map(|executed| counted(executed, "store-exclusive"));
315        match (*failed, executed) {
316            (0, Some(executed)) => write!(f, "{executed} executed, none failed")?,
317            (0, None) => write!(f, "No store-exclusive failed")?,
318            (failed, executed) => {
319                let failed = counted(failed, "store-exclusive failure");
320                write!(f, "{failed}; replay may diverge")?;
321                if let Some(executed) = executed {
322                    write!(f, " ({executed} executed)")?;
323                }
324            }
325        }
326        if !fully_counted {
327            write!(
328                f,
329                "; not counted for the whole run (multiplexed, or the task ran on CPUs whose \
330                 PMU does not carry these events)"
331            )?;
332        }
333        Ok(())
334    }
335}
336
337/// `count` and `noun`, plural unless the count is 1.
338fn counted(count: u64, noun: &str) -> String {
339    if count == 1 {
340        format!("1 {noun}")
341    } else {
342        format!("{count} {noun}s")
343    }
344}
345
346/// The full human-readable report, including recorded launch configuration.
347impl fmt::Display for RunSummary {
348    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
349        self.fmt_summary(f, true, self.reprio_descrip.as_deref())
350    }
351}
352
353/// INFO view built with the producer's preemption description, before its host
354/// destination is appended. Launch configuration is separate report metadata;
355/// this view preserves the guest-behavior fields compared by `--verify`.
356pub struct RunSummaryInfo<'a> {
357    summary: &'a RunSummary,
358    reprio_description: Option<&'a str>,
359}
360
361impl fmt::Display for RunSummaryInfo<'_> {
362    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
363        self.summary.fmt_summary(f, false, self.reprio_description)
364    }
365}
366
367impl RunSummary {
368    /// Whether the histogram, when present, counts exactly the `syscalls`
369    /// total. `None` when either is absent.
370    pub fn syscall_histogram_is_consistent(&self) -> Option<bool> {
371        let histogram = self.syscall_histogram.as_ref()?;
372        Some(Some(histogram.total_calls()) == self.syscalls)
373    }
374
375    pub fn info<'a>(&'a self, reprio_description: Option<&'a str>) -> RunSummaryInfo<'a> {
376        RunSummaryInfo {
377            summary: self,
378            reprio_description,
379        }
380    }
381
382    fn fmt_summary(
383        &self,
384        f: &mut fmt::Formatter<'_>,
385        include_report_metadata: bool,
386        reprio_description: Option<&str>,
387    ) -> fmt::Result {
388        let RunSummary {
389            sched_turns,
390            schedevent_replayed,
391            schedevent_recorded,
392            schedevent_desynced,
393            desync_descrip,
394            reprio_descrip: _,
395            num_processes,
396            num_threads,
397            syscalls,
398            virttime_elapsed,
399            virttime_final,
400            realtime_elapsed,
401            threads_descrip,
402            timeslice_stats,
403            per_thread_timeslice,
404            preemption,
405            dispatch_stats: _,
406            syscall_histogram: _,
407            host_inputs: _,
408            host_inputs_changed: _,
409            exclusive_pairs,
410            store_exclusives,
411            store_exclusives_unavailable,
412            guest_deadlock,
413            guest_ids,
414            host_id_tasks,
415            virtual_timeout_ns,
416            replay_departure: _,
417        } = self;
418        writeln!(f, "Final thread-tree was: {}", threads_descrip)?;
419        writeln!(
420            f,
421            "There were {} group leaders of {} thread(s) total.",
422            num_processes, num_threads
423        )?;
424        if let Some(syscalls) = syscalls {
425            writeln!(f, "Guest threads completed {syscalls} syscalls.")?;
426        }
427        if *guest_deadlock {
428            writeln!(
429                f,
430                "Deadlock under Hermit's model: no remaining thread can be woken by anything Hermit models."
431            )?;
432        }
433        if let Some(bound) = virtual_timeout_ns {
434            writeln!(
435                f,
436                "The run's virtual clock passed --virtual-timeout={bound}: no scheduler turn was granted after it."
437            )?;
438        }
439        if include_report_metadata {
440            writeln!(
441                f,
442                "Internally, the hermit scheduler ran {} turns, recorded {} events, replayed {} events ({} desynced)",
443                sched_turns, schedevent_recorded, schedevent_replayed, schedevent_desynced,
444            )?;
445        } else {
446            writeln!(
447                f,
448                "Internally, the hermit scheduler ran {} turns, recorded {} events ({} desynced)",
449                sched_turns, schedevent_recorded, schedevent_desynced,
450            )?;
451        }
452
453        if include_report_metadata && let Some(preemption) = preemption {
454            writeln!(f, "{preemption}")?;
455        }
456        if include_report_metadata && let Some(guest_ids) = guest_ids {
457            writeln!(f, "Guest process IDs: {guest_ids}")?;
458            if *host_id_tasks > 0 {
459                writeln!(
460                    f,
461                    "  {host_id_tasks} host-ID report(s) accepted; chosen process IDs are not guaranteed"
462                )?;
463            }
464        }
465
466        if include_report_metadata {
467            if let Some(counts) = store_exclusives {
468                writeln!(f, "{counts}")?;
469            }
470            if let Some(reason) = store_exclusives_unavailable {
471                writeln!(f, "Store-exclusives were not counted: {reason}")?;
472            }
473        }
474        if !exclusive_pairs.is_empty() {
475            writeln!(
476                f,
477                "Store-exclusive (LL/SC) pairs in executable mappings of {} file(s):",
478                exclusive_pairs.len()
479            )?;
480            for (path, pairs) in exclusive_pairs {
481                writeln!(f, "  {path}: {pairs}")?;
482            }
483        }
484
485        if let Some(txt) = desync_descrip {
486            write!(f, "{}", txt)?;
487        }
488        if let Some(txt) = reprio_description {
489            write!(f, "{}", txt)?;
490        }
491
492        writeln!(
493            f,
494            "Final virtual global (cpu) time: {}",
495            LogicalTime::from_nanos(*virttime_final)
496        )?;
497        writeln!(
498            f,
499            "Elapsed virtual global (cpu) time: {}",
500            LogicalTime::from_nanos(*virttime_elapsed)
501        )?;
502
503        if timeslice_stats.is_empty() {
504            writeln!(f, "Timeslice stats: none recorded")?;
505        } else {
506            writeln!(
507                f,
508                "Timeslice stats: min={}ns max={}ns mean={}ns count={}",
509                timeslice_stats.min_ns,
510                timeslice_stats.max_ns,
511                timeslice_stats.mean_ns(),
512                timeslice_stats.count,
513            )?;
514            // Per-thread breakdown (only informative when more than one thread
515            // recorded slices); shown as part of the report body.
516            if per_thread_timeslice.len() > 1 {
517                for (dettid, st) in per_thread_timeslice {
518                    if st.is_empty() {
519                        continue;
520                    }
521                    writeln!(
522                        f,
523                        "  timeslice thread {}: min={}ns max={}ns mean={}ns count={}",
524                        dettid,
525                        st.min_ns,
526                        st.max_ns,
527                        st.mean_ns(),
528                        st.count,
529                    )?;
530                }
531            }
532        }
533
534        if let Some(rt) = realtime_elapsed {
535            writeln!(f, "Nondeterministic realtime elapsed: {:?}", rt)?
536        };
537
538        Ok(())
539    }
540}
541
542/*
543  ------------------------------ hermit run report ------------------------------
544Final thread-tree was: [3]
545There were 1 group leaders of 1 thread(s) total.
546Internally, the hermit scheduler ran 8 turns, recorded 0 events, replayed 0 events (0 desynced)
547Nondeterministic realtime elapsed: 27.08914ms
548Final virtual global (cpu) time: 1_640_995_199.005_045_040s
549Elapsed virtual global (cpu) time: 5_045_040ns
550Timeslice stats: min=199999995ns max=200000000ns mean=199999998ns count=4
551*/
552
553#[cfg(test)]
554mod tests {
555    use std::collections::BTreeMap;
556
557    use super::PathEvidence;
558    use super::PreemptionSummary;
559    use super::RunSummary;
560    use super::StoreExclusiveCounts;
561    use super::TimesliceStats;
562
563    #[test]
564    fn replay_diagnostics_are_optional_json_and_never_change_compared_display() {
565        use crate::replay_departure::Finding;
566        use crate::replay_departure::Source;
567        use crate::replay_departure::Summary;
568        let plain = RunSummary::default();
569        let json = serde_json::to_value(&plain).unwrap();
570        assert!(json.get("replay_departure").is_none());
571        let decoded: RunSummary = serde_json::from_value(json.clone()).unwrap();
572        assert!(decoded.replay_departure.is_none());
573        let mut marked = plain.clone();
574        marked.replay_departure = Some(Summary {
575            count: 2,
576            first: Finding {
577                source: Source::ThreadFiling,
578                thread: crate::pid::DetTid::from_raw(3),
579                previous: crate::time::LogicalTime::from_nanos(101),
580                observed: crate::time::LogicalTime::from_nanos(100),
581                slice: Some(7),
582                recorded_event_count: Some(19),
583            },
584        });
585        assert_eq!(marked.to_string(), plain.to_string());
586        assert_eq!(marked.info(None).to_string(), plain.info(None).to_string());
587        let mut marked_json = serde_json::to_value(&marked).unwrap();
588        let roundtrip: RunSummary = serde_json::from_value(marked_json.clone()).unwrap();
589        assert_eq!(roundtrip.replay_departure, marked.replay_departure);
590        assert_eq!(
591            marked_json
592                .as_object_mut()
593                .unwrap()
594                .remove("replay_departure"),
595            Some(serde_json::to_value(marked.replay_departure.unwrap()).unwrap())
596        );
597        assert_eq!(
598            marked_json, json,
599            "every existing JSON field stays identical"
600        );
601    }
602
603    #[test]
604    fn path_evidence_json_is_exact() {
605        let evidence = PathEvidence {
606            schema: PathEvidence::SCHEMA,
607            guest_rpc_observed: true,
608            ptrace_fallback_sites: 0,
609            trusted_shared_object_sites: 1,
610            trusted_shared_objects: vec!["/usr/lib/libc.so.6".into()],
611        };
612        let json = serde_json::to_string(&evidence).unwrap();
613        assert_eq!(
614            json,
615            r#"{"schema":1,"guest_rpc_observed":true,"ptrace_fallback_sites":0,"trusted_shared_object_sites":1,"trusted_shared_objects":["/usr/lib/libc.so.6"]}"#
616        );
617        assert_eq!(
618            serde_json::from_str::<PathEvidence>(&json).unwrap(),
619            evidence
620        );
621
622        let unknown = json.replace(
623            r#""trusted_shared_objects""#,
624            r#""unexpected":0,"trusted_shared_objects""#,
625        );
626        let error = serde_json::from_str::<PathEvidence>(&unknown).unwrap_err();
627        assert!(error.to_string().contains("unknown field `unexpected`"));
628
629        let missing = json.replace(r#""guest_rpc_observed":true,"#, "");
630        let error = serde_json::from_str::<PathEvidence>(&missing).unwrap_err();
631        assert!(
632            error
633                .to_string()
634                .contains("missing field `guest_rpc_observed`")
635        );
636    }
637
638    #[test]
639    fn timeslice_stats_empty() {
640        let s = TimesliceStats::default();
641        assert!(s.is_empty());
642        assert_eq!(s.count, 0);
643        assert_eq!(s.mean_ns(), 0); // no divide-by-zero
644    }
645
646    #[test]
647    fn timeslice_stats_record() {
648        let mut s = TimesliceStats::default();
649        s.record(10);
650        s.record(30);
651        s.record(20);
652        assert!(!s.is_empty());
653        assert_eq!(s.count, 3);
654        assert_eq!(s.min_ns, 10);
655        assert_eq!(s.max_ns, 30);
656        assert_eq!(s.sum_ns, 60);
657        assert_eq!(s.mean_ns(), 20);
658    }
659
660    #[test]
661    fn timeslice_stats_merge() {
662        let mut a = TimesliceStats::default();
663        a.record(10);
664        a.record(40);
665        let mut b = TimesliceStats::default();
666        b.record(5);
667        b.record(100);
668        a.merge(&b);
669        assert_eq!(a.count, 4);
670        assert_eq!(a.min_ns, 5);
671        assert_eq!(a.max_ns, 100);
672        assert_eq!(a.sum_ns, 155);
673
674        // Merging an empty distribution is a no-op.
675        let before = a;
676        a.merge(&TimesliceStats::default());
677        assert_eq!(a, before);
678
679        // Merging into an empty distribution adopts the other.
680        let mut empty = TimesliceStats::default();
681        empty.merge(&b);
682        assert_eq!(empty, b);
683    }
684
685    #[test]
686    fn older_summary_json_keeps_an_unrecorded_syscall_total_absent() {
687        let value = serde_json::to_value(RunSummary::default()).unwrap();
688        let mut object = value.as_object().unwrap().clone();
689        object.remove("syscalls");
690        let parsed: RunSummary = serde_json::from_value(object.into()).unwrap();
691        assert_eq!(parsed.syscalls, None);
692
693        let measured = RunSummary {
694            syscalls: Some(0),
695            ..Default::default()
696        };
697        let value = serde_json::to_value(&measured).unwrap();
698        assert_eq!(value["syscalls"], 0);
699        let parsed: RunSummary = serde_json::from_value(value).unwrap();
700        assert_eq!(parsed.syscalls, Some(0));
701    }
702
703    #[test]
704    fn older_summary_json_keeps_preemption_configuration_unknown() {
705        let value = serde_json::to_value(RunSummary::default()).unwrap();
706        assert!(value.get("preemption").is_none());
707        let parsed: RunSummary = serde_json::from_value(value).unwrap();
708        assert_eq!(parsed.preemption, None);
709
710        let mut value = serde_json::to_value(&parsed).unwrap();
711        value["preemption"] = serde_json::json!({ "max_timeslice_ns": 200_000_000 });
712        let parsed: RunSummary = serde_json::from_value(value).unwrap();
713        assert_eq!(
714            parsed.preemption,
715            Some(PreemptionSummary {
716                max_timeslice_ns: Some(200_000_000),
717                timer_enabled: None,
718                stopless_slice_ends: None,
719            })
720        );
721
722        let mut value = serde_json::to_value(&parsed).unwrap();
723        value["preemption"] = serde_json::Value::Null;
724        let parsed: RunSummary = serde_json::from_value(value).unwrap();
725        assert_eq!(parsed.preemption, None);
726    }
727
728    #[test]
729    fn summary_json_distinguishes_disabled_and_positive_preemption() {
730        for (max_timeslice_ns, timer_enabled, expected) in [
731            (
732                None,
733                Some(false),
734                serde_json::json!({ "max_timeslice_ns": null, "timer_enabled": false }),
735            ),
736            (
737                Some(200_000_000),
738                Some(false),
739                serde_json::json!({ "max_timeslice_ns": 200_000_000, "timer_enabled": false }),
740            ),
741            (
742                Some(12_340),
743                Some(true),
744                serde_json::json!({ "max_timeslice_ns": 12_340, "timer_enabled": true }),
745            ),
746            (
747                Some(200_000_000),
748                None,
749                serde_json::json!({ "max_timeslice_ns": 200_000_000, "timer_enabled": null }),
750            ),
751        ] {
752            let summary = RunSummary {
753                preemption: Some(PreemptionSummary {
754                    max_timeslice_ns,
755                    timer_enabled,
756                    stopless_slice_ends: None,
757                }),
758                ..Default::default()
759            };
760            let value = serde_json::to_value(&summary).unwrap();
761            assert_eq!(value["preemption"], expected);
762            let parsed: RunSummary = serde_json::from_value(value).unwrap();
763            assert_eq!(parsed.preemption, summary.preemption);
764        }
765    }
766
767    #[test]
768    fn full_summary_shows_effective_preemption_without_changing_guest_info() {
769        let baseline = RunSummary {
770            syscalls: Some(7),
771            ..Default::default()
772        };
773        let baseline_info = baseline.info(None).to_string();
774        assert!(baseline_info.contains("Guest threads completed 7 syscalls."));
775        assert!(
776            !baseline
777                .to_string()
778                .contains("Configured maximum timeslice:")
779        );
780        for (max_timeslice_ns, timer_enabled, expected) in [
781            (
782                None,
783                Some(false),
784                "Configured maximum timeslice: disabled; timer: none",
785            ),
786            (
787                Some(12_340),
788                Some(true),
789                "Configured maximum timeslice: 12340 virtual ns; timer: enabled",
790            ),
791            (
792                Some(200_000_000),
793                Some(false),
794                "Configured maximum timeslice: 200000000 virtual ns; timer: none",
795            ),
796            (
797                Some(200_000_000),
798                None,
799                "Configured maximum timeslice: 200000000 virtual ns; timer: unknown",
800            ),
801        ] {
802            let preemption = PreemptionSummary {
803                max_timeslice_ns,
804                timer_enabled,
805                stopless_slice_ends: None,
806            };
807            assert_eq!(preemption.to_string(), expected);
808            let summary = RunSummary {
809                preemption: Some(preemption),
810                ..baseline.clone()
811            };
812            assert!(summary.to_string().contains(expected));
813            assert_eq!(summary.info(None).to_string(), baseline_info);
814        }
815    }
816
817    #[test]
818    fn a_count_of_slice_ends_without_a_stop_is_shown_when_known() {
819        let preemption = PreemptionSummary {
820            max_timeslice_ns: Some(1000),
821            timer_enabled: Some(true),
822            stopless_slice_ends: Some(42),
823        };
824        assert_eq!(
825            preemption.to_string(),
826            "Configured maximum timeslice: 1000 virtual ns; timer: enabled; \
827             slice ends taken without a stop (no other thread could run): 42"
828        );
829        let value = serde_json::to_value(preemption).unwrap();
830        assert_eq!(value["stopless_slice_ends"], 42);
831        let unknown = PreemptionSummary {
832            stopless_slice_ends: None,
833            ..preemption
834        };
835        assert!(
836            serde_json::to_value(unknown)
837                .unwrap()
838                .get("stopless_slice_ends")
839                .is_none()
840        );
841    }
842
843    #[test]
844    fn store_exclusive_pairs_are_in_both_views_and_counts_only_in_the_full_report() {
845        let baseline = RunSummary {
846            syscalls: Some(7),
847            ..Default::default()
848        };
849        let value = serde_json::to_value(&baseline).unwrap();
850        for key in [
851            "exclusive_pairs",
852            "store_exclusives",
853            "store_exclusives_unavailable",
854        ] {
855            assert!(value.get(key).is_none(), "{key}");
856        }
857        assert!(!baseline.to_string().contains("tore-exclusive"));
858
859        let pairs = RunSummary {
860            exclusive_pairs: BTreeMap::from([
861                ("/lib64/libc.so.6".to_owned(), 38),
862                ("/bin/guest".to_owned(), 2),
863            ]),
864            ..baseline.clone()
865        };
866        let listing = "Store-exclusive (LL/SC) pairs in executable mappings of 2 file(s):\n  \
867                       /bin/guest: 2\n  /lib64/libc.so.6: 38\n";
868        assert!(pairs.info(None).to_string().contains(listing));
869        assert!(pairs.to_string().contains(listing));
870
871        for (counts, line, json) in [
872            (
873                StoreExclusiveCounts {
874                    failed: 20,
875                    executed: Some(10_000_020),
876                    fully_counted: true,
877                },
878                "20 store-exclusive failures; replay may diverge (10000020 store-exclusives \
879                 executed)\n",
880                serde_json::json!({"failed": 20, "executed": 10_000_020, "fully_counted": true}),
881            ),
882            (
883                StoreExclusiveCounts {
884                    failed: 0,
885                    executed: Some(2),
886                    fully_counted: true,
887                },
888                "2 store-exclusives executed, none failed\n",
889                serde_json::json!({"failed": 0, "executed": 2, "fully_counted": true}),
890            ),
891            (
892                StoreExclusiveCounts {
893                    failed: 0,
894                    executed: Some(1),
895                    fully_counted: true,
896                },
897                "1 store-exclusive executed, none failed\n",
898                serde_json::json!({"failed": 0, "executed": 1, "fully_counted": true}),
899            ),
900            (
901                StoreExclusiveCounts {
902                    failed: 3,
903                    executed: None,
904                    fully_counted: true,
905                },
906                "3 store-exclusive failures; replay may diverge\n",
907                serde_json::json!({"failed": 3, "fully_counted": true}),
908            ),
909            (
910                StoreExclusiveCounts {
911                    failed: 0,
912                    executed: None,
913                    fully_counted: true,
914                },
915                "No store-exclusive failed\n",
916                serde_json::json!({"failed": 0, "fully_counted": true}),
917            ),
918            (
919                StoreExclusiveCounts {
920                    failed: 1,
921                    executed: Some(9),
922                    fully_counted: false,
923                },
924                "1 store-exclusive failure; replay may diverge (9 store-exclusives executed); \
925                 not counted for the whole run (multiplexed, or the task ran on CPUs whose PMU \
926                 does not carry these events)\n",
927                serde_json::json!({"failed": 1, "executed": 9, "fully_counted": false}),
928            ),
929        ] {
930            let counted = RunSummary {
931                store_exclusives: Some(counts),
932                ..pairs.clone()
933            };
934            assert!(counted.to_string().contains(line), "{counted}");
935            assert_eq!(counted.info(None).to_string(), pairs.info(None).to_string());
936            let value = serde_json::to_value(&counted).unwrap();
937            assert_eq!(value["store_exclusives"], json);
938            let parsed: RunSummary = serde_json::from_value(value).unwrap();
939            assert_eq!(parsed.store_exclusives, Some(counts));
940            assert_eq!(parsed.exclusive_pairs, pairs.exclusive_pairs);
941        }
942
943        let unavailable = RunSummary {
944            store_exclusives_unavailable: Some("no PMU".to_owned()),
945            ..pairs.clone()
946        };
947        assert!(
948            unavailable
949                .to_string()
950                .contains("Store-exclusives were not counted: no PMU\n")
951        );
952        assert_eq!(
953            unavailable.info(None).to_string(),
954            pairs.info(None).to_string()
955        );
956    }
957}