BCM module diagnostics replace timestamp probes: bringup forces EEE off and runs ECD (re-run on every reset, re-baselining past its blip), 1 Hz SNR margin + corrected counters on panel and console; X520 bench divergences bypassed and tracked in open-questions

This commit is contained in:
flamingcow
2026-08-13 07:10:39 -07:00
parent a00f2216b7
commit 979dc7a772
18 changed files with 1240 additions and 416 deletions
+48 -17
View File
@@ -1,7 +1,9 @@
package main package main
import ( import (
"fmt"
"os" "os"
"path/filepath"
"strconv" "strconv"
"strings" "strings"
"sync/atomic" "sync/atomic"
@@ -28,22 +30,43 @@ var nicTxFields = []string{
// Receiving is the hardware's, and is taken from the driver's own array, which // 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 // 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 // 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 // bad frame check twice. Named individually per driver, so nothing contains
// else in the list. // anything else in its list.
var nicRxStats = []string{ var nicRxStatsByDriver = map[string][]string{
"rx_crc_errors.nic", // The reference set (E810). Below the frame: illegal_bytes is a 64b/66b
"rx_jabber.nic", // block that decoded to no legal symbol, and the faults are the ordered
"rx_undersize.nic", // sets the pcs sends when it loses sync. A cable going marginal moves
"rx_oversize.nic", // these while every frame still arrives intact, which is as close to a
"rx_fragments.nic", // bit error rate as this link will report.
"rx_dropped.nic", "ice": {
// Below the frame: a 64b/66b block that decoded to no legal symbol, and the "rx_crc_errors.nic",
// fault ordered sets the pcs sends when it loses sync. A cable going "rx_jabber.nic",
// marginal moves these while every frame still arrives intact, which is as "rx_undersize.nic",
// close to a bit error rate as this link will report. "rx_oversize.nic",
"illegal_bytes.nic", "rx_fragments.nic",
"mac_local_faults.nic", "rx_dropped.nic",
"mac_remote_faults.nic", "illegal_bytes.nic",
"mac_local_faults.nic",
"mac_remote_faults.nic",
},
// The 82599 exposes no jabber, fragment, illegal-byte or fault counters
// through ethtool — this is the closest bench set, and one of the X520
// divergences listed in docs/open-questions.md.
"ixgbe": {
"rx_crc_errors",
"rx_missed_errors",
"rx_length_errors",
"rx_long_length_errors",
"rx_short_length_errors",
},
}
func ifDriver(name string) (string, error) {
link, err := os.Readlink("/sys/class/net/" + name + "/device/driver")
if err != nil {
return "", err
}
return filepath.Base(link), nil
} }
func readUint(path string) (uint64, bool) { func readUint(path string) (uint64, bool) {
@@ -93,7 +116,15 @@ type nicPoller struct {
} }
func newNICPoller(fd int, txName, rxName string, total *atomic.Uint64) (*nicPoller, error) { func newNICPoller(fd int, txName, rxName string, total *atomic.Uint64) (*nicPoller, error) {
rx, err := newStatReader(fd, rxName, nicRxStats) drv, err := ifDriver(rxName)
if err != nil {
return nil, err
}
want, ok := nicRxStatsByDriver[drv]
if !ok {
return nil, fmt.Errorf("%s: no rx error statistic set for driver %s", rxName, drv)
}
rx, err := newStatReader(fd, rxName, want)
if err != nil { if err != nil {
return nil, err return nil, err
} }
+2 -2
View File
@@ -8,8 +8,8 @@ The target of measurement is the cable, not throughput. Line rate is a means to
1. **Loss and error attribution** — reception gaps, link errors, NIC/driver counters as first-class output alongside application loss. Baseline loss must be exactly zero before a run counts; any host-side loss masks real cable faults. 1. **Loss and error attribution** — reception gaps, link errors, NIC/driver counters as first-class output alongside application loss. Baseline loss must be exactly zero before a run counts; any host-side loss masks real cable faults.
2. **Noise tolerance** — a deliberately-bad "noise" cable intertwined with the test cable, driven by link up/down cycling, stresses the cable under test with alien crosstalk. 2. **Noise tolerance** — a deliberately-bad "noise" cable intertwined with the test cable, driven by link up/down cycling, stresses the cable under test with alien crosstalk.
3. **Per-pair SNR** from the module PHYs — the leading indicator of a marginal cable before it drops frames. IEEE 802.3an standard registers on the Marvells; the BCM leaves those unpopulated and reports through its vendor command handler instead (modules/fs/). 3. **Per-pair SNR** from the module PHYs — the leading indicator of a marginal cable before it drops frames. IEEE 802.3an standard registers on the Marvells; the BCM leaves those unpopulated and reports through its vendor command handler instead (modules/fs/). Shown as margin over the operating point, classified good/marginal/bad, so nobody needs the magic numbers to read it (`phy.go`).
4. **Cable length** — sanity check and fault localization. The BCM ECD is the product path: per-pair lengths in meters, healthy pairs included, proven meter-accurate on the bench (modules/fs/). 4. **Cable length** — sanity check and fault localization. The BCM ECD is the product path: per-pair lengths in meters, healthy pairs included, proven meter-accurate on the bench (modules/fs/); run at bringup and on every reset, with pair-swap and fault verdicts on the panel.
5. **Pre-FEC error visibility** — corrected-error counters that move before post-FEC loss appears. Vendor-specific; located on the Aquantia (modules/fibergaga/), unlocated elsewhere. Standard latched PCS counters (errored blocks, BER, block-lock loss) are the working proxy under noise stress. 5. **Pre-FEC error visibility** — corrected-error counters that move before post-FEC loss appears. Vendor-specific; located on the Aquantia (modules/fibergaga/), unlocated elsewhere. Standard latched PCS counters (errored blocks, BER, block-lock loss) are the working proxy under noise stress.
## Design preferences that shaped the tool ## Design preferences that shaped the tool
+2 -2
View File
@@ -27,7 +27,7 @@ Protocol and full verified catalog: [bcm84891l-mdio-commands.md](bcm84891l-mdio-
| Capability | How | Observed | | Capability | How | Observed |
|---|---|---| |---|---|---|
| Per-pair SNR | `CMD_GET_SNR` 0x8030, **invoked bare** (writing the documented DATA1 display flag returns zeros; IEEE 1.1331.140 never populate — constant 0x8080) | DATA25 = SNR AD ×0.1 dB; ≈ 2732 dB absolute on bench, 0.10.4 dB jitter. 10GBASE-T operating point ≈ 26.5 dB, so margin ≈ value 26.5 | | Per-pair SNR | `CMD_GET_SNR` 0x8030, **invoked bare** (writing the documented DATA1 display flag returns zeros; IEEE 1.1331.140 never populate — constant 0x8080) | DATA25 = SNR AD ×0.1 dB; ≈ 2732 dB absolute on bench, 0.10.4 dB jitter. 10GBASE-T operating point ≈ 26.5 dB, so margin ≈ value 26.5. cabletest shows the margin classified green ≥ 3 dB / amber ≥ 1 dB / red below — provisional thresholds until the graded-noise correlation run |
| Die temperature | `CMD_GET_CURRENT_TEMP` 0x8031 | ~6870 °C on bench | | Die temperature | `CMD_GET_CURRENT_TEMP` 0x8031 | ~6870 °C on bench |
| Supply rails | `GET_CURRENT_VOLTAGE` 0x802F | 0.8 V and 1.88 V rails, tenths of mV | | Supply rails | `GET_CURRENT_VOLTAGE` 0x802F | 0.8 V and 1.88 V rails, tenths of mV |
| Error counters | IEEE PCS 3.32/3.33 — block lock, latched errored-block/BER, clear-on-read | The noise-stress error proxy | | Error counters | IEEE PCS 3.32/3.33 — block lock, latched errored-block/BER, clear-on-read | The noise-stress error proxy |
@@ -37,7 +37,7 @@ Protocol and full verified catalog: [bcm84891l-mdio-commands.md](bcm84891l-mdio-
| Item | Command | State | | Item | Command | State |
|---|---|---| |---|---|---|
| EEE / AutogrEEEn | 0x8008/0x8009 | Once read 0x0047 (10G AutogrEEEn variable latency + 5G/1G native — local-only, invisible in IEEE 7.60/7.61, which read 0); later reads 0. **Forced all-off** via SET with explicit params `(0, 0, 0x7A12, 0x480, 0)` + AN restart, verified. cabletest should apply this defensively at bringup | | EEE / AutogrEEEn | 0x8008/0x8009 | Once read 0x0047 (10G AutogrEEEn variable latency + 5G/1G native — local-only, invisible in IEEE 7.60/7.61, which read 0); later reads 0. **Forced all-off** via SET with explicit params `(0, 0, 0x7A12, 0x480, 0)` + AN restart, verified. cabletest applies this defensively at every bringup and verifies 7.60 reads 0 after relink (`phy.go`) |
| EEE wire-truth | arm 0x801A after link-up, read 0x801B | Zero LPI events/duration on idle link; repeat under traffic | | EEE wire-truth | arm 0x801A after link-up, read 0x801B | Zero LPI events/duration on idle link; repeat under traffic |
| Fast retrain | 0x800A (datasheet titles it EMI_MODE; description is fast retrain) | Enabled 10G/5G/2.5G; IEEE 1.147 = 0x0019, count bits zero. Keep enabled; read the 1.147 count per run — a marginal cable that fast-retrains still gets counted | | Fast retrain | 0x800A (datasheet titles it EMI_MODE; description is fast retrain) | Enabled 10G/5G/2.5G; IEEE 1.147 = 0x0019, count bits zero. Keep enabled; read the 1.147 count per run — a marginal cable that fast-retrains still gets counted |
| Pair map | 0x8000 | DATA2 = 0x00E4 = identity (A/B/C/D straight through) — MDI wiring verification works | | Pair map | 0x8000 | DATA2 = 0x00E4 = identity (A/B/C/D straight through) — MDI wiring verification works |
+10
View File
@@ -21,3 +21,13 @@ The register question is answered (post-FEC vs corrected-by-iteration histogram
## 4. Wiitek VCT — pursue or leave dead? ## 4. Wiitek VCT — pursue or leave dead?
No confirmed-safe path exists (every candidate lands in the µC danger window). The open decision is whether the capability is worth the NDA route or a sacrificial unit — the product doesn't need it for length ([modules/wiitek/](modules/wiitek/README.md), [modules/README.md](modules/README.md)). No confirmed-safe path exists (every candidate lands in the µC danger window). The open decision is whether the capability is worth the NDA route or a sacrificial unit — the product doesn't need it for length ([modules/wiitek/](modules/wiitek/README.md), [modules/README.md](modules/README.md)).
## 5. X520 bench divergences — features to restore on the product NIC
Running on the X520 (BCM development) required parking product-NIC capabilities the 82599 lacks. Each stays parked only until the ConnectX-5 is in; none is a settled design change:
- **Hardware timestamp hard check bypassed** (`ts.go`): `rx_filter=ALL` failure now reports yellow and continues instead of stopping the run. On the X520 that means the per-frame-stamp rate buckets never fill and the panel/console rates read zero — the measurement doctrine (exact per-frame RX stamps as a hard host requirement) is intact in the docs and must return to fatal on the product NIC.
- **RX error counter set is per-driver** (`counters.go`, `nicRxStatsByDriver`): the ice set is the reference — jabber, fragments, `illegal_bytes` (64b/66b decode errors) and MAC local/remote faults have **no ixgbe ethtool equivalent**, so those signals are simply absent on the bench. The mlx5 name set needs deriving on CX-5 arrival; the "as close to BER as the link reports" counters (illegal bytes, faults) are the ones to insist on finding there.
- **TX interrupt moderation** (`system.go`): ixgbe's mixed rx/tx vectors reject a tx-specific value, so `checkCoalesce` falls back to rx-shared-with-tx on EINVAL. Generic and self-reporting, but verify the product NIC takes the full rx+tx pair (the fallback must never fire there).
- **`testDriver` still names "ice"** (`main.go`): the default pair discovery has no working target — bench runs pass `-a`/`-b` explicitly. Point it at the product driver (mlx5_core) when the CX-5 lands.
- **Module I2C transport is ixgbe-only** (`phy.go`, `openBCM`): the sff_i2c debugfs path. The CX-5 needs the MCIA answer (question 1) and a second transport arm.
+6 -6
View File
@@ -2,7 +2,7 @@
## Committed tree ## Committed tree
AF_PACKET raw sockets everywhere (`sock.go`); flow-director steering; per-packet-MAC-rx-stamped rate buckets (`SO_TIMESTAMPING` cmsg, `rx_filter=ALL` as a hard host check — nics/README.md for what that demands of the NIC); read-time-stamped NIC-counter rates; hardware-timestamped length probes (`probe.go`); framebuffer UI; harness. AF_PACKET raw sockets everywhere (`sock.go`); flow-director steering; per-packet-MAC-rx-stamped rate buckets (`SO_TIMESTAMPING` cmsg, `rx_filter=ALL` as a hard host check — nics/README.md for what that demands of the NIC; **temporarily bypassed** in `ts.go` so BCM work can run on the X520, which cannot stamp — the check reports yellow and the panel rates read zero there; restore to fatal for the product NIC); read-time-stamped NIC-counter rates; BCM module diagnostics (`phy.go`, over the patched-ixgbe `sff_i2c` debugfs, compound-op framing): bringup identifies both modules, forces EEE off with an AN restart, then runs the ECD — per-pair verdicts, lengths and pair maps are the length/wiring path — and every reset re-runs it, re-baselining the counters only after the diag's own link blip so it is never charged to the run; a 1 Hz poller feeds per-pair SNR margin (vs the ≈26.5 dB operating point; green ≥ 3 dB, amber ≥ 1 dB — provisional until the graded-noise run) and the corrected-error set (PCS 3.33 errored blocks/BER, PMA 1.147 fast-retrain count) to the panel and console; framebuffer UI; harness.
## Stashes ## Stashes
@@ -17,7 +17,7 @@ AF_PACKET raw sockets everywhere (`sock.go`); flow-director steering; per-packet
| Interface | Device | Role (rules: hardware.md) | | Interface | Device | Role (rules: hardware.md) |
|---|---|---| |---|---|---|
| `enp1s0f0` | X520 port 0 (ixgbe) | Test pair — new Wiitek module | | `enp1s0f0` | X520 port 0 (ixgbe) | Test pair — FS module |
| `enp1s0f1` | X520 port 1 (ixgbe) | Test pair — FS module | | `enp1s0f1` | X520 port 1 (ixgbe) | Test pair — FS module |
| `enp3s0f0np0` / `enp3s0f1np1` | X710 (i40e) | Noise pair (has been `enp4s0f*` across reboots) | | `enp3s0f0np0` / `enp3s0f1np1` | X710 (i40e) | Noise pair (has been `enp4s0f*` across reboots) |
| `enp88s0` | igc | LAN uplink, default route; sibling `enp89s0` is dark | | `enp88s0` | igc | LAN uplink, default route; sibling `enp89s0` is dark |
@@ -26,10 +26,10 @@ AF_PACKET raw sockets everywhere (`sock.go`); flow-director steering; per-packet
| Item | Status | Notes | | Item | Status | Notes |
|---|---|---| |---|---|---|
| X520-DA2 | **Installed** in the single PCIe slot (E810 out); PCIe 5 GT/s ×8 | Port 0 `enp1s0f0` = new Wiitek (SN WAMZ012606X039U); port 1 `enp1s0f1` = FS (SN S2433774168); cable linked at 10G. Stock ixgbe needs `allow_unsupported_sfp=1` — the *FS* trips qualification (hardware.md) | | X520-DA2 | **Installed** in the single PCIe slot (E810 out); PCIe 5 GT/s ×8 | Both ports FS (port 1 SN S2433774168); cable linked at 10G. Stock ixgbe needs `allow_unsupported_sfp=1` — the *FS* trips qualification (hardware.md) |
| ConnectX-5 | **Ordered** (dual SFP28, PCIe x8) — the product NIC candidate | mlx5 is the one driver meeting the full requirement set: stamps every packet, shared PHC across ports, native ETHER_FLOW steering. Open: MCIA diagnostics questions (nics/connectx-5/). Arrival notes: ports may ship in InfiniBand mode (`mlxconfig set LINK_TYPE_P1=2 LINK_TYPE_P2=2`); SFP+ drops into SFP28 cages at 10G; check `mlx5_ib` vs channel changes (the irdma lesson) | | ConnectX-5 | **Ordered** (dual SFP28, PCIe x8) — the product NIC candidate | mlx5 is the one driver meeting the full requirement set: stamps every packet, shared PHC across ports, native ETHER_FLOW steering. Open: MCIA diagnostics questions (nics/connectx-5/). Arrival notes: ports may ship in InfiniBand mode (`mlxconfig set LINK_TYPE_P1=2 LINK_TYPE_P2=2`); SFP+ drops into SFP28 cages at 10G; check `mlx5_ib` vs channel changes (the irdma lesson) |
| Replacement Wiiteks | Arrived; one in X520 port 0 | Originals bricked by register exploration — modules/wiitek/ trap first | | Replacement Wiiteks | Arrived; on the shelf | Originals bricked by register exploration — modules/wiitek/ trap first |
| FS SFP-10G-T-100 ×2 | In hand; one in X520 port 1 | BCM84891L, documented, robust | | FS SFP-10G-T-100 ×2 | Both in the X520 test pair | BCM84891L, documented, robust. **2× FS at both ends is the expected product module config** unless mixed ends prove wanted |
| Fibergaga SFP-10G-T-30M | In hand | Aquantia, RollBall, the documented oracle | | Fibergaga SFP-10G-T-30M | In hand | Aquantia, RollBall, the documented oracle |
| 10Gtek | In hand | Claims SFP-10G-SR, still copper RJ45; filler, not in the test set | | 10Gtek | In hand | Claims SFP-10G-SR, still copper RJ45; filler, not in the test set |
| E810 | Out of the box | Patched ice + `sff_i2c` remains useful only if it returns for read-side work | | E810 | Out of the box | Patched ice + `sff_i2c` remains useful only if it returns for read-side work |
@@ -64,5 +64,5 @@ In `~/work/` alongside the phydiag artifacts, ready to fold into the repo's `ker
## Open items ## Open items
- **BCM ECD works** (recipe recovered from the OpenBCM SDK, validated on the FS — modules/fs/): per-pair lengths meter-accurate against a known ~45 m cable. Remaining work is characterizing the link blip the run causes (length is a between-runs operation until then). The FS ECD-chapter ask is now confirmation, not unblocking. - **BCM ECD works** (recipe recovered from the OpenBCM SDK, validated on the FS — modules/fs/): per-pair lengths meter-accurate against a known ~45 m cable. cabletest runs it at bringup and on every reset, re-baselining counters after the relink so the blip is never charged (`phy.go`). Remaining work is characterizing the link blip the run causes (length stays a between-measurements operation until then). The FS ECD-chapter ask is now confirmation, not unblocking.
- **Pre-FEC verification** on the Aquantia — counters documented; needs the graded-noise correlation run (design: modules/fibergaga/). - **Pre-FEC verification** on the Aquantia — counters documented; needs the graded-noise correlation run (design: modules/fibergaga/).
+2
View File
@@ -22,6 +22,8 @@ type mountSpec struct {
var wantMounts = []mountSpec{ var wantMounts = []mountSpec{
{"/proc", "proc", unix.PROC_SUPER_MAGIC, unix.MS_NOSUID | unix.MS_NODEV | unix.MS_NOEXEC}, {"/proc", "proc", unix.PROC_SUPER_MAGIC, unix.MS_NOSUID | unix.MS_NODEV | unix.MS_NOEXEC},
{"/sys", "sysfs", unix.SYSFS_MAGIC, unix.MS_NOSUID | unix.MS_NODEV | unix.MS_NOEXEC}, {"/sys", "sysfs", unix.SYSFS_MAGIC, unix.MS_NOSUID | unix.MS_NODEV | unix.MS_NOEXEC},
// The module I2C transport lives behind debugfs.
{"/sys/kernel/debug", "debugfs", unix.DEBUGFS_MAGIC, unix.MS_NOSUID | unix.MS_NODEV | unix.MS_NOEXEC},
// devtmpfs reports itself as tmpfs, which is why the magic alone is not // devtmpfs reports itself as tmpfs, which is why the magic alone is not
// enough to tell it apart from the directory it would be mounted over. // enough to tell it apart from the directory it would be mounted over.
{"/dev", "devtmpfs", unix.TMPFS_MAGIC, unix.MS_NOSUID}, {"/dev", "devtmpfs", unix.TMPFS_MAGIC, unix.MS_NOSUID},
+50 -77
View File
@@ -37,12 +37,7 @@ type direction struct {
streams []lossWindow streams []lossWindow
txFDs []int txFDs []int
rxFDs []int rxFDs []int
statFD int
probeSpec *frameSpec
probeTxFD int
probeRxFD int
statFD int
cable *cableStats
// Guards everything the sampler touches. The counters are read on their own // Guards everything the sampler touches. The counters are read on their own
// clock and drawn on another, and the two must not read them at once: // clock and drawn on another, and the two must not read them at once:
@@ -220,16 +215,17 @@ func (d *direction) reset() {
d.base = d.capture() d.base = d.capture()
d.win.push(d.base) d.win.push(d.base)
d.mu.Unlock() d.mu.Unlock()
d.cable.reset()
} }
// Returns the new start time, so the uptime shown alongside the totals counts // Returns the new start time, so the uptime shown alongside the totals counts
// from the reset rather than from launch. // from the reset rather than from launch.
func resetAll(dirs []*direction, stats *streamTable) time.Time { func resetAll(dirs []*direction, mods []*phyModule, stats *streamTable) time.Time {
for _, d := range dirs { for _, d := range dirs {
d.reset() d.reset()
} }
for _, m := range mods {
m.reset()
}
stats.sinceHeader = 0 stats.sinceHeader = 0
fmt.Println(stats.rule("counters reset")) fmt.Println(stats.rule("counters reset"))
return time.Now() return time.Now()
@@ -248,15 +244,18 @@ func gbps(bytes, frames uint64, secs float64) float64 {
var intervalCols = []colSpec{ var intervalCols = []colSpec{
{group: "NOW", title: "bits/s", width: 9, right: true}, {group: "NOW", title: "bits/s", width: 9, right: true},
{group: "NOW", title: "packets/s", width: 9, right: true}, {group: "NOW", title: "packets/s", width: 9, right: true},
{group: "NOW", title: "snr", width: 6, right: true},
{group: "NOW", title: "lost", width: 7, right: true}, {group: "NOW", title: "lost", width: 7, right: true},
{group: "NOW", title: "corrupt", width: 7, right: true}, {group: "NOW", title: "corrupt", width: 7, right: true},
{group: "NOW", title: "link", width: 7, right: true}, {group: "NOW", title: "link", width: 7, right: true},
{group: "NOW", title: "internal", width: 8, right: true}, {group: "NOW", title: "internal", width: 8, right: true},
{group: "NOW", title: "corrected", width: 9, right: true},
{group: "NOW", title: "noise", width: 7, right: true}, {group: "NOW", title: "noise", width: 7, right: true},
{group: "OVERALL", title: "elapsed", width: 9, right: true}, {group: "OVERALL", title: "elapsed", width: 9, right: true},
{group: "OVERALL", title: "packets", width: 9, right: true}, {group: "OVERALL", title: "packets", width: 9, right: true},
{group: "OVERALL", title: "bytes", width: 9, right: true}, {group: "OVERALL", title: "bytes", width: 9, right: true},
{group: "OVERALL", title: "metres", width: 6, right: true}, {group: "OVERALL", title: "metres", width: 6, right: true},
{group: "OVERALL", title: "corrected", width: 9, right: true},
{group: "OVERALL", title: "lost", width: 9, right: true}, {group: "OVERALL", title: "lost", width: 9, right: true},
{group: "OVERALL", title: "corrupt", width: 9, right: true}, {group: "OVERALL", title: "corrupt", width: 9, right: true},
{group: "OVERALL", title: "link", width: 9, right: true}, {group: "OVERALL", title: "link", width: 9, right: true},
@@ -271,7 +270,6 @@ type view struct {
rxFrames, rxBytes uint64 rxFrames, rxBytes uint64
since errs since errs
window errs window errs
cable cableView
} }
func errsBetween(b, n counterSet) errs { func errsBetween(b, n counterSet) errs {
@@ -297,7 +295,6 @@ func (d *direction) counters(now counterSet) view {
return view{ return view{
rxFrames: now.s.rxFrames - d.base.s.rxFrames, rxFrames: now.s.rxFrames - d.base.s.rxFrames,
rxBytes: now.s.rxBytes - d.base.s.rxBytes, rxBytes: now.s.rxBytes - d.base.s.rxBytes,
cable: d.cable.view(),
since: errsBetween(d.base, now), since: errsBetween(d.base, now),
} }
} }
@@ -329,7 +326,7 @@ func (d *direction) displayView() view {
n := d.win.count() n := d.win.count()
if n == 0 { if n == 0 {
d.mu.Unlock() d.mu.Unlock()
return view{cable: d.cable.view()} return view{}
} }
v := d.counters(d.win.at(n - 1)) v := d.counters(d.win.at(n - 1))
if n >= 2 { if n >= 2 {
@@ -344,19 +341,22 @@ func (d *direction) displayView() view {
// The same figures the panel draws, in the same order: the last second as // The same figures the panel draws, in the same order: the last second as
// rates and error flags with the noise cable riding at the end of them, then // rates and error flags with the noise cable riding at the end of them, then
// everything since the reset. // everything since the reset.
func totalRow(elapsed time.Duration, v view, target float64, length string, noiseMissing uint64) []string { func totalRow(elapsed time.Duration, v view, target float64, phy phyDisplay, noiseMissing uint64) []string {
return []string{ return []string{
rateCell(v.rxGbps*1e9, target*1e9), rateCell(v.rxGbps*1e9, target*1e9),
scaleSI(v.rxPPS), scaleSI(v.rxPPS),
snrCell(phy),
flagCell(v.window.lost), flagCell(v.window.lost),
flagCell(v.window.corrupt), flagCell(v.window.corrupt),
flagCell(v.window.link), flagCell(v.window.link),
flagCell(v.window.internal), flagCell(v.window.internal),
correctedFlag(phy.recent),
flagCell(noiseMissing), flagCell(noiseMissing),
scaleTime(elapsed), scaleTime(elapsed),
scaleCount(v.rxFrames), scaleCount(v.rxFrames),
scaleCount(v.rxBytes), scaleCount(v.rxBytes),
length, phy.metres,
correctedCell(phy.corrected),
statusCell(v.since.lost), statusCell(v.since.lost),
statusCell(v.since.corrupt), statusCell(v.since.corrupt),
statusCell(v.since.link), statusCell(v.since.link),
@@ -382,7 +382,6 @@ func buildDirection(label string, tx, rx endpoint) (*direction, error) {
d := &direction{ d := &direction{
txStats: txs, txStats: txs,
streams: newLossWindows(txs), streams: newLossWindows(txs),
cable: newCableStats(),
} }
// Held open for the life of the run: the stats ioctl is issued five times a // Held open for the life of the run: the stats ioctl is issued five times a
// second and reopening a socket for each one is pure overhead. // second and reopening a socket for each one is pure overhead.
@@ -412,8 +411,8 @@ func buildDirection(label string, tx, rx endpoint) (*direction, error) {
if err != nil { if err != nil {
return nil, fmt.Errorf("%s rx socket for 0x%04x: %w", label, et, err) return nil, fmt.Errorf("%s rx socket for 0x%04x: %w", label, et, err)
} }
// The mac already stamps every frame for the probe's sake, so this only // The mac already stamps every frame; this only asks for the stamp to be
// asks for the stamp to be delivered. // delivered.
if err := enableRxTimestamps(fd); err != nil { if err := enableRxTimestamps(fd); err != nil {
return nil, fmt.Errorf("%s rx timestamps for 0x%04x: %w", label, et, err) return nil, fmt.Errorf("%s rx timestamps for 0x%04x: %w", label, et, err)
} }
@@ -421,26 +420,6 @@ func buildDirection(label string, tx, rx endpoint) (*direction, error) {
d.rxStats = append(d.rxStats, &rxStats{}) d.rxStats = append(d.rxStats, &rxStats{})
} }
// Deliberately given no flow rule: a few frames a second does not need a
// queue of its own, and the stamps are taken at the wire either way.
d.probeSpec = newFrameSpec(rx.mac, tx.mac, probeEther, []int{probeSize})
fd, err := openTxSocket(tx.idx)
if err != nil {
return nil, fmt.Errorf("%s probe tx socket: %w", label, err)
}
if err := enableTxTimestamps(fd); err != nil {
return nil, fmt.Errorf("%s probe tx timestamps: %w", label, err)
}
d.probeTxFD = fd
fd, err = openRxSocket(rx.idx, probeEther)
if err != nil {
return nil, fmt.Errorf("%s probe rx socket: %w", label, err)
}
if err := enableRxTimestamps(fd); err != nil {
return nil, fmt.Errorf("%s probe rx timestamps: %w", label, err)
}
d.probeRxFD = fd
return d, nil return d, nil
} }
@@ -479,22 +458,6 @@ func (d *direction) start(wg *sync.WaitGroup, done *atomic.Bool, rxReady *sync.W
}() }()
} }
sender := &probeSender{fd: d.probeTxFD, spec: d.probeSpec, stats: d.cable}
wg.Add(1)
go func() {
defer wg.Done()
defer holdPanic()
sender.run(done, startTx)
}()
receiver := &probeReceiver{fd: d.probeRxFD, stats: d.cable, ready: rxReady}
wg.Add(1)
go func() {
defer wg.Done()
defer holdPanic()
receiver.run(done)
}()
wg.Add(1) wg.Add(1)
go func() { go func() {
defer wg.Done() defer wg.Done()
@@ -510,8 +473,6 @@ func (d *direction) close() {
for _, fd := range d.rxFDs { for _, fd := range d.rxFDs {
unix.Close(fd) unix.Close(fd)
} }
unix.Close(d.probeTxFD)
unix.Close(d.probeRxFD)
unix.Close(d.statFD) unix.Close(d.statFD)
} }
@@ -519,8 +480,6 @@ const (
numStreams = 7 numStreams = 7
batchSize = 64 batchSize = 64
probeEther uint16 = etherBase + numStreams
testDriver = "ice" testDriver = "ice"
// A constant rather than the negotiated speed, since this has to come up // A constant rather than the negotiated speed, since this has to come up
@@ -538,10 +497,9 @@ func main() {
// which ethN shifts with every driver built into the kernel. // which ethN shifts with every driver built into the kernel.
aName := flag.String("a", "", "first interface (default: the ice pair)") aName := flag.String("a", "", "first interface (default: the ice pair)")
bName := flag.String("b", "", "second interface") bName := flag.String("b", "", "second interface")
nsPerM := flag.Float64("ns-per-m", 4.85, "mean of both directions, per metre of cable")
flag.Parse() flag.Parse()
if err := run(*aName, *bName, *nsPerM); err != nil { if err := run(*aName, *bName); err != nil {
fatal(err) fatal(err)
} }
// A clean return is ctrl-alt-delete, which the kernel hands PID 1 as a // A clean return is ctrl-alt-delete, which the kernel hands PID 1 as a
@@ -584,7 +542,7 @@ func (s *sampler) run(done *atomic.Bool, startTx <-chan struct{}) {
} }
} }
func run(aName, bName string, nsPerM float64) (err error) { func run(aName, bName string) (err error) {
defer func() { defer func() {
if p := recover(); p != nil { if p := recover(); p != nil {
if os.Getpid() != 1 { if os.Getpid() != 1 {
@@ -640,6 +598,12 @@ func run(aName, bName string, nsPerM float64) (err error) {
return err return err
} }
modules, cable, moduleChecks := moduleBringup([2]string{a.name, b.name})
if err := reportChecks("MODULES", moduleChecks); err != nil {
return err
}
diag := newCableDiag(modules, cable)
var dirs []*direction var dirs []*direction
for _, p := range [][2]endpoint{{a, b}, {b, a}} { for _, p := range [][2]endpoint{{a, b}, {b, a}} {
d, err := buildDirection(p[0].name+"->"+p[1].name, p[0], p[1]) d, err := buildDirection(p[0].name+"->"+p[1].name, p[0], p[1])
@@ -673,7 +637,7 @@ func run(aName, bName string, nsPerM float64) (err error) {
var rxReady sync.WaitGroup var rxReady sync.WaitGroup
startTx := make(chan struct{}) startTx := make(chan struct{})
for _, d := range dirs { for _, d := range dirs {
rxReady.Add(len(d.rxFDs) + 1) rxReady.Add(len(d.rxFDs))
} }
for _, d := range dirs { for _, d := range dirs {
d.start(&wg, &done, &rxReady, startTx) d.start(&wg, &done, &rxReady, startTx)
@@ -693,6 +657,16 @@ func run(aName, bName string, nsPerM float64) (err error) {
defer holdPanic() defer holdPanic()
noise.run(&done) noise.run(&done)
}() }()
// Also ungated: SNR and temperature ride the module's own management bus,
// not the wire being measured.
for _, m := range modules {
wg.Add(1)
go func() {
defer wg.Done()
defer holdPanic()
m.run(&done)
}()
}
// Every return from here on stops the workers before the deferred closes // Every return from here on stops the workers before the deferred closes
// pull their sockets out from under them: otherwise the sampler panics on a // pull their sockets out from under them: otherwise the sampler panics on a
// closed fd and can mask the error that actually ended the run. An error // closed fd and can mask the error that actually ended the run. An error
@@ -754,41 +728,40 @@ func run(aName, bName string, nsPerM float64) (err error) {
return fmt.Errorf("%v", p) return fmt.Errorf("%v", p)
case <-sig: case <-sig:
return nil return nil
// A reset re-measures the cable first — the cable under a reset is
// usually a new one — and the counters re-baseline when the diag's own
// link blip is over, so it is never charged to the fresh run.
case <-space: case <-space:
start = resetAll(dirs, stats) if diag.kick(&done) {
fmt.Println(stats.rule("measuring cable"))
}
case <-diag.completed:
start = resetAll(dirs, modules, stats)
case <-disp.fb.flips: case <-disp.fb.flips:
now := time.Now() now := time.Now()
px, py, down := touch.get() px, py, down := touch.get()
x, y := disp.fb.fromPanel(px, py) x, y := disp.fb.fromPanel(px, py)
if disp.holdReset(x, y, down, now) { if disp.holdReset(x, y, down, now) {
start = resetAll(dirs, stats) diag.kick(&done)
} }
disp.showVersion = down && disp.versionSpot.contains(x, y) disp.showVersion = down && disp.versionSpot.contains(x, y)
for i, d := range dirs { for i, d := range dirs {
views[i] = d.displayView() views[i] = d.displayView()
} }
// Empty until the probe has a stamp from each direction, so the info, measuring := diag.snapshot()
// panel shows nothing there rather than a placeholder. phy := phyDisplayFrom(info, measuring, modules[0].view(), modules[1].view())
cable := "" if err := disp.render(totalView(views), now.Sub(start), phy,
if m, ok := cableMetres(views, nsPerM); ok {
cable = fmt.Sprintf("%.1f", m)
}
if err := disp.render(totalView(views), now.Sub(start), cable,
noise.missing()); err != nil { noise.missing()); err != nil {
return err return err
} }
case now := <-tick.C: case now := <-tick.C:
elapsed := now.Sub(start) elapsed := now.Sub(start)
// Length needs both directions, so every row is sampled before any of
// them is printed.
for i, d := range dirs { for i, d := range dirs {
rows[i] = d.displayView() rows[i] = d.displayView()
} }
length := "-" info, measuring := diag.snapshot()
if m, ok := cableMetres(rows, nsPerM); ok { phy := phyDisplayFrom(info, measuring, modules[0].view(), modules[1].view())
length = fmt.Sprintf("%.1f", m) for _, line := range stats.emit(totalRow(elapsed, totalView(rows), target, phy,
}
for _, line := range stats.emit(totalRow(elapsed, totalView(rows), target, length,
noise.missing())) { noise.missing())) {
fmt.Println(line) fmt.Println(line)
} }
-1
View File
@@ -169,7 +169,6 @@ func TestErrsBetweenBuckets(t *testing.T) {
func TestResetDoesNotUnderflowTotals(t *testing.T) { func TestResetDoesNotUnderflowTotals(t *testing.T) {
d := &direction{ d := &direction{
win: newRateWindow(8), win: newRateWindow(8),
cable: newCableStats(),
rxStats: []*rxStats{{}}, rxStats: []*rxStats{{}},
} }
+1 -1
View File
@@ -32,7 +32,7 @@ const (
noiseDownSpan = 5 * time.Second noiseDownSpan = 5 * time.Second
noiseFrameGap = 10 * time.Millisecond noiseFrameGap = 10 * time.Millisecond
noiseEther uint16 = probeEther + 1 noiseEther uint16 = etherBase + numStreams
) )
// Kernel names shift with which drivers are built in, since ethN is handed out // Kernel names shift with which drivers are built in, since ethN is handed out
+808
View File
@@ -0,0 +1,808 @@
package main
import (
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"time"
"golang.org/x/sys/unix"
)
const (
bcmI2CWrite = 0xAC
bcmI2CRead = 0xAD
bcmMMDVendor uint16 = 0x1E
bcmRegCmd uint16 = 0x4005
bcmRegStatus uint16 = 0x4037
bcmRegData1 uint16 = 0x4038
bcmStInProgress uint16 = 0x0002
bcmStPass uint16 = 0x0004
bcmStError uint16 = 0x0008
bcmStBusy uint16 = 0xBBBB
bcmCmdGetPairSwap uint16 = 0x8000
bcmCmdSetEEEMode uint16 = 0x8009
bcmCmdGetSNR uint16 = 0x8030
bcmRegECDCtrl uint16 = 0x4006
bcmRegECDResult uint16 = 0xA896
bcmRegECDLen uint16 = 0xA897
bcmPHYIDHi = 0x3590
bcmPHYIDLo = 0x5081
bcmReadDelayUs = 3000
bcmRetryDelayUs = 10000
bcmStatusPoll = 100 * time.Millisecond
bcmStatusTries = 30
ecdPoll = 200 * time.Millisecond
ecdDeadline = 50 * time.Second
pairIdentityMap = 0xE4
)
const (
pairOK = 1
pairOpen = 2
pairShort = 3
pairXtalk = 4
)
var pairVerdicts = map[int]string{
pairOK: "ok", pairOpen: "OPEN", pairShort: "SHORT", pairXtalk: "XTALK",
}
type bcm struct {
ifname string
path string
// Serializes the multi-op sequences — a handler command, an ECD run — that
// would corrupt each other interleaved. Single register reads ride bare:
// the compound op makes each one atomic on the wire.
mu sync.Mutex
}
func openBCM(ifname string) (*bcm, error) {
devLink, err := os.Readlink("/sys/class/net/" + ifname + "/device")
if err != nil {
return nil, fmt.Errorf("%s: %w", ifname, err)
}
drv, err := ifDriver(ifname)
if err != nil {
return nil, fmt.Errorf("%s: %w", ifname, err)
}
if drv != "ixgbe" {
return nil, fmt.Errorf("%s: no module I2C transport for driver %s", ifname, drv)
}
b := &bcm{
ifname: ifname,
path: "/sys/kernel/debug/ixgbe/" + filepath.Base(devLink) + "/sff_i2c",
}
if _, err := os.Stat(b.path); err != nil {
return nil, fmt.Errorf("%s: %w (patched ixgbe?)", ifname, err)
}
return b, nil
}
func (b *bcm) op(cmd string) (string, error) {
fd, err := unix.Open(b.path, unix.O_RDWR, 0)
if err != nil {
return "", fmt.Errorf("%s: %w", b.path, err)
}
defer unix.Close(fd)
if _, err := unix.Write(fd, []byte(cmd)); err != nil {
return "", fmt.Errorf("%s %q: %w", b.ifname, cmd, err)
}
buf := make([]byte, 256)
n, err := unix.Read(fd, buf)
if err != nil {
return "", fmt.Errorf("%s %q: %w", b.ifname, cmd, err)
}
resp := strings.TrimSpace(string(buf[:n]))
if !strings.HasPrefix(resp, "ok") {
return "", fmt.Errorf("%s %q: %s", b.ifname, cmd, resp)
}
return strings.TrimSpace(resp[2:]), nil
}
func parseHexBytes(s string, n int) ([]byte, error) {
fields := strings.Fields(s)
if len(fields) != n {
return nil, fmt.Errorf("want %d bytes, got %q", n, s)
}
out := make([]byte, n)
for i, f := range fields {
var v byte
if _, err := fmt.Sscanf(f, "%x", &v); err != nil {
return nil, fmt.Errorf("byte %q in %q", f, s)
}
out[i] = v
}
return out, nil
}
// One write-STOP-delay-read transaction under a single bus hold, so the
// driver's own SFP traffic can never consume the bridge's pending data.
func (b *bcm) compound(waddr, raddr byte, delayUs, n int, wdata []byte) ([]byte, error) {
var sb strings.Builder
fmt.Fprintf(&sb, "x %02x %02x %d %x", waddr, raddr, delayUs, n)
for _, v := range wdata {
fmt.Fprintf(&sb, " %02x", v)
}
resp, err := b.op(sb.String())
if err != nil {
return nil, err
}
return parseHexBytes(resp, n)
}
func (b *bcm) mdioReadDelay(devad, reg uint16, delayUs int) (uint16, error) {
d, err := b.compound(bcmI2CWrite, bcmI2CRead, delayUs, 2,
[]byte{0x20 | byte(devad), byte(reg >> 8), byte(reg)})
if err != nil {
return 0, err
}
return uint16(d[0])<<8 | uint16(d[1]), nil
}
// 0x0000 is also the bridge's not-ready signature, so a zero is read again at
// a longer delay before being believed.
func (b *bcm) mdioRead(devad, reg uint16) (uint16, error) {
v, err := b.mdioReadDelay(devad, reg, bcmReadDelayUs)
if err != nil || v != 0 {
return v, err
}
return b.mdioReadDelay(devad, reg, bcmRetryDelayUs)
}
func (b *bcm) mdioWrite(devad, reg, val uint16) error {
_, err := b.op(fmt.Sprintf("w %02x %02x %02x %02x %02x %02x",
bcmI2CWrite, byte(devad), byte(reg>>8), byte(reg), byte(val>>8), byte(val)))
return err
}
func (b *bcm) eeprom(off byte, n int) ([]byte, error) {
return b.compound(0xA0, 0xA1, 500, n, []byte{off})
}
func (b *bcm) waitStatus(want func(uint16) bool) (uint16, error) {
var st uint16
for i := 0; i < bcmStatusTries; i++ {
var err error
st, err = b.mdioRead(bcmMMDVendor, bcmRegStatus)
if err != nil {
return 0, err
}
if want(st) {
return st, nil
}
time.Sleep(bcmStatusPoll)
}
return 0, fmt.Errorf("%s: command handler stuck, status %#04x", b.ifname, st)
}
// The handler never clears DATA registers it does not use, so every SET must
// pass its full parameter set and every GET must pass none.
func (b *bcm) command(code uint16, params ...uint16) ([5]uint16, error) {
b.mu.Lock()
defer b.mu.Unlock()
var data [5]uint16
if _, err := b.waitStatus(func(st uint16) bool {
return st != bcmStInProgress && st != bcmStBusy
}); err != nil {
return data, err
}
for i, p := range params {
if err := b.mdioWrite(bcmMMDVendor, bcmRegData1+uint16(i), p); err != nil {
return data, err
}
}
if err := b.mdioWrite(bcmMMDVendor, bcmRegCmd, code); err != nil {
return data, err
}
st, err := b.waitStatus(func(st uint16) bool {
return st == bcmStPass || st == bcmStError
})
if err != nil {
return data, err
}
if st == bcmStError {
return data, fmt.Errorf("%s: command %#04x returned ERROR", b.ifname, code)
}
for i := range data {
data[i], err = b.mdioRead(bcmMMDVendor, bcmRegData1+uint16(i))
if err != nil {
return data, err
}
}
return data, nil
}
func (b *bcm) identify() (string, error) {
hi, err := b.mdioRead(1, 2)
if err != nil {
return "", err
}
lo, err := b.mdioRead(1, 3)
if err != nil {
return "", err
}
if hi != bcmPHYIDHi || lo != bcmPHYIDLo {
return "", fmt.Errorf("%s: PHY ID %#04x:%#04x, want %#04x:%#04x",
b.ifname, hi, lo, bcmPHYIDHi, bcmPHYIDLo)
}
sn, err := b.eeprom(68, 16)
if err != nil {
return "", err
}
return "BCM84891L sn " + strings.TrimSpace(string(sn)), nil
}
// PMA 1.1 latches low, so the first read reports any drop since it was last
// read and the second reports the wire as it is now.
func (b *bcm) linkUp() (bool, error) {
if _, err := b.mdioRead(1, 1); err != nil {
return false, err
}
v, err := b.mdioRead(1, 1)
if err != nil {
return false, err
}
return v&0x0004 != 0, nil
}
func (b *bcm) forceEEEOff() error {
_, err := b.command(bcmCmdSetEEEMode, 0x0000, 0x0000, 0x7A12, 0x0480, 0x0000)
return err
}
func (b *bcm) restartAN() error {
v, err := b.mdioRead(7, 0)
if err != nil {
return err
}
return b.mdioWrite(7, 0, v|0x0200)
}
func (b *bcm) eeeAdvert() (uint16, error) {
return b.mdioRead(7, 60)
}
func (b *bcm) pairMap() (byte, error) {
d, err := b.command(bcmCmdGetPairSwap)
if err != nil {
return 0, err
}
return byte(d[1]), nil
}
func (b *bcm) snr() ([4]float64, error) {
var out [4]float64
d, err := b.command(bcmCmdGetSNR)
if err != nil {
return out, err
}
for i := range out {
out[i] = float64(d[i+1]) / 10
}
return out, nil
}
func (b *bcm) pcsLatch() (blocks, ber uint64, err error) {
v, err := b.mdioRead(3, 33)
if err != nil {
return 0, 0, err
}
return uint64(v & 0xFF), uint64((v >> 8) & 0x3F), nil
}
func (b *bcm) fastRetrainCount() (uint16, error) {
v, err := b.mdioRead(1, 147)
if err != nil {
return 0, err
}
return v >> 11, nil
}
type ecdResult struct {
verdicts [4]int
metres [4]int
}
func (b *bcm) cableDiag() (ecdResult, error) {
b.mu.Lock()
defer b.mu.Unlock()
var res ecdResult
ctrl, err := b.mdioRead(bcmMMDVendor, bcmRegECDCtrl)
if err != nil {
return res, err
}
if err := b.mdioWrite(bcmMMDVendor, bcmRegECDCtrl, ctrl&^0xF400|0x8400); err != nil {
return res, err
}
deadline := time.Now().Add(ecdDeadline)
for {
ctrl, err = b.mdioRead(bcmMMDVendor, bcmRegECDCtrl)
if err != nil {
return res, err
}
if ctrl&0x0800 == 0 {
break
}
if time.Now().After(deadline) {
return res, fmt.Errorf("%s: cable diag still busy after %s", b.ifname, ecdDeadline)
}
time.Sleep(ecdPoll)
}
v, err := b.mdioRead(1, bcmRegECDResult)
if err != nil {
return res, err
}
for i := range res.verdicts {
res.verdicts[i] = int(v>>(4*i)) & 0xF
m, err := b.mdioRead(1, bcmRegECDLen+uint16(i))
if err != nil {
return res, err
}
res.metres[i] = int(m)
}
return res, nil
}
const (
phyInterval = time.Second
phyStale = 5 * time.Second
phyMaxDark = 30
linkWaitSpan = 25 * time.Second
linkWaitPoll = time.Second
snrOperatingPoint = 26.5
snrGoodMargin = 3.0
snrWarnMargin = 1.0
)
type phyModule struct {
bcm *bcm
mu sync.Mutex
sampled bool
lastOK time.Time
link bool
haveSNR bool
snr [4]float64
blocks uint64
ber uint64
retrains uint64
recentDelta uint64
primed bool
retrainCount uint16
}
func (m *phyModule) poll() error {
link, err := m.bcm.linkUp()
if err != nil {
return err
}
var snr [4]float64
if link {
if snr, err = m.bcm.snr(); err != nil {
return err
}
}
blocks, ber, err := m.bcm.pcsLatch()
if err != nil {
return err
}
count, err := m.bcm.fastRetrainCount()
if err != nil {
return err
}
m.mu.Lock()
m.sampled = true
m.lastOK = time.Now()
m.link = link
m.haveSNR = link
m.snr = snr
// The first poll after a baseline drains what the latches gathered during
// the bringup or diag retrain, which predates the run: it only establishes
// the origin. The retrain counter is 5 bits and rolls over, so only its
// forward motion is kept.
if m.primed {
delta := blocks + ber + uint64((count-m.retrainCount)&0x1F)
m.blocks += blocks
m.ber += ber
m.retrains += uint64((count - m.retrainCount) & 0x1F)
m.recentDelta = delta
} else {
m.recentDelta = 0
m.primed = true
}
m.retrainCount = count
m.mu.Unlock()
return nil
}
// A tester that quietly loses its SNR eye goes on reporting a clean link, so a
// transport that stays dark past every transient explanation stops the run.
func (m *phyModule) run(done *atomic.Bool) {
tick := time.NewTicker(phyInterval)
defer tick.Stop()
dark := 0
var lastErr error
for !done.Load() {
<-tick.C
if err := m.poll(); err != nil {
dark++
lastErr = err
if dark >= phyMaxDark {
panic(fmt.Sprintf("module diagnostics dark for %d polls: %v", dark, lastErr))
}
continue
}
dark = 0
}
}
func (m *phyModule) reset() {
m.mu.Lock()
m.blocks, m.ber, m.retrains, m.recentDelta = 0, 0, 0, 0
m.primed = false
m.mu.Unlock()
}
type phyModView struct {
fresh bool
link bool
margins [4]float64
blocks uint64
ber uint64
retrain uint64
recent uint64
}
func (m *phyModule) view() phyModView {
m.mu.Lock()
defer m.mu.Unlock()
v := phyModView{
fresh: m.sampled && time.Since(m.lastOK) < phyStale,
link: m.link && m.haveSNR,
blocks: m.blocks,
ber: m.ber,
retrain: m.retrains,
}
if v.fresh {
v.recent = m.recentDelta
}
for i, s := range m.snr {
v.margins[i] = s - snrOperatingPoint
}
return v
}
type cableInfo struct {
ecd ecdResult
maps [2]byte
}
// The four pair lengths of one healthy cable disagree by a few metres of twist
// rate, so the cable's length is shown as their mean.
func (c cableInfo) metresString() string {
sum, n := 0, 0
for i, v := range c.ecd.verdicts {
if v == pairOK {
sum += c.ecd.metres[i]
n++
}
}
if n == 0 {
return "-"
}
return fmt.Sprintf("%d", (sum+n/2)/n)
}
const (
clsNone = iota
clsGood
clsWarn
clsBad
)
func snrClass(margin float64) int {
switch {
case margin >= snrGoodMargin:
return clsGood
case margin >= snrWarnMargin:
return clsWarn
default:
return clsBad
}
}
type phyDisplay struct {
haveSNR bool
worstMargin float64
corrected uint64
recent uint64
metres string
metresClass int
}
func pairLetter(i int) string { return string(rune('A' + i)) }
// Each end resolves MDI on its own, so a swap at either end counts.
func pairSwapped(i int, maps [2]byte) bool {
return int(maps[0]>>(2*i))&3 != i || int(maps[1]>>(2*i))&3 != i
}
// The cable as one figure and one judgment: the mean length of its healthy
// pairs, red when the diag found a fault, amber when a pair arrived swapped.
// Per-pair detail stays on the console — pair letters don't correlate back to
// wires by eye.
func cableSummary(cable cableInfo, measuring bool) (string, int) {
if measuring {
return "...", clsNone
}
anyData, anyFault, anySwap := false, false, false
for i, v := range cable.ecd.verdicts {
if v != 0 {
anyData = true
}
if v != 0 && v != pairOK {
anyFault = true
}
if pairSwapped(i, cable.maps) {
anySwap = true
}
}
s := cable.metresString()
switch {
case !anyData:
return "-", clsNone
case anyFault:
return s, clsBad
case anySwap:
return s, clsWarn
}
return s, clsGood
}
// The worse of the two receivers' margins, worst pair across the cable.
func phyDisplayFrom(cable cableInfo, measuring bool, a, b phyModView) phyDisplay {
d := phyDisplay{
haveSNR: a.fresh && b.fresh && a.link && b.link,
corrected: a.blocks + a.ber + a.retrain + b.blocks + b.ber + b.retrain,
recent: a.recent + b.recent,
}
if d.haveSNR {
d.worstMargin = min(a.margins[0], b.margins[0])
for i := range a.margins {
if m := min(a.margins[i], b.margins[i]); m < d.worstMargin {
d.worstMargin = m
}
}
}
d.metres, d.metresClass = cableSummary(cable, measuring)
return d
}
func waitLink(mods []*phyModule, done *atomic.Bool) (time.Duration, bool, error) {
start := time.Now()
deadline := start.Add(linkWaitSpan)
for {
up := true
for _, m := range mods {
v, err := m.bcm.linkUp()
if err != nil {
return 0, false, err
}
up = up && v
}
if up {
return time.Since(start), true, nil
}
if time.Now().After(deadline) || (done != nil && done.Load()) {
return time.Since(start), false, nil
}
time.Sleep(linkWaitPoll)
}
}
// The whole cable picture in one pass: the ECD's per-pair verdicts and
// lengths, then — after the blip it causes has settled — both ends' pair
// maps, read post-link so the MDI resolution is the fresh one.
func measureCable(mods []*phyModule, waitRelink bool, done *atomic.Bool) (cableInfo, bool, error) {
var c cableInfo
var err error
c.ecd, err = mods[0].bcm.cableDiag()
if err != nil {
return c, false, err
}
relinked := false
if waitRelink {
if _, relinked, err = waitLink(mods, done); err != nil {
return c, false, err
}
}
for i, m := range mods {
if c.maps[i], err = m.bcm.pairMap(); err != nil {
return c, false, err
}
}
return c, relinked, nil
}
// Owns the cable picture after bringup: a reset re-measures — the cable under
// a reset is usually a different cable — and the counters re-baseline only
// once the diag's own link blip is over, so it is never charged to the run.
type cableDiag struct {
mods []*phyModule
completed chan struct{}
mu sync.Mutex
info cableInfo
running bool
}
func newCableDiag(mods []*phyModule, info cableInfo) *cableDiag {
return &cableDiag{mods: mods, completed: make(chan struct{}, 1), info: info}
}
func (c *cableDiag) snapshot() (cableInfo, bool) {
c.mu.Lock()
defer c.mu.Unlock()
return c.info, c.running
}
// Runs the re-measure off the display loop, so the panel keeps drawing while
// the diag and the relink take their seconds. Reports whether one started; a
// press while one is in flight is absorbed.
func (c *cableDiag) kick(done *atomic.Bool) bool {
c.mu.Lock()
if c.running {
c.mu.Unlock()
return false
}
c.running = true
c.mu.Unlock()
go func() {
defer holdPanic()
info, _, err := measureCable(c.mods, true, done)
if err != nil {
panic(err)
}
c.mu.Lock()
c.info = info
c.running = false
c.mu.Unlock()
select {
case c.completed <- struct{}{}:
default:
}
}()
return true
}
func mapString(m byte) string {
if m == pairIdentityMap {
return "straight"
}
out := make([]string, 4)
for i := range out {
out[i] = pairLetter(int(m>>(2*i)) & 3)
}
return "swapped to " + strings.Join(out, "")
}
func verdictString(r ecdResult) string {
bad := []string{}
for i, v := range r.verdicts {
if v != pairOK {
s, ok := pairVerdicts[v]
if !ok {
s = fmt.Sprintf("%d", v)
}
bad = append(bad, fmt.Sprintf("%s %s at %dm", pairLetter(i), s, r.metres[i]))
}
}
if len(bad) > 0 {
return strings.Join(bad, ", ")
}
return fmt.Sprintf("all pairs ok, %d/%d/%d/%d m",
r.metres[0], r.metres[1], r.metres[2], r.metres[3])
}
// Runs before any socket opens: forcing EEE off retrains the link and the ECD
// blips it, and both belong before the baselines rather than under them.
func moduleBringup(names [2]string) ([]*phyModule, cableInfo, []checkResult) {
var out []checkResult
var cable cableInfo
mods := make([]*phyModule, 0, 2)
fail := func(item string, err error) ([]*phyModule, cableInfo, []checkResult) {
return nil, cable, append(out, checkResult{item: item, err: err})
}
for _, name := range names {
res := checkResult{item: name + " module"}
b, err := openBCM(name)
if err != nil {
return fail(res.item, err)
}
res.state, err = b.identify()
if err != nil {
return fail(res.item, err)
}
out = append(out, res)
mods = append(mods, &phyModule{bcm: b})
}
for i, m := range mods {
res := checkResult{item: names[i] + " eee", fixed: true}
if err := m.bcm.forceEEEOff(); err != nil {
return fail(res.item, err)
}
if err := m.bcm.restartAN(); err != nil {
return fail(res.item, err)
}
res.state = "forced off, retraining"
out = append(out, res)
}
res := checkResult{item: "link retrain"}
took, up, err := waitLink(mods, nil)
if err != nil {
return fail(res.item, err)
}
if up {
var adv [2]string
for i, m := range mods {
v, err := m.bcm.eeeAdvert()
if err != nil {
return fail(res.item, err)
}
adv[i] = fmt.Sprintf("%#04x", v)
if v != 0 {
res.err = fmt.Errorf("%s still advertises EEE %#04x", names[i], v)
}
}
res.state = fmt.Sprintf("up in %.1fs, eee advert %s/%s", took.Seconds(), adv[0], adv[1])
} else {
res.state = "no link (cable unplugged?)"
}
out = append(out, res)
if res.err != nil {
return nil, cable, out
}
res = checkResult{item: "cable diag"}
cable, relinked, err := measureCable(mods, up, nil)
if err != nil {
return fail(res.item, err)
}
res.state = verdictString(cable.ecd)
if up && !relinked {
res.err = fmt.Errorf("link did not return after cable diag")
}
out = append(out, res)
for i := range mods {
out = append(out, checkResult{
item: names[i] + " pair map",
state: mapString(cable.maps[i]),
})
}
return mods, cable, out
}
+103
View File
@@ -0,0 +1,103 @@
package main
import "testing"
func freshMod(margins [4]float64) phyModView {
return phyModView{fresh: true, link: true, margins: margins}
}
// The worst pair at the worse end is what the margin cell shows, and a fault
// paints the length red: the live margins can only make the cable look worse,
// never repair a fault.
func TestPhyDisplayWorstMarginAndFault(t *testing.T) {
cable := cableInfo{
ecd: ecdResult{verdicts: [4]int{pairOK, pairOpen, pairOK, pairOK}, metres: [4]int{45, 12, 41, 46}},
maps: [2]byte{pairIdentityMap, pairIdentityMap},
}
a := freshMod([4]float64{5, 5, 2, 0.5})
b := freshMod([4]float64{4, 5, 5, 5})
d := phyDisplayFrom(cable, false, a, b)
if !d.haveSNR || d.worstMargin != 0.5 {
t.Errorf("worstMargin = %v (have %v), want 0.5", d.worstMargin, d.haveSNR)
}
if d.metres != "44" || d.metresClass != clsBad {
t.Errorf("metres = %q class %d, want the healthy mean 44 painted bad", d.metres, d.metresClass)
}
}
// A swap at either end marks the cable: the far end resolving MDI-X on its
// own is the usual way a crossover shows up.
func TestCableSummary(t *testing.T) {
healthy := ecdResult{verdicts: [4]int{pairOK, pairOK, pairOK, pairOK}, metres: [4]int{50, 48, 47, 51}}
for _, c := range []struct {
name string
cable cableInfo
measuring bool
want string
class int
}{
{"clean", cableInfo{ecd: healthy, maps: [2]byte{pairIdentityMap, pairIdentityMap}}, false, "49", clsGood},
{"far-end swap", cableInfo{ecd: healthy, maps: [2]byte{pairIdentityMap, 0xE1}}, false, "49", clsWarn},
{"no diag yet", cableInfo{}, false, "-", clsNone},
{"measuring", cableInfo{ecd: healthy, maps: [2]byte{pairIdentityMap, pairIdentityMap}}, true, "...", clsNone},
} {
s, cl := cableSummary(c.cable, c.measuring)
if s != c.want || cl != c.class {
t.Errorf("%s = %q class %d, want %q class %d", c.name, s, cl, c.want, c.class)
}
}
}
func TestPhyDisplayStaleGoesDim(t *testing.T) {
cable := cableInfo{
ecd: ecdResult{verdicts: [4]int{pairOK, pairOK, pairOK, pairOK}, metres: [4]int{45, 45, 41, 46}},
maps: [2]byte{pairIdentityMap, pairIdentityMap},
}
d := phyDisplayFrom(cable, false, freshMod([4]float64{5, 5, 5, 5}), phyModView{})
if d.haveSNR {
t.Error("one silent module should withhold snr")
}
if d.metres != "44" || d.metresClass != clsGood {
t.Errorf("metres = %q class %d, want the diag verdict kept", d.metres, d.metresClass)
}
}
// The mean skips faulted pairs, whose length is a distance to the fault
// rather than a length of the cable.
func TestCableMetresString(t *testing.T) {
c := cableInfo{ecd: ecdResult{
verdicts: [4]int{pairOK, pairOpen, pairOK, pairOK},
metres: [4]int{45, 3, 41, 46},
}}
if got := c.metresString(); got != "44" {
t.Errorf("metres = %q, want 44", got)
}
if got := (cableInfo{}).metresString(); got != "-" {
t.Errorf("no diag = %q, want -", got)
}
}
func TestSNRClass(t *testing.T) {
for _, c := range []struct {
margin float64
want int
}{
{5, clsGood}, {3, clsGood}, {2, clsWarn}, {1, clsWarn}, {0.5, clsBad}, {-2, clsBad},
} {
if got := snrClass(c.margin); got != c.want {
t.Errorf("snrClass(%v) = %d, want %d", c.margin, got, c.want)
}
}
}
func TestVerdictString(t *testing.T) {
ok := ecdResult{verdicts: [4]int{1, 1, 1, 1}, metres: [4]int{45, 45, 41, 46}}
if got := verdictString(ok); got != "all pairs ok, 45/45/41/46 m" {
t.Errorf("healthy = %q", got)
}
bad := ecdResult{verdicts: [4]int{1, 2, 4, 1}, metres: [4]int{45, 12, 30, 46}}
if got := verdictString(bad); got != "B OPEN at 12m, C XTALK at 30m" {
t.Errorf("faulted = %q", got)
}
}
-226
View File
@@ -1,226 +0,0 @@
package main
import (
"sync"
"sync/atomic"
"time"
"golang.org/x/sys/unix"
)
const (
// Above any real stream number, so a probe is never taken for payload.
probeStream = 0xffff
probeSize = 64
probePattern = 0
// The mac has only a handful of transmit stamp slots. Asking faster than it
// can drain them gets slots recycled while a stamp is still outstanding, and
// the one that comes back then belongs to a different frame.
probeInterval = 200 * time.Millisecond
probeTimeout = 20 * time.Millisecond
// A phy costs microseconds and a hundred metres of copper costs five hundred
// nanoseconds, so anything past this is a broken stamp, not a slow frame.
probeMaxDelay = 50 * time.Microsecond
probePendCap = 256
)
// Both ports hang off one PTP clock, so a transmit stamp from one and a receive
// stamp from the other subtract directly. Both are taken at the mac, so all host
// time and all queueing falls outside the stamped interval, which is why load
// does not move it.
type cableStats struct {
mu sync.Mutex
min int64
samples uint64
floor int64
txPend map[uint64]int64
rxPend map[uint64]int64
}
type cableView struct {
min int64
floor int64
ok bool
}
// Averaging the two directions cancels the phy asymmetry between them, which is
// about 790ns and swamps any cable, so one direction alone cannot give a length.
func cableMetres(views []view, nsPerM float64) (float64, bool) {
if len(views) == 0 {
return 0, false
}
var excess float64
for _, v := range views {
if !v.cable.ok {
return 0, false
}
excess += float64(v.cable.min - v.cable.floor)
}
return excess / float64(len(views)) / nsPerM, true
}
func newCableStats() *cableStats {
return &cableStats{
txPend: make(map[uint64]int64, probePendCap),
rxPend: make(map[uint64]int64, probePendCap),
}
}
func (c *cableStats) put(seq uint64, ts int64, tx bool) {
c.mu.Lock()
defer c.mu.Unlock()
mine, theirs := c.txPend, c.rxPend
if !tx {
mine, theirs = c.rxPend, c.txPend
}
other, ok := theirs[seq]
if !ok {
if len(mine) >= probePendCap {
clear(mine)
}
mine[seq] = ts
return
}
delete(theirs, seq)
delta := ts - other
if tx {
delta = -delta
}
// The driver rebuilds a full timestamp from a truncated hardware value plus a
// cached clock read, and a stale cache lands hundreds of milliseconds out. A
// minimum would latch onto the first of those and never recover.
if delta <= 0 || delta > int64(probeMaxDelay) {
return
}
if c.samples == 0 || delta < c.min {
c.min = delta
}
if c.floor == 0 || delta < c.floor {
c.floor = delta
}
c.samples++
}
func (c *cableStats) view() cableView {
c.mu.Lock()
defer c.mu.Unlock()
return cableView{c.min, c.floor, c.samples > 0}
}
func (c *cableStats) reset() {
c.mu.Lock()
c.min, c.samples = 0, 0
clear(c.txPend)
clear(c.rxPend)
c.mu.Unlock()
}
type probeSender struct {
fd int
spec *frameSpec
stats *cableStats
}
func (p *probeSender) run(done *atomic.Bool, startTx <-chan struct{}) {
buf := make([]byte, probeSize)
p.spec.prefill(buf, probePattern)
oob := make([]byte, 512)
scratch := make([]byte, 1)
<-startTx
tick := time.NewTicker(probeInterval)
defer tick.Stop()
var seq uint64
for !done.Load() {
<-tick.C
// Stamps are matched to sends by position in the queue, so one that
// arrived after its probe gave up would be handed to this probe.
for {
if _, _, _, _, err := unix.Recvmsg(p.fd, scratch, oob,
unix.MSG_ERRQUEUE|unix.MSG_DONTWAIT); err != nil {
break
}
}
putHeader(buf, probePattern, probeStream, seq, probeSize-minFrame)
err := unix.Send(p.fd, buf, 0)
// The sequence advances even when a probe fails, so a stale receive half
// can never be paired with a later probe that reused its number.
cur := seq
seq++
if err != nil {
continue
}
ts, ok := p.awaitTx(scratch, oob)
if !ok {
continue
}
p.stats.put(cur, ts, true)
}
}
func (p *probeSender) awaitTx(scratch, oob []byte) (int64, bool) {
fds := []unix.PollFd{{Fd: int32(p.fd), Events: unix.POLLERR}}
deadline := time.Now().Add(probeTimeout)
for {
left := time.Until(deadline)
if left <= 0 {
return 0, false
}
// Rounded up, since truncating would give up with time still on the
// clock and shave the last fraction of a millisecond off every wait.
ms := int((left + time.Millisecond - 1) / time.Millisecond)
n, err := unix.Poll(fds, ms)
if err == unix.EINTR {
continue
}
if err != nil || n == 0 {
return 0, false
}
_, oobn, _, _, err := unix.Recvmsg(p.fd, scratch, oob,
unix.MSG_ERRQUEUE|unix.MSG_DONTWAIT)
if err == unix.EAGAIN || err == unix.EINTR {
continue
}
if err != nil {
return 0, false
}
return hwTimestamp(oob[:oobn])
}
}
type probeReceiver struct {
fd int
stats *cableStats
ready *sync.WaitGroup
}
func (r *probeReceiver) run(done *atomic.Bool) {
buf := make([]byte, maxFrame)
oob := make([]byte, 512)
r.ready.Done()
for !done.Load() {
n, oobn, _, _, err := unix.Recvmsg(r.fd, buf, oob, 0)
if err != nil {
continue
}
h, st := parseHeader(buf[:n])
if st != hdrOK || h.stream != probeStream {
continue
}
ts, ok := hwTimestamp(oob[:oobn])
if !ok {
continue
}
r.stats.put(h.seq, ts, false)
}
}
-25
View File
@@ -1,25 +0,0 @@
package main
import "testing"
func stamped(min, floor int64) view {
return view{cable: cableView{min: min, floor: floor, ok: true}}
}
// Averaged across directions, since one direction alone carries a phy asymmetry
// that swamps the cable.
func TestCableMetresAveragesDirections(t *testing.T) {
m, ok := cableMetres([]view{stamped(1000, 900), stamped(1100, 900)}, 5)
if !ok || m != 30 {
t.Errorf("cableMetres = %v, %v; want 30, true", m, ok)
}
}
func TestCableMetresNeedsEveryDirection(t *testing.T) {
if _, ok := cableMetres(nil, 5); ok {
t.Error("no views should not yield a length")
}
if _, ok := cableMetres([]view{stamped(1000, 900), {}}, 5); ok {
t.Error("a direction with no stamp yet should not yield a length")
}
}
+33
View File
@@ -275,6 +275,39 @@ func statusCell(v uint64) string {
return paint(s, cRed) return paint(s, cRed)
} }
// The worst pair margin across both receivers, in dB above the 10GBASE-T
// operating point, so nobody has to know the operating point to read it.
func snrCell(phy phyDisplay) string {
if !phy.haveSNR {
return paint("-", cGrey)
}
s := fmt.Sprintf("%+.1f", phy.worstMargin)
switch snrClass(phy.worstMargin) {
case clsGood:
return paint(s, cGreen)
case clsWarn:
return paint(s, cYellow)
default:
return paint(s, cRed)
}
}
// Errors the phy absorbed before they could cost a frame: yellow rather than
// red, the cable being stressed rather than failing.
func correctedCell(v uint64) string {
if v == 0 {
return paint("0", cGreen)
}
return paint(commas(v), cYellow)
}
func correctedFlag(v uint64) string {
if v == 0 {
return paint("ok", cGreen)
}
return paint("warn", cYellow)
}
// Per-interval rates jitter by a couple of percent at line rate, so green has // Per-interval rates jitter by a couple of percent at line rate, so green has
// to cover that. Yellow means a real shortfall, red means badly off. // to cover that. Yellow means a real shortfall, red means badly off.
const ( const (
+30 -8
View File
@@ -567,18 +567,40 @@ func checkCoalesce(fd int, ifname string, rxUsecs, txUsecs uint32) checkResult {
return fmt.Sprintf("adaptive rx=%d tx=%d rx-usecs=%d tx-usecs=%d", return fmt.Sprintf("adaptive rx=%d tx=%d rx-usecs=%d tx-usecs=%d",
e.useAdaptiveRxCoalesce, e.useAdaptiveTxCoalesce, e.rxCoalesceUsecs, e.txCoalesceUsecs) e.useAdaptiveRxCoalesce, e.useAdaptiveTxCoalesce, e.rxCoalesceUsecs, e.txCoalesceUsecs)
} }
if ec.useAdaptiveRxCoalesce == 0 && ec.useAdaptiveTxCoalesce == 0 && settled := func(e ethtoolCoalesce, tx uint32) bool {
ec.rxCoalesceUsecs == rxUsecs && ec.txCoalesceUsecs == txUsecs { return e.useAdaptiveRxCoalesce == 0 && e.useAdaptiveTxCoalesce == 0 &&
e.rxCoalesceUsecs == rxUsecs && e.txCoalesceUsecs == tx
}
if settled(ec, txUsecs) {
res.state = desc(ec) res.state = desc(ec)
return res return res
} }
was := desc(ec) was := desc(ec)
ec.cmd = unix.ETHTOOL_SCOALESCE set := func(tx uint32) error {
ec.useAdaptiveRxCoalesce = 0 s := ec
ec.useAdaptiveTxCoalesce = 0 s.cmd = unix.ETHTOOL_SCOALESCE
ec.rxCoalesceUsecs = rxUsecs s.useAdaptiveRxCoalesce = 0
ec.txCoalesceUsecs = txUsecs s.useAdaptiveTxCoalesce = 0
if err := ethtoolCall(fd, ifname, unsafe.Pointer(&ec)); err != nil { s.rxCoalesceUsecs = rxUsecs
s.txCoalesceUsecs = tx
return ethtoolCall(fd, ifname, unsafe.Pointer(&s))
}
err = set(txUsecs)
// A driver running mixed rx/tx vectors has one moderation value per
// vector and rejects a tx-specific one: rx-usecs covers both, and get
// reports the tx side as zero.
if err == unix.EINVAL {
if settled(ec, 0) {
res.state = desc(ec) + " (tx shares rx)"
return res
}
if err = set(0); err == nil {
res.fixed = true
res.state = fmt.Sprintf("was %s, now adaptive off rx-usecs=%d shared with tx", was, rxUsecs)
return res
}
}
if err != nil {
res.err = err res.err = err
res.state = "could not set" res.state = "could not set"
return res return res
+19 -12
View File
@@ -8,7 +8,7 @@ import (
) )
const ( const (
hwtstampTxOn = 1 hwtstampTxOff = 0
hwtstampFilterAll = 1 hwtstampFilterAll = 1
) )
@@ -18,9 +18,23 @@ type hwtstampConfig struct {
rxFilter int32 rxFilter int32
} }
// Temporarily bypassed rather than fatal: the X520 bench card cannot stamp
// all packets, and the BCM diagnostics path needs runs now. Without stamps
// the rate buckets never fill, so the panel's rates read zero; everything
// else still measures.
func checkTimestamping(fd int, ifname string) checkResult {
res := configureTimestamping(fd, ifname)
if res.err != nil {
res.state = "bypassed: " + res.detail()
res.err = nil
res.fixed = true
}
return res
}
// Receive stamping is filtered by protocol and ours is not PTP, so nothing // Receive stamping is filtered by protocol and ours is not PTP, so nothing
// narrower than "all" will see our frames. // narrower than "all" will see our frames.
func checkTimestamping(fd int, ifname string) checkResult { func configureTimestamping(fd int, ifname string) checkResult {
res := checkResult{item: ifname + " hw timestamps"} res := checkResult{item: ifname + " hw timestamps"}
desc := func(c hwtstampConfig) string { desc := func(c hwtstampConfig) string {
return fmt.Sprintf("tx_type=%d rx_filter=%d", c.txType, c.rxFilter) return fmt.Sprintf("tx_type=%d rx_filter=%d", c.txType, c.rxFilter)
@@ -42,20 +56,20 @@ func checkTimestamping(fd int, ifname string) checkResult {
res.err = err res.err = err
return res return res
} }
if have.txType == hwtstampTxOn && have.rxFilter == hwtstampFilterAll { if have.txType == hwtstampTxOff && have.rxFilter == hwtstampFilterAll {
res.state = desc(have) res.state = desc(have)
return res return res
} }
// The ioctl reports back what the driver actually applied, which can be // The ioctl reports back what the driver actually applied, which can be
// narrower than what was asked for. // narrower than what was asked for.
want := hwtstampConfig{txType: hwtstampTxOn, rxFilter: hwtstampFilterAll} want := hwtstampConfig{txType: hwtstampTxOff, rxFilter: hwtstampFilterAll}
if err := hwtstampCall(unix.SIOCSHWTSTAMP, &want); err != nil { if err := hwtstampCall(unix.SIOCSHWTSTAMP, &want); err != nil {
res.err = err res.err = err
res.state = "could not set" res.state = "could not set"
return res return res
} }
if want.txType != hwtstampTxOn || want.rxFilter != hwtstampFilterAll { if want.txType != hwtstampTxOff || want.rxFilter != hwtstampFilterAll {
res.err = fmt.Errorf("driver applied %s instead", desc(want)) res.err = fmt.Errorf("driver applied %s instead", desc(want))
return res return res
} }
@@ -64,13 +78,6 @@ func checkTimestamping(fd int, ifname string) checkResult {
return res return res
} }
func enableTxTimestamps(fd int) error {
return unix.SetsockoptInt(fd, unix.SOL_SOCKET, unix.SO_TIMESTAMPING,
unix.SOF_TIMESTAMPING_TX_HARDWARE|
unix.SOF_TIMESTAMPING_RAW_HARDWARE|
unix.SOF_TIMESTAMPING_OPT_TSONLY)
}
func enableRxTimestamps(fd int) error { func enableRxTimestamps(fd int) error {
return unix.SetsockoptInt(fd, unix.SOL_SOCKET, unix.SO_TIMESTAMPING, return unix.SetsockoptInt(fd, unix.SOL_SOCKET, unix.SO_TIMESTAMPING,
unix.SOF_TIMESTAMPING_RX_HARDWARE|unix.SOF_TIMESTAMPING_RAW_HARDWARE) unix.SOF_TIMESTAMPING_RX_HARDWARE|unix.SOF_TIMESTAMPING_RAW_HARDWARE)
+94 -36
View File
@@ -17,9 +17,22 @@ var (
uiDim = rgb{0x9a, 0xa2, 0xac} uiDim = rgb{0x9a, 0xa2, 0xac}
uiCyan = rgb{0x5c, 0xc8, 0xe0} uiCyan = rgb{0x5c, 0xc8, 0xe0}
uiGreen = rgb{0x6c, 0xdc, 0x86} uiGreen = rgb{0x6c, 0xdc, 0x86}
uiAmber = rgb{0xe8, 0xc4, 0x52}
uiRed = rgb{0xf0, 0x6b, 0x6b} uiRed = rgb{0xf0, 0x6b, 0x6b}
) )
func classColor(c int) rgb {
switch c {
case clsGood:
return uiGreen
case clsWarn:
return uiAmber
case clsBad:
return uiRed
}
return uiDim
}
// Distances between ink, since layout measures a line from the top of a digit // Distances between ink, since layout measures a line from the top of a digit
// to the baseline rather than across a cell with accent and descender slack in // to the baseline rather than across a cell with accent and descender slack in
// it. Values carried over from spacing cells will look too small here. // it. Values carried over from spacing cells will look too small here.
@@ -48,7 +61,7 @@ const (
chipBorder = 2 chipBorder = 2
gridCols = 2 gridCols = 2
chipPadY = spaceGroup chipPadY = step * 3
chipGap = spaceTight chipGap = spaceTight
statRowGap = spaceRow statRowGap = spaceRow
) )
@@ -92,9 +105,9 @@ func newDisplay() (*display, error) {
bold bool bold bool
size float64 size float64
}{ }{
{&d.big, true, 40}, {&d.big, true, 36},
{&d.grid, false, 34}, {&d.grid, false, 30},
{&d.gridB, true, 34}, {&d.gridB, true, 30},
} { } {
face, err := loadFace(spec.bold, spec.size) face, err := loadFace(spec.bold, spec.size)
if err != nil { if err != nil {
@@ -108,22 +121,29 @@ func newDisplay() (*display, error) {
return nil, fmt.Errorf("grid faces disagree on cell width: %d vs %d", return nil, fmt.Errorf("grid faces disagree on cell width: %d vs %d",
d.grid.cellW, d.gridB.cellW) d.grid.cellW, d.gridB.cellW)
} }
now := []int{d.statsH(d.big, 2), d.chipsH()} if err := d.layout(fb.w, fb.h); err != nil {
since := []int{d.statsH(d.gridB, 4), d.countsH(), btnH} fb.close()
return nil, err
}
return d, nil
}
func (d *display) layout(w, h int) error {
now := []int{d.statsH(d.big, 3), d.chipsH()}
since := []int{d.statsH(d.gridB, 4), d.statsH(d.gridB, 1), d.countsH(), btnH}
// One gap for both panels, and vertically the frame is the border alone. // One gap for both panels, and vertically the frame is the border alone.
// Insetting by uiPad as well would add it to the gaps at a panel's ends but // Insetting by uiPad as well would add it to the gaps at a panel's ends but
// not to the ones between blocks, which is not equal spacing however evenly // not to the ones between blocks, which is not equal spacing however evenly
// the remainder is divided. // the remainder is divided.
gaps := len(now) + len(since) + 2 gaps := len(now) + len(since) + 2
spare := fb.h - 2*uiMargin - blockGap - 4*uiBorder - sum(now) - sum(since) spare := h - 2*uiMargin - blockGap - 4*uiBorder - sum(now) - sum(since)
if spare < 0 { if spare < 0 {
fb.close() return fmt.Errorf("panel content is %dpx taller than the screen", -spare)
return nil, fmt.Errorf("panel content is %dpx taller than the screen", -spare)
} }
gap := spare / gaps gap := spare / gaps
inner := fb.w - 2*uiMargin inner := w - 2*uiMargin
nowH := 2*uiBorder + sum(now) + (len(now)+1)*gap nowH := 2*uiBorder + sum(now) + (len(now)+1)*gap
sinceH := 2*uiBorder + sum(since) + (len(since)+1)*gap sinceH := 2*uiBorder + sum(since) + (len(since)+1)*gap
d.nowPanel = rect{uiMargin, uiMargin, inner, nowH} d.nowPanel = rect{uiMargin, uiMargin, inner, nowH}
@@ -137,8 +157,8 @@ func newDisplay() (*display, error) {
w: btnW, w: btnW,
h: btnH, h: btnH,
} }
d.versionSpot = rect{fb.w - versionSpotSide, 0, versionSpotSide, versionSpotSide} d.versionSpot = rect{w - versionSpotSide, 0, versionSpotSide, versionSpotSide}
return d, nil return nil
} }
func sum(hs []int) int { func sum(hs []int) int {
@@ -270,10 +290,10 @@ func (d *display) statsH(vf *textFace, n int) int {
} }
// A last row that does not fill the grid is centred. // A last row that does not fill the grid is centred.
func gridCell(i, n, x, w int) (cx, cw int) { func gridCell(i, n, cols, x, w int) (cx, cw int) {
cw = (w - (gridCols-1)*chipGap) / gridCols cw = (w - (cols-1)*chipGap) / cols
inRow := min(n-(i/gridCols)*gridCols, gridCols) inRow := min(n-(i/cols)*cols, cols)
cx = x + (w-(inRow*cw+(inRow-1)*chipGap))/2 + (i%gridCols)*(cw+chipGap) cx = x + (w-(inRow*cw+(inRow-1)*chipGap))/2 + (i%cols)*(cw+chipGap)
return cx, cw return cx, cw
} }
@@ -284,7 +304,7 @@ func (d *display) stats(vf *textFace, x, w, y int, cells []statCell) int {
if c.value == "" { if c.value == "" {
continue continue
} }
cx, cw := gridCell(i, len(cells), x, w) cx, cw := gridCell(i, len(cells), gridCols, x, w)
cy := y + (i/gridCols)*(d.statPairH(vf)+statRowGap) cy := y + (i/gridCols)*(d.statPairH(vf)+statRowGap)
ly := d.centerIn(vf, cx, cw, cy, c.value, c.col) ly := d.centerIn(vf, cx, cw, cy, c.value, c.col)
d.centerIn(d.grid, cx, cw, ly, c.label, uiDim) d.centerIn(d.grid, cx, cw, ly, c.label, uiDim)
@@ -306,9 +326,10 @@ func (d *display) chipH() int { return d.grid.lineH + 2*chipPadY }
func (d *display) countChipH() int { return d.chipH() + d.gridB.lineH + pairGap } func (d *display) countChipH() int { return d.chipH() + d.gridB.lineH + pairGap }
// The error chips plus the noise cable's, which shares their row grid. // The error chips plus corrected and the noise cable's, which share their
// row grid.
func (d *display) chipsH() int { func (d *display) chipsH() int {
return gridRows(len(errRows)+1)*(d.chipH()+chipGap) - chipGap return gridRows(len(errRows)+2)*(d.chipH()+chipGap) - chipGap
} }
func (d *display) countsH() int { func (d *display) countsH() int {
@@ -317,9 +338,9 @@ func (d *display) countsH() int {
// Outlined by drawing the border colour and sinking a smaller well of // Outlined by drawing the border colour and sinking a smaller well of
// background into it, so both curves get the same antialiasing. // background into it, so both curves get the same antialiasing.
func (d *display) chipAt(i, n, x, w, y, h int, c rgb) (int, int, int) { func (d *display) chipAt(i, n, cols, x, w, y, h int, c rgb) (int, int, int) {
cx, cw := gridCell(i, n, x, w) cx, cw := gridCell(i, n, cols, x, w)
cy := y + (i/gridCols)*(h+chipGap) cy := y + (i/cols)*(h+chipGap)
d.fb.roundRect(cx, cy, cw, h, chipRadius, c) d.fb.roundRect(cx, cy, cw, h, chipRadius, c)
d.fb.roundRect(cx+chipBorder, cy+chipBorder, d.fb.roundRect(cx+chipBorder, cy+chipBorder,
@@ -330,15 +351,22 @@ func (d *display) chipAt(i, n, x, w, y, h int, c rgb) (int, int, int) {
// Whether rather than how many: over a window this short a count changes faster // Whether rather than how many: over a window this short a count changes faster
// than it can be read. The noise chip rides along at the end, presence rather // than it can be read. The noise chip rides along at the end, presence rather
// than health: red is the cable missing, not the cable failing. // than health: red is the cable missing, not the cable failing.
func (d *display) errChips(x, w, y int, e errs, noiseMissing uint64) int { func (d *display) errChips(x, w, y int, e errs, recentCorrected, noiseMissing uint64) int {
n := len(errRows) + 1 n := len(errRows) + 2
for i, r := range errRows { for i, r := range errRows {
c := errColor(r.get(e)) c := errColor(r.get(e))
cx, cw, cy := d.chipAt(i, n, x, w, y, d.chipH(), c) cx, cw, cy := d.chipAt(i, n, gridCols, x, w, y, d.chipH(), c)
d.centerIn(d.grid, cx, cw, cy+chipPadY, r.label, c) d.centerIn(d.grid, cx, cw, cy+chipPadY, r.label, c)
} }
c := errColor(noiseMissing) // Amber rather than red: the phy absorbed these before they cost a frame.
cx, cw, cy := d.chipAt(len(errRows), n, x, w, y, d.chipH(), c) c := uiGreen
if recentCorrected > 0 {
c = uiAmber
}
cx, cw, cy := d.chipAt(len(errRows), n, gridCols, x, w, y, d.chipH(), c)
d.centerIn(d.grid, cx, cw, cy+chipPadY, "corrected", c)
c = errColor(noiseMissing)
cx, cw, cy = d.chipAt(len(errRows)+1, n, gridCols, x, w, y, d.chipH(), c)
d.centerIn(d.grid, cx, cw, cy+chipPadY, "noise", c) d.centerIn(d.grid, cx, cw, cy+chipPadY, "noise", c)
return y + d.chipsH() return y + d.chipsH()
} }
@@ -347,16 +375,26 @@ func (d *display) errCounts(x, w, y int, e errs) int {
for i, r := range errRows { for i, r := range errRows {
n := r.get(e) n := r.get(e)
c := errColor(n) c := errColor(n)
cx, cw, cy := d.chipAt(i, len(errRows), x, w, y, d.countChipH(), c) cx, cw, cy := d.chipAt(i, len(errRows), gridCols, x, w, y, d.countChipH(), c)
ty := d.centerIn(d.gridB, cx, cw, cy+chipPadY, scaleCount(n), c) ty := d.centerIn(d.gridB, cx, cw, cy+chipPadY, scaleCount(n), c)
d.centerIn(d.grid, cx, cw, ty, r.label, c) d.centerIn(d.grid, cx, cw, ty, r.label, c)
} }
return y + d.countsH() return y + d.countsH()
} }
func (d *display) panel(p rect, e errs) (int, int) { // White when the wiring is clean: a measurement rather than a judgment. The
// colours are reserved for the diag finding a fault or a swapped pair.
func metresStat(phy phyDisplay) statCell {
col := uiFg
if phy.metresClass != clsGood {
col = classColor(phy.metresClass)
}
return statCell{phy.metres, "m", col}
}
func (d *display) panel(p rect, bad bool) (int, int) {
fill, edge := uiOKFill, uiOKEdge fill, edge := uiOKFill, uiOKEdge
if e.total() > 0 { if bad {
fill, edge = uiErrFil, uiErrEdg fill, edge = uiErrFil, uiErrEdg
} }
d.fb.rect(p.x, p.y, p.w, p.h, edge) d.fb.rect(p.x, p.y, p.w, p.h, edge)
@@ -374,25 +412,45 @@ func errColor(n uint64) rgb {
return uiRed return uiRed
} }
func (d *display) render(v view, elapsed time.Duration, cable string, noiseMissing uint64) error { func snrStat(phy phyDisplay) statCell {
if !phy.haveSNR {
return statCell{"-", "db margin", uiDim}
}
return statCell{fmt.Sprintf("%+.1f", phy.worstMargin), "db margin",
classColor(snrClass(phy.worstMargin))}
}
// Errors the phy absorbed before they could cost a frame: amber rather than
// red, the cable being stressed rather than failing.
func correctedStat(v uint64) statCell {
col := uiGreen
if v > 0 {
col = uiAmber
}
return statCell{scaleCount(v), "corrected", col}
}
func (d *display) render(v view, elapsed time.Duration, phy phyDisplay, noiseMissing uint64) error {
fb := d.fb fb := d.fb
fb.fill(uiBg) fb.fill(uiBg)
x, w := d.panel(d.nowPanel, v.window) x, w := d.panel(d.nowPanel, v.window.total() > 0)
d.stats(d.big, x, w, d.nowYs[0], []statCell{ d.stats(d.big, x, w, d.nowYs[0], []statCell{
{scaleSI(v.rxGbps * 1e9), "bits/s", uiFg}, {scaleSI(v.rxGbps * 1e9), "bits/s", uiFg},
{scaleSI(v.rxPPS), "packets/s", uiFg}, {scaleSI(v.rxPPS), "packets/s", uiFg},
snrStat(phy),
}) })
d.errChips(x, w, d.nowYs[1], v.window, noiseMissing) d.errChips(x, w, d.nowYs[1], v.window, phy.recent, noiseMissing)
x, w = d.panel(d.sincePanel, v.since) x, w = d.panel(d.sincePanel, v.since.total() > 0 || phy.metresClass == clsBad)
d.stats(d.gridB, x, w, d.sinceYs[0], []statCell{ d.stats(d.gridB, x, w, d.sinceYs[0], []statCell{
{scaleTime(elapsed), "elapsed", uiFg}, {scaleTime(elapsed), "elapsed", uiFg},
{scaleCount(v.rxFrames), "packets", uiFg}, {scaleCount(v.rxFrames), "packets", uiFg},
{scaleCount(v.rxBytes), "bytes", uiFg}, {scaleCount(v.rxBytes), "bytes", uiFg},
{cable, "m", uiFg}, correctedStat(phy.corrected),
}) })
d.errCounts(x, w, d.sinceYs[1], v.since) d.stats(d.gridB, x, w, d.sinceYs[1], []statCell{metresStat(phy)})
d.errCounts(x, w, d.sinceYs[2], v.since)
d.drawResetButton() d.drawResetButton()
return fb.flush() return fb.flush()
+32 -3
View File
@@ -3,8 +3,8 @@ package main
import "testing" import "testing"
func TestGridCellFullRow(t *testing.T) { func TestGridCellFullRow(t *testing.T) {
x0, w0 := gridCell(0, 2, 0, 100) x0, w0 := gridCell(0, 2, gridCols, 0, 100)
x1, w1 := gridCell(1, 2, 0, 100) x1, w1 := gridCell(1, 2, gridCols, 0, 100)
if w0 != w1 { if w0 != w1 {
t.Errorf("cells differ in width: %d vs %d", w0, w1) t.Errorf("cells differ in width: %d vs %d", w0, w1)
} }
@@ -21,8 +21,37 @@ func TestGridCellFullRow(t *testing.T) {
// A last row that does not fill the grid is centred. // A last row that does not fill the grid is centred.
func TestGridCellShortLastRow(t *testing.T) { func TestGridCellShortLastRow(t *testing.T) {
cx, cw := gridCell(2, 3, 0, 100) cx, cw := gridCell(2, 3, gridCols, 0, 100)
if left, right := cx, 100-(cx+cw); left != right { if left, right := cx, 100-(cx+cw); left != right {
t.Errorf("lone cell has %d left and %d right, want centred", left, right) t.Errorf("lone cell has %d left and %d right, want centred", left, right)
} }
} }
// The panel is a fixed 600x1024, so whether everything fits is decidable here
// rather than on the hardware, with enough spare that the gaps stay readable.
func TestPanelFitsScreen(t *testing.T) {
d := &display{}
for _, spec := range []struct {
dst **textFace
bold bool
size float64
}{
{&d.big, true, 40},
{&d.grid, false, 34},
{&d.gridB, true, 34},
} {
face, err := loadFace(spec.bold, spec.size)
if err != nil {
t.Fatal(err)
}
*spec.dst = face
}
if err := d.layout(600, 1024); err != nil {
t.Fatal(err)
}
gap := d.nowYs[0] - (d.nowPanel.y + uiBorder)
t.Logf("panel gap %dpx, since panel ends at %dpx", gap, d.sincePanel.y+d.sincePanel.h)
if gap < spaceTight {
t.Errorf("panel gap is %dpx, want at least %d", gap, spaceTight)
}
}