ktstr/monitor/
dmesg_scx.rs

1//! Parse `sched_ext` disable events from kernel-message buffer
2//! (dmesg / `/dev/kmsg`) output.
3//!
4//! Companion to [`super::live_host_kernel`] (kernel discovery). The
5//! live-host pipeline tails `/dev/kmsg`, anchors on the kernel's
6//! `sched_ext: BPF scheduler "X" disabled (...)` line, and extracts
7//! the stack-trace symbols the kernel printed via `%pS`. Those
8//! symbol names feed the auto-repro instrumentation: the live-host
9//! producer attaches kprobes / fentry probes to the discovered
10//! functions to capture their arguments + timing on the next failure.
11//!
12//! # Why parse dmesg
13//!
14//! The kernel emits stack traces for error-class scx exits via
15//! `pr_err` (kernel/sched/ext.c `stack_trace_print(ei->bt,
16//! ei->bt_len, 2)` in `scx_root_disable`'s
17//! `if (ei->kind >= SCX_EXIT_ERROR)` arm), gated by
18//! `CONFIG_STACKTRACE`. The stack frames are pre-captured into
19//! `ei->bt` at exit time — the print path just renders the
20//! cached snapshot, so the trace reflects the BPF-prog state at
21//! the failure instant, not at print time. The `%pS` format
22//! renders kernel addresses as `funcname+0xoff/0xsz` — readable
23//! AND symbolic, so the live-host pipeline can extract function
24//! names without doing its own kallsyms walk against raw
25//! addresses (the alternative path).
26//!
27//! For STALL-class exits the stack is the WATCHDOG KTHREAD's stack
28//! (`check_rq_for_timeouts` → `scx_watchdog_workfn`), NOT the BPF
29//! scheduler's path. The parser surfaces the `kind` field so callers
30//! can distinguish "stack tells us where the BPF prog hung" vs
31//! "stack tells us the watchdog noticed a stuck task — go probe the
32//! BPF ops callbacks via the fallback path".
33//!
34//! # Async timing
35//!
36//! dmesg lines arrive 100-500ms after the actual `scx_exit` call
37//! (the kernel buffers prints through klogd). The library is purely
38//! a parser — it doesn't do timing or polling. The production
39//! consumer is the eval harness ([`crate::test_support`]'s eval
40//! module), which calls [`parse_kmsg_window`] on the captured VM
41//! console (`result.stderr`) to attribute a scheduler exit when a
42//! test times out or a scheduled test produces no result.
43
44use serde::{Deserialize, Serialize};
45
46/// Kind of scx exit event extracted from dmesg.
47///
48/// Distinguishes the source of the printed stack so the auto-repro
49/// pipeline knows whether to trust the stack as "where the BPF
50/// scheduler hung" or "where the watchdog noticed the hang".
51#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
52#[non_exhaustive]
53#[serde(tag = "kind")]
54#[allow(dead_code)] // the eval harness consumes scheduler_name/message;
55// kind/stack/stuck_task_comm and these variants are exercised only by tests.
56pub enum ScxExitKind {
57    /// Default — used by `ScxExitEvent::default()` in test fixtures
58    /// and as the post-anchor placeholder before classification
59    /// runs. Real parsed events overwrite this with one of the
60    /// classified variants below.
61    #[default]
62    Unclassified,
63    /// Operator-error exit (`scx_error()` called from BPF program
64    /// or from kernel-side validation). Stack trace is the BPF
65    /// caller's path — useful for direct probe placement.
66    Error,
67    /// Watchdog-detected stall (`check_rq_for_timeouts` fired).
68    /// Stack trace is the watchdog kthread, NOT the BPF scheduler.
69    /// Auto-repro fallback: probe all BPF ops callbacks since the
70    /// causal callback is not directly recoverable from the watchdog
71    /// stack.
72    Stall,
73    /// Normal disable — ops.exit() called cleanly. No error stack.
74    Normal,
75    /// A scx event line was detected but the kind couldn't be
76    /// classified from the surrounding text. Treat as unknown
77    /// classification rather than dropping the event.
78    Other,
79}
80
81/// One parsed scx exit event from a kernel-message buffer window.
82#[derive(Debug, Clone, Default, Serialize, Deserialize)]
83#[non_exhaustive]
84#[allow(dead_code)]
85pub struct ScxExitEvent {
86    /// Scheduler name extracted from `sched_ext: BPF scheduler "<name>" disabled (...)`.
87    pub scheduler_name: String,
88    /// Exit-kind classification. Defaults to
89    /// [`ScxExitKind::Unclassified`] via the `#[default]` arm on the
90    /// enum, so an absent `kind` field round-trips back to the
91    /// pre-classification placeholder.
92    #[serde(default)]
93    pub kind: ScxExitKind,
94    /// Exit message aggregated from the kernel prints. The
95    /// parenthesized text in the anchor line is `ei->reason`
96    /// (kernel/sched/ext.c:6005/6014); the follow-on
97    /// `sched_ext: <name>: <msg>` pr_err line (ext.c:6008) carries
98    /// `ei->msg`. Empty when the kernel emitted no message body.
99    #[serde(default, skip_serializing_if = "String::is_empty")]
100    pub message: String,
101    /// Stuck task COMM (16-byte limit per `TASK_COMM_LEN`)
102    /// extracted from the message body when the parser detects
103    /// `task <COMM>:<pid>` or `pid <pid>` patterns. `None` when
104    /// the kernel didn't print a stuck-task identifier (typical
105    /// for normal exits).
106    #[serde(default, skip_serializing_if = "Option::is_none")]
107    pub stuck_task_comm: Option<String>,
108    /// Stack-trace symbol frames in dmesg order (top of stack
109    /// first). Each frame holds the function name plus the
110    /// `funcname+0xoff/0xsz` raw text so the consumer can either
111    /// use the structured form or recreate the original line.
112    #[serde(default, skip_serializing_if = "Vec::is_empty")]
113    pub stack: Vec<StackSymbol>,
114}
115
116/// One frame of a `%pS`-formatted stack trace.
117///
118/// `funcname+0xoff/0xsz` is the canonical kernel format
119/// (`%pS`, rendered by `lib/vsprintf.c`'s `symbol_string` →
120/// `kernel/kallsyms.c`'s `sprint_symbol`, which emits `+%#lx/%#lx`).
121/// The parser captures the full original token
122/// alongside the structured fields so the producer can render either.
123#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
124#[non_exhaustive]
125pub struct StackSymbol {
126    /// Symbol name (the part before `+0x...`).
127    pub name: String,
128    /// Byte offset within the function (the `+0xNNN` value).
129    pub offset: u64,
130    /// Total function size when present (the `/0xNNN` value).
131    /// `None` when the kernel rendered the offset without a size
132    /// (older kernels and some configs omit `/<size>`).
133    #[serde(default, skip_serializing_if = "Option::is_none")]
134    pub size: Option<u64>,
135    /// Original text token from dmesg, for reference.
136    pub raw: String,
137}
138
139/// Anchor pattern that marks the start of an scx exit event in
140/// kernel-message output.
141///
142/// Source: `kernel/sched/ext.c` `scx_root_disable` —
143/// `pr_err("sched_ext: BPF scheduler \"%s\" disabled (%s)",
144/// sch->ops.name, ei->reason)` on the error path (ext.c:6004, the
145/// arm that also prints the stack) and the `pr_info` sibling
146/// (ext.c:6013) for non-error exits. The leading `sched_ext:`
147/// prefix and the `BPF scheduler "..."` shape have been stable since
148/// 6.12.
149const ANCHOR_PREFIX: &str = "sched_ext: BPF scheduler \"";
150
151/// Parse a window of `/dev/kmsg` (or `dmesg` text) and return every
152/// scx exit event found in it.
153///
154/// Looks for `ANCHOR_PREFIX` anchor lines, then collects
155/// follow-on lines (typically `<N>` or `[ts]` prefixed kernel print
156/// continuation) until the next non-stack-looking line or the next
157/// anchor. Stack-trace `%pS` tokens are extracted from the
158/// collected lines via [`extract_stack_symbols`].
159///
160/// Multiple events in one window produce multiple records — the
161/// kernel can emit several `disable` events back-to-back (especially
162/// when a scheduler load+disable cycles rapidly during a test).
163///
164/// Returns an empty vec when the window contains no anchor — that
165/// is "no scx events in this slice", NOT an error.
166#[allow(dead_code)]
167pub fn parse_kmsg_window(text: &str) -> Vec<ScxExitEvent> {
168    let mut events = Vec::new();
169    let lines: Vec<&str> = text.lines().collect();
170
171    let mut i = 0;
172    while i < lines.len() {
173        let line = lines[i];
174        if let Some(anchor_pos) = line.find(ANCHOR_PREFIX) {
175            // Parse the anchor line for the scheduler name + the
176            // parenthesized exit-context expression.
177            let after = &line[anchor_pos + ANCHOR_PREFIX.len()..];
178            let scheduler_name = after.split('"').next().unwrap_or("").to_string();
179            let message_body = after
180                .split_once('(')
181                .map(|(_, m)| m.trim_end_matches(')').trim().to_string())
182                .unwrap_or_default();
183
184            // Collect follow-on lines until we either hit another
185            // anchor or run out of stack-looking content.
186            let mut frames: Vec<StackSymbol> = Vec::new();
187            let mut full_message = message_body.clone();
188            let mut j = i + 1;
189            while j < lines.len() {
190                let next = lines[j];
191                if next.contains(ANCHOR_PREFIX) {
192                    break;
193                }
194                // Try to extract %pS frames from this follow-on
195                // line. Lines that are pure narrative (no symbol
196                // tokens) just contribute to the message text.
197                let mut new_frames = extract_stack_symbols(next);
198                if !new_frames.is_empty() {
199                    frames.append(&mut new_frames);
200                } else if !next.trim().is_empty() {
201                    // Skip standard kernel stack-trace formatting lines
202                    // (`Call Trace:`, `<TASK>`, `</TASK>`) that appear
203                    // between the message body and the actual %pS frames.
204                    // These carry no symbol tokens but mark the start of
205                    // a stack section that follows — breaking here would
206                    // miss the frames the kernel emits next.
207                    let trimmed = next.trim();
208                    if trimmed.ends_with("Call Trace:")
209                        || trimmed == "<TASK>"
210                        || trimmed == "</TASK>"
211                        || trimmed.ends_with("<TASK>")
212                        || trimmed.ends_with("</TASK>")
213                    {
214                        j += 1;
215                        continue;
216                    }
217                    // Stop accumulating message text once we leave
218                    // the printk continuation block — heuristic:
219                    // lines that don't carry a kmsg priority/timestamp
220                    // prefix and aren't blank are probably from a
221                    // different subsystem. Conservative early exit
222                    // when the line contains no `sched_ext` or
223                    // `BPF` / `scx_` token.
224                    let lower = next.to_ascii_lowercase();
225                    if !(lower.contains("sched_ext")
226                        || lower.contains("scx_")
227                        || lower.contains("bpf"))
228                    {
229                        break;
230                    }
231                    if !full_message.is_empty() {
232                        full_message.push(' ');
233                    }
234                    full_message.push_str(next.trim());
235                }
236                j += 1;
237            }
238
239            let stuck_task_comm = extract_stuck_task_comm(&full_message);
240            let kind = classify_exit_kind(&full_message, &frames);
241
242            events.push(ScxExitEvent {
243                scheduler_name,
244                kind,
245                message: full_message,
246                stuck_task_comm,
247                stack: frames,
248            });
249            i = j;
250            continue;
251        }
252        i += 1;
253    }
254
255    events
256}
257
258/// Extract `funcname+0xoff/0xsz` tokens from one line of kernel
259/// output.
260///
261/// Recognized shapes:
262/// - `funcname+0xNN/0xMM` — standard `%pS` with function size
263/// - `funcname+0xNN` — `%pS` without function size (older kernels)
264/// - `funcname+0xNN/0xMM [module]` — same with module suffix; the
265///   module name is currently dropped (the live-host pipeline
266///   resolves to the same function regardless of containing module)
267///
268/// Returns the structured frames in encounter order.
269pub fn extract_stack_symbols(line: &str) -> Vec<StackSymbol> {
270    let mut frames = Vec::new();
271
272    // Tokenize on whitespace; for each token containing `+0x`,
273    // attempt to parse it as a stack frame.
274    for token in line.split_whitespace() {
275        let Some(plus) = token.find("+0x") else {
276            continue;
277        };
278        // The function name must be a non-empty sequence of valid
279        // identifier characters before the `+`.
280        let name_part = &token[..plus];
281        if name_part.is_empty() {
282            continue;
283        }
284        if !name_part.chars().all(is_kernel_symbol_char) {
285            continue;
286        }
287
288        let after_plus = &token[plus + 3..]; // skip "+0x"
289        let (off_str, size_str) = match after_plus.split_once('/') {
290            Some((off, rest)) => {
291                // rest may start with "0x"; strip if present
292                let s = rest.strip_prefix("0x").unwrap_or(rest);
293                // Strip trailing punctuation / module suffix
294                let s = s.trim_end_matches(|c: char| !c.is_ascii_hexdigit());
295                (off, Some(s))
296            }
297            None => {
298                // No '/size' part; off_str runs until end-of-token
299                // or until a non-hex char (some kernels print
300                // trailing punctuation like ',' between frames).
301                let off = after_plus.trim_end_matches(|c: char| !c.is_ascii_hexdigit());
302                (off, None)
303            }
304        };
305
306        let Ok(offset) = u64::from_str_radix(off_str, 16) else {
307            continue;
308        };
309        let size = size_str.and_then(|s| u64::from_str_radix(s, 16).ok());
310
311        frames.push(StackSymbol {
312            name: name_part.to_string(),
313            offset,
314            size,
315            raw: token.to_string(),
316        });
317    }
318
319    frames
320}
321
322/// Heuristic: classify an scx exit event based on its message body
323/// + the function names in its stack trace.
324///
325/// - `Stall` when the message mentions "watchdog" / "stall" / "stuck"
326///   OR when the stack contains `check_rq_for_timeouts` /
327///   `scx_watchdog_workfn`.
328/// - `Error` when the message starts with the scheduler's
329///   error-prefix (`scx_error()` callers print `<scheduler>: <msg>`)
330///   or the message contains `aborting` / `error` keywords.
331/// - `Normal` when the message indicates `unloaded` or `removed`
332///   without error keywords AND the stack is empty.
333/// - `Other` otherwise.
334fn classify_exit_kind(message: &str, stack: &[StackSymbol]) -> ScxExitKind {
335    let lower = message.to_ascii_lowercase();
336    if lower.contains("watchdog")
337        || lower.contains("stall")
338        || lower.contains("stuck")
339        || stack
340            .iter()
341            .any(|f| f.name == "check_rq_for_timeouts" || f.name == "scx_watchdog_workfn")
342    {
343        return ScxExitKind::Stall;
344    }
345    if lower.contains("aborting")
346        || lower.contains("error")
347        || lower.contains("ebpf")
348        || lower.contains("enabled") && lower.contains("disabled")
349    // weak — see Other below
350    {
351        return ScxExitKind::Error;
352    }
353    if (lower.contains("unloaded") || lower.contains("removed") || lower.contains("done"))
354        && stack.is_empty()
355    {
356        return ScxExitKind::Normal;
357    }
358    if !stack.is_empty() {
359        // Stack present but no obvious watchdog / error keyword;
360        // treat as Error since normal exits don't carry a stack.
361        return ScxExitKind::Error;
362    }
363    ScxExitKind::Other
364}
365
366/// Extract a stuck-task COMM from an exit message body.
367///
368/// Scans for the patterns ktstr-aware schedulers (and the
369/// upstream watchdog) tend to emit:
370/// - `task <COMM>:<pid>` (custom-scheduler messages; the mainline
371///   watchdog instead prints `<comm>[<pid>] failed to run for ...`
372///   at kernel/sched/ext.c:3462 and is classified via the
373///   `check_rq_for_timeouts` / `scx_watchdog_workfn` stack frames)
374/// - `comm=<COMM>` (some custom schedulers)
375///
376/// The `task <COMM>` pattern requires the `COMM` to be followed by
377/// `:<digits>` (the watchdog always prints the pid). This avoids
378/// matching prose like "runnable task stall" in the anchor's
379/// parenthesized body, where "stall" is a classification keyword
380/// rather than a process name.
381///
382/// Returns the first matching COMM truncated to TASK_COMM_LEN bytes,
383/// or `None` when no pattern matches.
384fn extract_stuck_task_comm(message: &str) -> Option<String> {
385    const TASK_COMM_LEN: usize = 16;
386    // Kernel watchdog format: `task <COMM>:<pid>`. The bare `find("task ")`
387    // matches phrases like "task stall" before "task hot_path:1234", so we
388    // walk every "task " occurrence and accept the first whose next
389    // whitespace-delimited token has the `<COMM>:<digits>` shape.
390    let mut search_from = 0;
391    while let Some(rel) = message[search_from..].find("task ") {
392        let idx = search_from + rel;
393        let after = &message[idx + 5..];
394        let token = after.split_whitespace().next().unwrap_or("");
395        if let Some((comm, pid_part)) = token.split_once(':') {
396            let pid_digits: String = pid_part
397                .chars()
398                .take_while(|c| c.is_ascii_digit())
399                .collect();
400            if !pid_digits.is_empty() {
401                let comm =
402                    comm.trim_matches(|c: char| !c.is_alphanumeric() && c != '_' && c != '-');
403                if !comm.is_empty() {
404                    let bounded: String = comm.chars().take(TASK_COMM_LEN).collect();
405                    return Some(bounded);
406                }
407            }
408        }
409        search_from = idx + 5;
410    }
411    if let Some(idx) = message.find("comm=") {
412        let after = &message[idx + 5..];
413        let token = after
414            .split(|c: char| c.is_whitespace() || c == ',' || c == ')')
415            .next()?
416            .trim_matches('"');
417        if !token.is_empty() {
418            let bounded: String = token.chars().take(TASK_COMM_LEN).collect();
419            return Some(bounded);
420        }
421    }
422    None
423}
424
425/// True for characters valid in a Linux kernel symbol name. Kernel
426/// symbols use C identifier rules plus `.` (compiler-emitted local
427/// labels like `func.cold` and `func.constprop.0`).
428fn is_kernel_symbol_char(c: char) -> bool {
429    c.is_ascii_alphanumeric() || c == '_' || c == '.'
430}
431
432#[cfg(test)]
433mod tests {
434    use super::*;
435
436    /// `extract_stack_symbols` recovers a single frame from the
437    /// canonical `funcname+0xoff/0xsz` shape.
438    #[test]
439    fn extract_single_frame_with_size() {
440        let frames = extract_stack_symbols("? scx_watchdog_workfn+0x123/0x456");
441        assert_eq!(frames.len(), 1);
442        assert_eq!(frames[0].name, "scx_watchdog_workfn");
443        assert_eq!(frames[0].offset, 0x123);
444        assert_eq!(frames[0].size, Some(0x456));
445        assert_eq!(frames[0].raw, "scx_watchdog_workfn+0x123/0x456");
446    }
447
448    /// Frame without `/size` — older kernel shape — still parses.
449    #[test]
450    fn extract_frame_without_size() {
451        let frames = extract_stack_symbols("scx_disable_workfn+0x42");
452        assert_eq!(frames.len(), 1);
453        assert_eq!(frames[0].name, "scx_disable_workfn");
454        assert_eq!(frames[0].offset, 0x42);
455        assert_eq!(frames[0].size, None);
456    }
457
458    /// Multiple frames on one line all extract.
459    #[test]
460    fn extract_multiple_frames_one_line() {
461        let frames = extract_stack_symbols("? func_a+0x10/0x20 func_b+0x30/0x40 func_c+0x50");
462        assert_eq!(frames.len(), 3);
463        assert_eq!(frames[0].name, "func_a");
464        assert_eq!(frames[1].name, "func_b");
465        assert_eq!(frames[2].name, "func_c");
466        assert_eq!(frames[2].size, None);
467    }
468
469    /// Symbol names with `.` (cold / constprop variants) parse.
470    #[test]
471    fn extract_frame_with_dot_in_name() {
472        let frames = extract_stack_symbols("scx_dispatch_q.cold+0x10/0x40");
473        assert_eq!(frames.len(), 1);
474        assert_eq!(frames[0].name, "scx_dispatch_q.cold");
475        assert_eq!(frames[0].offset, 0x10);
476    }
477
478    /// Lines without `+0x` produce no frames.
479    #[test]
480    fn extract_no_frames_from_plain_text() {
481        let frames = extract_stack_symbols(
482            "[12345.678] sched_ext: BPF scheduler \"foo\" disabled (operator request)",
483        );
484        assert!(frames.is_empty());
485    }
486
487    /// The canonical anchor line + a follow-on stack trace produces
488    /// one event with the right scheduler name + extracted frames.
489    /// Verdict-routed so a multi-field parser regression (anchor
490    /// shape change, classifier rename, stack extractor regression)
491    /// surfaces every drift in one run.
492    #[test]
493    fn parse_kmsg_window_simple_error() {
494        use crate::assert::Verdict;
495
496        let text = "\
497[12345.678] sched_ext: BPF scheduler \"scx_test\" disabled (BPF runtime error)
498[12345.679] scx_test: aborting due to BPF runtime error
499[12345.680] Call Trace:
500[12345.681]  ? scx_disable_workfn+0x100/0x200
501[12345.682]  ? scx_internal_disable+0x50/0x100
502[12345.683]  ? scx_error+0x30/0x80
503";
504        let events = parse_kmsg_window(text);
505        let event_count = events.len();
506        assert_eq!(event_count, 1, "expected exactly one event");
507        let ev = &events[0];
508        let scheduler_name = ev.scheduler_name.clone();
509        // ScxExitKind doesn't Display; match-against-shape and claim
510        // on the resulting bool so the verdict carries a labeled detail.
511        let kind_is_error = matches!(ev.kind, ScxExitKind::Error);
512        let message_has_runtime_error = ev.message.contains("BPF runtime error");
513        let stack_len = ev.stack.len();
514        let first_frame_name = ev.stack[0].name.clone();
515
516        let mut v = Verdict::new();
517        crate::claim!(v, scheduler_name).eq("scx_test".to_string());
518        crate::claim!(v, kind_is_error).eq(true);
519        crate::claim!(v, message_has_runtime_error).eq(true);
520        crate::claim!(v, stack_len).eq(3usize);
521        crate::claim!(v, first_frame_name).eq("scx_disable_workfn".to_string());
522        let r = v.into_result();
523        assert!(
524            r.is_pass(),
525            "kmsg parse drift on canonical error event: {:?}",
526            r.outcomes,
527        );
528    }
529
530    /// Watchdog / stall classification fires when the message
531    /// mentions stall keywords OR the stack carries
532    /// check_rq_for_timeouts.
533    ///
534    /// Fixture mirrors production `dump_stack` output: a `Call Trace:`
535    /// header followed by `<TASK>` / `</TASK>` brackets around the
536    /// actual `%pS` frames. The parser must skip those formatting
537    /// lines (they carry no symbol tokens) instead of treating them
538    /// as end-of-stack.
539    #[test]
540    fn parse_kmsg_window_stall_classification() {
541        let text = "\
542[1.0] sched_ext: BPF scheduler \"scx_test\" disabled (runnable task stall)
543[1.1] scx_test: stalled task hot_path:1234 not dispatched
544[1.2] Call Trace:
545[1.3]  <TASK>
546[1.4]  ? check_rq_for_timeouts+0x50/0x100
547[1.5]  ? scx_watchdog_workfn+0x10/0x80
548[1.6]  </TASK>
549";
550        let events = parse_kmsg_window(text);
551        assert_eq!(events.len(), 1);
552        assert_eq!(events[0].kind, ScxExitKind::Stall);
553        assert_eq!(events[0].stack.len(), 2);
554        // stuck-task COMM extracted from "stalled task hot_path:1234"
555        // — pattern matched on "task hot_path".
556        assert_eq!(events[0].stuck_task_comm.as_deref(), Some("hot_path"));
557    }
558
559    /// Stuck-task COMM extraction handles the `task <COMM>` and
560    /// `comm=<COMM>` patterns plus 16-byte truncation.
561    #[test]
562    fn extract_stuck_task_comm_patterns() {
563        assert_eq!(
564            extract_stuck_task_comm("stalled task foo:1234 stuck"),
565            Some("foo".to_string())
566        );
567        assert_eq!(
568            extract_stuck_task_comm("operator complaint about comm=bar)"),
569            Some("bar".to_string())
570        );
571        assert_eq!(
572            extract_stuck_task_comm("task this_is_a_very_long_task_name_too_long:1"),
573            // Truncated to 16 bytes (TASK_COMM_LEN).
574            Some("this_is_a_very_l".to_string())
575        );
576        assert_eq!(extract_stuck_task_comm("no patterns here"), None);
577    }
578
579    /// Multiple events in one window produce multiple records.
580    #[test]
581    fn parse_kmsg_window_multiple_events() {
582        let text = "\
583[1.0] sched_ext: BPF scheduler \"scx_a\" disabled (manual unload)
584[2.0] sched_ext: BPF scheduler \"scx_b\" disabled (BPF runtime error)
585[2.1]  ? scx_disable_workfn+0x100/0x200
586";
587        let events = parse_kmsg_window(text);
588        assert_eq!(events.len(), 2);
589        assert_eq!(events[0].scheduler_name, "scx_a");
590        assert_eq!(events[1].scheduler_name, "scx_b");
591        assert_eq!(events[1].stack.len(), 1);
592    }
593
594    /// Window with no anchor produces no events (NOT an error).
595    #[test]
596    fn parse_kmsg_window_no_anchor() {
597        let text = "\
598[1.0] kernel: random unrelated message
599[1.1] systemd: started service
600";
601        let events = parse_kmsg_window(text).len();
602        assert_eq!(events, 0);
603    }
604
605    /// ScxExitEvent serializes with empty fields suppressed.
606    #[test]
607    fn scx_exit_event_serde_skips_empty() {
608        let ev = ScxExitEvent {
609            scheduler_name: "scx_test".into(),
610            kind: ScxExitKind::Normal,
611            message: String::new(),
612            stuck_task_comm: None,
613            stack: Vec::new(),
614        };
615        let json = serde_json::to_string(&ev).unwrap();
616        assert!(!json.contains("message"));
617        assert!(!json.contains("stuck_task_comm"));
618        assert!(!json.contains("stack"));
619        assert!(json.contains("scx_test"));
620        assert!(json.contains("Normal"));
621    }
622}