From 92d5655fc320f4686bb1beae7eb67cb84a43479d Mon Sep 17 00:00:00 2001 From: Federico Iezzi Date: Mon, 10 Aug 2026 12:55:16 +0200 Subject: [PATCH 1/5] feat: add machine-readable statistics export --- README.md | 23 +++ internal/app/app.go | 36 +++-- internal/app/stats_output.go | 40 +++++ internal/app/stats_output_test.go | 77 ++++++++++ internal/cli/flags.go | 8 + internal/cli/flags_test.go | 31 ++++ internal/config/config.go | 22 +++ internal/config/config_test.go | 36 +++++ internal/prefs/prefs.go | 4 + internal/prefs/prefs_test.go | 22 +++ internal/statsexport/export.go | 207 +++++++++++++++++++++++++ internal/statsexport/export_test.go | 227 ++++++++++++++++++++++++++++ 12 files changed, 723 insertions(+), 10 deletions(-) create mode 100644 internal/app/stats_output.go create mode 100644 internal/app/stats_output_test.go create mode 100644 internal/statsexport/export.go create mode 100644 internal/statsexport/export_test.go diff --git a/README.md b/README.md index f553cc6..b684d07 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,28 @@ sudo wireblast -i eth1 --dst-ip 192.0.2.10 --packet-size 512 --pps 1M -d 30s sudo wireblast --no-tui -i eth1 --dst-ip 192.0.2.10 --pps 1M -d 30s -y ``` +For repeatable benchmarks, `--no-tui` can also write versioned, machine-readable +statistics without changing the human-readable output: + +```bash +# CSV for spreadsheets and analysis tools (the default format) +sudo wireblast --no-tui -i eth1 --dst-ip 192.0.2.10 -d 30s -y \ + --stats-file run.csv + +# Or newline-delimited JSON for streaming consumers +sudo wireblast --no-tui -i eth1 --dst-ip 192.0.2.10 -d 30s -y \ + --stats-file run.jsonl --stats-format jsonl +``` + +The output contains one aggregate sample per second, followed by a final +aggregate and a final record for every AF_XDP queue. It includes traffic totals, +the most recently sampled rates, and the kernel's AF_XDP drop and ring counters. +AF_XDP counter fields use their exact Linux UAPI names. The +`kernel_rx_descriptors` and `kernel_tx_descriptors` fields report ring progress, +not packet counts; one multi-buffer packet can occupy several descriptors. +The output file must not already exist, which protects previous benchmark data +from accidental replacement. + No spare interface? A veth pair gives you a sender and a receiver on one machine, with nothing touching your real network: ```bash @@ -199,6 +221,7 @@ The command is a thin shell; the work is in `internal/`: | `internal/rate` | the aggregate token-bucket rate limiter | | `internal/dataplane` | packet I/O: AF_XDP sockets and the XDP filter, mlx5 Direct Verbs, backend choice, the run loop | | `internal/stats` | atomic counters, rate snapshots, history | +| `internal/statsexport` | versioned CSV and JSONL statistics output | | `internal/tui` | the interactive wizard and live dashboard (Bubble Tea) | | `internal/prefs` | remembers your last run under `~/.wireblast/` | | `internal/app` | wires a validated config into a running dataplane for `--no-tui` | diff --git a/internal/app/app.go b/internal/app/app.go index e2fbea7..00c9fea 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -93,7 +93,7 @@ func Prepare(cfg *config.Config, opts PrepareOptions) (*Prepared, error) { // RunNonInteractive validates the flags, prints what it is about to do, and // transmits — printing a statistics line every second and a summary at the // end. This is the shape scripts and CI use. -func RunNonInteractive(ctx context.Context, cfg *config.Config, out io.Writer) error { +func RunNonInteractive(ctx context.Context, cfg *config.Config, out io.Writer) (retErr error) { logf := func(format string, args ...any) { fmt.Fprintf(out, "wireblast: "+format+"\n", args...) } @@ -101,6 +101,13 @@ func RunNonInteractive(ctx context.Context, cfg *config.Config, out io.Writer) e if err != nil { return err } + statsOut, err := openStatsOutput(cfg) + if err != nil { + return err + } + if statsOut != nil { + defer func() { retErr = errors.Join(retErr, statsOut.Close()) }() + } if err := p.Preflight.Err(); err != nil { return err @@ -149,21 +156,24 @@ func RunNonInteractive(ctx context.Context, cfg *config.Config, out io.Writer) e // Print a status line every second while the run proceeds. reportCtx, stopReport := context.WithCancel(ctx) - done := make(chan struct{}) + done := make(chan error, 1) go func() { - defer close(done) - report(reportCtx, p.Runner, out) + done <- report(reportCtx, p.Runner, out, statsOut) }() runErr := p.Runner.Wait() stopReport() - <-done + reportErr := <-done + var finalErr error + if statsOut != nil { + finalErr = statsOut.Final(p.Runner.Stats()) + } fmt.Fprintf(out, "\n%s\n", p.Runner.Stats().Summary()) - if runErr != nil && !errors.Is(runErr, context.Canceled) { - return runErr + if errors.Is(runErr, context.Canceled) { + runErr = nil } - return nil + return errors.Join(runErr, reportErr, finalErr) } // progress prints a growing line of dots while something slow happens, and @@ -198,18 +208,24 @@ func progress(out io.Writer, what string) func() { } // report prints one status line a second until the run ends. -func report(ctx context.Context, r *dataplane.Runner, out io.Writer) { +func report(ctx context.Context, r *dataplane.Runner, out io.Writer, statsOut *statsOutput) error { t := time.NewTicker(time.Second) defer t.Stop() for { select { case <-ctx.Done(): - return + return nil case <-t.C: s := r.Stats() if s.State == stats.StateStarting { continue } + if statsOut != nil { + if err := statsOut.Sample(s); err != nil { + r.Stop() + return err + } + } fmt.Fprintln(out, s.Line()) } } diff --git a/internal/app/stats_output.go b/internal/app/stats_output.go new file mode 100644 index 0000000..7228b9b --- /dev/null +++ b/internal/app/stats_output.go @@ -0,0 +1,40 @@ +package app + +import ( + "errors" + "fmt" + "os" + + "github.com/atoonk/wireblast/internal/config" + "github.com/atoonk/wireblast/internal/stats" + "github.com/atoonk/wireblast/internal/statsexport" +) + +type statsOutput struct { + file *os.File + stream *statsexport.Writer +} + +func openStatsOutput(cfg *config.Config) (*statsOutput, error) { + if cfg.StatsFile == "" { + return nil, nil + } + file, err := os.OpenFile(cfg.StatsFile, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644) + if err != nil { + return nil, fmt.Errorf("create statistics file %s: %w", cfg.StatsFile, err) + } + stream, err := statsexport.New(statsexport.Format(cfg.StatsFormat), file) + if err != nil { + closeErr := file.Close() + removeErr := os.Remove(cfg.StatsFile) + return nil, errors.Join(err, closeErr, removeErr) + } + return &statsOutput{file: file, stream: stream}, nil +} + +func (o *statsOutput) Sample(s *stats.Snapshot) error { return o.stream.Sample(s) } +func (o *statsOutput) Final(s *stats.Snapshot) error { return o.stream.Final(s) } + +func (o *statsOutput) Close() error { + return errors.Join(o.stream.Close(), o.file.Close()) +} diff --git a/internal/app/stats_output_test.go b/internal/app/stats_output_test.go new file mode 100644 index 0000000..4192c7c --- /dev/null +++ b/internal/app/stats_output_test.go @@ -0,0 +1,77 @@ +package app + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/atoonk/wireblast/internal/config" + "github.com/atoonk/wireblast/internal/stats" +) + +func TestOpenStatsOutputDisabled(t *testing.T) { + cfg := config.Default() + out, err := openStatsOutput(&cfg) + if err != nil { + t.Fatalf("openStatsOutput: %v", err) + } + if out != nil { + t.Fatal("disabled stats output should return nil") + } +} + +func TestOpenStatsOutputRefusesToOverwrite(t *testing.T) { + path := filepath.Join(t.TempDir(), "run.csv") + if err := os.WriteFile(path, []byte("keep me"), 0o644); err != nil { + t.Fatal(err) + } + cfg := config.Default() + cfg.StatsFile = path + cfg.StatsFormat = config.StatsCSV + + if _, err := openStatsOutput(&cfg); err == nil { + t.Fatal("opening an existing stats file should fail") + } + got, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if string(got) != "keep me" { + t.Fatalf("existing file changed to %q", got) + } +} + +func TestStatsOutputCreatesAndFlushesAStream(t *testing.T) { + path := filepath.Join(t.TempDir(), "run.csv") + cfg := config.Default() + cfg.StatsFile = path + cfg.StatsFormat = config.StatsCSV + out, err := openStatsOutput(&cfg) + if err != nil { + t.Fatalf("openStatsOutput: %v", err) + } + s := &stats.Snapshot{ + At: time.Date(2026, 8, 10, 12, 0, 0, 0, time.UTC), + State: stats.StateRunning, + } + if err := out.Sample(s); err != nil { + t.Fatalf("Sample: %v", err) + } + + // A sample must be visible before Close so a preempted process leaves data. + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(data), "schema_version") || !strings.Contains(string(data), "sample") { + t.Fatalf("stream was not flushed after Sample:\n%s", data) + } + if err := out.Final(s); err != nil { + t.Fatalf("Final: %v", err) + } + if err := out.Close(); err != nil { + t.Fatalf("Close: %v", err) + } +} diff --git a/internal/cli/flags.go b/internal/cli/flags.go index eb945b6..a0d8420 100644 --- a/internal/cli/flags.go +++ b/internal/cli/flags.go @@ -119,6 +119,10 @@ in L1, so --bps 10G means 10G line rate.`, "transmit queues one worker drives, round-robin (0 = per backend: 1 for AF_XDP, 4 for mlx5)") f.StringVar(&cfg.IO, "io", cfg.IO, "packet I/O backend: auto (default), afxdp, or mlx5 for ConnectX Direct Verbs (needs a -tags mlx5 build)") + f.StringVar(&cfg.StatsFile, "stats-file", cfg.StatsFile, + "write machine-readable statistics to a new file (requires --no-tui)") + f.StringVar((*string)(&cfg.StatsFormat), "stats-format", string(cfg.StatsFormat), + "machine-readable statistics format: csv, jsonl") f.StringVar((*string)(&cfg.RxMode), "rx-mode", string(cfg.RxMode), "what to receive through AF_XDP: "+rxModeList()) @@ -201,6 +205,10 @@ func applyOptions(cmd *cobra.Command, cfg *config.Config, opt *options) error { cfg.RxMode = config.RxMode(strings.ToLower(strings.TrimSpace(string(cfg.RxMode)))) cfg.PCAPTiming = config.PcapTiming(strings.ToLower(strings.TrimSpace(string(cfg.PCAPTiming)))) cfg.FlowOrder = config.FlowOrder(strings.ToLower(strings.TrimSpace(string(cfg.FlowOrder)))) + cfg.StatsFormat = config.StatsFormat(strings.ToLower(strings.TrimSpace(string(cfg.StatsFormat)))) + if f.Changed("stats-format") && cfg.StatsFile == "" { + return fmt.Errorf("--stats-format requires --stats-file") + } // Choosing --mode pcap without saying --rx-mode implies nothing about // receiving, but choosing --pcap without --mode is an easy slip to catch. diff --git a/internal/cli/flags_test.go b/internal/cli/flags_test.go index 4bb0af8..319ae02 100644 --- a/internal/cli/flags_test.go +++ b/internal/cli/flags_test.go @@ -68,6 +68,36 @@ func TestFullNonInteractiveExample(t *testing.T) { } } +func TestStatsExportFlags(t *testing.T) { + got, err := parse(t, + "--no-tui", + "--stats-file", "run.csv", + "--stats-format", "CSV", + ) + if err != nil { + t.Fatalf("Execute: %v", err) + } + if got.StatsFile != "run.csv" || got.StatsFormat != config.StatsCSV { + t.Fatalf("stats export parsed as file=%q format=%q", got.StatsFile, got.StatsFormat) + } + + got, err = parse(t, + "--no-tui", + "--stats-file", "run.jsonl", + "--stats-format", "jsonl", + ) + if err != nil { + t.Fatalf("Execute: %v", err) + } + if got.StatsFormat != config.StatsJSONL { + t.Fatalf("stats format = %q, want jsonl", got.StatsFormat) + } + + if _, err := parse(t, "--stats-format", "jsonl"); err == nil { + t.Error("--stats-format without --stats-file should fail") + } +} + func TestRateFlags(t *testing.T) { tests := []struct { args []string @@ -193,6 +223,7 @@ func TestEveryConfigFieldHasAFlag(t *testing.T) { "duration", "pps", "bps", "queues", "rx-mode", "rx-port", "rx-cidr", "pcap", "pcap-timing", "pcap-loop", "pcap-memory", + "stats-file", "stats-format", "no-tui", "start", "yes", "allow-match-all", "forget", } for _, name := range want { diff --git a/internal/config/config.go b/internal/config/config.go index 4a56db3..33e5ed6 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -88,6 +88,14 @@ const ( FlowRandom FlowOrder = "random" ) +// StatsFormat is the machine-readable stream written by --stats-file. +type StatsFormat string + +const ( + StatsCSV StatsFormat = "csv" + StatsJSONL StatsFormat = "jsonl" +) + // Frame-size limits, in total Ethernet frame bytes including the 4-byte FCS. // See the package docs on PacketSize for exactly what that means. const ( @@ -174,6 +182,11 @@ type Config struct { // card and AF_XDP everywhere else; the run prints which it chose. IO string + // Machine-readable statistics. StatsFile being empty disables export. + // These describe one invocation and are never remembered by the wizard. + StatsFile string + StatsFormat StatsFormat + // Receive behaviour. RxMode RxMode RxPorts []uint16 @@ -217,6 +230,7 @@ func Default() Config { PCAPLoop: true, IO: "auto", QueuesPerWorker: 0, + StatsFormat: StatsCSV, } } @@ -383,6 +397,14 @@ func (c *Config) Validate() error { if c.Queues < 0 { bad("--queues must not be negative (0 means all available queues)") } + if c.StatsFile != "" { + if !c.NoTUI { + bad("--stats-file is currently supported only with --no-tui") + } + if c.StatsFormat != StatsCSV && c.StatsFormat != StatsJSONL { + bad("--stats-format %q is not one of csv, jsonl", c.StatsFormat) + } + } errs = append(errs, c.validateRx()...) return errors.Join(errs...) diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 515a6e1..0806e59 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -44,6 +44,9 @@ func TestDefaults(t *testing.T) { if c.Queues != 0 { t.Errorf("default queues = %d, want 0 (all)", c.Queues) } + if c.StatsFile != "" || c.StatsFormat != StatsCSV { + t.Errorf("default stats export = file %q format %q, want disabled csv", c.StatsFile, c.StatsFormat) + } if c.FlowOrder != FlowSequential { t.Errorf("default flow-order = %q, want sequential", c.FlowOrder) } @@ -53,6 +56,39 @@ func TestDefaults(t *testing.T) { } } +func TestStatsExportValidation(t *testing.T) { + tests := []struct { + name string + mutate func(*Config) + wantErr bool + }{ + {"disabled", func(*Config) {}, false}, + {"csv noninteractive", func(c *Config) { + c.NoTUI, c.StatsFile, c.StatsFormat = true, "run.csv", StatsCSV + }, false}, + {"jsonl noninteractive", func(c *Config) { + c.NoTUI, c.StatsFile, c.StatsFormat = true, "run.jsonl", StatsJSONL + }, false}, + {"tui rejected", func(c *Config) { + c.StatsFile, c.StatsFormat = "run.csv", StatsCSV + }, true}, + {"unknown format", func(c *Config) { + c.NoTUI, c.StatsFile, c.StatsFormat = true, "run.bin", StatsFormat("binary") + }, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := valid() + tt.mutate(&cfg) + err := cfg.Validate() + if (err != nil) != tt.wantErr { + t.Fatalf("Validate() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + func TestValidate(t *testing.T) { tests := []struct { name string diff --git a/internal/prefs/prefs.go b/internal/prefs/prefs.go index f00de2f..ff66472 100644 --- a/internal/prefs/prefs.go +++ b/internal/prefs/prefs.go @@ -188,6 +188,8 @@ func sanitise(c config.Config) config.Config { c.SkipWizard = false c.AssumeYes = false c.AllowMatchAll = false + c.StatsFile = "" + c.StatsFormat = config.StatsCSV // Match-all is not remembered either. It is still guarded by a // confirmation, so carrying it forward would not be unsafe — but a later, @@ -273,6 +275,8 @@ func Merge(flags config.Config, saved config.Config, changed func(flag string) b out.SkipWizard = flags.SkipWizard out.AssumeYes = flags.AssumeYes out.AllowMatchAll = flags.AllowMatchAll + out.StatsFile = flags.StatsFile + out.StatsFormat = flags.StatsFormat // A --bps on the command line lifts the remembered packet cap the same way // it lifts the default one. diff --git a/internal/prefs/prefs_test.go b/internal/prefs/prefs_test.go index 4d94e01..703a78c 100644 --- a/internal/prefs/prefs_test.go +++ b/internal/prefs/prefs_test.go @@ -72,6 +72,22 @@ func TestConsentIsNeverRemembered(t *testing.T) { } } +func TestStatsOutputIsNeverRemembered(t *testing.T) { + s := NewAt(t.TempDir()) + c := sample() + c.NoTUI = true + c.StatsFile = "/tmp/run.jsonl" + c.StatsFormat = config.StatsJSONL + if err := s.Save(&c); err != nil { + t.Fatal(err) + } + + got, _ := s.Last() + if got.StatsFile != "" || got.StatsFormat != config.StatsCSV { + t.Errorf("stats output must not be remembered: file=%q format=%q", got.StatsFile, got.StatsFormat) + } +} + // Narrower receive modes are genuinely useful to remember, and are kept. func TestNarrowReceiveModesAreRemembered(t *testing.T) { s := NewAt(t.TempDir()) @@ -246,12 +262,18 @@ func TestMergeNeverInheritsConsent(t *testing.T) { saved.AllowMatchAll = true saved.AssumeYes = true saved.NoTUI = true + saved.StatsFile = "/tmp/old.csv" + saved.StatsFormat = config.StatsJSONL flags := config.Default() + flags.StatsFile = "/tmp/new.csv" got := Merge(flags, saved, func(string) bool { return false }) if got.AllowMatchAll || got.AssumeYes || got.NoTUI { t.Errorf("consent flags must come from this invocation only: %+v", got) } + if got.StatsFile != "/tmp/new.csv" || got.StatsFormat != config.StatsCSV { + t.Errorf("stats output must come from this invocation only: file=%q format=%q", got.StatsFile, got.StatsFormat) + } } // A --bps on the command line has to lift a remembered packet cap, or the new diff --git a/internal/statsexport/export.go b/internal/statsexport/export.go new file mode 100644 index 0000000..aba2a51 --- /dev/null +++ b/internal/statsexport/export.go @@ -0,0 +1,207 @@ +// Package statsexport writes stable, machine-readable run statistics. +package statsexport + +import ( + "encoding/csv" + "encoding/json" + "fmt" + "io" + "strconv" + "time" + + "github.com/atoonk/wireblast/internal/stats" +) + +// SchemaVersion changes only when the record contract changes incompatibly. +const SchemaVersion = 1 + +// Format selects the output encoding. +type Format string + +const ( + FormatCSV Format = "csv" + FormatJSONL Format = "jsonl" +) + +// Writer serializes snapshots. It is not safe for concurrent use. +type Writer struct { + format Format + csv *csv.Writer + json *json.Encoder +} + +// New creates an exporter and writes the CSV header when needed. +func New(format Format, out io.Writer) (*Writer, error) { + w := &Writer{format: format} + switch format { + case FormatCSV: + w.csv = csv.NewWriter(out) + if err := w.csv.Write(csvHeader); err != nil { + return nil, fmt.Errorf("write CSV header: %w", err) + } + w.csv.Flush() + if err := w.csv.Error(); err != nil { + return nil, fmt.Errorf("flush CSV header: %w", err) + } + case FormatJSONL: + w.json = json.NewEncoder(out) + default: + return nil, fmt.Errorf("unknown statistics format %q", format) + } + return w, nil +} + +// Sample writes one periodic aggregate record. +func (w *Writer) Sample(s *stats.Snapshot) error { + return w.write(aggregate("sample", s)) +} + +// Final writes the lifetime aggregate followed by one record per queue. +func (w *Writer) Final(s *stats.Snapshot) error { + if err := w.write(aggregate("final", s)); err != nil { + return err + } + for _, q := range s.Kernel.PerQueue { + queue := q.Queue + r := baseRecord("queue_final", s) + r.QueueID = &queue + r.KernelRXDescriptors = q.RxPackets + r.KernelTXDescriptors = q.TxPackets + r.RXDropped = q.RxDropped + r.RXRingFull = q.RxRingFull + if err := w.write(r); err != nil { + return err + } + } + return nil +} + +// Close flushes buffered output. +func (w *Writer) Close() error { + if w.csv == nil { + return nil + } + w.csv.Flush() + if err := w.csv.Error(); err != nil { + return fmt.Errorf("flush CSV statistics: %w", err) + } + return nil +} + +func (w *Writer) write(r record) error { + switch w.format { + case FormatCSV: + if err := w.csv.Write(r.csv()); err != nil { + return fmt.Errorf("write CSV statistics: %w", err) + } + w.csv.Flush() + if err := w.csv.Error(); err != nil { + return fmt.Errorf("flush CSV statistics: %w", err) + } + case FormatJSONL: + if err := w.json.Encode(r); err != nil { + return fmt.Errorf("write JSONL statistics: %w", err) + } + } + return nil +} + +type record struct { + SchemaVersion int `json:"schema_version"` + RecordType string `json:"record_type"` + TimestampUTC string `json:"timestamp_utc"` + Elapsed float64 `json:"elapsed_seconds"` + State string `json:"state"` + Transmits bool `json:"transmits"` + QueueID *int `json:"queue_id,omitempty"` + + TXPackets uint64 `json:"tx_packets"` + TXBytes uint64 `json:"tx_bytes"` + TXUDP uint64 `json:"tx_udp"` + TXTCP uint64 `json:"tx_tcp"` + TXOther uint64 `json:"tx_other"` + TXErrors uint64 `json:"tx_errors"` + TXPPS float64 `json:"tx_pps"` + TXL1BPS float64 `json:"tx_l1_bps"` + TXL2BPS float64 `json:"tx_l2_bps"` + + RXPackets uint64 `json:"rx_packets"` + RXBytes uint64 `json:"rx_bytes"` + RXUDP uint64 `json:"rx_udp"` + RXTCP uint64 `json:"rx_tcp"` + RXOther uint64 `json:"rx_other"` + RXDrops uint64 `json:"rx_drops"` + RXPPS float64 `json:"rx_pps"` + RXL1BPS float64 `json:"rx_l1_bps"` + RXL2BPS float64 `json:"rx_l2_bps"` + + KernelQueues int `json:"kernel_queues"` + KernelRXDescriptors uint64 `json:"kernel_rx_descriptors"` + KernelTXDescriptors uint64 `json:"kernel_tx_descriptors"` + RXDropped uint64 `json:"rx_dropped"` + RXRingFull uint64 `json:"rx_ring_full"` + RXFillRingEmpty uint64 `json:"rx_fill_ring_empty_descs"` + RXInvalidDescs uint64 `json:"rx_invalid_descs"` + TXInvalidDescs uint64 `json:"tx_invalid_descs"` + TXRingEmpty uint64 `json:"tx_ring_empty_descs"` +} + +func baseRecord(kind string, s *stats.Snapshot) record { + timestamp := "" + if !s.At.IsZero() { + timestamp = s.At.UTC().Format(time.RFC3339Nano) + } + return record{ + SchemaVersion: SchemaVersion, + RecordType: kind, + TimestampUTC: timestamp, + Elapsed: s.Elapsed.Seconds(), + State: s.State.String(), + Transmits: s.Transmits, + } +} + +func aggregate(kind string, s *stats.Snapshot) record { + r := baseRecord(kind, s) + r.TXPackets, r.TXBytes = s.TotalTX.Packets, s.TotalTX.Bytes + r.TXUDP, r.TXTCP, r.TXOther = s.TotalTX.UDP, s.TotalTX.TCP, s.TotalTX.Other + r.TXErrors = s.TotalTX.Errors + r.TXPPS, r.TXL1BPS, r.TXL2BPS = s.TXRate.PPS, s.TXRate.WireBPS, s.TXRate.FrameBPS + r.RXPackets, r.RXBytes = s.TotalRX.Packets, s.TotalRX.Bytes + r.RXUDP, r.RXTCP, r.RXOther = s.TotalRX.UDP, s.TotalRX.TCP, s.TotalRX.Other + r.RXDrops = s.TotalRX.Drops + r.RXPPS, r.RXL1BPS, r.RXL2BPS = s.RXRate.PPS, s.RXRate.WireBPS, s.RXRate.FrameBPS + r.KernelQueues = s.Kernel.Queues + r.KernelRXDescriptors, r.KernelTXDescriptors = s.Kernel.RxPackets, s.Kernel.TxPackets + r.RXDropped, r.RXRingFull = s.Kernel.RxDropped, s.Kernel.RxRingFull + r.RXFillRingEmpty, r.RXInvalidDescs = s.Kernel.RxFillRingEmpty, s.Kernel.RxInvalidDescs + r.TXInvalidDescs, r.TXRingEmpty = s.Kernel.TxInvalidDescs, s.Kernel.TxRingEmpty + return r +} + +var csvHeader = []string{ + "schema_version", "record_type", "timestamp_utc", "elapsed_seconds", "state", "transmits", "queue_id", + "tx_packets", "tx_bytes", "tx_udp", "tx_tcp", "tx_other", "tx_errors", "tx_pps", "tx_l1_bps", "tx_l2_bps", + "rx_packets", "rx_bytes", "rx_udp", "rx_tcp", "rx_other", "rx_drops", "rx_pps", "rx_l1_bps", "rx_l2_bps", + "kernel_queues", "kernel_rx_descriptors", "kernel_tx_descriptors", "rx_dropped", "rx_ring_full", "rx_fill_ring_empty_descs", + "rx_invalid_descs", "tx_invalid_descs", "tx_ring_empty_descs", +} + +func (r record) csv() []string { + queue := "" + if r.QueueID != nil { + queue = strconv.Itoa(*r.QueueID) + } + u := strconv.FormatUint + f := func(v float64) string { return strconv.FormatFloat(v, 'f', 6, 64) } + return []string{ + strconv.Itoa(r.SchemaVersion), r.RecordType, r.TimestampUTC, f(r.Elapsed), r.State, + strconv.FormatBool(r.Transmits), queue, + u(r.TXPackets, 10), u(r.TXBytes, 10), u(r.TXUDP, 10), u(r.TXTCP, 10), u(r.TXOther, 10), u(r.TXErrors, 10), + f(r.TXPPS), f(r.TXL1BPS), f(r.TXL2BPS), + u(r.RXPackets, 10), u(r.RXBytes, 10), u(r.RXUDP, 10), u(r.RXTCP, 10), u(r.RXOther, 10), u(r.RXDrops, 10), + f(r.RXPPS), f(r.RXL1BPS), f(r.RXL2BPS), + strconv.Itoa(r.KernelQueues), u(r.KernelRXDescriptors, 10), u(r.KernelTXDescriptors, 10), u(r.RXDropped, 10), + u(r.RXRingFull, 10), u(r.RXFillRingEmpty, 10), u(r.RXInvalidDescs, 10), u(r.TXInvalidDescs, 10), u(r.TXRingEmpty, 10), + } +} diff --git a/internal/statsexport/export_test.go b/internal/statsexport/export_test.go new file mode 100644 index 0000000..136877b --- /dev/null +++ b/internal/statsexport/export_test.go @@ -0,0 +1,227 @@ +package statsexport + +import ( + "bytes" + "encoding/csv" + "encoding/json" + "errors" + "io" + "strings" + "testing" + "time" + + "github.com/atoonk/wireblast/internal/stats" +) + +func fixture() *stats.Snapshot { + return &stats.Snapshot{ + At: time.Date(2026, 8, 10, 12, 30, 0, 123, time.FixedZone("test", 2*60*60)), + Elapsed: 1500 * time.Millisecond, + State: stats.StateRunning, + Transmits: true, + TotalTX: stats.Totals{ + Packets: 100, Bytes: 6400, UDP: 90, TCP: 5, Other: 5, Errors: 7, + }, + TotalRX: stats.Totals{ + Packets: 80, Bytes: 5120, UDP: 70, TCP: 5, Other: 5, Drops: 11, + }, + TXRate: stats.Rates{PPS: 99.5, FrameBPS: 50_944, WireBPS: 66_864}, + RXRate: stats.Rates{PPS: 79.5, FrameBPS: 40_704, WireBPS: 53_424}, + Kernel: stats.Kernel{ + Queues: 2, RxPackets: 80, TxPackets: 100, + RxDropped: 3, RxRingFull: 8, RxFillRingEmpty: 9, + RxInvalidDescs: 4, TxInvalidDescs: 7, TxRingEmpty: 6, + PerQueue: []stats.KernelQueue{ + {Queue: 0, RxPackets: 50, TxPackets: 60, RxDropped: 1, RxRingFull: 2}, + {Queue: 1, RxPackets: 30, TxPackets: 40, RxDropped: 2, RxRingFull: 6}, + }, + }, + } +} + +func TestCSVWritesStableAggregateAndQueueRecords(t *testing.T) { + var out bytes.Buffer + w, err := New(FormatCSV, &out) + if err != nil { + t.Fatalf("New: %v", err) + } + if err := w.Sample(fixture()); err != nil { + t.Fatalf("Sample: %v", err) + } + if err := w.Final(fixture()); err != nil { + t.Fatalf("Final: %v", err) + } + if err := w.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + + rows, err := csv.NewReader(strings.NewReader(out.String())).ReadAll() + if err != nil { + t.Fatalf("ReadAll: %v\n%s", err, out.String()) + } + if len(rows) != 5 { + t.Fatalf("rows = %d, want header + sample + final + 2 queues", len(rows)) + } + header := index(rows[0]) + assertCSV(t, header, rows[1], "record_type", "sample") + assertCSV(t, header, rows[1], "timestamp_utc", "2026-08-10T10:30:00.000000123Z") + assertCSV(t, header, rows[1], "elapsed_seconds", "1.500000") + assertCSV(t, header, rows[1], "tx_packets", "100") + assertCSV(t, header, rows[1], "tx_l1_bps", "66864.000000") + assertCSV(t, header, rows[1], "rx_dropped", "3") + assertCSV(t, header, rows[1], "rx_fill_ring_empty_descs", "9") + assertCSV(t, header, rows[1], "tx_ring_empty_descs", "6") + assertCSV(t, header, rows[2], "record_type", "final") + assertCSV(t, header, rows[3], "record_type", "queue_final") + assertCSV(t, header, rows[3], "queue_id", "0") + assertCSV(t, header, rows[4], "queue_id", "1") +} + +func TestSchemaNamesRingProgressAsDescriptors(t *testing.T) { + var out bytes.Buffer + w, err := New(FormatCSV, &out) + if err != nil { + t.Fatalf("New: %v", err) + } + if err := w.Final(fixture()); err != nil { + t.Fatalf("Final: %v", err) + } + + rows, err := csv.NewReader(strings.NewReader(out.String())).ReadAll() + if err != nil { + t.Fatalf("ReadAll: %v", err) + } + header := index(rows[0]) + for _, name := range []string{"kernel_rx_descriptors", "kernel_tx_descriptors"} { + if _, ok := header[name]; !ok { + t.Errorf("CSV header is missing descriptor field %q", name) + } + } + for _, old := range []string{"kernel_rx_packets", "kernel_tx_packets"} { + if _, ok := header[old]; ok { + t.Errorf("CSV header still calls ring descriptors packets: %q", old) + } + } + assertCSV(t, header, rows[1], "kernel_rx_descriptors", "80") + assertCSV(t, header, rows[1], "kernel_tx_descriptors", "100") + assertCSV(t, header, rows[2], "kernel_rx_descriptors", "50") + assertCSV(t, header, rows[2], "kernel_tx_descriptors", "60") +} + +func TestJSONLWritesTheSameVersionedSchema(t *testing.T) { + var out bytes.Buffer + w, err := New(FormatJSONL, &out) + if err != nil { + t.Fatalf("New: %v", err) + } + if err := w.Sample(fixture()); err != nil { + t.Fatalf("Sample: %v", err) + } + if err := w.Final(fixture()); err != nil { + t.Fatalf("Final: %v", err) + } + if err := w.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + + dec := json.NewDecoder(&out) + var records []map[string]any + for { + var record map[string]any + if err := dec.Decode(&record); errors.Is(err, io.EOF) { + break + } else if err != nil { + t.Fatalf("Decode: %v", err) + } + records = append(records, record) + } + if len(records) != 4 { + t.Fatalf("records = %d, want sample + final + 2 queues", len(records)) + } + if got := records[0]["schema_version"]; got != float64(SchemaVersion) { + t.Errorf("schema_version = %v, want %d", got, SchemaVersion) + } + if got := records[0]["record_type"]; got != "sample" { + t.Errorf("record_type = %v, want sample", got) + } + if got := records[0]["rx_invalid_descs"]; got != float64(4) { + t.Errorf("rx_invalid_descs = %v, want 4", got) + } + if got := records[0]["rx_fill_ring_empty_descs"]; got != float64(9) { + t.Errorf("rx_fill_ring_empty_descs = %v, want 9", got) + } + if got := records[0]["tx_ring_empty_descs"]; got != float64(6) { + t.Errorf("tx_ring_empty_descs = %v, want 6", got) + } + if _, ok := records[0]["rx_fill_ring_empty"]; ok { + t.Error("JSONL record must use the exact rx_fill_ring_empty_descs UAPI name") + } + if _, ok := records[0]["tx_ring_empty"]; ok { + t.Error("JSONL record must use the exact tx_ring_empty_descs UAPI name") + } + if got := records[0]["kernel_rx_descriptors"]; got != float64(80) { + t.Errorf("kernel_rx_descriptors = %v, want 80", got) + } + if _, ok := records[0]["kernel_rx_packets"]; ok { + t.Error("JSONL record must not call ring descriptors packets") + } + if _, ok := records[0]["kernel_tx_packets"]; ok { + t.Error("JSONL record must not call ring descriptors packets") + } + if _, ok := records[0]["queue_id"]; ok { + t.Error("aggregate JSON record should omit queue_id") + } + if got := records[2]["queue_id"]; got != float64(0) { + t.Errorf("first queue_id = %v, want 0", got) + } +} + +func TestNewRejectsUnknownFormat(t *testing.T) { + if _, err := New(Format("xml"), io.Discard); err == nil { + t.Fatal("New(xml) should fail") + } +} + +func TestNewReportsCSVHeaderWriteFailure(t *testing.T) { + if _, err := New(FormatCSV, failWriter{}); err == nil { + t.Fatal("New(csv) should report a header write failure") + } +} + +func TestJSONLReportsRecordWriteFailure(t *testing.T) { + w, err := New(FormatJSONL, failWriter{}) + if err != nil { + t.Fatalf("New: %v", err) + } + if err := w.Sample(fixture()); err == nil { + t.Fatal("Sample should report a record write failure") + } +} + +type failWriter struct{} + +func (failWriter) Write([]byte) (int, error) { + return 0, errors.New("disk full") +} + +func index(header []string) map[string]int { + out := make(map[string]int, len(header)) + for i, name := range header { + out[name] = i + } + return out +} + +func assertCSV(t *testing.T, header map[string]int, row []string, field, want string) { + t.Helper() + i, ok := header[field] + if !ok { + t.Fatalf("missing CSV field %q", field) + } + if i >= len(row) { + t.Fatalf("row has %d columns, field %q needs column %d", len(row), field, i) + } + if got := row[i]; got != want { + t.Errorf("%s = %q, want %q", field, got, want) + } +} From 7c3c80974a05f14b0f55eea6ae3dace7d66106eb Mon Sep 17 00:00:00 2001 From: Federico Iezzi Date: Mon, 10 Aug 2026 13:04:35 +0200 Subject: [PATCH 2/5] feat: expose typed AF_XDP diagnostics --- README.md | 14 ++++ internal/dataplane/runner.go | 47 +++--------- internal/dataplane/session_test.go | 15 +++- internal/stats/format.go | 33 ++++++++- internal/stats/format_test.go | 18 ++++- internal/stats/stats.go | 114 ++++++++++++++++++++++++++--- internal/stats/stats_test.go | 72 ++++++++++++++++-- internal/tui/dashboard.go | 69 +++++++++++------ internal/tui/tui.go | 6 +- internal/tui/tui_test.go | 28 +++++++ internal/tui/wizard.go | 2 +- 11 files changed, 329 insertions(+), 89 deletions(-) diff --git a/README.md b/README.md index b684d07..9e75ebe 100644 --- a/README.md +++ b/README.md @@ -116,6 +116,20 @@ sudo wireblast -i wb0 --dst-ip 10.99.0.2 --packet-size 512 --pps 100k -d 10s The [namespace lab guide](https://wireblast.mintlify.site/guides/namespace-lab) builds this out into a full sender and receiver. +### AF_XDP diagnostics + +The dashboard keeps packet drops and ring-starvation events separate. Press +`w` during a run to expand the compact AF_XDP line into all six Linux +`XDP_STATISTICS` counters, with exact UAPI names, counts, rates, meanings and +per-queue details. The final summary prints the same counter taxonomy for +unattended `--no-tui` runs. + +`rx_dropped`, `rx_ring_full`, `rx_invalid_descs` and `tx_invalid_descs` report +drops. `rx_fill_ring_empty_descs` and `tx_ring_empty_descs` report failed ring +retrievals: useful signs of starvation, but not packet-loss counts. See the +[Linux AF_XDP documentation](https://docs.kernel.org/networking/af_xdp.html) +and [`struct xdp_statistics`](https://github.com/torvalds/linux/blob/master/include/uapi/linux/if_xdp.h). + ## Examples The [`examples/`](examples) directory has 20 runnable examples, simplest to most advanced, each with a short README and a shell script driven by environment variables: diff --git a/internal/dataplane/runner.go b/internal/dataplane/runner.go index 3b40780..f5a6e3d 100644 --- a/internal/dataplane/runner.go +++ b/internal/dataplane/runner.go @@ -890,11 +890,15 @@ func (r *Runner) readKernel() (stats.Kernel, error) { } for q, s := range fs.PerQueue { k.PerQueue = append(k.PerQueue, stats.KernelQueue{ - Queue: q, - RxPackets: s.Received, - TxPackets: s.Transmitted, - RxDropped: s.KernelStats.Rx_dropped, - RxRingFull: s.KernelStats.Rx_ring_full, + Queue: q, + RxPackets: s.Received, + TxPackets: s.Transmitted, + RxDropped: s.KernelStats.Rx_dropped, + RxRingFull: s.KernelStats.Rx_ring_full, + RxFillRingEmpty: s.KernelStats.Rx_fill_ring_empty_descs, + RxInvalidDescs: s.KernelStats.Rx_invalid_descs, + TxInvalidDescs: s.KernelStats.Tx_invalid_descs, + TxRingEmpty: s.KernelStats.Tx_ring_empty_descs, }) } return k, nil @@ -918,38 +922,7 @@ func closedSocket(err error) bool { // baseline still holds the old totals; clamping at zero keeps that transition // from producing nonsense. func subtractKernel(a, b stats.Kernel) stats.Kernel { - sub := func(x, y uint64) uint64 { - if x < y { - return 0 - } - return x - y - } - out := stats.Kernel{ - Queues: a.Queues, - RxPackets: sub(a.RxPackets, b.RxPackets), - TxPackets: sub(a.TxPackets, b.TxPackets), - RxDropped: sub(a.RxDropped, b.RxDropped), - RxRingFull: sub(a.RxRingFull, b.RxRingFull), - RxFillRingEmpty: sub(a.RxFillRingEmpty, b.RxFillRingEmpty), - RxInvalidDescs: sub(a.RxInvalidDescs, b.RxInvalidDescs), - TxInvalidDescs: sub(a.TxInvalidDescs, b.TxInvalidDescs), - TxRingEmpty: sub(a.TxRingEmpty, b.TxRingEmpty), - PerQueue: make([]stats.KernelQueue, 0, len(a.PerQueue)), - } - for i, q := range a.PerQueue { - base := stats.KernelQueue{} - if i < len(b.PerQueue) { - base = b.PerQueue[i] - } - out.PerQueue = append(out.PerQueue, stats.KernelQueue{ - Queue: q.Queue, - RxPackets: sub(q.RxPackets, base.RxPackets), - TxPackets: sub(q.TxPackets, base.TxPackets), - RxDropped: sub(q.RxDropped, base.RxDropped), - RxRingFull: sub(q.RxRingFull, base.RxRingFull), - }) - } - return out + return a.Since(b) } // nextPow2 rounds up to a power of two, which is what the frame region wants. diff --git a/internal/dataplane/session_test.go b/internal/dataplane/session_test.go index 36e1f34..6452f65 100644 --- a/internal/dataplane/session_test.go +++ b/internal/dataplane/session_test.go @@ -154,17 +154,17 @@ func TestSessionReportsOpenFailures(t *testing.T) { func TestKernelCountersAreBaselined(t *testing.T) { base := stats.Kernel{ RxPackets: 1000, TxPackets: 2000, RxDropped: 7, RxRingFull: 3, - TxInvalidDescs: 1, + RxFillRingEmpty: 2, RxInvalidDescs: 4, TxInvalidDescs: 1, TxRingEmpty: 5, PerQueue: []stats.KernelQueue{ - {Queue: 0, RxPackets: 600, TxPackets: 1200, RxDropped: 4}, + {Queue: 0, RxPackets: 600, TxPackets: 1200, RxDropped: 4, RxInvalidDescs: 2, TxRingEmpty: 3}, {Queue: 1, RxPackets: 400, TxPackets: 800, RxDropped: 3}, }, } now := stats.Kernel{ Queues: 2, RxPackets: 1500, TxPackets: 2500, RxDropped: 9, RxRingFull: 3, - TxInvalidDescs: 1, + RxFillRingEmpty: 8, RxInvalidDescs: 7, TxInvalidDescs: 1, TxRingEmpty: 9, PerQueue: []stats.KernelQueue{ - {Queue: 0, RxPackets: 900, TxPackets: 1500, RxDropped: 5}, + {Queue: 0, RxPackets: 900, TxPackets: 1500, RxDropped: 5, RxInvalidDescs: 7, TxRingEmpty: 9}, {Queue: 1, RxPackets: 600, TxPackets: 1000, RxDropped: 4}, }, } @@ -188,6 +188,13 @@ func TestKernelCountersAreBaselined(t *testing.T) { t.Errorf("per-queue = %d/%d, want 300/200", got.PerQueue[0].RxPackets, got.PerQueue[1].RxPackets) } + if got.RxFillRingEmpty != 6 || got.RxInvalidDescs != 3 || got.TxRingEmpty != 4 { + t.Errorf("extended counters = fill-empty %d, rx-invalid %d, tx-empty %d; want 6, 3, 4", + got.RxFillRingEmpty, got.RxInvalidDescs, got.TxRingEmpty) + } + if got.PerQueue[0].RxInvalidDescs != 5 || got.PerQueue[0].TxRingEmpty != 6 { + t.Errorf("extended per-queue counters = %+v, want rx-invalid 5 and tx-empty 6", got.PerQueue[0]) + } } // A fresh attach zeroes the kernel's counters while the baseline still holds diff --git a/internal/stats/format.go b/internal/stats/format.go index d0e68c8..2269f80 100644 --- a/internal/stats/format.go +++ b/internal/stats/format.go @@ -37,6 +37,22 @@ func PPS(v float64) string { } } +// PerSecond renders a generic event rate without calling every event a packet. +func PerSecond(v float64) string { + switch { + case v >= 1e12: + return trim(v/1e12) + " T/s" + case v >= 1e9: + return trim(v/1e9) + " G/s" + case v >= 1e6: + return trim(v/1e6) + " M/s" + case v >= 1e3: + return trim(v/1e3) + " k/s" + default: + return trim(v) + "/s" + } +} + // Bits renders a bit rate, e.g. "9.87 Gbit/s". func Bits(v float64) string { switch { @@ -142,7 +158,9 @@ func (s *Snapshot) Summary() string { dur := s.Elapsed.Seconds() fmt.Fprintf(&b, "ran for %s\n", Duration(s.Elapsed)) if !s.Transmits { - return strings.TrimRight(b.String()+s.rxSummary(dur), "\n") + b.WriteString(s.rxSummary(dur)) + b.WriteString(s.afxdpSummary()) + return strings.TrimRight(b.String(), "\n") } fmt.Fprintf(&b, " tx: %s packets, %s", Count(s.TotalTX.Packets), Bytes(s.TotalTX.Bytes)) if dur > 0 { @@ -163,6 +181,7 @@ func (s *Snapshot) Summary() string { fmt.Fprintf(&b, " errors %s\n", Count(s.TotalTX.Errors)) } b.WriteString(s.rxSummary(dur)) + b.WriteString(s.afxdpSummary()) // The summary is a lifetime report (like the drop count above), so it lists // problems since the run started, not since the last on-screen reset. for _, p := range problems(s.Kernel, Kernel{}) { @@ -171,6 +190,18 @@ func (s *Snapshot) Summary() string { return strings.TrimRight(b.String(), "\n") } +// afxdpSummary keeps the six XDP_STATISTICS counter types separate. In +// particular, empty-ring events are pressure signals, not packet-drop counts. +func (s *Snapshot) afxdpSummary() string { + var b strings.Builder + b.WriteString(" AF_XDP diagnostics:\n") + for _, d := range s.Kernel.Diagnostics(s.Elapsed) { + fmt.Fprintf(&b, " %-25s %s (%s) %s\n", + d.Name, Count(d.Count), PerSecond(d.PerSecond), d.Meaning) + } + return b.String() +} + // rxSummary renders the receive half of the final report. func (s *Snapshot) rxSummary(dur float64) string { if s.TotalRX.Packets == 0 && s.TotalRX.Drops == 0 && s.Transmits { diff --git a/internal/stats/format_test.go b/internal/stats/format_test.go index 46721ed..088b566 100644 --- a/internal/stats/format_test.go +++ b/internal/stats/format_test.go @@ -43,6 +43,12 @@ func TestPPSAndBits(t *testing.T) { if got := Bits(100e6); got != "100 Mbit/s" { t.Errorf("Bits = %q, want 100 Mbit/s", got) } + if got := PerSecond(0.25); got != "0.25/s" { + t.Errorf("PerSecond = %q, want 0.25/s", got) + } + if got := PerSecond(12_500); got != "12.5 k/s" { + t.Errorf("PerSecond = %q, want 12.5 k/s", got) + } } func TestBytesAndDuration(t *testing.T) { @@ -95,7 +101,11 @@ func TestSnapshotLine(t *testing.T) { func TestSnapshotSummary(t *testing.T) { c := New(1, 0, func() (Kernel, error) { - return Kernel{RxDropped: 5, PerQueue: []KernelQueue{{Queue: 0, RxDropped: 5}}}, nil + return Kernel{ + RxDropped: 5, RxRingFull: 4, RxInvalidDescs: 3, + RxFillRingEmpty: 2, TxInvalidDescs: 1, TxRingEmpty: 6, + PerQueue: []KernelQueue{{Queue: 0, RxDropped: 5}}, + }, nil }) c.Queue(0).AddTx(700, 700*60, ClassUDP) c.Queue(0).AddTx(100, 100*590, ClassTCP) @@ -104,7 +114,11 @@ func TestSnapshotSummary(t *testing.T) { s := c.Sample() sum := s.Summary() - for _, want := range []string{"ran for", "tx:", "L1", "L2", "udp", "tcp", "rx:", "drops", "queue 0"} { + for _, want := range []string{ + "ran for", "tx:", "L1", "L2", "udp", "tcp", "rx:", "drops", "queue 0", + "AF_XDP diagnostics", "rx_dropped", "rx_ring_full", "rx_invalid_descs", + "tx_invalid_descs", "rx_fill_ring_empty_descs", "tx_ring_empty_descs", "/s", + } { if !strings.Contains(sum, want) { t.Errorf("Summary() missing %q:\n%s", want, sum) } diff --git a/internal/stats/stats.go b/internal/stats/stats.go index d838810..a949520 100644 --- a/internal/stats/stats.go +++ b/internal/stats/stats.go @@ -135,11 +135,87 @@ type Kernel struct { // KernelQueue is one queue's slice of the kernel counters, used to point at a // queue that is dropping or stalled. type KernelQueue struct { - Queue int - RxPackets uint64 - TxPackets uint64 - RxDropped uint64 - RxRingFull uint64 + Queue int + RxPackets uint64 + TxPackets uint64 + RxDropped uint64 + RxRingFull uint64 + RxFillRingEmpty uint64 + RxInvalidDescs uint64 + TxInvalidDescs uint64 + TxRingEmpty uint64 +} + +// KernelDiagnostic is one counter from Linux's XDP_STATISTICS socket option. +// Drop distinguishes packet/descriptor drops from ring-starvation events; +// the latter are useful pressure signals but are not packet-loss counts. +type KernelDiagnostic struct { + Name string + Meaning string + Count uint64 + PerSecond float64 + Drop bool +} + +// Diagnostics returns every XDP_STATISTICS counter in a stable display order. +// duration is the period covered by the counts and is used only for the rate. +func (k Kernel) Diagnostics(duration time.Duration) []KernelDiagnostic { + rate := func(n uint64) float64 { + if duration <= 0 { + return 0 + } + return float64(n) / duration.Seconds() + } + values := []KernelDiagnostic{ + {Name: "rx_dropped", Meaning: "RX drops for other reasons", Count: k.RxDropped, Drop: true}, + {Name: "rx_ring_full", Meaning: "RX drops because the RX ring was full", Count: k.RxRingFull, Drop: true}, + {Name: "rx_invalid_descs", Meaning: "RX drops due to invalid descriptors", Count: k.RxInvalidDescs, Drop: true}, + {Name: "tx_invalid_descs", Meaning: "TX drops due to invalid descriptors", Count: k.TxInvalidDescs, Drop: true}, + {Name: "rx_fill_ring_empty_descs", Meaning: "failed reads from an empty fill ring", Count: k.RxFillRingEmpty}, + {Name: "tx_ring_empty_descs", Meaning: "failed reads from an empty TX ring", Count: k.TxRingEmpty}, + } + for i := range values { + values[i].PerSecond = rate(values[i].Count) + } + return values +} + +// Since subtracts a baseline from cumulative kernel counters. Counter resets +// clamp at zero, and queues are matched by queue id rather than slice order. +func (k Kernel) Since(base Kernel) Kernel { + sub := func(x, y uint64) uint64 { + if x < y { + return 0 + } + return x - y + } + out := Kernel{ + Queues: k.Queues, + RxPackets: sub(k.RxPackets, base.RxPackets), + TxPackets: sub(k.TxPackets, base.TxPackets), + RxDropped: sub(k.RxDropped, base.RxDropped), + RxRingFull: sub(k.RxRingFull, base.RxRingFull), + RxFillRingEmpty: sub(k.RxFillRingEmpty, base.RxFillRingEmpty), + RxInvalidDescs: sub(k.RxInvalidDescs, base.RxInvalidDescs), + TxInvalidDescs: sub(k.TxInvalidDescs, base.TxInvalidDescs), + TxRingEmpty: sub(k.TxRingEmpty, base.TxRingEmpty), + PerQueue: make([]KernelQueue, 0, len(k.PerQueue)), + } + baseQ := make(map[int]KernelQueue, len(base.PerQueue)) + for _, q := range base.PerQueue { + baseQ[q.Queue] = q + } + for _, q := range k.PerQueue { + b := baseQ[q.Queue] + out.PerQueue = append(out.PerQueue, KernelQueue{ + Queue: q.Queue, RxPackets: sub(q.RxPackets, b.RxPackets), TxPackets: sub(q.TxPackets, b.TxPackets), + RxDropped: sub(q.RxDropped, b.RxDropped), RxRingFull: sub(q.RxRingFull, b.RxRingFull), + RxFillRingEmpty: sub(q.RxFillRingEmpty, b.RxFillRingEmpty), + RxInvalidDescs: sub(q.RxInvalidDescs, b.RxInvalidDescs), + TxInvalidDescs: sub(q.TxInvalidDescs, b.TxInvalidDescs), TxRingEmpty: sub(q.TxRingEmpty, b.TxRingEmpty), + }) + } + return out } // KernelFunc reports the current kernel counters. It is injected so the @@ -243,7 +319,8 @@ type Snapshot struct { // IntervalSince is when the visible counters were last reset. IntervalSince time.Time - Kernel Kernel + Kernel Kernel // lifetime counters for this run + KernelInterval Kernel // counters since the last on-screen reset // Problems names queues that are dropping or stalled, ready to display. Problems []string @@ -392,7 +469,7 @@ func (c *Collector) ResetInterval() { // Errors/Drops counter and the per-queue problem lines would keep showing the // lifetime figure and never clear on reset. tx.Errors += k.TxInvalidDescs - rx.Drops = k.RxDropped + k.RxRingFull + rx.Drops = k.RxDropped + k.RxRingFull + k.RxInvalidDescs c.mu.Lock() c.baseTX, c.baseRX, c.baseKernel, c.baseAt = tx, rx, k, c.now() c.mu.Unlock() @@ -465,7 +542,7 @@ func (c *Collector) build() *Snapshot { tx, rx := c.readTotals() k := c.readKernel() - rx.Drops = k.RxDropped + k.RxRingFull + rx.Drops = k.RxDropped + k.RxRingFull + k.RxInvalidDescs tx.Errors += k.TxInvalidDescs s := &Snapshot{ @@ -514,6 +591,7 @@ func (c *Collector) build() *Snapshot { c.mu.Unlock() s.Problems = problems(k, baseKernel) + s.KernelInterval = k.Since(baseKernel) return s } @@ -558,12 +636,24 @@ func problems(k, base Kernel) []string { var out []string for _, q := range k.PerQueue { b := baseQ[q.Queue] - switch { - case q.RxRingFull > b.RxRingFull: - out = append(out, formatQueueProblem(q.Queue, "rx ring full", q.RxRingFull-b.RxRingFull)) - case q.RxDropped > b.RxDropped: + if q.RxDropped > b.RxDropped { out = append(out, formatQueueProblem(q.Queue, "rx dropped", q.RxDropped-b.RxDropped)) } + if q.RxRingFull > b.RxRingFull { + out = append(out, formatQueueProblem(q.Queue, "rx ring full", q.RxRingFull-b.RxRingFull)) + } + if q.RxInvalidDescs > b.RxInvalidDescs { + out = append(out, formatQueueProblem(q.Queue, "rx invalid descriptors", q.RxInvalidDescs-b.RxInvalidDescs)) + } + if q.TxInvalidDescs > b.TxInvalidDescs { + out = append(out, formatQueueProblem(q.Queue, "tx invalid descriptors", q.TxInvalidDescs-b.TxInvalidDescs)) + } + if q.RxFillRingEmpty > b.RxFillRingEmpty { + out = append(out, formatQueueProblem(q.Queue, "rx fill ring empty", q.RxFillRingEmpty-b.RxFillRingEmpty)) + } + if q.TxRingEmpty > b.TxRingEmpty { + out = append(out, formatQueueProblem(q.Queue, "tx ring empty", q.TxRingEmpty-b.TxRingEmpty)) + } } return out } diff --git a/internal/stats/stats_test.go b/internal/stats/stats_test.go index 362061c..13073b1 100644 --- a/internal/stats/stats_test.go +++ b/internal/stats/stats_test.go @@ -148,14 +148,14 @@ func TestResetIntervalKeepsLifetimeTotals(t *testing.T) { // totals and the final summary keep them. func TestResetIntervalClearsDropsAndProblems(t *testing.T) { k := Kernel{ - RxDropped: 5, RxRingFull: 3, TxInvalidDescs: 2, + RxDropped: 5, RxRingFull: 3, RxInvalidDescs: 4, TxInvalidDescs: 2, PerQueue: []KernelQueue{{Queue: 1, RxRingFull: 3}}, } c := New(2, 0, func() (Kernel, error) { return k, nil }) s := c.Sample() - if s.RX.Drops != 8 || s.TX.Errors != 2 { - t.Fatalf("before reset: RX.Drops=%d TX.Errors=%d, want 8 and 2", s.RX.Drops, s.TX.Errors) + if s.RX.Drops != 12 || s.TX.Errors != 2 { + t.Fatalf("before reset: RX.Drops=%d TX.Errors=%d, want 12 and 2", s.RX.Drops, s.TX.Errors) } if len(s.Problems) != 1 { t.Fatalf("before reset: want one problem line, got %v", s.Problems) @@ -170,8 +170,8 @@ func TestResetIntervalClearsDropsAndProblems(t *testing.T) { t.Errorf("after reset: problem lines should clear, got %v", s.Problems) } // Lifetime totals and the summary are unaffected by the on-screen reset. - if s.TotalRX.Drops != 8 { - t.Errorf("TotalRX.Drops = %d, want 8 (lifetime)", s.TotalRX.Drops) + if s.TotalRX.Drops != 12 { + t.Errorf("TotalRX.Drops = %d, want 12 (lifetime)", s.TotalRX.Drops) } if !strings.Contains(s.Summary(), "queue 1") { t.Errorf("summary should still name queue 1 (lifetime):\n%s", s.Summary()) @@ -182,7 +182,7 @@ func TestKernelCountersMerge(t *testing.T) { k := Kernel{ Queues: 2, TxPackets: 12345, - RxDropped: 7, RxRingFull: 3, TxInvalidDescs: 2, + RxDropped: 7, RxRingFull: 3, RxInvalidDescs: 4, TxInvalidDescs: 2, PerQueue: []KernelQueue{ {Queue: 0, RxPackets: 100}, {Queue: 1, RxPackets: 50, RxRingFull: 3}, @@ -193,8 +193,8 @@ func TestKernelCountersMerge(t *testing.T) { if s.Kernel.TxPackets != 12345 { t.Errorf("Kernel.TxPackets = %d, want 12345", s.Kernel.TxPackets) } - if s.RX.Drops != 10 { - t.Errorf("RX.Drops = %d, want 10 (dropped + ring full)", s.RX.Drops) + if s.RX.Drops != 14 { + t.Errorf("RX.Drops = %d, want 14 (other + ring full + invalid descriptor)", s.RX.Drops) } if s.TX.Errors != 2 { t.Errorf("TX.Errors = %d, want 2 (invalid tx descriptors)", s.TX.Errors) @@ -204,6 +204,62 @@ func TestKernelCountersMerge(t *testing.T) { } } +func TestKernelDiagnosticsKeepTypesAndRatesSeparate(t *testing.T) { + k := Kernel{ + RxDropped: 10, RxRingFull: 20, RxFillRingEmpty: 30, + RxInvalidDescs: 40, TxInvalidDescs: 50, TxRingEmpty: 60, + } + d := k.Diagnostics(10 * time.Second) + want := []struct { + name string + count uint64 + rate float64 + drop bool + }{ + {"rx_dropped", 10, 1, true}, + {"rx_ring_full", 20, 2, true}, + {"rx_invalid_descs", 40, 4, true}, + {"tx_invalid_descs", 50, 5, true}, + {"rx_fill_ring_empty_descs", 30, 3, false}, + {"tx_ring_empty_descs", 60, 6, false}, + } + if len(d) != len(want) { + t.Fatalf("Diagnostics has %d entries, want %d", len(d), len(want)) + } + for i := range want { + if d[i].Name != want[i].name || d[i].Count != want[i].count || + d[i].PerSecond != want[i].rate || d[i].Drop != want[i].drop { + t.Errorf("Diagnostics[%d] = %+v, want %+v", i, d[i], want[i]) + } + } +} + +func TestPerQueueProblemsReportEveryCounterType(t *testing.T) { + k := Kernel{PerQueue: []KernelQueue{{ + Queue: 7, RxDropped: 1, RxRingFull: 2, RxInvalidDescs: 3, + TxInvalidDescs: 4, RxFillRingEmpty: 5, TxRingEmpty: 6, + }}} + got := problems(k, Kernel{}) + for _, want := range []string{ + "rx dropped", "rx ring full", "rx invalid descriptors", "tx invalid descriptors", + "rx fill ring empty", "tx ring empty", + } { + found := false + for _, line := range got { + if strings.Contains(line, want) { + found = true + break + } + } + if !found { + t.Errorf("problems = %v, missing %q", got, want) + } + } + if len(got) != 6 { + t.Errorf("problems has %d entries, want one for each counter type", len(got)) + } +} + // A failing kernel read must not take the snapshot down with it. func TestKernelErrorIsTolerated(t *testing.T) { c := New(1, 0, func() (Kernel, error) { return Kernel{}, context.DeadlineExceeded }) diff --git a/internal/tui/dashboard.go b/internal/tui/dashboard.go index 0cee6dc..1b4c71d 100644 --- a/internal/tui/dashboard.go +++ b/internal/tui/dashboard.go @@ -330,22 +330,13 @@ func counterBlock(name string, t stats.Totals, r stats.Rates, isRx bool, width i func (m model) dashFooter(s *stats.Snapshot) string { var b strings.Builder + b.WriteString(m.dashDiagnostics(s)) - // Anything a queue is unhappy about. The per-queue lines are hidden by - // default (a NIC with many queues dropping would otherwise fill the screen) - // and revealed with 'w'; a one-line summary keeps them discoverable. - if n := len(s.Problems); n > 0 { - if m.showProblems { - for _, p := range s.Problems { - b.WriteString(styleWarn.Render(" ! "+p) + "\n") - } - } else { - noun := "queue" - if n > 1 { - noun = "queues" - } - b.WriteString(styleWarn.Render(fmt.Sprintf(" ! %d %s dropping or stalling", n, noun)) + - styleFaint.Render(" (w to show)") + "\n") + // A NIC with many unhappy queues can fill a screen, so per-queue detail is + // shown alongside the expanded AF_XDP counters rather than by default. + if m.showProblems { + for _, p := range s.Problems { + b.WriteString(styleWarn.Render(" ! "+p) + "\n") } } if !s.IntervalSince.IsZero() && s.TotalTX.Packets != s.TX.Packets { @@ -380,18 +371,54 @@ func (m model) dashFooter(s *stats.Snapshot) string { keyHint("g", m.graphHint()), keyHint("r", "reset counters"), } - if len(s.Problems) > 0 { - label := "show drops" - if m.showProblems { - label = "hide drops" - } - hints = append(hints, keyHint("w", label)) + label := "show AF_XDP" + if m.showProblems { + label = "hide AF_XDP" } + hints = append(hints, keyHint("w", label)) hints = append(hints, keyHint("?", "help"), keyHint("q", "stop and quit")) b.WriteString("\n" + footer(hints...)) return b.String() } +// dashDiagnostics shows the kernel's AF_XDP socket counters. The collapsed +// line separates drops from empty-ring events; pressing w reveals exact UAPI +// names, meanings, counts and average rates since the last counter reset. +func (m model) dashDiagnostics(s *stats.Snapshot) string { + duration := s.At.Sub(s.IntervalSince) + diagnostics := s.KernelInterval.Diagnostics(duration) + var drops, starvation uint64 + for _, d := range diagnostics { + if d.Drop { + drops += d.Count + } else { + starvation += d.Count + } + } + if !m.showProblems { + line := fmt.Sprintf(" AF_XDP drops %s · ring starvation %s", + stats.Count(drops), stats.Count(starvation)) + style := styleFaint + if drops+starvation > 0 { + style = styleWarn + } + return style.Render(line) + styleFaint.Render(" (w to show)") + "\n" + } + + var b strings.Builder + b.WriteString(styleLabel.Render(" AF_XDP diagnostics") + "\n") + for _, d := range diagnostics { + line := fmt.Sprintf(" %-25s %8s %10s %s", + d.Name, stats.Count(d.Count), stats.PerSecond(d.PerSecond), d.Meaning) + style := styleFaint + if d.Count > 0 { + style = styleWarn + } + b.WriteString(style.Render(line) + "\n") + } + return b.String() +} + // graphHint names what pressing g will switch to, rather than what is already // on screen. func (m model) graphHint() string { diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 75e4a6f..4c7d490 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -135,9 +135,9 @@ type model struct { graph graphMode showHelp bool - // showProblems reveals the per-queue drop/stall lines, which are hidden by - // default so a NIC with many busy queues doesn't fill the dashboard with - // them. Toggled with 'w'. + // showProblems reveals AF_XDP counter types and per-queue drop/stall lines, + // which are hidden by default so a many-queue NIC does not fill the screen. + // Toggled with 'w'. showProblems bool width int height int diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go index 75cbdf2..96b4343 100644 --- a/internal/tui/tui_test.go +++ b/internal/tui/tui_test.go @@ -1192,3 +1192,31 @@ func TestToggleProblemsKey(t *testing.T) { t.Error("w again should hide them") } } + +func TestDashboardShowsTypedAFXDPDiagnostics(t *testing.T) { + s := &stats.Snapshot{ + At: time.Unix(10, 0), + IntervalSince: time.Unix(0, 0), + KernelInterval: stats.Kernel{ + RxDropped: 10, RxRingFull: 20, RxInvalidDescs: 30, + TxInvalidDescs: 40, RxFillRingEmpty: 50, TxRingEmpty: 60, + }, + } + m := model{} + compact := m.dashDiagnostics(s) + for _, want := range []string{"AF_XDP", "drops 100", "ring starvation 110", "w to show"} { + if !strings.Contains(compact, want) { + t.Errorf("compact diagnostics missing %q:\n%s", want, compact) + } + } + m.showProblems = true + expanded := m.dashDiagnostics(s) + for _, want := range []string{ + "rx_dropped", "rx_ring_full", "rx_invalid_descs", "tx_invalid_descs", + "rx_fill_ring_empty_descs", "tx_ring_empty_descs", "/s", + } { + if !strings.Contains(expanded, want) { + t.Errorf("expanded diagnostics missing %q:\n%s", want, expanded) + } + } +} diff --git a/internal/tui/wizard.go b/internal/tui/wizard.go index 58577cc..fa13c47 100644 --- a/internal/tui/wizard.go +++ b/internal/tui/wizard.go @@ -495,7 +495,7 @@ func (m model) viewHelp() string { {"-", "decrease the rate by 10%"}, {"g", "graph: packets/sec, then bits/sec, then off"}, {"r", "reset the visible counters (lifetime totals are kept)"}, - {"w", "show or hide the per-queue drop and stall lines"}, + {"w", "show or hide AF_XDP counters and per-queue details"}, {"q", "stop and quit cleanly"}, {"ctrl+c", "the same as q"}, }) From af93a9418f94b05bb03b4bdefac0f1648538a863 Mon Sep 17 00:00:00 2001 From: Federico Iezzi Date: Mon, 10 Aug 2026 15:07:47 +0200 Subject: [PATCH 3/5] feat: explain AF_XDP generic fallback --- internal/dataplane/fallback_test.go | 92 ++++++++++++++++++++++ internal/dataplane/runner.go | 117 ++++++++++++++++++++++------ internal/tui/dashboard.go | 9 ++- internal/tui/tui_test.go | 19 +++++ 4 files changed, 211 insertions(+), 26 deletions(-) create mode 100644 internal/dataplane/fallback_test.go diff --git a/internal/dataplane/fallback_test.go b/internal/dataplane/fallback_test.go new file mode 100644 index 0000000..930b08d --- /dev/null +++ b/internal/dataplane/fallback_test.go @@ -0,0 +1,92 @@ +package dataplane + +import ( + "errors" + "fmt" + "strings" + "testing" + + afxdp "github.com/atoonk/go-afxdp" +) + +func TestOpenAFXDPNativeFirstReportsFallbackReason(t *testing.T) { + nativeErr := errors.New("afxdp: could not open eth0 (1 queues): native attach: operation not supported") + var logs []string + genericCalls := 0 + + got, err := openAFXDPNativeFirst( + func() (*afxdp.Fleet, error) { return nil, nativeErr }, + func() (*afxdp.Fleet, error) { genericCalls++; return nil, nil }, + func(format string, args ...any) { logs = append(logs, fmt.Sprintf(format, args...)) }, + ) + if err != nil { + t.Fatalf("fallback returned an error: %v", err) + } + if !errors.Is(got.nativeAttemptError, nativeErr) { + t.Fatalf("retained native error = %v, want %v", got.nativeAttemptError, nativeErr) + } + if genericCalls != 1 { + t.Fatalf("generic open calls = %d, want 1", genericCalls) + } + if len(logs) != 1 { + t.Fatalf("logs = %q, want one fallback diagnostic", logs) + } + for _, want := range []string{"native AF_XDP attempt failed", nativeErr.Error(), "generic XDP copy mode"} { + if !strings.Contains(logs[0], want) { + t.Errorf("fallback diagnostic %q does not contain %q", logs[0], want) + } + } +} + +func TestOpenAFXDPNativeFirstDoesNotRetryModeIndependentFailure(t *testing.T) { + nativeErr := errors.New("afxdp: look up eth0: no such interface") + genericCalls := 0 + logCalls := 0 + + _, err := openAFXDPNativeFirst( + func() (*afxdp.Fleet, error) { return nil, nativeErr }, + func() (*afxdp.Fleet, error) { genericCalls++; return new(afxdp.Fleet), nil }, + func(string, ...any) { logCalls++ }, + ) + if !errors.Is(err, nativeErr) { + t.Fatalf("err = %v, want original non-mode error", err) + } + if genericCalls != 0 || logCalls != 0 { + t.Fatalf("non-mode failure made %d generic calls and %d log calls, want 0/0", genericCalls, logCalls) + } +} + +func TestOpenAFXDPNativeFirstDoesNotMentionFallbackOnSuccess(t *testing.T) { + want := new(afxdp.Fleet) + genericCalls := 0 + logCalls := 0 + + got, err := openAFXDPNativeFirst( + func() (*afxdp.Fleet, error) { return want, nil }, + func() (*afxdp.Fleet, error) { genericCalls++; return nil, nil }, + func(string, ...any) { logCalls++ }, + ) + if err != nil || got.fleet != want { + t.Fatalf("got fleet=%p err=%v, want fleet=%p and no error", got.fleet, err, want) + } + if got.nativeAttemptError != nil { + t.Fatalf("native success retained error %v", got.nativeAttemptError) + } + if genericCalls != 0 || logCalls != 0 { + t.Errorf("native success made %d generic calls and %d log calls, want 0/0", genericCalls, logCalls) + } +} + +func TestOpenAFXDPNativeFirstPreservesBothErrors(t *testing.T) { + nativeErr := errors.New("afxdp: could not open eth0 (1 queues): native bind: native failed") + genericErr := errors.New("generic failed") + + _, err := openAFXDPNativeFirst( + func() (*afxdp.Fleet, error) { return nil, nativeErr }, + func() (*afxdp.Fleet, error) { return nil, genericErr }, + func(string, ...any) {}, + ) + if !errors.Is(err, nativeErr) || !errors.Is(err, genericErr) { + t.Fatalf("err = %v, want both native and generic causes", err) + } +} diff --git a/internal/dataplane/runner.go b/internal/dataplane/runner.go index f5a6e3d..5d7313d 100644 --- a/internal/dataplane/runner.go +++ b/internal/dataplane/runner.go @@ -9,6 +9,7 @@ import ( "fmt" "net" "os" + "strings" "sync" "sync/atomic" "time" @@ -69,9 +70,12 @@ type Info struct { PerWorker int XDPMode string // "native", "generic", ... ZeroCopy bool - Filter string - FrameSize int - NumFrames int + // NativeAttemptError is the exact error from a failed native attach or + // bind when the run continued successfully in generic XDP mode. + NativeAttemptError string + Filter string + FrameSize int + NumFrames int // MultiBuffer is true when packets are chained across several UMEM frames, // which is how jumbo frames are carried. Worth showing: it is also why a // jumbo run may report copy rather than zero-copy. @@ -710,29 +714,42 @@ func (r *Runner) attach() error { return errors.New("dataplane: already started") } - open := func() (*afxdp.Fleet, error) { - opts := []afxdp.Option{ - // WithOptions replaces the whole struct, so it must come first; - // the options after it layer on top. - afxdp.WithOptions(r.umemOptions()), - afxdp.WithFilter(r.plan.Matches...), - afxdp.WithQueues(r.queues), - // Without need-wakeup a starved driver spins in ksoftirqd instead - // of parking, burning cores while forwarding nothing. - afxdp.WithNeedWakeup(), - } - if r.multiBuffer { - // Packets bigger than a frame have to chain across several. This - // also lets the program attach at a jumbo MTU on drivers that - // otherwise cap XDP to a single buffer. - opts = append(opts, afxdp.WithMultiBuffer()) - } - if r.plan.KeepManagement { - // Spare ARP, ND, SSH and DNS from the match-all redirect so the box - // stays reachable while everything else is captured. - opts = append(opts, afxdp.WithKeepManagement()) + openMode := func(mode afxdp.Option) func() (*afxdp.Fleet, error) { + return func() (*afxdp.Fleet, error) { + opts := []afxdp.Option{ + // WithOptions replaces the whole struct, so it must come first; + // the options after it layer on top. + afxdp.WithOptions(r.umemOptions()), + afxdp.WithFilter(r.plan.Matches...), + afxdp.WithQueues(r.queues), + // Without need-wakeup a starved driver spins in ksoftirqd instead + // of parking, burning cores while forwarding nothing. + afxdp.WithNeedWakeup(), + } + if r.multiBuffer { + // Packets bigger than a frame have to chain across several. This + // also lets the program attach at a jumbo MTU on drivers that + // otherwise cap XDP to a single buffer. + opts = append(opts, afxdp.WithMultiBuffer()) + } + if r.plan.KeepManagement { + // Spare ARP, ND, SSH and DNS from the match-all redirect so the box + // stays reachable while everything else is captured. + opts = append(opts, afxdp.WithKeepManagement()) + } + opts = append(opts, mode) + return afxdp.Open(r.res.Link.Name, opts...) } - return afxdp.Open(r.res.Link.Name, opts...) + } + var nativeAttemptError error + open := func() (*afxdp.Fleet, error) { + result, err := openAFXDPNativeFirst( + openMode(afxdp.WithDriverMode()), + openMode(afxdp.WithGenericMode()), + r.logf, + ) + nativeAttemptError = result.nativeAttemptError + return result.fleet, err } if r.io == "mlx5" { @@ -779,6 +796,9 @@ func (r *Runner) attach() error { r.dev = pioafxdp.NewDevice(fleet) r.started = true r.info.Reused = reused + if nativeAttemptError != nil { + r.info.NativeAttemptError = nativeAttemptError.Error() + } if info, err := fleet.Info(); err == nil { r.info.Queues = info.NumQueues @@ -797,6 +817,53 @@ func (r *Runner) attach() error { return nil } +// openAFXDPNativeFirst makes the library's mode fallback visible without +// teaching Wireblast anything about individual drivers. The native opener +// still selects zero-copy first and native copy second. Generic mode is tried +// only after the same attach or bind failures for which go-afxdp's automatic +// mode would continue; validation and setup errors are returned immediately. +func openAFXDPNativeFirst( + native, generic func() (*afxdp.Fleet, error), + logf func(string, ...any), +) (afxdpOpenResult, error) { + fleet, nativeErr := native() + if nativeErr == nil { + return afxdpOpenResult{fleet: fleet}, nil + } + result := afxdpOpenResult{nativeAttemptError: nativeErr} + if !nativeFailureCanFallback(nativeErr) { + return result, nativeErr + } + logf("native AF_XDP attempt failed: %v; trying generic XDP copy mode", nativeErr) + + fleet, genericErr := generic() + if genericErr == nil { + result.fleet = fleet + return result, nil + } + return result, errors.Join( + fmt.Errorf("native AF_XDP: %w", nativeErr), + fmt.Errorf("generic XDP copy mode: %w", genericErr), + ) +} + +type afxdpOpenResult struct { + fleet *afxdp.Fleet + nativeAttemptError error +} + +// nativeFailureCanFallback mirrors go-afxdp v0.11.0's mode loop conservatively. +// Its forced-native Open call wraps exhausted attach and bind attempts in this +// form; errors returned before that loop are not mode-specific and must not be +// retried as generic XDP. +func nativeFailureCanFallback(err error) bool { + msg := err.Error() + if !strings.HasPrefix(msg, "afxdp: could not open ") { + return false + } + return strings.Contains(msg, ": native attach:") || strings.Contains(msg, ": native bind:") +} + // close releases this run's hold on the fleet. // // When a session owns the fleet the XDP program deliberately stays attached, diff --git a/internal/tui/dashboard.go b/internal/tui/dashboard.go index 1b4c71d..29fea66 100644 --- a/internal/tui/dashboard.go +++ b/internal/tui/dashboard.go @@ -7,6 +7,7 @@ import ( "github.com/charmbracelet/lipgloss" "github.com/atoonk/wireblast/internal/config" + "github.com/atoonk/wireblast/internal/dataplane" "github.com/atoonk/wireblast/internal/stats" ) @@ -132,7 +133,10 @@ func (m model) graphLabel() string { } func (m model) dashHeader(s *stats.Snapshot) string { - i := m.runner.Info() + return m.dashHeaderWithInfo(s, m.runner.Info()) +} + +func (m model) dashHeaderWithInfo(s *stats.Snapshot, i dataplane.Info) string { var b strings.Builder state := styleOK.Render("running") @@ -180,6 +184,9 @@ func (m model) dashHeader(s *stats.Snapshot) string { iface += fmt.Sprintf(" · link back in %.1fs", i.LinkWait.Seconds()) } line("interface", fmt.Sprintf("%s %s", i.Interface, styleFaint.Render(iface))) + if i.NativeAttemptError != "" { + line("fallback", styleWarn.Render("native AF_XDP attempt failed: "+i.NativeAttemptError)) + } line("pattern", fmt.Sprintf("%s %s", i.Pattern, styleFaint.Render(i.PacketSizes))) line("rx filter", i.Filter) diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go index 96b4343..d4457c9 100644 --- a/internal/tui/tui_test.go +++ b/internal/tui/tui_test.go @@ -12,6 +12,7 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/atoonk/wireblast/internal/config" + "github.com/atoonk/wireblast/internal/dataplane" "github.com/atoonk/wireblast/internal/discovery" "github.com/atoonk/wireblast/internal/stats" ) @@ -1220,3 +1221,21 @@ func TestDashboardShowsTypedAFXDPDiagnostics(t *testing.T) { } } } + +func TestDashboardShowsExactNativeAttemptErrorAfterGenericFallback(t *testing.T) { + m := newTestModel(t, nil) + m.cfg.Mode = config.ModeReceive + nativeErr := "afxdp: could not open eth0 (8 queues): native bind: operation not supported" + view := m.dashHeaderWithInfo(&stats.Snapshot{}, dataplane.Info{ + Interface: "eth0", + Driver: "ixgbe", + Queues: 8, + XDPMode: "generic", + NativeAttemptError: nativeErr, + }) + for _, want := range []string{"generic XDP", "native AF_XDP attempt failed", nativeErr} { + if !strings.Contains(view, want) { + t.Errorf("dashboard header is missing %q:\n%s", want, view) + } + } +} From 7f54a4fd551b6d67c4e168c0926240752efcb902 Mon Sep 17 00:00:00 2001 From: Federico Iezzi Date: Mon, 10 Aug 2026 13:19:07 +0200 Subject: [PATCH 4/5] feat: export extended per-queue diagnostics --- internal/statsexport/export.go | 4 ++++ internal/statsexport/export_test.go | 7 ++++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/internal/statsexport/export.go b/internal/statsexport/export.go index aba2a51..107f77a 100644 --- a/internal/statsexport/export.go +++ b/internal/statsexport/export.go @@ -69,6 +69,10 @@ func (w *Writer) Final(s *stats.Snapshot) error { r.KernelTXDescriptors = q.TxPackets r.RXDropped = q.RxDropped r.RXRingFull = q.RxRingFull + r.RXFillRingEmpty = q.RxFillRingEmpty + r.RXInvalidDescs = q.RxInvalidDescs + r.TXInvalidDescs = q.TxInvalidDescs + r.TXRingEmpty = q.TxRingEmpty if err := w.write(r); err != nil { return err } diff --git a/internal/statsexport/export_test.go b/internal/statsexport/export_test.go index 136877b..35d175a 100644 --- a/internal/statsexport/export_test.go +++ b/internal/statsexport/export_test.go @@ -32,7 +32,8 @@ func fixture() *stats.Snapshot { RxDropped: 3, RxRingFull: 8, RxFillRingEmpty: 9, RxInvalidDescs: 4, TxInvalidDescs: 7, TxRingEmpty: 6, PerQueue: []stats.KernelQueue{ - {Queue: 0, RxPackets: 50, TxPackets: 60, RxDropped: 1, RxRingFull: 2}, + {Queue: 0, RxPackets: 50, TxPackets: 60, RxDropped: 1, RxRingFull: 2, + RxFillRingEmpty: 3, RxInvalidDescs: 4, TxInvalidDescs: 5, TxRingEmpty: 6}, {Queue: 1, RxPackets: 30, TxPackets: 40, RxDropped: 2, RxRingFull: 6}, }, }, @@ -74,6 +75,10 @@ func TestCSVWritesStableAggregateAndQueueRecords(t *testing.T) { assertCSV(t, header, rows[2], "record_type", "final") assertCSV(t, header, rows[3], "record_type", "queue_final") assertCSV(t, header, rows[3], "queue_id", "0") + assertCSV(t, header, rows[3], "rx_fill_ring_empty_descs", "3") + assertCSV(t, header, rows[3], "rx_invalid_descs", "4") + assertCSV(t, header, rows[3], "tx_invalid_descs", "5") + assertCSV(t, header, rows[3], "tx_ring_empty_descs", "6") assertCSV(t, header, rows[4], "queue_id", "1") } From c05baf30b2c724275ab3cc20c9e72b2a18b4597b Mon Sep 17 00:00:00 2001 From: Federico Iezzi Date: Wed, 12 Aug 2026 19:08:03 +0200 Subject: [PATCH 5/5] fix: keep empty-ring counters informational --- README.md | 15 +++++++------- internal/stats/format.go | 12 ++++++++--- internal/stats/format_test.go | 25 ++++++++++++++++++++++- internal/stats/stats.go | 21 +++++++------------ internal/stats/stats_test.go | 12 +++++++---- internal/tui/dashboard.go | 32 ++++++++++++++--------------- internal/tui/tui_test.go | 38 ++++++++++++++++++++++++++++++++++- 7 files changed, 107 insertions(+), 48 deletions(-) diff --git a/README.md b/README.md index 9e75ebe..b0812f7 100644 --- a/README.md +++ b/README.md @@ -118,16 +118,15 @@ The [namespace lab guide](https://wireblast.mintlify.site/guides/namespace-lab) ### AF_XDP diagnostics -The dashboard keeps packet drops and ring-starvation events separate. Press -`w` during a run to expand the compact AF_XDP line into all six Linux -`XDP_STATISTICS` counters, with exact UAPI names, counts, rates, meanings and -per-queue details. The final summary prints the same counter taxonomy for -unattended `--no-tui` runs. +The default dashboard and final summary show only nonzero packet-drop counters. +Press `w` during a run to expand the AF_XDP detail into all six Linux +`XDP_STATISTICS` counters, with exact UAPI names, counts, rates and meanings. `rx_dropped`, `rx_ring_full`, `rx_invalid_descs` and `tx_invalid_descs` report -drops. `rx_fill_ring_empty_descs` and `tx_ring_empty_descs` report failed ring -retrievals: useful signs of starvation, but not packet-loss counts. See the -[Linux AF_XDP documentation](https://docs.kernel.org/networking/af_xdp.html) +drops. `rx_fill_ring_empty_descs` and `tx_ring_empty_descs` count polls that +found an empty ring. They remain available as neutral expanded diagnostics and +in machine-readable output, but do not appear as errors in the default views. +See the [Linux AF_XDP documentation](https://docs.kernel.org/networking/af_xdp.html) and [`struct xdp_statistics`](https://github.com/torvalds/linux/blob/master/include/uapi/linux/if_xdp.h). ## Examples diff --git a/internal/stats/format.go b/internal/stats/format.go index 2269f80..459a0d0 100644 --- a/internal/stats/format.go +++ b/internal/stats/format.go @@ -190,12 +190,18 @@ func (s *Snapshot) Summary() string { return strings.TrimRight(b.String(), "\n") } -// afxdpSummary keeps the six XDP_STATISTICS counter types separate. In -// particular, empty-ring events are pressure signals, not packet-drop counts. +// afxdpSummary reports only nonzero drop counters. Empty-ring polling remains +// available in expanded TUI detail and machine-readable output, but is not an +// actionable condition in a final human-readable report. func (s *Snapshot) afxdpSummary() string { var b strings.Builder - b.WriteString(" AF_XDP diagnostics:\n") for _, d := range s.Kernel.Diagnostics(s.Elapsed) { + if !d.Drop || d.Count == 0 { + continue + } + if b.Len() == 0 { + b.WriteString(" AF_XDP diagnostics:\n") + } fmt.Fprintf(&b, " %-25s %s (%s) %s\n", d.Name, Count(d.Count), PerSecond(d.PerSecond), d.Meaning) } diff --git a/internal/stats/format_test.go b/internal/stats/format_test.go index 088b566..f431f49 100644 --- a/internal/stats/format_test.go +++ b/internal/stats/format_test.go @@ -117,12 +117,17 @@ func TestSnapshotSummary(t *testing.T) { for _, want := range []string{ "ran for", "tx:", "L1", "L2", "udp", "tcp", "rx:", "drops", "queue 0", "AF_XDP diagnostics", "rx_dropped", "rx_ring_full", "rx_invalid_descs", - "tx_invalid_descs", "rx_fill_ring_empty_descs", "tx_ring_empty_descs", "/s", + "tx_invalid_descs", "/s", } { if !strings.Contains(sum, want) { t.Errorf("Summary() missing %q:\n%s", want, sum) } } + for _, unwanted := range []string{"rx_fill_ring_empty_descs", "tx_ring_empty_descs"} { + if strings.Contains(sum, unwanted) { + t.Errorf("Summary() should hide informational %q:\n%s", unwanted, sum) + } + } // The summary reports lifetime totals, so an interval reset is invisible. if !strings.Contains(sum, "800") { t.Errorf("Summary() should report the 800 lifetime packets:\n%s", sum) @@ -131,3 +136,21 @@ func TestSnapshotSummary(t *testing.T) { t.Error("Summary() should not end with a newline") } } + +func TestIdleReceiverSummaryHidesEmptyRingCounters(t *testing.T) { + c := New(1, 0, func() (Kernel, error) { + return Kernel{ + TxRingEmpty: 100, + PerQueue: []KernelQueue{{Queue: 0, TxRingEmpty: 100}}, + }, nil + }) + s := c.Sample() + s.Transmits = false + + sum := s.Summary() + for _, unwanted := range []string{"AF_XDP diagnostics", "tx_ring_empty_descs", "tx ring empty", "! queue"} { + if strings.Contains(sum, unwanted) { + t.Errorf("idle receiver summary should hide %q:\n%s", unwanted, sum) + } + } +} diff --git a/internal/stats/stats.go b/internal/stats/stats.go index a949520..0e4e7ef 100644 --- a/internal/stats/stats.go +++ b/internal/stats/stats.go @@ -133,7 +133,7 @@ type Kernel struct { } // KernelQueue is one queue's slice of the kernel counters, used to point at a -// queue that is dropping or stalled. +// queue that is dropping packets and to preserve per-queue diagnostics. type KernelQueue struct { Queue int RxPackets uint64 @@ -171,8 +171,8 @@ func (k Kernel) Diagnostics(duration time.Duration) []KernelDiagnostic { {Name: "rx_ring_full", Meaning: "RX drops because the RX ring was full", Count: k.RxRingFull, Drop: true}, {Name: "rx_invalid_descs", Meaning: "RX drops due to invalid descriptors", Count: k.RxInvalidDescs, Drop: true}, {Name: "tx_invalid_descs", Meaning: "TX drops due to invalid descriptors", Count: k.TxInvalidDescs, Drop: true}, - {Name: "rx_fill_ring_empty_descs", Meaning: "failed reads from an empty fill ring", Count: k.RxFillRingEmpty}, - {Name: "tx_ring_empty_descs", Meaning: "failed reads from an empty TX ring", Count: k.TxRingEmpty}, + {Name: "rx_fill_ring_empty_descs", Meaning: "polls that found the fill ring empty", Count: k.RxFillRingEmpty}, + {Name: "tx_ring_empty_descs", Meaning: "polls that found the TX ring empty (normal when idle or rate limited)", Count: k.TxRingEmpty}, } for i := range values { values[i].PerSecond = rate(values[i].Count) @@ -322,7 +322,7 @@ type Snapshot struct { Kernel Kernel // lifetime counters for this run KernelInterval Kernel // counters since the last on-screen reset - // Problems names queues that are dropping or stalled, ready to display. + // Problems names queues with actionable drop counters, ready to display. Problems []string // Transmits is false for a receive-only run, so the status line can leave @@ -624,10 +624,9 @@ func rateOver(now, then Totals, dt float64) Rates { } } -// problems turns the per-queue kernel counters into short, displayable -// descriptions of queues that are losing packets. -// problems names queues that are dropping or stalling, counting only what has -// happened since the last reset (base) so the `r` hotkey clears them. +// problems names queues with actionable drop counters, counting only what has +// happened since the last reset (base) so the `r` hotkey clears them. Empty-ring +// polls are intentionally excluded because they can be normal when idle. func problems(k, base Kernel) []string { baseQ := make(map[int]KernelQueue, len(base.PerQueue)) for _, q := range base.PerQueue { @@ -648,12 +647,6 @@ func problems(k, base Kernel) []string { if q.TxInvalidDescs > b.TxInvalidDescs { out = append(out, formatQueueProblem(q.Queue, "tx invalid descriptors", q.TxInvalidDescs-b.TxInvalidDescs)) } - if q.RxFillRingEmpty > b.RxFillRingEmpty { - out = append(out, formatQueueProblem(q.Queue, "rx fill ring empty", q.RxFillRingEmpty-b.RxFillRingEmpty)) - } - if q.TxRingEmpty > b.TxRingEmpty { - out = append(out, formatQueueProblem(q.Queue, "tx ring empty", q.TxRingEmpty-b.TxRingEmpty)) - } } return out } diff --git a/internal/stats/stats_test.go b/internal/stats/stats_test.go index 13073b1..9213a4e 100644 --- a/internal/stats/stats_test.go +++ b/internal/stats/stats_test.go @@ -234,7 +234,7 @@ func TestKernelDiagnosticsKeepTypesAndRatesSeparate(t *testing.T) { } } -func TestPerQueueProblemsReportEveryCounterType(t *testing.T) { +func TestPerQueueProblemsExcludeEmptyRingPolls(t *testing.T) { k := Kernel{PerQueue: []KernelQueue{{ Queue: 7, RxDropped: 1, RxRingFull: 2, RxInvalidDescs: 3, TxInvalidDescs: 4, RxFillRingEmpty: 5, TxRingEmpty: 6, @@ -242,7 +242,6 @@ func TestPerQueueProblemsReportEveryCounterType(t *testing.T) { got := problems(k, Kernel{}) for _, want := range []string{ "rx dropped", "rx ring full", "rx invalid descriptors", "tx invalid descriptors", - "rx fill ring empty", "tx ring empty", } { found := false for _, line := range got { @@ -255,8 +254,13 @@ func TestPerQueueProblemsReportEveryCounterType(t *testing.T) { t.Errorf("problems = %v, missing %q", got, want) } } - if len(got) != 6 { - t.Errorf("problems has %d entries, want one for each counter type", len(got)) + if len(got) != 4 { + t.Errorf("problems has %d entries, want only the four drop counters: %v", len(got), got) + } + for _, line := range got { + if strings.Contains(line, "ring empty") { + t.Errorf("empty-ring polling is informational, not a problem: %v", got) + } } } diff --git a/internal/tui/dashboard.go b/internal/tui/dashboard.go index 29fea66..7bc7346 100644 --- a/internal/tui/dashboard.go +++ b/internal/tui/dashboard.go @@ -389,27 +389,25 @@ func (m model) dashFooter(s *stats.Snapshot) string { } // dashDiagnostics shows the kernel's AF_XDP socket counters. The collapsed -// line separates drops from empty-ring events; pressing w reveals exact UAPI -// names, meanings, counts and average rates since the last counter reset. +// view reports only nonzero drop counters; pressing w reveals all six UAPI +// counters, including informational empty-ring polling. func (m model) dashDiagnostics(s *stats.Snapshot) string { duration := s.At.Sub(s.IntervalSince) diagnostics := s.KernelInterval.Diagnostics(duration) - var drops, starvation uint64 - for _, d := range diagnostics { - if d.Drop { - drops += d.Count - } else { - starvation += d.Count - } - } if !m.showProblems { - line := fmt.Sprintf(" AF_XDP drops %s · ring starvation %s", - stats.Count(drops), stats.Count(starvation)) - style := styleFaint - if drops+starvation > 0 { - style = styleWarn + var b strings.Builder + for _, d := range diagnostics { + if !d.Drop || d.Count == 0 { + continue + } + if b.Len() == 0 { + b.WriteString(styleLabel.Render(" AF_XDP diagnostics") + "\n") + } + line := fmt.Sprintf(" %-25s %8s %10s %s", + d.Name, stats.Count(d.Count), stats.PerSecond(d.PerSecond), d.Meaning) + b.WriteString(styleWarn.Render(line) + "\n") } - return style.Render(line) + styleFaint.Render(" (w to show)") + "\n" + return b.String() } var b strings.Builder @@ -418,7 +416,7 @@ func (m model) dashDiagnostics(s *stats.Snapshot) string { line := fmt.Sprintf(" %-25s %8s %10s %s", d.Name, stats.Count(d.Count), stats.PerSecond(d.PerSecond), d.Meaning) style := styleFaint - if d.Count > 0 { + if d.Drop && d.Count > 0 { style = styleWarn } b.WriteString(style.Render(line) + "\n") diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go index d4457c9..95b634a 100644 --- a/internal/tui/tui_test.go +++ b/internal/tui/tui_test.go @@ -1205,11 +1205,19 @@ func TestDashboardShowsTypedAFXDPDiagnostics(t *testing.T) { } m := model{} compact := m.dashDiagnostics(s) - for _, want := range []string{"AF_XDP", "drops 100", "ring starvation 110", "w to show"} { + for _, want := range []string{ + "AF_XDP diagnostics", "rx_dropped", "rx_ring_full", + "rx_invalid_descs", "tx_invalid_descs", + } { if !strings.Contains(compact, want) { t.Errorf("compact diagnostics missing %q:\n%s", want, compact) } } + for _, unwanted := range []string{"rx_fill_ring_empty_descs", "tx_ring_empty_descs", "ring starvation"} { + if strings.Contains(compact, unwanted) { + t.Errorf("compact diagnostics should hide %q:\n%s", unwanted, compact) + } + } m.showProblems = true expanded := m.dashDiagnostics(s) for _, want := range []string{ @@ -1220,6 +1228,34 @@ func TestDashboardShowsTypedAFXDPDiagnostics(t *testing.T) { t.Errorf("expanded diagnostics missing %q:\n%s", want, expanded) } } + for _, want := range []string{ + "polls that found the fill ring empty", + "polls that found the TX ring empty (normal when idle or rate limited)", + } { + if !strings.Contains(expanded, want) { + t.Errorf("expanded diagnostics missing friendly wording %q:\n%s", want, expanded) + } + } +} + +func TestIdleReceiverHidesEmptyRingCountersByDefault(t *testing.T) { + s := &stats.Snapshot{ + At: time.Unix(10, 0), + IntervalSince: time.Unix(0, 0), + KernelInterval: stats.Kernel{ + TxRingEmpty: 100, + }, + } + + m := model{} + if got := m.dashDiagnostics(s); got != "" { + t.Errorf("idle receiver should have no default AF_XDP warning, got:\n%s", got) + } + + m.showProblems = true + if got := m.dashDiagnostics(s); !strings.Contains(got, "tx_ring_empty_descs") { + t.Errorf("expanded detail should preserve tx_ring_empty_descs:\n%s", got) + } } func TestDashboardShowsExactNativeAttemptErrorAfterGenericFallback(t *testing.T) {