diff --git a/changelog/inomurko-iostat-header-columns.md b/changelog/inomurko-iostat-header-columns.md new file mode 100644 index 00000000000..bc15a92d485 --- /dev/null +++ b/changelog/inomurko-iostat-header-columns.md @@ -0,0 +1,2 @@ +### Fixed +- Skip extra header columns in iostat parsing diff --git a/util/iostat/iostat.go b/util/iostat/iostat.go index 96a0228c1d7..7ac4ccba415 100644 --- a/util/iostat/iostat.go +++ b/util/iostat/iostat.go @@ -6,6 +6,7 @@ import ( "bufio" "context" "fmt" + "io" "os/exec" "runtime" "strconv" @@ -69,8 +70,20 @@ func Run(ctx context.Context, interval int, receiver chan DeviceStats) { log.Error("Failed to start iostat command", "err", err) return } + parseStream(stdout, receiver) + if err := cmd.Process.Kill(); err != nil { + log.Error("Failed to kill iostat process", "err", err) + } + if err := cmd.Wait(); err != nil { + log.Error("Error waiting for iostat to exit", "err", err) + } + stdout.Close() + log.Info("Iostat command terminated") +} + +func parseStream(r io.Reader, receiver chan<- DeviceStats) { var fields []string - scanner := bufio.NewScanner(stdout) + scanner := bufio.NewScanner(r) for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) if strings.HasPrefix(line, "Device") { @@ -84,6 +97,12 @@ func Run(ctx context.Context, interval int, receiver chan DeviceStats) { stat := DeviceStats{} var err error for i, field := range fields { + if i >= len(data) { + // Some iostat builds emit a header with more columns than a data row. + // Stop matching schema fields once the data row is exhausted. + log.Warn("iostat row has fewer columns than header", "headerCols", len(fields), "dataCols", len(data), "header", fields, "row", data) + break + } switch field { case "Device", "Device:": stat.DeviceName = data[i] @@ -105,14 +124,6 @@ func Run(ctx context.Context, interval int, receiver chan DeviceStats) { receiver <- stat } if scanner.Err() != nil { - log.Error("Iostat scanner error", err, scanner.Err()) + log.Error("Iostat scanner error", "err", scanner.Err()) } - if err := cmd.Process.Kill(); err != nil { - log.Error("Failed to kill iostat process", "err", err) - } - if err := cmd.Wait(); err != nil { - log.Error("Error waiting for iostat to exit", "err", err) - } - stdout.Close() - log.Info("Iostat command terminated") } diff --git a/util/iostat/iostat_test.go b/util/iostat/iostat_test.go new file mode 100644 index 00000000000..873cad19635 --- /dev/null +++ b/util/iostat/iostat_test.go @@ -0,0 +1,74 @@ +// Copyright 2024-2026, Offchain Labs, Inc. +// For license information, see https://github.com/OffchainLabs/nitro/blob/master/LICENSE.md +package iostat + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestParseStream(t *testing.T) { + tests := []struct { + name string + input string + want []DeviceStats + }{ + { + name: "well formed row", + input: ` + Device r/s w/s await + nvme0n1 1.25 2.50 3.75 + `, + want: []DeviceStats{{ + DeviceName: "nvme0n1", + ReadsPerSecond: 1.25, + WritesPerSecond: 2.50, + Await: 3.75, + }}, + }, + { + name: "header has more columns than data row", + input: ` + Device r/s w/s await + nvme0n1 1.25 2.50 + `, + want: []DeviceStats{{ + DeviceName: "nvme0n1", + ReadsPerSecond: 1.25, + WritesPerSecond: 2.50, + Await: 0, + }}, + }, + { + name: "device header with trailing colon", + input: ` + Device: r/s w/s await + nvme0n1 4.25 5.50 6.75 + `, + want: []DeviceStats{{ + DeviceName: "nvme0n1", + ReadsPerSecond: 4.25, + WritesPerSecond: 5.50, + Await: 6.75, + }}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + receiver := make(chan DeviceStats) + go func() { + parseStream(strings.NewReader(tt.input), receiver) + close(receiver) + }() + + var got []DeviceStats + for stat := range receiver { + got = append(got, stat) + } + require.Equal(t, tt.want, got) + }) + } +}