From be2bf56fa7112c4bbe197e51687327cb99192129 Mon Sep 17 00:00:00 2001 From: flamingcow Date: Mon, 17 Aug 2026 21:29:02 -0700 Subject: [PATCH] =?UTF-8?q?Unplugged-cable=20behavior=20fixed=20end=20to?= =?UTF-8?q?=20end:=20heat=20grid=20rolls=20on=20the=20slot=20boundary=20(v?= =?UTF-8?q?iew=20computes=20the=20newest=20slot=20from=20the=20draw=20cloc?= =?UTF-8?q?k;=20history=20sampled=20every=20frame=20so=20slots=20color=20w?= =?UTF-8?q?ithin=20a=20frame;=20noise=20phase=20displayed=20as=20commanded?= =?UTF-8?q?=20straight=20from=20the=20shared=20clock=20=E2=80=94=20radiati?= =?UTF-8?q?ng=20and=20the=20per-slot=20vote=20deleted),=20SNR=20ghost=20pa?= =?UTF-8?q?nic=20disambiguated=20via=20the=20PMA=201.1=20latch=20(link=20d?= =?UTF-8?q?ropped=20mid-read=20discards=20the=20batch,=20ghost=20on=20a=20?= =?UTF-8?q?proven-up=20link=20still=20dies),=20measure=20skips=20the=2025s?= =?UTF-8?q?=20relink=20wait=20when=20the=20ECD=20verdict=20says=20faulted?= =?UTF-8?q?=20and=20the=20header=20says=20MEASURING=20instead=20of=20GOOD?= =?UTF-8?q?=20while=20a=20diag=20runs,=20RollBall=20mailbox=20deadline=20r?= =?UTF-8?q?esized=20to=2010s=20from=20bench=20measurement=20(<25ms=20linke?= =?UTF-8?q?d,=20~220ms=20no-link,=20>3s=20during=20a=20live=20plug's=20tra?= =?UTF-8?q?ining;=20docs/modules/wiitek=20updated)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/modules/wiitek/README.md | 2 ++ history.go | 29 +++++++++++++---------------- main.go | 6 +++--- noise.go | 19 +++++++++++++------ phy.go | 18 ++++++++++++++---- rollball.go | 24 ++++++++++++++++++++---- ui.go | 23 ++++++++++++++++------- 7 files changed, 81 insertions(+), 40 deletions(-) diff --git a/docs/modules/wiitek/README.md b/docs/modules/wiitek/README.md index 166054f..9ed2669 100644 --- a/docs/modules/wiitek/README.md +++ b/docs/modules/wiitek/README.md @@ -40,6 +40,8 @@ cabletest drives this module through its own Go RollBall client (`phy.go`): modu Not available in safe space: pre-FEC counter, cable length — those live in the µC vendor space that killed the modules. +**Mailbox latency is regime-dependent** — <25 ms per read linked (X520 host), ~220 ms steady with no link (bench-measured, `~/work/phydiag-work/rb_mbox_latency.py`), and beyond 3 s while 10GBASE-T training runs (observed live when a cable plug landed mid-read at bringup). cabletest's mailbox deadline (`rbCmdTimeout`) is sized past a full ~6 s training so a plug event reads busy, not dead. + **Mailbox trap: an orphaned command's completion answers as yours.** If a session dies mid-mailbox-op (killed process), the µC finishes the orphaned command on its own time, leaving CMD=DONE and that command's value in the block. A next session that issues a command and samples status immediately reads the orphan's DONE and value as its own — seen live: a PHY ID 1.2 read answered 0x0006, the orphaned EEE-advert (7.60) value of the killed session, while the very next read was correct. The client therefore never samples status before a full poll gap, reads the value from the same 6-byte block read as the status, and accepts a completion only when the block echoes the issued devad/reg (`phy.go` mbox). The split-transaction python clients always had the pre-sample gap, which is why the field campaign never hit this. ## VCT/cable length: no confirmed-safe path diff --git a/history.go b/history.go index f1dc5a6..6b3431c 100644 --- a/history.go +++ b/history.go @@ -5,10 +5,11 @@ import ( "time" ) -// The panel's recent-history ring: 18 slots of 5 seconds, sampled once a -// second from the same figures the console row prints. Slots are wall-time -// and survive a reset — recently is recently, whatever the counters were -// re-based to — while the since-reset aggregates and fault ages clear. +// The panel's recent-history ring: 18 slots of 5 seconds, sampled every drawn +// frame from the same figures the panel paints, so a slot has data within one +// frame of being born. Slots are wall-time and survive a reset — recently is +// recently, whatever the counters were re-based to — while the since-reset +// aggregates and fault ages clear. const ( histSlots = 18 histSlotSpan = 5 * time.Second @@ -39,15 +40,12 @@ type histSlot struct { rateLow bool haveSNR bool snrMin float64 - noiseOn int - noiseN int } type history struct { mu sync.Mutex start time.Time slots [histSlots]histSlot - seq int64 prevValid bool prevSince errs @@ -80,13 +78,12 @@ func delta(now, prev uint64) uint64 { return now - prev } -func (h *history) sample(now time.Time, v view, phy phyDisplay, nv noiseView, +func (h *history) sample(now time.Time, v view, phy phyDisplay, measuring bool, target float64) { h.mu.Lock() defer h.mu.Unlock() - h.seq = int64(now.Sub(h.start) / histSlotSpan) - s := h.slot(h.seq) + s := h.slot(int64(now.Sub(h.start) / histSlotSpan)) if h.prevValid { for i, c := range faultClasses { @@ -118,10 +115,6 @@ func (h *history) sample(now time.Time, v view, phy phyDisplay, nv noiseView, } } } - s.noiseN++ - if nv.on { - s.noiseOn++ - } } // The wall-time slots stay: recently is recently. Everything judged against @@ -146,12 +139,16 @@ type histView struct { sinceSNRMin float64 } -func (h *history) view() histView { +// The window's newest column is the slot containing now, taken from the draw +// clock rather than the last sample: the grid then rolls on the slot boundary +// itself — the same instant the noise cycle flips. +func (h *history) view(now time.Time) histView { h.mu.Lock() defer h.mu.Unlock() + newest := int64(now.Sub(h.start) / histSlotSpan) var out histView for k := 0; k < histSlots; k++ { - seq := h.seq - int64(histSlots-1-k) + seq := newest - int64(histSlots-1-k) if seq < 0 { continue } diff --git a/main.go b/main.go index ccff08b..d6e4518 100644 --- a/main.go +++ b/main.go @@ -403,8 +403,9 @@ func run(aName, bName string) (err error) { } v := totalView(views) phy, measuring := phyView(diag, modules) + hist.sample(now, v, phy, measuring, target) if err := disp.render(v, now.Sub(start), phy, measuring, - noise.view(), hist.view(), target); err != nil { + noise.view(), hist.view(now), target); err != nil { return err } case now := <-tick.C: @@ -413,8 +414,7 @@ func run(aName, bName string) (err error) { rows[i] = d.displayView() } v := totalView(rows) - phy, measuring := phyView(diag, modules) - hist.sample(now, v, phy, noise.view(), measuring, target) + phy, _ := phyView(diag, modules) for _, m := range modules { for _, n := range m.takeNotes() { fmt.Println(stats.rule(n)) diff --git a/noise.go b/noise.go index 5e8893e..35f85bd 100644 --- a/noise.go +++ b/noise.go @@ -65,6 +65,16 @@ type noisePort struct { frame []byte } +// The cycle is a pure function of the shared slot clock: even slots up — ports +// admin-up and the wire loud, training or frames — odd slots down. Everything +// that shows the phase computes it from the clock through here rather than +// sampling the actuator, whose ioctls lag the boundary. +func slotUp(seq int64) bool { return seq%2 == 0 } + +func noiseUp(now time.Time) bool { + return slotUp(int64(now.Sub(rateEpochStart) / histSlotSpan)) +} + type noiser struct { eps [2]endpoint ports [2]noisePort @@ -73,8 +83,6 @@ type noiser struct { // phase. Latched across the down phase, where the missing carrier is our // own doing and says nothing about the cable. connected atomic.Bool - // The cycle's up phase: ports admin-up and the wire loud, training or frames. - radiating atomic.Bool } func newNoiser() (*noiser, error) { @@ -124,9 +132,9 @@ type noiseView struct { // missing is zero while the cable was there at the last verdict, one while it // was not: the shape the error cells already colour by, so absence paints as -// the fault it is. on is the cycle's phase, presence granted. +// the fault it is. on is the commanded phase, straight from the clock. func (n *noiser) view() noiseView { - v := noiseView{on: n.radiating.Load()} + v := noiseView{on: noiseUp(time.Now())} if !n.connected.Load() { v.missing = 1 } @@ -188,7 +196,7 @@ func (n *noiser) run(done *atomic.Bool) { applied, wasUp, linked := false, false, false for !done.Load() { <-tick.C - up := (time.Since(rateEpochStart)/histSlotSpan)%2 == 0 + up := noiseUp(time.Now()) if !applied || up != wasUp { // A whole up phase with no link is many times the ~1s the wire // needs to train, so at its end the silence is the cable's answer. @@ -196,7 +204,6 @@ func (n *noiser) run(done *atomic.Bool) { n.connected.Store(linked) } n.setLinks(fd, up) - n.radiating.Store(up) applied, wasUp, linked = true, up, false } if !up || !n.bothUp() { diff --git a/phy.go b/phy.go index e807d21..f9a2066 100644 --- a/phy.go +++ b/phy.go @@ -38,7 +38,7 @@ type phyDev interface { } type snrSource interface { - snrMargins() ([4]float64, error) + snrMargins() ([4]float64, bool, error) } // PMA 1.1 latches low, so the first read reports any drop since it was last @@ -137,10 +137,9 @@ func (m *phyModule) poll() error { var margins [4]float64 haveSNR := false if src, ok := m.dev.(snrSource); ok && link { - if margins, err = src.snrMargins(); err != nil { + if margins, haveSNR, err = src.snrMargins(); err != nil { return err } - haveSNR = true } blocks, ber, pcsRaw, err := devPCSLatch(m.dev) if err != nil { @@ -405,7 +404,18 @@ func measureCable(mods []*phyModule, done *atomic.Bool) (cableInfo, error) { if err = devRestartAN(end); err != nil { return c, err } - waitCarrier([2]string{mods[0].dev.name(), mods[1].dev.name()}, done) + // A pair the diag just called faulted is not going to relink; waiting the + // full carrier timeout would hold the verdict hostage for 25 s on an + // unplugged cable. + healthy := true + for _, v := range c.ecd.verdicts { + if v != pairOK { + healthy = false + } + } + if healthy { + waitCarrier([2]string{mods[0].dev.name(), mods[1].dev.name()}, done) + } for i, m := range mods { b, ok := m.dev.(*bcm) if !ok { diff --git a/rollball.go b/rollball.go index 1714c1d..fb763d1 100644 --- a/rollball.go +++ b/rollball.go @@ -25,8 +25,10 @@ const ( rbReadDelayUs = 500 rbCmdPoll = 20 * time.Millisecond - // Matches the BCM allowance for a handler frozen by 10GBASE-T training. - rbCmdTimeout = 3 * time.Second + // Bench-measured: <25 ms linked, ~220 ms steady with no link, and >3 s + // while a live plug event's training ran — sized past a full ~6 s + // training, so a cable plugged mid-read reads busy, not dead. + rbCmdTimeout = 10 * time.Second rbPHYIDHi uint16 = 0x002B rbPHYIDLo uint16 = 0x0BF4 @@ -168,7 +170,12 @@ func (r *rollball) identify() (ident string, err error) { return } -func (r *rollball) snrMargins() (out [4]float64, err error) { +// An untrained PHY honestly serves 0x0000 here, so an out-of-band value is +// only a ghost if the link stayed up across the reads. The caller drained the +// PMA 1.1 latch when it checked the link, so one latched read now answers +// that: a drop since means the unplug race — discard the batch, ok false, and +// the next poll reads the link down. +func (r *rollball) snrMargins() (out [4]float64, ok bool, err error) { r.exec(func() { for i := range out { var v uint16 @@ -177,10 +184,19 @@ func (r *rollball) snrMargins() (out [4]float64, err error) { } m := (float64(v) - 0x8000) / 10 if m < rbGhostLow || m > rbGhostHigh { - panic(fmt.Sprintf("%s: ghost SNR margin %.1f dB (1.%d=%#04x)", r.ifname, m, 133+i, v)) + var link uint16 + if link, err = r.mdioRead(1, 1); err != nil { + return + } + if link&0x0004 == 0 { + return + } + panic(fmt.Sprintf("%s: ghost SNR margin %.1f dB (1.%d=%#04x) with the link up", + r.ifname, m, 133+i, v)) } out[i] = m } + ok = true }) return } diff --git a/ui.go b/ui.go index 9ff6039..9dd7e65 100644 --- a/ui.go +++ b/ui.go @@ -326,8 +326,10 @@ func (d *display) heatCell(rowY, k int, c rgb) { // The verdict names the newest thing wrong at any horizon: the cable's own // fault first, then the most recent traffic fault with its age, and only a -// clean record reads GOOD. -func (d *display) drawHeader(v view, phy phyDisplay, hv histView, now time.Time) { +// clean record reads GOOD. While a measure runs the tester is asserting +// nothing, and says so instead of GOOD. +func (d *display) drawHeader(v view, phy phyDisplay, hv histView, measuring bool, + now time.Time) { good := true word, sub := "GOOD", []seg{{d.text, "no faults", uiInk2}} if i, age, ok := hv.newestFault(now); ok { @@ -349,13 +351,18 @@ func (d *display) drawHeader(v view, phy phyDisplay, hv histView, now time.Time) if !good { col = uiCrit } + if measuring { + word, sub, col = "MEASURING", []seg{{d.text, "cable diag running", uiInk2}}, uiMuted + } rx, ry := uiMargin, headerTop d.fb.roundRect(rx, ry, ringD, ringD, ringD/2, col) d.fb.roundRect(rx+ringEdge, ry+ringEdge, ringD-2*ringEdge, ringD-2*ringEdge, ringD/2-ringEdge, uiBg) - if good { + switch { + case measuring: + case good: d.check(rx+ringD/2, ry+ringD/2, 44, col) - } else { + default: d.cross(rx+ringD/2, ry+ringD/2, 40, col) } @@ -476,11 +483,13 @@ func (d *display) drawRows(v view, phy phyDisplay, measuring bool, nv noiseView, } else { d.colText(d.text, nowR, y, state, uiInk2) } + // The lane shows the commanded phase, a pure function of each + // slot's parity; only slots the run never lived through are unknown. for k, s := range hv.slots { c := uiCellNA - if s.sampled && s.noiseN > 0 { + if s.sampled { c = uiNoise0 - if 2*s.noiseOn >= s.noiseN { + if slotUp(s.seq) { c = uiNoise } } @@ -548,7 +557,7 @@ func (d *display) render(v view, elapsed time.Duration, phy phyDisplay, fb := d.fb fb.fill(uiBg) - d.drawHeader(v, phy, hv, time.Now()) + d.drawHeader(v, phy, hv, measuring, time.Now()) d.hairline(d.headerH - 1) d.drawColHeaders() d.drawRows(v, phy, measuring, nv, hv, target)