#![cfg(test)]
use super::tests_helpers::stage_synthetic_proc;
use super::*;
use crate::metric_types::MonotonicCount;
use std::path::Path;
fn stage_minimal_proc_for_parse(root: &Path, tgid: i32, tid: i32) {
use std::fs;
let tgid_dir = root.join(tgid.to_string());
let task_dir = tgid_dir.join("task").join(tid.to_string());
fs::create_dir_all(&task_dir).unwrap();
fs::write(tgid_dir.join("comm"), "p\n").unwrap();
fs::write(task_dir.join("comm"), "live\n").unwrap();
let stat_line = format!(
"{tid} (live) R 1 2 3 4 5 6 7 0 8 0 10 11 12 13 14 0 1 0 \
555555 100 200 300 400 500 600 700 800 900 1000 1100 \
1200 1300 1400 1500 1600 1700 1800 0\n"
);
fs::write(task_dir.join("stat"), stat_line).unwrap();
fs::write(task_dir.join("schedstat"), "0 0 0\n").unwrap();
fs::write(
task_dir.join("status"),
"voluntary_ctxt_switches:\t0\n\
nonvoluntary_ctxt_switches:\t0\n",
)
.unwrap();
fs::write(task_dir.join("io"), "rchar: 0\n").unwrap();
fs::write(task_dir.join("sched"), "").unwrap();
fs::write(task_dir.join("cgroup"), "0::/\n").unwrap();
}
#[test]
fn parse_summary_records_schedstat_failure() {
let proc_tmp = tempfile::TempDir::new().unwrap();
let tgid: i32 = 5050;
let tid: i32 = 5051;
stage_minimal_proc_for_parse(proc_tmp.path(), tgid, tid);
std::fs::remove_file(
proc_tmp
.path()
.join(tgid.to_string())
.join("task")
.join(tid.to_string())
.join("schedstat"),
)
.unwrap();
let mut tally = ParseTally::default();
let mut tally_opt: Option<&mut ParseTally> = Some(&mut tally);
tally_opt.as_mut().unwrap().tids_walked += 1;
let _ = capture_thread_at_with_tally(
proc_tmp.path(),
tgid,
tid,
"p",
"live",
false,
&mut tally_opt,
);
tally_opt.as_mut().unwrap().commit_pending();
let summary = tally.to_public();
assert_eq!(summary.tids_walked, 1);
assert_eq!(summary.read_failures, 1);
assert_eq!(summary.read_failures_by_file.get("schedstat"), Some(&1));
assert!(!summary.read_failures_by_file.contains_key("stat"));
assert!(!summary.read_failures_by_file.contains_key("io"));
}
#[test]
fn parse_summary_records_io_failure() {
let proc_tmp = tempfile::TempDir::new().unwrap();
let tgid: i32 = 5060;
let tid: i32 = 5061;
stage_minimal_proc_for_parse(proc_tmp.path(), tgid, tid);
std::fs::remove_file(
proc_tmp
.path()
.join(tgid.to_string())
.join("task")
.join(tid.to_string())
.join("io"),
)
.unwrap();
let mut tally = ParseTally::default();
let mut tally_opt: Option<&mut ParseTally> = Some(&mut tally);
tally_opt.as_mut().unwrap().tids_walked += 1;
let _ = capture_thread_at_with_tally(
proc_tmp.path(),
tgid,
tid,
"p",
"live",
false,
&mut tally_opt,
);
tally_opt.as_mut().unwrap().commit_pending();
let summary = tally.to_public();
assert_eq!(summary.read_failures_by_file.get("io"), Some(&1));
}
#[test]
fn parse_summary_clean_proc_yields_empty_map() {
let proc_tmp = tempfile::TempDir::new().unwrap();
let tgid: i32 = 5070;
let tid: i32 = 5071;
stage_synthetic_proc(proc_tmp.path(), tgid, tid, "p", "live");
let mut tally = ParseTally::default();
let mut tally_opt: Option<&mut ParseTally> = Some(&mut tally);
tally_opt.as_mut().unwrap().tids_walked += 1;
let _ = capture_thread_at_with_tally(
proc_tmp.path(),
tgid,
tid,
"p",
"live",
false,
&mut tally_opt,
);
tally_opt.as_mut().unwrap().commit_pending();
let summary = tally.to_public();
assert_eq!(summary.tids_walked, 1);
assert_eq!(summary.read_failures, 0);
assert!(
summary.read_failures_by_file.is_empty(),
"clean procfs must yield an empty map, got {:?}",
summary.read_failures_by_file,
);
assert!(summary.dominant_read_failure.is_none());
assert!(!summary.kernel_config_dominant);
}
#[test]
fn parse_summary_excludes_ghost_filtered_tids() {
let proc_tmp = tempfile::TempDir::new().unwrap();
let tgid: i32 = 5080;
let tid: i32 = 5081;
let task_dir = proc_tmp
.path()
.join(tgid.to_string())
.join("task")
.join(tid.to_string());
std::fs::create_dir_all(&task_dir).unwrap();
let mut tally = ParseTally::default();
let mut tally_opt: Option<&mut ParseTally> = Some(&mut tally);
tally_opt.as_mut().unwrap().tids_walked += 1;
let t = capture_thread_at_with_tally(proc_tmp.path(), tgid, tid, "", "", false, &mut tally_opt);
if t.comm.is_empty() && t.start_time_clock_ticks == 0 {
tally_opt.as_mut().unwrap().discard_pending();
} else {
tally_opt.as_mut().unwrap().commit_pending();
}
let summary = tally.to_public();
assert_eq!(
summary.read_failures, 0,
"ghost-filtered tid must NOT contribute to read_failures; \
got {} failures (the discard_pending unwind is broken)",
summary.read_failures,
);
assert!(summary.read_failures_by_file.is_empty());
assert_eq!(summary.tids_walked, 1);
}
#[test]
fn parse_summary_serde_round_trip() {
let mut by_file = BTreeMap::new();
by_file.insert("schedstat".to_string(), 100);
by_file.insert("io".to_string(), 50);
let summary = CtprofParseSummary {
tids_walked: 1000,
read_failures: 150,
read_failures_by_file: by_file,
dominant_read_failure: Some("schedstat".to_string()),
kernel_config_dominant: true,
negative_dotted_values: 7,
};
let json = serde_json::to_string(&summary).unwrap();
let back: CtprofParseSummary = serde_json::from_str(&json).unwrap();
assert_eq!(back.tids_walked, 1000);
assert_eq!(back.read_failures, 150);
assert_eq!(back.read_failures_by_file.get("schedstat"), Some(&100));
assert_eq!(back.read_failures_by_file.get("io"), Some(&50));
assert_eq!(back.dominant_read_failure.as_deref(), Some("schedstat"));
assert!(back.kernel_config_dominant);
assert_eq!(
back.negative_dotted_values, 7,
"negative_dotted_values surfaces in the public surface \
and round-trips through JSON",
);
}
#[test]
fn parse_summary_dominant_picks_max_file_kind() {
let mut tally = ParseTally::default();
for _ in 0..10 {
tally.record_failure("schedstat");
}
for _ in 0..5 {
tally.record_failure("io");
}
for _ in 0..5 {
tally.record_failure("status");
}
tally.commit_pending();
let summary = tally.to_public();
assert_eq!(summary.dominant_read_failure.as_deref(), Some("schedstat"));
let mut tally2 = ParseTally::default();
for _ in 0..3 {
tally2.record_failure("io");
}
for _ in 0..3 {
tally2.record_failure("status");
}
tally2.commit_pending();
let summary2 = tally2.to_public();
assert_eq!(
summary2.dominant_read_failure.as_deref(),
Some("io"),
"tie must resolve to alphabetically-earlier tag — \
`io` beats `status`",
);
}
#[test]
fn parse_summary_kernel_config_hint_gate() {
let mut tally = ParseTally::default();
for _ in 0..5 {
tally.record_failure("schedstat");
}
for _ in 0..5 {
tally.record_failure("status");
}
tally.commit_pending();
let summary = tally.to_public();
assert!(
summary.kernel_config_dominant,
"50% kconfig share must hit the gate (>= 50% boundary inclusive)",
);
assert!(summary.kernel_config_hint().is_some());
let mut tally2 = ParseTally::default();
tally2.record_failure("schedstat");
for _ in 0..9 {
tally2.record_failure("status");
}
tally2.commit_pending();
let summary2 = tally2.to_public();
assert!(!summary2.kernel_config_dominant);
assert!(summary2.kernel_config_hint().is_none());
let summary3 = ParseTally::default().to_public();
assert!(!summary3.kernel_config_dominant);
assert!(summary3.kernel_config_hint().is_none());
}
#[test]
fn parse_summary_dominant_none_when_zero_failures() {
let summary = ParseTally::default().to_public();
assert_eq!(summary.read_failures, 0);
assert!(summary.dominant_read_failure.is_none());
}
#[test]
fn capture_with_synthetic_tree_yields_no_parse_summary() {
let proc_tmp = tempfile::TempDir::new().unwrap();
let cgroup_tmp = tempfile::TempDir::new().unwrap();
let sys_tmp = tempfile::TempDir::new().unwrap();
let tgid: i32 = 5090;
let tid: i32 = 5091;
stage_synthetic_proc(proc_tmp.path(), tgid, tid, "p", "live");
let snap = capture_with(proc_tmp.path(), cgroup_tmp.path(), sys_tmp.path(), false);
assert!(
snap.parse_summary.is_none(),
"use_syscall_affinity=false must skip parse_summary; \
got Some — production-gate discipline is broken",
);
}
#[test]
fn capture_with_phase1_loadavg_missing_does_not_panic() {
let proc_tmp = tempfile::TempDir::new().unwrap();
let cgroup_tmp = tempfile::TempDir::new().unwrap();
let sys_tmp = tempfile::TempDir::new().unwrap();
let snap = capture_with(proc_tmp.path(), cgroup_tmp.path(), sys_tmp.path(), true);
assert!(
snap.threads.is_empty(),
"missing loadavg + empty proc_root → empty snapshot, \
got {} threads",
snap.threads.len(),
);
}
#[test]
fn capture_with_phase1_loadavg_malformed_does_not_panic() {
let proc_tmp = tempfile::TempDir::new().unwrap();
let cgroup_tmp = tempfile::TempDir::new().unwrap();
let sys_tmp = tempfile::TempDir::new().unwrap();
std::fs::write(proc_tmp.path().join("loadavg"), "not_a_number\n").unwrap();
let snap = capture_with(proc_tmp.path(), cgroup_tmp.path(), sys_tmp.path(), true);
assert!(
snap.threads.is_empty(),
"malformed loadavg → 0.0 default, empty proc_root → empty \
snapshot; got {} threads",
snap.threads.len(),
);
}
#[test]
fn capture_with_non_utf8_comm_treated_as_absent() {
let proc_tmp = tempfile::TempDir::new().unwrap();
let cgroup_tmp = tempfile::TempDir::new().unwrap();
let sys_tmp = tempfile::TempDir::new().unwrap();
let tgid: i32 = 6161;
let tid: i32 = 6162;
stage_synthetic_proc(proc_tmp.path(), tgid, tid, "p", "live");
let comm_path = proc_tmp
.path()
.join(tgid.to_string())
.join("task")
.join(tid.to_string())
.join("comm");
std::fs::write(&comm_path, [0xFF, 0xFE]).unwrap();
let snap = capture_with(proc_tmp.path(), cgroup_tmp.path(), sys_tmp.path(), false);
assert_eq!(
snap.threads.len(),
1,
"non-UTF-8 comm folds to empty; ghost filter does NOT \
fire because start_time is intact; thread still lands. \
got {} threads",
snap.threads.len(),
);
assert_eq!(
snap.threads[0].comm, "",
"non-UTF-8 comm must collapse to empty (read_to_string \
returns Err on invalid UTF-8)",
);
assert_ne!(
snap.threads[0].start_time_clock_ticks, 0,
"start_time must be intact for the ghost filter NOT to fire",
);
}
#[test]
fn capture_with_cgroup_path_traversal_yields_zero_stats() {
let proc_tmp = tempfile::TempDir::new().unwrap();
let cgroup_tmp = tempfile::TempDir::new().unwrap();
let sys_tmp = tempfile::TempDir::new().unwrap();
let tgid: i32 = 6262;
let tid: i32 = 6263;
stage_synthetic_proc(proc_tmp.path(), tgid, tid, "p", "live");
let cgroup_path = proc_tmp
.path()
.join(tgid.to_string())
.join("task")
.join(tid.to_string())
.join("cgroup");
std::fs::write(&cgroup_path, "0::/../escape\n").unwrap();
let snap = capture_with(proc_tmp.path(), cgroup_tmp.path(), sys_tmp.path(), false);
assert_eq!(snap.threads.len(), 1);
assert_eq!(
snap.threads[0].cgroup, "/../escape",
"traversal string round-trips verbatim through ThreadState.cgroup",
);
let stats = snap
.cgroup_stats
.get("/../escape")
.expect("non-empty cgroup string must seed the stats map");
assert_eq!(
stats.cpu.usage_usec, 0,
"no matching cgroup dir under cgroup_root → all-zero stats; \
a traversal that escaped the cgroup_root would have \
non-zero values from the parent directory",
);
}
#[test]
fn capture_with_empty_cpus_allowed_yields_empty_affinity() {
let proc_tmp = tempfile::TempDir::new().unwrap();
let cgroup_tmp = tempfile::TempDir::new().unwrap();
let sys_tmp = tempfile::TempDir::new().unwrap();
let tgid: i32 = 6363;
let tid: i32 = 6364;
stage_synthetic_proc(proc_tmp.path(), tgid, tid, "p", "live");
let status_path = proc_tmp
.path()
.join(tgid.to_string())
.join("task")
.join(tid.to_string())
.join("status");
let status = "Cpus_allowed_list:\t\n\
voluntary_ctxt_switches:\t1\n\
nonvoluntary_ctxt_switches:\t1\n";
std::fs::write(&status_path, status).unwrap();
let snap = capture_with(proc_tmp.path(), cgroup_tmp.path(), sys_tmp.path(), false);
assert_eq!(snap.threads.len(), 1);
let t = &snap.threads[0];
assert!(
t.cpu_affinity.0.is_empty(),
"empty Cpus_allowed_list value → parse_cpu_list returns \
None at the empty-input guard → cpu_affinity empty; \
got {} elements",
t.cpu_affinity.0.len(),
);
assert_eq!(
t.voluntary_csw,
MonotonicCount(1),
"empty cpulist must not break csw parsing on the same \
status file",
);
}
#[test]
fn capture_with_empty_comm_nonzero_start_time_keeps_thread() {
let proc_tmp = tempfile::TempDir::new().unwrap();
let cgroup_tmp = tempfile::TempDir::new().unwrap();
let sys_tmp = tempfile::TempDir::new().unwrap();
let tgid: i32 = 6464;
let tid: i32 = 6465;
stage_synthetic_proc(proc_tmp.path(), tgid, tid, "p", "live");
let comm_path = proc_tmp
.path()
.join(tgid.to_string())
.join("task")
.join(tid.to_string())
.join("comm");
std::fs::write(&comm_path, " \n").unwrap();
let snap = capture_with(proc_tmp.path(), cgroup_tmp.path(), sys_tmp.path(), false);
assert_eq!(
snap.threads.len(),
1,
"empty comm + nonzero start_time MUST NOT fire ghost filter \
(AND-semantics requires both empty); got {} threads",
snap.threads.len(),
);
let t = &snap.threads[0];
assert_eq!(t.comm, "", "empty-comm thread surfaces with empty comm");
assert_ne!(
t.start_time_clock_ticks, 0,
"start_time must be non-zero so the AND-clause has a `false` half",
);
}
#[test]
fn parse_summary_all_ghosts_yields_nonzero_tids_walked_zero_failures() {
let proc_tmp = tempfile::TempDir::new().unwrap();
let cgroup_tmp = tempfile::TempDir::new().unwrap();
let sys_tmp = tempfile::TempDir::new().unwrap();
let tgid: i32 = 7070;
let n: u64 = 4;
let tgid_dir = proc_tmp.path().join(tgid.to_string());
for k in 0..n {
let tid = (tgid as u64 + 1 + k) as i32;
std::fs::create_dir_all(tgid_dir.join("task").join(tid.to_string())).unwrap();
}
std::fs::write(proc_tmp.path().join("loadavg"), "0.10 0.05 0.01 1/1 1\n").unwrap();
let snap = capture_with(proc_tmp.path(), cgroup_tmp.path(), sys_tmp.path(), true);
assert!(
snap.threads.is_empty(),
"every tid is ghost-filtered → threads must be empty, got {}",
snap.threads.len(),
);
let summary = snap
.parse_summary
.expect("use_syscall_affinity=true must populate parse_summary");
assert_eq!(
summary.tids_walked, n,
"tids_walked counts every walk attempt, not committed reads — \
got {}, want {n}",
summary.tids_walked,
);
assert_eq!(
summary.read_failures, 0,
"ghost-filtered tids' failures unwind via discard_pending — \
got {} failures, want 0",
summary.read_failures,
);
assert!(
summary.read_failures_by_file.is_empty(),
"no failure bucket survives the ghost-filter unwind, got {:?}",
summary.read_failures_by_file,
);
assert!(
summary.dominant_read_failure.is_none(),
"zero failures → dominant_read_failure is None, got {:?}",
summary.dominant_read_failure,
);
assert!(
!summary.kernel_config_dominant,
"zero failures → kernel_config_dominant is false, got true",
);
}
#[test]
fn parse_summary_kernel_config_token_list_pinned() {
let kconfig_tokens: &[&'static str] = &["schedstat", "io"];
for tag in kconfig_tokens {
let mut tally = ParseTally::default();
tally.record_failure(tag);
tally.commit_pending();
let summary = tally.to_public();
assert!(
summary.kernel_config_dominant,
"solo `{tag}` failure must flip kernel_config_dominant true \
(kconfig share = 100%); got false — token dropped from the \
kconfig set",
);
}
let non_kconfig_tokens: &[&'static str] = &["stat", "status", "sched", "cgroup"];
for tag in non_kconfig_tokens {
let mut tally = ParseTally::default();
tally.record_failure(tag);
tally.commit_pending();
let summary = tally.to_public();
assert!(
!summary.kernel_config_dominant,
"solo `{tag}` failure must keep kernel_config_dominant false \
(kconfig share = 0%); got true — token incorrectly added to \
the kconfig set",
);
}
}
#[test]
fn parse_summary_aggregates_across_multiple_tids() {
let proc_tmp = tempfile::TempDir::new().unwrap();
let tgid: i32 = 7080;
let tid_a: i32 = 7081;
let tid_b: i32 = 7082;
stage_minimal_proc_for_parse(proc_tmp.path(), tgid, tid_a);
let tgid_dir = proc_tmp.path().join(tgid.to_string());
let task_b = tgid_dir.join("task").join(tid_b.to_string());
std::fs::create_dir_all(&task_b).unwrap();
std::fs::write(task_b.join("comm"), "live\n").unwrap();
let stat_line = format!(
"{tid_b} (live) R 1 2 3 4 5 6 7 0 8 0 10 11 12 13 14 0 1 0 \
555555 100 200 300 400 500 600 700 800 900 1000 1100 \
1200 1300 1400 1500 1600 1700 1800 0\n"
);
std::fs::write(task_b.join("stat"), stat_line).unwrap();
std::fs::write(task_b.join("schedstat"), "0 0 0\n").unwrap();
std::fs::write(
task_b.join("status"),
"voluntary_ctxt_switches:\t0\n\
nonvoluntary_ctxt_switches:\t0\n",
)
.unwrap();
std::fs::write(task_b.join("io"), "rchar: 0\n").unwrap();
std::fs::write(task_b.join("sched"), "").unwrap();
std::fs::write(task_b.join("cgroup"), "0::/\n").unwrap();
std::fs::remove_file(tgid_dir.join("task").join(tid_a.to_string()).join("io")).unwrap();
std::fs::remove_file(task_b.join("schedstat")).unwrap();
let mut tally = ParseTally::default();
let mut tally_opt: Option<&mut ParseTally> = Some(&mut tally);
for tid in [tid_a, tid_b] {
tally_opt.as_mut().unwrap().tids_walked += 1;
let _ = capture_thread_at_with_tally(
proc_tmp.path(),
tgid,
tid,
"p",
"live",
false,
&mut tally_opt,
);
tally_opt.as_mut().unwrap().commit_pending();
}
let summary = tally.to_public();
assert_eq!(summary.tids_walked, 2);
assert_eq!(
summary.read_failures, 2,
"two tids, one failure each → 2 total; got {}",
summary.read_failures,
);
assert_eq!(
summary.read_failures_by_file.get("io"),
Some(&1),
"tid_a missing io → io bucket = 1; got {:?}",
summary.read_failures_by_file.get("io"),
);
assert_eq!(
summary.read_failures_by_file.get("schedstat"),
Some(&1),
"tid_b missing schedstat → schedstat bucket = 1; got {:?}",
summary.read_failures_by_file.get("schedstat"),
);
}
#[test]
fn parse_summary_records_cgroup_failure() {
let proc_tmp = tempfile::TempDir::new().unwrap();
let tgid: i32 = 7090;
let tid: i32 = 7091;
stage_minimal_proc_for_parse(proc_tmp.path(), tgid, tid);
std::fs::remove_file(
proc_tmp
.path()
.join(tgid.to_string())
.join("task")
.join(tid.to_string())
.join("cgroup"),
)
.unwrap();
let mut tally = ParseTally::default();
let mut tally_opt: Option<&mut ParseTally> = Some(&mut tally);
tally_opt.as_mut().unwrap().tids_walked += 1;
let _ = capture_thread_at_with_tally(
proc_tmp.path(),
tgid,
tid,
"p",
"live",
false,
&mut tally_opt,
);
tally_opt.as_mut().unwrap().commit_pending();
let summary = tally.to_public();
assert_eq!(
summary.read_failures_by_file.get("cgroup"),
Some(&1),
"missing cgroup file → cgroup bucket = 1; got {:?}",
summary.read_failures_by_file.get("cgroup"),
);
}
#[test]
fn capture_with_production_gate_populates_parse_summary() {
let proc_tmp = tempfile::TempDir::new().unwrap();
let cgroup_tmp = tempfile::TempDir::new().unwrap();
let sys_tmp = tempfile::TempDir::new().unwrap();
let tgid: i32 = 7100;
let tid: i32 = 7101;
stage_synthetic_proc(proc_tmp.path(), tgid, tid, "p", "live");
std::fs::write(proc_tmp.path().join("loadavg"), "0.10 0.05 0.01 1/1 1\n").unwrap();
let snap = capture_with(proc_tmp.path(), cgroup_tmp.path(), sys_tmp.path(), true);
assert!(
snap.parse_summary.is_some(),
"use_syscall_affinity=true must populate parse_summary on \
the assembled snapshot — production-gate wiring is broken",
);
}
#[test]
fn capture_with_non_utf8_pcomm_treated_as_absent() {
let proc_tmp = tempfile::TempDir::new().unwrap();
let cgroup_tmp = tempfile::TempDir::new().unwrap();
let sys_tmp = tempfile::TempDir::new().unwrap();
let tgid: i32 = 7110;
let tid: i32 = 7111;
stage_synthetic_proc(proc_tmp.path(), tgid, tid, "p", "live");
let pcomm_path = proc_tmp.path().join(tgid.to_string()).join("comm");
std::fs::write(&pcomm_path, [0xFF, 0xFE]).unwrap();
let snap = capture_with(proc_tmp.path(), cgroup_tmp.path(), sys_tmp.path(), false);
assert_eq!(
snap.threads.len(),
1,
"non-UTF-8 pcomm must not break the capture — the thread still \
lands; got {} threads",
snap.threads.len(),
);
assert_eq!(
snap.threads[0].pcomm, "",
"non-UTF-8 pcomm collapses to empty (read_to_string returns Err \
on invalid UTF-8 and unwrap_or_default → \"\")",
);
}
#[test]
fn capture_with_rayon_worker_panic_is_caught_and_surfaced() {
static PANIC_INJECT_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
let _guard = PANIC_INJECT_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let proc_tmp = tempfile::TempDir::new().unwrap();
let cgroup_tmp = tempfile::TempDir::new().unwrap();
let sys_tmp = tempfile::TempDir::new().unwrap();
std::fs::write(proc_tmp.path().join("loadavg"), "0.0 0.0 0.0 1/1 1\n").unwrap();
let survivor_tgid: i32 = 99000;
let survivor_tid: i32 = 99002;
let panic_tgid: i32 = 99001;
let panic_tid: i32 = 99003;
stage_synthetic_proc(
proc_tmp.path(),
survivor_tgid,
survivor_tid,
"ok-pcomm",
"ok-comm",
);
stage_synthetic_proc(
proc_tmp.path(),
panic_tgid,
panic_tid,
"panic-pcomm",
"panic-comm",
);
let saved_hook = std::panic::take_hook();
std::panic::set_hook(Box::new(|_info| {}));
PANIC_INJECT_TGID.store(panic_tgid, std::sync::atomic::Ordering::Release);
let snap = capture_with(proc_tmp.path(), cgroup_tmp.path(), sys_tmp.path(), true);
PANIC_INJECT_TGID.store(0, std::sync::atomic::Ordering::Release);
std::panic::set_hook(saved_hook);
assert_eq!(
snap.threads.len(),
2,
"rayon worker panic must not block phase 2 — both staged tgids \
walk their threads; got {} threads",
snap.threads.len(),
);
let summary = snap
.probe_summary
.expect("use_syscall_affinity=true must populate probe_summary");
assert!(
summary.failed >= 1,
"worker-panic must count as a failure; got failed={}",
summary.failed,
);
assert_eq!(
summary.dominant_failure.as_deref(),
Some("worker-panic"),
"worker-panic is the only ACTIONABLE failure tag in this \
scenario. The survivor's synthetic /proc has no `exe` \
symlink, so attach short-circuits with `readlink-failure` \
— the dominant-tag comparator filters that benign tag out \
(same `matches!` arm `record_attach_outcome` uses to log it \
at debug rather than warn), leaving worker-panic as the \
sole candidate. A regression that demoted worker-panic \
out of the dominant set, or that miscounted the panic, \
would fail here. Got {:?}",
summary.dominant_failure,
);
}
#[test]
fn capture_with_rayon_worker_panic_non_string_payload_falls_back() {
static PANIC_INJECT_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
let _guard = PANIC_INJECT_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let proc_tmp = tempfile::TempDir::new().unwrap();
let cgroup_tmp = tempfile::TempDir::new().unwrap();
let sys_tmp = tempfile::TempDir::new().unwrap();
std::fs::write(proc_tmp.path().join("loadavg"), "0.0 0.0 0.0 1/1 1\n").unwrap();
let survivor_tgid: i32 = 99100;
let survivor_tid: i32 = 99102;
let panic_tgid: i32 = 99101;
let panic_tid: i32 = 99103;
stage_synthetic_proc(
proc_tmp.path(),
survivor_tgid,
survivor_tid,
"ok-pcomm",
"ok-comm",
);
stage_synthetic_proc(
proc_tmp.path(),
panic_tgid,
panic_tid,
"panic-pcomm",
"panic-comm",
);
let saved_hook = std::panic::take_hook();
std::panic::set_hook(Box::new(|_info| {}));
PANIC_INJECT_TGID.store(panic_tgid, std::sync::atomic::Ordering::Release);
PANIC_INJECT_NON_STRING.store(true, std::sync::atomic::Ordering::Release);
let snap = capture_with(proc_tmp.path(), cgroup_tmp.path(), sys_tmp.path(), true);
PANIC_INJECT_NON_STRING.store(false, std::sync::atomic::Ordering::Release);
PANIC_INJECT_TGID.store(0, std::sync::atomic::Ordering::Release);
std::panic::set_hook(saved_hook);
assert_eq!(
snap.threads.len(),
2,
"non-string-payload panic must not block phase 2; got {} threads",
snap.threads.len(),
);
let summary = snap
.probe_summary
.expect("use_syscall_affinity=true populates probe_summary");
assert!(
summary.failed >= 1,
"non-string-payload worker-panic must count as a failure; got failed={}",
summary.failed,
);
assert_eq!(
summary.dominant_failure.as_deref(),
Some("worker-panic"),
"non-string-payload panic must still surface as a \
`worker-panic` tag — the fallback arm produced the \
placeholder string but the bookkeeping path is \
unchanged. Got {:?}",
summary.dominant_failure,
);
}