ktstr/vmm/
result.rs

1//! Public [`VmResult`] returned from [`super::KtstrVm::run`], plus
2//! the internal [`VmRunState`] passed from `run_vm` to
3//! `collect_results` and the [`KvmStatsTotals`] aggregate of per-vCPU
4//! KVM counters.
5//!
6//! The split keeps the result-shaping types independent of the
7//! orchestration code (which still lives in [`super::KtstrVm`]). Test
8//! code outside `vmm/` constructs `VmResult` literals and reads
9//! `KvmStatsTotals` fields, so both types stay public; `VmRunState`
10//! is `pub(crate)`-only because it's an implementation detail of the
11//! run-then-collect handoff.
12
13use std::collections::HashMap;
14use std::sync::Arc;
15use std::sync::atomic::AtomicBool;
16use std::thread::JoinHandle;
17use std::time::{Duration, Instant};
18
19use super::console;
20use super::host_comms::BulkDrainResult;
21use super::kvm;
22use super::pi_mutex::PiMutex;
23use super::vcpu::{VcpuThread, WatchpointArm};
24use super::virtio_blk::{VirtioBlkCounters, VirtioBlkCountersSnapshot};
25use super::virtio_net::{VirtioNetCounters, VirtioNetCountersSnapshot};
26use super::wire;
27use crate::monitor;
28
29/// Result of a VM execution.
30///
31/// `Clone` is supported, but two field categories have different
32/// Clone semantics that callers must understand:
33///
34/// 1. **Pure-data fields** (the bulk of the struct): primitives,
35///    `String`, `Vec`, `Option<_>`, plus `MonitorReport` /
36///    `BulkDrainResult` / `ProgVerifierStats` / `StimulusEvent` /
37///    `KvmStatsTotals` / `VirtioBlkCountersSnapshot` /
38///    `VirtioNetCountersSnapshot`. Every clone produces an
39///    independent value — mutations to one do not affect the
40///    other. The `virtio_blk_counters` / `virtio_net_counters`
41///    fields are materialized `*CountersSnapshot` types (atomic
42///    loads done at construction time inside
43///    `super::KtstrVm::collect_results`), so clones cannot alias
44///    live device state.
45///
46/// 2. **Arc-shared handles** (`snapshot_bridge`, `stats_client`):
47///    these wrap `Arc<Mutex<…>>` / `Arc<AtomicUsize>` and clone via
48///    shallow refcount bump. Two `VmResult` clones SHARE the
49///    underlying store — calling `snapshot_bridge.drain()` on one
50///    clone empties the data visible to the other. See each
51///    field's own doc for the precise drain / iteration contract.
52///    If you need an independent snapshot view, drain into a local
53///    `Vec` before cloning the `VmResult`.
54///
55/// 3. **The capture-series cache** (`periodic_series_cache`): a
56///    `OnceLock<SampleSeries>` that memoizes the one destructive drain
57///    of the category-2 `snapshot_bridge` (see
58///    [`Self::captures_series`]). Its clone behavior depends on whether
59///    it is populated at clone time:
60///    - **Populated** (any of [`Self::captures_series`] /
61///      [`Self::periodic_series`] / [`Self::phase_buckets`] was already
62///      called): the clone carries an INDEPENDENT copy of the cached
63///      series — category-1 semantics. Both clones return the same
64///      captures without touching the (now-drained) shared bridge.
65///    - **Empty**: the clone shares the category-2 bridge, so the
66///      FIRST `captures_series()` call on EITHER clone performs the
67///      single drain and the other clone — if it later drains the same
68///      shared bridge — sees nothing. To give each clone its own
69///      buckets, call [`Self::captures_series`] (or any accessor that
70///      routes through it) on the original BEFORE cloning.
71#[derive(Debug, Clone)]
72pub struct VmResult {
73    /// Overall success flag: `true` when the test reported a pass AND
74    /// the VM exited cleanly without crash, timeout, or watchdog.
75    pub success: bool,
76    /// Guest vCPU count for this run (topology llcs*cores*threads),
77    /// carried from `KtstrVm` to the sidecar `(vcpus, cpu_budget)` stamp.
78    pub vcpus: u32,
79    /// Effective host-CPU budget the vCPU threads ran on; stamped to the
80    /// sidecar. Normally `KtstrVm::effective_cpu_budget` (the build-time
81    /// plan's CPU count), but the default-overcommit path (host too small
82    /// for a 1:1 pin) overrides it in `run()` with the actual masked
83    /// host-CPU count, since the build-time value assumes 1:1 pinning.
84    /// Below `vcpus` means host overcommit, which confounds the
85    /// guest-scheduler timing metrics.
86    pub cpu_budget: u32,
87    /// How the userspace scheduler binary was resolved for this run —
88    /// the snake_case `crate::test_support::ResolveSource::as_str` tag
89    /// (`"auto_built"`, `"target_debug"`, `"path"`, ...). Stamped by the
90    /// host eval layer (`run_ktstr_test_inner_impl`) AFTER the run,
91    /// alongside `entry_name` / `variant_hash`, then carried to the
92    /// sidecar `resolve_source` stamp the same way `vcpus` / `cpu_budget`
93    /// are. `None` for VmResults built outside the host eval path (the
94    /// freeze coordinator, test fixtures) — those resolve no scheduler
95    /// binary.
96    pub resolve_source: Option<String>,
97    /// True when the `#[ktstr_test(expect_auto_repro)]` attribute set
98    /// `expect_auto_repro = true` on the entry AND the auto-repro
99    /// path fired with a valid repro artifact during the run — the
100    /// signal that the verdict-flip from fail-with-artifact → PASS
101    /// is satisfied.
102    ///
103    /// The eval-layer derives this field AFTER `evaluate_vm_result`
104    /// returns (preserving the original `success` + error chain for
105    /// diagnostic visibility); the eval layer then wraps any
106    /// failure `Err` with the
107    /// `crate::test_support::eval::ExpectAutoReproSatisfied`
108    /// marker, and the dispatch arm
109    /// (`crate::test_support::dispatch::result_to_exit_code`)
110    /// downcasts the marker and routes the verdict to `EXIT_PASS`
111    /// without mutating the original `success` or stripping the
112    /// error chain. Pattern mirrors the `expect_err` matcher
113    /// inversion.
114    ///
115    /// Default `false`. When `expect_auto_repro = false` (the
116    /// macro-attribute default) the eval layer skips the artifact
117    /// probe entirely and leaves the field at `false`, so the
118    /// dispatch arm is never matched and the original verdict
119    /// stands.
120    pub expect_auto_repro_satisfied: bool,
121    /// Guest exit code as surfaced through the SHM ring
122    /// (`MSG_TYPE_EXIT`) or COM2 sentinel.
123    pub exit_code: i32,
124    /// Wall-clock duration of the VM run.
125    pub duration: Duration,
126    /// True when the host hit its watchdog before the guest exited.
127    pub timed_out: bool,
128    /// Captured guest stdout (and any non-dmesg serial console content).
129    pub output: String,
130    /// Captured guest stderr (separated from `output` when the guest
131    /// reported them distinctly).
132    pub stderr: String,
133    /// Host-side monitor report: sampled per-CPU state, stuck
134    /// verdicts, and SCX event deltas. `None` when the monitor did
135    /// not run (host-only tests, early VM failure).
136    pub monitor: Option<monitor::MonitorReport>,
137    /// TLV messages drained from the guest after VM exit. Merges
138    /// mid-flight bytes the freeze coordinator pulled off
139    /// virtio-console port 1 during the run with the final port-1
140    /// `port1_tx_buf` flush.
141    pub guest_messages: Option<BulkDrainResult>,
142    /// BPF verifier stats collected from host-side memory reads.
143    pub verifier_stats: Vec<monitor::bpf_prog::ProgVerifierStats>,
144    /// KVM per-vCPU cumulative stats (requires Linux >= 5.14).
145    pub kvm_stats: Option<KvmStatsTotals>,
146    /// Crash message extracted from COM2 output via
147    /// `crate::test_support::extract_panic_message`. The guest
148    /// panic hook in `rust_init/init.rs` writes `PANIC: <info>\n<bt>\n`
149    /// to `/dev/ttyS1` synchronously inside `KVM_RUN`, so the host
150    /// captures the full backtrace in `output` even when the guest
151    /// is wedged. `None` when no `PANIC:`-prefixed line was seen.
152    pub crash_message: Option<String>,
153    /// Wall-clock time from BSP exit to the moment
154    /// `super::KtstrVm::collect_results` finishes assembling
155    /// [`VmResult`].
156    /// Records the host-side cost of every teardown step that runs
157    /// after the guest has stopped advancing: watchdog join, AP joins,
158    /// monitor join, BPF-writer join, SHM drain, exit/crash-message
159    /// extraction, and BPF verifier-stat read. Always `Some(_)` for
160    /// VMs whose `super::KtstrVm::run_vm` returns normally —
161    /// including the host-watchdog timeout path, because
162    /// `run_bsp_loop` exits cleanly with `timed_out = true` and
163    /// `collect_results` still executes, populating the field.
164    /// `None` only when `run_vm` does not complete (a BSP panic
165    /// propagated through `?`, or any pre-BSP setup error that
166    /// returns an `Err` before `VmRunState` is constructed) and on
167    /// the `test_fixture` / skip-sidecar paths that never boot a VM.
168    /// Persisted via
169    /// [`SidecarResult`](crate::test_support::SidecarResult) so stats
170    /// tooling can flag cleanup regressions across runs.
171    pub cleanup_duration: Option<Duration>,
172    /// Host-side virtio-blk device counters, snapshotted after the
173    /// guest has exited. `Some(_)` when the builder attached a disk
174    /// via `super::KtstrVmBuilder::disk`; `None` when no disk was
175    /// configured and `super::KtstrVm::init_virtio_blk` returned
176    /// `None`. The device increments its internal `AtomicU64`
177    /// counters from `drain_bracket_impl` (production cfg: on the
178    /// dedicated `ktstr-vblk` worker thread; cfg(test): inline on
179    /// the test thread); by the time `collect_results` constructs
180    /// the [`VmResult`] every vCPU and the worker have joined and
181    /// no further mutation can occur. The snapshot is taken at that
182    /// point — readers see plain `u64` fields holding the final
183    /// cumulative totals; no atomic load is needed on the consumer
184    /// side.
185    ///
186    /// The counter struct exposes nine `AtomicU64` fields, each
187    /// bumped from `drain_bracket_impl` (in `src/vmm/virtio_blk/drain.rs`)
188    /// via the `VirtioBlkCounters::record_*` helpers (defined in
189    /// `src/vmm/virtio_blk/counters.rs`). Per-request
190    /// cumulative counters, per-event cumulative counters, and
191    /// per-request live gauges are kept distinct per the
192    /// counter-taxonomy doc on `VirtioBlkCounters`:
193    ///
194    ///   - `reads_completed` — count of `VIRTIO_BLK_T_IN` requests
195    ///     that returned `S_OK` to the guest. Bumped together with
196    ///     `bytes_read` per `VirtioBlkCounters::record_read`.
197    ///   - `writes_completed` — count of `VIRTIO_BLK_T_OUT` requests
198    ///     that returned `S_OK`. Bumped together with `bytes_written`.
199    ///   - `flushes_completed` — count of `VIRTIO_BLK_T_FLUSH`
200    ///     requests that returned `S_OK` (real `fdatasync` for
201    ///     read-write disks, no-op for `read_only`).
202    ///   - `bytes_read` — total bytes returned to the guest for
203    ///     completed reads.
204    ///   - `bytes_written` — total bytes accepted from the guest for
205    ///     completed writes.
206    ///   - `throttled_count` — cumulative token-bucket **stall events**
207    ///     for the device's lifetime. The chain is rolled back and
208    ///     the worker arms a retry timerfd; the guest does not see
209    ///     `S_IOERR` for a stall (the request is deferred until the
210    ///     bucket refills). This counter is separate from `io_errors`
211    ///     so operators can distinguish "throttle bucket drained,
212    ///     request deferred" from "real IO problem". Per-event (NOT
213    ///     per-request): a single chain that stalls twice produces
214    ///     two bumps.
215    ///   - `io_errors` — every path that reports `S_IOERR`:
216    ///     spec violations, backend `pread`/`pwrite` errors,
217    ///     malformed chains, `add_used` failures.
218    ///     Stalls do not report `S_IOERR`; see `throttled_count`.
219    ///   - `currently_throttled_gauge` — **live gauge**: how many
220    ///     requests are RIGHT NOW waiting for throttle tokens.
221    ///     Increments when a chain transitions into stalled,
222    ///     decrements on retry success or reset. Bounded at 0 or 1
223    ///     on this single-queue device. NOT cumulative — answers
224    ///     "what's stuck now," distinct from `throttled_count`
225    ///     which answers "how many stall events happened over
226    ///     time."
227    ///   - `invalid_avail_idx_count` — cumulative count of
228    ///     `Error::InvalidAvailRingIndex` events observed by
229    ///     `drain_bracket_impl` (avail.idx more than `queue.size`
230    ///     ahead of `next_avail` — a virtio-v1.2 §2.7.13.3
231    ///     avail.idx-distance violation by the guest). Per-event
232    ///     counter; the `queue_poisoned` flag short-circuits
233    ///     subsequent kicks so one guest fault produces exactly
234    ///     one bump regardless of how many notifications follow
235    ///     before reset.
236    ///
237    /// Counters are cumulative for the device's lifetime. A guest
238    /// driver re-bind (writing `STATUS=0` to `VIRTIO_MMIO_STATUS`
239    /// triggers `VirtioBlk::reset`) does NOT zero them — the
240    /// device's internal `AtomicU64` storage persists across reset
241    /// cycles, and the post-exit snapshot captures the final
242    /// cumulative totals spanning the entire device lifetime, not
243    /// just a post-reset fragment.
244    ///
245    /// Reading example:
246    ///
247    /// ```ignore
248    /// let r: VmResult = builder.run()?;
249    /// let c = r.virtio_blk_counters.expect("disk attached");
250    /// assert!(c.reads_completed > 0);
251    /// ```
252    ///
253    /// `#[allow(dead_code)]`: the field is part of the public API
254    /// surface and read by user test code outside `lib.rs`, but the
255    /// lib build doesn't see any in-tree readers because no lib code
256    /// path calls `.virtio_blk_counters` on a `VmResult`. The in-tree
257    /// readers live in unit tests.
258    #[allow(dead_code)]
259    pub virtio_blk_counters: Option<VirtioBlkCountersSnapshot>,
260    /// Host-side virtio-net device counters, snapshotted after the
261    /// guest has exited — the cross-NIC AGGREGATE (field-wise
262    /// saturating sum via `VirtioNetCountersSnapshot::aggregate`) over
263    /// every attached NIC. `Some(_)` when the builder attached one or
264    /// more networks via `super::KtstrVmBuilder::network`; `None` when
265    /// no network was configured (the aggregate over an empty NIC set).
266    /// Each NIC's device increments its own `AtomicU64` counters on the
267    /// vCPU thread inside `process_tx_loopback`; by the time
268    /// `collect_results` constructs the [`VmResult`] every vCPU has
269    /// joined and no further mutation can occur. The per-NIC snapshots
270    /// are summed at that point — readers see plain `u64` fields holding
271    /// the final cumulative totals across all NICs; no atomic load is
272    /// needed on the consumer side. Per-NIC IRQ-delivery observability
273    /// comes from the per-CPU / per-IRQ metrics axis, not these
274    /// device-internal loopback counters.
275    ///
276    /// The counter struct exposes sixteen `AtomicU64` fields, each
277    /// bumped across the TX-drain path rooted at `process_tx_loopback`
278    /// (several are bumped inside the `pop_and_capture_tx` /
279    /// `try_loopback_to_rx` helpers it calls; the three `ctrl_*`
280    /// counters are bumped on the control-vq path, not the TX-drain
281    /// path):
282    ///
283    ///   - `tx_packets` — count of TX chains whose L2 frame was
284    ///     captured (`frame_len = Some`) AND whose TX `add_used`
285    ///     succeeded. Over-size-dropped and malformed chains are still
286    ///     marked used (so the guest doesn't hang) but do NOT advance
287    ///     `tx_packets`; a chain whose `add_used` fails advances
288    ///     `tx_add_used_failures` instead. So `tx_packets` advances per
289    ///     successfully-captured-and-published chain, not per parsed
290    ///     chain.
291    ///   - `tx_bytes` — bytes of L2 frame data captured from
292    ///     successfully parsed TX chains (excludes the 12-byte
293    ///     virtio header).
294    ///   - `rx_packets` / `rx_bytes` — count + bytes of RX chains
295    ///     successfully written and marked used. `rx_packets` and
296    ///     `tx_packets` gate INDEPENDENTLY per chain: `rx_packets`
297    ///     bumps when the loopback delivers — recorded BEFORE the TX
298    ///     `add_used` — while `tx_packets` bumps only when that later
299    ///     TX `add_used` succeeds. So the identity
300    ///     `rx_packets == tx_packets - tx_dropped_no_rx_buffer
301    ///     - tx_dropped_rx_poisoned` holds ONLY when both the RX-side
302    ///     failure counters AND `tx_add_used_failures` are zero:
303    ///     RX-side failures (`rx_add_used_failures`, `rx_chain_invalid`,
304    ///     `rx_write_failed`) make `rx_packets` fall SHORT of
305    ///     `tx_packets - drops`, while a `tx_add_used_failures` on a
306    ///     chain whose RX already delivered makes `rx_packets` EXCEED
307    ///       it. Asymmetric counts surface queue-state breakage on
308    ///       either side.
309    ///   - `tx_dropped_no_rx_buffer` — successfully-captured TX
310    ///     frames the device could not deliver because the RX queue
311    ///     was empty (transient back-pressure event).
312    ///   - `tx_dropped_rx_poisoned` — successfully-captured TX frames
313    ///     dropped because the RX queue was poisoned by a prior guest
314    ///     avail-ring violation (wedged until a virtio reset), as
315    ///     opposed to the transient empty-queue back-pressure counted
316    ///     by `tx_dropped_no_rx_buffer`.
317    ///   - `tx_chain_invalid` / `rx_chain_invalid` — chains rejected
318    ///     for malformed shape (short header, wrong direction,
319    ///     attacker-controlled descriptor address overflow).
320    ///   - `tx_oversize_dropped` — TX chains dropped (not truncated)
321    ///     because the captured post-header frame data exceeded the
322    ///     maximum L2 frame size the guest's `max_mtu` permits.
323    ///   - `rx_write_failed` — RX chain whose shape was valid but
324    ///     whose guest-memory `write_slice` (header or frame) hit
325    ///     an unmapped GPA. Distinct from `rx_chain_invalid` so an
326    ///     operator can tell "guest violated the RX descriptor-
327    ///     direction rule" from "guest posted a buffer at an
328    ///     unmapped GPA"; the two are mutually exclusive per chain.
329    ///   - `tx_add_used_failures` / `rx_add_used_failures` —
330    ///     `add_used` failures, indicating the queue's used-ring
331    ///     address itself is unmapped or otherwise inaccessible.
332    ///     Distinct from the `*_chain_invalid` / `rx_write_failed`
333    ///     counters so an operator can tell "guest sent malformed
334    ///     frame" / "guest's posted buffer GPA was unmapped" from
335    ///     "queue itself is broken".
336    ///   - `invalid_avail_idx_count` — cumulative count of
337    ///     `Error::InvalidAvailRingIndex` events observed by
338    ///     `process_tx_loopback` (avail.idx more than `queue.size`
339    ///     ahead of `next_avail` — virtio-v1.2 §2.7.13.3 violation
340    ///     by the guest). Per-event counter; the per-queue
341    ///     `queue_poisoned` flag short-circuits subsequent kicks
342    ///     so one guest fault produces exactly one bump regardless
343    ///     of how many notifications follow before reset.
344    ///   - `ctrl_mq_set` — cumulative count of successful control-vq
345    ///     `VIRTIO_NET_CTRL_MQ` / `VQ_PAIRS_SET` commands (the device
346    ///     updated `curr_queue_pairs` and wrote `VIRTIO_NET_OK`).
347    ///     Per-event counter.
348    ///   - `ctrl_chain_invalid` — cumulative count of control-vq
349    ///     chains the device could not satisfy: malformed shape (no
350    ///     device-writable status descriptor, too few readable command
351    ///     bytes), an unknown `(class, cmd)`, or a `virtqueue_pairs`
352    ///     outside `[1, queue_pairs]`. Per-event hostile/buggy-guest
353    ///     counter; the control-vq analog of `tx_chain_invalid`.
354    ///   - `ctrl_add_used_failures` — cumulative count of control-vq
355    ///     status-write or `add_used` failures (the status byte's GPA
356    ///     or the used-ring address is unmapped). Queue-state breakage
357    ///     distinct from `ctrl_chain_invalid`; the control-vq analog
358    ///     of `tx_add_used_failures`.
359    ///
360    /// Counters are cumulative for the device's lifetime — a guest
361    /// driver re-bind (writing `STATUS=0`) does NOT zero them.
362    #[allow(dead_code)]
363    pub virtio_net_counters: Option<VirtioNetCountersSnapshot>,
364    /// Snapshot bridge populated by the freeze coordinator over the
365    /// run's lifetime. Every `Op::CaptureSnapshot` and `Op::WatchSnapshot`
366    /// fire stores a `FailureDumpReport` keyed by its tag.
367    ///
368    /// `#[ktstr_test]` test bodies whose scenario fires snapshot
369    /// ops in the guest assert on the captured reports through a
370    /// `post_vm = NAME` attribute. The named callback runs on the
371    /// HOST after `vm.run()` returns (see
372    /// [`crate::test_support::KtstrTestEntry::post_vm`]) and
373    /// receives `&VmResult`; it calls
374    /// [`crate::scenario::snapshot::SnapshotBridge::drain`] on
375    /// this field to take ownership of the stored reports and
376    /// walks them — typically through
377    /// [`crate::scenario::snapshot::Snapshot::new`] for typed
378    /// access to map values, per-CPU entries, and scalar
379    /// variables. Out-of-tree consumers can drain the bridge the
380    /// same way: `VmResult` is in `ktstr::prelude`.
381    ///
382    /// Always present after a successful `run_vm`; `None`-equivalent
383    /// (empty) when the VM crashed before any snapshot fired.
384    ///
385    /// **Drained exactly once, via [`Self::captures_series`]**: the
386    /// bridge yields each capture once, so the host-side consumers
387    /// share a single drain. The first call to
388    /// [`Self::captures_series`] — whether from a `post_vm` callback
389    /// (which runs FIRST, via [`Self::periodic_series`] /
390    /// [`Self::phase_buckets`]) or from the framework's
391    /// `evaluate_vm_result` (which runs AFTER `post_vm` to build
392    /// [`crate::assert::ScenarioStats::phases`]) — drains this bridge
393    /// and memoizes the resulting
394    /// [`crate::scenario::sample::SampleSeries`] on the
395    /// `periodic_series_cache` field; every later call reads the
396    /// cache. That is why a per-phase `post_vm` and the framework's
397    /// `result.stats.phases` build no longer starve each other (a
398    /// `post_vm` that drained the bridge first used to leave
399    /// `stats.phases` empty). Integration tests under `tests/` that
400    /// bypass the series accessors and call
401    /// `result.snapshot_bridge.drain*()` directly (e.g.
402    /// `tests/stats_bridge_e2e.rs`, `tests/temporal_assertions_e2e.rs`)
403    /// are unaffected: the cache is only populated by
404    /// [`Self::captures_series`], which those tests never call, so the
405    /// raw destructive drain still returns the full capture set.
406    pub snapshot_bridge: crate::scenario::snapshot::SnapshotBridge,
407    /// Live scheduler-stats client. `Some(_)` when the run wired the
408    /// virtio-console port-2 stats bridge (the in-tree path always
409    /// does so, but tests that construct a [`VmResult`] manually via
410    /// `Self::test_fixture` leave this `None`). Test code that
411    /// asserts on scheduler-reported metrics calls
412    /// `super::SchedStatsClient::stats` /
413    /// `super::SchedStatsClient::stats_meta` on this handle WHILE
414    /// the guest is alive — calling after VM exit will time out
415    /// because the relay thread has already exited. Cloneable;
416    /// multiple test threads may share the same client.
417    #[allow(dead_code)]
418    pub stats_client: Option<super::SchedStatsClient>,
419    /// Number of periodic snapshot boundaries the freeze
420    /// coordinator actually fired during this run. Includes both
421    /// successful captures and rendezvous-timeout placeholders.
422    /// Tests can assert `result.periodic_fired >= some_lower_bound`
423    /// to guard periodic-capture coverage; mismatches against
424    /// [`Self::periodic_target`] flag missing samples (early VM
425    /// exit, kill-flag stop, abandoned-after-timeouts).
426    pub periodic_fired: u32,
427    /// Periodic captures that landed REAL BPF state — the
428    /// placeholder-excluded subset of [`Self::periodic_fired`]
429    /// (`periodic_fired` counts rendezvous-timeout placeholders as
430    /// fired). Snapshotted from
431    /// [`crate::scenario::snapshot::SnapshotBridge::periodic_real_count`]
432    /// at result-collection time so it is stable regardless of any
433    /// later test-side drain of the bridge. `periodic_real <
434    /// periodic_fired` means the gap is placeholder-only fills (the
435    /// boundary fired but the dump was degraded); the failure-output
436    /// periodic-samples section surfaces this so a "100% fired" run
437    /// whose captures were all placeholders does not read as full
438    /// coverage.
439    pub periodic_real: u32,
440    /// Configured `num_snapshots` count for the entry that drove
441    /// this run (mirrors the `KtstrTestEntry::num_snapshots` field
442    /// the entry was registered with). `0` when periodic capture
443    /// was disabled. Pairs with [`Self::periodic_fired`] so a
444    /// test can compute coverage without re-reading the entry
445    /// table.
446    pub periodic_target: u32,
447    /// Runtime virt-KASLR offset (kernel-image slide). Captured
448    /// from the freeze coordinator's `kern_virt_kaslr` Arc snapshot
449    /// at run-end via `load(Acquire).saturating_sub(1)`. `0` means
450    /// either (a) KASLR was off — test ran with
451    /// `#[ktstr_test(kaslr = false)]` or
452    /// `Scheduler::kargs(&["nokaslr"])`, OR (b) the derivation
453    /// chain (MSR_LSTAR readback in `vmm::x86_64::msr_kaslr` +
454    /// KERN_ADDRS `_text` path in `crate::vmm::freeze_coord::dispatch`) never
455    /// published a non-zero value (early-boot crash, kallsyms masked
456    /// by kptr_restrict, FRED-enabled kernel). E2E test consumers
457    /// distinguish (a) from (b) by reading the test entry's `kaslr`
458    /// attribute alongside this field — see
459    /// [`Self::kaslr_enabled`] for the binary-question companion.
460    pub kern_kaslr_offset: u64,
461    /// Name of the `#[ktstr_test]` fn whose execution produced this
462    /// result. Stamped from
463    /// `crate::test_support::entry::KtstrTestEntry::name` (a
464    /// `&'static str` the macro emits at compile time) in
465    /// `test_support::eval::run_ktstr_test_inner_impl` immediately
466    /// after `super::KtstrVm::run` returns and BEFORE the
467    /// `post_vm` callback dispatch runs.
468    ///
469    /// `Some(_)` for every result that flowed through the real
470    /// `run_ktstr_test_inner_impl` path. `None` for the
471    /// `freeze_coord::collect_results` direct-synthesis path
472    /// (entry-agnostic boundary; entry is not in scope there) and
473    /// for `#[cfg(test)]`-only `Self::test_fixture` callers. The
474    /// path-derivation methods `wprof_pb_path` and
475    /// `repro_wprof_pb_path` (require the `wprof` feature) bail with a loud diagnostic
476    /// on `None` so any `VmResult` reaching the derivation path
477    /// without going through the eval-layer stamping site
478    /// surfaces the misuse rather than producing a garbage-named
479    /// path.
480    ///
481    /// Test authors writing `post_vm` callbacks should derive
482    /// per-test sidecar paths via the helper methods rather than
483    /// hardcoding a `wprof_pb_path("<literal>")` string against
484    /// the fn name — a future rename of the test fn drifts the
485    /// hardcoded literal silently, where the method-form derives
486    /// from this field automatically.
487    pub entry_name: Option<&'static str>,
488    /// The run's variant hash (see `variant_hash_from_parts`),
489    /// stamped alongside [`Self::entry_name`] after `vm.run()` returns.
490    /// The post-VM `failure_dump_path` / `wprof_pb_path` derivations
491    /// embed it as the `-{16-hex}` filename suffix so a gauntlet test's
492    /// per-preset dumps don't clobber and each matches its sidecar's
493    /// variant hash. `0` on a synthesized/fixture result (which has
494    /// `entry_name = None` and thus bails before reading this).
495    pub variant_hash: u64,
496    /// Memoized single drain of [`Self::snapshot_bridge`].
497    ///
498    /// The snapshot bridge yields each capture exactly once, but two
499    /// host-side consumers need the captures: a `post_vm` callback
500    /// (which runs first) and the framework's `evaluate_vm_result`
501    /// (which builds [`crate::assert::ScenarioStats::phases`]). Before
502    /// this cache existed, whichever drained first starved the other —
503    /// a per-phase `post_vm` calling [`Self::periodic_series`] left
504    /// `evaluate_vm_result` with an empty bridge, silently emptying
505    /// `result.stats.phases` and the failure-message timeline.
506    ///
507    /// [`Self::captures_series`] performs the one destructive bridge
508    /// drain on first call and stores the resulting full
509    /// [`crate::scenario::sample::SampleSeries`] here; every later call
510    /// — and [`Self::periodic_series`] / [`Self::phase_buckets`] /
511    /// `evaluate_vm_result` — reads the cached series instead of
512    /// re-draining. Lazily populated so a consumer that only touches
513    /// the raw bridge via `snapshot_bridge.drain*()` (e.g. integration
514    /// tests under `tests/`) is unaffected: the cache is never
515    /// initialised on that path.
516    ///
517    /// `pub(crate)` (not `pub`): in-crate constructors
518    /// (`freeze_coord::collect_results`, test fixtures) set it to an
519    /// empty `OnceLock`, but out-of-tree code cannot struct-literal a
520    /// `VmResult` — it flows from `run_vm` — so the cache stays an
521    /// implementation detail behind [`Self::captures_series`].
522    pub(crate) periodic_series_cache: std::sync::OnceLock<crate::scenario::sample::SampleSeries>,
523}
524
525impl VmResult {
526    /// Whether the guest kernel booted with KASLR enabled (= a
527    /// non-zero virt-KASLR offset published into the freeze
528    /// coordinator's `kern_virt_kaslr` Arc). Returns `true` when
529    /// [`Self::kern_kaslr_offset`] is non-zero. The inverse case
530    /// (returns `false`) covers two scenarios: (a) the test
531    /// explicitly opted out via `#[ktstr_test(kaslr = false)]` or
532    /// `Scheduler::kargs(&["nokaslr"])`, OR (b) the derivation
533    /// chain failed to publish a non-zero value (early-boot crash,
534    /// kallsyms masked, kernel built without `CONFIG_RANDOMIZE_BASE`).
535    /// E2E test consumers distinguish (a) from (b) by reading the
536    /// test entry's `kaslr` attribute alongside this method.
537    ///
538    /// Companion to [`Self::kern_kaslr_offset`] — use this when the
539    /// caller cares about the binary "did KASLR happen?" question
540    /// and use the raw field for exact-offset assertions
541    /// (alignment, entropy-range, etc.).
542    pub fn kaslr_enabled(&self) -> bool {
543        self.kern_kaslr_offset != 0
544    }
545
546    /// The full capture series for this run — every snapshot the
547    /// freeze coordinator stored on [`Self::snapshot_bridge`]
548    /// (periodic boundaries AND on-demand `Op::CaptureSnapshot` /
549    /// watchpoint-fire captures), in the order the bridge surfaced.
550    ///
551    /// Performs the bridge's single destructive drain on the first
552    /// call and memoizes the resulting
553    /// [`crate::scenario::sample::SampleSeries`] on the
554    /// `periodic_series_cache` field; every later call — and
555    /// [`Self::periodic_series`] / [`Self::phase_buckets`] and the
556    /// framework's `evaluate_vm_result` — returns the cached series
557    /// without re-draining. This is what lets a `post_vm` callback and
558    /// the framework's [`crate::assert::ScenarioStats::phases`] build
559    /// share one drain instead of starving each other (the bridge
560    /// yields each capture exactly once).
561    ///
562    /// Takes `&self`: the cache uses interior mutability
563    /// ([`std::sync::OnceLock`]) so this composes with the
564    /// `#[ktstr_test(post_vm = ...)]` callback signature
565    /// (`fn(&VmResult) -> Result<()>`).
566    ///
567    /// A consumer that calls `snapshot_bridge.drain*()` directly
568    /// (e.g. integration tests under `tests/`) bypasses this cache. If
569    /// such a raw drain runs BEFORE the first `captures_series()` call
570    /// the cache memoizes an empty series, so prefer this accessor over
571    /// a raw drain on any path that also reaches `evaluate_vm_result`.
572    pub fn captures_series(&self) -> &crate::scenario::sample::SampleSeries {
573        self.periodic_series_cache.get_or_init(|| {
574            crate::scenario::sample::SampleSeries::from_drained_typed(
575                self.snapshot_bridge.drain_ordered_with_stats(),
576                self.monitor.clone(),
577            )
578        })
579    }
580
581    /// The periodic-capture-only view of this run's series: the
582    /// `"periodic_"`-tagged subset of [`Self::captures_series`] — the
583    /// projection the temporal-assertion / per-phase patterns expect
584    /// (on-demand `Op::CaptureSnapshot` and watchpoint-fire captures
585    /// are filtered out as off-cadence outliers, see
586    /// [`crate::scenario::sample::SampleSeries::periodic_only`]).
587    ///
588    /// Reads the shared [`Self::captures_series`] cache (the single
589    /// bridge drain) and returns an owned, periodic-only clone.
590    /// Idempotent: calling it twice — or alongside
591    /// [`Self::phase_buckets`] / `evaluate_vm_result` — no longer
592    /// empties the bridge for the other consumers (the pre-cache
593    /// behavior, which silently starved whichever drained second).
594    ///
595    /// Takes `&self` so it composes with the
596    /// `#[ktstr_test(post_vm = ...)]` callback signature.
597    pub fn periodic_series(&self) -> crate::scenario::sample::SampleSeries {
598        self.captures_series().clone().periodic_only()
599    }
600
601    /// The complete per-phase stimulus timeline for `post_vm`
602    /// callbacks doing per-phase metric assertions: one
603    /// [`crate::timeline::StimulusEvent`] per guest `Stimulus` frame
604    /// (the step-start boundaries, via
605    /// [`crate::timeline::StimulusEvent::from_wire`]) PLUS the
606    /// synthesized terminal scenario-end boundary (from the
607    /// `ScenarioEnd` frame's final cumulative count, via
608    /// [`crate::timeline::StimulusEvent::terminal`]).
609    ///
610    /// Fold THIS through
611    /// [`crate::assert::build_phase_buckets_with_stimulus`] — it is the
612    /// SAME timeline the framework's own `evaluate_vm_result` builds,
613    /// so the LAST step gets an `iteration_rate` (the terminal supplies
614    /// its right boundary). A hand-rolled map over only the guest
615    /// `Stimulus` frames would omit the terminal and silently drop the
616    /// final step's rate.
617    ///
618    /// Non-destructive: reads the already-drained `guest_messages` TLV
619    /// log (unlike the bridge-cache accessors [`Self::captures_series`]
620    /// / [`Self::periodic_series`] / [`Self::phase_buckets`], which
621    /// perform the single destructive snapshot-bridge drain), so it may
622    /// be called alongside the bridge drain. CRC-bad / malformed frames
623    /// are skipped.
624    pub fn stimulus_timeline(&self) -> Vec<crate::timeline::StimulusEvent> {
625        let mut out = Vec::new();
626        let Some(bulk) = &self.guest_messages else {
627            return out;
628        };
629        for entry in &bulk.entries {
630            if !entry.crc_ok {
631                continue;
632            }
633            match wire::MsgType::from_wire(entry.msg_type) {
634                Some(wire::MsgType::Stimulus) => {
635                    if let Some(ev) = wire::StimulusEvent::from_payload(&entry.payload) {
636                        out.push(crate::timeline::StimulusEvent::from_wire(&ev));
637                    }
638                }
639                Some(wire::MsgType::StepEnd) => {
640                    // Per-step end-of-hold frame (reuses the StimulusPayload
641                    // body). Paired with its StepStart for step-local
642                    // iteration_rate in build_phase_buckets_with_stimulus.
643                    if let Some(ev) = wire::StimulusEvent::from_payload(&entry.payload) {
644                        out.push(crate::timeline::StimulusEvent::from_step_end(&ev));
645                    }
646                }
647                Some(wire::MsgType::ScenarioEnd) => {
648                    if let Some((elapsed_ms, total_iterations)) =
649                        wire::parse_scenario_end(&entry.payload)
650                    {
651                        out.push(crate::timeline::StimulusEvent::terminal(
652                            elapsed_ms,
653                            total_iterations,
654                        ));
655                    }
656                }
657                _ => {}
658            }
659        }
660        out
661    }
662
663    /// Worker-iteration throughput (iterations/sec) for one scenario
664    /// [`Phase`](crate::assert::Phase), from the stimulus timeline's
665    /// `StepStart[k]` -> `StepEnd[k]` step-local window (the per-event rate
666    /// via [`crate::timeline::StimulusEvent::rate_to`]).
667    ///
668    /// `None` when the phase has no `StepStart` ([`crate::assert::Phase::BASELINE`], or a
669    /// step the run never reached), no right boundary (no `StepEnd`, no
670    /// later step, and no scenario-end terminal), or the rate is
671    /// unmeasurable (zero-length window / counter went backward). A step
672    /// whose workers made zero forward progress over a positive hold
673    /// returns `Some(0.0)` (measured zero), not `None`.
674    ///
675    /// Collapse-immune: the stimulus timeline carries per-step boundaries
676    /// independent of the periodic-capture pipeline, so this works even for
677    /// `--cell-parent-cgroup` schedulers where the capture-derived
678    /// [`PhaseBucket`](crate::assert::PhaseBucket) path can collapse.
679    pub fn step_throughput(&self, phase: crate::assert::Phase) -> Option<f64> {
680        Self::step_throughput_in(&self.stimulus_timeline(), phase)
681    }
682
683    /// Ratio `step_throughput(a) / step_throughput(b)` — e.g.
684    /// scheduler-vs-EEVDF throughput when phase `b` runs on the detached
685    /// kernel default ([`crate::scenario::ops::Op::detach_scheduler`]).
686    /// Walks the stimulus timeline once. `None` when either phase has no
687    /// measurable throughput; a `Some(0.0)` denominator yields `inf` so a
688    /// collapsed/stalled phase `b` surfaces rather than vanishing to `None`.
689    pub fn throughput_ratio(
690        &self,
691        a: crate::assert::Phase,
692        b: crate::assert::Phase,
693    ) -> Option<f64> {
694        let timeline = self.stimulus_timeline();
695        let ta = Self::step_throughput_in(&timeline, a)?;
696        let tb = Self::step_throughput_in(&timeline, b)?;
697        Some(ta / tb)
698    }
699
700    /// Shared core for [`Self::step_throughput`] / [`Self::throughput_ratio`]
701    /// over an already-built timeline: pair the phase's `StepStart` with its
702    /// own `StepEnd` (step-local), falling back to the next step's
703    /// `StepStart` then the scenario-end terminal for the last step on
704    /// legacy/sched-died data that lacks a `StepEnd`. Mirrors the boundary
705    /// selection in [`crate::timeline::Timeline::build`].
706    fn step_throughput_in(
707        timeline: &[crate::timeline::StimulusEvent],
708        phase: crate::assert::Phase,
709    ) -> Option<f64> {
710        let start = timeline
711            .iter()
712            .find(|e| !e.is_terminal && !e.is_step_end && e.phase() == Some(phase))?;
713        let end = timeline
714            .iter()
715            .find(|e| e.is_step_end && e.phase() == Some(phase))
716            .or_else(|| {
717                timeline
718                    .iter()
719                    .filter(|e| {
720                        !e.is_terminal && !e.is_step_end && e.phase().is_some_and(|p| p > phase)
721                    })
722                    .min_by_key(|e| e.elapsed_ms)
723            })
724            .or_else(|| timeline.iter().find(|e| e.is_terminal))?;
725        start.rate_to(end)
726    }
727
728    /// The guest-side [`crate::assert::AssertResult`] decoded from this
729    /// run's `MSG_TYPE_TEST_RESULT` frame — the verdict the in-VM scenario
730    /// body produced, carrying the per-phase per-cgroup raw telemetry
731    /// carriers in `stats.phases[].per_cgroup`
732    /// ([`crate::assert::PhaseCgroupStats`]: `total_migrations`,
733    /// `run_delays_ns`, `cpus_used`, …) and the per-cgroup reductions in
734    /// `stats.cgroups`.
735    ///
736    /// Non-destructive: reads the already-drained `guest_messages` TLV log
737    /// (the last crc-ok `MSG_TYPE_TEST_RESULT` entry), so it composes with
738    /// the snapshot-bridge accessors and may be called repeatedly. Shares
739    /// the exact decode the eval layer uses
740    /// (`crate::test_support::parse_assert_result_from_drain`).
741    ///
742    /// `Err` when there is no guest verdict to decode — a host-only run, a
743    /// crash before the body emitted its result, or a drain with no
744    /// `MSG_TYPE_TEST_RESULT` frame.
745    ///
746    /// This is the GUEST view: the run-level distribution / `ext_metrics`
747    /// re-pools (`worst_*` wake-latency / run-delay aggregates, pooled
748    /// `iterations_per_cpu_sec`) are applied HOST-side in `evaluate_vm_result`
749    /// AFTER the body returns and are NOT on this value — only
750    /// `stats.phases[].per_cgroup` and the per-cgroup `stats.cgroups`
751    /// reductions are guest-authoritative. For the per-phase per-cgroup view
752    /// aligned to the host capture windows use [`Self::phase_buckets`] (which
753    /// folds these carriers in); for one cgroup in one phase use
754    /// [`Self::phase_cgroup`].
755    pub fn guest_assert_result(&self) -> anyhow::Result<crate::assert::AssertResult> {
756        crate::test_support::parse_assert_result_from_drain(self.guest_messages.as_ref())
757    }
758
759    /// The framework-computed per-phase metric buckets for this run — the
760    /// SAME [`crate::assert::PhaseBucket`] vec the framework folds onto
761    /// [`crate::assert::ScenarioStats::phases`] in `evaluate_vm_result`,
762    /// INCLUDING the per-phase per-cgroup carriers in
763    /// [`crate::assert::PhaseBucket::per_cgroup`].
764    ///
765    /// This is the answer to "my `post_vm` callback wants the per-phase
766    /// metrics the framework already built." It folds the same two sources
767    /// `evaluate_vm_result` does:
768    /// 1. the host-rebuilt buckets from [`Self::periodic_series`] (the
769    ///    periodic-only projection of the shared single drain — on-demand /
770    ///    watchpoint captures are off-cadence outliers excluded from
771    ///    per-phase folds) through
772    ///    [`crate::assert::build_phase_buckets_with_stimulus`] using
773    ///    [`Self::stimulus_timeline`] for the step windows (window + metric
774    ///    folds; `per_cgroup` empty by construction); and
775    /// 2. the guest per-cgroup carriers from [`Self::guest_assert_result`]
776    ///    (`stats.phases[].per_cgroup`), folded in by
777    ///    `crate::assert::fold_guest_per_cgroup_into_host_buckets` keyed by
778    ///    `step_index` (the host window + metrics win; each carrier
779    ///    contributes only its `per_cgroup`, an unmatched carrier surfacing
780    ///    as a `(0,0)`-window orphan bucket).
781    ///
782    /// Production builds `stats.phases` from these same two sources (same
783    /// periodic-only series, same stimulus timeline, same guest carriers) and
784    /// the eval layer's later `populate_run_*` passes write only
785    /// `stats.ext_metrics` / `stats.cgroups`, never `phases[].per_cgroup`, so
786    /// this returns content IDENTICAL to `result.stats.phases` — the
787    /// no-carrier case pinned by
788    /// `phase_buckets_equals_stats_phases_and_post_vm_read_does_not_starve`
789    /// and the with-per-cgroup-carrier case by
790    /// `phase_buckets_equals_stats_phases_with_guest_per_cgroup_carriers`.
791    ///
792    /// Both sources are non-destructive on the snapshot bridge:
793    /// [`Self::periodic_series`] reads the memoized single drain
794    /// ([`Self::captures_series`]) and [`Self::guest_assert_result`] reads the
795    /// already-drained `guest_messages`. A run with no guest verdict
796    /// ([`Self::guest_assert_result`] `Err` — host-only / early crash) folds
797    /// no carriers and returns the host-rebuilt buckets alone (the prior
798    /// behavior).
799    pub fn phase_buckets(&self) -> Vec<crate::assert::PhaseBucket> {
800        let mut buckets = self.phase_buckets_pre_derive();
801        // Derive the per-phase scalars into the now-final (post-fold) buckets so
802        // a per-phase A/B claim reads them via phase_metric / phase_cgroup_metric:
803        // the non-schbench carrier scalars (every cgroup) into each pc.metrics,
804        // and the schbench scalars into pc.metrics + the pooled bucket.metrics.
805        // A no-op only when no phase carries a per-cgroup carrier; must run
806        // post-fold (the merge skips is_derived keys, so an earlier derive would
807        // be dropped).
808        crate::assert::derive_phase_metrics(&mut buckets);
809        buckets
810    }
811
812    /// The pre-`derive_phase_metrics` phase fold: host buckets from
813    /// [`Self::periodic_series`] + [`Self::stimulus_timeline`] with the guest
814    /// per-cgroup carriers folded in, BEFORE the per-phase scalar derivation
815    /// [`Self::phase_buckets`] applies. This is the exact phase state the eval
816    /// layer feeds to the run-level ext-metrics population
817    /// (`populate_run_ext_metrics_from_phases` runs on the pre-derive phases;
818    /// `derive_phase_metrics` runs AFTER, inside `evaluate_vm_result`), so
819    /// [`Self::run_metric`] reuses it to reproduce that sequence by construction
820    /// (eval-faithful): post-derive phases yield the same run-level map today (the
821    /// run-level phase fold skips `is_derived` keys, and the pooled scalars
822    /// `derive_phase_metrics` adds are all `PerPhase`), but the pre-derive fold
823    /// avoids depending on that skip, so a pooled key ever registered as
824    /// non-derived cannot diverge `run_metric` from the eval map. Non-destructive
825    /// on the snapshot bridge, like [`Self::phase_buckets`].
826    fn phase_buckets_pre_derive(&self) -> Vec<crate::assert::PhaseBucket> {
827        let host = crate::assert::build_phase_buckets_with_stimulus(
828            &self.periodic_series(),
829            &self.stimulus_timeline(),
830        );
831        match self.guest_assert_result() {
832            Ok(guest) => {
833                crate::assert::fold_guest_per_cgroup_into_host_buckets(host, guest.stats.phases)
834            }
835            Err(_) => host,
836        }
837    }
838
839    /// One phase's per-cgroup telemetry for `cgroup` — the per-phase analog
840    /// of reading `result.stats.cgroups` for a single phase. Reads
841    /// [`Self::phase_buckets`] (which folds the guest per-cgroup carriers
842    /// against the host capture windows) and returns the
843    /// [`crate::assert::PhaseCgroupStats`] keyed by `cgroup` in `phase`.
844    ///
845    /// `None` when `phase` has no bucket (no capture landed in it AND no
846    /// stimulus `StepStart` synthesized one) or the phase carries no carrier
847    /// for `cgroup` (the cgroup ran no workers in that phase, or the run had
848    /// no step-local cgroups). Owned (cloned from the folded bucket) so it
849    /// composes with the `&VmResult` `post_vm` callback signature.
850    pub fn phase_cgroup(
851        &self,
852        phase: crate::assert::Phase,
853        cgroup: &str,
854    ) -> Option<crate::assert::PhaseCgroupStats> {
855        self.phase_buckets()
856            .into_iter()
857            .find(|b| b.step_index == phase.as_u16())
858            .and_then(|b| b.per_cgroup.get(cgroup).cloned())
859    }
860
861    /// One framework-computed per-phase metric for `phase` — the
862    /// metric-name analog of [`Self::step_throughput`] /
863    /// [`Self::throughput_ratio`]. Resolves `metric` (any `impl Into<MetricId>` —
864    /// a typed `BuiltinMetric`, typo-proof, or a dynamic scheduler-runtime
865    /// string) from the folded
866    /// [`Self::phase_buckets`] bucket for `phase`, checking two stores:
867    /// 1. [`crate::assert::PhaseBucket::metrics`] (via
868    ///    [`crate::assert::PhaseBucket::get`]) — the host-folded
869    ///    per-sample / monitor / stimulus metrics: per-phase CPU time
870    ///    (`system_time_ns`, `user_time_ns`), scheduling quality
871    ///    (`avg_imbalance_ratio`, `avg_dsq_depth`, ...), and
872    ///    `iteration_rate`.
873    /// 2. failing that, the cross-cgroup phase sum of a per-cgroup Counter
874    ///    ([`crate::assert::PhaseBucket::cgroup_counter_total`]) for the
875    ///    keys whose value lives ONLY in the per-cgroup carriers —
876    ///    `"total_migrations"`, `"total_iterations"`, and
877    ///    `"total_cpu_time_ns"`. These are
878    ///    registered `Counter`s with no per-sample source
879    ///    (`crate::stats::MetricDef::read_sample` returns `None`), so
880    ///    they never reach `metrics`; without this fallback
881    ///    `phase_metric(phase, "total_migrations")` returned a silent
882    ///    `None` even though the value was present per-cgroup.
883    ///
884    /// The wake-latency / run-delay distributions (`MetricKind::Distribution`)
885    /// have no per-phase `metrics` value and no single per-cgroup Counter
886    /// (they re-pool run-level from the per-cgroup raw sample vectors), so
887    /// they are not readable here — read them per-cgroup via
888    /// [`Self::phase_cgroup`].
889    ///
890    /// Reads the SAME buckets [`Self::phase_buckets`] folds onto
891    /// `result.stats.phases`, so a `post_vm` callback can compare any
892    /// metric across two phases (e.g. scheduler-vs-detached-EEVDF
893    /// `total_migrations` or `system_time_ns`) without re-deriving the
894    /// buckets — the general form of the scheduler-vs-EEVDF throughput
895    /// compare.
896    ///
897    /// `None` when `phase` has no bucket — no capture landed in it AND no
898    /// stimulus `StepStart` synthesized one (e.g. `Phase::BASELINE` when
899    /// the settle window fired no captures, or a `phase` past the last
900    /// step) — or the bucket carries no reading for `metric` in either
901    /// store. Sentinel-free, distinct from a real `Some(0.0)`: a
902    /// per-cgroup counter returns `Some(0.0)` when carriers exist but
903    /// counted zero, `None` only when the phase has no carrier at all. A
904    /// started-but-uncaptured step (a `StepStart` with zero captures) DOES
905    /// produce a synthesized bucket, so `phase_metric` returns its
906    /// stimulus-derived `iteration_rate` rather than `None`.
907    ///
908    /// ```ignore
909    /// // Typed (typo-proof) is the primary form; a dynamic scheduler-runtime
910    /// // string is the escape hatch through the SAME call.
911    /// let p99 = result.phase_metric(Phase::step(0), BuiltinMetric::WakeupP99LatencyUs);
912    /// let custom = result.phase_metric(Phase::step(0), "scx_layered_layer0_util");
913    /// ```
914    pub fn phase_metric(
915        &self,
916        phase: crate::assert::Phase,
917        metric: impl Into<crate::stats::MetricId>,
918    ) -> Option<f64> {
919        let metric = metric.into();
920        self.phase_buckets()
921            .into_iter()
922            .find(|b| b.step_index == phase.as_u16())
923            .and_then(|b| {
924                b.get(metric.as_str())
925                    .or_else(|| b.cgroup_counter_total(metric.as_str()))
926            })
927    }
928
929    /// One run-level extensible ("ext") metric by name — the whole-run analog of
930    /// [`Self::phase_metric`], for a `post_vm` callback asserting a run-level
931    /// aggregate (e.g. `avg_irq_util`, `max_cpu_hardirqs`,
932    /// `worst_p99_wake_latency_us`, `iterations_per_cpu_sec`). SELF-COMPUTES the
933    /// run-level `ext_metrics` map exactly as the framework's `evaluate_vm_result`
934    /// does — a [`VmResult`](Self) carries no stored run-level stats (`post_vm`
935    /// runs BEFORE the host populates them) — by replaying the shared
936    /// [`crate::assert::populate_run_ext_all`] sequence over
937    /// [`Self::periodic_series`], the pre-derive phase fold
938    /// (`phase_buckets_pre_derive`), and the guest per-cgroup `stats.cgroups`.
939    /// The result is byte-identical to the run-level `ext_metrics` the sidecar
940    /// records for this run.
941    ///
942    /// Resolves the ext-sourced family that
943    /// [`crate::assert::ScenarioStats::run_metric`] (the post-merge host
944    /// accessor) resolves — the `read_sample`-wired registry metrics, the
945    /// phase-only ext metrics (`avg_imbalance_ratio`, `iteration_rate`,
946    /// `system_time_ns`, `user_time_ns`, the IRQ counters/rates, the per-CPU
947    /// spatial maxes `max_cpu_hardirqs` / `max_cpu_softirq_net_rx` and their
948    /// concentrations), the pooled `iterations_per_cpu_sec`, and the run-level
949    /// `Distribution` / `WorstLowest` / `WakeLatencyTailRatio` / `WorstCrossNodeRatio`
950    /// re-pools — and for
951    /// those keys the two accessors return identical values (this one
952    /// self-computes pre-merge, the other reads the stored post-merge map).
953    ///
954    /// ADDITIONALLY resolves the 5 ext-only run-level MONITOR metrics from
955    /// [`Self::monitor`]'s summary: `avg_nr_running`, `avg_irq_util`,
956    /// `max_avg_irq_util`, `psi_irq_full_avg10`, `total_irq_pressure_us` (folded
957    /// via `MonitorSummary::fold_run_level_ext`, shared with the sidecar row).
958    /// These DIVERGE from [`crate::assert::ScenarioStats::run_metric`], which has
959    /// no `MonitorReport` to fold and returns `None` for them — the one place the
960    /// two accessors differ.
961    ///
962    /// RESOLVED here None-aware (via the delegated `ScenarioStats::run_metric`
963    /// typed dispatch): the cross-cgroup metrics `worst_spread`,
964    /// `worst_migration_ratio`, `worst_gap_ms`, `total_migrations`,
965    /// `total_iterations`, `worst_page_locality`,
966    /// `worst_cross_node_migration_ratio`. The dispatch re-derives each from the
967    /// carriers (the per-cgroup `stats.cgroups` + the per_cgroup-folded
968    /// `stats.phases` this method builds) — `None` when no carrier measured it,
969    /// `Some(0.0)` for a measured zero. The 5 non-NUMA metrics are 0.0-sentinel
970    /// typed struct fields (re-derived because the field cannot carry the
971    /// measured-vs-unmeasured distinction); the two NUMA roll-ups
972    /// (`worst_page_locality`, `worst_cross_node_migration_ratio`) have no struct
973    /// field and re-pool purely from the per-phase NUMA carriers.
974    ///
975    /// NOT resolved here:
976    /// - the typed-backed monitor run-level metrics (`max_imbalance_ratio`,
977    ///   `max_dsq_depth`, `stuck_count`, `total_fallback`, `total_keep_last`) —
978    ///   these have typed `GauntletRow` fields (not ext-only); read them
979    ///   per-phase via [`Self::phase_metric`].
980    ///
981    /// Sentinel-free, matching [`Self::phase_metric`]: `None` means the metric is
982    /// absent from this run (no populator produced it, or a name not in the map);
983    /// `Some(0.0)` is a real measured zero. Check [`crate::stats::MetricId::def`]
984    /// on a dynamic key to distinguish an unregistered key from genuinely-absent
985    /// data (built-in ids always resolve).
986    ///
987    /// A host-only run (no guest verdict — [`Self::guest_assert_result`] `Err`)
988    /// resolves the SampleSeries + phase families but no per-cgroup pooled /
989    /// distribution keys (no per-cgroup data exists), the same absence
990    /// [`crate::assert::ScenarioStats::run_metric`] documents.
991    ///
992    /// Non-destructive: reads the memoized snapshot-bridge drain
993    /// ([`Self::periodic_series`] / [`Self::phase_buckets`]) and the
994    /// already-drained `guest_messages`, so it composes with [`Self::phase_metric`]
995    /// in one `post_vm` callback.
996    ///
997    /// ```ignore
998    /// let irq = result.run_metric(BuiltinMetric::AvgIrqUtil);
999    /// let custom = result.run_metric("scx_layered_layer0_util");
1000    /// ```
1001    pub fn run_metric(&self, metric: impl Into<crate::stats::MetricId>) -> Option<f64> {
1002        let metric = metric.into();
1003        let mut stats = crate::assert::ScenarioStats {
1004            phases: self.phase_buckets_pre_derive(),
1005            cgroups: self
1006                .guest_assert_result()
1007                .map(|g| g.stats.cgroups)
1008                .unwrap_or_default(),
1009            ..Default::default()
1010        };
1011        crate::assert::populate_run_ext_all(&mut stats, &self.periodic_series());
1012        // Fold the run-level ext-only monitor metrics (avg_nr_running + the PELT
1013        // IRQ load pair + the PSI-irq pair) from the stored MonitorReport
1014        // summary. populate_run_ext_all can't produce them — they're
1015        // MonitorSummary-sourced, not phase/series-folded — but VmResult holds
1016        // self.monitor, so (unlike ScenarioStats::run_metric, which has no
1017        // monitor) this accessor CAN resolve them. Shared with
1018        // group::sidecar_to_row via fold_run_level_ext.
1019        if let Some(report) = self.monitor.as_ref() {
1020            report.summary.fold_run_level_ext(&mut stats.ext_metrics);
1021        }
1022        // Delegate to ScenarioStats::run_metric: it resolves the typed
1023        // 0.0-sentinel cross-cgroup fields None-aware from the carriers
1024        // (stats.cgroups + the per_cgroup-folded stats.phases, both populated
1025        // above) ahead of the ext lookup — so the typed fields resolve here too,
1026        // matching ScenarioStats::run_metric. The monitor metrics folded into
1027        // stats.ext_metrics above resolve via that method's ext fallback.
1028        stats.run_metric(metric)
1029    }
1030
1031    /// Polarity-aware "is the `candidate` phase better than the `baseline` phase
1032    /// on `metric`?" comparator — the per-phase A/B primitive for "assert
1033    /// scheduler X beats EEVDF across phases" (e.g. an scx Step vs the EEVDF
1034    /// Step after `Op::DetachScheduler`). Resolves both per-phase values via
1035    /// [`Self::phase_metric`] and the metric's polarity via
1036    /// `crate::stats::metric_def`, then returns a
1037    /// [`crate::assert::temporal::BetterThanPhase`] builder whose terminal
1038    /// (`better_than` / `by_at_least`) records the outcome into `verdict`.
1039    /// "Better" is oriented from the registry polarity, so the SAME call works
1040    /// for a LowerBetter latency (`BuiltinMetric::WakeupP99LatencyUs`) and a
1041    /// HigherBetter throughput (`BuiltinMetric::SchbenchLoopCount`) with no
1042    /// caller-specified direction.
1043    ///
1044    /// A post_vm callback collapses the verdict to its `anyhow::Result` via
1045    /// [`crate::assert::Verdict::into_anyhow_or_log`], which bails on a Fail OR
1046    /// an Inconclusive — so a missing metric (a phase with no schbench carrier),
1047    /// an undirected metric, or a zero-baseline fractional-margin comparison
1048    /// does NOT silently pass.
1049    pub fn better_across_phases<'v>(
1050        &self,
1051        verdict: &'v mut crate::assert::Verdict,
1052        baseline: crate::assert::Phase,
1053        candidate: crate::assert::Phase,
1054        metric: impl Into<crate::stats::MetricId>,
1055    ) -> crate::assert::temporal::BetterThanPhase<'v> {
1056        let metric = metric.into();
1057        crate::assert::temporal::BetterThanPhase::new(
1058            metric.as_str().to_string(),
1059            verdict,
1060            baseline,
1061            candidate,
1062            self.phase_metric(baseline, metric.clone()),
1063            self.phase_metric(candidate, metric.clone()),
1064            metric.def().map(|m| m.polarity),
1065            None, // pooled producer — no per-cgroup scope label
1066        )
1067    }
1068
1069    /// One per-phase, PER-CGROUP derived metric — the per-cgroup analog of
1070    /// [`Self::phase_metric`], answering "metric M of cgroup C in phase P" as
1071    /// readily as the phase aggregate (the N-cgroups-to-N-queryable-sets goal).
1072    /// Resolves `metric` (any `impl Into<MetricId>` — a typed `BuiltinMetric` or
1073    /// a dynamic scheduler-runtime string) from this
1074    /// cgroup's per-phase carrier via [`crate::assert::PhaseCgroupStats::get`] (its
1075    /// derived `metrics` map), falling back to
1076    /// [`crate::assert::PhaseCgroupStats::cgroup_counter`] for the per-cgroup
1077    /// Counters `total_migrations`/`total_iterations`/`total_cpu_time_ns` (carrier
1078    /// fields, not derived) — symmetric with [`Self::phase_metric`]'s
1079    /// `cgroup_counter_total` fallback.
1080    /// `None` when `phase` has no bucket, the bucket has no carrier for `cgroup`, or
1081    /// the carrier carried no finite value for the metric — sentinel-free, distinct
1082    /// from a real `Some(0.0)`.
1083    pub fn phase_cgroup_metric(
1084        &self,
1085        phase: crate::assert::Phase,
1086        cgroup: &str,
1087        metric: impl Into<crate::stats::MetricId>,
1088    ) -> Option<f64> {
1089        let metric = metric.into();
1090        self.phase_cgroup(phase, cgroup).and_then(|pc| {
1091            pc.get(metric.as_str())
1092                .or_else(|| pc.cgroup_counter(metric.as_str()))
1093        })
1094    }
1095
1096    /// Per-cgroup analog of [`Self::better_across_phases`]: "is `metric` of
1097    /// `cgroup` better in the `candidate` phase than the `baseline` phase?",
1098    /// oriented from the registry polarity. Reuses the SAME
1099    /// [`crate::assert::temporal::BetterThanPhase`] comparator/verdict machinery;
1100    /// only value resolution is re-scoped from the pooled aggregate to the named
1101    /// cgroup, so a missing carrier / undirected metric / zero-baseline margin
1102    /// collapses to Inconclusive (not a silent pass), exactly as the pooled form.
1103    pub fn better_across_phases_cgroup<'v>(
1104        &self,
1105        verdict: &'v mut crate::assert::Verdict,
1106        baseline: crate::assert::Phase,
1107        candidate: crate::assert::Phase,
1108        cgroup: &str,
1109        metric: impl Into<crate::stats::MetricId>,
1110    ) -> crate::assert::temporal::BetterThanPhase<'v> {
1111        let metric = metric.into();
1112        crate::assert::temporal::BetterThanPhase::new(
1113            metric.as_str().to_string(),
1114            verdict,
1115            baseline,
1116            candidate,
1117            self.phase_cgroup_metric(baseline, cgroup, metric.clone()),
1118            self.phase_cgroup_metric(candidate, cgroup, metric.clone()),
1119            metric.def().map(|m| m.polarity),
1120            Some(cgroup.to_string()), // per-cgroup scope label for the diagnostics
1121        )
1122    }
1123
1124    /// Minimal "nothing happened" fixture for tests that exercise
1125    /// code consuming a [`VmResult`] without actually booting a VM
1126    /// (the sidecar-write tests in `src/test_support/sidecar.rs`
1127    /// are the primary users). Every field carries the empty /
1128    /// default / `None` value that `run_vm` would produce for a
1129    /// VM that launched, exited cleanly with exit code 0, and
1130    /// produced no telemetry. Tests that need a specific field
1131    /// override it with a struct-update expression:
1132    ///
1133    /// ```ignore
1134    /// let result = VmResult { success: false, ..VmResult::test_fixture() };
1135    /// ```
1136    ///
1137    /// Gated on `#[cfg(test)]` so the symbol does not appear in
1138    /// release builds — production `VmResult` values flow from
1139    /// `run_vm` and never from this fixture. See
1140    /// `sidecar_vm_result_is_test_fixture_boilerplate` in
1141    /// `test_support/sidecar.rs` for the motivating deduplication
1142    /// (7 identical literal constructions collapsed to a single
1143    /// call).
1144    #[cfg(test)]
1145    pub fn test_fixture() -> Self {
1146        Self {
1147            success: true,
1148            vcpus: 1,
1149            cpu_budget: 1,
1150            resolve_source: None,
1151            expect_auto_repro_satisfied: false,
1152            exit_code: 0,
1153            duration: Duration::from_secs(1),
1154            timed_out: false,
1155            output: String::new(),
1156            stderr: String::new(),
1157            monitor: None,
1158            guest_messages: None,
1159            verifier_stats: Vec::new(),
1160            kvm_stats: None,
1161            crash_message: None,
1162            cleanup_duration: None,
1163            virtio_blk_counters: None,
1164            virtio_net_counters: None,
1165            snapshot_bridge: empty_snapshot_bridge_for_tests(),
1166            stats_client: None,
1167            periodic_fired: 0,
1168            periodic_real: 0,
1169            periodic_target: 0,
1170            kern_kaslr_offset: 0,
1171            entry_name: None,
1172            variant_hash: 0,
1173            periodic_series_cache: std::sync::OnceLock::new(),
1174        }
1175    }
1176
1177    /// Per-test sidecar path for the `.wprof.pb` artifact:
1178    /// `{sidecar_dir()}/{entry_name}-{variant_hash:016x}.wprof.pb`.
1179    #[cfg(feature = "wprof")]
1180    pub fn wprof_pb_path(&self) -> anyhow::Result<std::path::PathBuf> {
1181        let name = self.entry_name.ok_or_else(|| {
1182            anyhow::anyhow!(
1183                "VmResult.entry_name is None — wprof_pb_path() requires the \
1184                 macro-stamped entry name set by run_ktstr_test_inner_impl \
1185                 after vm.run() returns. A `None` here means the VmResult \
1186                 was constructed via the freeze_coord::collect_results \
1187                 direct-synthesis path and the eval-layer stamping was \
1188                 bypassed; route the result through run_ktstr_test_inner_impl \
1189                 OR assign entry_name = Some(\"<test-fn-name>\") manually \
1190                 before calling .wprof_pb_path()."
1191            )
1192        })?;
1193        Ok(crate::test_support::sidecar_dir()
1194            .join(format!("{name}-{:016x}.wprof.pb", self.variant_hash)))
1195    }
1196
1197    /// Per-test sidecar path for the `.repro.wprof.pb` artifact.
1198    #[cfg(feature = "wprof")]
1199    pub fn repro_wprof_pb_path(&self) -> anyhow::Result<std::path::PathBuf> {
1200        let name = self.entry_name.ok_or_else(|| {
1201            anyhow::anyhow!(
1202                "VmResult.entry_name is None — repro_wprof_pb_path() \
1203                 requires the macro-stamped entry name set by \
1204                 run_ktstr_test_inner_impl after vm.run() returns. A `None` \
1205                 here means the VmResult was constructed via the \
1206                 freeze_coord::collect_results direct-synthesis path and \
1207                 the eval-layer stamping was bypassed; route the result \
1208                 through run_ktstr_test_inner_impl OR assign entry_name \
1209                 manually before calling."
1210            )
1211        })?;
1212        Ok(crate::test_support::sidecar_dir()
1213            .join(format!("{name}-{:016x}.repro.wprof.pb", self.variant_hash)))
1214    }
1215
1216    /// Per-test failure-dump sidecar path. Derives
1217    /// `{sidecar_dir()}/{entry_name}-{variant_hash:016x}.failure-dump.json`
1218    /// from the macro-stamped [`Self::entry_name`].
1219    ///
1220    /// # Sibling to
1221    /// [`crate::scenario::Ctx::failure_dump_path`]
1222    ///
1223    /// The pre-VM body context carries its own copy of the
1224    /// macro-stamped entry name (stamped at Ctx construction by
1225    /// the dispatch path) and computes the same path string. A
1226    /// test body invocation `ctx.failure_dump_path()` and a
1227    /// post-VM `result.failure_dump_path()` resolve to identical
1228    /// paths because both stamp from the same
1229    /// `entry.name: &'static str` source — proc-macro emission
1230    /// at the `#[ktstr_test]` site. This pair gives post_vm
1231    /// callbacks a symmetric path-derivation surface to the
1232    /// pre-VM body, so a future post_vm hook that wants to
1233    /// inspect or clean up the failure dump uses the same method
1234    /// shape the body uses to look at it.
1235    ///
1236    /// # Errors
1237    ///
1238    /// Returns `Err` when [`Self::entry_name`] is `None`.
1239    pub fn failure_dump_path(&self) -> anyhow::Result<std::path::PathBuf> {
1240        let name = self.entry_name.ok_or_else(|| {
1241            anyhow::anyhow!(
1242                "VmResult.entry_name is None — failure_dump_path() \
1243                 requires the macro-stamped entry name set by \
1244                 run_ktstr_test_inner_impl after vm.run() returns. \
1245                 A `None` here means the VmResult was constructed via \
1246                 the freeze_coord::collect_results direct-synthesis \
1247                 path and the eval-layer stamping was bypassed; route \
1248                 the result through run_ktstr_test_inner_impl OR \
1249                 assign entry_name manually before calling."
1250            )
1251        })?;
1252        Ok(crate::test_support::sidecar_dir().join(format!(
1253            "{name}-{:016x}.failure-dump.json",
1254            self.variant_hash
1255        )))
1256    }
1257
1258    /// Concatenated guest `/dev/kmsg` content forwarded via
1259    /// `crate::send_kmsg`, or empty when no frames arrived (the scenario
1260    /// did not forward, or the VM exited before the forward completed).
1261    /// Lets a post_vm callback read the guest kernel log even at the
1262    /// default `loglevel=0`, where kernel printks never reach the COM1
1263    /// console (and thus not `stderr`). Encapsulates the bulk-port
1264    /// `ShmEntry` + `MsgType::Dmesg` filter inside the crate.
1265    pub fn guest_kmsg(&self) -> String {
1266        let Some(drain) = self.guest_messages.as_ref() else {
1267            return String::new();
1268        };
1269        drain
1270            .entries
1271            .iter()
1272            .filter(|e| {
1273                e.crc_ok
1274                    && !e.payload.is_empty()
1275                    && matches!(
1276                        crate::vmm::wire::MsgType::from_wire(e.msg_type),
1277                        Some(crate::vmm::wire::MsgType::Dmesg)
1278                    )
1279            })
1280            .map(|e| String::from_utf8_lossy(&e.payload).into_owned())
1281            .collect::<Vec<String>>()
1282            .join("\n")
1283    }
1284
1285    /// The scheduler's captured log — its stderr, including any libbpf /
1286    /// BPF-verifier output — extracted from the bulk-port
1287    /// `MSG_TYPE_SCHED_LOG` frames in [`Self::guest_messages`]. Empty when
1288    /// no scheduler log was captured (no scheduler was spawned, or the
1289    /// framed markers never arrived).
1290    ///
1291    /// Mirrors the extraction [`crate::verifier::collect_verifier_output`]
1292    /// performs: concatenate the `SchedLog` chunks, slice the span from the
1293    /// first `SCHED_OUTPUT_START` to the last `SCHED_OUTPUT_END` (spanning
1294    /// any intervening markers when the guest staged more than one log),
1295    /// and fall back to `output` when the bulk-port drain carried no
1296    /// `SchedLog` frames (a kernel without the bulk port). Lets a post_vm
1297    /// callback assert on what the scheduler logged — e.g. a libbpf
1298    /// verifier-reject — without reaching into the crate-internal wire
1299    /// types.
1300    pub fn scheduler_log(&self) -> String {
1301        let merged = crate::verifier::concat_sched_log_chunks(self.guest_messages.as_ref());
1302        let source = if merged.is_empty() {
1303            &self.output
1304        } else {
1305            &merged
1306        };
1307        crate::verifier::parse_sched_output(source)
1308            .unwrap_or("")
1309            .to_string()
1310    }
1311
1312    /// The host watchdog-override readback (`expected_jiffies` =
1313    /// host-written, `observed_jiffies` = read back from guest memory).
1314    /// `None` when the scheduler never attached (no readback recorded).
1315    /// A post_vm callback asserts the two are equal to prove the override
1316    /// landed; the readback is taken eagerly by the monitor (in-DRAM,
1317    /// microseconds), so it is immune to the watchdog-kworker starvation
1318    /// that inflates the kernel-measured stall duration. Encapsulates the
1319    /// `MonitorReport` access (`WatchdogObservation` is re-exported at the
1320    /// crate root for the return type).
1321    pub fn watchdog_observation(&self) -> Option<crate::monitor::WatchdogObservation> {
1322        self.monitor.as_ref().and_then(|m| m.watchdog_observation)
1323    }
1324
1325    /// Assert the primary-VM `.wprof.pb` landed and is shape-valid.
1326    /// Returns `Ok(())` immediately when `self.success` is false.
1327    #[cfg(feature = "wprof")]
1328    pub fn assert_wprof_pb_landed(&self) -> anyhow::Result<()> {
1329        if !self.success {
1330            return Ok(());
1331        }
1332        // Pre-check `entry_name` with a callable-specific diagnostic
1333        // BEFORE delegating to `self.wprof_pb_path()` (which would
1334        // bail with the wprof_pb_path-perspective message). A
1335        // fixture-constructed VmResult hitting this method should
1336        // see a diagnostic naming `assert_wprof_pb_landed` so the
1337        // caller's mental model lines up with the error text.
1338        anyhow::ensure!(
1339            self.entry_name.is_some(),
1340            "VmResult::assert_wprof_pb_landed requires entry_name set by \
1341             run_ktstr_test_inner_impl after vm.run() returns. This \
1342             VmResult was constructed manually (freeze_coord direct \
1343             synthesis path or a test fixture); either route the result \
1344             through run_ktstr_test_inner_impl OR call \
1345             crate::test_support::wprof::assert_wprof_pb_shape with a \
1346             manually-computed path.",
1347        );
1348        let path = self.wprof_pb_path()?;
1349        crate::test_support::wprof::assert_wprof_pb_shape(&path)
1350    }
1351}
1352
1353/// Build an empty `SnapshotBridge` whose capture callback always
1354/// returns `None`. Used by `VmResult::test_fixture` and the legacy
1355/// `VmResult` literal constructions in unit tests so they still
1356/// compile after the snapshot_bridge field landed. Production
1357/// `run_vm` constructs its own bridge whose callback is
1358/// intentionally unused — the freeze coordinator stores reports
1359/// directly via `bridge.store(name, report)`.
1360#[cfg(test)]
1361pub(crate) fn empty_snapshot_bridge_for_tests() -> crate::scenario::snapshot::SnapshotBridge {
1362    let cb: crate::scenario::snapshot::CaptureCallback = std::sync::Arc::new(|_| None);
1363    crate::scenario::snapshot::SnapshotBridge::new(cb)
1364}
1365
1366/// Per-vCPU KVM stats read after VM exit. Each map holds cumulative
1367/// counter values from the VM's lifetime.
1368#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1369pub struct KvmStatsTotals {
1370    /// Per-vCPU stat maps. Index is vCPU id.
1371    pub per_vcpu: Vec<HashMap<String, u64>>,
1372}
1373
1374/// KVM stat names surfaced in sidecar output for scheduler testing.
1375///
1376/// Covers VM exit rate, halt-polling behavior, preemption notifications,
1377/// signal-driven exits, and hypercall counts; all fields scheduler
1378/// authors typically correlate with scx decisions.
1379///
1380/// Per-arch availability: `halt_exits`, `preemption_reported`, and
1381/// `hypercalls` are published by KVM only on x86. On aarch64 the
1382/// kernel does not expose these stats via `KVM_GET_STATS_FD`; they
1383/// are absent from the per-vCPU map and read as `0` from
1384/// [`KvmStatsTotals::sum`] / [`KvmStatsTotals::avg`]. The remaining
1385/// names (`exits`, `halt_successful_poll`, `halt_attempted_poll`,
1386/// `halt_wait_ns`, `signal_exits`) are published on both arches.
1387#[allow(dead_code)]
1388pub const KVM_INTERESTING_STATS: &[&str] = &[
1389    "exits",
1390    "halt_exits",
1391    "halt_successful_poll",
1392    "halt_attempted_poll",
1393    "halt_wait_ns",
1394    "preemption_reported",
1395    "signal_exits",
1396    "hypercalls",
1397];
1398
1399impl KvmStatsTotals {
1400    /// Sum a stat across all vCPUs. Returns 0 BOTH when no vCPU published
1401    /// the stat and when every vCPU measured zero — use [`Self::try_sum`]
1402    /// to distinguish "unpublished" from "measured zero".
1403    pub fn sum(&self, name: &str) -> u64 {
1404        self.try_sum(name).unwrap_or(0)
1405    }
1406
1407    /// Average a stat across all vCPUs (returns 0 if no vCPUs). Same
1408    /// absent-vs-zero ambiguity as [`Self::sum`]; see [`Self::try_avg`].
1409    pub fn avg(&self, name: &str) -> u64 {
1410        self.try_avg(name).unwrap_or(0)
1411    }
1412
1413    /// Sum a stat across all vCPUs, or `None` when NO per-vCPU map
1414    /// published it. Distinguishes an unpublished stat (`None`) from a
1415    /// genuinely-measured zero (`Some(0)`) — the plain [`Self::sum`]
1416    /// collapses both to `0`, so a test reading a counter that the kernel
1417    /// never emitted on this arch cannot tell it apart from a real zero.
1418    pub fn try_sum(&self, name: &str) -> Option<u64> {
1419        let mut acc: Option<u64> = None;
1420        for m in &self.per_vcpu {
1421            if let Some(&v) = m.get(name) {
1422                acc = Some(acc.unwrap_or(0) + v);
1423            }
1424        }
1425        acc
1426    }
1427
1428    /// Average a stat across all vCPUs, or `None` when there are no vCPUs
1429    /// or NO per-vCPU map published the stat. The absent-aware counterpart
1430    /// of [`Self::avg`].
1431    pub fn try_avg(&self, name: &str) -> Option<u64> {
1432        let n = self.per_vcpu.len() as u64;
1433        if n == 0 {
1434            return None;
1435        }
1436        self.try_sum(name).map(|s| s / n)
1437    }
1438}
1439
1440/// State returned by [`super::KtstrVm::run_vm`] after the BSP exits.
1441/// Passed to [`super::KtstrVm::collect_results`] to produce
1442/// [`VmResult`].
1443pub(crate) struct VmRunState {
1444    pub(crate) exit_code: i32,
1445    pub(crate) timed_out: bool,
1446    pub(crate) ap_threads: Vec<VcpuThread>,
1447    pub(crate) monitor_handle: Option<JoinHandle<monitor::reader::MonitorLoopResult>>,
1448    pub(crate) bpf_write_handle: Option<JoinHandle<()>>,
1449    /// Freeze coordinator handle, always `None` in the
1450    /// production path: [`super::KtstrVm::run_vm`] joins the
1451    /// coordinator BEFORE the BSP `VcpuFd` falls out of scope so the
1452    /// coordinator's captured BSP `ImmediateExitHandle` cannot
1453    /// outlive the kvm_run mmap (UAF prevention). The optional shape
1454    /// is preserved so the field stays trivially constructible in
1455    /// any future test-only or alternative-orchestration path that
1456    /// might not perform the early join.
1457    pub(crate) freeze_coordinator: Option<JoinHandle<()>>,
1458    pub(crate) com1: Arc<PiMutex<console::Serial>>,
1459    pub(crate) com2: Arc<PiMutex<console::Serial>>,
1460    pub(crate) kill: Arc<AtomicBool>,
1461    /// Wake fd paired with `kill`. Setters that flip `kill`
1462    /// (`collect_results`, vCPU shutdown classifier, panic hook)
1463    /// also write to this EventFd so any consumer blocked in
1464    /// `epoll_wait` (notably the freeze coordinator and the
1465    /// monitor sampler) wakes within microseconds of the flip
1466    /// rather than waiting up to one full poll interval. The
1467    /// AtomicBool above remains the source of truth — the EventFd
1468    /// is purely a wake signal. EFD_NONBLOCK so a saturated
1469    /// counter never stalls the writer.
1470    pub(crate) kill_evt: Arc<vmm_sys_util::eventfd::EventFd>,
1471    /// Broadcast freeze flag for the failure-dump coordinator. When the
1472    /// coordinator receives a guest-side error-exit signal it sets this
1473    /// to true, kicks every vCPU, waits for all `parked` flags to flip
1474    /// true, and then reads guest BPF map state. Released to false to
1475    /// resume normal execution. Lives alongside `kill` so the same Arc
1476    /// pattern (broadcast + per-vCPU ACK) covers both shutdown and
1477    /// freeze rendezvous.
1478    pub(crate) freeze: Arc<AtomicBool>,
1479    /// Hardware-watchpoint arming state Arc, forwarded so
1480    /// [`super::KtstrVm::collect_results`] can invalidate the
1481    /// `kind_host_ptr` and `request_kva` slots after every vCPU
1482    /// thread joins but BEFORE `vm` drops.
1483    ///
1484    /// Without the invalidation, the slots' published values
1485    /// continue to address (a) a host pointer into `vm.guest_mem`'s
1486    /// mapping that becomes unmapped when `vm` drops and (b) a
1487    /// guest KVA whose translation goes through the same mapping.
1488    /// The freeze coordinator joins before `vm` drops in
1489    /// `run_vm`, and AP threads join inside `collect_results` —
1490    /// but defense-in-depth says we zero the slots once every
1491    /// reader is gone so any future restructuring (a stray Arc
1492    /// clone surviving past teardown, a follow-up that adds a
1493    /// new reader path) cannot trip a use-after-free.
1494    ///
1495    /// Declared before `vm` so the implicit drop order on
1496    /// `VmRunState` teardown drops `watchpoint` first: any Arc
1497    /// clone outliving the struct can no longer dereference its
1498    /// `kind_host_ptr` after `vm.guest_mem` has unmapped, even if
1499    /// a future caller forgets the explicit pre-drop
1500    /// invalidation in `collect_results`.
1501    pub(crate) watchpoint: Arc<WatchpointArm>,
1502    pub(crate) vm: kvm::KtstrKvm,
1503    /// Captured immediately after the BSP exits its run loop. Subtracted
1504    /// from `Instant::now()` in [`super::KtstrVm::collect_results`]
1505    /// right before the [`VmResult`] is returned to populate
1506    /// [`VmResult::cleanup_duration`]. Records the wall-clock cost of
1507    /// every host-side teardown step that runs after the guest has
1508    /// stopped advancing, in execution order: the watchdog-thread join
1509    /// in [`super::KtstrVm::run_vm`], then the AP-thread joins, the
1510    /// monitor-thread join, the BPF-map-writer join, the SHM-ring
1511    /// drain, the post-exit exit-code/crash-message extraction, and
1512    /// finally the BPF verifier-stat read inside
1513    /// [`super::KtstrVm::collect_results`].
1514    pub(crate) cleanup_start: Instant,
1515    /// Cloned counter handle from `KtstrVm::init_virtio_blk`
1516    /// when a disk was attached, captured before the device-arc is
1517    /// dropped so [`super::KtstrVm::collect_results`] can snapshot
1518    /// it into [`VmResult::virtio_blk_counters`]. The device worker
1519    /// bumps these atomics from `drain_bracket_impl` (production cfg:
1520    /// dedicated `ktstr-vblk` thread; cfg(test): inline on the test
1521    /// thread); by the time `collect_results` reads this field every
1522    /// vCPU thread has joined upstream, the worker can receive no
1523    /// further kicks, and the conversion site
1524    /// (`run.virtio_blk_counters.as_deref().map(|c| c.snapshot())`)
1525    /// loads the final cumulative state into a plain-u64 snapshot
1526    /// before storing on the public `VmResult`.
1527    pub(crate) virtio_blk_counters: Option<Arc<VirtioBlkCounters>>,
1528    /// Cloned per-NIC counter handles from the net device init
1529    /// (`init_virtio_net` on aarch64 / `init_virtio_net_pci` on x86_64, both
1530    /// arch-gated), one per attached NIC, captured before the device arcs are
1531    /// dropped so [`super::KtstrVm::collect_results`] can snapshot and aggregate
1532    /// them into [`VmResult::virtio_net_counters`] via
1533    /// [`VirtioNetCountersSnapshot::aggregate`]. Empty when no NIC is attached.
1534    pub(crate) virtio_net_counters: Vec<Arc<VirtioNetCounters>>,
1535    /// Snapshot bridge owning every report captured during the run.
1536    /// The freeze coordinator clones this bridge into its closure
1537    /// state; on every guest-side
1538    /// [`crate::vmm::wire::MSG_TYPE_SNAPSHOT_REQUEST`] frame the
1539    /// coordinator's TOKEN_TX handler decoded with kind
1540    /// [`crate::vmm::wire::SNAPSHOT_KIND_CAPTURE`], the dispatch runs
1541    /// `freeze_and_dispatch(FreezeMode::Capture { gate_on_exit_kind: false })` and stores the resulting
1542    /// `FailureDumpReport` here keyed by the snapshot name. After
1543    /// VM exit, [`super::KtstrVm::collect_results`] forwards the
1544    /// bridge onto [`VmResult::snapshot_bridge`] so the test code
1545    /// can drain captured snapshots and walk them via the
1546    /// [`crate::scenario::snapshot::Snapshot`] accessor surface.
1547    pub(crate) snapshot_bridge: crate::scenario::snapshot::SnapshotBridge,
1548    /// Cached aarch64 TCR_EL1 register, populated lazily by the BSP
1549    /// once the guest kernel programs the MMU. Always `None` on
1550    /// x86_64 (the register does not exist). Threads that construct
1551    /// a `GuestKernel` for page-table walks (monitor, BPF map writer,
1552    /// freeze coordinator, post-exit verifier-stats collector) read
1553    /// this atomic to feed the granule-agnostic walker (4 KB / 16 KB
1554    /// / 64 KB). A 0 reading on aarch64 means "kernel hasn't reached
1555    /// MMU bring-up yet"; the walker's T1SZ=0 gate rejects walks in
1556    /// that state and the affected lookup returns `None` cleanly.
1557    pub(crate) tcr_el1: Option<Arc<std::sync::atomic::AtomicU64>>,
1558    /// Cached BSP CR3 (x86_64) / TTBR1_EL1 (aarch64), populated lazily
1559    /// by the BSP loop after initial page-table setup. Used by
1560    /// post-exit `GuestKernel` constructions to walk the live page
1561    /// tables for `phys_base` resolution. `0` means the cache wasn't
1562    /// populated (early boot crash); the walk fails and `phys_base`
1563    /// falls back to `0`, which produces correct translations on
1564    /// non-KASLR boots.
1565    pub(crate) cr3: Arc<std::sync::atomic::AtomicU64>,
1566    /// Cached vmlinux bytes for collect_verifier_stats. Avoids
1567    /// re-reading from disk (14-28s on cold cache).
1568    pub(crate) vmlinux_data: Option<Arc<Vec<u8>>>,
1569    /// Pre-built prog accessor from the accessor-init worker.
1570    /// When present, `collect_verifier_stats` skips the ~4s
1571    /// ELF/BTF parse and uses this directly.
1572    pub(crate) prog_accessor: Option<crate::monitor::bpf_prog::GuestMemProgAccessorOwned>,
1573    /// Guest-reported phys_base (biased +1). Used by
1574    /// `collect_verifier_stats` fallback when the pre-built prog
1575    /// accessor is unavailable.
1576    pub(crate) kern_phys_base: u64,
1577    /// Runtime virt-KASLR offset (kernel-image slide), captured from
1578    /// the freeze coordinator's `kern_virt_kaslr` Arc snapshot at run
1579    /// end via `load(Acquire).saturating_sub(1)`. `0` means either
1580    /// (a) KASLR was off (test ran with `#[ktstr_test(kaslr = false)]`
1581    /// or `Scheduler::kargs(&["nokaslr"])`), or (b) the derivation
1582    /// chain (MSR_LSTAR readback at `vmm::x86_64::msr_kaslr` +
1583    /// KERN_ADDRS `_text` path at `crate::vmm::freeze_coord::dispatch`) never
1584    /// published a non-zero value (early-boot crash, kallsyms masked
1585    /// by kptr_restrict, FRED-enabled kernel). E2E test consumers
1586    /// distinguish (a) from (b) by asserting against the test entry's
1587    /// `kaslr` attribute. The companion [`Self::kern_phys_base`]
1588    /// carries the kernel-image physical-randomization slide; together
1589    /// they identify the KASLR-randomized kernel layout.
1590    pub kern_kaslr_offset: u64,
1591    /// Virtio-console device shared with vCPU threads. Carries the
1592    /// port-1 (`/dev/vport0p1`) bulk TLV stream from guest to host;
1593    /// `collect_results` calls `drain_bulk()` after the run to feed
1594    /// `parse_tlv_stream` and produce the `BulkDrainResult` that
1595    /// `VmResult.guest_messages` exposes to test verdicts.
1596    pub(crate) virtio_con: Arc<crate::vmm::PiMutex<crate::vmm::virtio_console::VirtioConsole>>,
1597    /// Bulk TLV entries the freeze coordinator parsed from
1598    /// `port1_tx_buf` mid-run. The coord's TOKEN_TX handler reads
1599    /// the device's accumulated bulk bytes, feeds them through
1600    /// [`crate::vmm::bulk::HostAssembler`], and stashes every parsed
1601    /// frame here so [`super::KtstrVm::collect_results`] can merge
1602    /// them into `VmResult::guest_messages` alongside the post-exit
1603    /// `drain_bulk` and the post-mortem SHM CRASH-ring drain.
1604    /// Without this stash every EXIT / TEST / PAYLOAD_METRICS /
1605    /// PROFRAW frame consumed by the coord
1606    /// would vanish — only the leftover bytes that arrived on
1607    /// `port1_tx_buf` after the coord exited would reach the
1608    /// verdict, and a typical run would surface no metrics.
1609    pub(crate) bulk_messages: Arc<std::sync::Mutex<Vec<crate::vmm::wire::ShmEntry>>>,
1610    /// Scheduler-stats client constructed at the top of `run_vm`,
1611    /// or `None` when the run has no scheduler attached
1612    /// (`scheduler_binary` is `None` on the builder). Forwarded
1613    /// to [`VmResult::stats_client`] so test code can issue
1614    /// `request_raw` / typed `stats` / `stats_meta` calls through
1615    /// the run's lifetime. The drainer thread tears down when the
1616    /// last clone of the client drops; `None` here means no
1617    /// drainer was spawned at all, so the run pays no
1618    /// stats-bridge cost.
1619    pub(crate) stats_client: Option<super::SchedStatsClient>,
1620    /// Periodic captures actually fired by the freeze coordinator
1621    /// during the run (success + timeout-placeholder count).
1622    /// Forwarded to [`VmResult::periodic_fired`] from the run-loop's
1623    /// `next_periodic_idx` final value.
1624    pub(crate) periodic_fired: u32,
1625    /// Configured periodic-snapshot target (mirrors
1626    /// `KtstrVm::num_snapshots`). Forwarded to
1627    /// [`VmResult::periodic_target`] so test code can compute
1628    /// coverage as `fired / target`.
1629    pub(crate) periodic_target: u32,
1630}
1631#[cfg(test)]
1632mod tests {
1633    use super::*;
1634
1635    /// scheduler_log() concatenates the bulk-port SchedLog frames and
1636    /// slices the `SCHED_OUTPUT_START`/`END`-bracketed content (mirroring
1637    /// collect_verifier_output). A CRC-bad frame is dropped; with no valid
1638    /// SchedLog frames and an empty `output`, the log is empty. CI-runnable
1639    /// (no VM) — pins the accessor the demo_verifier post_vm assertions
1640    /// depend on, so a boot-path capture regression is caught even when the
1641    /// host-gated e2e cells skip under resource contention.
1642    #[test]
1643    fn scheduler_log_extracts_bracketed_content_and_drops_crc_bad() {
1644        use crate::vmm::host_comms::BulkDrainResult;
1645        use crate::vmm::wire::{MSG_TYPE_SCHED_LOG, ShmEntry};
1646
1647        let framed = "===SCHED_OUTPUT_START===\n\
1648            libbpf: prog 'ktstr_dispatch': BPF program load failed: -EACCES\n\
1649            -- BEGIN PROG LOAD LOG --\n0: (b7) r0 = 0\n-- END PROG LOAD LOG --\n\
1650            ===SCHED_OUTPUT_END===\n";
1651        let mut result = VmResult::test_fixture();
1652        result.guest_messages = Some(BulkDrainResult {
1653            entries: vec![ShmEntry {
1654                msg_type: MSG_TYPE_SCHED_LOG,
1655                payload: framed.as_bytes().to_vec(),
1656                crc_ok: true,
1657            }],
1658        });
1659        let log = result.scheduler_log();
1660        assert!(
1661            log.contains("-- BEGIN PROG LOAD LOG --"),
1662            "libbpf verifier-reject marker survives extraction: {log}"
1663        );
1664        assert!(
1665            log.contains("BPF program load failed"),
1666            "scheduler stderr content present: {log}"
1667        );
1668        assert!(
1669            !log.contains("SCHED_OUTPUT_START"),
1670            "wire brackets stripped by parse_sched_output: {log}"
1671        );
1672
1673        // A CRC-bad SchedLog frame is dropped; with no valid frames the
1674        // merged stream is empty and `output` (empty here) is the fallback,
1675        // so scheduler_log() is empty.
1676        let mut bad = VmResult::test_fixture();
1677        bad.guest_messages = Some(BulkDrainResult {
1678            entries: vec![ShmEntry {
1679                msg_type: MSG_TYPE_SCHED_LOG,
1680                payload: framed.as_bytes().to_vec(),
1681                crc_ok: false,
1682            }],
1683        });
1684        assert_eq!(bad.scheduler_log(), "", "crc-bad frame dropped -> empty");
1685    }
1686
1687    /// A StepStart/StepEnd/terminal `StimulusEvent` for the
1688    /// `step_throughput_in` pairing tests.
1689    fn ev(
1690        elapsed_ms: u64,
1691        step_index: Option<u16>,
1692        iters: Option<u64>,
1693        is_step_end: bool,
1694        is_terminal: bool,
1695    ) -> crate::timeline::StimulusEvent {
1696        crate::timeline::StimulusEvent {
1697            elapsed_ms,
1698            label: String::new(),
1699            op_kind: None,
1700            detail: None,
1701            total_iterations: iters,
1702            step_index,
1703            is_terminal,
1704            is_step_end,
1705        }
1706    }
1707
1708    /// `step_throughput_in` pairs `StepStart[k]` -> `StepEnd[k]` of the SAME
1709    /// `Phase` for the step-local rate; falls back to the next step then the
1710    /// scenario-end terminal when a StepEnd is absent; a flat counter over a
1711    /// positive window is measured-zero `Some(0.0)` (not `None`); BASELINE /
1712    /// an absent step is `None`.
1713    #[test]
1714    fn step_throughput_in_pairs_step_local_and_handles_edges() {
1715        use crate::assert::Phase;
1716        let tl = vec![
1717            ev(0, Some(1), Some(0), false, false),       // StepStart[0]
1718            ev(1000, Some(1), Some(5000), true, false),  // StepEnd[0] -> 5000/s
1719            ev(1100, Some(2), Some(5000), false, false), // StepStart[1]
1720            ev(2100, Some(2), Some(5000), true, false),  // StepEnd[1] -> 0/s (flat)
1721            ev(2200, Some(3), Some(5000), false, false), // StepStart[2] (no StepEnd)
1722            crate::timeline::StimulusEvent::terminal(3200, 11000), // right boundary for step 2
1723        ];
1724        // Step 0: (5000-0)/1s = 5000/s.
1725        assert_eq!(
1726            VmResult::step_throughput_in(&tl, Phase::step(0)),
1727            Some(5000.0)
1728        );
1729        // Step 1: counter flat over a positive window -> measured zero
1730        // Some(0.0), NOT None.
1731        assert_eq!(VmResult::step_throughput_in(&tl, Phase::step(1)), Some(0.0));
1732        // Step 2: no StepEnd -> falls back to the terminal:
1733        // (11000-5000)/1s = 6000/s.
1734        assert_eq!(
1735            VmResult::step_throughput_in(&tl, Phase::step(2)),
1736            Some(6000.0)
1737        );
1738        // BASELINE and an absent step have no StepStart -> None.
1739        assert_eq!(VmResult::step_throughput_in(&tl, Phase::BASELINE), None);
1740        assert_eq!(VmResult::step_throughput_in(&tl, Phase::step(9)), None);
1741    }
1742
1743    #[test]
1744    fn kvm_stats_try_sum_distinguishes_absent_from_zero() {
1745        let totals = KvmStatsTotals {
1746            per_vcpu: vec![
1747                [("exits".to_string(), 0u64)].into_iter().collect(),
1748                [("exits".to_string(), 0u64)].into_iter().collect(),
1749            ],
1750        };
1751        // "exits" is published as 0 on every vCPU -> a measured zero.
1752        assert_eq!(totals.try_sum("exits"), Some(0));
1753        assert_eq!(totals.try_avg("exits"), Some(0));
1754        // "halt_exits" was never published -> absent, not zero.
1755        assert_eq!(totals.try_sum("halt_exits"), None);
1756        assert_eq!(totals.try_avg("halt_exits"), None);
1757        // sum/avg keep the 0-coercing behavior for both cases.
1758        assert_eq!(totals.sum("exits"), 0);
1759        assert_eq!(totals.sum("halt_exits"), 0);
1760        // No vCPUs at all -> try_avg None (no div-by-zero, no false 0).
1761        let empty = KvmStatsTotals { per_vcpu: vec![] };
1762        assert_eq!(empty.try_sum("exits"), None);
1763        assert_eq!(empty.try_avg("exits"), None);
1764    }
1765
1766    #[test]
1767    fn vm_result_fields_carry_values() {
1768        let r = VmResult {
1769            duration: Duration::from_secs(5),
1770            output: "hello world".into(),
1771            stderr: "boot log".into(),
1772            cleanup_duration: Some(Duration::from_millis(50)),
1773            ..VmResult::test_fixture()
1774        };
1775        assert!(r.success);
1776        assert_eq!(r.exit_code, 0);
1777        assert!(!r.timed_out);
1778        assert_eq!(r.duration, Duration::from_secs(5));
1779        assert_eq!(r.output, "hello world");
1780        assert_eq!(r.stderr, "boot log");
1781        assert!(r.monitor.is_none());
1782        assert!(r.guest_messages.is_none());
1783        assert!(r.stimulus_timeline().is_empty());
1784        assert_eq!(r.cleanup_duration, Some(Duration::from_millis(50)));
1785        assert!(r.virtio_blk_counters.is_none());
1786        // Second construction covers the opposite polarity of
1787        // every boolean/numeric field so no field is silently
1788        // dropped by a future refactor that only exercises the
1789        // success path.
1790        let r2 = VmResult {
1791            success: false,
1792            exit_code: 1,
1793            duration: Duration::from_millis(500),
1794            timed_out: true,
1795            virtio_blk_counters: Some(VirtioBlkCountersSnapshot::default()),
1796            periodic_fired: 3,
1797            periodic_real: 2,
1798            periodic_target: 7,
1799            ..VmResult::test_fixture()
1800        };
1801        assert!(!r2.success);
1802        assert_eq!(r2.exit_code, 1);
1803        assert!(r2.timed_out);
1804        assert_eq!(r2.duration, Duration::from_millis(500));
1805        assert!(r2.cleanup_duration.is_none());
1806        assert_eq!(r2.periodic_fired, 3);
1807        assert_eq!(r2.periodic_target, 7);
1808        // Opposite polarity: counters present. Reads must observe
1809        // the default-zero values for every field — a future field
1810        // added to VirtioBlkCountersSnapshot that doesn't initialise
1811        // to 0 would break the "fresh device reports zero activity"
1812        // contract that VmResult readers rely on. The snapshot was
1813        // taken from the device's atomic counters at collect_results
1814        // time, after every vCPU and worker thread joined; readers
1815        // see plain `u64` field reads with no atomic ordering needed.
1816        let counters = r2.virtio_blk_counters.as_ref().unwrap();
1817        assert_eq!(counters.reads_completed, 0);
1818        assert_eq!(counters.writes_completed, 0);
1819        assert_eq!(counters.flushes_completed, 0);
1820        assert_eq!(counters.bytes_read, 0);
1821        assert_eq!(counters.bytes_written, 0);
1822        assert_eq!(counters.throttled_count, 0);
1823        assert_eq!(counters.io_errors, 0);
1824        assert_eq!(counters.currently_throttled_gauge, 0);
1825        assert_eq!(counters.invalid_avail_idx_count, 0);
1826    }
1827
1828    #[test]
1829    fn vm_result_without_monitor_has_no_samples() {
1830        let r = VmResult {
1831            output: "test output".into(),
1832            ..VmResult::test_fixture()
1833        };
1834        assert!(r.monitor.is_none());
1835        // Output and exit_code must still be accessible.
1836        assert_eq!(r.output, "test output");
1837        assert_eq!(r.exit_code, 0);
1838    }
1839
1840    #[test]
1841    fn vm_result_with_monitor_carries_summary() {
1842        let summary = monitor::MonitorSummary {
1843            prog_stats_deltas: None,
1844            total_samples: 5,
1845            max_imbalance_ratio: 3.5,
1846            max_local_dsq_depth: 10,
1847            stuck_count: 1,
1848            event_deltas: None,
1849            schedstat_deltas: None,
1850            ..Default::default()
1851        };
1852        let report = monitor::MonitorReport {
1853            samples: vec![],
1854            summary: summary.clone(),
1855            ..Default::default()
1856        };
1857        let r = VmResult {
1858            success: false,
1859            exit_code: 1,
1860            duration: Duration::from_millis(500),
1861            timed_out: true,
1862            stderr: "kernel panic".into(),
1863            monitor: Some(report),
1864            ..VmResult::test_fixture()
1865        };
1866        let mon = r.monitor.as_ref().unwrap();
1867        assert_eq!(mon.summary.total_samples, 5);
1868        assert!((mon.summary.max_imbalance_ratio - 3.5).abs() < f64::EPSILON);
1869        assert_eq!(mon.summary.max_local_dsq_depth, 10);
1870        assert!(mon.summary.stuck_count > 0);
1871        assert!(r.timed_out);
1872        assert_eq!(r.exit_code, 1);
1873        assert_eq!(r.stderr, "kernel panic");
1874    }
1875
1876    /// Compile-time pin that `VmResult: Clone`. A future field
1877    /// added with a non-Clone type would break the derive at compile
1878    /// time and break this test's `let _: Self = self_clone(r)` call.
1879    /// Cheap insurance that nobody silently strips the Clone derive
1880    /// or adds a non-Clone field.
1881    #[test]
1882    fn vm_result_is_clone() {
1883        fn self_clone<T: Clone>(t: &T) -> T {
1884            t.clone()
1885        }
1886        let r = VmResult::test_fixture();
1887        let _: VmResult = self_clone(&r);
1888    }
1889
1890    /// Pin the documented aliasing semantic on the Arc-shared
1891    /// `snapshot_bridge` field: clones of `VmResult` share the
1892    /// underlying snapshot store. A future refactor that turned
1893    /// `SnapshotBridge` into a deep-copy struct would break this
1894    /// test — at which point the doc paragraph at the head of
1895    /// `VmResult` must be updated to drop the Arc-shared-handle
1896    /// category. Loud failure on contract drift, not a silent
1897    /// behavior change.
1898    #[test]
1899    fn vm_result_clone_snapshot_bridge_aliases_via_arc() {
1900        let r = VmResult::test_fixture();
1901        let c = r.clone();
1902        // Pre-condition: both bridges start empty.
1903        assert_eq!(r.snapshot_bridge.len(), 0);
1904        assert_eq!(c.snapshot_bridge.len(), 0);
1905        // Store a synthetic report through ONE clone's bridge.
1906        r.snapshot_bridge.store(
1907            "regression_pin",
1908            crate::monitor::dump::FailureDumpReport::default(),
1909        );
1910        // The OTHER clone observes the store — proves the Arc<Mutex<…>>
1911        // is shared, not deep-copied. If this assertion ever fires,
1912        // SnapshotBridge's Clone has changed shape and VmResult's
1913        // doc paragraph must be revisited.
1914        assert_eq!(
1915            r.snapshot_bridge.len(),
1916            c.snapshot_bridge.len(),
1917            "snapshot_bridge clones must observe the same store \
1918             per the VmResult Clone contract (Arc-shared handle)"
1919        );
1920        assert_eq!(c.snapshot_bridge.len(), 1);
1921    }
1922
1923    /// Build a `VmResult` whose snapshot bridge holds `n` periodic
1924    /// captures stamped into Step[0] (`step_index = 1`). No stimulus
1925    /// frames are attached, so `stimulus_timeline()` is empty and the
1926    /// bucketer falls back to each capture's stamped `step_index`.
1927    fn vm_result_with_periodic_captures(n: usize) -> VmResult {
1928        let r = VmResult {
1929            periodic_fired: n as u32,
1930            periodic_target: n as u32,
1931            ..VmResult::test_fixture()
1932        };
1933        for i in 0..n {
1934            r.snapshot_bridge.store_with_stats_and_step(
1935                &format!("periodic_{i}"),
1936                crate::monitor::dump::FailureDumpReport::default(),
1937                None,
1938                Some(i as u64 * 100),
1939                None,
1940                1,
1941            );
1942        }
1943        r
1944    }
1945
1946    /// Regression for the drain-once starvation bug: the snapshot
1947    /// bridge is drained EXACTLY once and the resulting series is
1948    /// shared, so a `post_vm`-style consumer reading the series does
1949    /// not starve a later framework-style consumer. Before
1950    /// [`VmResult::captures_series`], `periodic_series()` drained the
1951    /// bridge directly and a second reader saw an empty bridge — the
1952    /// silent-data-drop this task de-conflicts.
1953    #[test]
1954    fn captures_series_shared_across_consumers() {
1955        let r = vm_result_with_periodic_captures(3);
1956        // First consumer = post_vm style (drains the bridge into the cache).
1957        let post_vm_series = r.periodic_series();
1958        assert_eq!(
1959            post_vm_series.len(),
1960            3,
1961            "post_vm consumer must see all captures"
1962        );
1963        // Second consumer = framework style (reads the cache, no re-drain).
1964        let framework_series = r.captures_series();
1965        assert_eq!(
1966            framework_series.len(),
1967            3,
1968            "framework consumer must NOT see an empty bridge — the single \
1969             cached drain is shared, not re-drained (pre-cache this was 0)"
1970        );
1971        // The raw bridge was consumed exactly once: a direct drain now
1972        // yields nothing because captures_series() took ownership of the
1973        // captures into the cache on first read.
1974        assert_eq!(
1975            r.snapshot_bridge.drain_ordered_with_stats().len(),
1976            0,
1977            "captures_series() performs the single destructive drain"
1978        );
1979    }
1980
1981    /// `phase_buckets()` folds the cached captures into per-phase
1982    /// buckets without the caller draining the bridge — the
1983    /// phase-buckets accessor. Idempotent: a second call returns the same vec from
1984    /// the shared cache.
1985    #[test]
1986    fn phase_buckets_from_cached_captures() {
1987        let r = vm_result_with_periodic_captures(2);
1988        let buckets = r.phase_buckets();
1989        assert!(
1990            !buckets.is_empty(),
1991            "phase_buckets must yield buckets from the cached captures"
1992        );
1993        assert!(
1994            buckets.iter().any(|b| b.step_index >= 1),
1995            "captures stamped step_index=1 must produce a Step bucket, got {:?}",
1996            buckets.iter().map(|b| b.step_index).collect::<Vec<_>>(),
1997        );
1998        assert_eq!(
1999            r.phase_buckets(),
2000            buckets,
2001            "phase_buckets() must be idempotent (shared cache)"
2002        );
2003    }
2004
2005    /// Clone semantics, category 3 (the `periodic_series_cache` field):
2006    /// a clone taken AFTER the cache is populated carries an
2007    /// INDEPENDENT copy, so `phase_buckets()` on both the original and
2008    /// the clone returns identical non-empty buckets without
2009    /// re-touching the (already-drained) shared bridge. Pins the
2010    /// documented safe path.
2011    #[test]
2012    fn vm_result_clone_after_cache_populated_carries_buckets() {
2013        let r = vm_result_with_periodic_captures(2);
2014        // Populate the cache BEFORE cloning (the documented safe path).
2015        let original = r.phase_buckets();
2016        assert!(!original.is_empty());
2017        let c = r.clone();
2018        let cloned = c.phase_buckets();
2019        assert!(!cloned.is_empty());
2020        assert_eq!(
2021            cloned, original,
2022            "a clone taken after cache population must carry the same \
2023             buckets (category-3 independent-once-populated semantics)"
2024        );
2025    }
2026
2027    /// `phase_metric` keys by `Phase` (1-indexed wire `step_index`) and
2028    /// delegates to the matching bucket's `get()`: it returns each
2029    /// present metric's value, `None` for an unknown metric on an
2030    /// existing phase, and `None` for a phase with no bucket. (The
2031    /// Some-value read itself is `PhaseBucket::get`, pinned by its own
2032    /// tests; this pins the phase-keying and the None contract.)
2033    #[test]
2034    fn phase_metric_keys_by_phase_and_delegates_to_bucket_get() {
2035        let r = vm_result_with_periodic_captures(2);
2036        // The captures are stamped step_index=1 -> Phase::step(0).
2037        let p0 = crate::assert::Phase::step(0);
2038        let buckets = r.phase_buckets();
2039        let step0 = buckets
2040            .iter()
2041            .find(|b| b.step_index == p0.as_u16())
2042            .expect("a Step[0] bucket from the stamped captures (keys by step_index)");
2043        // Some-path delegation: for every metric the matching bucket
2044        // carries, phase_metric returns that exact value. The
2045        // default-report fixture may fold no metrics, so this loop can be
2046        // empty — the keying + None assertions below pin the rest.
2047        for (name, val) in &step0.metrics {
2048            assert_eq!(
2049                r.phase_metric(p0, name),
2050                Some(*val),
2051                "phase_metric must return the matching bucket's value for present metric '{name}'",
2052            );
2053        }
2054        // A name absent from the bucket -> None (the bucket is still found).
2055        assert_eq!(
2056            r.phase_metric(p0, "definitely_not_a_registry_metric"),
2057            None,
2058            "an unknown metric name yields None even when the phase has a bucket",
2059        );
2060        // A phase with no bucket -> None (not a panic, not a wrong bucket).
2061        assert_eq!(
2062            r.phase_metric(crate::assert::Phase::step(99), "iteration_rate"),
2063            None,
2064            "a phase with no bucket yields None",
2065        );
2066    }
2067
2068    /// Build a guest `AssertResult` carrying ONE per-phase per-cgroup
2069    /// carrier at `step_index` (the merge-neutral `(u64::MAX, 0)` window
2070    /// `fold_guest_per_cgroup_into_host_buckets` requires) and wrap it in
2071    /// the `MSG_TYPE_TEST_RESULT` TLV a real run leaves on
2072    /// `guest_messages`, so `VmResult::guest_assert_result` decodes it.
2073    fn guest_drain_with_per_cgroup(
2074        step_index: u16,
2075        carriers: &[(&str, u64, u64)],
2076    ) -> crate::vmm::host_comms::BulkDrainResult {
2077        let mut per_cgroup = std::collections::BTreeMap::new();
2078        for &(name, migrations, iters) in carriers {
2079            per_cgroup.insert(
2080                name.to_string(),
2081                crate::assert::PhaseCgroupStats {
2082                    total_migrations: migrations,
2083                    total_iterations: iters,
2084                    ..Default::default()
2085                },
2086            );
2087        }
2088        let mut guest = crate::test_support::test_helpers::build_assert_result(true, vec![]);
2089        guest.stats.phases = vec![crate::assert::PhaseBucket {
2090            step_index,
2091            label: crate::assert::Phase::from(step_index).to_string(),
2092            // Merge-neutral window: fold_guest_per_cgroup_into_host_buckets
2093            // debug_asserts guest carriers carry exactly (u64::MAX, 0).
2094            start_ms: u64::MAX,
2095            end_ms: 0,
2096            sample_count: 0,
2097            metrics: std::collections::BTreeMap::new(),
2098            per_cgroup,
2099        }];
2100        crate::vmm::host_comms::BulkDrainResult {
2101            entries: vec![crate::test_support::test_helpers::assert_result_tlv_entry(
2102                &guest,
2103            )],
2104        }
2105    }
2106
2107    /// `phase_buckets()` folds the guest per-cgroup carriers (parsed from
2108    /// `guest_messages`) into the host buckets keyed by `step_index`, so the
2109    /// returned buckets carry `per_cgroup`; `phase_cgroup` reads one cgroup
2110    /// out of the folded bucket. Pins the guest-carrier fold: the host
2111    /// captures stamp step_index=1 == `Phase::step(0)`, the carrier matches
2112    /// it, and the matched arm unions the carrier's `per_cgroup` into that
2113    /// bucket.
2114    #[test]
2115    fn phase_buckets_folds_guest_per_cgroup_carriers() {
2116        let r = VmResult {
2117            guest_messages: Some(guest_drain_with_per_cgroup(1, &[("cellA", 7, 11)])),
2118            ..vm_result_with_periodic_captures(2)
2119        };
2120        let buckets = r.phase_buckets();
2121        let step0 = buckets
2122            .iter()
2123            .find(|b| b.step_index == crate::assert::Phase::step(0).as_u16())
2124            .expect("host bucket at step_index=1 from the stamped captures");
2125        let cg = step0
2126            .per_cgroup
2127            .get("cellA")
2128            .expect("matched-arm fold must carry the guest carrier's per_cgroup");
2129        assert_eq!(cg.total_migrations, 7);
2130        assert_eq!(cg.total_iterations, 11);
2131        // phase_cgroup is the per-(phase, cgroup) accessor over the same fold.
2132        let via_accessor = r
2133            .phase_cgroup(crate::assert::Phase::step(0), "cellA")
2134            .expect("phase_cgroup must reach the folded carrier");
2135        assert_eq!(via_accessor.total_migrations, 7);
2136        // A cgroup the phase never had -> None (not measured).
2137        assert!(
2138            r.phase_cgroup(crate::assert::Phase::step(0), "nope")
2139                .is_none()
2140        );
2141    }
2142
2143    /// `phase_metric("total_migrations")` resolves the per-phase
2144    /// cross-cgroup SUM via the `cgroup_counter_total` fallback — the key
2145    /// lives ONLY in `per_cgroup` (its `read_sample` is `None`, so
2146    /// `build_phase_buckets` never folds it into `bucket.metrics`, so
2147    /// `get()` misses). Two cgroups in the phase sum: 7 + 3 = 10. Without
2148    /// the `cgroup_counter_total` fallback, `phase_metric` returns a silent
2149    /// `None` for these keys.
2150    #[test]
2151    fn phase_metric_resolves_total_migrations_from_per_cgroup() {
2152        let r = VmResult {
2153            guest_messages: Some(guest_drain_with_per_cgroup(
2154                1,
2155                &[("cellA", 7, 5), ("cellB", 3, 6)],
2156            )),
2157            ..vm_result_with_periodic_captures(2)
2158        };
2159        assert_eq!(
2160            r.phase_metric(crate::assert::Phase::step(0), "total_migrations"),
2161            Some(10.0),
2162            "cross-cgroup sum across per_cgroup carriers (7 + 3)",
2163        );
2164        // The total_iterations arm of cgroup_counter_total resolves too (5 + 6).
2165        assert_eq!(
2166            r.phase_metric(crate::assert::Phase::step(0), "total_iterations"),
2167            Some(11.0),
2168            "cross-cgroup sum of the total_iterations per_cgroup counter (5 + 6)",
2169        );
2170        // A carrier-bearing phase that counted zero is a measured Some(0.0),
2171        // distinct from a phase with no carrier (None).
2172        let r0 = VmResult {
2173            guest_messages: Some(guest_drain_with_per_cgroup(1, &[("cellA", 0, 0)])),
2174            ..vm_result_with_periodic_captures(2)
2175        };
2176        assert_eq!(
2177            r0.phase_metric(crate::assert::Phase::step(0), "total_migrations"),
2178            Some(0.0),
2179            "carriers present but counted zero is a measured Some(0.0)",
2180        );
2181        // No carriers at all (guest_messages with no per_cgroup) -> None.
2182        let r_none = vm_result_with_periodic_captures(2);
2183        assert_eq!(
2184            r_none.phase_metric(crate::assert::Phase::step(0), "total_migrations"),
2185            None,
2186            "no per_cgroup carrier in the phase -> None (not measured)",
2187        );
2188    }
2189
2190    /// A guest carrier whose `step_index` has NO matching host bucket takes
2191    /// the fold's ORPHAN arm: it is appended with its merge-neutral
2192    /// `(u64::MAX, 0)` window normalized to `(0, 0)`, and stays reachable
2193    /// through the public accessors. The host captures stamp step_index=1
2194    /// only, so a step_index=2 carrier is an orphan. Pins that
2195    /// `phase_cgroup` reaches the orphan and the window does not underflow
2196    /// duration consumers (`end_ms - start_ms == 0`, not `0 - u64::MAX`).
2197    #[test]
2198    fn phase_buckets_orphan_carrier_reachable_via_phase_cgroup() {
2199        let r = VmResult {
2200            // step_index 2 == Phase::step(1); no host capture stamps step 2.
2201            guest_messages: Some(guest_drain_with_per_cgroup(2, &[("orphanCell", 4, 0)])),
2202            ..vm_result_with_periodic_captures(2)
2203        };
2204        let buckets = r.phase_buckets();
2205        let orphan = buckets
2206            .iter()
2207            .find(|b| b.step_index == crate::assert::Phase::step(1).as_u16())
2208            .expect("orphan carrier must be appended as its own bucket");
2209        assert_eq!(
2210            (orphan.start_ms, orphan.end_ms),
2211            (0, 0),
2212            "orphan arm normalizes the (u64::MAX, 0) sentinel window to (0, 0)",
2213        );
2214        assert_eq!(
2215            r.phase_cgroup(crate::assert::Phase::step(1), "orphanCell")
2216                .map(|c| c.total_migrations),
2217            Some(4),
2218            "phase_cgroup must reach an orphan carrier",
2219        );
2220    }
2221
2222    /// With no guest verdict (guest_messages None — host-only run / early
2223    /// crash), `guest_assert_result()` is Err and `phase_buckets()` returns
2224    /// the host-rebuilt buckets ALONE: non-empty (captures landed) with every
2225    /// `per_cgroup` empty. Pins the Err arm (the prior behavior) carries the
2226    /// host buckets, no panic.
2227    #[test]
2228    fn phase_buckets_without_guest_verdict_is_host_only() {
2229        let r = vm_result_with_periodic_captures(2);
2230        assert!(
2231            r.guest_assert_result().is_err(),
2232            "test_fixture has no guest verdict"
2233        );
2234        let buckets = r.phase_buckets();
2235        assert!(
2236            !buckets.is_empty(),
2237            "host captures must still yield buckets"
2238        );
2239        assert!(
2240            buckets.iter().all(|b| b.per_cgroup.is_empty()),
2241            "no guest carriers -> every per_cgroup stays empty",
2242        );
2243    }
2244
2245    /// A guest per-cgroup carrier carrying NON-EMPTY sample vectors
2246    /// (run_delays_ns / off_cpu_pcts / wake_latencies_ns) survives the
2247    /// guest->host postcard round-trip (assert_result_tlv_entry encode ->
2248    /// guest_assert_result decode) and the fold verbatim, and the
2249    /// PhaseCgroupStats summary methods reduce them post-decode. The
2250    /// guest_drain_with_per_cgroup helper carries only counters (empty
2251    /// vecs), so this pins the sample-vec wire fidelity that helper does not.
2252    #[test]
2253    fn guest_carrier_sample_vecs_survive_postcard_and_fold() {
2254        let mut pc = std::collections::BTreeMap::new();
2255        pc.insert(
2256            "cellA".to_string(),
2257            crate::assert::PhaseCgroupStats {
2258                total_migrations: 2,
2259                run_delays_ns: vec![10, 20, 30],
2260                off_cpu_pcts: vec![1.5, 2.5],
2261                wake_latencies_ns: vec![100, 200],
2262                wake_sample_total: 2,
2263                ..Default::default()
2264            },
2265        );
2266        let mut guest = crate::test_support::test_helpers::build_assert_result(true, vec![]);
2267        guest.stats.phases = vec![crate::assert::PhaseBucket {
2268            step_index: 1,
2269            label: "Step[0]".to_string(),
2270            start_ms: u64::MAX,
2271            end_ms: 0,
2272            sample_count: 0,
2273            metrics: std::collections::BTreeMap::new(),
2274            per_cgroup: pc,
2275        }];
2276        let r = VmResult {
2277            guest_messages: Some(crate::vmm::host_comms::BulkDrainResult {
2278                entries: vec![crate::test_support::test_helpers::assert_result_tlv_entry(
2279                    &guest,
2280                )],
2281            }),
2282            ..vm_result_with_periodic_captures(2)
2283        };
2284        let cg = r
2285            .phase_cgroup(crate::assert::Phase::step(0), "cellA")
2286            .expect("carrier survives postcard + fold");
2287        // Sample vectors round-trip byte-for-byte through the wire.
2288        assert_eq!(cg.run_delays_ns, vec![10, 20, 30]);
2289        assert_eq!(cg.off_cpu_pcts, vec![1.5, 2.5]);
2290        assert_eq!(cg.wake_latencies_ns, vec![100, 200]);
2291        // And the summary methods reduce the decoded samples (worst = max(ns)/1000).
2292        let (_, worst_us) = cg
2293            .run_delay_summary()
2294            .expect("non-empty run_delays -> Some");
2295        assert_eq!(worst_us, 30.0 / 1000.0);
2296    }
2297
2298    #[cfg(feature = "wprof")]
2299    #[test]
2300    fn vm_result_wprof_pb_path_bails_when_entry_name_none() {
2301        let r = VmResult::test_fixture();
2302        assert!(r.entry_name.is_none());
2303        let err = r.wprof_pb_path().expect_err("None entry_name must Err");
2304        let msg = format!("{err:#}");
2305        assert!(
2306            msg.contains("entry_name"),
2307            "diagnostic must name the missing field: {msg}",
2308        );
2309        assert!(
2310            msg.contains("run_ktstr_test_inner_impl"),
2311            "diagnostic must name the stamping site so the operator \
2312             can trace the missing-stamp path: {msg}",
2313        );
2314    }
2315
2316    #[cfg(feature = "wprof")]
2317    #[test]
2318    fn vm_result_wprof_pb_path_returns_writer_mirror_path() {
2319        let r = VmResult {
2320            entry_name: Some("vm_result_wprof_pb_path_returns_writer_mirror_path_fixture"),
2321            variant_hash: 0xab,
2322            ..VmResult::test_fixture()
2323        };
2324        let path = r.wprof_pb_path().expect("Some entry_name must Ok");
2325        // The path's file_name must exactly match
2326        // `<entry_name>-<variant_hash:016x>.wprof.pb` — the writer uses
2327        // the same variant-keyed pattern (the wprof writer reads this
2328        // very method). A divergence would mean the method derives a
2329        // different path than the writer wrote to, surfacing as ENOENT in
2330        // the post_vm callback.
2331        let file_name = path.file_name().and_then(|n| n.to_str()).unwrap();
2332        assert_eq!(
2333            file_name,
2334            "vm_result_wprof_pb_path_returns_writer_mirror_path_fixture-00000000000000ab.wprof.pb",
2335        );
2336    }
2337
2338    #[cfg(feature = "wprof")]
2339    #[test]
2340    fn vm_result_repro_wprof_pb_path_bails_when_entry_name_none() {
2341        let r = VmResult::test_fixture();
2342        let err = r
2343            .repro_wprof_pb_path()
2344            .expect_err("None entry_name must Err");
2345        let msg = format!("{err:#}");
2346        assert!(msg.contains("entry_name"));
2347        assert!(msg.contains("run_ktstr_test_inner_impl"));
2348    }
2349
2350    #[cfg(feature = "wprof")]
2351    #[test]
2352    fn vm_result_repro_wprof_pb_path_returns_writer_mirror_path() {
2353        let r = VmResult {
2354            entry_name: Some("vm_result_repro_wprof_pb_path_fixture"),
2355            variant_hash: 0xab,
2356            ..VmResult::test_fixture()
2357        };
2358        let path = r.repro_wprof_pb_path().expect("Some entry_name must Ok");
2359        let file_name = path.file_name().and_then(|n| n.to_str()).unwrap();
2360        assert_eq!(
2361            file_name,
2362            "vm_result_repro_wprof_pb_path_fixture-00000000000000ab.repro.wprof.pb"
2363        );
2364    }
2365
2366    #[cfg(feature = "wprof")]
2367    #[test]
2368    fn vm_result_assert_wprof_pb_landed_skips_when_success_false() {
2369        let r = VmResult {
2370            success: false,
2371            ..VmResult::test_fixture()
2372        };
2373        assert!(r.entry_name.is_none());
2374        let result = r.assert_wprof_pb_landed();
2375        assert!(
2376            result.is_ok(),
2377            "assert_wprof_pb_landed must Ok-skip on !success EVEN when \
2378             entry_name is None — the entry_name pre-check is downstream of \
2379             the success short-circuit. Got: {result:?}",
2380        );
2381    }
2382}