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}