ktstr/test_support/wprof.rs
1//! Public wire-format constants + assertion helpers for the wprof
2//! Perfetto-trace artifacts produced by `#[ktstr_test(wprof, ...)]`
3//! tests.
4//!
5//! The wprof trace is generated inside the guest VM and shipped to
6//! the host via the `MsgType::WprofTrace` virtq message; a
7//! post-drain pre-pass in
8//! `test_support::eval::run_ktstr_test_inner_impl` reassembles the
9//! trace via `reassemble_wprof_trace` and writes
10//! a `.wprof.pb` file per test run under
11//! `{sidecar_dir()}/{test_name}-{variant_hash:016x}.wprof.pb` — the
12//! variant hash keys the artifact to the resolved variant so sibling
13//! gauntlet presets of the same test do not clobber each other (same
14//! convention as the `.repro.failure-dump.json` sidecar).
15//!
16//! Assertions on the `.pb` MUST run host-side via the
17//! `#[ktstr_test(post_vm = ...)]` callback, NOT inside the guest
18//! test body — the test body runs INSIDE the guest VM and the
19//! guest mount table does NOT include a virtio-fs mount of the
20//! host sidecar directory. A guest-side `std::fs::read(...)` on
21//! the host sidecar path resolves to a host path the guest cannot
22//! open and returns ENOENT regardless of whether the host-side
23//! write succeeded.
24//!
25//! ## Drift-safe test naming
26//!
27//! Test authors writing `post_vm` callbacks should derive
28//! `.wprof.pb` paths through the
29//! [`VmResult::wprof_pb_path`](crate::vmm::VmResult::wprof_pb_path)
30//! method on the `&VmResult` arg rather than recomputing the path
31//! with a hardcoded fn-name literal. The method derives the path
32//! from the entry name the macro stamped at compile time — a future
33//! rename of the test fn surfaces the drift as a `None` bail rather
34//! than a runtime ENOENT against a stale literal.
35//!
36//! The high-level
37//! [`VmResult::assert_wprof_pb_landed`](crate::vmm::VmResult::assert_wprof_pb_landed)
38//! sugar collapses the recurring `post_vm`-callback boilerplate into
39//! a single method call. Use it as the default; reach for
40//! [`assert_wprof_pb_shape`] directly only when the callback owns
41//! path resolution (e.g. checking a specific
42//! `.repro.wprof.pb` artifact via
43//! [`VmResult::repro_wprof_pb_path`](crate::vmm::VmResult::repro_wprof_pb_path)).
44
45use anyhow::{Context, anyhow, ensure};
46use std::path::Path;
47
48/// Minimum wprof `.pb` file size in bytes. wprof's `init_pb_trace`
49/// emits a ~4 KB interned-string table (CAT + NAME + ANNK + ANNV
50/// ranges, ~216 entries × ~20 bytes wire cost) on every capture
51/// regardless of trace activity. A smaller file means wprof either
52/// aborted before `init_pb_trace` OR the .pb write/transport
53/// truncated.
54pub const WPROF_PB_MIN_BYTES: usize = 4096;
55
56/// Perfetto wire-format leading byte: `(1 << 3) | 2 == 0x0a` for
57/// `message Trace { repeated TracePacket packets = 1; }` (field=1,
58/// wire_type=2 length-delimited). Stable across Perfetto's
59/// published schema history.
60pub const PERFETTO_TRACE_PACKETS_TAG: u8 = 0x0a;
61
62/// Reassemble a wprof `.pb` from a bulk-port drain: concatenate every
63/// [`WprofTraceChunk`](crate::vmm::wire::MsgType::WprofTraceChunk) payload in
64/// arrival order, then append the terminal
65/// [`WprofTrace`](crate::vmm::wire::MsgType::WprofTrace) payload. The guest
66/// (`guest_comms::send_wprof_trace`) ships a trace that fits in one frame as
67/// a lone terminal `WprofTrace` (no chunks), so this reassembles to exactly
68/// that payload for the common case.
69///
70/// Returns `None` — the caller then writes no `.pb` — when:
71/// - no terminal `WprofTrace` frame is present (an incomplete or absent
72/// trace), or the reassembled trace is empty, matching the pre-chunking
73/// skip-on-empty/absent behavior; or
74/// - any wprof frame (chunk or terminal) failed its transport CRC. A torn
75/// frame means the trace is corrupt; concatenating the surviving frames
76/// would produce a hole-y `.pb` that still passes the leading-tag/size
77/// shape check but decodes to garbage. Bailing to `None` fails loudly (the
78/// post_vm `.pb`-landed assert reports a missing file) rather than shipping
79/// a plausible-but-corrupt artifact. Non-wprof crc-bad frames are ignored
80/// — only the trace's own frames gate it.
81pub(crate) fn reassemble_wprof_trace(entries: &[crate::vmm::wire::ShmEntry]) -> Option<Vec<u8>> {
82 use crate::vmm::wire::MsgType;
83 let mut out: Vec<u8> = Vec::new();
84 let mut saw_terminal = false;
85 for e in entries {
86 match MsgType::from_wire(e.msg_type) {
87 Some(MsgType::WprofTraceChunk) => {
88 if !e.crc_ok {
89 return None;
90 }
91 out.extend_from_slice(&e.payload);
92 }
93 Some(MsgType::WprofTrace) => {
94 if !e.crc_ok {
95 return None;
96 }
97 out.extend_from_slice(&e.payload);
98 saw_terminal = true;
99 break;
100 }
101 _ => {}
102 }
103 }
104 (saw_terminal && !out.is_empty()).then_some(out)
105}
106
107/// Verify the wprof `.pb` at `path` exists, is at least
108/// [`WPROF_PB_MIN_BYTES`] bytes, and leads with
109/// [`PERFETTO_TRACE_PACKETS_TAG`].
110///
111/// Returns `Err(_)` with a diagnostic naming the specific
112/// regression hop (missing file, truncated, wrong format) so a
113/// debugging operator can trace the failure back to the transport
114/// site that broke. The error message references the host-side
115/// write site at `test_support::eval` for missing-file diagnoses.
116///
117/// Intended use: a `#[ktstr_test(post_vm = my_check)]` callback
118/// resolves the per-test `.wprof.pb` path via
119/// [`VmResult::wprof_pb_path`](crate::vmm::VmResult::wprof_pb_path)
120/// and forwards the `Result` from this helper. The
121/// [`VmResult::assert_wprof_pb_landed`](crate::vmm::VmResult::assert_wprof_pb_landed)
122/// method packages the common path-derive + shape-check into a
123/// single call. Do NOT call from inside the guest test body —
124/// the guest cannot read the host sidecar directory (see the
125/// module-level doc).
126pub fn assert_wprof_pb_shape(path: &Path) -> anyhow::Result<()> {
127 let bytes = std::fs::read(path).with_context(|| {
128 format!(
129 "wprof .pb missing at {}. Chain: #[ktstr_test(wprof)] → \
130 KtstrTestEntry::wprof → primary VM builder.wprof(Some(config)) \
131 at src/test_support/eval/mod.rs → KTSTR_WPROF_ARGS cmdline → \
132 guest spawn_wprof_if_configured → send_wprof_trace → host \
133 MsgType::WprofTrace arm → \
134 sidecar_dir.join(\"<name>-<variant_hash:016x>.wprof.pb\")",
135 path.display(),
136 )
137 })?;
138 ensure!(
139 bytes.len() >= WPROF_PB_MIN_BYTES,
140 "wprof .pb at {} is only {} bytes — expected >= {WPROF_PB_MIN_BYTES}. \
141 wprof's init_pb_trace emits a ~4 KB interned-string table on every \
142 capture; a smaller file means wprof either aborted before \
143 init_pb_trace or the .pb write/transport truncated.",
144 path.display(),
145 bytes.len(),
146 );
147 // Size check above guarantees bytes is non-empty; bytes[0] is
148 // a direct index rather than `bytes.first()` to make the
149 // size-check → indexability dependency explicit.
150 let first = bytes[0];
151 if first != PERFETTO_TRACE_PACKETS_TAG {
152 return Err(anyhow!(
153 "wprof .pb at {} first byte {first:#04x} — expected \
154 {PERFETTO_TRACE_PACKETS_TAG:#04x} (field=1, wire_type=2, the \
155 Perfetto `Trace.packets` repeated TracePacket tag). File may \
156 be truncated, in a different format, or corrupted.",
157 path.display(),
158 ));
159 }
160 Ok(())
161}
162
163#[cfg(all(test, feature = "wprof"))]
164mod tests {
165 use super::reassemble_wprof_trace;
166 use crate::vmm::guest_comms::wprof_trace_frames;
167 use crate::vmm::wire::{MsgType, ShmEntry};
168
169 /// Build the `ShmEntry` drain the host parses from the frames the guest
170 /// planner emits for `buf` at `cap` (all crc-good), then reassemble it —
171 /// the full guest-split → host-concat roundtrip against the exact
172 /// production splitting code.
173 fn roundtrip(buf: &[u8], cap: usize) -> Option<Vec<u8>> {
174 let entries: Vec<ShmEntry> = wprof_trace_frames(buf, cap)
175 .into_iter()
176 .map(|(msg_type, chunk)| ShmEntry {
177 msg_type,
178 payload: chunk.to_vec(),
179 crc_ok: true,
180 })
181 .collect();
182 reassemble_wprof_trace(&entries)
183 }
184
185 #[test]
186 fn frames_tag_all_but_last_as_chunk() {
187 let buf: Vec<u8> = (0..10u8).collect();
188 // 10 bytes / cap 4 => [4, 4, 2] => chunk, chunk, terminal.
189 let frames = wprof_trace_frames(&buf, 4);
190 assert_eq!(frames.len(), 3);
191 assert_eq!(frames[0].0, MsgType::WprofTraceChunk.wire_value());
192 assert_eq!(frames[1].0, MsgType::WprofTraceChunk.wire_value());
193 assert_eq!(frames[2].0, MsgType::WprofTrace.wire_value());
194 assert_eq!(frames[0].1, &[0, 1, 2, 3]);
195 assert_eq!(frames[1].1, &[4, 5, 6, 7]);
196 assert_eq!(frames[2].1, &[8, 9]);
197 }
198
199 #[test]
200 fn single_frame_is_lone_terminal() {
201 // A buf that fits in one cap-byte frame ships as one terminal
202 // WprofTrace, no chunks (host reassembly is a no-op concat).
203 let buf: Vec<u8> = (0..4u8).collect();
204 let frames = wprof_trace_frames(&buf, 4);
205 assert_eq!(frames.len(), 1);
206 assert_eq!(frames[0].0, MsgType::WprofTrace.wire_value());
207 }
208
209 #[test]
210 fn roundtrip_small_cap_byte_identical() {
211 for len in [0usize, 1, 3, 4, 5, 8, 9, 100] {
212 let buf: Vec<u8> = (0..len).map(|i| (i % 251) as u8).collect();
213 let out = roundtrip(&buf, 4);
214 if buf.is_empty() {
215 // Empty trace: one empty terminal frame → reassembly returns
216 // None (skip-on-empty), matching pre-chunking behavior.
217 assert_eq!(out, None, "empty trace reassembles to None");
218 } else {
219 assert_eq!(out.as_deref(), Some(buf.as_slice()), "len={len}");
220 }
221 }
222 }
223
224 #[test]
225 fn boundary_at_real_cap() {
226 let cap = crate::vmm::bulk::MAX_BULK_FRAME_PAYLOAD as usize;
227 // Exactly cap bytes => single terminal frame, no chunks.
228 let exact = vec![0xABu8; cap];
229 let frames = wprof_trace_frames(&exact, cap);
230 assert_eq!(frames.len(), 1);
231 assert_eq!(frames[0].0, MsgType::WprofTrace.wire_value());
232 assert_eq!(roundtrip(&exact, cap).as_deref(), Some(exact.as_slice()));
233
234 // cap + 1 bytes => one cap-byte chunk + a 1-byte terminal.
235 let mut over = vec![0xCDu8; cap];
236 over.push(0xEE);
237 let frames = wprof_trace_frames(&over, cap);
238 assert_eq!(frames.len(), 2);
239 assert_eq!(frames[0].0, MsgType::WprofTraceChunk.wire_value());
240 assert_eq!(frames[0].1.len(), cap);
241 assert_eq!(frames[1].0, MsgType::WprofTrace.wire_value());
242 assert_eq!(frames[1].1, &[0xEE]);
243 assert_eq!(roundtrip(&over, cap).as_deref(), Some(over.as_slice()));
244 }
245
246 #[test]
247 fn torn_chunk_bails_to_none() {
248 // A crc-bad chunk means the trace is corrupt; reassembly returns None
249 // (write no .pb) rather than concatenating a hole-y .pb.
250 let entries = vec![
251 ShmEntry {
252 msg_type: MsgType::WprofTraceChunk.wire_value(),
253 payload: vec![1, 2, 3],
254 crc_ok: false,
255 },
256 ShmEntry {
257 msg_type: MsgType::WprofTrace.wire_value(),
258 payload: vec![4, 5],
259 crc_ok: true,
260 },
261 ];
262 assert_eq!(reassemble_wprof_trace(&entries), None);
263 }
264
265 #[test]
266 fn torn_terminal_bails_to_none() {
267 let entries = vec![
268 ShmEntry {
269 msg_type: MsgType::WprofTraceChunk.wire_value(),
270 payload: vec![1, 2, 3],
271 crc_ok: true,
272 },
273 ShmEntry {
274 msg_type: MsgType::WprofTrace.wire_value(),
275 payload: vec![4, 5],
276 crc_ok: false,
277 },
278 ];
279 assert_eq!(reassemble_wprof_trace(&entries), None);
280 }
281
282 #[test]
283 fn no_terminal_returns_none() {
284 // Chunks present but no terminal WprofTrace => incomplete => None.
285 let entries = vec![ShmEntry {
286 msg_type: MsgType::WprofTraceChunk.wire_value(),
287 payload: vec![1, 2, 3],
288 crc_ok: true,
289 }];
290 assert_eq!(reassemble_wprof_trace(&entries), None);
291 }
292
293 #[test]
294 fn unrelated_crc_bad_frame_ignored() {
295 // A crc-bad frame of an unrelated msg_type does not gate the trace.
296 let entries = vec![
297 ShmEntry {
298 msg_type: MsgType::Stdout.wire_value(),
299 payload: vec![9, 9, 9],
300 crc_ok: false,
301 },
302 ShmEntry {
303 msg_type: MsgType::WprofTrace.wire_value(),
304 payload: vec![4, 5],
305 crc_ok: true,
306 },
307 ];
308 assert_eq!(
309 reassemble_wprof_trace(&entries).as_deref(),
310 Some(&[4u8, 5][..])
311 );
312 }
313
314 fn chunk(payload: &[u8], crc_ok: bool) -> ShmEntry {
315 ShmEntry {
316 msg_type: MsgType::WprofTraceChunk.wire_value(),
317 payload: payload.to_vec(),
318 crc_ok,
319 }
320 }
321
322 fn terminal(payload: &[u8], crc_ok: bool) -> ShmEntry {
323 ShmEntry {
324 msg_type: MsgType::WprofTrace.wire_value(),
325 payload: payload.to_vec(),
326 crc_ok,
327 }
328 }
329
330 #[test]
331 fn torn_middle_chunk_in_n_gt_2_bails() {
332 // A crc-bad chunk at an INTERIOR position (not just the first) torns
333 // the whole trace → None. Pins the "any torn wprof frame → None"
334 // invariant at an arbitrary position (firecracker per-element rigor).
335 let entries = vec![
336 chunk(&[1, 2], true),
337 chunk(&[3, 4], false), // torn middle chunk
338 chunk(&[5, 6], true),
339 terminal(&[7, 8], true),
340 ];
341 assert_eq!(reassemble_wprof_trace(&entries), None);
342 }
343
344 #[test]
345 fn interleaved_crc_good_nonwprof_frame_skipped_preserves_order() {
346 // A crc-GOOD unrelated frame BETWEEN chunks is skipped by msg_type and
347 // does NOT break chunk concatenation order (only unrelated crc-BAD was
348 // covered before).
349 let entries = vec![
350 chunk(&[1, 2], true),
351 ShmEntry {
352 msg_type: MsgType::Stdout.wire_value(),
353 payload: vec![9, 9],
354 crc_ok: true,
355 },
356 chunk(&[3, 4], true),
357 terminal(&[5], true),
358 ];
359 assert_eq!(
360 reassemble_wprof_trace(&entries).as_deref(),
361 Some(&[1, 2, 3, 4, 5][..])
362 );
363 }
364
365 #[test]
366 fn duplicate_terminal_stops_at_first() {
367 // Reassembly breaks at the FIRST crc-good terminal; a second terminal
368 // and its bytes are ignored. A well-formed guest sends exactly one
369 // terminal (the last frame), so a duplicate is malformed/hostile —
370 // pinning break-at-first (not append-second) is the defensive choice.
371 let entries = vec![
372 chunk(&[1], true),
373 terminal(&[2], true),
374 terminal(&[3], true),
375 ];
376 assert_eq!(
377 reassemble_wprof_trace(&entries).as_deref(),
378 Some(&[1u8, 2][..])
379 );
380 }
381
382 #[test]
383 fn chunk_after_terminal_ignored() {
384 // A WprofTraceChunk AFTER the terminal is swallowed by the
385 // break-at-first-terminal path (a well-formed guest never emits one).
386 let entries = vec![chunk(&[1], true), terminal(&[2], true), chunk(&[3], true)];
387 assert_eq!(
388 reassemble_wprof_trace(&entries).as_deref(),
389 Some(&[1u8, 2][..])
390 );
391 }
392}