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}