use anyhow::{Context, Result};
use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use ktstr::host_context::{HostContext, collect_host_context};
const EMPTY_POOL_FILTER: &str = "test(/^__ktstr_no_failures_to_replay__$/)";
pub(crate) fn run_replay(dir: Option<&Path>, filter: Option<&str>, exec: bool) -> Result<i32> {
let root: PathBuf = dir
.map(Path::to_path_buf)
.unwrap_or_else(ktstr::test_support::runs_root);
let pool = ktstr::test_support::collect_pool(&root);
if pool.is_empty() {
anyhow::bail!(
"ktstr replay: no sidecars found under {} — \
run the suite first (cargo ktstr test) or pass \
--dir <DIR> to point at an archived pool",
root.display(),
);
}
let failed_names = select_failed_names(&pool, filter);
if failed_names.is_empty() {
eprintln!(
"ktstr replay: no failed sidecars in pool at {} \
(filter: {:?}) — nothing to re-run",
root.display(),
filter,
);
println!("{EMPTY_POOL_FILTER}");
return Ok(0);
}
let filter_expr = build_nextest_filter(&failed_names);
let host_section = compute_host_diff(extract_captured_host(&pool), &collect_host_context());
if !exec {
println!("{filter_expr}");
eprintln!(
"ktstr replay: {} failed test name(s) selected. \
Pipe the printed filter into `cargo nextest run -E` \
or re-run with --exec to invoke nextest directly.",
failed_names.len(),
);
render_host_diff_section(&host_section);
return Ok(0);
}
let queued: BTreeSet<String> = failed_names.iter().map(|s| s.to_string()).collect();
let exit = invoke_nextest(&filter_expr).with_context(|| {
format!("ktstr replay: cargo nextest run -E {filter_expr:?} failed to spawn")
})?;
let post_pool = ktstr::test_support::collect_pool(&root);
let queued_refs: BTreeSet<&str> = queued.iter().map(String::as_str).collect();
let outcomes = classify_replay(&queued_refs, &post_pool);
render_outcome_diff(&outcomes);
render_host_diff_section(&host_section);
Ok(exit)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum ReplayOutcome {
Fixed,
Persistent,
Inconclusive,
Dropped,
Mixed {
fixed_count: usize,
persistent_count: usize,
inconclusive_count: usize,
},
}
pub(crate) fn classify_replay<'a>(
queued: &'a BTreeSet<&'a str>,
post_pool: &'a [ktstr::test_support::SidecarResult],
) -> BTreeMap<&'a str, ReplayOutcome> {
let mut by_name: BTreeMap<&str, Vec<&ktstr::test_support::SidecarResult>> = BTreeMap::new();
for sc in post_pool {
by_name.entry(sc.test_name.as_str()).or_default().push(sc);
}
queued
.iter()
.map(|name| {
let outcome = match by_name.get(name) {
None => ReplayOutcome::Dropped,
Some(variants) => {
let inconclusive_count =
variants.iter().filter(|sc| sc.is_inconclusive()).count();
let fixed_count = variants.iter().filter(|sc| sc.is_pass()).count();
let persistent_count = variants.len() - fixed_count - inconclusive_count;
match (fixed_count, persistent_count, inconclusive_count) {
(n, 0, 0) if n > 0 => ReplayOutcome::Fixed,
(0, n, 0) if n > 0 => ReplayOutcome::Persistent,
(0, 0, n) if n > 0 => ReplayOutcome::Inconclusive,
_ => ReplayOutcome::Mixed {
fixed_count,
persistent_count,
inconclusive_count,
},
}
}
};
(*name, outcome)
})
.collect()
}
fn render_outcome_diff(outcomes: &BTreeMap<&str, ReplayOutcome>) {
let (mut fixed, mut persistent, mut inconclusive, mut dropped, mut mixed) =
(0usize, 0usize, 0usize, 0usize, 0usize);
for o in outcomes.values() {
match o {
ReplayOutcome::Fixed => fixed += 1,
ReplayOutcome::Persistent => persistent += 1,
ReplayOutcome::Inconclusive => inconclusive += 1,
ReplayOutcome::Dropped => dropped += 1,
ReplayOutcome::Mixed { .. } => mixed += 1,
}
}
eprintln!();
eprintln!(
"ktstr replay: {fixed} FIXED, {persistent} PERSISTENT, \
{inconclusive} INCONCLUSIVE, {mixed} MIXED, {dropped} DROPPED",
);
if persistent > 0 || dropped > 0 || mixed > 0 || inconclusive > 0 {
for (name, outcome) in outcomes {
match outcome {
ReplayOutcome::Persistent => {
eprintln!(" PERSISTENT {name}");
}
ReplayOutcome::Inconclusive => {
eprintln!(
" INCONCLUSIVE {name} \
(zero-denominator gate across every variant — \
re-run against a workload that produces the \
missing signal, or drill into the per-variant \
sidecars to see which gates went unevaluated)",
);
}
ReplayOutcome::Dropped => {
eprintln!(
" DROPPED {name} \
(not run — test removed, --filter narrowed past, \
or nextest skipped/crashed before reaching it)",
);
}
ReplayOutcome::Mixed {
fixed_count,
persistent_count,
inconclusive_count,
} => {
eprintln!(
" MIXED {name} \
({fixed_count} variant(s) fixed, \
{persistent_count} variant(s) still failing, \
{inconclusive_count} variant(s) inconclusive — \
drill into the per-variant sidecars to triage)",
);
}
ReplayOutcome::Fixed => {}
}
}
}
}
#[derive(Debug, PartialEq, Eq)]
pub(crate) enum HostDiffSection {
NoCapture,
Unchanged,
Changed(String),
}
pub(crate) fn extract_captured_host(
pool: &[ktstr::test_support::SidecarResult],
) -> Option<&HostContext> {
pool.iter().find_map(|sc| sc.host.as_ref())
}
pub(crate) fn compute_host_diff(
captured: Option<&HostContext>,
current: &HostContext,
) -> HostDiffSection {
let Some(captured) = captured else {
return HostDiffSection::NoCapture;
};
let captured = without_heap_state(captured);
let current = without_heap_state(current);
if captured == current {
HostDiffSection::Unchanged
} else {
HostDiffSection::Changed(captured.diff(¤t))
}
}
fn without_heap_state(host: &HostContext) -> HostContext {
let mut stripped = host.clone();
stripped.heap_state = None;
stripped
}
fn render_host_diff_section(section: &HostDiffSection) {
match section {
HostDiffSection::NoCapture => {
eprintln!("ktstr replay: (no host context captured)");
}
HostDiffSection::Unchanged => {
eprintln!("ktstr replay: (host context unchanged since capture)");
}
HostDiffSection::Changed(body) => {
eprintln!("ktstr replay: host context drift since capture:");
eprint!("{body}");
}
}
}
pub(crate) fn select_failed_names<'a>(
pool: &'a [ktstr::test_support::SidecarResult],
filter: Option<&str>,
) -> BTreeSet<&'a str> {
pool.iter()
.filter(|s| s.is_fail())
.map(|s| s.test_name.as_str())
.filter(|n| match filter {
Some(f) => n.contains(f),
None => true,
})
.collect()
}
fn build_nextest_filter(names: &BTreeSet<&str>) -> String {
let parts: Vec<String> = names
.iter()
.map(|n| format!("test(/^(.*::)?{}$/)", regex_escape(n)))
.collect();
parts.join(" | ")
}
fn regex_escape(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for ch in s.chars() {
match ch {
'.' | '*' | '+' | '?' | '(' | ')' | '[' | ']' | '{' | '}' | '^' | '$' | '|' | '\\' => {
out.push('\\');
out.push(ch);
}
_ => out.push(ch),
}
}
out
}
fn invoke_nextest(filter_expr: &str) -> Result<i32> {
use std::process::Command;
let status = Command::new("cargo")
.args(["nextest", "run", "-E", filter_expr])
.status()
.context("spawn `cargo nextest run`")?;
Ok(status.code().unwrap_or(1))
}
#[cfg(test)]
mod tests {
use super::*;
use ktstr::test_support::SidecarResult;
fn synth_sidecar(test_name: &str, passed: bool, skipped: bool) -> SidecarResult {
SidecarResult {
test_name: test_name.to_string(),
topology: "synth".into(),
scheduler: "synth".into(),
scheduler_commit: None,
project_commit: None,
payload: None,
metrics: Vec::new(),
passed,
skipped,
inconclusive: false,
stats: ktstr::assert::ScenarioStats::default(),
monitor: None,
stimulus_events: Vec::new(),
work_type: "synth".into(),
verifier_stats: Vec::new(),
kvm_stats: None,
sysctls: Vec::new(),
kargs: Vec::new(),
kernel_version: None,
kernel_commit: None,
timestamp: "synth".into(),
run_id: "synth".into(),
host: None,
cleanup_duration_ms: None,
run_source: None,
}
}
fn synth_pool(rows: &[(&str, bool, bool)]) -> Vec<SidecarResult> {
rows.iter()
.map(|(n, p, s)| synth_sidecar(n, *p, *s))
.collect()
}
fn synth_sidecar_with_inconclusive(
test_name: &str,
passed: bool,
skipped: bool,
inconclusive: bool,
) -> SidecarResult {
let set_count = u8::from(passed) + u8::from(skipped) + u8::from(inconclusive);
debug_assert!(
set_count <= 1,
"SidecarResult strict 4-state mutex: at most one of \
passed/skipped/inconclusive may be true; got \
passed={passed}, skipped={skipped}, inconclusive={inconclusive}",
);
let mut sc = synth_sidecar(test_name, passed, skipped);
sc.inconclusive = inconclusive;
sc
}
#[test]
fn build_nextest_filter_single_name_emits_regex_anchored_form() {
let mut names = BTreeSet::new();
names.insert("scheduler_smoke_test");
let expr = build_nextest_filter(&names);
assert_eq!(
expr, "test(/^(.*::)?scheduler_smoke_test$/)",
"single-name filter wraps in regex with optional path prefix + end anchor"
);
}
#[test]
fn build_nextest_filter_multiple_names_sorted_and_joined() {
let mut names = BTreeSet::new();
names.insert("z_test");
names.insert("a_test");
names.insert("m_test");
let expr = build_nextest_filter(&names);
assert_eq!(
expr,
"test(/^(.*::)?a_test$/) | test(/^(.*::)?m_test$/) | test(/^(.*::)?z_test$/)"
);
}
#[test]
fn build_nextest_filter_substring_names_both_present() {
let mut names = BTreeSet::new();
names.insert("phase_pipeline_two_step_e2e");
names.insert("phase_pipeline_no_periodic_samples_yields_empty_phases");
let expr = build_nextest_filter(&names);
assert!(
expr.contains("phase_pipeline_two_step_e2e$"),
"two_step_e2e present with end anchor"
);
assert!(
expr.contains("phase_pipeline_no_periodic_samples_yields_empty_phases$"),
"no_periodic_samples present with end anchor"
);
assert_eq!(
expr.matches(" | ").count(),
1,
"BTreeSet dedups; exactly one `|` between the two entries"
);
}
#[test]
fn regex_escape_passes_through_identifier_chars() {
assert_eq!(regex_escape("phase_pipeline_e2e"), "phase_pipeline_e2e");
assert_eq!(regex_escape("test123"), "test123");
}
#[test]
fn regex_escape_escapes_metacharacters() {
assert_eq!(regex_escape("a.b"), "a\\.b");
assert_eq!(regex_escape("(group)"), "\\(group\\)");
assert_eq!(regex_escape("a|b"), "a\\|b");
assert_eq!(regex_escape("end$"), "end\\$");
}
#[test]
fn select_failed_skips_passed_and_skipped_keeps_only_real_failures() {
let pool = synth_pool(&[
("test_pass", true, false), ("test_skip", true, true), ("test_fail1", false, false), ("test_fail2", false, false), ("test_corner", false, true), ]);
let result = select_failed_names(&pool, None);
let expected: BTreeSet<&str> = ["test_fail1", "test_fail2"].iter().copied().collect();
assert_eq!(result, expected);
}
#[test]
fn select_failed_with_filter_substring_match_keeps_matching_failures() {
let pool = synth_pool(&[
("scheduler_smoke_a", false, false),
("scheduler_smoke_b", false, false),
("workload_perf", false, false),
]);
let result = select_failed_names(&pool, Some("scheduler_"));
let expected: BTreeSet<&str> = ["scheduler_smoke_a", "scheduler_smoke_b"]
.iter()
.copied()
.collect();
assert_eq!(result, expected);
}
#[test]
fn select_failed_with_filter_no_match_returns_empty_set() {
let pool = synth_pool(&[("test_pass", true, false), ("test_fail", false, false)]);
let result = select_failed_names(&pool, Some("nonexistent"));
assert!(result.is_empty());
}
#[test]
fn select_failed_corner_case_failed_and_skipped_excluded() {
let pool = synth_pool(&[("test_fail_skip", false, true)]);
let result = select_failed_names(&pool, None);
assert!(
result.is_empty(),
"failed+skipped must be excluded; the && !skipped guard is load-bearing"
);
}
#[test]
fn select_failed_corner_case_inconclusive_excluded() {
let pool = vec![
synth_sidecar_with_inconclusive("test_inconclusive_only", false, false, true),
synth_sidecar_with_inconclusive("test_real_fail", false, false, false),
];
let result = select_failed_names(&pool, None);
let expected: BTreeSet<&str> = ["test_real_fail"].iter().copied().collect();
assert_eq!(
result, expected,
"inconclusive sidecars must NOT enter the replay queue — \
SidecarResult::is_fail requires !inconclusive; a replay of a \
zero-denominator run would re-spend budget on a non-measurement"
);
}
#[test]
fn classify_replay_failing_then_passing_classifies_as_fixed() {
let post_pool = synth_pool(&[("test_fix_me", true, false)]);
let queued: BTreeSet<&str> = ["test_fix_me"].iter().copied().collect();
let outcomes = classify_replay(&queued, &post_pool);
assert_eq!(outcomes.get("test_fix_me"), Some(&ReplayOutcome::Fixed));
}
#[test]
fn classify_replay_still_failing_classifies_as_persistent() {
let post_pool = synth_pool(&[("test_still_broken", false, false)]);
let queued: BTreeSet<&str> = ["test_still_broken"].iter().copied().collect();
let outcomes = classify_replay(&queued, &post_pool);
assert_eq!(
outcomes.get("test_still_broken"),
Some(&ReplayOutcome::Persistent)
);
}
#[test]
fn classify_replay_missing_from_post_pool_classifies_as_dropped() {
let post_pool = synth_pool(&[("unrelated_test", true, false)]);
let queued: BTreeSet<&str> = ["test_was_removed"].iter().copied().collect();
let outcomes = classify_replay(&queued, &post_pool);
assert_eq!(
outcomes.get("test_was_removed"),
Some(&ReplayOutcome::Dropped)
);
}
#[test]
fn classify_replay_mixed_outcomes_classifies_each_correctly() {
let post_pool = synth_pool(&[
("test_a_fixed", true, false),
("test_b_persistent", false, false),
("unrelated_pass", true, false),
]);
let queued: BTreeSet<&str> = ["test_a_fixed", "test_b_persistent", "test_c_dropped"]
.iter()
.copied()
.collect();
let outcomes = classify_replay(&queued, &post_pool);
assert_eq!(
outcomes.len(),
3,
"every queued name gets exactly one outcome"
);
assert_eq!(outcomes.get("test_a_fixed"), Some(&ReplayOutcome::Fixed));
assert_eq!(
outcomes.get("test_b_persistent"),
Some(&ReplayOutcome::Persistent)
);
assert_eq!(
outcomes.get("test_c_dropped"),
Some(&ReplayOutcome::Dropped)
);
}
#[test]
fn classify_replay_post_skipped_is_persistent_not_fixed() {
let post_pool = synth_pool(&[("test_skipped", true, true)]);
let queued: BTreeSet<&str> = ["test_skipped"].iter().copied().collect();
let outcomes = classify_replay(&queued, &post_pool);
assert_eq!(
outcomes.get("test_skipped"),
Some(&ReplayOutcome::Persistent),
"post-replay skipped means the original failure is unvalidated; \
classifier must NOT treat skip as Fixed"
);
}
#[test]
fn classify_replay_mixed_variants_classifies_as_mixed() {
let post_pool = synth_pool(&[
("test_param", true, false), ("test_param", false, false), ]);
let queued: BTreeSet<&str> = ["test_param"].iter().copied().collect();
let outcomes = classify_replay(&queued, &post_pool);
assert_eq!(
outcomes.get("test_param"),
Some(&ReplayOutcome::Mixed {
fixed_count: 1,
persistent_count: 1,
inconclusive_count: 0,
}),
"variant disagreement must surface as Mixed; \
silent collapse to Fixed would hide the failing variant"
);
}
#[test]
fn classify_replay_all_variants_pass_classifies_as_fixed() {
let post_pool = synth_pool(&[
("test_consistent", true, false),
("test_consistent", true, false),
("test_consistent", true, false),
]);
let queued: BTreeSet<&str> = ["test_consistent"].iter().copied().collect();
let outcomes = classify_replay(&queued, &post_pool);
assert_eq!(
outcomes.get("test_consistent"),
Some(&ReplayOutcome::Fixed),
"3-of-3 variants passed → Fixed, not Mixed"
);
}
#[test]
fn classify_replay_all_variants_fail_classifies_as_persistent() {
let post_pool = synth_pool(&[("test_broken", false, false), ("test_broken", false, false)]);
let queued: BTreeSet<&str> = ["test_broken"].iter().copied().collect();
let outcomes = classify_replay(&queued, &post_pool);
assert_eq!(
outcomes.get("test_broken"),
Some(&ReplayOutcome::Persistent),
"2-of-2 variants failed → Persistent, not Mixed"
);
}
#[test]
fn classify_replay_all_variants_inconclusive_classifies_as_inconclusive() {
let post_pool = vec![
synth_sidecar_with_inconclusive("test_zero_denom", false, false, true),
synth_sidecar_with_inconclusive("test_zero_denom", false, false, true),
];
let queued: BTreeSet<&str> = ["test_zero_denom"].iter().copied().collect();
let outcomes = classify_replay(&queued, &post_pool);
assert_eq!(
outcomes.get("test_zero_denom"),
Some(&ReplayOutcome::Inconclusive),
"2-of-2 variants inconclusive → Inconclusive, not Persistent or Fixed"
);
}
#[test]
fn classify_replay_three_way_mix_classifies_as_mixed_with_inconclusive() {
let post_pool = vec![
synth_sidecar_with_inconclusive("test_param", true, false, false),
synth_sidecar_with_inconclusive("test_param", false, false, false),
synth_sidecar_with_inconclusive("test_param", false, false, true),
];
let queued: BTreeSet<&str> = ["test_param"].iter().copied().collect();
let outcomes = classify_replay(&queued, &post_pool);
assert_eq!(
outcomes.get("test_param"),
Some(&ReplayOutcome::Mixed {
fixed_count: 1,
persistent_count: 1,
inconclusive_count: 1,
}),
"three-way variant disagreement surfaces every bucket"
);
}
fn synth_sidecar_with_host(
test_name: &str,
passed: bool,
skipped: bool,
host: HostContext,
) -> SidecarResult {
let mut sc = synth_sidecar(test_name, passed, skipped);
sc.host = Some(host);
sc
}
#[test]
fn compute_host_diff_empty_pool_yields_no_capture() {
let pool: Vec<SidecarResult> = Vec::new();
let captured = extract_captured_host(&pool);
assert!(
captured.is_none(),
"empty pool must yield None — no sidecar means no captured host"
);
let current = HostContext::test_fixture();
let section = compute_host_diff(captured, ¤t);
assert_eq!(section, HostDiffSection::NoCapture);
}
#[test]
fn compute_host_diff_pool_without_host_yields_no_capture() {
let pool = synth_pool(&[("test_a", false, false), ("test_b", false, false)]);
assert!(pool.iter().all(|sc| sc.host.is_none()));
let captured = extract_captured_host(&pool);
assert!(captured.is_none());
let section = compute_host_diff(captured, &HostContext::test_fixture());
assert_eq!(section, HostDiffSection::NoCapture);
}
#[test]
fn compute_host_diff_captured_equals_current_yields_unchanged() {
let host = HostContext::test_fixture();
let pool = vec![synth_sidecar_with_host(
"test_a",
false,
false,
host.clone(),
)];
let captured = extract_captured_host(&pool);
assert_eq!(
captured,
Some(&host),
"extracted host must equal the one we attached to the sidecar"
);
let section = compute_host_diff(captured, &host);
assert_eq!(section, HostDiffSection::Unchanged);
}
#[test]
fn compute_host_diff_differing_kernel_release_yields_changed_with_diff_body() {
let mut captured = HostContext::test_fixture();
captured.kernel_release = Some("6.16.0-test".to_string());
let mut current = HostContext::test_fixture();
current.kernel_release = Some("6.17.0-test".to_string());
let pool = vec![synth_sidecar_with_host(
"test_a",
false,
false,
captured.clone(),
)];
let extracted = extract_captured_host(&pool);
let section = compute_host_diff(extracted, ¤t);
let HostDiffSection::Changed(body) = section else {
panic!("kernel_release mismatch must yield Changed, got non-Changed variant");
};
assert!(
body.contains("kernel_release"),
"diff body must name the differing field; got: {body:?}"
);
assert!(
body.contains("6.16.0-test") && body.contains("6.17.0-test"),
"diff body must carry both before and after values; got: {body:?}"
);
}
#[test]
fn extract_captured_host_first_some_wins_over_later_sidecars() {
let mut host_first = HostContext::test_fixture();
host_first.kernel_release = Some("FIRST-release".to_string());
let mut host_second = HostContext::test_fixture();
host_second.kernel_release = Some("SECOND-release".to_string());
let pool = vec![
synth_sidecar_with_host("test_a", false, false, host_first.clone()),
synth_sidecar_with_host("test_b", false, false, host_second.clone()),
];
let extracted = extract_captured_host(&pool);
assert_eq!(
extracted.and_then(|h| h.kernel_release.as_deref()),
Some("FIRST-release"),
"first-match-wins: earlier sidecar's host capture must win"
);
}
#[test]
fn compute_host_diff_changed_body_covers_each_dimension_class() {
fn drift_body(mutate: impl FnOnce(&mut HostContext, &mut HostContext)) -> String {
let mut captured = HostContext::test_fixture();
let mut current = HostContext::test_fixture();
mutate(&mut captured, &mut current);
let pool = vec![synth_sidecar_with_host("test", false, false, captured)];
let extracted = extract_captured_host(&pool);
match compute_host_diff(extracted, ¤t) {
HostDiffSection::Changed(body) => body,
other => panic!("expected Changed, got {other:?}"),
}
}
type Mutator = fn(&mut HostContext, &mut HostContext);
let cases: &[(&str, Mutator, &str)] = &[
(
"scalar Option<String> drift (kernel_release)",
|c, n| {
c.kernel_release = Some("6.16.0-a".to_string());
n.kernel_release = Some("6.16.0-b".to_string());
},
"kernel_release",
),
(
"scalar Option<u64> drift (total_memory_kib)",
|c, n| {
c.total_memory_kib = Some(32 * 1024 * 1024);
n.total_memory_kib = Some(64 * 1024 * 1024);
},
"total_memory_kib",
),
(
"scalar Option<usize> drift (online_cpus)",
|c, n| {
c.online_cpus = Some(8);
n.online_cpus = Some(16);
},
"online_cpus",
),
(
"scalar Option<usize> drift (numa_nodes)",
|c, n| {
c.numa_nodes = Some(1);
n.numa_nodes = Some(2);
},
"numa_nodes",
),
(
"scalar Option<String> drift (arch)",
|c, n| {
c.arch = Some("x86_64".to_string());
n.arch = Some("aarch64".to_string());
},
"arch",
),
(
"scalar Option<String> drift (kernel_cmdline)",
|c, n| {
c.kernel_cmdline = Some("root=/dev/sda1".to_string());
n.kernel_cmdline = Some("root=/dev/sda2".to_string());
},
"kernel_cmdline",
),
(
"BTreeMap<usize,String> per-CPU drift (cpufreq_governor)",
|c, n| {
c.cpufreq_governor.insert(0, "performance".to_string());
n.cpufreq_governor.insert(0, "powersave".to_string());
},
"cpufreq_governor.cpu0",
),
(
"Option<BTreeMap<String,String>> entry drift (sched_tunables)",
|_c, n| {
if let Some(m) = n.sched_tunables.as_mut() {
m.insert("sched_migration_cost_ns".to_string(), "999999".to_string());
}
},
"sched_tunables.sched_migration_cost_ns",
),
(
"Option None vs Some transition (cpu_vendor)",
|c, n| {
c.cpu_vendor = None;
n.cpu_vendor = Some("GenuineIntel".to_string());
},
"cpu_vendor",
),
];
for (label, mutate, expected_substr) in cases {
let body = drift_body(*mutate);
assert!(
body.contains(expected_substr),
"{label}: diff body missing expected substring \
{expected_substr:?}; got body: {body:?}"
);
}
}
#[test]
fn compute_host_diff_strips_heap_state_so_process_local_drift_is_unchanged() {
use ktstr::host_heap::HostHeapState;
let mut captured = HostContext::test_fixture();
let mut current = HostContext::test_fixture();
let mut h1 = HostHeapState::test_fixture();
h1.allocated_bytes = Some(1_000_000);
captured.heap_state = Some(h1);
let mut h2 = HostHeapState::test_fixture();
h2.allocated_bytes = Some(2_000_000);
current.heap_state = Some(h2);
let pool = vec![synth_sidecar_with_host("test", false, false, captured)];
let extracted = extract_captured_host(&pool);
let section = compute_host_diff(extracted, ¤t);
assert_eq!(
section,
HostDiffSection::Unchanged,
"heap_state drift must NOT yield Changed; if it does, \
the strip in compute_host_diff regressed and every \
replay will spam process-local jemalloc deltas as \
host drift"
);
}
#[test]
fn extract_captured_host_skips_none_to_first_some() {
let mut host = HostContext::test_fixture();
host.kernel_release = Some("LATE-CAPTURE".to_string());
let pool = vec![
synth_sidecar("test_a_no_host", false, false), synth_sidecar_with_host("test_b_with_host", false, false, host.clone()),
];
let extracted = extract_captured_host(&pool);
assert_eq!(
extracted.and_then(|h| h.kernel_release.as_deref()),
Some("LATE-CAPTURE"),
"extractor must skip host=None and land on first host=Some entry"
);
}
}