package main import ( "os" "strconv" "strings" "sync/atomic" "time" ) // Split by direction of travel so each counter belongs to exactly one // direction: the transmitting interface owns the tx fields and the receiving one // the rx fields. Reading both sets off both interfaces double counts every drop. // // The two sides come from different places, and deliberately not from both at // once. dev_get_stats adds the core's own drop counts on top of whatever the // driver reports, so a sysfs field is the driver's counter plus the stack's and // overlaps any driver counter read beside it. // // Sending is the stack's story: what it refused, and what the qdisc discarded // because we outran it, neither of which the driver ever sees. var nicTxFields = []string{ "tx_errors", "tx_dropped", "tx_fifo_errors", "tx_carrier_errors", "tx_aborted_errors", "tx_window_errors", "collisions", } // Receiving is the hardware's, and is taken from the driver's own array, which // sysfs both flattens and overlaps: ice folds crc errors and jabbers into // rx_errors, so the old sum of rx_errors alongside rx_crc_errors charged every // bad frame check twice. Named individually here, so nothing contains anything // else in the list. var nicRxStats = []string{ "rx_crc_errors.nic", "rx_jabber.nic", "rx_undersize.nic", "rx_oversize.nic", "rx_fragments.nic", "rx_dropped.nic", // Below the frame: a 64b/66b block that decoded to no legal symbol, and the // fault ordered sets the pcs sends when it loses sync. A cable going // marginal moves these while every frame still arrives intact, which is as // close to a bit error rate as this link will report. "illegal_bytes.nic", "mac_local_faults.nic", "mac_remote_faults.nic", } func readUint(path string) (uint64, bool) { b, err := os.ReadFile(path) if err != nil { return 0, false } v, err := strconv.ParseUint(strings.TrimSpace(string(b)), 10, 64) if err != nil { return 0, false } return v, true } // Every counter here was read once before the test began, so one that stops // reading now is the interface going away underneath us. Stopping is the only // honest answer: a tester that quietly gives up counting errors goes on // reporting a clean link, which is the one lie it must never tell. func mustReadUint(path string) uint64 { v, ok := readUint(path) if !ok { panic("reading " + path) } return v } func sumFields(base string, fields []string) uint64 { var total uint64 for _, f := range fields { total += mustReadUint(base + "/statistics/" + f) } return total } // A poll is an ioctl and a handful of sysfs attributes, each costing // microseconds of open and read, so it runs here rather than on the goroutine // that has a frame to draw every sixteen milliseconds. Off that path the // interval can be short enough that an unplugged cable shows up as a link error // while the hand is still on it. const nicInterval = 200 * time.Millisecond type nicPoller struct { txBase string rx *statReader total *atomic.Uint64 raw uint64 } func newNICPoller(fd int, txName, rxName string, total *atomic.Uint64) (*nicPoller, error) { rx, err := newStatReader(fd, rxName, nicRxStats) if err != nil { return nil, err } return &nicPoller{ txBase: "/sys/class/net/" + txName, rx: rx, total: total, }, nil } // Carrier is a property of the netdev rather than a counter of either side, and // is taken from the transmitting interface only: across both directions each // interface transmits exactly once, so every flap is counted once. func (p *nicPoller) read() uint64 { return sumFields(p.txBase, nicTxFields) + p.rx.sum() + mustReadUint(p.txBase+"/carrier_down_count") } // Nic counters run from boot and restart from zero whenever the driver resets // its statistics, so only their forward motion is accumulated. Taking raw // differences instead charges a boot's worth of errors to the first sample and // turns a reset into a near-2^64 underflow. func (p *nicPoller) poll() { raw := p.read() if raw > p.raw { p.total.Add(raw - p.raw) } p.raw = raw } // Whatever the interfaces counted before now is not ours, so the first poll has // something to measure forward motion from and adds nothing. func (p *nicPoller) prime() { p.raw = p.read() } // Held until the test starts, since polling before the baseline is primed would // charge everything the interfaces counted since boot to the first sample. func (p *nicPoller) run(done *atomic.Bool, startTx <-chan struct{}) { <-startTx tick := time.NewTicker(nicInterval) defer tick.Stop() for !done.Load() { <-tick.C p.poll() } }