fix(agent): SKIP_GPU excludes GPU hwmon from temperatures and fans (#2313)

This commit is contained in:
Miłosz Kolber
2026-09-24 19:59:52 +02:00
committed by GitHub
parent c21412f45d
commit 6141b15f03
7 changed files with 336 additions and 8 deletions

View File

@@ -252,7 +252,11 @@ func (a *Agent) gatherStats(options common.DataRequestOptions) *system.CombinedD
// Start initializes and starts the agent with optional WebSocket connection // Start initializes and starts the agent with optional WebSocket connection
func (a *Agent) Start(serverOptions ServerOptions) error { func (a *Agent) Start(serverOptions ServerOptions) error {
a.keys = serverOptions.Keys a.keys = serverOptions.Keys
return a.connectionManager.Start(serverOptions) err := a.connectionManager.Start(serverOptions)
if err != nil {
a.cleanupSensorShadow()
}
return err
} }
func (a *Agent) getFingerprint() string { func (a *Agent) getFingerprint() string {

View File

@@ -155,6 +155,7 @@ func (c *ConnectionManager) stop() error {
_ = c.agent.StopServer() _ = c.agent.StopServer()
c.agent.monitorManager.Stop() c.agent.monitorManager.Stop()
c.closeWebSocket() c.closeWebSocket()
c.agent.cleanupSensorShadow()
return health.CleanUp() return health.CleanUp()
} }

View File

@@ -12,7 +12,7 @@ import (
) )
type fanSensor struct { type fanSensor struct {
key, path string key, path, chip string
} }
var getFanSensors = newFanSensorCache(hwmonRoot) var getFanSensors = newFanSensorCache(hwmonRoot)
@@ -34,6 +34,10 @@ func (a *Agent) updateFans(systemStats *system.Stats) {
slog.Debug("Error reading fans", "err", err) slog.Debug("Error reading fans", "err", err)
return return
} }
// Filter before reading fan*_input: each read can wake an idle GPU.
if a.sensorConfig != nil && a.sensorConfig.skipGPU {
sensors = filterGpuFans(sensors)
}
fans := readFanSensors(sensors) fans := readFanSensors(sensors)
if len(fans) == 0 { if len(fans) == 0 {
return return
@@ -100,7 +104,7 @@ func discoverHwmonFans(root string) ([]fanSensor, error) {
if label != "" { if label != "" {
key = chipName + "_" + label key = chipName + "_" + label
} }
sensors = append(sensors, fanSensor{key, inputPath}) sensors = append(sensors, fanSensor{key, inputPath, chipName})
} }
} }
return sensors, nil return sensors, nil
@@ -115,3 +119,15 @@ func readFanSensors(sensors []fanSensor) map[string]uint16 {
} }
return fans return fans
} }
// filterGpuFans drops GPU chips without touching the shared cache backing array.
func filterGpuFans(sensors []fanSensor) []fanSensor {
kept := make([]fanSensor, 0, len(sensors))
for _, sensor := range sensors {
if isGpuChipName(sensor.chip) {
continue
}
kept = append(kept, sensor)
}
return kept
}

View File

@@ -103,3 +103,20 @@ func TestFanDiscoveryCache(t *testing.T) {
fans = readFanSensors(sensors) fans = readFanSensors(sensors)
assert.Equal(t, map[string]uint16{"chip_fan1": 1200}, fans) assert.Equal(t, map[string]uint16{"chip_fan1": 1200}, fans)
} }
func TestFilterGpuFans(t *testing.T) {
root := t.TempDir()
writeFile(t, filepath.Join(root, "hwmon0", "name"), "xe\n")
writeFile(t, filepath.Join(root, "hwmon0", "fan1_input"), "1200\n")
writeFile(t, filepath.Join(root, "hwmon1", "name"), "nct6798\n")
writeFile(t, filepath.Join(root, "hwmon1", "fan1_input"), "800\n")
discovered, err := discoverHwmonFans(root)
require.NoError(t, err)
require.Len(t, discovered, 2)
filtered := filterGpuFans(discovered)
require.Len(t, filtered, 1)
assert.Equal(t, "nct6798_fan1", filtered[0].key)
assert.Len(t, discovered, 2)
}

View File

@@ -454,8 +454,8 @@ func (gm *GPUManager) storeSnapshot(id string, gpu *system.GPUData, cacheKey uin
// It only reports capability presence and does not apply policy decisions. // It only reports capability presence and does not apply policy decisions.
func (gm *GPUManager) discoverGpuCapabilities() gpuCapabilities { func (gm *GPUManager) discoverGpuCapabilities() gpuCapabilities {
caps := gpuCapabilities{ caps := gpuCapabilities{
hasAmdSysfs: gm.hasAmdSysfs(), hasAmdSysfs: gm.hasAmdSysfs(),
hasXe: gm.hasXe(), hasXe: gm.hasXe(),
hasIntelSysfs: gm.hasIntelSysfs(), hasIntelSysfs: gm.hasIntelSysfs(),
} }
if _, err := exec.LookPath(nvidiaSmiCmd); err == nil { if _, err := exec.LookPath(nvidiaSmiCmd); err == nil {
@@ -750,9 +750,36 @@ func (gm *GPUManager) resolveLegacyCollectorPriority(caps gpuCapabilities) []col
return priorities return priorities
} }
// gpuHwmonChips are hwmon chip names belonging to GPUs. Sensor reads on some
// of these drivers (notably Intel Xe, where each read is a runtime PM resume)
// wake the card, so SKIP_GPU must avoid touching them, not just hide them.
var gpuHwmonChips = []string{"xe", "i915", "amdgpu", "radeon", "nvidia", "nouveau"}
func isGpuChipName(name string) bool {
name = strings.ToLower(strings.TrimSpace(name))
for _, chip := range gpuHwmonChips {
if name == chip {
return true
}
}
return false
}
// SensorKeys are "<chip>" or "<chip>_<label>".
func isGpuSensorKey(key string) bool {
key = strings.ToLower(strings.TrimSpace(key))
for _, chip := range gpuHwmonChips {
if key == chip || strings.HasPrefix(key, chip+"_") {
return true
}
}
return false
}
// NewGPUManager creates and initializes a new GPUManager // NewGPUManager creates and initializes a new GPUManager
func NewGPUManager() (*GPUManager, error) { func NewGPUManager() (*GPUManager, error) {
if skipGPU, _ := utils.GetEnv("SKIP_GPU"); skipGPU == "true" { if skipGPU, _ := utils.GetEnv("SKIP_GPU"); skipGPU == "true" {
slog.Info("SKIP_GPU enabled, skipping GPU monitoring (collectors, temperatures, and fans)")
return nil, nil return nil, nil
} }
var gm GPUManager var gm GPUManager

View File

@@ -5,7 +5,9 @@ import (
"errors" "errors"
"fmt" "fmt"
"log/slog" "log/slog"
"os"
"path" "path"
"path/filepath"
"runtime" "runtime"
"strconv" "strconv"
"strings" "strings"
@@ -32,6 +34,8 @@ type SensorConfig struct {
isBlacklist bool isBlacklist bool
hasWildcards bool hasWildcards bool
skipCollection bool skipCollection bool
skipGPU bool
sensorShadow string
firstRun bool firstRun bool
} }
@@ -41,13 +45,14 @@ func (a *Agent) newSensorConfig() *SensorConfig {
sensorsEnvVal, sensorsSet := utils.GetEnv("SENSORS") sensorsEnvVal, sensorsSet := utils.GetEnv("SENSORS")
skipCollection := sensorsSet && sensorsEnvVal == "" skipCollection := sensorsSet && sensorsEnvVal == ""
sensorsTimeout, _ := utils.GetEnv("SENSORS_TIMEOUT") sensorsTimeout, _ := utils.GetEnv("SENSORS_TIMEOUT")
skipGPU, _ := utils.GetEnv("SKIP_GPU")
return a.newSensorConfigWithEnv(primarySensor, sysSensors, sensorsEnvVal, sensorsTimeout, skipCollection) return a.newSensorConfigWithEnv(primarySensor, sysSensors, sensorsEnvVal, sensorsTimeout, skipCollection, skipGPU == "true")
} }
// newSensorConfigWithEnv creates a SensorConfig with the provided environment variables // newSensorConfigWithEnv creates a SensorConfig with the provided environment variables
// sensorsSet indicates if the SENSORS environment variable was explicitly set (even to empty string) // sensorsSet indicates if the SENSORS environment variable was explicitly set (even to empty string)
func (a *Agent) newSensorConfigWithEnv(primarySensor, sysSensors, sensorsEnvVal, sensorsTimeout string, skipCollection bool) *SensorConfig { func (a *Agent) newSensorConfigWithEnv(primarySensor, sysSensors, sensorsEnvVal, sensorsTimeout string, skipCollection, skipGPU bool) *SensorConfig {
timeout := 2 * time.Second timeout := 2 * time.Second
if sensorsTimeout != "" { if sensorsTimeout != "" {
if d, err := time.ParseDuration(sensorsTimeout); err == nil { if d, err := time.ParseDuration(sensorsTimeout); err == nil {
@@ -62,6 +67,7 @@ func (a *Agent) newSensorConfigWithEnv(primarySensor, sysSensors, sensorsEnvVal,
primarySensor: primarySensor, primarySensor: primarySensor,
timeout: timeout, timeout: timeout,
skipCollection: skipCollection, skipCollection: skipCollection,
skipGPU: skipGPU,
firstRun: true, firstRun: true,
sensors: make(map[string]struct{}), sensors: make(map[string]struct{}),
} }
@@ -73,6 +79,19 @@ func (a *Agent) newSensorConfigWithEnv(primarySensor, sysSensors, sensorsEnvVal,
common.EnvKey, common.EnvMap{common.HostSysEnvKey: sysSensors}, common.EnvKey, common.EnvMap{common.HostSysEnvKey: sysSensors},
) )
} }
if skipGPU && runtime.GOOS == "linux" {
// gopsutil reads every temp*_input before results can be filtered, so
// point it at a shadow tree built from the effective sysfs root instead.
if shadow, err := buildNonGpuSysShadow(effectiveSysRoot(config.context)); err == nil {
slog.Info("SKIP_GPU enabled, using non-GPU sensor sysfs shadow", "path", shadow)
config.sensorShadow = shadow
config.context = context.WithValue(config.context,
common.EnvKey, common.EnvMap{common.HostSysEnvKey: shadow},
)
} else {
slog.Warn("SKIP_GPU sensor shadow unavailable, falling back to post-read filtering", "err", err)
}
}
// handle blacklist // handle blacklist
if strings.HasPrefix(sensorsEnvVal, "-") { if strings.HasPrefix(sensorsEnvVal, "-") {
@@ -149,6 +168,9 @@ func (a *Agent) updateTemperatures(systemStats *system.Stats) {
if !isValidSensor(sensorName, a.sensorConfig) { if !isValidSensor(sensorName, a.sensorConfig) {
continue continue
} }
if a.sensorConfig.skipGPU && isGpuSensorKey(sensorName) {
continue
}
// set dashboard temperature // set dashboard temperature
switch a.sensorConfig.primarySensor { switch a.sensorConfig.primarySensor {
case "": case "":
@@ -245,3 +267,102 @@ func scaleTemperature(temp float64) float64 {
} }
return scaled100 return scaled100
} }
// effectiveSysRoot mirrors gopsutil's HostSys lookup, which lives in its
// internal package: context override, then HOST_SYS env, then /sys.
func effectiveSysRoot(ctx context.Context) string {
if envMap, ok := ctx.Value(common.EnvKey).(common.EnvMap); ok {
if v := envMap[common.HostSysEnvKey]; v != "" {
return v
}
}
if v := os.Getenv("HOST_SYS"); v != "" {
return v
}
return "/sys"
}
func (config *SensorConfig) cleanupSensorShadow() {
if config.sensorShadow == "" {
return
}
if err := os.RemoveAll(config.sensorShadow); err != nil {
slog.Warn("Error removing sensor sysfs shadow", "path", config.sensorShadow, "err", err)
return
}
config.sensorShadow = ""
}
func (a *Agent) cleanupSensorShadow() {
if a.sensorConfig != nil {
a.sensorConfig.cleanupSensorShadow()
}
}
func isGpuThermalZone(zoneType string) bool {
zoneType = strings.ToLower(strings.TrimSpace(zoneType))
return isGpuChipName(zoneType) || strings.Contains(zoneType, "gpu")
}
// buildNonGpuSysShadow links non-GPU sensor directories into a temp dir. Only
// static chip names and thermal-zone types are read; no sensor values are touched.
func buildNonGpuSysShadow(sysRoot string) (string, error) {
shadow, err := os.MkdirTemp("", "beszel-sensors-*")
if err != nil {
return "", err
}
shadowHwmon := filepath.Join(shadow, "class", "hwmon")
if err := os.MkdirAll(shadowHwmon, 0o755); err != nil {
os.RemoveAll(shadow)
return "", err
}
entries, err := os.ReadDir(filepath.Join(sysRoot, "class", "hwmon"))
if err != nil && !os.IsNotExist(err) {
os.RemoveAll(shadow)
return "", err
}
for _, entry := range entries {
chipDir := filepath.Join(sysRoot, "class", "hwmon", entry.Name())
// Some hwmon devices expose name under device/ (gopsutil's CentOS fallback).
name, ok := utils.ReadStringFileOK(filepath.Join(chipDir, "name"))
if !ok {
name, ok = utils.ReadStringFileOK(filepath.Join(chipDir, "device", "name"))
}
if !ok || isGpuChipName(name) {
continue
}
if err := os.Symlink(chipDir, filepath.Join(shadowHwmon, entry.Name())); err != nil {
os.RemoveAll(shadow)
return "", err
}
}
thermalEntries, err := os.ReadDir(filepath.Join(sysRoot, "class", "thermal"))
if err != nil {
if os.IsNotExist(err) {
return shadow, nil
}
os.RemoveAll(shadow)
return "", err
}
shadowThermal := filepath.Join(shadow, "class", "thermal")
if err := os.MkdirAll(shadowThermal, 0o755); err != nil {
os.RemoveAll(shadow)
return "", err
}
for _, entry := range thermalEntries {
if !strings.HasPrefix(entry.Name(), "thermal_zone") {
continue
}
zoneDir := filepath.Join(sysRoot, "class", "thermal", entry.Name())
zoneType, ok := utils.ReadStringFileOK(filepath.Join(zoneDir, "type"))
if !ok || isGpuThermalZone(zoneType) {
continue
}
if err := os.Symlink(zoneDir, filepath.Join(shadowThermal, entry.Name())); err != nil {
os.RemoveAll(shadow)
return "", err
}
}
return shadow, nil
}

View File

@@ -5,6 +5,8 @@ package agent
import ( import (
"context" "context"
"fmt" "fmt"
"os"
"path/filepath"
"testing" "testing"
"time" "time"
@@ -328,7 +330,7 @@ func TestNewSensorConfigWithEnv(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
result := agent.newSensorConfigWithEnv(tt.primarySensor, tt.sysSensors, tt.sensors, tt.sensorsTimeout, tt.skipCollection) result := agent.newSensorConfigWithEnv(tt.primarySensor, tt.sysSensors, tt.sensors, tt.sensorsTimeout, tt.skipCollection, false)
// Check primary sensor // Check primary sensor
assert.Equal(t, tt.expectedConfig.primarySensor, result.primarySensor) assert.Equal(t, tt.expectedConfig.primarySensor, result.primarySensor)
@@ -620,3 +622,143 @@ func TestUpdateTemperaturesSkipsOnTimeout(t *testing.T) {
assert.Equal(t, 0.0, agent.systemInfo.DashboardTemp) assert.Equal(t, 0.0, agent.systemInfo.DashboardTemp)
assert.Equal(t, map[string]float64{}, stats.Temperatures) assert.Equal(t, map[string]float64{}, stats.Temperatures)
} }
func TestIsGpuSensorKey(t *testing.T) {
for _, key := range []string{"xe", "XE_temp1", "amdgpu_edge", "NVIDIA"} {
assert.True(t, isGpuSensorKey(key), key)
}
for _, key := range []string{"coretemp_core_0", "acpitz", "xen_temp", "myxe", ""} {
assert.False(t, isGpuSensorKey(key), key)
}
}
func TestSkipGpuSensorShadow(t *testing.T) {
sysRoot := t.TempDir()
writeFile(t, filepath.Join(sysRoot, "class", "hwmon", "hwmon0", "name"), "coretemp\n")
writeFile(t, filepath.Join(sysRoot, "class", "hwmon", "hwmon0", "temp1_input"), "55000\n")
writeFile(t, filepath.Join(sysRoot, "class", "hwmon", "hwmon1", "name"), "xe\n")
writeFile(t, filepath.Join(sysRoot, "class", "hwmon", "hwmon1", "temp1_input"), "48000\n")
writeFile(t, filepath.Join(sysRoot, "class", "thermal", "thermal_zone0", "type"), "cpu-thermal\n")
writeFile(t, filepath.Join(sysRoot, "class", "thermal", "thermal_zone0", "temp"), "55000\n")
writeFile(t, filepath.Join(sysRoot, "class", "thermal", "thermal_zone1", "type"), "gpu\n")
writeFile(t, filepath.Join(sysRoot, "class", "thermal", "thermal_zone1", "temp"), "48000\n")
shadow, err := buildNonGpuSysShadow(sysRoot)
require.NoError(t, err)
t.Cleanup(func() { os.RemoveAll(shadow) })
assert.FileExists(t, filepath.Join(shadow, "class", "hwmon", "hwmon0", "temp1_input"))
assert.NoFileExists(t, filepath.Join(shadow, "class", "hwmon", "hwmon1"))
assert.FileExists(t, filepath.Join(shadow, "class", "thermal", "thermal_zone0", "temp"))
assert.NoFileExists(t, filepath.Join(shadow, "class", "thermal", "thermal_zone1"))
}
func TestSkipGpuSensorShadowDeviceName(t *testing.T) {
sysRoot := t.TempDir()
writeFile(t, filepath.Join(sysRoot, "class", "hwmon", "hwmon0", "device", "name"), "coretemp\n")
writeFile(t, filepath.Join(sysRoot, "class", "hwmon", "hwmon0", "device", "temp1_input"), "55000\n")
writeFile(t, filepath.Join(sysRoot, "class", "hwmon", "hwmon1", "device", "name"), "xe\n")
writeFile(t, filepath.Join(sysRoot, "class", "hwmon", "hwmon1", "device", "temp1_input"), "48000\n")
shadow, err := buildNonGpuSysShadow(sysRoot)
require.NoError(t, err)
t.Cleanup(func() { os.RemoveAll(shadow) })
assert.FileExists(t, filepath.Join(shadow, "class", "hwmon", "hwmon0", "device", "temp1_input"))
assert.NoFileExists(t, filepath.Join(shadow, "class", "hwmon", "hwmon1"))
}
func TestSkipGpuSensorShadowKeepsThermalZonesWithoutNonGpuHwmon(t *testing.T) {
sysRoot := t.TempDir()
writeFile(t, filepath.Join(sysRoot, "class", "hwmon", "hwmon0", "name"), "xe\n")
writeFile(t, filepath.Join(sysRoot, "class", "hwmon", "hwmon0", "temp1_input"), "48000\n")
writeFile(t, filepath.Join(sysRoot, "class", "thermal", "thermal_zone0", "type"), "cpu-thermal\n")
writeFile(t, filepath.Join(sysRoot, "class", "thermal", "thermal_zone0", "temp"), "55000\n")
writeFile(t, filepath.Join(sysRoot, "class", "thermal", "thermal_zone1", "type"), "gpu\n")
writeFile(t, filepath.Join(sysRoot, "class", "thermal", "thermal_zone1", "temp"), "48000\n")
shadow, err := buildNonGpuSysShadow(sysRoot)
require.NoError(t, err)
t.Cleanup(func() { os.RemoveAll(shadow) })
hwmonTemps, err := filepath.Glob(filepath.Join(shadow, "class", "hwmon", "hwmon*", "temp*_input"))
require.NoError(t, err)
assert.Empty(t, hwmonTemps)
assert.FileExists(t, filepath.Join(shadow, "class", "thermal", "thermal_zone0", "temp"))
assert.NoFileExists(t, filepath.Join(shadow, "class", "thermal", "thermal_zone1"))
}
func TestNewSensorConfigSkipGpuWiresShadow(t *testing.T) {
t.Setenv("SKIP_GPU", "true")
agent := &Agent{}
config := agent.newSensorConfig()
assert.True(t, config.skipGPU)
envMap, ok := config.context.Value(common.EnvKey).(common.EnvMap)
require.True(t, ok, "SKIP_GPU should point the sensor context at a sysfs shadow")
shadow, ok := envMap[common.HostSysEnvKey]
require.True(t, ok)
assert.DirExists(t, filepath.Join(shadow, "class", "hwmon"))
assert.Equal(t, shadow, config.sensorShadow)
config.cleanupSensorShadow()
assert.NoDirExists(t, shadow)
assert.Empty(t, config.sensorShadow)
}
func TestSkipGpuShadowUsesSysSensorsRoot(t *testing.T) {
sysRoot := t.TempDir()
writeFile(t, filepath.Join(sysRoot, "class", "hwmon", "hwmon0", "name"), "coretemp\n")
writeFile(t, filepath.Join(sysRoot, "class", "hwmon", "hwmon0", "temp1_input"), "55000\n")
writeFile(t, filepath.Join(sysRoot, "class", "hwmon", "hwmon1", "name"), "xe\n")
writeFile(t, filepath.Join(sysRoot, "class", "hwmon", "hwmon1", "temp1_input"), "48000\n")
agent := &Agent{}
config := agent.newSensorConfigWithEnv("", sysRoot, "", "", false, true)
t.Cleanup(config.cleanupSensorShadow)
envMap, ok := config.context.Value(common.EnvKey).(common.EnvMap)
require.True(t, ok, "SKIP_GPU should point the sensor context at a sysfs shadow")
shadow, ok := envMap[common.HostSysEnvKey]
require.True(t, ok)
require.NotEqual(t, sysRoot, shadow, "shadow must not be the SYS_SENSORS tree itself")
target, err := os.Readlink(filepath.Join(shadow, "class", "hwmon", "hwmon0"))
require.NoError(t, err)
assert.Equal(t, filepath.Join(sysRoot, "class", "hwmon", "hwmon0"), target)
assert.NoFileExists(t, filepath.Join(shadow, "class", "hwmon", "hwmon1"))
}
func TestUpdateTemperaturesSkipGpu(t *testing.T) {
originalGetSensorTemps := getSensorTemps
t.Cleanup(func() {
getSensorTemps = originalGetSensorTemps
})
getSensorTemps = func(ctx context.Context) ([]sensors.TemperatureStat, error) {
return []sensors.TemperatureStat{
{SensorKey: "coretemp_core_0", Temperature: 55},
{SensorKey: "XE", Temperature: 48},
}, nil
}
newAgent := func(skipGPU bool) *Agent {
agent := &Agent{
systemInfo: system.Info{},
sensorConfig: &SensorConfig{
context: context.Background(),
timeout: 2 * time.Second,
sensors: map[string]struct{}{},
skipGPU: skipGPU,
},
}
return agent
}
stats := &system.Stats{}
newAgent(true).updateTemperatures(stats)
assert.Equal(t, map[string]float64{"coretemp_core_0": 55}, stats.Temperatures)
stats = &system.Stats{}
newAgent(false).updateTemperatures(stats)
assert.Len(t, stats.Temperatures, 2)
}