Skip to content
Merged
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
2 changes: 1 addition & 1 deletion internal/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ func Prepare(cfg *config.Config, opts PrepareOptions) (*Prepared, error) {
// broken file should never cost you a link bounce.
var frames generator.FrameSource
if cfg.Mode == config.ModePCAP {
f, err := pcapfile.Load(cfg.PCAPFile)
f, err := pcapfile.Load(cfg.PCAPFile, pcapfile.Limits{MaxBytes: cfg.PCAPMemory})
if err != nil {
return nil, err
}
Expand Down
21 changes: 16 additions & 5 deletions internal/cli/flags.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,12 @@ type RunFunc func(ctx context.Context, cfg *config.Config) error

// options holds the raw string forms of flags that need custom parsing.
type options struct {
pps string
bps string
rxPorts []string
etherHex string
forget bool
pps string
bps string
rxPorts []string
etherHex string
pcapMemory string
forget bool
}

// NewRootCommand builds the `wireblast` command. Running it with no flags at
Expand Down Expand Up @@ -124,6 +125,9 @@ in L1, so --bps 10G means 10G line rate.`,
f.StringVar((*string)(&cfg.PCAPTiming), "pcap-timing", string(cfg.PCAPTiming),
"pcap pacing: rate (use --pps/--bps) or original (preserve capture timing)")
f.BoolVar(&cfg.PCAPLoop, "pcap-loop", cfg.PCAPLoop, "loop the capture until the duration expires")
f.StringVar(&opt.pcapMemory, "pcap-memory", "",
"memory budget for loading the capture, in binary units (e.g. 512M, 4G; default 1G). "+
"The whole capture is held in RAM, so the budget has to fit")

f.BoolVar(&cfg.NoTUI, "no-tui", cfg.NoTUI,
"skip the wizard and dashboard; validate the flags and run, printing periodic statistics")
Expand Down Expand Up @@ -166,6 +170,13 @@ func applyOptions(cmd *cobra.Command, cfg *config.Config, opt *options) error {
cfg.PPS = 0
}
}
if f.Changed("pcap-memory") {
v, err := config.ParseSize(opt.pcapMemory)
if err != nil {
return fmt.Errorf("--pcap-memory: %w", err)
}
cfg.PCAPMemory = v
}
if f.Changed("ethertype") {
v, err := parseEtherType(opt.etherHex)
if err != nil {
Expand Down
15 changes: 14 additions & 1 deletion internal/cli/flags_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,7 @@ func TestEveryConfigFieldHasAFlag(t *testing.T) {
"packet-size", "vlan", "ethertype", "payload-byte",
"duration", "pps", "bps", "queues",
"rx-mode", "rx-port", "rx-cidr",
"pcap", "pcap-timing", "pcap-loop",
"pcap", "pcap-timing", "pcap-loop", "pcap-memory",
"no-tui", "start", "yes", "allow-match-all", "forget",
}
for _, name := range want {
Expand All @@ -202,6 +202,19 @@ func TestEveryConfigFieldHasAFlag(t *testing.T) {
}
}

func TestPcapMemoryFlag(t *testing.T) {
got, err := parse(t, "--pcap", "x.pcap", "--pcap-memory", "4G")
if err != nil {
t.Fatalf("Execute: %v", err)
}
if got.PCAPMemory != 4<<30 {
t.Errorf("PCAPMemory = %d, want %d", got.PCAPMemory, uint64(4<<30))
}
if _, err := parse(t, "--pcap-memory", "banana"); err == nil {
t.Error("a nonsense size should fail")
}
}

func TestUnknownFlagAndArgsRejected(t *testing.T) {
if _, err := parse(t, "--nope"); err == nil {
t.Error("unknown flag should fail")
Expand Down
9 changes: 9 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,10 @@ type Config struct {
PCAPFile string
PCAPTiming PcapTiming
PCAPLoop bool
// PCAPMemory is the memory budget in bytes for loading the capture (frame
// bytes plus index). 0 means the 1 GiB default; the whole capture is held
// in RAM, so a raised budget has to fit in it.
PCAPMemory uint64

// Front-end and confirmation.
NoTUI bool
Expand Down Expand Up @@ -335,6 +339,11 @@ func (c *Config) Validate() error {
if c.PCAPTiming != PcapRate && c.PCAPTiming != PcapOriginal {
bad("--pcap-timing %q is not one of rate, original", c.PCAPTiming)
}
// A tiny budget is almost certainly a unit slip ("4" meaning 4 bytes
// rather than 4G), so refuse it rather than reject every capture.
if c.PCAPMemory != 0 && c.PCAPMemory < 1<<20 {
bad("--pcap-memory %d bytes is smaller than 1 MiB; give a size like 512M or 4G", c.PCAPMemory)
}
}

if c.Duration < 0 {
Expand Down
6 changes: 6 additions & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,12 @@ func TestValidate(t *testing.T) {
{"pcap bad timing", func(c *Config) {
c.Mode, c.PCAPFile, c.PCAPTiming = ModePCAP, "x.pcap", "asap"
}, "--pcap-timing"},
{"pcap memory too small", func(c *Config) {
c.Mode, c.PCAPFile, c.PCAPMemory = ModePCAP, "x.pcap", 4
}, "--pcap-memory"},
{"pcap memory ok", func(c *Config) {
c.Mode, c.PCAPFile, c.PCAPMemory = ModePCAP, "x.pcap", 4<<30
}, ""},
{"pcap ignores packet size", func(c *Config) {
c.Mode, c.PCAPFile, c.PacketSize = ModePCAP, "x.pcap", 0
}, ""},
Expand Down
94 changes: 94 additions & 0 deletions internal/config/parse.go
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,100 @@ func FormatBPS(bps uint64) string {
return formatSI(float64(bps)) + "bps"
}

// ParseSize parses a memory size in bytes. It accepts a plain number and the
// suffixes k/K, m/M, g/G and t/T, optionally followed by "B" or "iB". Unlike
// ParsePPS and ParseBPS the multipliers are binary (1k = 1024), because memory
// is quoted in powers of two: the default capture budget is 1<<30, and a
// decimal "1G" would silently be smaller than that default.
//
// ParseSize("1073741824") == 1 << 30
// ParseSize("512M") == 512 << 20
// ParseSize("4G") == 4 << 30
// ParseSize("4GiB") == 4 << 30
// ParseSize("1.5G") == 3 << 29
//
// There is no "unlimited": a size of zero or the unlimited words are errors,
// because an unbounded load is exactly what a memory budget exists to prevent.
func ParseSize(s string) (uint64, error) {
t := strings.ToLower(strings.TrimSpace(s))
if t == "" {
return 0, errors.New("empty size value")
}
if unlimitedWords[t] {
return 0, fmt.Errorf("%q is not a size; give a byte count like 512M or 4G", s)
}
// Strip a trailing unit, longest first so "gib" is consumed before the "g"
// multiplier is looked for.
for _, u := range []string{"ib", "b"} {
if strings.HasSuffix(t, u) {
t = strings.TrimSpace(strings.TrimSuffix(t, u))
break
}
}
mult := uint64(1)
if t != "" {
switch t[len(t)-1] {
case 'k':
mult, t = 1<<10, t[:len(t)-1]
case 'm':
mult, t = 1<<20, t[:len(t)-1]
case 'g':
mult, t = 1<<30, t[:len(t)-1]
case 't':
mult, t = 1<<40, t[:len(t)-1]
}
}
t = strings.TrimSpace(t)
if t == "" {
return 0, fmt.Errorf("%q is not a size", s)
}
var n uint64
// Integers exactly; anything with a decimal point via float.
if !strings.ContainsAny(t, ".eE") {
v, err := strconv.ParseUint(t, 10, 64)
if err != nil {
return 0, fmt.Errorf("%q is not a size", s)
}
if v > math.MaxUint64/mult {
return 0, fmt.Errorf("%q is too large a size", s)
}
n = v * mult
} else {
f, err := strconv.ParseFloat(t, 64)
if err != nil {
return 0, fmt.Errorf("%q is not a size", s)
}
if f < 0 {
return 0, fmt.Errorf("%q is negative", s)
}
if prod := f * float64(mult); prod > float64(math.MaxUint64) {
return 0, fmt.Errorf("%q is too large a size", s)
}
n = uint64(f * float64(mult))
}
if n == 0 {
return 0, fmt.Errorf("%q is not a usable size", s)
}
return n, nil
}

// FormatSize renders a byte count in the binary units ParseSize accepts.
func FormatSize(n uint64) string {
v := float64(n)
switch {
case v >= 1<<40:
return trimZeros(strconv.FormatFloat(v/(1<<40), 'f', 2, 64)) + "T"
case v >= 1<<30:
return trimZeros(strconv.FormatFloat(v/(1<<30), 'f', 2, 64)) + "G"
case v >= 1<<20:
return trimZeros(strconv.FormatFloat(v/(1<<20), 'f', 2, 64)) + "M"
case v >= 1<<10:
return trimZeros(strconv.FormatFloat(v/(1<<10), 'f', 2, 64)) + "k"
default:
return strconv.FormatUint(n, 10)
}
}

func formatSI(v float64) string {
switch {
case v >= 1e9:
Expand Down
63 changes: 63 additions & 0 deletions internal/config/parse_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,69 @@ func TestParseBPS(t *testing.T) {
}
}

func TestParseSize(t *testing.T) {
tests := []struct {
in string
want uint64
wantErr bool
}{
{"1073741824", 1 << 30, false},
{"512M", 512 << 20, false},
{"512m", 512 << 20, false},
{"4G", 4 << 30, false},
{"4g", 4 << 30, false},
{"4GB", 4 << 30, false},
{"4GiB", 4 << 30, false},
{"4096MB", 4096 << 20, false},
{"1.5G", 3 << 29, false},
{"2T", 2 << 40, false},
{"64k", 64 << 10, false},
{" 4G ", 4 << 30, false},
{"100", 100, false},
// Sizes have no "unlimited": a budget of nothing is not a budget.
{"0", 0, true},
{"unlimited", 0, true},
{"max", 0, true},
{"", 0, true},
{"-1G", 0, true},
{"xyz", 0, true},
{"1X", 0, true},
{"99999999999999999999", 0, true}, // exceeds uint64 on its own
{"18446744073t", 0, true}, // fits uint64 until the multiplier
{"1e40", 0, true}, // float path overflow
}
for _, tt := range tests {
got, err := ParseSize(tt.in)
if (err != nil) != tt.wantErr {
t.Errorf("ParseSize(%q) error = %v, wantErr %v", tt.in, err, tt.wantErr)
continue
}
if err == nil && got != tt.want {
t.Errorf("ParseSize(%q) = %d, want %d", tt.in, got, tt.want)
}
}
}

func TestFormatSize(t *testing.T) {
tests := []struct {
in uint64
want string
}{
{1 << 30, "1G"},
{512 << 20, "512M"},
{4 << 30, "4G"},
{3 << 29, "1.5G"},
{2 << 40, "2T"},
{64 << 10, "64k"},
{100, "100"},
}
for _, tt := range tests {
if got := FormatSize(tt.in); got != tt.want {
t.Errorf("FormatSize(%d) = %q, want %q", tt.in, got, tt.want)
}
}
}

func TestFormatRates(t *testing.T) {
tests := []struct {
pps uint64
Expand Down
Loading