Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -143,6 +165,7 @@ The command is a thin shell; the work is in `internal/`:
| `internal/rate` | the aggregate token-bucket rate limiter |
| `internal/dataplane` | everything AF_XDP: opening sockets, the XDP filter, 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` |
Expand Down
36 changes: 26 additions & 10 deletions internal/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -93,14 +93,21 @@ 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...)
}
p, err := Prepare(cfg, PrepareOptions{Logf: logf})
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
Expand Down Expand Up @@ -144,21 +151,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
Expand Down Expand Up @@ -193,18 +203,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())
}
}
Expand Down
40 changes: 40 additions & 0 deletions internal/app/stats_output.go
Original file line number Diff line number Diff line change
@@ -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())
}
77 changes: 77 additions & 0 deletions internal/app/stats_output_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
8 changes: 8 additions & 0 deletions internal/cli/flags.go
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,10 @@ in L1, so --bps 10G means 10G line rate.`,
"bit rate limit in L1 bits (frame plus preamble, SFD and interframe gap), "+
"aggregate across queues (e.g. 10G, 2.5Gbps, or 'unlimited')")
f.IntVar(&cfg.Queues, "queues", cfg.Queues, "number of NIC queues to transmit on (0 = all)")
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())
Expand Down Expand Up @@ -196,6 +200,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.
Expand Down
31 changes: 31 additions & 0 deletions internal/cli/flags_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
22 changes: 22 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -161,6 +169,11 @@ type Config struct {
BPS uint64 // on-the-wire bits/sec, 0 means unlimited
Queues int // 0 means all available queues

// 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
Expand Down Expand Up @@ -199,6 +212,7 @@ func Default() Config {
PayloadByte: 0x5a,
Duration: 30 * time.Second,
PPS: DefaultPPS,
StatsFormat: StatsCSV,
RxMode: RxNone,
PCAPTiming: PcapRate,
PCAPLoop: true,
Expand Down Expand Up @@ -352,6 +366,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...)
Expand Down
Loading