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}