use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
#[serde(tag = "kind")]
#[allow(dead_code)] pub enum ScxExitKind {
#[default]
Unclassified,
Error,
Stall,
Normal,
Other,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[non_exhaustive]
#[allow(dead_code)]
pub struct ScxExitEvent {
pub scheduler_name: String,
#[serde(default = "default_exit_kind")]
pub kind: ScxExitKind,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub message: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub stuck_task_comm: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub stack: Vec<StackSymbol>,
}
fn default_exit_kind() -> ScxExitKind {
ScxExitKind::Unclassified
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct StackSymbol {
pub name: String,
pub offset: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub size: Option<u64>,
pub raw: String,
}
const ANCHOR_PREFIX: &str = "sched_ext: BPF scheduler \"";
#[allow(dead_code)]
pub fn parse_kmsg_window(text: &str) -> Vec<ScxExitEvent> {
let mut events = Vec::new();
let lines: Vec<&str> = text.lines().collect();
let mut i = 0;
while i < lines.len() {
let line = lines[i];
if let Some(anchor_pos) = line.find(ANCHOR_PREFIX) {
let after = &line[anchor_pos + ANCHOR_PREFIX.len()..];
let scheduler_name = after.split('"').next().unwrap_or("").to_string();
let message_body = after
.split_once('(')
.map(|(_, m)| m.trim_end_matches(')').trim().to_string())
.unwrap_or_default();
let mut frames: Vec<StackSymbol> = Vec::new();
let mut full_message = message_body.clone();
let mut j = i + 1;
while j < lines.len() {
let next = lines[j];
if next.contains(ANCHOR_PREFIX) {
break;
}
let mut new_frames = extract_stack_symbols(next);
if !new_frames.is_empty() {
frames.append(&mut new_frames);
} else if !next.trim().is_empty() {
let trimmed = next.trim();
if trimmed.ends_with("Call Trace:")
|| trimmed == "<TASK>"
|| trimmed == "</TASK>"
|| trimmed.ends_with("<TASK>")
|| trimmed.ends_with("</TASK>")
{
j += 1;
continue;
}
let lower = next.to_ascii_lowercase();
if !(lower.contains("sched_ext")
|| lower.contains("scx_")
|| lower.contains("bpf"))
{
break;
}
if !full_message.is_empty() {
full_message.push(' ');
}
full_message.push_str(next.trim());
}
j += 1;
}
let stuck_task_comm = extract_stuck_task_comm(&full_message);
let kind = classify_exit_kind(&full_message, &frames);
events.push(ScxExitEvent {
scheduler_name,
kind,
message: full_message,
stuck_task_comm,
stack: frames,
});
i = j;
continue;
}
i += 1;
}
events
}
pub fn extract_stack_symbols(line: &str) -> Vec<StackSymbol> {
let mut frames = Vec::new();
for token in line.split_whitespace() {
let Some(plus) = token.find("+0x") else {
continue;
};
let name_part = &token[..plus];
if name_part.is_empty() {
continue;
}
if !name_part.chars().all(is_kernel_symbol_char) {
continue;
}
let after_plus = &token[plus + 3..]; let (off_str, size_str) = match after_plus.split_once('/') {
Some((off, rest)) => {
let s = rest.strip_prefix("0x").unwrap_or(rest);
let s = s.trim_end_matches(|c: char| !c.is_ascii_hexdigit());
(off, Some(s))
}
None => {
let off = after_plus.trim_end_matches(|c: char| !c.is_ascii_hexdigit());
(off, None)
}
};
let Ok(offset) = u64::from_str_radix(off_str, 16) else {
continue;
};
let size = size_str.and_then(|s| u64::from_str_radix(s, 16).ok());
frames.push(StackSymbol {
name: name_part.to_string(),
offset,
size,
raw: token.to_string(),
});
}
frames
}
fn classify_exit_kind(message: &str, stack: &[StackSymbol]) -> ScxExitKind {
let lower = message.to_ascii_lowercase();
if lower.contains("watchdog")
|| lower.contains("stall")
|| lower.contains("stuck")
|| stack
.iter()
.any(|f| f.name == "check_rq_for_timeouts" || f.name == "scx_watchdog_workfn")
{
return ScxExitKind::Stall;
}
if lower.contains("aborting")
|| lower.contains("error")
|| lower.contains("ebpf")
|| lower.contains("enabled") && lower.contains("disabled")
{
return ScxExitKind::Error;
}
if (lower.contains("unloaded") || lower.contains("removed") || lower.contains("done"))
&& stack.is_empty()
{
return ScxExitKind::Normal;
}
if !stack.is_empty() {
return ScxExitKind::Error;
}
ScxExitKind::Other
}
fn extract_stuck_task_comm(message: &str) -> Option<String> {
const TASK_COMM_LEN: usize = 16;
let mut search_from = 0;
while let Some(rel) = message[search_from..].find("task ") {
let idx = search_from + rel;
let after = &message[idx + 5..];
let token = after.split_whitespace().next().unwrap_or("");
if let Some((comm, pid_part)) = token.split_once(':') {
let pid_digits: String = pid_part
.chars()
.take_while(|c| c.is_ascii_digit())
.collect();
if !pid_digits.is_empty() {
let comm =
comm.trim_matches(|c: char| !c.is_alphanumeric() && c != '_' && c != '-');
if !comm.is_empty() {
let bounded: String = comm.chars().take(TASK_COMM_LEN).collect();
return Some(bounded);
}
}
}
search_from = idx + 5;
}
if let Some(idx) = message.find("comm=") {
let after = &message[idx + 5..];
let token = after
.split(|c: char| c.is_whitespace() || c == ',' || c == ')')
.next()?
.trim_matches('"');
if !token.is_empty() {
let bounded: String = token.chars().take(TASK_COMM_LEN).collect();
return Some(bounded);
}
}
None
}
fn is_kernel_symbol_char(c: char) -> bool {
c.is_ascii_alphanumeric() || c == '_' || c == '.'
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn extract_single_frame_with_size() {
let frames = extract_stack_symbols("? scx_watchdog_workfn+0x123/0x456");
assert_eq!(frames.len(), 1);
assert_eq!(frames[0].name, "scx_watchdog_workfn");
assert_eq!(frames[0].offset, 0x123);
assert_eq!(frames[0].size, Some(0x456));
assert_eq!(frames[0].raw, "scx_watchdog_workfn+0x123/0x456");
}
#[test]
fn extract_frame_without_size() {
let frames = extract_stack_symbols("scx_disable_workfn+0x42");
assert_eq!(frames.len(), 1);
assert_eq!(frames[0].name, "scx_disable_workfn");
assert_eq!(frames[0].offset, 0x42);
assert_eq!(frames[0].size, None);
}
#[test]
fn extract_multiple_frames_one_line() {
let frames = extract_stack_symbols("? func_a+0x10/0x20 func_b+0x30/0x40 func_c+0x50");
assert_eq!(frames.len(), 3);
assert_eq!(frames[0].name, "func_a");
assert_eq!(frames[1].name, "func_b");
assert_eq!(frames[2].name, "func_c");
assert_eq!(frames[2].size, None);
}
#[test]
fn extract_frame_with_dot_in_name() {
let frames = extract_stack_symbols("scx_dispatch_q.cold+0x10/0x40");
assert_eq!(frames.len(), 1);
assert_eq!(frames[0].name, "scx_dispatch_q.cold");
assert_eq!(frames[0].offset, 0x10);
}
#[test]
fn extract_no_frames_from_plain_text() {
let frames = extract_stack_symbols(
"[12345.678] sched_ext: BPF scheduler \"foo\" disabled (operator request)",
);
assert!(frames.is_empty());
}
#[test]
fn parse_kmsg_window_simple_error() {
use crate::assert::Verdict;
let text = "\
[12345.678] sched_ext: BPF scheduler \"scx_test\" disabled (BPF runtime error)
[12345.679] scx_test: aborting due to BPF runtime error
[12345.680] Call Trace:
[12345.681] ? scx_disable_workfn+0x100/0x200
[12345.682] ? scx_internal_disable+0x50/0x100
[12345.683] ? scx_error+0x30/0x80
";
let events = parse_kmsg_window(text);
let event_count = events.len();
assert_eq!(event_count, 1, "expected exactly one event");
let ev = &events[0];
let scheduler_name = ev.scheduler_name.clone();
let kind_is_error = matches!(ev.kind, ScxExitKind::Error);
let message_has_runtime_error = ev.message.contains("BPF runtime error");
let stack_len = ev.stack.len();
let first_frame_name = ev.stack[0].name.clone();
let mut v = Verdict::new();
crate::claim!(v, scheduler_name).eq("scx_test".to_string());
crate::claim!(v, kind_is_error).eq(true);
crate::claim!(v, message_has_runtime_error).eq(true);
crate::claim!(v, stack_len).eq(3usize);
crate::claim!(v, first_frame_name).eq("scx_disable_workfn".to_string());
let r = v.into_result();
assert!(
r.passed,
"kmsg parse drift on canonical error event: {:?}",
r.details,
);
}
#[test]
fn parse_kmsg_window_stall_classification() {
let text = "\
[1.0] sched_ext: BPF scheduler \"scx_test\" disabled (runnable task stall)
[1.1] scx_test: stalled task hot_path:1234 not dispatched
[1.2] Call Trace:
[1.3] <TASK>
[1.4] ? check_rq_for_timeouts+0x50/0x100
[1.5] ? scx_watchdog_workfn+0x10/0x80
[1.6] </TASK>
";
let events = parse_kmsg_window(text);
assert_eq!(events.len(), 1);
assert_eq!(events[0].kind, ScxExitKind::Stall);
assert_eq!(events[0].stack.len(), 2);
assert_eq!(events[0].stuck_task_comm.as_deref(), Some("hot_path"));
}
#[test]
fn extract_stuck_task_comm_patterns() {
assert_eq!(
extract_stuck_task_comm("stalled task foo:1234 stuck"),
Some("foo".to_string())
);
assert_eq!(
extract_stuck_task_comm("operator complaint about comm=bar)"),
Some("bar".to_string())
);
assert_eq!(
extract_stuck_task_comm("task this_is_a_very_long_task_name_too_long:1"),
Some("this_is_a_very_l".to_string())
);
assert_eq!(extract_stuck_task_comm("no patterns here"), None);
}
#[test]
fn parse_kmsg_window_multiple_events() {
let text = "\
[1.0] sched_ext: BPF scheduler \"scx_a\" disabled (manual unload)
[2.0] sched_ext: BPF scheduler \"scx_b\" disabled (BPF runtime error)
[2.1] ? scx_disable_workfn+0x100/0x200
";
let events = parse_kmsg_window(text);
assert_eq!(events.len(), 2);
assert_eq!(events[0].scheduler_name, "scx_a");
assert_eq!(events[1].scheduler_name, "scx_b");
assert_eq!(events[1].stack.len(), 1);
}
#[test]
fn parse_kmsg_window_no_anchor() {
let text = "\
[1.0] kernel: random unrelated message
[1.1] systemd: started service
";
let events = parse_kmsg_window(text).len();
assert_eq!(events, 0);
}
#[test]
fn scx_exit_event_serde_skips_empty() {
let ev = ScxExitEvent {
scheduler_name: "scx_test".into(),
kind: ScxExitKind::Normal,
message: String::new(),
stuck_task_comm: None,
stack: Vec::new(),
};
let json = serde_json::to_string(&ev).unwrap();
assert!(!json.contains("message"));
assert!(!json.contains("stuck_task_comm"));
assert!(!json.contains("stack"));
assert!(json.contains("scx_test"));
assert!(json.contains("Normal"));
}
}