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}