Files
bee/audit/internal/platform/pcie_link_check.go
Mikhail ChusavitinandClaude Sonnet 5 366c1d5f29 fix(pcie): compare retrain verdict against peer-capped max speed
The downstream-capability adjustment lowered f.MaxSpeed/f.MaxWidth to the
rate the endpoint supports, but the Degraded verdict still compared the
retrained speed against the stale local maxSpeed holding the bridge's own
uncapped capability. A Gen5 root port feeding a Gen4 HBA (or a Gen2 NIC)
was flagged DEGRADED even though the link ran at the fastest rate the pair
supports, failing the PCIe link check on healthy hardware.

Keep the local maxSpeed in sync with the peer-capped f.MaxSpeed so both
the pre-retrain and post-retrain verdicts use the real target.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mx6AvuXcJNT7jx3jjWw4hc
2026-08-28 11:30:57 +03:00

507 lines
17 KiB
Go

package platform
import (
"context"
"fmt"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
"bee/audit/internal/collector"
)
// pcieLinkRetrainTimeout bounds how long we wait for a device to finish
// retraining (clear the Link Status "Link Training" bit) before giving up
// and reading whatever speed it settled on anyway. The PCIe spec allows up
// to 100ms for Gen1-3 and longer for higher generations with equalization;
// this is generous headroom above that.
const pcieLinkRetrainTimeout = 2 * time.Second
// pcieLinkFinding is one device's before/after link-speed retrain result.
type pcieLinkFinding struct {
BDF string
Description string
VendorID string
ClassCode string
IsGPU bool
GPUVendor string // "nvidia" or "amd", only set when IsGPU
Skipped string // non-empty reason this device wasn't retrained
BeforeSpeed string
AfterSpeed string
MaxSpeed string
PortMaxSpeed string // bridge's own capability when MaxSpeed is limited by its downstream peer
Width int
MaxWidth int
PortMaxWidth int // bridge's own capability when MaxWidth is limited by its downstream peer
Degraded bool
NotPresent bool // true when the slot trained to zero lanes: nothing is plugged in (or it fell off the bus), not a speed regression
}
// RunPCIeLinkCheckPack forces every enabled PCIe device to retrain its link
// (via the PCIe Link Control register's spec-defined Retrain Link bit — see
// PCIe base spec, Link Control Register, bit 5) and compares the
// post-retrain negotiated speed against the device's own reported maximum.
//
// This exists because a plain idle-time sysfs read of current_link_speed is
// not a reliable fault signal: NVIDIA GPUs (and other devices with runtime
// power management) deliberately downclock their PCIe link to save power
// while idle, which is indistinguishable from a real degraded slot/riser/
// cable without either sustained traffic or a forced retrain. Forcing a
// retrain sidesteps needing a device-specific load generator (bee-gpu-burn
// exists for GPUs; nothing plays that role for NICs, HBAs, or PCIe
// switches) — retraining is a PCIe-spec mechanism every endpoint supports,
// so this one check covers every PCIe device in the machine, not just
// GPUs. See bible-local/decisions/2026-08-24-pcie-gpu-gen1-idle-warning-unresolved.md
// for the history of narrower attempts that didn't generalize.
//
// Disabled devices (sysfs enable==0 — e.g. PCIe fabric-management endpoints
// the kernel never activates, per the 2026-06-12 decision) are left alone:
// they carry no data traffic, so there is nothing to verify and no reason
// to poke them.
func (s *System) RunPCIeLinkCheckPack(ctx context.Context, baseDir string, logFunc func(string)) (string, error) {
if ctx == nil {
ctx = context.Background()
}
if baseDir == "" {
baseDir = "/var/log/bee-sat"
}
ts := time.Now().UTC().Format("20060102-150405")
runDir := filepath.Join(baseDir, "pcie-link-"+ts)
if err := os.MkdirAll(runDir, 0755); err != nil {
return "", err
}
verboseLog := filepath.Join(runDir, "verbose.log")
bdfs, err := listPCIDeviceBDFs()
if err != nil {
return "", fmt.Errorf("list PCI devices: %w", err)
}
var findings []pcieLinkFinding
for _, bdf := range bdfs {
if logFunc != nil {
logFunc(fmt.Sprintf("=== %s ===", bdf))
}
f := retrainAndSamplePCIeDevice(ctx, verboseLog, bdf, logFunc)
findings = append(findings, f)
}
summary := renderPCIeLinkCheckSummary(findings)
if err := os.WriteFile(filepath.Join(runDir, "summary.txt"), []byte(summary), 0644); err != nil {
return "", err
}
report := renderPCIeLinkCheckReport(findings)
if err := os.WriteFile(filepath.Join(runDir, "pcie-link-report.txt"), []byte(report), 0644); err != nil {
return "", err
}
return runDir, nil
}
// listPCIDeviceBDFs returns every BDF under /sys/bus/pci/devices, sorted for
// deterministic report ordering.
func listPCIDeviceBDFs() ([]string, error) {
entries, err := os.ReadDir("/sys/bus/pci/devices")
if err != nil {
return nil, err
}
bdfs := make([]string, 0, len(entries))
for _, e := range entries {
bdfs = append(bdfs, e.Name())
}
sort.Strings(bdfs)
return bdfs, nil
}
func retrainAndSamplePCIeDevice(ctx context.Context, verboseLog, bdf string, logFunc func(string)) pcieLinkFinding {
f := pcieLinkFinding{BDF: bdf}
vendor, _ := readPCIeSysfsHex(bdf, "vendor")
class, _ := readPCIeSysfsHex(bdf, "class")
f.VendorID = vendor
f.ClassCode = class
f.IsGPU, f.GPUVendor = classifyGPUFromVendorClass(vendor, class)
f.Description = pcieDeviceDescription(ctx, verboseLog, bdf, logFunc)
if enabled, ok := readPCIeSysfsInt(bdf, "enable"); ok && enabled == 0 {
f.Skipped = "device disabled (no data traffic; link state has no operational impact)"
return f
}
before, beforeOK := readPCIeSysfsString(bdf, "current_link_speed")
maxSpeed, maxOK := readPCIeSysfsString(bdf, "max_link_speed")
width, _ := readPCIeSysfsInt(bdf, "current_link_width")
maxWidth, _ := readPCIeSysfsInt(bdf, "max_link_width")
f.BeforeSpeed = before
f.MaxSpeed = maxSpeed
f.MaxWidth = maxWidth
if !beforeOK || !maxOK {
f.Skipped = "no PCIe link-speed attributes in sysfs (not a link-trained endpoint)"
return f
}
if width == 0 {
// A downstream switch/root port with nothing seated reads zero
// trained lanes even before we touch it. Plenty of legitimate
// configs leave slots like this unpopulated (not every server ships
// every NIC/riser slot filled), so this is not by itself evidence
// of anything wrong — retraining an empty slot can't produce a
// meaningful speed reading, and there's no baseline here to say
// "this used to have a card." Skip it exactly like a disabled
// device: nothing to verify, no reason to fail the run over it.
f.NotPresent = true
f.Skipped = "no device present downstream (empty slot/riser — nothing to retrain)"
return f
}
// A bridge/root port reports its own maximum capability in sysfs, not
// the maximum mutually supported by the device at the other end of the
// link. Comparing a Gen4 x16 root port directly with a Gen3 x8 or Gen2
// x4 endpoint therefore produces a false degradation even though the
// link is running at the fastest rate the endpoint supports. The child
// device is tested separately, so use its advertised capability to
// calculate the real target for this bridge-side view of the same link.
if isPCIeBridgeClass(class) {
if peerSpeed, peerWidth, ok := downstreamPCIeLinkCapability(bdf); ok {
f.PortMaxSpeed = f.MaxSpeed
f.PortMaxWidth = f.MaxWidth
f.MaxSpeed = minPCIeLinkSpeed(f.MaxSpeed, peerSpeed)
f.MaxWidth = minPositiveInt(f.MaxWidth, peerWidth)
// The degradation verdict below compares against maxSpeed, so
// it has to track the peer-capped target too — otherwise a
// bridge whose port out-specs its downstream device (e.g. a
// Gen5 root port feeding a Gen4 HBA) is flagged DEGRADED even
// though the link is at the fastest rate the pair supports.
maxSpeed = f.MaxSpeed
}
}
if err := retrainPCIeLink(ctx, verboseLog, bdf, logFunc); err != nil {
f.Skipped = "retrain failed: " + err.Error()
f.AfterSpeed = before
f.Width = width
// before/maxSpeed are already normalized "GenN" labels (see
// readPCIeSysfsString) — compare directly, don't re-normalize.
f.Degraded = before != maxSpeed
return f
}
after, _ := readPCIeSysfsString(bdf, "current_link_speed")
widthAfter, _ := readPCIeSysfsInt(bdf, "current_link_width")
if widthAfter == 0 {
// The device answered before the retrain but is gone immediately
// after it (fell off the bus mid-check) — unlike the pre-retrain
// case above, this had a live link a moment ago, so it's worth
// surfacing rather than silently skipping.
f.AfterSpeed = after
f.Width = widthAfter
f.NotPresent = true
f.Degraded = true
return f
}
f.AfterSpeed = after
f.Width = widthAfter
f.Degraded = after != maxSpeed
return f
}
// retrainPCIeLink sets the Retrain Link bit (bit 5) of the PCI Express
// Capability's Link Control register via setpci, then polls the Link
// Status register's Link Training bit (bit 11) until it clears or
// pcieLinkRetrainTimeout elapses.
func retrainPCIeLink(ctx context.Context, verboseLog, bdf string, logFunc func(string)) error {
linkCtrlOut, err := runSATCommandCtx(ctx, verboseLog, "setpci-read-"+bdf,
[]string{"setpci", "-s", bdf, "CAP_EXP+0x10.w"}, nil, logFunc)
if err != nil {
return fmt.Errorf("read Link Control: %w", err)
}
cur, err := strconv.ParseUint(strings.TrimSpace(string(linkCtrlOut)), 16, 16)
if err != nil {
return fmt.Errorf("parse Link Control %q: %w", linkCtrlOut, err)
}
const retrainLinkBit = 0x0020
newVal := uint16(cur) | retrainLinkBit
if _, err := runSATCommandCtx(ctx, verboseLog, "setpci-retrain-"+bdf,
[]string{"setpci", "-s", bdf, fmt.Sprintf("CAP_EXP+0x10.w=%04x", newVal)}, nil, logFunc); err != nil {
return fmt.Errorf("write Retrain Link bit: %w", err)
}
const linkTrainingBit = 0x0800
deadline := time.Now().Add(pcieLinkRetrainTimeout)
for time.Now().Before(deadline) {
statusOut, err := runSATCommandCtx(ctx, verboseLog, "setpci-status-"+bdf,
[]string{"setpci", "-s", bdf, "CAP_EXP+0x12.w"}, nil, nil)
if err == nil {
if status, perr := strconv.ParseUint(strings.TrimSpace(string(statusOut)), 16, 16); perr == nil {
if status&linkTrainingBit == 0 {
return nil
}
}
}
time.Sleep(50 * time.Millisecond)
}
// Timed out waiting for training to clear; the caller still samples
// whatever speed sysfs reports, which is the honest answer either way.
return nil
}
func pcieDeviceDescription(ctx context.Context, verboseLog, bdf string, logFunc func(string)) string {
out, err := runSATCommandCtx(ctx, verboseLog, "lspci-"+bdf, []string{"lspci", "-s", bdf}, nil, logFunc)
if err != nil {
return ""
}
line := strings.TrimSpace(string(out))
if idx := strings.Index(line, "\n"); idx >= 0 {
line = line[:idx]
}
if idx := strings.Index(line, " "); idx >= 0 {
return strings.TrimSpace(line[idx+1:])
}
return line
}
func readPCIeSysfsString(bdf, attr string) (string, bool) {
raw, err := os.ReadFile(filepath.Join("/sys/bus/pci/devices", bdf, attr))
if err != nil {
return "", false
}
v := strings.TrimSpace(string(raw))
if v == "" {
return "", false
}
return collector.NormalizePCILinkSpeed(v), true
}
func readPCIeSysfsInt(bdf, attr string) (int, bool) {
raw, err := os.ReadFile(filepath.Join("/sys/bus/pci/devices", bdf, attr))
if err != nil {
return 0, false
}
v, err := strconv.Atoi(strings.TrimSpace(string(raw)))
if err != nil || v < 0 {
return 0, false
}
return v, true
}
func readPCIeSysfsHex(bdf, attr string) (string, bool) {
raw, err := os.ReadFile(filepath.Join("/sys/bus/pci/devices", bdf, attr))
if err != nil {
return "", false
}
return strings.TrimSpace(string(raw)), true
}
// isPCIeBridgeClass matches PCI class 0x0604xx (PCI-to-PCI bridge). These
// functions describe the upstream side of a downstream link, so their own
// max_link_* values must be capped by the peer's capability.
func isPCIeBridgeClass(classHex string) bool {
c := strings.TrimPrefix(strings.ToLower(strings.TrimSpace(classHex)), "0x")
return len(c) >= 4 && c[:4] == "0604"
}
// downstreamPCIeLinkCapability returns the strongest capability advertised
// by a bridge's immediate child functions. In sysfs those functions are
// direct entries below the bridge device directory. Multifunction devices
// expose several children for one physical link; taking the strongest values
// avoids understating the link because one auxiliary function omitted data.
func downstreamPCIeLinkCapability(bdf string) (speed string, width int, ok bool) {
bridgeDir := filepath.Join("/sys/bus/pci/devices", bdf)
return downstreamPCIeLinkCapabilityAt(bridgeDir)
}
func downstreamPCIeLinkCapabilityAt(bridgeDir string) (speed string, width int, ok bool) {
entries, err := os.ReadDir(bridgeDir)
if err != nil {
return "", 0, false
}
for _, entry := range entries {
if !isFullPCIBDF(entry.Name()) {
continue
}
childDir := filepath.Join(bridgeDir, entry.Name())
rawSpeed, speedErr := os.ReadFile(filepath.Join(childDir, "max_link_speed"))
if speedErr != nil {
continue
}
childSpeed := collector.NormalizePCILinkSpeed(strings.TrimSpace(string(rawSpeed)))
if pcieGeneration(childSpeed) > pcieGeneration(speed) {
speed = childSpeed
}
if rawWidth, widthErr := os.ReadFile(filepath.Join(childDir, "max_link_width")); widthErr == nil {
if childWidth, parseErr := strconv.Atoi(strings.TrimSpace(string(rawWidth))); parseErr == nil && childWidth > width {
width = childWidth
}
}
ok = true
}
return speed, width, ok && speed != ""
}
func isFullPCIBDF(s string) bool {
if len(s) != len("0000:00:00.0") || s[4] != ':' || s[7] != ':' || s[10] != '.' {
return false
}
for i, r := range s {
if i == 4 || i == 7 || i == 10 {
continue
}
if !((r >= '0' && r <= '9') || (r >= 'a' && r <= 'f') || (r >= 'A' && r <= 'F')) {
return false
}
}
return true
}
func minPCIeLinkSpeed(a, b string) string {
ga, gb := pcieGeneration(a), pcieGeneration(b)
switch {
case ga == 0:
return b
case gb == 0 || ga <= gb:
return a
default:
return b
}
}
func pcieGeneration(speed string) int {
v := strings.TrimPrefix(strings.TrimSpace(speed), "Gen")
gen, _ := strconv.Atoi(v)
return gen
}
func minPositiveInt(a, b int) int {
switch {
case a <= 0:
return b
case b <= 0 || a <= b:
return a
default:
return b
}
}
// classifyGPUFromVendorClass reports whether a device is a GPU die itself
// (PCI base class 0x03 — Display Controller — under NVIDIA/AMD's vendor
// ID), as opposed to a same-vendor companion device (NIC, storage
// controller, NVLink bridge) that shares the GPU's PCI vendor ID. Class-code
// based, not name-substring based, per the same reasoning as
// collector.IsGPUClass.
func classifyGPUFromVendorClass(vendorHex, classHex string) (isGPU bool, vendor string) {
v := strings.TrimPrefix(strings.ToLower(strings.TrimSpace(vendorHex)), "0x")
c := strings.TrimPrefix(strings.ToLower(strings.TrimSpace(classHex)), "0x")
if len(c) < 2 || c[:2] != "03" {
return false, ""
}
switch v {
case "10de":
return true, "nvidia"
case "1002":
return true, "amd"
default:
return false, ""
}
}
func renderPCIeLinkCheckSummary(findings []pcieLinkFinding) string {
var b strings.Builder
fmt.Fprintf(&b, "run_at_utc=%s\n", time.Now().UTC().Format(time.RFC3339))
fmt.Fprintf(&b, "devices_tested=%d\n", len(findings))
gpuStatus := map[string]string{} // vendor -> OK/FAILED
otherDegraded := 0
otherTested := 0
anyDegraded := false
for _, f := range findings {
if f.Skipped != "" && f.AfterSpeed == "" {
continue
}
if f.IsGPU {
if _, ok := gpuStatus[f.GPUVendor]; !ok {
gpuStatus[f.GPUVendor] = "OK"
}
if f.Degraded {
gpuStatus[f.GPUVendor] = "FAILED"
anyDegraded = true
}
continue
}
otherTested++
if f.Degraded {
otherDegraded++
anyDegraded = true
}
}
for _, vendor := range []string{"nvidia", "amd"} {
if status, ok := gpuStatus[vendor]; ok {
fmt.Fprintf(&b, "gpu_%s_status=%s\n", vendor, status)
}
}
fmt.Fprintf(&b, "other_devices_tested=%d\n", otherTested)
fmt.Fprintf(&b, "other_devices_degraded=%d\n", otherDegraded)
if otherTested > 0 {
if otherDegraded > 0 {
fmt.Fprintln(&b, "other_status=FAILED")
} else {
fmt.Fprintln(&b, "other_status=OK")
}
}
if anyDegraded {
fmt.Fprintln(&b, "overall_status=FAILED")
var reasons []string
for _, f := range findings {
if !f.Degraded {
continue
}
if f.NotPresent {
reasons = append(reasons, fmt.Sprintf("%s (%s): no device detected downstream (link down / empty slot or riser, capable of %s)",
f.BDF, nonEmptyOr(f.Description, "unknown device"), f.MaxSpeed))
continue
}
reasons = append(reasons, fmt.Sprintf("%s (%s): retrained to %s, capable of %s",
f.BDF, nonEmptyOr(f.Description, "unknown device"), f.AfterSpeed, f.MaxSpeed))
}
fmt.Fprintf(&b, "warnings=%s\n", strings.Join(reasons, "; "))
} else {
fmt.Fprintln(&b, "overall_status=OK")
}
return b.String()
}
func renderPCIeLinkCheckReport(findings []pcieLinkFinding) string {
var b strings.Builder
line := strings.Repeat("=", 80)
b.WriteString(line + "\n")
b.WriteString("PCIe Link Retrain Check\n")
b.WriteString(line + "\n\n")
for _, f := range findings {
fmt.Fprintf(&b, "%s %s\n", f.BDF, nonEmptyOr(f.Description, "(unknown device)"))
if f.Skipped != "" && f.AfterSpeed == "" {
fmt.Fprintf(&b, " skipped: %s\n", f.Skipped)
continue
}
verdict := "OK"
switch {
case f.NotPresent:
verdict = "FELL OFF BUS"
case f.Degraded:
verdict = "DEGRADED"
}
fmt.Fprintf(&b, " %s: before=%s after=%s max=%s width=%d/%d",
verdict, f.BeforeSpeed, f.AfterSpeed, f.MaxSpeed, f.Width, f.MaxWidth)
if f.PortMaxSpeed != "" && (f.PortMaxSpeed != f.MaxSpeed || f.PortMaxWidth != f.MaxWidth) {
fmt.Fprintf(&b, " (port capability %s x%d, limited by downstream device)", f.PortMaxSpeed, f.PortMaxWidth)
}
fmt.Fprintln(&b)
if f.Skipped != "" {
fmt.Fprintf(&b, " note: %s\n", f.Skipped)
}
}
return b.String()
}