ktstr/monitor/dump/display.rs
1//! Human-readable [`std::fmt::Display`] impls for the failure-dump
2//! types.
3//!
4//! All `Display` impls for [`super::FailureDumpReport`],
5//! [`super::DualFailureDumpReport`], [`super::FailureDumpReportAny`],
6//! [`super::DegradedFailureDumpReport`], [`super::FailureDumpMap`],
7//! [`super::FailureDumpEntry`],
8//! [`super::FailureDumpArrayEntry`], [`super::FailureDumpPercpuEntry`],
9//! and [`super::FailureDumpPercpuHashEntry`] live here so the type
10//! definitions in [`super`] stay focused on the data shape and the
11//! formatting concerns are isolated in one place.
12//!
13//! JSON remains the programmatic form via `serde_json`; these impls
14//! are the default presentation used in test-failure output.
15
16use super::super::btf_render::{
17 RenderedMember, RenderedValue, is_inline_scalar, is_zero, write_value_at_depth,
18};
19use super::{
20 DegradedFailureDumpReport, DualFailureDumpReport, EventCounterSample, FailureDumpArrayEntry,
21 FailureDumpEntry, FailureDumpMap, FailureDumpPercpuEntry, FailureDumpPercpuHashEntry,
22 FailureDumpReport, FailureDumpReportAny, render_sparkline_i64,
23};
24
25/// Minimum entry count for [`FailureDumpMap`] table rendering. Two
26/// entries are still meaningful as a table (column headers vs two
27/// per-entry blocks is denser); below that the table header
28/// overhead exceeds the savings.
29const TABLE_MIN_ENTRIES: usize = 2;
30
31/// Try to render a [`FailureDumpMap`]'s `entries` as a homogeneous
32/// table. Returns `Ok(true)` when the table was rendered (caller
33/// must skip per-entry rendering); `Ok(false)` when the entries
34/// don't qualify (caller falls through to per-entry rendering).
35///
36/// Table eligibility (every condition required):
37/// - at least [`TABLE_MIN_ENTRIES`] entries,
38/// - every entry has BOTH `key.is_some()` AND `value.is_some()`,
39/// - no entry has a `payload` (typed sdt_alloc payloads need
40/// block rendering below the entry; the table format can't
41/// represent them),
42/// - every key is a `RenderedValue::Struct` with the same
43/// `type_name` and same member names AND every member is an
44/// inline scalar (matches [`is_inline_scalar`]'s definition),
45/// - every value is a `RenderedValue::Struct` with the same
46/// `type_name` and same member names AND every member is an
47/// inline scalar.
48///
49/// Output shape, with `|` separating key columns from value columns
50/// and numeric columns right-aligned:
51/// ```text
52/// cgrp_id llc_id | llcx
53/// 1 5 | 17592186046336
54/// 61 3 | 17592186047616
55/// ```
56fn try_write_entry_table(
57 f: &mut std::fmt::Formatter<'_>,
58 entries: &[FailureDumpEntry],
59) -> Result<bool, std::fmt::Error> {
60 if entries.len() < TABLE_MIN_ENTRIES {
61 return Ok(false);
62 }
63 // Reject if any entry has a payload — the typed payload renders
64 // in a block below the entry; the table can't carry it without
65 // breaking the row layout.
66 if entries.iter().any(|e| e.payload.is_some()) {
67 return Ok(false);
68 }
69 // Collect every entry's key + value as Struct references. Any
70 // missing render (None key or None value) bails immediately —
71 // a hex-only entry can't be a table cell.
72 let pairs: Option<Vec<(&Vec<RenderedMember>, &Vec<RenderedMember>)>> = entries
73 .iter()
74 .map(|e| match (&e.key, &e.value) {
75 (
76 Some(RenderedValue::Struct { members: k, .. }),
77 Some(RenderedValue::Struct { members: v, .. }),
78 ) => Some((k, v)),
79 _ => None,
80 })
81 .collect();
82 let Some(pairs) = pairs else {
83 return Ok(false);
84 };
85 if pairs.is_empty() {
86 return Ok(false);
87 }
88
89 // Type names + member names must match across every entry. The
90 // first entry sets the template; subsequent entries must agree.
91 let (first_key_name, first_value_name) = match (&entries[0].key, &entries[0].value) {
92 (
93 Some(RenderedValue::Struct { type_name: kn, .. }),
94 Some(RenderedValue::Struct { type_name: vn, .. }),
95 ) => (kn.clone(), vn.clone()),
96 _ => return Ok(false),
97 };
98 for e in &entries[1..] {
99 match (&e.key, &e.value) {
100 (
101 Some(RenderedValue::Struct { type_name: kn, .. }),
102 Some(RenderedValue::Struct { type_name: vn, .. }),
103 ) => {
104 if *kn != first_key_name || *vn != first_value_name {
105 return Ok(false);
106 }
107 }
108 _ => return Ok(false),
109 }
110 }
111
112 let (k0, v0) = pairs[0];
113 let key_names: Vec<&str> = k0.iter().map(|m| m.name.as_str()).collect();
114 let value_names: Vec<&str> = v0.iter().map(|m| m.name.as_str()).collect();
115
116 // Member names + counts must match across every entry. A
117 // mismatch means the structs aren't actually homogeneous (the
118 // BTF rendered different fields per entry — possible if the
119 // entry value type id changed mid-iteration, or a torn read
120 // produced a Truncated partial inside the Struct).
121 for (k, v) in &pairs {
122 if k.len() != k0.len() || v.len() != v0.len() {
123 return Ok(false);
124 }
125 for (a, b) in k.iter().zip(k0.iter()) {
126 if a.name != b.name {
127 return Ok(false);
128 }
129 }
130 for (a, b) in v.iter().zip(v0.iter()) {
131 if a.name != b.name {
132 return Ok(false);
133 }
134 }
135 }
136
137 // Every member in every entry's key + value must be an inline
138 // scalar. A composite member (Struct, Array, CpuList, etc.)
139 // breaks the single-line-per-row contract.
140 for (k, v) in &pairs {
141 if !k.iter().all(|m| is_inline_scalar(&m.value)) {
142 return Ok(false);
143 }
144 if !v.iter().all(|m| is_inline_scalar(&m.value)) {
145 return Ok(false);
146 }
147 }
148
149 // Pre-render every cell so column widths can be measured. A
150 // numeric cell uses the same Display impl that produces
151 // "<value>" (e.g. "1024") so widths reflect the rendered
152 // form, not the raw integer.
153 let key_rows: Vec<Vec<String>> = pairs
154 .iter()
155 .map(|(k, _)| k.iter().map(|m| format!("{}", m.value)).collect())
156 .collect();
157 let value_rows: Vec<Vec<String>> = pairs
158 .iter()
159 .map(|(_, v)| v.iter().map(|m| format!("{}", m.value)).collect())
160 .collect();
161
162 // Per-column width: max of header (member name) and any cell
163 // in the column. Header is the member name; cells come from
164 // the pre-rendered row vectors.
165 let key_widths: Vec<usize> = (0..key_names.len())
166 .map(|c| {
167 let cell_max = key_rows.iter().map(|r| r[c].len()).max().unwrap_or(0);
168 key_names[c].len().max(cell_max)
169 })
170 .collect();
171 let value_widths: Vec<usize> = (0..value_names.len())
172 .map(|c| {
173 let cell_max = value_rows.iter().map(|r| r[c].len()).max().unwrap_or(0);
174 value_names[c].len().max(cell_max)
175 })
176 .collect();
177
178 // Header row: key names | value names.
179 f.write_str("\n ")?;
180 for (i, name) in key_names.iter().enumerate() {
181 if i > 0 {
182 f.write_str(" ")?;
183 }
184 write!(f, "{:>width$}", name, width = key_widths[i])?;
185 }
186 f.write_str(" | ")?;
187 for (i, name) in value_names.iter().enumerate() {
188 if i > 0 {
189 f.write_str(" ")?;
190 }
191 write!(f, "{:>width$}", name, width = value_widths[i])?;
192 }
193
194 // Data rows. Right-align every cell to the column width — the
195 // values are scalar Display output (integers, hex pointers,
196 // booleans), and right-alignment makes a vertical tens/hundreds
197 // alignment immediately readable.
198 for (key_row, value_row) in key_rows.iter().zip(value_rows.iter()) {
199 f.write_str("\n ")?;
200 for (i, cell) in key_row.iter().enumerate() {
201 if i > 0 {
202 f.write_str(" ")?;
203 }
204 write!(f, "{:>width$}", cell, width = key_widths[i])?;
205 }
206 f.write_str(" | ")?;
207 for (i, cell) in value_row.iter().enumerate() {
208 if i > 0 {
209 f.write_str(" ")?;
210 }
211 write!(f, "{:>width$}", cell, width = value_widths[i])?;
212 }
213 }
214
215 Ok(true)
216}
217
218impl std::fmt::Display for DualFailureDumpReport {
219 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
220 // Summary header: a one-line at-a-glance description so an
221 // operator scanning logs sees the shape (early present /
222 // absent, late map + vcpu_regs counts, plus the trigger
223 // metric and threshold when early fired) before paging
224 // through the full body.
225 let n_maps = self.late.maps.len();
226 let m_vcpu_regs = self.late.vcpu_regs.len();
227 if self.early.is_some() {
228 // Both jiffies fields zero means the early-snapshot
229 // bookkeeping never recorded a trigger metric (e.g. the
230 // snapshot was attached without the runnable_at scan
231 // populating the threshold). Render a distinct line so
232 // operators don't read "max_age=0j, threshold=0j" as a
233 // legitimate sub-tick trigger.
234 if self.early_max_age_jiffies == 0 && self.early_threshold_jiffies == 0 {
235 write!(
236 f,
237 "DualFailureDumpReport: early=present (jiffies not captured), \
238 late=({n_maps} maps, {m_vcpu_regs} vcpu_regs)\n\n",
239 )?;
240 } else {
241 write!(
242 f,
243 "DualFailureDumpReport: early=present (max_age={}j, threshold={}j), \
244 late=({n_maps} maps, {m_vcpu_regs} vcpu_regs)\n\n",
245 self.early_max_age_jiffies, self.early_threshold_jiffies,
246 )?;
247 }
248 } else if let Some(reason) = self.early_skipped_reason.as_deref() {
249 // Structured reason populated by the freeze coordinator
250 // (one of: scan prerequisites unavailable, max_age never
251 // crossed threshold, scx_tick stall — see
252 // `DualFailureDumpReport::early_skipped_reason` doc for
253 // the full set). Surface it directly so the operator
254 // does not have to re-run with RUST_LOG=ktstr=debug to
255 // recover the cause.
256 write!(
257 f,
258 "DualFailureDumpReport: early=absent ({reason}), \
259 late=({n_maps} maps, {m_vcpu_regs} vcpu_regs)\n\n",
260 )?;
261 } else {
262 // Legacy generic message. Reached only on dumps written
263 // before the freeze coordinator started populating
264 // `early_skipped_reason` (no field on the JSON, deserialised
265 // as None). Keep the RUST_LOG hint so old dumps remain
266 // actionable; new dumps take the structured branch above.
267 write!(
268 f,
269 "DualFailureDumpReport: early=absent, late=({n_maps} maps, \
270 {m_vcpu_regs} vcpu_regs)\n\n",
271 )?;
272 }
273 match &self.early {
274 Some(early) => {
275 f.write_str("early snapshot (sched_ext watchdog half-way):\n")?;
276 std::fmt::Display::fmt(early, f)?;
277 f.write_str("\n\nlate snapshot (error-exit):\n")?;
278 std::fmt::Display::fmt(&self.late, f)
279 }
280 None => {
281 if let Some(reason) = self.early_skipped_reason.as_deref() {
282 writeln!(
283 f,
284 "late snapshot (error-exit; early snapshot absent: \
285 {reason}):",
286 )?;
287 } else {
288 f.write_str(
289 "late snapshot (error-exit; early snapshot absent \
290 (stall fired before half-way threshold, or runnable_at \
291 scan setup failed) — re-run with RUST_LOG=ktstr=debug \
292 for scan resolution diagnostics):\n",
293 )?;
294 }
295 std::fmt::Display::fmt(&self.late, f)
296 }
297 }
298 }
299}
300
301impl std::fmt::Display for FailureDumpReportAny {
302 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
303 match self {
304 Self::Single(r) => std::fmt::Display::fmt(r, f),
305 Self::Dual(r) => std::fmt::Display::fmt(r.as_ref(), f),
306 Self::Degraded(r) => std::fmt::Display::fmt(r.as_ref(), f),
307 }
308 }
309}
310
311impl std::fmt::Display for DegradedFailureDumpReport {
312 /// Renders the degraded report as a short operator-oriented
313 /// banner: schema label, the human reason, the per-vCPU
314 /// `parked` / `not_parked` pattern that identifies which vCPUs
315 /// stalled, the watchpoint + bss latch state, the optional
316 /// live `exit_kind`, and the elapsed-ms budget the coordinator
317 /// spent before giving up. Designed to fit a single terminal
318 /// scroll without paging — the full diagnostic surface lives
319 /// in the structured fields.
320 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
321 f.write_str("degraded failure dump:\n")?;
322 writeln!(f, " reason: {}", self.reason)?;
323 if !self.vcpu_regs.is_empty() {
324 let parked = self.vcpu_regs.iter().filter(|s| s.is_some()).count();
325 let total = self.vcpu_regs.len();
326 writeln!(f, " vcpus_parked: {parked}/{total}")?;
327 for (i, slot) in self.vcpu_regs.iter().enumerate() {
328 match slot {
329 Some(s) => writeln!(f, " vcpu {i}: parked, {s}")?,
330 None => writeln!(f, " vcpu {i}: not_parked")?,
331 }
332 }
333 }
334 writeln!(f, " watchpoint_hit: {}", self.watchpoint_hit)?;
335 writeln!(f, " bss_latch_state: {}", self.bss_latch_state)?;
336 if let Some(kind) = self.exit_kind {
337 writeln!(f, " exit_kind: {kind}")?;
338 }
339 if self.elapsed_ms != 0 {
340 writeln!(f, " elapsed_ms: {}", self.elapsed_ms)?;
341 }
342 Ok(())
343 }
344}
345
346impl FailureDumpReport {
347 /// Renders the scx walker section: per-CPU rq->scx / DSQ / scx_sched
348 /// counts, then the walker-unavailable reason when the walk failed.
349 fn fmt_scx_walker(
350 &self,
351 f: &mut std::fmt::Formatter<'_>,
352 first: &mut bool,
353 ) -> std::fmt::Result {
354 if !self.rq_scx_states.is_empty()
355 || !self.dsq_states.is_empty()
356 || self.scx_sched_state.is_some()
357 {
358 if !*first {
359 f.write_str("\n\n")?;
360 }
361 *first = false;
362 // scx walker section: counts of each sub-walk's output.
363 // JSON carries the full per-CPU rq->scx state, per-DSQ
364 // task lists, and scx_sched scalars; the Display surface
365 // tells the operator what the walker reached.
366 write!(
367 f,
368 "scx_walker: rq_scx={} dsq={} sched={}",
369 self.rq_scx_states.len(),
370 self.dsq_states.len(),
371 if self.scx_sched_state.is_some() {
372 "captured"
373 } else {
374 "absent"
375 },
376 )?;
377 }
378 if let Some(reason) = &self.scx_walker_unavailable {
379 if !*first {
380 f.write_str("\n\n")?;
381 }
382 *first = false;
383 write!(f, "scx_walker: <unavailable: {reason}>")?;
384 }
385 Ok(())
386 }
387
388 /// Renders the event-counter timeline: sample-count header plus a
389 /// per-counter sparkline row for each non-zero SCX_EV_* counter.
390 fn fmt_event_counter_timeline(
391 &self,
392 f: &mut std::fmt::Formatter<'_>,
393 first: &mut bool,
394 ) -> std::fmt::Result {
395 if !self.event_counter_timeline.is_empty() {
396 if !*first {
397 f.write_str("\n\n")?;
398 }
399 *first = false;
400 // Clear `first` like the sibling sections so a dump whose
401 // only populated sections are this one and
402 // vcpu_perf_at_freeze still renders the blank-line
403 // separator between them. The per-counter sparkline rows
404 // below append their own `\n`-prefixed lines.
405 write!(
406 f,
407 "event_counter_timeline: {} samples ({}–{}ms)",
408 self.event_counter_timeline.len(),
409 self.event_counter_timeline
410 .first()
411 .map(|s| s.elapsed_ms)
412 .unwrap_or(0),
413 self.event_counter_timeline
414 .last()
415 .map(|s| s.elapsed_ms)
416 .unwrap_or(0),
417 )?;
418 // Per-counter sparkline. Each row is one of the 13
419 // SCX_EV_* counters across all samples in the
420 // timeline. Skips counters that stayed at zero across
421 // every sample to keep the rendering compact (a
422 // counter at zero everywhere has no signal worth
423 // surfacing in the human-readable view).
424 type EventCounterExtract = (&'static str, fn(&EventCounterSample) -> i64);
425 let extract: [EventCounterExtract; 13] = [
426 ("select_cpu_fallback", |s| s.select_cpu_fallback),
427 ("dispatch_local_dsq_offline", |s| {
428 s.dispatch_local_dsq_offline
429 }),
430 ("dispatch_keep_last", |s| s.dispatch_keep_last),
431 ("enq_skip_exiting", |s| s.enq_skip_exiting),
432 ("enq_skip_migration_disabled", |s| {
433 s.enq_skip_migration_disabled
434 }),
435 ("reenq_immed", |s| s.reenq_immed),
436 ("reenq_local_repeat", |s| s.reenq_local_repeat),
437 ("refill_slice_dfl", |s| s.refill_slice_dfl),
438 ("bypass_duration", |s| s.bypass_duration),
439 ("bypass_dispatch", |s| s.bypass_dispatch),
440 ("bypass_activate", |s| s.bypass_activate),
441 ("insert_not_owned", |s| s.insert_not_owned),
442 ("sub_bypass_dispatch", |s| s.sub_bypass_dispatch),
443 ];
444 for (name, ext) in extract {
445 let series: Vec<i64> = self.event_counter_timeline.iter().map(ext).collect();
446 if series.iter().all(|&v| v == 0) {
447 continue;
448 }
449 let line = render_sparkline_i64(&series);
450 let last = series.last().copied().unwrap_or(0);
451 write!(f, "\n {name:>30} {line} (last={last})")?;
452 }
453 }
454 Ok(())
455 }
456
457 /// Renders the per-vCPU performance-counter snapshot captured at
458 /// freeze: cycles / instructions / IPC / cache + branch misses.
459 fn fmt_vcpu_perf_at_freeze(
460 &self,
461 f: &mut std::fmt::Formatter<'_>,
462 first: &mut bool,
463 ) -> std::fmt::Result {
464 if !self.vcpu_perf_at_freeze.is_empty() {
465 if !*first {
466 f.write_str("\n\n")?;
467 }
468 *first = false;
469 f.write_str("vcpu_perf_at_freeze:")?;
470 for (i, slot) in self.vcpu_perf_at_freeze.iter().enumerate() {
471 f.write_str("\n ")?;
472 match slot {
473 Some(s) => write!(
474 f,
475 "vcpu {i}: cycles={} insns={} ipc={:.3} cache_misses={} branch_misses={} (en/ru={}/{} ns)",
476 s.cycles,
477 s.instructions,
478 s.ipc(),
479 s.cache_misses,
480 s.branch_misses,
481 s.time_enabled_ns,
482 s.time_running_ns,
483 )?,
484 None => write!(f, "vcpu {i}: <unavailable>")?,
485 }
486 }
487 }
488 Ok(())
489 }
490}
491
492impl std::fmt::Display for FailureDumpReport {
493 /// Human-readable rendering of every map plus per-vCPU register
494 /// snapshots, per-program runtime stats, per-CPU CPU-time /
495 /// softirq / IRQ counters, per-node NUMA stats, per-task
496 /// enrichments, scx walker output (rq->scx, DSQ, scx_sched
497 /// state), and event-counter timeline. JSON remains the
498 /// programmatic form via `serde_json`; this Display is the
499 /// default presentation used in test-failure output.
500 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
501 if self.maps.is_empty()
502 && self.vcpu_regs.is_empty()
503 && self.sdt_allocations.is_empty()
504 && self.scx_static_ranges.is_empty()
505 && self.prog_runtime_stats.is_empty()
506 && self.prog_runtime_stats_unavailable.is_none()
507 && self.per_cpu_time.is_empty()
508 && self.per_node_numa.is_empty()
509 && self.per_node_numa_unavailable.is_none()
510 && self.task_enrichments.is_empty()
511 && self.task_enrichments_unavailable.is_none()
512 && self.event_counter_timeline.is_empty()
513 && self.rq_scx_states.is_empty()
514 && self.dsq_states.is_empty()
515 && self.scx_sched_state.is_none()
516 && self.scx_walker_unavailable.is_none()
517 && self.vcpu_perf_at_freeze.is_empty()
518 && self.dump_truncated_at_us.is_none()
519 && self.maps_truncated == 0
520 {
521 return f.write_str("(empty failure dump)");
522 }
523 use rayon::prelude::*;
524 let rendered_maps: Vec<String> = self.maps.par_iter().map(|m| format!("{m}")).collect();
525 let mut first = true;
526 for s in &rendered_maps {
527 if !first {
528 f.write_str("\n\n")?;
529 }
530 first = false;
531 f.write_str(s)?;
532 }
533 if !self.vcpu_regs.is_empty() {
534 if !first {
535 f.write_str("\n\n")?;
536 }
537 first = false;
538 f.write_str("vcpu_regs:")?;
539 for (i, slot) in self.vcpu_regs.iter().enumerate() {
540 f.write_str("\n ")?;
541 match slot {
542 Some(s) => write!(f, "vcpu {i}: {s}")?,
543 None => write!(f, "vcpu {i}: <unavailable>")?,
544 }
545 }
546 }
547 for snap in &self.sdt_allocations {
548 if !first {
549 f.write_str("\n\n")?;
550 }
551 first = false;
552 std::fmt::Display::fmt(snap, f)?;
553 }
554 if !self.scx_static_ranges.is_empty() {
555 if !first {
556 f.write_str("\n\n")?;
557 }
558 first = false;
559 std::fmt::Display::fmt(&self.scx_static_ranges, f)?;
560 }
561 if !self.prog_runtime_stats.is_empty() {
562 if !first {
563 f.write_str("\n\n")?;
564 }
565 first = false;
566 f.write_str("prog_runtime_stats:")?;
567 for stats in &self.prog_runtime_stats {
568 f.write_str("\n ")?;
569 std::fmt::Display::fmt(stats, f)?;
570 }
571 }
572 if let Some(reason) = &self.prog_runtime_stats_unavailable {
573 if !first {
574 f.write_str("\n\n")?;
575 }
576 first = false;
577 write!(f, "prog_runtime_stats: <unavailable: {reason}>")?;
578 }
579 if !self.per_cpu_time.is_empty() {
580 if !first {
581 f.write_str("\n\n")?;
582 }
583 first = false;
584 // Per-CPU CPU-time / softirq / IRQ summary. JSON carries
585 // the full per-CPU breakdown; this Display surfaces
586 // counts so the test-failure log shows what was captured
587 // without paging through every CPU.
588 write!(f, "per_cpu_time: {} CPUs captured", self.per_cpu_time.len())?;
589 }
590 if !self.per_node_numa.is_empty() {
591 if !first {
592 f.write_str("\n\n")?;
593 }
594 first = false;
595 write!(
596 f,
597 "per_node_numa: {} nodes captured",
598 self.per_node_numa.len()
599 )?;
600 }
601 if let Some(reason) = &self.per_node_numa_unavailable {
602 if !first {
603 f.write_str("\n\n")?;
604 }
605 first = false;
606 write!(f, "per_node_numa: <unavailable: {reason}>")?;
607 }
608 if !self.task_enrichments.is_empty() {
609 if !first {
610 f.write_str("\n\n")?;
611 }
612 first = false;
613 write!(
614 f,
615 "task_enrichments: {} tasks captured",
616 self.task_enrichments.len(),
617 )?;
618 }
619 if let Some(reason) = &self.task_enrichments_unavailable {
620 if !first {
621 f.write_str("\n\n")?;
622 }
623 first = false;
624 write!(f, "task_enrichments: <unavailable: {reason}>")?;
625 }
626 self.fmt_scx_walker(f, &mut first)?;
627 self.fmt_event_counter_timeline(f, &mut first)?;
628 self.fmt_vcpu_perf_at_freeze(f, &mut first)?;
629 // Truncation footer: the per-map render loop bounds the freeze
630 // window by skipping work once the soft deadline is crossed.
631 // Surface both WHEN (`dump_truncated_at_us`) and HOW MANY maps
632 // (`maps_truncated`) were dropped so a degraded dump reads as
633 // "incomplete by truncation" rather than "this is everything".
634 if self.dump_truncated_at_us.is_some() || self.maps_truncated > 0 {
635 if !first {
636 f.write_str("\n\n")?;
637 }
638 f.write_str("dump truncated:")?;
639 if let Some(us) = self.dump_truncated_at_us {
640 write!(f, " deadline crossed at {us}us")?;
641 }
642 if self.maps_truncated > 0 {
643 write!(f, " ({} map(s) skipped)", self.maps_truncated)?;
644 }
645 }
646 Ok(())
647 }
648}
649
650impl std::fmt::Display for FailureDumpMap {
651 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
652 // Render `map_type` as the symbolic `BPF_MAP_TYPE_<NAME>`
653 // suffix when known; fall through to the raw integer for
654 // forward-compatibility with kernels newer than this dump
655 // renderer. Operators see "type=ringbuf" instead of
656 // "type=27"; the rare unknown discriminant still surfaces
657 // a stable numeric handle.
658 let type_str: std::borrow::Cow<'_, str> =
659 match super::render_map::map_type_name(self.map_type) {
660 Some(name) => std::borrow::Cow::Borrowed(name),
661 None => std::borrow::Cow::Owned(format!("{}", self.map_type)),
662 };
663 write!(
664 f,
665 "map {} (type={}, value_size={}, max_entries={})",
666 self.name, type_str, self.value_size, self.max_entries
667 )?;
668 if let Some(err) = &self.error {
669 write!(f, " [error: {err}]")?;
670 }
671 if let Some(value) = &self.value {
672 f.write_str("\n")?;
673 std::fmt::Display::fmt(value, f)?;
674 }
675 // Try table layout for homogeneous entries: when every
676 // entry has BTF-rendered key + value, both sides are
677 // structs of the same shape, every member is an inline
678 // scalar, and no entry has a typed payload, render as a
679 // compact table. The table form replaces the per-entry
680 // `entry { ... }` blocks for the qualifying batch — a
681 // 30-entry hash map renders as 31 lines (header + 30 rows)
682 // instead of 30 four-line entry blocks.
683 if !try_write_entry_table(f, &self.entries)? {
684 for entry in &self.entries {
685 f.write_str("\n")?;
686 std::fmt::Display::fmt(entry, f)?;
687 }
688 }
689 for entry in &self.array_entries {
690 f.write_str("\n")?;
691 std::fmt::Display::fmt(entry, f)?;
692 }
693 for entry in &self.percpu_entries {
694 f.write_str("\n")?;
695 std::fmt::Display::fmt(entry, f)?;
696 }
697 for entry in &self.percpu_hash_entries {
698 f.write_str("\n")?;
699 std::fmt::Display::fmt(entry, f)?;
700 }
701 if let Some(arena) = &self.arena {
702 let total_pages = arena.pages.len();
703 let total_kib: usize = arena.pages.iter().map(|p| p.bytes.len()).sum::<usize>() / 1024;
704 let nonzero = arena
705 .pages
706 .iter()
707 .filter(|p| p.bytes.iter().any(|&b| b != 0))
708 .count();
709 write!(
710 f,
711 "\narena: {total_pages} pages captured ({total_kib} KiB), \
712 {nonzero} non-zero (see sdt_alloc section + JSON for typed data)",
713 )?;
714 if arena.truncated {
715 write!(f, " (truncated, {} declared)", arena.declared_pages)?;
716 }
717 }
718 if let Some(rb) = &self.ringbuf {
719 // Show capacity, pending bytes (consumer lag), and the
720 // four position counters. The pending_pos vs producer_pos
721 // gap signals a producer mid-reserve; the pending_bytes
722 // vs capacity ratio signals consumer-stall pressure.
723 let pct = if rb.capacity == 0 {
724 0
725 } else {
726 (rb.pending_bytes.saturating_mul(100) / rb.capacity).min(100)
727 };
728 write!(
729 f,
730 "\nringbuf: capacity={}B, pending={}B ({pct}%), \
731 consumer_pos={}, producer_pos={}, pending_pos={}",
732 rb.capacity, rb.pending_bytes, rb.consumer_pos, rb.producer_pos, rb.pending_pos,
733 )?;
734 }
735 if let Some(st) = &self.stack_trace {
736 write!(
737 f,
738 "\nstack_trace: {} of {} buckets populated",
739 st.entries.len(),
740 st.n_buckets,
741 )?;
742 if st.truncated {
743 f.write_str(" (truncated)")?;
744 }
745 if st.buckets_unreadable > 0 {
746 write!(f, " ({} unreadable)", st.buckets_unreadable)?;
747 }
748 for entry in &st.entries {
749 if entry.pcs.is_empty() {
750 write!(f, "\n bucket {}: nr={}", entry.bucket_id, entry.nr)?;
751 } else {
752 // Show first up to 8 PCs as hex; full list is
753 // in JSON. Write directly to the formatter
754 // with a manual comma separator — no
755 // intermediate Vec<String> + join allocation
756 // per bucket. Stack-trace dumps with hundreds
757 // of buckets compounded the per-bucket Vec
758 // alloc into a measurable overhead.
759 write!(f, "\n bucket {}: nr={} pcs=[", entry.bucket_id, entry.nr)?;
760 for (i, pc) in entry.pcs.iter().take(8).enumerate() {
761 if i > 0 {
762 f.write_str(", ")?;
763 }
764 write!(f, "{pc:#x}")?;
765 }
766 let extra = entry.pcs.len().saturating_sub(8);
767 if extra > 0 {
768 write!(f, ", +{extra} more")?;
769 }
770 f.write_str("]")?;
771 }
772 }
773 }
774 if let Some(fda) = &self.fd_array {
775 write!(
776 f,
777 "\nfd_array: {} of {} slots populated",
778 fda.populated, fda.scanned,
779 )?;
780 if fda.truncated {
781 f.write_str(" (slots truncated)")?;
782 }
783 if fda.unreadable > 0 {
784 write!(f, " ({} unreadable)", fda.unreadable)?;
785 }
786 if !fda.indices.is_empty() {
787 // Same pattern as stack-trace: stream directly
788 // to the formatter rather than allocating a Vec
789 // and joining.
790 f.write_str(" indices=[")?;
791 for (i, idx) in fda.indices.iter().take(16).enumerate() {
792 if i > 0 {
793 f.write_str(", ")?;
794 }
795 write!(f, "{idx}")?;
796 }
797 let extra = fda.indices.len().saturating_sub(16);
798 if extra > 0 {
799 write!(f, ", +{extra} more")?;
800 }
801 f.write_str("]")?;
802 }
803 }
804 Ok(())
805 }
806}
807
808impl std::fmt::Display for FailureDumpEntry {
809 /// Render an entry using the indent-based format:
810 ///
811 /// ```text
812 /// entry: key=<rendered key>
813 /// value: <rendered value>
814 /// .data TypeName:
815 /// field=val field=val field=val
816 /// ```
817 ///
818 /// `entry:` is a label and `key=` is a field assignment; the
819 /// `=` follows the field-assignment convention used elsewhere
820 /// in the dump output. `value:` is also a label introducing
821 /// the rendered value (which carries its own `TypeName{...}`
822 /// or breadcrumb form). The optional payload follows the
823 /// breadcrumb pattern: `.data <rendered>` where the value's
824 /// own Type breadcrumb completes the line.
825 ///
826 /// The renderer is invoked with `depth = 1` for the value and
827 /// payload positions so any multi-line struct / array body
828 /// indents one level deeper than the entry's own ` ` prefix.
829 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
830 // Header: `entry: key=<rendered>` on a single line. Small
831 // struct keys collapse to `Type{field=value}` via the
832 // btf_render inline path; non-struct keys (Uint, etc.)
833 // render as their own scalar form. The hex fallback path
834 // (no BTF render) emits the hex string with a trailing
835 // `(raw)` marker so the operator distinguishes "no BTF"
836 // from a parsed value.
837 f.write_str("entry: key=")?;
838 match &self.key {
839 Some(k) => write_value_at_depth(f, k, 1)?,
840 None => write!(f, "{} (raw)", self.key_hex)?,
841 }
842 // Value: `value: <rendered>` indented one level. The
843 // rendered value carries its own Type breadcrumb /
844 // braces form depending on size; depth=1 ensures any
845 // multi-line body indents two levels deep (4 spaces).
846 f.write_str("\n value: ")?;
847 match &self.value {
848 Some(v) => write_value_at_depth(f, v, 1)?,
849 None => write!(f, "{} (raw)", self.value_hex)?,
850 }
851 // Typed sdt_alloc payload, when the entry value carried a
852 // `struct sdt_data __arena *` field that resolved into a
853 // captured arena page. Surfaced AFTER the surface value so
854 // the operator reads the surface struct first (with its
855 // tid / tptr / data fields) and then the typed payload —
856 // matching the order a kernel-side debugger would inspect:
857 // chase the pointer, then read the dereferenced struct.
858 // The space after `.data` lets the rendered value's own
859 // `TypeName:` breadcrumb (or inline `Type{...}` form) read
860 // as `.data TypeName:` on the same line.
861 if let Some(p) = &self.payload {
862 f.write_str("\n .data ")?;
863 write_value_at_depth(f, p, 1)?;
864 }
865 Ok(())
866 }
867}
868
869impl std::fmt::Display for FailureDumpArrayEntry {
870 /// `key <N>: <rendered value>` — one line for a flat struct, the
871 /// value's own multi-line body indented one level (depth=1) for a
872 /// nested struct/array. `<unreadable>` marks a key whose guest
873 /// page was unmapped at the freeze instant.
874 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
875 write!(f, "key {}: ", self.key)?;
876 match &self.value {
877 Some(v) => write_value_at_depth(f, v, 1)?,
878 None => f.write_str("<unreadable>")?,
879 }
880 Ok(())
881 }
882}
883
884impl std::fmt::Display for FailureDumpPercpuEntry {
885 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
886 let structs: Vec<(usize, &[RenderedMember])> = self
887 .per_cpu
888 .iter()
889 .enumerate()
890 .filter_map(|(cpu, slot)| match slot {
891 Some(RenderedValue::Struct { members, .. }) => Some((cpu, members.as_slice())),
892 _ => None,
893 })
894 .collect();
895
896 if structs.len() < 2 || structs.iter().any(|(_, m)| m.is_empty()) {
897 write!(f, "key {}:", self.key)?;
898 for (cpu, slot) in self.per_cpu.iter().enumerate() {
899 f.write_str("\n")?;
900 match slot {
901 Some(v) => {
902 write!(f, " cpu {cpu}: ")?;
903 std::fmt::Display::fmt(v, f)?;
904 }
905 None => write!(f, " cpu {cpu}: <unmapped>")?,
906 }
907 }
908 return Ok(());
909 }
910
911 let type_name = match &self.per_cpu.iter().flatten().next() {
912 Some(RenderedValue::Struct { type_name, .. }) => type_name.clone(),
913 _ => None,
914 };
915 let n_cpus = self.per_cpu.len();
916 match &type_name {
917 Some(name) => write!(f, "key {}: struct {name} ({n_cpus} CPUs)", self.key)?,
918 None => write!(f, "key {}: ({n_cpus} CPUs)", self.key)?,
919 }
920
921 // Skip the cross-CPU dedup pass on hosts with >64 CPUs:
922 // the existing dedup loop is O(n²) in the number of
923 // groups, so a 256-CPU host with mostly-unique values
924 // ends up doing several million RenderedValue equality
925 // comparisons (each itself a deep walk of the struct
926 // tree). Above the threshold we just emit one row per
927 // CPU — at scale, the table form would not fit in any
928 // reasonable terminal anyway, and the per-CPU rows are
929 // grep-friendly.
930 const PERCPU_DEDUP_CPU_LIMIT: usize = 64;
931 if n_cpus > PERCPU_DEDUP_CPU_LIMIT {
932 for (cpu, slot) in self.per_cpu.iter().enumerate() {
933 f.write_str("\n ")?;
934 match slot {
935 Some(v) => {
936 write!(f, "cpu {cpu}: ")?;
937 std::fmt::Display::fmt(v, f)?;
938 }
939 None => write!(f, "cpu {cpu}: <unmapped>")?,
940 }
941 }
942 return Ok(());
943 }
944
945 // Group CPUs by identical struct content. Show each unique
946 // value once with its CPU list. The find() is O(n) per
947 // CPU, which at 64 CPUs is bounded; the >64 fallback above
948 // protects callers against the quadratic scaling.
949 let mut groups: Vec<(Vec<usize>, &RenderedValue)> = Vec::new();
950 let mut unmapped: Vec<usize> = Vec::new();
951 for (cpu, slot) in self.per_cpu.iter().enumerate() {
952 match slot {
953 Some(val) => {
954 if let Some(g) = groups.iter_mut().find(|(_, v)| *v == val) {
955 g.0.push(cpu);
956 } else {
957 groups.push((vec![cpu], val));
958 }
959 }
960 None => unmapped.push(cpu),
961 }
962 }
963 // Template detection: if every CPU has a unique struct but
964 // most fields are identical, show the struct once with a
965 // per-CPU table of varying fields.
966 if groups.len() >= 3 && groups.iter().all(|(cpus, _)| cpus.len() == 1) {
967 let all_structs: Vec<(usize, &[RenderedMember])> = groups
968 .iter()
969 .filter_map(|(cpus, val)| match val {
970 RenderedValue::Struct { members, .. } => Some((cpus[0], members.as_slice())),
971 _ => None,
972 })
973 .collect();
974 if all_structs.len() == groups.len()
975 && all_structs
976 .iter()
977 .all(|(_, m)| m.len() == all_structs[0].1.len())
978 {
979 let first = all_structs[0].1;
980 let mut varying: Vec<usize> = Vec::new();
981 for i in 0..first.len() {
982 if all_structs[1..]
983 .iter()
984 .any(|(_, m)| m[i].value != first[i].value)
985 {
986 varying.push(i);
987 }
988 }
989 if !varying.is_empty() && varying.len() < 8 {
990 // Show common fields once. Zero fields are
991 // suppressed silently — no count line.
992 f.write_str("\n common:")?;
993 for (i, m) in first.iter().enumerate() {
994 if varying.contains(&i) {
995 continue;
996 }
997 if is_zero(&m.value) {
998 continue;
999 }
1000 write!(f, "\n {}: ", m.name)?;
1001 std::fmt::Display::fmt(&m.value, f)?;
1002 }
1003 // Show varying fields as per-CPU table.
1004 f.write_str("\n per-cpu:")?;
1005 f.write_str("\n cpu")?;
1006 for &vi in &varying {
1007 write!(f, " | {}", first[vi].name)?;
1008 }
1009 for (cpu, members) in &all_structs {
1010 write!(f, "\n {cpu:>3}")?;
1011 for &vi in &varying {
1012 write!(f, " | {}", members[vi].value)?;
1013 }
1014 }
1015 if !unmapped.is_empty() {
1016 write!(f, "\n cpus {unmapped:?}: <unmapped>")?;
1017 }
1018 return Ok(());
1019 }
1020 }
1021 }
1022 // Fallback: show each group with its CPU list.
1023 for (cpus, val) in &groups {
1024 let cpu_list = if cpus.len() == n_cpus {
1025 "all CPUs".to_string()
1026 } else if cpus.len() == 1 {
1027 format!("cpu {}", cpus[0])
1028 } else if cpus.windows(2).all(|w| w[1] == w[0] + 1) {
1029 // Contiguity: every adjacent pair differs by 1.
1030 // The endpoint-only `last - first + 1 == len` check
1031 // would falsely accept e.g. [0, 1, 1, 3] (span 4,
1032 // len 4, yet non-contiguous) if a duplicate or
1033 // gap somehow slipped past collection; `windows`
1034 // is robust to construction errors.
1035 format!("cpus {}-{}", cpus[0], cpus.last().unwrap())
1036 } else {
1037 format!("cpus {:?}", cpus)
1038 };
1039 write!(f, "\n {cpu_list}: ")?;
1040 std::fmt::Display::fmt(val, f)?;
1041 }
1042 if !unmapped.is_empty() {
1043 write!(f, "\n cpus {unmapped:?}: <unmapped>")?;
1044 }
1045 Ok(())
1046 }
1047}
1048
1049impl std::fmt::Display for FailureDumpPercpuHashEntry {
1050 /// Match [`FailureDumpEntry`]'s `entry: key=...` header so an
1051 /// operator scanning the human-readable failure dump sees the
1052 /// same shape regardless of whether the underlying map is a
1053 /// plain HASH or a PERCPU_HASH variant. Each per-CPU slot
1054 /// renders on its own indented line as `cpu N: <value>`.
1055 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1056 f.write_str("entry: key=")?;
1057 match &self.key {
1058 Some(k) => write_value_at_depth(f, k, 1)?,
1059 None => write!(f, "{} (raw)", self.key_hex)?,
1060 }
1061 // Per-CPU values: list each CPU's slot. Matches the
1062 // FailureDumpPercpuEntry simple branch (no group folding) for
1063 // readability — typical PERCPU_HASH maps have small
1064 // num_cpus * num_keys, so the verbose listing isn't a
1065 // problem in practice. CPU rows use `cpu N:` as a label
1066 // (the cpu id is metadata, not a struct field assignment).
1067 for (cpu, slot) in self.per_cpu.iter().enumerate() {
1068 f.write_str("\n ")?;
1069 match slot {
1070 Some(v) => {
1071 write!(f, "cpu {cpu}: ")?;
1072 write_value_at_depth(f, v, 1)?;
1073 }
1074 None => write!(f, "cpu {cpu}: <unmapped>")?,
1075 }
1076 }
1077 Ok(())
1078 }
1079}
1080
1081#[cfg(test)]
1082mod tests {
1083 use super::*;
1084
1085 /// Regression: when the event-counter timeline and
1086 /// vcpu_perf_at_freeze are the only populated sections, the
1087 /// timeline block must clear `first` so the blank-line separator
1088 /// still renders between the two. The timeline block previously
1089 /// left `first` set, which merged the two sections in this dump
1090 /// shape (every earlier section empty, so `first` was still true
1091 /// entering the timeline).
1092 #[test]
1093 fn report_display_event_counter_timeline_separated_from_vcpu_perf() {
1094 let report = FailureDumpReport {
1095 event_counter_timeline: vec![EventCounterSample {
1096 elapsed_ms: 1,
1097 select_cpu_fallback: 5,
1098 ..Default::default()
1099 }],
1100 vcpu_perf_at_freeze: vec![None],
1101 ..Default::default()
1102 };
1103 let out = format!("{report}");
1104 assert!(
1105 out.starts_with("event_counter_timeline: 1 samples (1–1ms)"),
1106 "timeline header missing: {out}"
1107 );
1108 assert!(
1109 out.contains("\n\nvcpu_perf_at_freeze:\n vcpu 0: <unavailable>"),
1110 "missing blank-line separator before vcpu_perf section: {out}"
1111 );
1112 }
1113}