ktstr/
verifier.rs

1//! BPF verifier log parsing, cycle detection, and output formatting.
2//!
3//! Provides:
4//! - [`VerifierStats`] / [`ProgStats`] / [`DiffRow`] — data types
5//! - [`collect_verifier_output`] — boot VM, collect stats via host introspection
6//! - [`format_verifier_output`] / [`format_verifier_diff`] — text formatting
7//! - [`extract_verifier_log`] — extract verifier trace from libbpf log blob
8//! - [`parse_verifier_stats`] — extract insn/state counts from verifier log
9//! - [`normalize_verifier_line`] — strip variable register state annotations
10//! - [`detect_cycle`] / [`collapse_cycles`] — loop iteration compression
11//! - [`build_b_map`] / [`build_diff_rows`] — A/B comparison helpers
12//! - `SCHED_OUTPUT_START` / `SCHED_OUTPUT_END` — delimiters the
13//!   guest's rust_init emits over the bulk port (as `MSG_TYPE_SCHED_LOG`
14//!   frames) around the scheduler log region;
15//!   `parse_sched_output` extracts the enclosed block
16
17use std::collections::HashMap;
18
19/// Delimiter the guest's rust_init emits over the bulk port (as a
20/// `MSG_TYPE_SCHED_LOG` frame) immediately before the scheduler log
21/// block. Paired with [`SCHED_OUTPUT_END`].
22pub(crate) const SCHED_OUTPUT_START: &str = "===SCHED_OUTPUT_START===";
23/// Delimiter the guest's rust_init emits over the bulk port (as a
24/// `MSG_TYPE_SCHED_LOG` frame) immediately after the scheduler log
25/// block. Paired with [`SCHED_OUTPUT_START`].
26pub(crate) const SCHED_OUTPUT_END: &str = "===SCHED_OUTPUT_END===";
27
28/// Extract the scheduler log from guest output between
29/// [`SCHED_OUTPUT_START`] and [`SCHED_OUTPUT_END`]. Returns `None` if
30/// the delimiters are absent or the enclosed content is empty after
31/// trimming.
32///
33/// Uses `find` on the start marker and `rfind` on the end marker: if
34/// the scheduler log itself contains the end sentinel string (e.g. a
35/// stack trace that quotes the marker), `rfind` anchors on the last
36/// occurrence, which is the real terminator emitted by the guest's
37/// post-scenario shutdown path.
38pub(crate) fn parse_sched_output(output: &str) -> Option<&str> {
39    let start = output.find(SCHED_OUTPUT_START)?;
40    let end = output.rfind(SCHED_OUTPUT_END)?;
41    let after_marker = start + SCHED_OUTPUT_START.len();
42    if after_marker >= end {
43        return None;
44    }
45    let content = output[after_marker..end].trim();
46    if content.is_empty() {
47        return None;
48    }
49    Some(content)
50}
51
52/// Concatenate every CRC-valid `MSG_TYPE_SCHED_LOG` chunk in the
53/// bulk-port drain into one `String`, in arrival order.
54///
55/// The guest's `dump_sched_output` emits the `SCHED_OUTPUT_START`
56/// and `SCHED_OUTPUT_END` markers as their own
57/// [`crate::vmm::wire::MsgType::SchedLog`] frames, with the file
58/// content split across one or more intermediate frames. Replaying
59/// the chunks back-to-back reproduces the byte-for-byte stream the
60/// prior COM2 path appended to `output`, so [`parse_sched_output`]
61/// runs unchanged on the result.
62///
63/// Empty / `None` drain yields an empty string.
64pub(crate) fn concat_sched_log_chunks(
65    drain: Option<&crate::vmm::host_comms::BulkDrainResult>,
66) -> String {
67    let Some(drain) = drain else {
68        return String::new();
69    };
70    let mut acc = String::new();
71    for e in &drain.entries {
72        if e.msg_type != crate::vmm::wire::MSG_TYPE_SCHED_LOG || !e.crc_ok {
73            continue;
74        }
75        acc.push_str(&String::from_utf8_lossy(&e.payload));
76    }
77    acc
78}
79
80/// Extract scheduler log content even when the closing delimiter is
81/// absent. Tries [`parse_sched_output`] first (well-formed
82/// open+close); on failure, returns the slice from
83/// [`SCHED_OUTPUT_START`] to the end of `output` when only the start
84/// marker is present. Returns `None` only when neither marker is
85/// found or every candidate slice is empty after trimming.
86///
87/// Used by the auto-repro path: a scheduler that crashes mid-run
88/// emits SCHED_OUTPUT_START but never reaches the post-scenario
89/// shutdown that writes SCHED_OUTPUT_END. The partial content still
90/// holds the stack frames the probe pipeline needs to seed kprobe
91/// targets, so discarding it would lose the only crash signal.
92pub(crate) fn parse_sched_output_partial(output: &str) -> Option<&str> {
93    if let Some(content) = parse_sched_output(output) {
94        return Some(content);
95    }
96    let start = output.find(SCHED_OUTPUT_START)?;
97    let after_marker = start + SCHED_OUTPUT_START.len();
98    let content = output[after_marker..].trim();
99    if content.is_empty() {
100        return None;
101    }
102    Some(content)
103}
104
105/// Parsed verifier stats from the kernel log line:
106/// `processed N insns (limit M) max_states_per_insn X total_states Y peak_states Z mark_read W`
107pub struct VerifierStats {
108    /// Instructions processed during verification.
109    pub processed_insns: u64,
110    /// Total explored verifier states.
111    pub total_states: u64,
112    /// Peak concurrent explored states.
113    pub peak_states: u64,
114    /// Total verification wall time in microseconds, when
115    /// BPF_LOG_STATS emitted a "verification time" line.
116    pub time_usec: Option<u64>,
117    /// Stack depth in the format `"<prog>+<subprog>+<main>"` (e.g.
118    /// `"32+16+8"`) when BPF_LOG_STATS emitted a "stack depth" line.
119    pub stack_depth: Option<String>,
120}
121
122/// Per-program verifier statistics collected from a VM run.
123#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
124pub struct ProgStats {
125    /// Program name as registered with the kernel.
126    pub name: String,
127    /// Instructions processed by the verifier (from host-side
128    /// `bpf_prog_aux->verified_insns`).
129    pub verified_insns: u32,
130}
131
132/// A single row in the A/B diff output.
133pub struct DiffRow {
134    /// Program name present in both A and B runs.
135    pub name: String,
136    /// `verified_insns` from the A run.
137    pub a: u64,
138    /// `verified_insns` from the B run.
139    pub b: u64,
140    /// Signed delta (`b - a`); positive means B's verifier cost grew
141    /// relative to A.
142    pub delta: i64,
143}
144
145/// Parse `raw` as `u64`, warning with `field` context on failure.
146/// Returns 0 on parse error. Centralizes the
147/// warn-and-default-to-zero path for the three count words
148/// (`processed_insns`, `total_states`, `peak_states`) so their
149/// error handling stays in lock-step.
150fn parse_or_warn(raw: &str, field: &str) -> u64 {
151    match raw.parse() {
152        Ok(n) => n,
153        Err(e) => {
154            tracing::warn!(
155                field,
156                word = raw,
157                err = %e,
158                "malformed BPF verifier count; leaving 0",
159            );
160            0
161        }
162    }
163}
164
165/// Parse verifier stats from the log output.
166///
167/// The kernel always emits a "processed N insns ..." line. When
168/// BPF_LOG_STATS is set, it also emits "verification time" and
169/// "stack depth" lines.
170pub fn parse_verifier_stats(log: &str) -> VerifierStats {
171    let mut stats = VerifierStats {
172        processed_insns: 0,
173        total_states: 0,
174        peak_states: 0,
175        time_usec: None,
176        stack_depth: None,
177    };
178
179    let mut found_insns = false;
180    let mut found_time = false;
181    let mut found_stack = false;
182
183    for line in log.lines().rev() {
184        if !found_insns && line.starts_with("processed ") {
185            found_insns = true;
186            let words: Vec<&str> = line.split_whitespace().collect();
187            if words.len() >= 2 {
188                stats.processed_insns = parse_or_warn(words[1], "processed_insns");
189            }
190            for (i, &w) in words.iter().enumerate() {
191                if w == "total_states"
192                    && let Some(v) = words.get(i + 1)
193                {
194                    stats.total_states = parse_or_warn(v, "total_states");
195                }
196                if w == "peak_states"
197                    && let Some(v) = words.get(i + 1)
198                {
199                    stats.peak_states = parse_or_warn(v, "peak_states");
200                }
201            }
202        }
203        if !found_time && line.contains("verification time") {
204            found_time = true;
205            for word in line.split_whitespace() {
206                if let Ok(n) = word.parse::<u64>() {
207                    stats.time_usec = Some(n);
208                    break;
209                }
210            }
211        }
212        if !found_stack && line.contains("stack depth") {
213            found_stack = true;
214            if let Some(pos) = line.find("stack depth") {
215                let after = &line[pos + "stack depth".len()..];
216                let depth_str = after.trim();
217                if !depth_str.is_empty() {
218                    stats.stack_depth = Some(depth_str.to_string());
219                }
220            }
221        }
222        if found_insns && found_time && found_stack {
223            break;
224        }
225    }
226
227    stats
228}
229
230/// Normalize a BPF verifier log line by stripping variable register-state
231/// annotations so that lines from different loop iterations compare equal.
232///
233/// Handles:
234/// - Instruction with `; frame` annotation: `3006: (07) r9 += 1  ; frame1: R9_w=2`
235/// - Instruction with `; R` + digit annotation: `9: (15) if r7 == 0x0 goto pc+1  ; R7=scalar(...)`
236/// - Branch with inline target state: `3026: (b5) if r6 <= 0x11dc0 goto pc+2 3029: frame1: R0=1`
237/// - Standalone register dump with frame: `3041: frame1: R0_w=scalar()`
238/// - Standalone register dump without frame: `3029: R0=1 R6=scalar()`
239///
240/// Preserves source comments (`; for (int j = 0; ...)`) and non-annotation
241/// semicolons (`; Return value`) -- these serve as cycle anchors.
242pub fn normalize_verifier_line(line: &str) -> &str {
243    let trimmed = line.trim();
244    if trimmed.is_empty() || !trimmed.as_bytes()[0].is_ascii_digit() {
245        return trimmed;
246    }
247    // "3041: frame1: ..." or "3041: R0_w=scalar()" — standalone register dump.
248    // State-only lines; keep just the instruction index.
249    if let Some(colon) = trimmed.find(": ") {
250        let after = &trimmed[colon + 2..];
251        if after.starts_with("frame")
252            || (after.starts_with('R')
253                && after.as_bytes().get(1).is_some_and(|b| b.is_ascii_digit()))
254        {
255            return &trimmed[..colon + 1];
256        }
257    }
258    // "; frame" annotation on instruction line
259    if let Some(pos) = trimmed.find("; frame") {
260        return trimmed[..pos].trim_end();
261    }
262    // "; R" followed by digit — register annotation without frame prefix
263    if let Some(pos) = trimmed.find("; R")
264        && trimmed
265            .as_bytes()
266            .get(pos + 3)
267            .is_some_and(|b| b.is_ascii_digit())
268    {
269        return trimmed[..pos].trim_end();
270    }
271    // Inline branch-target state: "goto pc+2 3029: frame1: ..."
272    if let Some(goto_pos) = trimmed.find("goto pc") {
273        let after_goto = &trimmed[goto_pos + 7..];
274        let end = after_goto
275            .find(|c: char| c != '+' && c != '-' && !c.is_ascii_digit())
276            .unwrap_or(after_goto.len());
277        let insn_end = goto_pos + 7 + end;
278        if insn_end < trimmed.len() {
279            return trimmed[..insn_end].trim_end();
280        }
281    }
282    trimmed
283}
284
285/// Normalize for cycle detection: strip register annotations (via
286/// `normalize_verifier_line`) then strip the leading instruction address
287/// (`NNN: `). Unrolled loops place each copy at different addresses, so
288/// the address must be removed for block comparison to find repeats.
289fn normalize_for_cycle_detection(line: &str) -> &str {
290    let n = normalize_verifier_line(line);
291    // Strip leading digits + ": " prefix (e.g. "42: (07) r1 += 8" -> "(07) r1 += 8").
292    if let Some(colon) = n.find(": ") {
293        let before = &n[..colon];
294        if !before.is_empty() && before.bytes().all(|b| b.is_ascii_digit()) {
295            return &n[colon + 2..];
296        }
297    }
298    n
299}
300
301/// Detect a single repeating cycle in a slice of lines.
302///
303/// Returns `Some((start, period, count))` where the cycle begins at
304/// `start`, each iteration is `period` lines, and it repeats `count` times.
305pub fn detect_cycle(lines: &[&str]) -> Option<(usize, usize, usize)> {
306    const MIN_PERIOD: usize = 5;
307    const MIN_REPS: usize = 3;
308
309    if lines.len() < MIN_PERIOD * MIN_REPS {
310        return None;
311    }
312
313    // Two normalization levels:
314    // - anchor_norms: keeps addresses, strips register annotations. Used for
315    //   anchor frequency counting — prevents within-period duplicates at
316    //   different addresses from inflating frequency.
317    // - block_norms: also strips addresses. Used for block equality comparison
318    //   so unrolled loops (same instructions at different addresses) can match.
319    let anchor_norms: Vec<&str> = lines.iter().map(|l| normalize_verifier_line(l)).collect();
320    let block_norms: Vec<&str> = lines
321        .iter()
322        .map(|l| normalize_for_cycle_detection(l))
323        .collect();
324
325    // Find most frequent non-trivial anchor-normalized line.
326    let mut sorted_norms: Vec<&str> = anchor_norms
327        .iter()
328        .filter(|l| l.len() >= 10)
329        .copied()
330        .collect();
331    sorted_norms.sort_unstable();
332
333    let mut best_anchor: Option<(&str, usize)> = None;
334    let mut i = 0;
335    while i < sorted_norms.len() {
336        let mut j = i + 1;
337        while j < sorted_norms.len() && sorted_norms[j] == sorted_norms[i] {
338            j += 1;
339        }
340        let count = j - i;
341        if count >= MIN_REPS && best_anchor.is_none_or(|(_, best)| count > best) {
342            best_anchor = Some((sorted_norms[i], count));
343        }
344        i = j;
345    }
346
347    // If address-preserving anchor search found nothing (unrolled loops
348    // where every address is unique), fall back to address-stripped norms.
349    let (anchor, use_block_norms_for_positions) = match best_anchor {
350        Some((a, _)) => (a, false),
351        None => {
352            let mut sorted_block: Vec<&str> = block_norms
353                .iter()
354                .filter(|l| l.len() >= 10)
355                .copied()
356                .collect();
357            sorted_block.sort_unstable();
358            let mut ba: Option<(&str, usize)> = None;
359            let mut bi = 0;
360            while bi < sorted_block.len() {
361                let mut bj = bi + 1;
362                while bj < sorted_block.len() && sorted_block[bj] == sorted_block[bi] {
363                    bj += 1;
364                }
365                let c = bj - bi;
366                if c >= MIN_REPS && ba.is_none_or(|(_, best)| c > best) {
367                    ba = Some((sorted_block[bi], c));
368                }
369                bi = bj;
370            }
371            match ba {
372                Some((a, _)) => (a, true),
373                None => return None,
374            }
375        }
376    };
377
378    let norms_for_pos = if use_block_norms_for_positions {
379        &block_norms
380    } else {
381        &anchor_norms
382    };
383    let positions: Vec<usize> = norms_for_pos
384        .iter()
385        .enumerate()
386        .filter(|(_, l)| **l == anchor)
387        .map(|(i, _)| i)
388        .collect();
389
390    // Try strides 1..=3 to handle anchors appearing K times per cycle.
391    for stride in 1..=3usize {
392        if positions.len() <= stride {
393            continue;
394        }
395
396        let mut gaps: Vec<usize> = positions
397            .windows(stride + 1)
398            .map(|w| w[stride] - w[0])
399            .filter(|g| *g >= MIN_PERIOD)
400            .collect();
401        gaps.sort_unstable();
402
403        let mut best_period = 0;
404        let mut best_gap_count = 0;
405        let mut gi = 0;
406        while gi < gaps.len() {
407            let mut gj = gi + 1;
408            while gj < gaps.len() && gaps[gj] == gaps[gi] {
409                gj += 1;
410            }
411            let count = gj - gi;
412            if count > best_gap_count {
413                best_gap_count = count;
414                best_period = gaps[gi];
415            }
416            gi = gj;
417        }
418        if best_period == 0 || best_gap_count < MIN_REPS - 1 {
419            continue;
420        }
421        let period = best_period;
422
423        for &pos in &positions {
424            if pos + 2 * period > lines.len() {
425                break;
426            }
427            if block_norms[pos..pos + period] == block_norms[pos + period..pos + 2 * period] {
428                let first_block = &block_norms[pos..pos + period];
429                let mut count = 1;
430                while pos + (count + 1) * period <= lines.len() {
431                    if block_norms[pos + count * period..pos + (count + 1) * period] != *first_block
432                    {
433                        break;
434                    }
435                    count += 1;
436                }
437                // Try earlier starts to find best alignment.
438                let mut best_start = pos;
439                let mut best_count = count;
440                for offset in 1..period {
441                    let Some(cand) = pos.checked_sub(offset) else {
442                        break;
443                    };
444                    if cand + 2 * period > lines.len() {
445                        continue;
446                    }
447                    if block_norms[cand..cand + period]
448                        != block_norms[cand + period..cand + 2 * period]
449                    {
450                        continue;
451                    }
452                    let mut c = 2;
453                    while cand + (c + 1) * period <= lines.len()
454                        && block_norms[cand + c * period..cand + (c + 1) * period]
455                            == block_norms[cand..cand + period]
456                    {
457                        c += 1;
458                    }
459                    if c > best_count {
460                        best_start = cand;
461                        best_count = c;
462                    }
463                }
464                if best_count >= MIN_REPS {
465                    return Some((best_start, period, best_count));
466                }
467            }
468        }
469    }
470
471    None
472}
473
474/// Collapse repeating cycles in a verifier log.
475///
476/// Runs cycle detection iteratively (up to 5 passes for nested loops).
477/// Each cycle is replaced with:
478/// - `--- Nx of the following M lines ---` (count header, no closing marker)
479/// - first iteration (with original register annotations)
480/// - `--- K identical iterations omitted ---` (omission marker)
481/// - last iteration (with original register annotations)
482/// - `--- end repeat ---` (closes the omission)
483pub fn collapse_cycles(log: &str) -> String {
484    const MAX_PASSES: usize = 5;
485    let mut text = log.to_string();
486
487    for _ in 0..MAX_PASSES {
488        let lines: Vec<&str> = text.lines().collect();
489        let (start, period, count) = match detect_cycle(&lines) {
490            Some(c) => c,
491            None => break,
492        };
493
494        let mut out = String::new();
495        for line in &lines[..start] {
496            out.push_str(line);
497            out.push('\n');
498        }
499        out.push_str(&format!(
500            "--- {}x of the following {} lines ---\n",
501            count, period
502        ));
503        for line in &lines[start..start + period] {
504            out.push_str(line);
505            out.push('\n');
506        }
507        out.push_str(&format!(
508            "--- {} identical iterations omitted ---\n",
509            count - 2
510        ));
511        let last_start = start + (count - 1) * period;
512        for line in &lines[last_start..last_start + period] {
513            out.push_str(line);
514            out.push('\n');
515        }
516        out.push_str("--- end repeat ---\n");
517        let suffix_start = start + count * period;
518        for line in &lines[suffix_start..] {
519            out.push_str(line);
520            out.push('\n');
521        }
522        text = out;
523    }
524
525    text
526}
527
528/// Build diff rows from A stats and B lookup map.
529pub fn build_diff_rows(stats_a: &[ProgStats], b_map: &HashMap<String, u64>) -> Vec<DiffRow> {
530    let mut rows = Vec::new();
531    for ps in stats_a {
532        let a = ps.verified_insns as u64;
533        let b = b_map.get(&ps.name).copied().unwrap_or(0);
534        rows.push(DiffRow {
535            name: ps.name.clone(),
536            a,
537            b,
538            delta: a as i64 - b as i64,
539        });
540    }
541    rows
542}
543
544/// Build the B-side lookup map from collected stats.
545pub fn build_b_map(stats_b: &[ProgStats]) -> HashMap<String, u64> {
546    stats_b
547        .iter()
548        .map(|ps| (ps.name.clone(), ps.verified_insns as u64))
549        .collect()
550}
551
552// ---------------------------------------------------------------------------
553// VM-based verifier collection
554// ---------------------------------------------------------------------------
555
556/// Whether the scheduler positively confirmed it turned on during a
557/// verifier VM run.
558///
559/// The guest init's attach gate (`poll_startup` + `poll_scx_attached`
560/// in `crate::vmm::rust_init::scheduler`) already runs on every verifier
561/// VM boot: it confirms the scheduler process survived BPF load AND that
562/// `/sys/kernel/sched_ext/state` reached `enabled`. The kernel sets
563/// `enabled` only after `ops.init`, per-task init, and switching
564/// eligible tasks to the sched_ext class (`kernel/sched/ext.c`
565/// `scx_root_enable_workfn`), so `enabled` proves the scheduler turned
566/// on and is scheduling — not merely that its BPF loaded.
567///
568/// The verdict is POSITIVE-confirmation, not absence-of-failure: a
569/// verifier cell PASSes only when the guest reached its post-attach
570/// dispatch phase — a `PayloadStarting` lifecycle frame, emitted at
571/// `ktstr_guest_init` Phase 5, which is reached ONLY if `start_scheduler`
572/// succeeded in Phase 3. On attach failure the guest emits
573/// `SchedulerDied` / `SchedulerNotAttached` and force-reboots in Phase 3
574/// BEFORE Phase 5, so no `PayloadStarting` arrives. A guest that vanishes
575/// before Phase 5 with NO frame at all — e.g. a kernel panic, which
576/// reboots via the guest's `panic=-1` (an i8042 reset →
577/// `ExitAction::Shutdown`, NOT a host watchdog timeout) — is
578/// [`AttachOutcome::Unconfirmed`], also a FAIL. Absence-of-failure alone
579/// would false-PASS that vanish case. [`collect_verifier_output`]
580/// consumes this verdict instead of discarding it. Scheduler-agnostic
581/// (kernel sysfs state), so it holds for every declared scheduler.
582#[derive(Debug, Clone, PartialEq, Eq)]
583pub enum AttachOutcome {
584    /// The guest reached its post-attach dispatch phase (a
585    /// `PayloadStarting` frame) with no failure frame: the scheduler
586    /// loaded, stayed alive, and reached sched_ext `enabled`.
587    Attached,
588    /// Scheduler process exited during BPF load / startup
589    /// (`LifecyclePhase::SchedulerDied`).
590    Died,
591    /// Scheduler stayed alive but never reached `enabled`
592    /// (`LifecyclePhase::SchedulerNotAttached`); carries the guest's
593    /// reason suffix when present.
594    NotAttached(String),
595    /// No failure frame AND no `PayloadStarting` frame: the guest never
596    /// reached the post-attach dispatch phase, so attach was never
597    /// positively confirmed (e.g. an early guest kernel panic that
598    /// reboots via `panic=-1` before Phase 3 — emitting no lifecycle
599    /// frame and NOT tripping the host watchdog).
600    Unconfirmed,
601}
602
603impl AttachOutcome {
604    /// Human-readable failure reason, or `None` when attached.
605    pub fn failure_reason(&self) -> Option<String> {
606        match self {
607            AttachOutcome::Attached => None,
608            AttachOutcome::Died => {
609                Some("scheduler process exited during BPF load/startup".to_string())
610            }
611            AttachOutcome::NotAttached(reason) if reason.is_empty() => {
612                Some("scheduler never reached sched_ext 'enabled'".to_string())
613            }
614            AttachOutcome::NotAttached(reason) => Some(format!(
615                "scheduler never reached sched_ext 'enabled': {reason}"
616            )),
617            AttachOutcome::Unconfirmed => Some(
618                "scheduler attach unconfirmed — guest never reached the dispatch phase \
619                 (no PayloadStarting frame; possible early guest kernel panic)"
620                    .to_string(),
621            ),
622        }
623    }
624}
625
626/// Derive the [`AttachOutcome`] from a VM run's bulk-port lifecycle
627/// frames using a POSITIVE-confirmation rule:
628/// - a `SchedulerDied` frame ⇒ [`AttachOutcome::Died`] (wins outright —
629///   a process that exited cannot have attached);
630/// - else a `SchedulerNotAttached` frame ⇒ [`AttachOutcome::NotAttached`]
631///   (with its reason suffix);
632/// - else a `PayloadStarting` frame ⇒ [`AttachOutcome::Attached`] (the
633///   guest reached its post-attach dispatch phase, so the scheduler
634///   turned on);
635/// - else [`AttachOutcome::Unconfirmed`] — no failure AND no positive
636///   frame, so attach was never confirmed (e.g. an early guest kernel
637///   panic that reboots before Phase 5 without emitting any frame).
638///
639/// Corrupt frames (`crc_ok == false`) and empty payloads are skipped. A
640/// `None` `guest_messages` (no frames at all) is
641/// [`AttachOutcome::Unconfirmed`].
642pub(crate) fn attach_outcome_from_messages(
643    guest_messages: Option<&crate::vmm::host_comms::BulkDrainResult>,
644) -> AttachOutcome {
645    let Some(drain) = guest_messages else {
646        return AttachOutcome::Unconfirmed;
647    };
648    let mut not_attached: Option<String> = None;
649    let mut payload_starting = false;
650    for e in &drain.entries {
651        if e.msg_type != crate::vmm::wire::MSG_TYPE_LIFECYCLE || !e.crc_ok || e.payload.is_empty() {
652            continue;
653        }
654        match crate::vmm::wire::LifecyclePhase::from_wire(e.payload[0]) {
655            Some(crate::vmm::wire::LifecyclePhase::SchedulerDied) => return AttachOutcome::Died,
656            Some(crate::vmm::wire::LifecyclePhase::SchedulerNotAttached) => {
657                not_attached = Some(String::from_utf8_lossy(&e.payload[1..]).into_owned());
658            }
659            Some(crate::vmm::wire::LifecyclePhase::PayloadStarting) => {
660                payload_starting = true;
661            }
662            _ => {}
663        }
664    }
665    if let Some(reason) = not_attached {
666        AttachOutcome::NotAttached(reason)
667    } else if payload_starting {
668        AttachOutcome::Attached
669    } else {
670        AttachOutcome::Unconfirmed
671    }
672}
673
674/// Whether the guest confirmed workload dispatch: at least one
675/// `WorkloadDispatched` lifecycle frame (crc-ok, non-empty payload) in
676/// the run's bulk-port frames. Emitted by `ktstr_guest_init` Phase 5 only
677/// when the injected SpinWait workload recorded a worker with non-zero
678/// `iterations` under a confirmed SCHED_EXT policy (`sched_policy_error`
679/// is None) after the scheduler attached — so a fair-class fallback
680/// cannot false-confirm — a positive, scheduler-agnostic proof the
681/// scheduler dispatched a task onto a CPU.
682/// Corrupt frames (`crc_ok == false`) and empty payloads are skipped. A
683/// `None` `guest_messages` (no frames at all) is `false`.
684fn dispatch_confirmed_from_messages(
685    guest_messages: Option<&crate::vmm::host_comms::BulkDrainResult>,
686) -> bool {
687    let Some(drain) = guest_messages else {
688        return false;
689    };
690    drain.entries.iter().any(|e| {
691        e.msg_type == crate::vmm::wire::MSG_TYPE_LIFECYCLE
692            && e.crc_ok
693            && !e.payload.is_empty()
694            && crate::vmm::wire::LifecyclePhase::from_wire(e.payload[0])
695                == Some(crate::vmm::wire::LifecyclePhase::WorkloadDispatched)
696    })
697}
698
699/// Result of collecting verifier output from a VM run.
700pub struct VerifierVmResult {
701    /// Per-program verifier statistics from host-side memory
702    /// introspection (`bpf_prog_aux->verified_insns`).
703    pub stats: Vec<ProgStats>,
704    /// Scheduler log (stdout+stderr) from the VM. Contains libbpf's
705    /// verifier instruction traces when BPF load fails.
706    pub scheduler_log: String,
707    /// Whether the scheduler positively confirmed attach. Derived from
708    /// the guest's lifecycle frames ([`AttachOutcome`]). Attach is
709    /// necessary but NOT sufficient for a cell PASS: this must be
710    /// [`AttachOutcome::Attached`] (the guest reached its post-attach
711    /// dispatch phase) AND [`Self::dispatched`] must be true. Verification
712    /// alone (non-empty `stats`) is not enough — a scheduler whose BPF
713    /// loads but never reaches sched_ext `enabled`, or a guest that
714    /// vanishes before the dispatch phase, is a real failure.
715    pub attach: AttachOutcome,
716    /// Whether the guest confirmed the injected verifier workload
717    /// dispatched — a `WorkloadDispatched` lifecycle frame, emitted by
718    /// `ktstr_guest_init` Phase 5 when the SpinWait probe recorded a
719    /// worker with non-zero `iterations` under a confirmed SCHED_EXT
720    /// policy after attach (so a fair-class fallback cannot false-confirm).
721    /// A cell PASSes only
722    /// when this is true AND [`Self::attach`] is
723    /// [`AttachOutcome::Attached`]: a scheduler that turns on (sched_ext
724    /// `enabled`) but never dispatches a runnable task is a real, distinct
725    /// failure — worse than never attaching — that the attach verdict
726    /// alone cannot catch. Derived from the run's lifecycle frames;
727    /// scheduler-agnostic — the probe runs as SCHED_EXT, so the BPF
728    /// scheduler dispatches it under any switch mode (full or
729    /// `SCX_OPS_SWITCH_PARTIAL`) and non-zero worker progress proves
730    /// dispatch, unlike an scx-specific `nr_dispatched` counter.
731    pub dispatched: bool,
732    /// The host watchdog fired (hard-deadline hang) before the guest
733    /// exited. Orthogonal to [`Self::attach`]: the attach verdict already
734    /// fails a guest that vanished BEFORE the dispatch phase — an early
735    /// kernel panic reboots via `panic=-1` (an i8042 reset →
736    /// `ExitAction::Shutdown`, `timed_out == false`) and is
737    /// [`AttachOutcome::Unconfirmed`]. This flag catches the remaining
738    /// case: a guest that wedges AFTER attaching (during teardown), which
739    /// leaves `attach == Attached` but never exits. A verifier cell FAILs
740    /// on it too — but NOT on the guest exit code, which is `1` even on
741    /// the verifier success path (no `#[ktstr_test]` body to dispatch).
742    pub timed_out: bool,
743}
744
745impl VerifierVmResult {
746    /// The verifier cell PASS/FAIL verdict: `Ok(())` when the scheduler
747    /// verified its BPF, attached (sched_ext `enabled`), AND dispatched
748    /// the injected workload; `Err(reason)` naming the first failing gate
749    /// otherwise. Gate order — `timed_out` (a post-attach teardown hang),
750    /// then attach (did it turn on?), then dispatch (did it schedule a
751    /// task?) — so the root-cause failure is reported first: an attach
752    /// failure is named before the dispatch gate it necessarily also
753    /// trips. Does NOT key on the guest exit code, which is `1` even on
754    /// the verifier success path (no `#[ktstr_test]` body to dispatch).
755    pub fn cell_verdict(&self) -> Result<(), String> {
756        // A hard-deadline hang. The attach verdict (Unconfirmed) already
757        // fails a guest that vanished BEFORE the dispatch phase (an early
758        // panic reboots via panic=-1 with timed_out=false), so this
759        // catches the orthogonal case: a guest that wedges AFTER attaching,
760        // during teardown.
761        if self.timed_out {
762            return Err("VM timed out (hung after attach, before exit)".to_string());
763        }
764        // PASS requires the scheduler to have turned ON, not just to have
765        // loaded + verified its BPF.
766        if let Some(reason) = self.attach.failure_reason() {
767            return Err(format!("scheduler did not turn on — {reason}"));
768        }
769        // PASS also requires DISPATCH: the guest injects a SpinWait
770        // workload after attach and only emits WorkloadDispatched when a
771        // SCHED_EXT worker makes forward progress. A scheduler that turns
772        // on but never dispatches a runnable task is a distinct, worse
773        // failure the attach gate can't catch.
774        if !self.dispatched {
775            return Err(
776                "scheduler attached but did not dispatch the injected workload (0 iterations)"
777                    .to_string(),
778            );
779        }
780        Ok(())
781    }
782}
783
784/// Boot a VM and collect verifier statistics via host-side memory
785/// introspection. Per-program `verified_insns` comes from
786/// `bpf_prog_aux->verified_insns` read through the guest's physical
787/// memory. On load failure, libbpf prints the verifier log to stderr;
788/// the returned `scheduler_log` field contains the scheduler's captured
789/// output from the VM.
790///
791/// `topology` selects the verifier VM's emulated topology via
792/// [`crate::test_support::TopologyJson`] — the same named-field
793/// shape carried in the per-scheduler `--ktstr-list-schedulers`
794/// JSON. The named fields force callers to spell every dimension
795/// at the call site, preventing position-swap misorders. The sole
796/// caller is the sweep cell handler (`crate::test_support::dispatch`'s
797/// `run_verifier_cell`), which passes the topology of the gauntlet
798/// preset named in the cell — the verifier sweeps each scheduler across
799/// every preset its constraints accept under no_perf_mode.
800pub fn collect_verifier_output(
801    sched_bin: &std::path::Path,
802    ktstr_bin: &std::path::Path,
803    kernel: &std::path::Path,
804    extra_sched_args: &[String],
805    topology: crate::test_support::TopologyJson,
806) -> anyhow::Result<VerifierVmResult> {
807    use anyhow::Context;
808
809    // Pre-validate via TryFrom so a clean "topology rejected" error
810    // surfaces here instead of the builder's Topology::new panic on
811    // bad input.
812    let validated: crate::vmm::topology::Topology = topology
813        .try_into()
814        .map_err(|e: String| anyhow::anyhow!("invalid topology {topology:?}: {e}"))?;
815
816    let sched_args: Vec<String> = extra_sched_args.to_vec();
817
818    // The verifier only loads the scheduler's BPF and reads the kernel
819    // verifier's load-time `verified_insns` counts via host-side
820    // introspection — a value fixed at BPF load, wholly INDEPENDENT of
821    // perf-mode tuning (CPU pinning, RT priority, hugepages, NUMA mbind,
822    // KVM exit suppression). So the verifier VM ALWAYS runs with
823    // performance mode disabled: it needs none of that tuning. Disabling
824    // perf mode also moves the run OFF the default run-lock path — whose
825    // per-offset `LOCK_SH` search hard-fails "all N LLC slots busy
826    // (LOCK_SH)" (`acquire_default_run_locks`, src/vmm/mod.rs) when no
827    // candidate offset is free, the failure the verifier was hitting —
828    // ONTO the no-perf-mode plan, which reserves a shared (`LOCK_SH`)
829    // SUBSET of LLCs via `acquire_llc_plan`. `LOCK_SH` holders are
830    // mutually compatible, so parallel verifier / no-perf cells no longer
831    // starve each other on the LLC lock; a `performance_mode` peer holding
832    // `LOCK_EX` on those LLCs can still defer a cell (nextest retries it),
833    // which is correct — the verifier must not perturb an isolated peer's
834    // pinned CPUs. Note `no_perf_mode(true)` does NOT skip the reservation
835    // ENTIRELY — only `KTSTR_BYPASS_LLC_LOCKS=1` does; see
836    // [`crate::vmm::KtstrVmBuilder::no_perf_mode`].
837    // Pass the validated Topology directly so misorder cannot occur
838    // at the builder boundary (the TryFrom above already enforces the
839    // type-level invariants).
840    let vm = crate::vmm::KtstrVm::builder()
841        .kernel(kernel)
842        .init_binary(ktstr_bin)
843        .scheduler_binary(sched_bin)
844        .sched_args(&sched_args)
845        // Boot the guest into the verifier dispatch probe: the sweep VM
846        // has no `#[ktstr_test]` body, so Phase 5 spawns a SpinWait
847        // workload and emits `WorkloadDispatched` on confirmed progress.
848        // The PASS verdict requires that frame in addition to attach.
849        .run_args(&[crate::test_support::VERIFIER_WORKLOAD_FLAG.to_string()])
850        .topology(validated)
851        .memory_mib(2048)
852        .timeout(std::time::Duration::from_secs(120))
853        .no_perf_mode(true)
854        .build()
855        .context("build verifier VM")?;
856
857    let result = vm.run().context("run verifier VM")?;
858
859    // Concatenate bulk-port `MSG_TYPE_SCHED_LOG` chunks, then run
860    // the marker-pair extractor on the merged stream — the
861    // SCHED_OUTPUT_START/END markers travel verbatim inside chunk
862    // bytes so the existing parser slices the same content the
863    // prior COM2 dump produced. Falls back to `result.output` when
864    // the bulk-port drain has no SchedLog frames (verifier VM
865    // running on a kernel without the bulk port, for instance).
866    let merged = concat_sched_log_chunks(result.guest_messages.as_ref());
867    let scheduler_log = if !merged.is_empty() {
868        parse_sched_output(&merged).unwrap_or("").to_string()
869    } else {
870        parse_sched_output(&result.output).unwrap_or("").to_string()
871    };
872
873    // Build ProgStats from host-side ProgVerifierStats. Each program
874    // that loaded successfully is visible in prog_idr with its
875    // verified_insns count.
876    let stats: Vec<ProgStats> = result
877        .verifier_stats
878        .iter()
879        .map(|pvs| ProgStats {
880            name: pvs.name.clone(),
881            verified_insns: pvs.verified_insns,
882        })
883        .collect();
884
885    let attach = attach_outcome_from_messages(result.guest_messages.as_ref());
886    let dispatched = dispatch_confirmed_from_messages(result.guest_messages.as_ref());
887
888    Ok(VerifierVmResult {
889        stats,
890        scheduler_log,
891        attach,
892        dispatched,
893        timed_out: result.timed_out,
894    })
895}
896
897/// Extract the verifier instruction trace from a scheduler log blob.
898///
899/// libbpf wraps the kernel verifier log between marker lines:
900///   `-- BEGIN PROG LOAD LOG --`
901///   `-- END PROG LOAD LOG --`
902///
903/// Returns the content between the first pair of markers, or `None` if
904/// no markers are found (backward compat with logs that contain only
905/// raw verifier output).
906pub fn extract_verifier_log(scheduler_log: &str) -> Option<&str> {
907    const BEGIN: &str = "-- BEGIN PROG LOAD LOG --";
908    const END: &str = "-- END PROG LOAD LOG --";
909
910    let begin_pos = scheduler_log.find(BEGIN)?;
911    let content_start = begin_pos + BEGIN.len();
912    // Skip the newline after the BEGIN marker if present.
913    let content_start = if scheduler_log.as_bytes().get(content_start) == Some(&b'\n') {
914        content_start + 1
915    } else {
916        content_start
917    };
918    let end_pos = scheduler_log[content_start..].find(END)?;
919    let content = &scheduler_log[content_start..content_start + end_pos];
920    // The END marker may appear mid-line (e.g. "libbpf: -- END ...").
921    // Trim back to the last newline to drop the partial prefix.
922    let content = content
923        .rfind('\n')
924        .map(|p| &content[..p])
925        .unwrap_or(content);
926    Some(content.trim_end_matches('\n'))
927}
928
929/// Format verifier results as text: brief lines per program and collapsed
930/// logs.
931pub fn format_verifier_output(label: &str, result: &VerifierVmResult, raw: bool) -> String {
932    let mut out = String::new();
933    out.push_str(&format!("\n{label}\n"));
934    if result.timed_out {
935        out.push_str("  scheduler: UNKNOWN — VM timed out before exit\n");
936    } else {
937        match result.attach.failure_reason() {
938            None => {
939                out.push_str("  scheduler: attached (sched_ext enabled)\n");
940                if result.dispatched {
941                    out.push_str("  dispatch: confirmed (injected workload ran)\n");
942                } else {
943                    out.push_str(
944                        "  dispatch: NOT CONFIRMED — attached but injected workload made no progress\n",
945                    );
946                }
947            }
948            Some(reason) => out.push_str(&format!("  scheduler: NOT ATTACHED — {reason}\n")),
949        }
950    }
951    for ps in &result.stats {
952        out.push_str(&format!(
953            "  {:<40} verified_insns={}\n",
954            ps.name, ps.verified_insns
955        ));
956    }
957
958    if !result.scheduler_log.is_empty() {
959        // Extract the verifier log from between libbpf's markers.
960        // Falls back to the full scheduler_log when no markers exist.
961        let verifier_log =
962            extract_verifier_log(&result.scheduler_log).unwrap_or(&result.scheduler_log);
963
964        let vs = parse_verifier_stats(verifier_log);
965        if vs.processed_insns > 0 {
966            out.push_str(&format!("\n{label} --- verifier stats ---\n"));
967            out.push_str(&format!(
968                "  processed={}  states={}/{}",
969                vs.processed_insns, vs.peak_states, vs.total_states
970            ));
971            if let Some(t) = vs.time_usec {
972                out.push_str(&format!("  time={t}us"));
973            }
974            if let Some(ref s) = vs.stack_depth {
975                out.push_str(&format!("  stack={s}"));
976            }
977            out.push('\n');
978        }
979
980        out.push_str(&format!("\n{label} --- scheduler log ---\n"));
981        if raw {
982            out.push_str(&result.scheduler_log);
983        } else {
984            out.push_str(&collapse_cycles(verifier_log));
985        }
986    }
987
988    out
989}
990
991/// Format an A/B diff table comparing two sets of verifier stats.
992pub fn format_verifier_diff(
993    label_a: &str,
994    stats_a: &[ProgStats],
995    label_b: &str,
996    stats_b: &[ProgStats],
997) -> String {
998    let b_map = build_b_map(stats_b);
999    let diff_rows = build_diff_rows(stats_a, &b_map);
1000
1001    let mut out = String::new();
1002    out.push_str(&format!("\ndelta A/B diff: {label_a} vs {label_b}\n"));
1003    let mut table = crate::cli::new_table();
1004    table.set_header(vec!["program", "A", "B", "delta"]);
1005    for row in &diff_rows {
1006        table.add_row(vec![
1007            row.name.clone(),
1008            row.a.to_string(),
1009            row.b.to_string(),
1010            format!("{:+}", row.delta),
1011        ]);
1012    }
1013    out.push_str(&table.to_string());
1014    out.push('\n');
1015    out
1016}
1017
1018// ---------------------------------------------------------------------------
1019// Per-cell PASS/FAIL result capture (for the run-summary table)
1020// ---------------------------------------------------------------------------
1021
1022/// One `cargo ktstr verifier` cell's outcome. The cell process writes it
1023/// (via `write_cell_record`) into the directory named by
1024/// [`crate::KTSTR_VERIFIER_RESULT_DIR_ENV`]; after nextest returns the
1025/// dispatcher reads them back (via [`read_cell_records`]) and renders the
1026/// per-(topology × scheduler) summary table. A cell is one
1027/// (scheduler, kernel, topology): the verifier sweeps each declared
1028/// scheduler across topologies, so topology IS a result axis — a
1029/// scheduler can pass on one topology and fail on another.
1030#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1031pub struct VerifierCellRecord {
1032    /// Declared scheduler name (the `<sched>` cell-name segment).
1033    pub scheduler: String,
1034    /// Sanitized kernel label (the `<kernel>` cell-name segment).
1035    pub kernel: String,
1036    /// Gauntlet topology preset (the `<preset>` cell-name segment).
1037    pub topology: String,
1038    /// Whether the cell passed (exit 0 from the cell handler).
1039    pub passed: bool,
1040    /// Per-program stats (program name + its `verified_insns` count)
1041    /// captured for this cell, copied from the VM run's
1042    /// [`VerifierVmResult::stats`]. Empty when the cell failed before
1043    /// producing stats. Drives the per-scheduler `verified_insns` tables
1044    /// ([`render_instruction_count_tables`], rows = kernel, cols = BPF
1045    /// program, cell = the count summarized across topologies) that the
1046    /// dispatcher prints before the PASS/FAIL grid.
1047    pub stats: Vec<ProgStats>,
1048}
1049
1050/// Map a cell's full name to a filesystem-safe record filename: every
1051/// non-alphanumeric byte becomes `_`. The input is the cell's full name
1052/// (`verifier/<sched>/<kernel>/<preset>`, unique per cell), so the
1053/// mapping is unique per cell — a nextest RETRY of the same cell overwrites its
1054/// own prior record, so the FINAL attempt's outcome wins (a cell that
1055/// failed then passed on retry records PASS).
1056fn cell_record_filename(full_name: &str) -> String {
1057    let mut s: String = full_name
1058        .chars()
1059        .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
1060        .collect();
1061    s.push_str(".json");
1062    s
1063}
1064
1065/// Write a cell's PASS/FAIL record into `dir`. Parses `full_name`
1066/// (`verifier/<sched>/<kernel>/<preset>`); a name that does not fit that
1067/// shape is skipped (the cell already errored on the malformed name).
1068/// Best-effort: a write failure is logged and swallowed — the summary
1069/// table is a convenience over the per-cell nextest output, so a lost
1070/// record must never turn an otherwise-passing cell into a failure.
1071///
1072/// `pub(crate)`: the only writer is the cell handler in
1073/// `test_support::dispatch` (same crate); the reader side
1074/// ([`read_cell_records`] / [`render_result_table`]) is `pub` for the
1075/// `cargo-ktstr` binary crate.
1076pub(crate) fn write_cell_record(
1077    dir: &std::path::Path,
1078    full_name: &str,
1079    passed: bool,
1080    stats: &[ProgStats],
1081) {
1082    let Some(rest) = full_name.strip_prefix("verifier/") else {
1083        return;
1084    };
1085    let parts: Vec<&str> = rest.splitn(3, '/').collect();
1086    if parts.len() != 3 {
1087        return;
1088    }
1089    let record = VerifierCellRecord {
1090        scheduler: parts[0].to_string(),
1091        kernel: parts[1].to_string(),
1092        topology: parts[2].to_string(),
1093        passed,
1094        stats: stats.to_vec(),
1095    };
1096    let path = dir.join(cell_record_filename(full_name));
1097    match serde_json::to_vec(&record) {
1098        Ok(bytes) => {
1099            if let Err(e) = std::fs::write(&path, bytes) {
1100                eprintln!(
1101                    "ktstr verifier: warning: could not write result record {}: {e}",
1102                    path.display(),
1103                );
1104            }
1105        }
1106        Err(e) => eprintln!("ktstr verifier: warning: serialize result record: {e}"),
1107    }
1108}
1109
1110/// Read every `*.json` cell record under `dir` (non-recursive). A missing
1111/// dir or an unparseable record is skipped (best-effort). Returns records
1112/// in filesystem-iteration order; [`render_result_table`] sorts for a
1113/// deterministic render.
1114pub fn read_cell_records(dir: &std::path::Path) -> Vec<VerifierCellRecord> {
1115    let Ok(entries) = std::fs::read_dir(dir) else {
1116        return Vec::new();
1117    };
1118    entries
1119        .flatten()
1120        .filter(|e| {
1121            e.path()
1122                .extension()
1123                .is_some_and(|x| x.eq_ignore_ascii_case("json"))
1124        })
1125        .filter_map(|e| std::fs::read(e.path()).ok())
1126        .filter_map(|bytes| serde_json::from_slice::<VerifierCellRecord>(&bytes).ok())
1127        .collect()
1128}
1129
1130/// Decide the `cargo ktstr verifier` run outcome from nextest's exit
1131/// success, whether any per-cell records were produced, and the optional
1132/// `--scheduler` filter.
1133///
1134/// The dispatcher runs nextest with `--no-tests=pass`, so a run that
1135/// selects zero verifier cells exits 0 (success) with empty records
1136/// rather than nextest's generic "no tests to run" (exit 4). That lets
1137/// the dispatcher diagnose the empty case itself instead of surfacing a
1138/// cryptic nextest exit:
1139/// - `--scheduler <NAME>` set + no records: the name is not a declared
1140///   scheduler, or no topology preset fits this host for it.
1141/// - no `--scheduler` + no records: no `declare_scheduler!` is linked into
1142///   a test binary the sweep sees, or every declared scheduler's
1143///   constraints rejected all topology presets on this host.
1144///
1145/// A genuine build/exec failure still fails nextest (exit non-zero, which
1146/// `--no-tests=pass` does not mask), surfaced via the `exit_code` arm.
1147pub fn classify_run_outcome(
1148    success: bool,
1149    records_empty: bool,
1150    scheduler: Option<&str>,
1151    exit_code: Option<i32>,
1152) -> Result<(), String> {
1153    if !success {
1154        let code = exit_code.map_or_else(|| "signal".to_string(), |c| c.to_string());
1155        return Err(format!("cargo nextest run exited with {code}"));
1156    }
1157    if records_empty {
1158        return Err(match scheduler {
1159            Some(name) => format!(
1160                "--scheduler {name:?}: matched no verifier cell — no declared BPF \
1161                 scheduler by that name, or no topology preset fits this host for \
1162                 it. Run `cargo ktstr verifier` with no --scheduler to see the \
1163                 swept set."
1164            ),
1165            None => "no verifier cells ran — no scheduler is declared via \
1166                 declare_scheduler! in a linked test binary, or every declared \
1167                 scheduler's constraints rejected all topology presets on this \
1168                 host."
1169                .to_string(),
1170        });
1171    }
1172    Ok(())
1173}
1174
1175/// Build the `cargo nextest run` argument vector for the verifier sweep.
1176///
1177/// Load-bearing tokens:
1178/// - `--run-ignored all`: verifier cells are emitted ignore-gated, so
1179///   nextest skips them unless opted in.
1180/// - `--no-tests pass`: a zero-cell selection exits 0 (not nextest's
1181///   default exit-4 "no tests to run"), so [`classify_run_outcome`] can
1182///   emit a targeted diagnostic instead of a cryptic nextest exit.
1183/// - `-E 'test(/^verifier/) & !test(/^verifier::/)'`: the `verifier/...`
1184///   cells, excluding the verifier module's own `verifier::tests::*`.
1185///
1186/// `nextest_profile`, if set, becomes nextest's `--profile <NAME>`,
1187/// emitted before `forward` so a forwarded token cannot shadow it.
1188/// `forward` is the user's trailing cargo/nextest args, appended verbatim.
1189pub fn build_nextest_args(nextest_profile: Option<&str>, forward: &[String]) -> Vec<String> {
1190    let mut args = vec![
1191        "nextest".to_string(),
1192        "run".to_string(),
1193        "--run-ignored".to_string(),
1194        "all".to_string(),
1195        "--no-tests".to_string(),
1196        "pass".to_string(),
1197        "-E".to_string(),
1198        "test(/^verifier/) & !test(/^verifier::/)".to_string(),
1199    ];
1200    if let Some(np) = nextest_profile {
1201        args.push("--profile".to_string());
1202        args.push(np.to_string());
1203    }
1204    args.extend(forward.iter().cloned());
1205    args
1206}
1207
1208/// Render the per-cell records into a summary grid: one row per topology
1209/// preset, one column per scheduler, aggregating across kernels. Each
1210/// cell is:
1211/// - ✅ every kernel that ran this (topology, scheduler) passed,
1212/// - ❌ every kernel that ran it failed,
1213/// - 🇽 mixed — at least one kernel passed AND at least one failed,
1214/// - `-` no kernel ran this (topology, scheduler) (e.g. the scheduler's
1215///   constraints rejected the preset).
1216///
1217/// After the grid, the specific failing `(scheduler, kernel, topology)`
1218/// combinations are listed so a ❌ or 🇽 cell can be drilled to the exact
1219/// kernel(s) that failed. Returns `None` for an empty record set (the
1220/// caller prints nothing).
1221///
1222/// Rows, columns, and the failing list are BTreeSet-sorted so the same
1223/// run renders the same output (shell-pipeline stable). The header line
1224/// carries a ✅/❌/🇽 tally counting grid cells.
1225pub fn render_result_table(records: &[VerifierCellRecord]) -> Option<String> {
1226    if records.is_empty() {
1227        return None;
1228    }
1229    use std::collections::{BTreeMap, BTreeSet};
1230    let mut schedulers: BTreeSet<String> = BTreeSet::new(); // columns
1231    let mut rows: BTreeSet<String> = BTreeSet::new(); // topology presets
1232    // (topology, scheduler) -> (passes, fails) aggregated across kernels.
1233    let mut agg: BTreeMap<(String, String), (u32, u32)> = BTreeMap::new();
1234    // Distinct failing (scheduler, kernel, topology) combinations.
1235    let mut failing: BTreeSet<(String, String, String)> = BTreeSet::new();
1236    for r in records {
1237        schedulers.insert(r.scheduler.clone());
1238        rows.insert(r.topology.clone());
1239        let counts = agg
1240            .entry((r.topology.clone(), r.scheduler.clone()))
1241            .or_insert((0, 0));
1242        if r.passed {
1243            counts.0 += 1;
1244        } else {
1245            counts.1 += 1;
1246            failing.insert((r.scheduler.clone(), r.kernel.clone(), r.topology.clone()));
1247        }
1248    }
1249
1250    let (mut n_pass, mut n_fail, mut n_mixed) = (0usize, 0usize, 0usize);
1251    let mut table = crate::cli::new_table();
1252    let mut header: Vec<String> = vec!["topology".to_string()];
1253    for sched in &schedulers {
1254        header.push(sched.clone());
1255    }
1256    table.set_header(header);
1257    for topo in &rows {
1258        let mut line: Vec<String> = vec![topo.clone()];
1259        for sched in &schedulers {
1260            // An entry only exists with >= 1 record, so (_, 0) means all
1261            // passed, (0, _) means all failed, and both-nonzero is mixed.
1262            let text = match agg.get(&(topo.clone(), sched.clone())) {
1263                None => "-",
1264                Some((_, 0)) => {
1265                    n_pass += 1;
1266                    "✅"
1267                }
1268                Some((0, _)) => {
1269                    n_fail += 1;
1270                    "❌"
1271                }
1272                Some(_) => {
1273                    n_mixed += 1;
1274                    "🇽"
1275                }
1276            };
1277            line.push(text.to_string());
1278        }
1279        table.add_row(line);
1280    }
1281
1282    let mut out = format!("\nverifier summary: {n_pass} ✅  {n_fail} ❌  {n_mixed} 🇽\n{table}\n");
1283    if !failing.is_empty() {
1284        out.push_str("\nfailing combinations (scheduler / kernel / topology):\n");
1285        for (sched, kernel, topo) in &failing {
1286            out.push_str(&format!("  {sched} / {kernel} / {topo}\n"));
1287        }
1288    }
1289    Some(out)
1290}
1291
1292/// Render one `verified_insns` table per declared scheduler. Within each
1293/// scheduler's section: rows = kernel version, columns = BPF program, and
1294/// each cell is that program's `verified_insns` for the (scheduler,
1295/// kernel) summarized ACROSS the topologies that ran it — a single number
1296/// when topology-invariant, `lo..hi` when it varies (`-` when that program
1297/// reported no stats on that kernel).
1298///
1299/// `verified_insns` is the verifier's PROCESSED-instruction count
1300/// (`env->insn_processed`) — fixed per load, but NOT topology-invariant
1301/// (a scheduler whose verification path depends on topology-derived
1302/// `.rodata`, e.g. `nr_cpus`, processes a different count per topology).
1303/// So topology is folded into the cell as a range rather than shown as its
1304/// own (usually all-identical) axis; the axes it genuinely varies on — BPF
1305/// program (x) and kernel version (y) — are the table axes, sectioned per
1306/// declared scheduler. Identical-binary declarations are sectioned
1307/// separately on purpose (they are run separately). Returns `None` when no
1308/// record carries any per-program stats (the caller prints nothing).
1309///
1310/// Schedulers, kernels, and programs are BTree-sorted so the same run
1311/// renders the same output (shell-pipeline stable). The range drops which
1312/// topology produced which count; a per-topology breakdown is a separate
1313/// detailed view, not this summary.
1314pub fn render_instruction_count_tables(records: &[VerifierCellRecord]) -> Option<String> {
1315    use std::collections::{BTreeMap, BTreeSet};
1316    // scheduler -> kernel -> program -> (min, max) verified_insns across
1317    // the topologies that ran it. Topology is folded into the (min, max)
1318    // range: a flat scheduler has min == max (one number), a
1319    // topology-sensitive one has min < max (`lo..hi`).
1320    type VerifiedInsnSpans = BTreeMap<String, BTreeMap<String, BTreeMap<String, (u32, u32)>>>;
1321    let mut by_sched: VerifiedInsnSpans = BTreeMap::new();
1322    // scheduler -> the union of program names it reported (the columns).
1323    let mut sched_progs: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
1324    for r in records {
1325        for s in &r.stats {
1326            let span = by_sched
1327                .entry(r.scheduler.clone())
1328                .or_default()
1329                .entry(r.kernel.clone())
1330                .or_default()
1331                .entry(s.name.clone())
1332                .or_insert((s.verified_insns, s.verified_insns));
1333            span.0 = span.0.min(s.verified_insns);
1334            span.1 = span.1.max(s.verified_insns);
1335            sched_progs
1336                .entry(r.scheduler.clone())
1337                .or_default()
1338                .insert(s.name.clone());
1339        }
1340    }
1341    if by_sched.is_empty() {
1342        return None;
1343    }
1344
1345    let mut out = String::from(
1346        "\nverifier verified_insns (per scheduler; rows: kernel, cols: BPF program, \
1347         cell: range across topologies):\n",
1348    );
1349    for (sched, kernels) in &by_sched {
1350        let progs = &sched_progs[sched];
1351        let mut table = crate::cli::new_table();
1352        let mut header: Vec<String> = vec!["kernel".to_string()];
1353        for p in progs {
1354            header.push(p.clone());
1355        }
1356        table.set_header(header);
1357        for (kernel, prog_map) in kernels {
1358            let mut line: Vec<String> = vec![kernel.clone()];
1359            for p in progs {
1360                let text = match prog_map.get(p) {
1361                    Some((lo, hi)) if lo == hi => lo.to_string(),
1362                    Some((lo, hi)) => format!("{lo}..{hi}"),
1363                    None => "-".to_string(),
1364                };
1365                line.push(text);
1366            }
1367            table.add_row(line);
1368        }
1369        out.push_str(&format!("\n{sched}:\n{table}\n"));
1370    }
1371    Some(out)
1372}
1373
1374#[cfg(test)]
1375mod tests {
1376    use super::*;
1377
1378    // -----------------------------------------------------------------------
1379    // per-cell result capture + summary table
1380    // -----------------------------------------------------------------------
1381
1382    /// A malformed cell name is skipped (no record); a well-formed
1383    /// 3-segment cell records (scheduler/kernel/topology); and a nextest
1384    /// RETRY of the same cell overwrites its own prior record so the
1385    /// FINAL outcome wins (fail-then-pass -> PASS).
1386    #[test]
1387    fn cell_record_write_read_roundtrip_and_retry_overwrites() {
1388        let dir = std::env::temp_dir().join(format!("ktstr-verif-rec-{}", std::process::id()));
1389        std::fs::create_dir_all(&dir).expect("mk temp dir");
1390        // Malformed: no verifier/ prefix, and a 2-segment name (no
1391        // <preset> after the kernel) — both skipped.
1392        write_cell_record(&dir, "not_a_cell", true, &[]);
1393        write_cell_record(&dir, "verifier/only/two", true, &[]);
1394        // Well-formed cell: fail, then a retry passes -> overwrites.
1395        let name = "verifier/scx_a/kernel_6_14/tiny-1llc";
1396        write_cell_record(&dir, name, false, &[]);
1397        // The retry passes and carries per-program verified_insns, so the
1398        // final record has both the PASS outcome and the stats.
1399        let stats = [
1400            ProgStats {
1401                name: "ktstr_dispatch".into(),
1402                verified_insns: 321,
1403            },
1404            ProgStats {
1405                name: "ktstr_enqueue".into(),
1406                verified_insns: 123,
1407            },
1408        ];
1409        write_cell_record(&dir, name, true, &stats);
1410        let recs = read_cell_records(&dir);
1411        assert_eq!(
1412            recs.len(),
1413            1,
1414            "malformed names skipped; the retry overwrote its own record (one file): {recs:?}",
1415        );
1416        assert_eq!(recs[0].scheduler, "scx_a");
1417        assert_eq!(recs[0].kernel, "kernel_6_14");
1418        assert_eq!(recs[0].topology, "tiny-1llc");
1419        assert!(
1420            recs[0].passed,
1421            "final retry outcome (PASS) wins over the earlier FAIL"
1422        );
1423        // Per-program verified_insns survive the JSON roundtrip and reflect
1424        // the final (retry) write, not the earlier stat-less fail.
1425        assert_eq!(recs[0].stats, stats, "stats roundtrip via serde");
1426        std::fs::remove_dir_all(&dir).ok();
1427    }
1428
1429    /// The summary grid aggregates across kernels per (topology,
1430    /// scheduler): all-pass -> ✅, all-fail -> ❌, with a ✅/❌/🇽 tally.
1431    /// Failing (scheduler, kernel, topology) combinations are listed after
1432    /// the grid; an empty record set renders nothing.
1433    #[test]
1434    fn render_result_table_matrix_tally_and_empty() {
1435        let recs = vec![
1436            VerifierCellRecord {
1437                scheduler: "scx_a".into(),
1438                kernel: "kernel_6_14".into(),
1439                topology: "tiny-1llc".into(),
1440                passed: true,
1441                stats: vec![],
1442            },
1443            VerifierCellRecord {
1444                scheduler: "scx_a".into(),
1445                kernel: "kernel_6_14".into(),
1446                topology: "large-4llc".into(),
1447                passed: false,
1448                stats: vec![],
1449            },
1450        ];
1451        let out = render_result_table(&recs).expect("non-empty records -> Some");
1452        assert!(
1453            out.contains("verifier summary: 1 ✅  1 ❌  0 🇽"),
1454            "tally: {out}"
1455        );
1456        // Columns are scheduler-only (kernels fold into the cell), so no
1457        // `scheduler @ kernel` labeling appears.
1458        assert!(
1459            out.contains("scx_a") && !out.contains(" @ "),
1460            "columns: {out}"
1461        );
1462        // The emoji must render in the GRID CELLS, not merely the tally
1463        // line: locate each topology's row and assert its cell glyph
1464        // (neither topology name appears on the `verifier summary:` line).
1465        let pass_row = out
1466            .lines()
1467            .find(|l| l.contains("tiny-1llc"))
1468            .expect("tiny-1llc row present");
1469        assert!(
1470            pass_row.contains('✅'),
1471            "all-pass cell renders ✅ in the grid row: {pass_row}"
1472        );
1473        let fail_row = out
1474            .lines()
1475            .find(|l| l.contains("large-4llc"))
1476            .expect("large-4llc row present");
1477        assert!(
1478            fail_row.contains('❌'),
1479            "all-fail cell renders ❌ in the grid row: {fail_row}"
1480        );
1481        // The single failure is listed after the grid.
1482        assert!(
1483            out.contains("failing combinations (scheduler / kernel / topology):")
1484                && out.contains("scx_a / kernel_6_14 / large-4llc"),
1485            "failing combinations listed: {out}"
1486        );
1487        assert!(render_result_table(&[]).is_none(), "empty -> None");
1488    }
1489
1490    /// A (topology, scheduler) where one kernel passes and another fails
1491    /// renders 🇽 (mixed); an all-pass (topology, scheduler) across kernels
1492    /// renders ✅. Only the failing kernel appears in the failing list.
1493    #[test]
1494    fn render_result_table_mixed_kernels_blue_x() {
1495        let recs = vec![
1496            // tiny-1llc / scx_a: 6_14 passes, 6_15 fails -> mixed -> 🇽.
1497            VerifierCellRecord {
1498                scheduler: "scx_a".into(),
1499                kernel: "kernel_6_14".into(),
1500                topology: "tiny-1llc".into(),
1501                passed: true,
1502                stats: vec![],
1503            },
1504            VerifierCellRecord {
1505                scheduler: "scx_a".into(),
1506                kernel: "kernel_6_15".into(),
1507                topology: "tiny-1llc".into(),
1508                passed: false,
1509                stats: vec![],
1510            },
1511            // smt-2llc / scx_a: both kernels pass -> ✅.
1512            VerifierCellRecord {
1513                scheduler: "scx_a".into(),
1514                kernel: "kernel_6_14".into(),
1515                topology: "smt-2llc".into(),
1516                passed: true,
1517                stats: vec![],
1518            },
1519            VerifierCellRecord {
1520                scheduler: "scx_a".into(),
1521                kernel: "kernel_6_15".into(),
1522                topology: "smt-2llc".into(),
1523                passed: true,
1524                stats: vec![],
1525            },
1526        ];
1527        let out = render_result_table(&recs).expect("Some");
1528        assert!(
1529            out.contains("verifier summary: 1 ✅  0 ❌  1 🇽"),
1530            "tally counts one all-pass + one mixed cell: {out}"
1531        );
1532        let mixed_row = out
1533            .lines()
1534            .find(|l| l.contains("tiny-1llc"))
1535            .expect("tiny-1llc row present");
1536        assert!(
1537            mixed_row.contains('🇽'),
1538            "mixed (some pass, some fail) cell renders 🇽: {mixed_row}"
1539        );
1540        let pass_row = out
1541            .lines()
1542            .find(|l| l.contains("smt-2llc"))
1543            .expect("smt-2llc row present");
1544        assert!(
1545            pass_row.contains('✅'),
1546            "all-kernels-pass cell renders ✅: {pass_row}"
1547        );
1548        // Only the failing kernel is listed after the grid.
1549        assert!(
1550            out.contains("scx_a / kernel_6_15 / tiny-1llc"),
1551            "the failing kernel is listed: {out}"
1552        );
1553        assert!(
1554            !out.contains("kernel_6_14 / tiny-1llc"),
1555            "the passing kernel on the mixed topology is not listed: {out}"
1556        );
1557        assert!(
1558            !out.contains("/ smt-2llc"),
1559            "the all-pass topology contributes no failing combination: {out}"
1560        );
1561    }
1562
1563    /// Per-scheduler verified_insns tables: one section per declared
1564    /// scheduler; within it rows = kernel version, columns = BPF program,
1565    /// each cell that program's verified_insns across the topologies that
1566    /// ran it — a single number when topology-invariant, `lo..hi` when it
1567    /// varies. A (kernel, program) that reported no stats shows `-`; an
1568    /// empty record set renders nothing.
1569    #[test]
1570    fn instruction_count_tables_per_scheduler_kernel_program_range() {
1571        let recs = vec![
1572            // scx_a / kernel_6_14 on two topologies with IDENTICAL counts
1573            // -> the cell collapses to a single number (topology-flat).
1574            VerifierCellRecord {
1575                scheduler: "scx_a".into(),
1576                kernel: "kernel_6_14".into(),
1577                topology: "tiny".into(),
1578                passed: true,
1579                stats: vec![
1580                    ProgStats {
1581                        name: "ktstr_dispatch".into(),
1582                        verified_insns: 128,
1583                    },
1584                    ProgStats {
1585                        name: "ktstr_enqueue".into(),
1586                        verified_insns: 64,
1587                    },
1588                ],
1589            },
1590            VerifierCellRecord {
1591                scheduler: "scx_a".into(),
1592                kernel: "kernel_6_14".into(),
1593                topology: "large".into(),
1594                passed: true,
1595                stats: vec![
1596                    ProgStats {
1597                        name: "ktstr_dispatch".into(),
1598                        verified_insns: 128,
1599                    },
1600                    ProgStats {
1601                        name: "ktstr_enqueue".into(),
1602                        verified_insns: 64,
1603                    },
1604                ],
1605            },
1606            // scx_a / kernel_6_15: ktstr_dispatch DIFFERS across topologies
1607            // -> `lo..hi` range; ktstr_enqueue is absent on this kernel
1608            // -> `-` in that column's kernel_6_15 row.
1609            VerifierCellRecord {
1610                scheduler: "scx_a".into(),
1611                kernel: "kernel_6_15".into(),
1612                topology: "tiny".into(),
1613                passed: true,
1614                stats: vec![ProgStats {
1615                    name: "ktstr_dispatch".into(),
1616                    verified_insns: 130,
1617                }],
1618            },
1619            VerifierCellRecord {
1620                scheduler: "scx_a".into(),
1621                kernel: "kernel_6_15".into(),
1622                topology: "large".into(),
1623                passed: true,
1624                stats: vec![ProgStats {
1625                    name: "ktstr_dispatch".into(),
1626                    verified_insns: 150,
1627                }],
1628            },
1629            // scx_b: a separate section (its own declaration).
1630            VerifierCellRecord {
1631                scheduler: "scx_b".into(),
1632                kernel: "kernel_6_14".into(),
1633                topology: "tiny".into(),
1634                passed: true,
1635                stats: vec![ProgStats {
1636                    name: "ktstr_dispatch".into(),
1637                    verified_insns: 200,
1638                }],
1639            },
1640        ];
1641        let out = render_instruction_count_tables(&recs).expect("stats present -> Some");
1642        // One section per declared scheduler.
1643        assert!(
1644            out.contains("scx_a:") && out.contains("scx_b:"),
1645            "one section per declared scheduler: {out}"
1646        );
1647        // Columns = BPF programs; rows = kernel version.
1648        assert!(
1649            out.contains("ktstr_dispatch") && out.contains("ktstr_enqueue"),
1650            "BPF-program columns: {out}"
1651        );
1652        assert!(
1653            out.contains("kernel_6_14") && out.contains("kernel_6_15"),
1654            "kernel-version rows: {out}"
1655        );
1656        // Topology folded into the cell as a range: flat -> "128",
1657        // varies across topologies -> "130..150".
1658        assert!(
1659            out.contains("128"),
1660            "topology-flat cell is a single number: {out}"
1661        );
1662        assert!(
1663            out.contains("130..150"),
1664            "topology-varying cell is a lo..hi range: {out}"
1665        );
1666        assert!(
1667            out.contains("64") && out.contains("200"),
1668            "other counts render: {out}"
1669        );
1670        // ktstr_enqueue reported no stats on kernel_6_15 -> `-`.
1671        assert!(
1672            out.contains('-'),
1673            "a (kernel, program) with no stats renders '-': {out}"
1674        );
1675        // Topology is NOT a table axis (folded into the range), so no
1676        // topology label appears in the output.
1677        assert!(
1678            !out.contains("tiny") && !out.contains("large"),
1679            "topology is not a table axis: {out}"
1680        );
1681
1682        // No record carries stats -> nothing to render.
1683        let bare = vec![VerifierCellRecord {
1684            scheduler: "scx_a".into(),
1685            kernel: "kernel_6_14".into(),
1686            topology: "tiny".into(),
1687            passed: false,
1688            stats: vec![],
1689        }];
1690        assert!(
1691            render_instruction_count_tables(&bare).is_none(),
1692            "no stats -> None"
1693        );
1694    }
1695
1696    /// classify_run_outcome: a build/exec failure surfaces nextest's exit;
1697    /// a successful-but-empty run is diagnosed by the dispatcher (friendly
1698    /// no-such-scheduler message when --scheduler was set, no-cells-ran
1699    /// message otherwise); a successful run with records is Ok.
1700    #[test]
1701    fn classify_run_outcome_cases() {
1702        // Records present + success -> Ok regardless of --scheduler.
1703        assert!(classify_run_outcome(true, false, None, Some(0)).is_ok());
1704        assert!(classify_run_outcome(true, false, Some("ktstr_sched"), Some(0)).is_ok());
1705
1706        // Success + empty + --scheduler -> friendly "no such scheduler".
1707        // Reachable ONLY because --no-tests=pass turns a 0-cell match into
1708        // exit 0; under the old `auto` default a 0-match exited 4 -> the
1709        // failure arm, leaving this message dead.
1710        let e = classify_run_outcome(true, true, Some("nope"), Some(0)).unwrap_err();
1711        assert!(
1712            e.contains("--scheduler \"nope\"") && e.contains("matched no verifier cell"),
1713            "scheduler-empty diagnostic: {e}"
1714        );
1715
1716        // Success + empty + no --scheduler -> "no cells ran" diagnosis
1717        // (must NOT silently succeed under --no-tests=pass).
1718        let e = classify_run_outcome(true, true, None, Some(0)).unwrap_err();
1719        assert!(
1720            e.contains("no verifier cells ran") && e.contains("declare_scheduler!"),
1721            "no-cells diagnostic: {e}"
1722        );
1723
1724        // Failure surfaces nextest's exit code; a signal (no code) renders
1725        // as "signal".
1726        assert_eq!(
1727            classify_run_outcome(false, true, None, Some(4)).unwrap_err(),
1728            "cargo nextest run exited with 4"
1729        );
1730        assert_eq!(
1731            classify_run_outcome(false, false, Some("x"), None).unwrap_err(),
1732            "cargo nextest run exited with signal"
1733        );
1734    }
1735
1736    /// build_nextest_args carries the flags that make the friendly
1737    /// diagnostic reachable: `--run-ignored all` (cells are ignore-gated)
1738    /// and `--no-tests pass` (a 0-cell selection exits 0 so
1739    /// classify_run_outcome runs instead of nextest's exit-4). Guards
1740    /// against a future edit silently dropping either, plus the
1741    /// profile-before-forwarded-args ordering.
1742    #[test]
1743    fn build_nextest_args_carries_load_bearing_flags() {
1744        let args = build_nextest_args(None, &[]);
1745        let ri = args
1746            .iter()
1747            .position(|a| a == "--run-ignored")
1748            .expect("--run-ignored present");
1749        assert_eq!(args[ri + 1], "all", "--run-ignored all");
1750        let nt = args
1751            .iter()
1752            .position(|a| a == "--no-tests")
1753            .expect("--no-tests present");
1754        assert_eq!(args[nt + 1], "pass", "--no-tests pass");
1755        assert!(
1756            args.iter()
1757                .any(|a| a == "test(/^verifier/) & !test(/^verifier::/)"),
1758            "verifier-cell filter present: {args:?}"
1759        );
1760
1761        // --profile <NAME> is emitted before forwarded args so a forwarded
1762        // token cannot shadow it.
1763        let args = build_nextest_args(Some("ci"), &["--features".to_string(), "wprof".to_string()]);
1764        let p = args
1765            .iter()
1766            .position(|a| a == "--profile")
1767            .expect("--profile present");
1768        assert_eq!(args[p + 1], "ci");
1769        let f = args
1770            .iter()
1771            .position(|a| a == "--features")
1772            .expect("forwarded --features present");
1773        assert!(p < f, "profile emitted before forwarded args: {args:?}");
1774    }
1775
1776    // -----------------------------------------------------------------------
1777    // scheduler attach verdict
1778    // -----------------------------------------------------------------------
1779
1780    /// attach_outcome_from_messages positive-confirmation rule:
1781    /// Died > NotAttached > PayloadStarting(=Attached) > Unconfirmed.
1782    /// Absence of a positive PayloadStarting frame is Unconfirmed (FAIL),
1783    /// NOT a blind pass — this is what catches an early guest panic that
1784    /// reboots emitting no frame. Corrupt / empty / non-LIFECYCLE /
1785    /// unknown frames are skipped.
1786    #[test]
1787    fn attach_outcome_from_lifecycle_frames() {
1788        use crate::vmm::host_comms::BulkDrainResult;
1789        use crate::vmm::wire::{LifecyclePhase, MSG_TYPE_LIFECYCLE, ShmEntry};
1790
1791        let frame = |phase: LifecyclePhase, reason: &str| -> ShmEntry {
1792            let mut payload = vec![phase.wire_value()];
1793            payload.extend_from_slice(reason.as_bytes());
1794            ShmEntry {
1795                msg_type: MSG_TYPE_LIFECYCLE,
1796                payload,
1797                crc_ok: true,
1798            }
1799        };
1800        let drain = |entries: Vec<ShmEntry>| BulkDrainResult { entries };
1801
1802        // No frames at all -> Unconfirmed (guest vanished before any
1803        // phase; e.g. an early kernel panic that reboots via panic=-1).
1804        assert_eq!(
1805            attach_outcome_from_messages(None),
1806            AttachOutcome::Unconfirmed,
1807        );
1808
1809        // Reached init but NOT the dispatch phase -> Unconfirmed.
1810        let init_only = drain(vec![frame(LifecyclePhase::InitStarted, "")]);
1811        assert_eq!(
1812            attach_outcome_from_messages(Some(&init_only)),
1813            AttachOutcome::Unconfirmed,
1814        );
1815
1816        // Reached the dispatch phase (PayloadStarting), no failure -> Attached.
1817        let progress = drain(vec![
1818            frame(LifecyclePhase::InitStarted, ""),
1819            frame(LifecyclePhase::PayloadStarting, ""),
1820        ]);
1821        assert_eq!(
1822            attach_outcome_from_messages(Some(&progress)),
1823            AttachOutcome::Attached,
1824        );
1825
1826        // SchedulerNotAttached carries its reason suffix verbatim.
1827        let not_attached = drain(vec![frame(LifecyclePhase::SchedulerNotAttached, "timeout")]);
1828        assert_eq!(
1829            attach_outcome_from_messages(Some(&not_attached)),
1830            AttachOutcome::NotAttached("timeout".to_string()),
1831        );
1832
1833        // A failure frame wins over a (defensively) co-present
1834        // PayloadStarting.
1835        let fail_beats_positive = drain(vec![
1836            frame(LifecyclePhase::PayloadStarting, ""),
1837            frame(LifecyclePhase::SchedulerNotAttached, "sysfs absent"),
1838        ]);
1839        assert_eq!(
1840            attach_outcome_from_messages(Some(&fail_beats_positive)),
1841            AttachOutcome::NotAttached("sysfs absent".to_string()),
1842        );
1843
1844        // SchedulerDied wins over NotAttached, in BOTH orders.
1845        for entries in [
1846            vec![
1847                frame(LifecyclePhase::SchedulerNotAttached, "timeout"),
1848                frame(LifecyclePhase::SchedulerDied, ""),
1849            ],
1850            vec![
1851                frame(LifecyclePhase::SchedulerDied, ""),
1852                frame(LifecyclePhase::SchedulerNotAttached, "timeout"),
1853            ],
1854        ] {
1855            let d = drain(entries);
1856            assert_eq!(attach_outcome_from_messages(Some(&d)), AttachOutcome::Died);
1857        }
1858
1859        // Died wins even over a PayloadStarting.
1860        let died_beats_positive = drain(vec![
1861            frame(LifecyclePhase::PayloadStarting, ""),
1862            frame(LifecyclePhase::SchedulerDied, ""),
1863        ]);
1864        assert_eq!(
1865            attach_outcome_from_messages(Some(&died_beats_positive)),
1866            AttachOutcome::Died,
1867        );
1868
1869        // Skipped frames (corrupt crc / empty payload / non-LIFECYCLE /
1870        // unknown discriminant) must NOT suppress a real PayloadStarting:
1871        // pairing each with a valid PayloadStarting must still resolve
1872        // Attached — proving the frame was skipped, not acted on (a
1873        // corrupt/non-LIFECYCLE Died byte would otherwise force Died).
1874        let corrupt_died = ShmEntry {
1875            msg_type: MSG_TYPE_LIFECYCLE,
1876            payload: vec![LifecyclePhase::SchedulerDied.wire_value()],
1877            crc_ok: false,
1878        };
1879        let empty = ShmEntry {
1880            msg_type: MSG_TYPE_LIFECYCLE,
1881            payload: Vec::new(),
1882            crc_ok: true,
1883        };
1884        let non_lifecycle_died = ShmEntry {
1885            msg_type: MSG_TYPE_LIFECYCLE + 1,
1886            payload: vec![LifecyclePhase::SchedulerDied.wire_value()],
1887            crc_ok: true,
1888        };
1889        let unknown_phase = ShmEntry {
1890            msg_type: MSG_TYPE_LIFECYCLE,
1891            payload: vec![250],
1892            crc_ok: true,
1893        };
1894        for skipped in [corrupt_died, empty, non_lifecycle_died, unknown_phase] {
1895            let d = drain(vec![skipped, frame(LifecyclePhase::PayloadStarting, "")]);
1896            assert_eq!(
1897                attach_outcome_from_messages(Some(&d)),
1898                AttachOutcome::Attached,
1899                "a skipped frame must not suppress a valid PayloadStarting",
1900            );
1901        }
1902    }
1903
1904    /// dispatch_confirmed_from_messages: true only when a crc-ok,
1905    /// non-empty WorkloadDispatched frame is present; false for None / no
1906    /// such frame / corrupt / empty / non-LIFECYCLE.
1907    #[test]
1908    fn dispatch_confirmed_from_lifecycle_frames() {
1909        use crate::vmm::host_comms::BulkDrainResult;
1910        use crate::vmm::wire::{LifecyclePhase, MSG_TYPE_LIFECYCLE, ShmEntry};
1911
1912        let frame = |phase: LifecyclePhase| -> ShmEntry {
1913            ShmEntry {
1914                msg_type: MSG_TYPE_LIFECYCLE,
1915                payload: vec![phase.wire_value()],
1916                crc_ok: true,
1917            }
1918        };
1919        let drain = |entries: Vec<ShmEntry>| BulkDrainResult { entries };
1920
1921        // No frames at all -> false.
1922        assert!(!dispatch_confirmed_from_messages(None));
1923
1924        // PayloadStarting but no WorkloadDispatched -> false (attached,
1925        // never dispatched).
1926        let attached_only = drain(vec![frame(LifecyclePhase::PayloadStarting)]);
1927        assert!(!dispatch_confirmed_from_messages(Some(&attached_only)));
1928
1929        // WorkloadDispatched present -> true.
1930        let dispatched = drain(vec![
1931            frame(LifecyclePhase::PayloadStarting),
1932            frame(LifecyclePhase::WorkloadDispatched),
1933        ]);
1934        assert!(dispatch_confirmed_from_messages(Some(&dispatched)));
1935
1936        // Corrupt crc / empty payload / non-LIFECYCLE WorkloadDispatched
1937        // frames are skipped and must not confirm dispatch.
1938        let corrupt = ShmEntry {
1939            msg_type: MSG_TYPE_LIFECYCLE,
1940            payload: vec![LifecyclePhase::WorkloadDispatched.wire_value()],
1941            crc_ok: false,
1942        };
1943        let empty = ShmEntry {
1944            msg_type: MSG_TYPE_LIFECYCLE,
1945            payload: Vec::new(),
1946            crc_ok: true,
1947        };
1948        let non_lifecycle = ShmEntry {
1949            msg_type: MSG_TYPE_LIFECYCLE + 1,
1950            payload: vec![LifecyclePhase::WorkloadDispatched.wire_value()],
1951            crc_ok: true,
1952        };
1953        for skipped in [corrupt, empty, non_lifecycle] {
1954            let d = drain(vec![skipped]);
1955            assert!(
1956                !dispatch_confirmed_from_messages(Some(&d)),
1957                "a corrupt/empty/non-LIFECYCLE frame must not confirm dispatch",
1958            );
1959        }
1960    }
1961
1962    /// VerifierVmResult::cell_verdict gate order + messages: timed_out >
1963    /// attach failure > dispatch failure > PASS.
1964    #[test]
1965    fn cell_verdict_gate_order_and_messages() {
1966        let base = |attach: AttachOutcome, dispatched: bool, timed_out: bool| VerifierVmResult {
1967            stats: Vec::new(),
1968            scheduler_log: String::new(),
1969            attach,
1970            dispatched,
1971            timed_out,
1972        };
1973
1974        // Verified + attached + dispatched, no hang -> PASS.
1975        assert_eq!(
1976            base(AttachOutcome::Attached, true, false).cell_verdict(),
1977            Ok(()),
1978        );
1979
1980        // Attached but 0-dispatch -> FAIL naming the dispatch gate.
1981        let no_dispatch = base(AttachOutcome::Attached, false, false).cell_verdict();
1982        assert!(
1983            no_dispatch
1984                .as_ref()
1985                .unwrap_err()
1986                .contains("did not dispatch"),
1987            "dispatch gate must name the failure: {no_dispatch:?}",
1988        );
1989
1990        // Attach failure -> FAIL naming attach, even if dispatched is
1991        // (defensively) true.
1992        let attach_fail = base(AttachOutcome::Died, true, false).cell_verdict();
1993        assert!(
1994            attach_fail
1995                .as_ref()
1996                .unwrap_err()
1997                .contains("did not turn on"),
1998            "attach gate must win over dispatch: {attach_fail:?}",
1999        );
2000
2001        // timed_out wins over everything, even a clean attach + dispatch.
2002        let hung = base(AttachOutcome::Attached, true, true).cell_verdict();
2003        assert!(
2004            hung.as_ref().unwrap_err().contains("timed out"),
2005            "timed_out must win: {hung:?}",
2006        );
2007
2008        // Attach failure outranks a co-present dispatch failure (root
2009        // cause reported first).
2010        let both = base(AttachOutcome::Died, false, false).cell_verdict();
2011        assert!(
2012            both.as_ref().unwrap_err().contains("did not turn on"),
2013            "attach failure reported before dispatch failure: {both:?}",
2014        );
2015    }
2016
2017    /// AttachOutcome::failure_reason surfaces (None when attached, the
2018    /// distinct Died / NotAttached reasons otherwise).
2019    #[test]
2020    fn attach_outcome_failure_reason() {
2021        assert_eq!(AttachOutcome::Attached.failure_reason(), None);
2022        assert!(
2023            AttachOutcome::Died
2024                .failure_reason()
2025                .unwrap()
2026                .contains("exited during BPF load"),
2027        );
2028        assert!(
2029            AttachOutcome::NotAttached(String::new())
2030                .failure_reason()
2031                .unwrap()
2032                .contains("never reached sched_ext 'enabled'"),
2033        );
2034        assert_eq!(
2035            AttachOutcome::NotAttached("sysfs absent".to_string()).failure_reason(),
2036            Some("scheduler never reached sched_ext 'enabled': sysfs absent".to_string()),
2037        );
2038        assert!(
2039            AttachOutcome::Unconfirmed
2040                .failure_reason()
2041                .unwrap()
2042                .contains("attach unconfirmed"),
2043        );
2044    }
2045
2046    /// A timed-out run shows UNKNOWN (not attached), overriding the
2047    /// attach line — catches a post-attach teardown hang (the frame scan
2048    /// + Unconfirmed already handle a guest that vanishes before attach).
2049    #[test]
2050    fn format_verifier_output_timed_out_shows_unknown() {
2051        let result = VerifierVmResult {
2052            stats: Vec::new(),
2053            scheduler_log: String::new(),
2054            attach: AttachOutcome::Attached,
2055            dispatched: false,
2056            timed_out: true,
2057        };
2058        let out = format_verifier_output("verifier", &result, false);
2059        assert!(
2060            out.contains("scheduler: UNKNOWN — VM timed out"),
2061            "timed-out run must show UNKNOWN: {out}",
2062        );
2063        assert!(
2064            !out.contains("scheduler: attached"),
2065            "timed-out run must not claim attached: {out}",
2066        );
2067    }
2068
2069    /// An attached-but-not-dispatched run shows the attach line AND a
2070    /// "dispatch: NOT CONFIRMED" line — the signal that the scheduler
2071    /// turned on but never dispatched the injected workload.
2072    /// Guards the `format_verifier_output` dispatched==false render branch,
2073    /// which the snapshot tests (dispatched==true / Died) do not reach.
2074    #[test]
2075    fn format_verifier_output_attached_not_dispatched_shows_not_confirmed() {
2076        let result = VerifierVmResult {
2077            stats: Vec::new(),
2078            scheduler_log: String::new(),
2079            attach: AttachOutcome::Attached,
2080            dispatched: false,
2081            timed_out: false,
2082        };
2083        let out = format_verifier_output("verifier", &result, false);
2084        assert!(
2085            out.contains("scheduler: attached"),
2086            "attached run must show the attach line: {out}",
2087        );
2088        assert!(
2089            out.contains("dispatch: NOT CONFIRMED"),
2090            "attached-but-not-dispatched must render the NOT CONFIRMED signal: {out}",
2091        );
2092    }
2093
2094    // -----------------------------------------------------------------------
2095    // parse_verifier_stats
2096    // -----------------------------------------------------------------------
2097
2098    #[test]
2099    fn parse_verifier_stats_full_line() {
2100        let log = "processed 1234 insns (limit 1000000) max_states_per_insn 5 total_states 200 peak_states 50 mark_read 10\nverification time 42 usec\nstack depth 32+0\n";
2101        let vs = parse_verifier_stats(log);
2102        assert_eq!(vs.processed_insns, 1234);
2103        assert_eq!(vs.total_states, 200);
2104        assert_eq!(vs.peak_states, 50);
2105        assert_eq!(vs.time_usec, Some(42));
2106        assert_eq!(vs.stack_depth.as_deref(), Some("32+0"));
2107    }
2108
2109    #[test]
2110    fn parse_verifier_stats_insns_only() {
2111        let log = "processed 500 insns (limit 1000000) max_states_per_insn 1 total_states 10 peak_states 3 mark_read 0\n";
2112        let vs = parse_verifier_stats(log);
2113        assert_eq!(vs.processed_insns, 500);
2114        assert_eq!(vs.total_states, 10);
2115        assert_eq!(vs.peak_states, 3);
2116        assert!(vs.time_usec.is_none());
2117        assert!(vs.stack_depth.is_none());
2118    }
2119
2120    #[test]
2121    fn parse_verifier_stats_empty() {
2122        let vs = parse_verifier_stats("");
2123        assert_eq!(vs.processed_insns, 0);
2124        assert_eq!(vs.total_states, 0);
2125        assert_eq!(vs.peak_states, 0);
2126        assert!(vs.time_usec.is_none());
2127        assert!(vs.stack_depth.is_none());
2128    }
2129
2130    #[test]
2131    fn parse_verifier_stats_garbage_lines() {
2132        let log = "some random output\nnot a stats line\n";
2133        let vs = parse_verifier_stats(log);
2134        assert_eq!(vs.processed_insns, 0);
2135        assert_eq!(vs.total_states, 0);
2136        assert!(vs.time_usec.is_none());
2137    }
2138
2139    #[test]
2140    fn parse_verifier_stats_time_without_insns() {
2141        let log = "verification time 100 usec\nstack depth 64\n";
2142        let vs = parse_verifier_stats(log);
2143        assert_eq!(vs.processed_insns, 0);
2144        assert_eq!(vs.time_usec, Some(100));
2145        assert_eq!(vs.stack_depth.as_deref(), Some("64"));
2146    }
2147
2148    #[test]
2149    fn parse_verifier_stats_multi_subprogram_stack() {
2150        let log = "processed 42 insns (limit 1000000) max_states_per_insn 1 total_states 5 peak_states 2 mark_read 0\nstack depth 32+16+8\n";
2151        let vs = parse_verifier_stats(log);
2152        assert_eq!(vs.processed_insns, 42);
2153        assert_eq!(vs.stack_depth.as_deref(), Some("32+16+8"));
2154    }
2155
2156    #[test]
2157    fn parse_verifier_stats_noise_between_lines() {
2158        let log = "\
2159libbpf: loading something
2160processed 999 insns (limit 1000000) max_states_per_insn 3 total_states 77 peak_states 20 mark_read 5
2161libbpf: prog 'dispatch': attached
2162verification time 7 usec
2163stack depth 48+0
2164";
2165        let vs = parse_verifier_stats(log);
2166        assert_eq!(vs.processed_insns, 999);
2167        assert_eq!(vs.total_states, 77);
2168        assert_eq!(vs.peak_states, 20);
2169        assert_eq!(vs.time_usec, Some(7));
2170        assert_eq!(vs.stack_depth.as_deref(), Some("48+0"));
2171    }
2172
2173    #[test]
2174    fn parse_verifier_stats_partial_insns_line() {
2175        let log = "processed 123\n";
2176        let vs = parse_verifier_stats(log);
2177        assert_eq!(vs.processed_insns, 123);
2178        assert_eq!(vs.total_states, 0);
2179        assert_eq!(vs.peak_states, 0);
2180    }
2181
2182    #[test]
2183    fn parse_verifier_stats_only_stack_depth() {
2184        let log = "stack depth 128\n";
2185        let vs = parse_verifier_stats(log);
2186        assert_eq!(vs.stack_depth.as_deref(), Some("128"));
2187        assert_eq!(vs.processed_insns, 0);
2188    }
2189
2190    #[test]
2191    fn parse_verifier_stats_zero_insns() {
2192        let log = "processed 0 insns (limit 1000000) max_states_per_insn 0 total_states 0 peak_states 0 mark_read 0\n";
2193        let vs = parse_verifier_stats(log);
2194        assert_eq!(vs.processed_insns, 0);
2195        assert_eq!(vs.total_states, 0);
2196        assert_eq!(vs.peak_states, 0);
2197    }
2198
2199    #[test]
2200    fn parse_verifier_stats_large_values() {
2201        let log = "processed 999999 insns (limit 1000000) max_states_per_insn 100 total_states 50000 peak_states 12345 mark_read 9999\nverification time 123456 usec\n";
2202        let vs = parse_verifier_stats(log);
2203        assert_eq!(vs.processed_insns, 999999);
2204        assert_eq!(vs.total_states, 50000);
2205        assert_eq!(vs.peak_states, 12345);
2206        assert_eq!(vs.time_usec, Some(123456));
2207    }
2208
2209    #[test]
2210    fn parse_verifier_stats_stack_depth_single() {
2211        let log = "stack depth 64\n";
2212        let vs = parse_verifier_stats(log);
2213        assert_eq!(vs.stack_depth.as_deref(), Some("64"));
2214    }
2215
2216    #[test]
2217    fn parse_verifier_stats_stack_depth_many_subprograms() {
2218        let log = "stack depth 32+16+8+0+0\n";
2219        let vs = parse_verifier_stats(log);
2220        assert_eq!(vs.stack_depth.as_deref(), Some("32+16+8+0+0"));
2221    }
2222
2223    #[test]
2224    fn parse_verifier_stats_multiple_processed_lines_takes_last() {
2225        let log = "processed 100 insns (limit 1000000) max_states_per_insn 1 total_states 5 peak_states 2 mark_read 0\nprocessed 200 insns (limit 1000000) max_states_per_insn 2 total_states 10 peak_states 4 mark_read 0\n";
2226        let vs = parse_verifier_stats(log);
2227        assert_eq!(vs.processed_insns, 200);
2228        assert_eq!(vs.total_states, 10);
2229    }
2230
2231    #[test]
2232    fn parse_verifier_stats_complexity_error_with_stats() {
2233        let log = "\
2234func#0 @0
22350: R1=ctx() R10=fp0
22361: (bf) r6 = r1                       ; R1=ctx() R6_w=ctx()
2237back-edge from insn 42 to 10
2238BPF program is too complex
2239processed 131071 insns (limit 131072) max_states_per_insn 12 total_states 9999 peak_states 5000 mark_read 800
2240verification time 250000 usec
2241stack depth 96+32
2242";
2243        let vs = parse_verifier_stats(log);
2244        assert_eq!(vs.processed_insns, 131071);
2245        assert_eq!(vs.total_states, 9999);
2246        assert_eq!(vs.peak_states, 5000);
2247        assert_eq!(vs.time_usec, Some(250000));
2248        assert_eq!(vs.stack_depth.as_deref(), Some("96+32"));
2249    }
2250
2251    #[test]
2252    fn parse_verifier_stats_complexity_error_no_stats() {
2253        let log = "\
2254func#0 @0
22550: R1=ctx() R10=fp0
2256R1 type=ctx expected=fp
2257";
2258        let vs = parse_verifier_stats(log);
2259        assert_eq!(vs.processed_insns, 0);
2260        assert_eq!(vs.total_states, 0);
2261        assert!(vs.time_usec.is_none());
2262        assert!(vs.stack_depth.is_none());
2263    }
2264
2265    #[test]
2266    fn parse_verifier_stats_loop_warning_with_stats() {
2267        let log = "\
2268infinite loop detected at insn 15
2269back-edge from insn 30 to 15
2270processed 500 insns (limit 1000000) max_states_per_insn 3 total_states 40 peak_states 15 mark_read 5
2271verification time 100 usec
2272";
2273        let vs = parse_verifier_stats(log);
2274        assert_eq!(vs.processed_insns, 500);
2275        assert_eq!(vs.total_states, 40);
2276        assert_eq!(vs.peak_states, 15);
2277        assert_eq!(vs.time_usec, Some(100));
2278    }
2279
2280    #[test]
2281    fn parse_verifier_stats_processed_no_number() {
2282        let log = "processed\n";
2283        let vs = parse_verifier_stats(log);
2284        assert_eq!(vs.processed_insns, 0);
2285    }
2286
2287    #[test]
2288    fn parse_verifier_stats_keyword_at_end_no_value() {
2289        let log = "processed 100 insns (limit 1000000) max_states_per_insn 1 total_states\n";
2290        let vs = parse_verifier_stats(log);
2291        assert_eq!(vs.processed_insns, 100);
2292        assert_eq!(vs.total_states, 0);
2293    }
2294
2295    #[test]
2296    fn parse_verifier_stats_non_numeric_values() {
2297        let log = "processed 100 insns (limit 1000000) max_states_per_insn 1 total_states abc peak_states xyz mark_read 0\n";
2298        let vs = parse_verifier_stats(log);
2299        assert_eq!(vs.processed_insns, 100);
2300        assert_eq!(vs.total_states, 0);
2301        assert_eq!(vs.peak_states, 0);
2302    }
2303
2304    #[test]
2305    fn parse_verifier_stats_verification_time_no_number() {
2306        let log = "verification time unknown usec\n";
2307        let vs = parse_verifier_stats(log);
2308        assert!(vs.time_usec.is_none());
2309    }
2310
2311    #[test]
2312    fn parse_verifier_stats_stack_depth_empty() {
2313        let log = "stack depth   \n";
2314        let vs = parse_verifier_stats(log);
2315        assert!(vs.stack_depth.is_none());
2316    }
2317
2318    #[test]
2319    fn parse_verifier_stats_peak_states_at_end() {
2320        let log = "processed 50 insns (limit 1000000) max_states_per_insn 1 total_states 10 peak_states\n";
2321        let vs = parse_verifier_stats(log);
2322        assert_eq!(vs.processed_insns, 50);
2323        assert_eq!(vs.total_states, 10);
2324        assert_eq!(vs.peak_states, 0);
2325    }
2326
2327    #[test]
2328    fn parse_verifier_stats_windows_line_endings() {
2329        let log = "processed 42 insns (limit 1000000) max_states_per_insn 1 total_states 5 peak_states 2 mark_read 0\r\nverification time 10 usec\r\nstack depth 16\r\n";
2330        let vs = parse_verifier_stats(log);
2331        assert_eq!(vs.processed_insns, 42);
2332        assert_eq!(vs.time_usec, Some(10));
2333        assert!(vs.stack_depth.is_some());
2334    }
2335
2336    // -----------------------------------------------------------------------
2337    // normalize_verifier_line
2338    // -----------------------------------------------------------------------
2339
2340    #[test]
2341    fn normalize_plain_instruction() {
2342        assert_eq!(
2343            normalize_verifier_line("100: (07) r1 += 8"),
2344            "100: (07) r1 += 8"
2345        );
2346    }
2347
2348    #[test]
2349    fn normalize_strips_frame_annotation() {
2350        assert_eq!(
2351            normalize_verifier_line("3006: (07) r9 += 1  ; frame1: R9_w=2"),
2352            "3006: (07) r9 += 1"
2353        );
2354    }
2355
2356    #[test]
2357    fn normalize_strips_register_annotation() {
2358        assert_eq!(
2359            normalize_verifier_line("42: (bf) r6 = r1 ; R1=ctx() R6_w=ctx()"),
2360            "42: (bf) r6 = r1"
2361        );
2362    }
2363
2364    #[test]
2365    fn normalize_standalone_register_dump() {
2366        assert_eq!(
2367            normalize_verifier_line("3041: frame1: R0_w=scalar()"),
2368            "3041:"
2369        );
2370    }
2371
2372    #[test]
2373    fn normalize_goto_inline_state() {
2374        assert_eq!(
2375            normalize_verifier_line(
2376                "3026: (b5) if r6 <= 0x11dc0 goto pc+2 3029: frame1: R0=1 R6=scalar()"
2377            ),
2378            "3026: (b5) if r6 <= 0x11dc0 goto pc+2"
2379        );
2380    }
2381
2382    #[test]
2383    fn normalize_goto_no_inline_state() {
2384        assert_eq!(
2385            normalize_verifier_line("50: (05) goto pc+10"),
2386            "50: (05) goto pc+10"
2387        );
2388    }
2389
2390    #[test]
2391    fn normalize_non_instruction_line() {
2392        assert_eq!(normalize_verifier_line("func#0 @0"), "func#0 @0");
2393    }
2394
2395    #[test]
2396    fn normalize_empty() {
2397        assert_eq!(normalize_verifier_line(""), "");
2398    }
2399
2400    #[test]
2401    fn normalize_goto_negative_offset() {
2402        assert_eq!(
2403            normalize_verifier_line("50: (05) goto pc-10 60: frame1: R0=1"),
2404            "50: (05) goto pc-10"
2405        );
2406    }
2407
2408    #[test]
2409    fn normalize_semicolon_source_comment() {
2410        let line = "100: (07) r1 += 8 ; for (int j = 0; j < n; j++)";
2411        assert_eq!(normalize_verifier_line(line), line);
2412    }
2413
2414    #[test]
2415    fn normalize_semicolon_return_value_comment() {
2416        let line = "200: (b7) r0 = 0 ; Return value";
2417        assert_eq!(normalize_verifier_line(line), line);
2418    }
2419
2420    #[test]
2421    fn normalize_standalone_bare_register_dump() {
2422        assert_eq!(
2423            normalize_verifier_line("3029: R0=1 R6=scalar(id=1)"),
2424            "3029:"
2425        );
2426    }
2427
2428    #[test]
2429    fn normalize_standalone_r10_dump() {
2430        assert_eq!(normalize_verifier_line("42: R10=fp0"), "42:");
2431    }
2432
2433    // -----------------------------------------------------------------------
2434    // detect_cycle / collapse_cycles
2435    // -----------------------------------------------------------------------
2436
2437    fn repeating_log(prefix: usize, period: usize, reps: usize, suffix: usize) -> String {
2438        let mut lines = Vec::new();
2439        for i in 0..prefix {
2440            lines.push(format!("{}: (07) r1 += {i}", 1000 + i));
2441        }
2442        for rep in 0..reps {
2443            for j in 0..period {
2444                let insn = 100 + j;
2445                lines.push(format!(
2446                    "{insn}: (bf) r{} = r{} ; frame1: R{}_w={}",
2447                    j % 10,
2448                    (j + 1) % 10,
2449                    j % 10,
2450                    rep * 100 + j
2451                ));
2452            }
2453        }
2454        for i in 0..suffix {
2455            lines.push(format!("{}: (95) exit_{i}", 2000 + i));
2456        }
2457        lines.join("\n")
2458    }
2459
2460    #[test]
2461    fn detect_cycle_basic() {
2462        let log = repeating_log(0, 10, 8, 0);
2463        let lines: Vec<&str> = log.lines().collect();
2464        let result = detect_cycle(&lines);
2465        assert!(result.is_some(), "should detect cycle");
2466        let (start, period, count) = result.unwrap();
2467        assert_eq!(period, 10);
2468        assert!(count >= 6, "count={count}");
2469        assert_eq!(start, 0);
2470    }
2471
2472    #[test]
2473    fn detect_cycle_with_prefix_suffix() {
2474        let log = repeating_log(5, 10, 8, 5);
2475        let lines: Vec<&str> = log.lines().collect();
2476        let result = detect_cycle(&lines);
2477        assert!(result.is_some(), "should detect cycle with prefix/suffix");
2478        let (_start, period, count) = result.unwrap();
2479        assert_eq!(period, 10);
2480        assert!(count >= 6);
2481    }
2482
2483    #[test]
2484    fn detect_cycle_too_few_reps() {
2485        let log = repeating_log(0, 10, 2, 0);
2486        let lines: Vec<&str> = log.lines().collect();
2487        assert!(detect_cycle(&lines).is_none());
2488    }
2489
2490    #[test]
2491    fn detect_cycle_too_few_lines() {
2492        let lines: Vec<String> = (0..20)
2493            .map(|i| format!("{}: (07) r1 += {i}", 100 + i % 3))
2494            .collect();
2495        let refs: Vec<&str> = lines.iter().map(|s| s.as_str()).collect();
2496        assert!(detect_cycle(&refs).is_none());
2497    }
2498
2499    #[test]
2500    fn detect_cycle_no_cycle() {
2501        let lines: Vec<String> = (0..100).map(|i| format!("{i}: unique_insn_{i}")).collect();
2502        let refs: Vec<&str> = lines.iter().map(|s| s.as_str()).collect();
2503        assert!(detect_cycle(&refs).is_none());
2504    }
2505
2506    #[test]
2507    fn detect_cycle_empty() {
2508        let empty: Vec<&str> = vec![];
2509        assert!(detect_cycle(&empty).is_none());
2510    }
2511
2512    #[test]
2513    fn detect_cycle_exact_boundary() {
2514        let log = repeating_log(0, 5, 6, 0);
2515        let lines: Vec<&str> = log.lines().collect();
2516        assert_eq!(lines.len(), 30);
2517        let result = detect_cycle(&lines);
2518        assert!(result.is_some(), "boundary case should detect cycle");
2519        let (_start, period, count) = result.unwrap();
2520        assert_eq!(period, 5);
2521        assert_eq!(count, 6);
2522    }
2523
2524    #[test]
2525    fn collapse_cycles_empty_string() {
2526        assert_eq!(collapse_cycles(""), "");
2527    }
2528
2529    #[test]
2530    fn collapse_cycles_basic() {
2531        let log = repeating_log(2, 10, 8, 2);
2532        let collapsed = collapse_cycles(&log);
2533        assert!(collapsed.contains("identical iterations omitted"));
2534        assert!(collapsed.contains("8x of the following 10 lines"));
2535        assert!(collapsed.contains("end repeat"));
2536        assert!(collapsed.lines().count() < log.lines().count());
2537    }
2538
2539    #[test]
2540    fn collapse_cycles_no_cycle() {
2541        let log = "line 1\nline 2\nline 3\n";
2542        let collapsed = collapse_cycles(log);
2543        assert_eq!(collapsed, log);
2544    }
2545
2546    #[test]
2547    fn collapse_cycles_preserves_stats() {
2548        let mut log = repeating_log(0, 10, 8, 0);
2549        log.push_str("\nprocessed 1000 insns (limit 1000000) max_states_per_insn 5 total_states 100 peak_states 30 mark_read 10\n");
2550        let collapsed = collapse_cycles(&log);
2551        assert!(collapsed.contains("processed 1000 insns"));
2552    }
2553
2554    #[test]
2555    fn collapse_cycles_with_register_annotations() {
2556        let mut lines = Vec::new();
2557        lines.push("0: (07) r1 += 1".to_string());
2558        for rep in 0..8 {
2559            for j in 0..6 {
2560                let insn = 100 + j;
2561                lines.push(format!(
2562                    "{insn}: (bf) r{} = r{} ; frame1: R{}_w={}",
2563                    j % 10,
2564                    (j + 1) % 10,
2565                    j % 10,
2566                    rep * 100 + j
2567                ));
2568            }
2569        }
2570        lines.push("200: (95) exit".to_string());
2571        let log = lines.join("\n");
2572        let collapsed = collapse_cycles(&log);
2573        assert!(collapsed.contains("identical iterations omitted"));
2574    }
2575
2576    // -----------------------------------------------------------------------
2577    // build_b_map / build_diff_rows
2578    // -----------------------------------------------------------------------
2579
2580    fn prog(name: &str, verified_insns: u32) -> ProgStats {
2581        ProgStats {
2582            name: name.to_string(),
2583            verified_insns,
2584        }
2585    }
2586
2587    #[test]
2588    fn build_b_map_basic() {
2589        let stats_b = vec![prog("dispatch", 500)];
2590        let map = build_b_map(&stats_b);
2591        assert_eq!(map.get("dispatch"), Some(&500));
2592    }
2593
2594    #[test]
2595    fn build_b_map_empty() {
2596        let map = build_b_map(&[]);
2597        assert!(map.is_empty());
2598    }
2599
2600    #[test]
2601    fn build_diff_rows_matching_programs() {
2602        let stats_a = vec![prog("dispatch", 500)];
2603        let mut b_map = HashMap::new();
2604        b_map.insert("dispatch".to_string(), 300u64);
2605        let rows = build_diff_rows(&stats_a, &b_map);
2606        assert_eq!(rows.len(), 1);
2607        assert_eq!(rows[0].name, "dispatch");
2608        assert_eq!(rows[0].a, 500);
2609        assert_eq!(rows[0].b, 300);
2610        assert_eq!(rows[0].delta, 200);
2611    }
2612
2613    #[test]
2614    fn build_diff_rows_program_missing_from_b() {
2615        let stats_a = vec![prog("new_prog", 100)];
2616        let b_map = HashMap::new();
2617        let rows = build_diff_rows(&stats_a, &b_map);
2618        assert_eq!(rows.len(), 1);
2619        assert_eq!(rows[0].a, 100);
2620        assert_eq!(rows[0].b, 0);
2621        assert_eq!(rows[0].delta, 100);
2622    }
2623
2624    #[test]
2625    fn build_diff_rows_negative_delta() {
2626        let stats_a = vec![prog("dispatch", 200)];
2627        let mut b_map = HashMap::new();
2628        b_map.insert("dispatch".to_string(), 500u64);
2629        let rows = build_diff_rows(&stats_a, &b_map);
2630        assert_eq!(rows[0].delta, -300);
2631    }
2632
2633    #[test]
2634    fn build_diff_rows_empty_a() {
2635        let b_map = HashMap::new();
2636        let rows = build_diff_rows(&[], &b_map);
2637        assert!(rows.is_empty());
2638    }
2639
2640    /// Simulates the verifier trace produced by #pragma unroll loops.
2641    /// Each copy is at a different base address but has the same
2642    /// instruction sequence. After normalize_for_cycle_detection strips
2643    /// addresses and register annotations, all copies look identical.
2644    fn unrolled_verifier_log(copies: usize, body_len: usize) -> String {
2645        let ops = [
2646            "(85) call bpf_ktime_get_ns#5",
2647            "(bf) r2 = r0",
2648            "(77) r0 >>= 16",
2649            "(af) r1 ^= r0",
2650            "(77) r2 >>= 32",
2651            "(0f) r1 += r2",
2652            "(24) w1 *= 7",
2653            "(04) w1 += 1",
2654        ];
2655        let mut lines = Vec::new();
2656        lines.push("func#0 @0".to_string());
2657        lines.push("0: R1=ctx() R10=fp0".to_string());
2658        let mut addr = 10;
2659        for copy in 0..copies {
2660            for (j, op) in ops.iter().enumerate().take(body_len) {
2661                lines.push(format!(
2662                    "{}: {op} ; R0_w=scalar(id={})",
2663                    addr,
2664                    copy * 100 + j
2665                ));
2666                addr += 1;
2667            }
2668        }
2669        lines.push(format!("{addr}: (05) goto pc-1"));
2670        lines.push(
2671            "processed 1000 insns (limit 1000000) max_states_per_insn 3 \
2672             total_states 50 peak_states 20 mark_read 5"
2673                .to_string(),
2674        );
2675        lines.join("\n")
2676    }
2677
2678    #[test]
2679    fn detect_cycle_unrolled_loop() {
2680        let log = unrolled_verifier_log(8, 6);
2681        let lines: Vec<&str> = log.lines().collect();
2682        let result = detect_cycle(&lines);
2683        assert!(result.is_some(), "should detect cycle in unrolled loop");
2684        let (_start, period, count) = result.unwrap();
2685        assert_eq!(period, 6);
2686        assert!(count >= 6, "count={count}");
2687    }
2688
2689    #[test]
2690    fn collapse_cycles_unrolled_loop() {
2691        let log = unrolled_verifier_log(8, 6);
2692        let collapsed = collapse_cycles(&log);
2693        assert!(
2694            collapsed.contains("identical iterations omitted"),
2695            "should collapse unrolled loop"
2696        );
2697        assert!(collapsed.lines().count() < log.lines().count());
2698    }
2699
2700    // -----------------------------------------------------------------------
2701    // extract_verifier_log
2702    // -----------------------------------------------------------------------
2703
2704    #[test]
2705    fn extract_verifier_log_basic() {
2706        let log = "\
2707libbpf: prog 'dispatch': BPF program load failed: -22
2708-- BEGIN PROG LOAD LOG --
2709func#0 @0
27100: R1=ctx() R10=fp0
2711processed 100 insns (limit 1000000) max_states_per_insn 1 total_states 5 peak_states 2 mark_read 0
2712-- END PROG LOAD LOG --
2713libbpf: failed to load object 'ktstr_ops'
2714";
2715        let extracted = extract_verifier_log(log);
2716        assert!(extracted.is_some());
2717        let v = extracted.unwrap();
2718        assert!(v.starts_with("func#0 @0"));
2719        assert!(v.contains("processed 100 insns"));
2720        assert!(!v.contains("BEGIN PROG LOAD LOG"));
2721        assert!(!v.contains("END PROG LOAD LOG"));
2722        assert!(!v.contains("libbpf:"));
2723    }
2724
2725    #[test]
2726    fn extract_verifier_log_none_without_markers() {
2727        let log = "func#0 @0\n0: R1=ctx()\nprocessed 50 insns\n";
2728        assert!(extract_verifier_log(log).is_none());
2729    }
2730
2731    #[test]
2732    fn extract_verifier_log_empty() {
2733        assert!(extract_verifier_log("").is_none());
2734    }
2735
2736    /// Attack 1: libbpf wraps verifier output with "libbpf: " prefix lines.
2737    /// `parse_verifier_stats` looks for `starts_with("processed ")` which
2738    /// won't match `libbpf: processed ...`. Without extraction, stats
2739    /// parsing fails on blobs where the `processed` line is only inside
2740    /// the markers.
2741    #[test]
2742    fn extract_verifier_log_attack1_stats_parse() {
2743        let blob = "\
2744libbpf: prog 'ktstr_ops_dispatch': BPF program load failed: -22
2745libbpf: -- BEGIN PROG LOAD LOG --
2746func#0 @0
27470: R1=ctx() R10=fp0
27481: (bf) r6 = r1 ; R1=ctx() R6_w=ctx()
2749back-edge from insn 42 to 10
2750BPF program is too complex
2751processed 131071 insns (limit 131072) max_states_per_insn 12 total_states 9999 peak_states 5000 mark_read 800
2752verification time 250000 usec
2753stack depth 96+32
2754libbpf: -- END PROG LOAD LOG --
2755libbpf: failed to load BPF skeleton 'ktstr_ops': -22
2756";
2757        let extracted = extract_verifier_log(blob);
2758        assert!(extracted.is_some(), "should find markers");
2759        let v = extracted.unwrap();
2760        let vs = parse_verifier_stats(v);
2761        assert_eq!(vs.processed_insns, 131071);
2762        assert_eq!(vs.total_states, 9999);
2763        assert_eq!(vs.peak_states, 5000);
2764        assert_eq!(vs.time_usec, Some(250000));
2765        assert_eq!(vs.stack_depth.as_deref(), Some("96+32"));
2766
2767        // Without extraction, parsing the full blob must also work
2768        // because the "processed" line doesn't have a "libbpf: " prefix
2769        // inside the markers. But verify extraction gives cleaner input.
2770        let vs_raw = parse_verifier_stats(blob);
2771        assert_eq!(vs_raw.processed_insns, 131071);
2772    }
2773
2774    /// Attack 3: three distinct program load logs in a single blob.
2775    /// Each has different instructions. `collapse_cycles` must NOT treat
2776    /// them as a repeating cycle.
2777    #[test]
2778    fn extract_verifier_log_attack3_no_false_collapse() {
2779        let blob = "\
2780libbpf: prog 'init': BPF program load failed: -22
2781libbpf: -- BEGIN PROG LOAD LOG --
2782func#0 @0
27830: R1=ctx() R10=fp0
27841: (bf) r6 = r1
27852: (07) r6 += 8
27863: (61) r0 = *(u32 *)(r6 + 0)
27874: (95) exit
2788processed 5 insns (limit 1000000) max_states_per_insn 1 total_states 3 peak_states 1 mark_read 0
2789libbpf: -- END PROG LOAD LOG --
2790libbpf: prog 'dispatch': BPF program load failed: -22
2791libbpf: -- BEGIN PROG LOAD LOG --
2792func#1 @10
279310: R1=ctx() R10=fp0
279411: (bf) r7 = r1
279512: (85) call bpf_ktime_get_ns#5
279613: (77) r0 >>= 32
279714: (95) exit
2798processed 5 insns (limit 1000000) max_states_per_insn 1 total_states 3 peak_states 1 mark_read 0
2799libbpf: -- END PROG LOAD LOG --
2800libbpf: prog 'enqueue': BPF program load failed: -22
2801libbpf: -- BEGIN PROG LOAD LOG --
2802func#2 @20
280320: R1=ctx() R10=fp0
280421: (b7) r0 = 0
280522: (63) *(u32 *)(r10 - 4) = r0
280623: (61) r1 = *(u32 *)(r10 - 4)
280724: (95) exit
2808processed 5 insns (limit 1000000) max_states_per_insn 1 total_states 3 peak_states 1 mark_read 0
2809libbpf: -- END PROG LOAD LOG --
2810libbpf: failed to load BPF skeleton 'ktstr_ops': -22
2811";
2812        // extract_verifier_log returns the FIRST log section.
2813        let extracted = extract_verifier_log(blob);
2814        assert!(extracted.is_some());
2815        let v = extracted.unwrap();
2816        assert!(v.contains("func#0 @0"), "should get first program's log");
2817        assert!(!v.contains("func#1"), "should not include second program");
2818
2819        // collapse_cycles on the extracted first section must not
2820        // collapse — it's only 7 lines total.
2821        let collapsed = collapse_cycles(v);
2822        assert!(
2823            !collapsed.contains("identical iterations omitted"),
2824            "must not false-collapse distinct program logs"
2825        );
2826    }
2827
2828    // -- insta snapshot tests --
2829
2830    #[test]
2831    fn snapshot_format_verifier_output_no_log() {
2832        let result = VerifierVmResult {
2833            stats: vec![
2834                ProgStats {
2835                    name: "enqueue".into(),
2836                    verified_insns: 500,
2837                },
2838                ProgStats {
2839                    name: "dispatch".into(),
2840                    verified_insns: 1200,
2841                },
2842                ProgStats {
2843                    name: "init".into(),
2844                    verified_insns: 300,
2845                },
2846            ],
2847            scheduler_log: String::new(),
2848            attach: AttachOutcome::Attached,
2849            dispatched: true,
2850            timed_out: false,
2851        };
2852        insta::assert_snapshot!(format_verifier_output("default", &result, false));
2853    }
2854
2855    #[test]
2856    fn snapshot_format_verifier_output_with_log() {
2857        let log = "\
2858-- BEGIN PROG LOAD LOG --\n\
2859func#0 @0\n\
28600: R1=ctx() R10=fp0\n\
2861processed 42 insns (limit 1000000) max_states_per_insn 1 total_states 10 peak_states 8 mark_read 5\n\
2862-- END PROG LOAD LOG --";
2863        let result = VerifierVmResult {
2864            stats: vec![ProgStats {
2865                name: "enqueue".into(),
2866                verified_insns: 42,
2867            }],
2868            scheduler_log: log.into(),
2869            // A load log present means the scheduler printed a verifier
2870            // trace then exited — the SchedulerDied failure path.
2871            attach: AttachOutcome::Died,
2872            dispatched: false,
2873            timed_out: false,
2874        };
2875        insta::assert_snapshot!(format_verifier_output("llc+steal", &result, false));
2876    }
2877
2878    #[test]
2879    fn snapshot_format_verifier_diff() {
2880        let stats_a = vec![
2881            ProgStats {
2882                name: "enqueue".into(),
2883                verified_insns: 500,
2884            },
2885            ProgStats {
2886                name: "dispatch".into(),
2887                verified_insns: 1200,
2888            },
2889            ProgStats {
2890                name: "init".into(),
2891                verified_insns: 300,
2892            },
2893        ];
2894        let stats_b = vec![
2895            ProgStats {
2896                name: "enqueue".into(),
2897                verified_insns: 480,
2898            },
2899            ProgStats {
2900                name: "dispatch".into(),
2901                verified_insns: 1350,
2902            },
2903            ProgStats {
2904                name: "init".into(),
2905                verified_insns: 300,
2906            },
2907        ];
2908        insta::assert_snapshot!(format_verifier_diff("default", &stats_a, "llc", &stats_b));
2909    }
2910
2911    #[test]
2912    fn snapshot_format_verifier_diff_missing_program() {
2913        let stats_a = vec![
2914            ProgStats {
2915                name: "enqueue".into(),
2916                verified_insns: 500,
2917            },
2918            ProgStats {
2919                name: "new_prog".into(),
2920                verified_insns: 100,
2921            },
2922        ];
2923        let stats_b = vec![ProgStats {
2924            name: "enqueue".into(),
2925            verified_insns: 500,
2926        }];
2927        insta::assert_snapshot!(format_verifier_diff("A", &stats_a, "B", &stats_b));
2928    }
2929
2930    // -----------------------------------------------------------------------
2931    // extract_verifier_log — log extraction + cross-check against
2932    // parse_sched_output so the two slicers stay consistent on shared input.
2933    // -----------------------------------------------------------------------
2934
2935    #[test]
2936    fn extract_verifier_log_between_begin_end_markers() {
2937        // libbpf wraps the verifier log between explicit marker lines;
2938        // the extractor returns the content between them, trimmed of
2939        // the BEGIN newline and the trailing libbpf END prefix.
2940        let blob = "\
2941            unrelated preamble\n\
2942            libbpf: -- BEGIN PROG LOAD LOG --\n\
2943            processed 1234 insns (limit 1000000) max_states_per_insn 5 total_states 200 peak_states 50 mark_read 10\n\
2944            libbpf: -- END PROG LOAD LOG --\n\
2945            trailing diagnostics\n";
2946        let log = extract_verifier_log(blob).expect("markers present");
2947        assert!(log.contains("processed 1234 insns"));
2948        assert!(!log.contains("BEGIN PROG LOAD LOG"));
2949        assert!(!log.contains("END PROG LOAD LOG"));
2950    }
2951
2952    #[test]
2953    fn extract_verifier_log_returns_none_when_markers_absent() {
2954        // Backward compat: logs without the libbpf markers are treated
2955        // as "no markers" — the caller falls back to using the raw blob.
2956        assert!(extract_verifier_log("no markers in here").is_none());
2957        assert!(extract_verifier_log("only BEGIN marker -- BEGIN PROG LOAD LOG --").is_none());
2958    }
2959
2960    #[test]
2961    fn extract_verifier_log_consistent_with_parse_sched_output() {
2962        // `collect_verifier_output` chains parse_sched_output →
2963        // extract_verifier_log on the VM stdout blob. Both slicers
2964        // operate on the same input without duplicating work, so a
2965        // single SCHED_OUTPUT block that wraps a libbpf-marked verifier
2966        // log must produce the same verifier text when extracted in
2967        // that order.
2968        let sched_inner = "\
2969            libbpf: -- BEGIN PROG LOAD LOG --\n\
2970            processed 7 insns (limit 1000000) max_states_per_insn 1 total_states 1 peak_states 1 mark_read 0\n\
2971            libbpf: -- END PROG LOAD LOG --\n";
2972        let vm_output = format!(
2973            "kernel boot junk\n{SCHED_OUTPUT_START}\n{sched_inner}{SCHED_OUTPUT_END}\nafterward\n",
2974        );
2975        let sched = parse_sched_output(&vm_output).expect("SCHED_OUTPUT block");
2976        let verifier_log = extract_verifier_log(sched).expect("verifier markers");
2977        assert!(verifier_log.contains("processed 7 insns"));
2978        assert!(!verifier_log.contains("SCHED_OUTPUT"));
2979        assert!(!verifier_log.contains("BEGIN PROG LOAD LOG"));
2980    }
2981
2982    #[test]
2983    fn parse_sched_output_valid() {
2984        let output = format!(
2985            "noise\n{SCHED_OUTPUT_START}\nscheduler log line 1\nline 2\n{SCHED_OUTPUT_END}\nmore"
2986        );
2987        let parsed = parse_sched_output(&output);
2988        assert!(parsed.is_some());
2989        let content = parsed.unwrap();
2990        assert!(content.contains("scheduler log line 1"));
2991        assert!(content.contains("line 2"));
2992    }
2993
2994    #[test]
2995    fn parse_sched_output_missing_start() {
2996        let output = format!("no start\n{SCHED_OUTPUT_END}\n");
2997        assert!(parse_sched_output(&output).is_none());
2998    }
2999
3000    #[test]
3001    fn parse_sched_output_missing_end() {
3002        let output = format!("{SCHED_OUTPUT_START}\nsome content");
3003        assert!(parse_sched_output(&output).is_none());
3004    }
3005
3006    #[test]
3007    fn parse_sched_output_empty_content() {
3008        let output = format!("{SCHED_OUTPUT_START}\n\n{SCHED_OUTPUT_END}");
3009        assert!(parse_sched_output(&output).is_none());
3010    }
3011
3012    #[test]
3013    fn parse_sched_output_with_stack_traces() {
3014        let stack = "do_enqueue_task+0x1a0/0x380\nbalance_one+0x50/0x100\n";
3015        let output = format!("{SCHED_OUTPUT_START}\n{stack}\n{SCHED_OUTPUT_END}");
3016        let parsed = parse_sched_output(&output).unwrap();
3017        assert!(parsed.contains("do_enqueue_task"));
3018        assert!(parsed.contains("balance_one"));
3019    }
3020
3021    #[test]
3022    fn parse_sched_output_rfind_survives_end_marker_in_content() {
3023        // Regression: if the scheduler log echoes the END marker
3024        // inside its own content (e.g. a shell heredoc, a diagnostic
3025        // that quotes the sentinel), `find` truncated the section at
3026        // the first occurrence — which was inside the content, not
3027        // at the terminator. `rfind` anchors on the last occurrence,
3028        // which is the real terminator.
3029        let content = format!("line1\nfake {SCHED_OUTPUT_END} inside\nline3");
3030        let output = format!("{SCHED_OUTPUT_START}\n{content}\n{SCHED_OUTPUT_END}\n");
3031        let parsed = parse_sched_output(&output).unwrap();
3032        assert!(
3033            parsed.contains("line3"),
3034            "rfind must keep content after an embedded END marker: {parsed:?}"
3035        );
3036        assert!(
3037            parsed.contains("fake"),
3038            "content before the embedded marker must also survive: {parsed:?}"
3039        );
3040    }
3041
3042    // -- parse_sched_output_partial --
3043
3044    #[test]
3045    fn parse_sched_output_partial_well_formed_matches_strict() {
3046        // When both delimiters are present, the partial parser
3047        // returns the same content as the strict parser.
3048        let output = format!(
3049            "noise\n{SCHED_OUTPUT_START}\nscheduler log line 1\nline 2\n{SCHED_OUTPUT_END}\nmore"
3050        );
3051        assert_eq!(
3052            parse_sched_output_partial(&output),
3053            parse_sched_output(&output),
3054        );
3055    }
3056
3057    #[test]
3058    fn parse_sched_output_partial_missing_end_returns_partial() {
3059        // When SCHED_OUTPUT_END is absent (scheduler crashed mid-run
3060        // before writing the closing delimiter), the partial parser
3061        // returns content from after SCHED_OUTPUT_START to end of
3062        // buffer. The strict parser returns None for the same input.
3063        let output = format!("{SCHED_OUTPUT_START}\nstack frame 1\nstack frame 2");
3064        assert!(parse_sched_output(&output).is_none());
3065        let partial = parse_sched_output_partial(&output).unwrap();
3066        assert!(partial.contains("stack frame 1"));
3067        assert!(partial.contains("stack frame 2"));
3068    }
3069
3070    #[test]
3071    fn parse_sched_output_partial_missing_start_returns_none() {
3072        // No start marker → no content recoverable. The end-marker-
3073        // only case is unrecoverable: we cannot infer where the log
3074        // begins.
3075        let output = format!("garbage\n{SCHED_OUTPUT_END}\n");
3076        assert!(parse_sched_output_partial(&output).is_none());
3077    }
3078
3079    #[test]
3080    fn parse_sched_output_partial_empty_content_returns_none() {
3081        // Start marker present but no payload after it.
3082        let output = format!("{SCHED_OUTPUT_START}\n");
3083        assert!(parse_sched_output_partial(&output).is_none());
3084    }
3085}