0415e905af
build-and-release / release (push) Successful in 2m34s
Adds GPUInfo struct and readGPUsFromSysfs parsing DRM card entries (/sys/class/drm/card*). Supports: - AMD GPUs (amdgpu driver): VRAM totals/utilization from sysfs files - NVIDIA GPUs: enrichment via nvidia-smi query - Intel/other: basic PCI vendor/device/driver identification Includes full test coverage for AMD enrichment, i915 fallback, missing sysfs dir, and non-GPU DRM entry filtering.
292 lines
8.8 KiB
Go
292 lines
8.8 KiB
Go
package system
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
)
|
|
|
|
func TestReadGPUsFromSysfs(t *testing.T) {
|
|
root := t.TempDir()
|
|
|
|
// card0 — AMD GPU with VRAM and utilization files
|
|
pciDev := filepath.Join(root, "devices/pci0000:00/0000:00:02.0")
|
|
mkdirAll(t, pciDev)
|
|
write(t, pciDev, ".", "vendor", "0x1002")
|
|
write(t, pciDev, ".", "device", "0x7480")
|
|
write(t, pciDev, ".", "mem_info_vram_total", "8573157376")
|
|
write(t, pciDev, ".", "mem_info_vram_used", "27267072")
|
|
write(t, pciDev, ".", "gpu_busy_percent", "23")
|
|
write(t, pciDev, ".", "mem_busy_percent", "15")
|
|
|
|
driverDir := filepath.Join(root, "bus/pci/drivers/amdgpu")
|
|
mkdirAll(t, driverDir)
|
|
mustSymlink(t, driverDir, filepath.Join(pciDev, "driver"))
|
|
|
|
cardTarget := filepath.Join(pciDev, "drm", "card0")
|
|
mkdirAll(t, cardTarget)
|
|
mustSymlink(t, pciDev, filepath.Join(cardTarget, "device"))
|
|
mustSymlink(t, cardTarget, filepath.Join(root, "card0"))
|
|
|
|
// Distractors
|
|
write(t, root, ".", "card0-HDMI-1", "distract")
|
|
write(t, root, ".", "renderD128", "distract")
|
|
|
|
gpus := readGPUsFromSysfs(root)
|
|
if len(gpus) != 1 {
|
|
t.Fatalf("want 1 GPU, got %d: %+v", len(gpus), gpus)
|
|
}
|
|
if gpus[0].Vendor != "1002" {
|
|
t.Errorf("vendor = %q, want 1002", gpus[0].Vendor)
|
|
}
|
|
if gpus[0].DeviceID != "7480" {
|
|
t.Errorf("device_id = %q, want 7480", gpus[0].DeviceID)
|
|
}
|
|
if gpus[0].Driver != "amdgpu" {
|
|
t.Errorf("driver = %q, want amdgpu", gpus[0].Driver)
|
|
}
|
|
if gpus[0].MemoryTotalBytes != 8573157376 {
|
|
t.Errorf("MemoryTotalBytes = %d, want 8573157376", gpus[0].MemoryTotalBytes)
|
|
}
|
|
if gpus[0].MemoryUsedBytes != 27267072 {
|
|
t.Errorf("MemoryUsedBytes = %d, want 27267072", gpus[0].MemoryUsedBytes)
|
|
}
|
|
if gpus[0].UtilizationPct != 23.0 {
|
|
t.Errorf("UtilizationPct = %f, want 23.0", gpus[0].UtilizationPct)
|
|
}
|
|
if gpus[0].MemUtilizationPct != 15.0 {
|
|
t.Errorf("MemUtilizationPct = %f, want 15.0", gpus[0].MemUtilizationPct)
|
|
}
|
|
}
|
|
|
|
func TestReadGPUsFromSysfsNoEnrichment(t *testing.T) {
|
|
root := t.TempDir()
|
|
|
|
// i915 GPU with no VRAM or utilization files
|
|
pciDev := filepath.Join(root, "devices/pci0000:00/0000:00:02.0")
|
|
mkdirAll(t, pciDev)
|
|
write(t, pciDev, ".", "vendor", "0x8086")
|
|
write(t, pciDev, ".", "device", "0x46a6")
|
|
|
|
driverDir := filepath.Join(root, "bus/pci/drivers/i915")
|
|
mkdirAll(t, driverDir)
|
|
mustSymlink(t, driverDir, filepath.Join(pciDev, "driver"))
|
|
|
|
cardTarget := filepath.Join(pciDev, "drm", "card0")
|
|
mkdirAll(t, cardTarget)
|
|
mustSymlink(t, pciDev, filepath.Join(cardTarget, "device"))
|
|
mustSymlink(t, cardTarget, filepath.Join(root, "card0"))
|
|
|
|
gpus := readGPUsFromSysfs(root)
|
|
if len(gpus) != 1 {
|
|
t.Fatalf("want 1 GPU, got %d", len(gpus))
|
|
}
|
|
if gpus[0].MemoryTotalBytes != 0 || gpus[0].MemoryUsedBytes != 0 {
|
|
t.Errorf("expected 0 VRAM for i915, got total=%d used=%d", gpus[0].MemoryTotalBytes, gpus[0].MemoryUsedBytes)
|
|
}
|
|
if gpus[0].UtilizationPct != 0 || gpus[0].MemUtilizationPct != 0 {
|
|
t.Errorf("expected 0 utilization for i915, got gpu=%f mem=%f", gpus[0].UtilizationPct, gpus[0].MemUtilizationPct)
|
|
}
|
|
}
|
|
|
|
func TestReadGPUsFromSysfsMissingDir(t *testing.T) {
|
|
gpus := readGPUsFromSysfs("/nonexistent/drm")
|
|
if gpus != nil {
|
|
t.Errorf("expected nil, got %+v", gpus)
|
|
}
|
|
}
|
|
|
|
func TestReadGPUsFromSysfsSkipsNonGPU(t *testing.T) {
|
|
root := t.TempDir()
|
|
write(t, root, ".", "renderD128", "x")
|
|
write(t, root, ".", "card0-HDMI-1", "x")
|
|
gpus := readGPUsFromSysfs(root)
|
|
if len(gpus) != 0 {
|
|
t.Errorf("expected 0 GPUs, got %d", len(gpus))
|
|
}
|
|
}
|
|
|
|
func TestReadHwmonTemps(t *testing.T) {
|
|
root := t.TempDir()
|
|
// k10temp: CPU, labelled Tctl.
|
|
write(t, root, "hwmon0", "name", "k10temp")
|
|
write(t, root, "hwmon0", "temp1_input", "62125")
|
|
write(t, root, "hwmon0", "temp1_label", "Tctl")
|
|
// amdgpu: GPU, no label -> falls back to chip name. Also a sensor reading
|
|
// an implausible value that must be dropped.
|
|
write(t, root, "hwmon1", "name", "amdgpu")
|
|
write(t, root, "hwmon1", "temp1_input", "56000")
|
|
write(t, root, "hwmon1", "temp2_input", "-150") // disabled placeholder
|
|
// empty input -> skipped without error.
|
|
write(t, root, "hwmon2", "name", "cros_ec")
|
|
write(t, root, "hwmon2", "temp1_input", "")
|
|
|
|
got := readHwmonTemps(root)
|
|
if len(got) != 2 {
|
|
t.Fatalf("want 2 temps, got %d: %+v", len(got), got)
|
|
}
|
|
if got[0].Chip != "k10temp" || got[0].Label != "Tctl" || got[0].Celsius != 62.125 {
|
|
t.Errorf("cpu sensor wrong: %+v", got[0])
|
|
}
|
|
if got[1].Chip != "amdgpu" || got[1].Label != "amdgpu" || got[1].Celsius != 56 {
|
|
t.Errorf("gpu sensor wrong (label should fall back to chip): %+v", got[1])
|
|
}
|
|
}
|
|
|
|
func mkdirAll(t *testing.T, path string) {
|
|
t.Helper()
|
|
if err := os.MkdirAll(path, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func mustSymlink(t *testing.T, target, link string) {
|
|
t.Helper()
|
|
if err := os.Symlink(target, link); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func write(t *testing.T, root, chip, file, val string) {
|
|
t.Helper()
|
|
dir := filepath.Join(root, chip)
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(dir, file), []byte(val), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func TestParseMeminfo(t *testing.T) {
|
|
data := []byte(`MemTotal: 16000 kB
|
|
MemFree: 2000 kB
|
|
MemAvailable: 8000 kB
|
|
SwapTotal: 4000 kB
|
|
SwapFree: 3000 kB
|
|
`)
|
|
m := parseMeminfo(data)
|
|
const kb = 1024
|
|
if m.TotalBytes != 16000*kb {
|
|
t.Errorf("TotalBytes = %d", m.TotalBytes)
|
|
}
|
|
if m.AvailableBytes != 8000*kb {
|
|
t.Errorf("AvailableBytes = %d", m.AvailableBytes)
|
|
}
|
|
if m.UsedBytes != (16000-8000)*kb {
|
|
t.Errorf("UsedBytes = %d, want %d", m.UsedBytes, (16000-8000)*kb)
|
|
}
|
|
if m.SwapTotalBytes != 4000*kb || m.SwapFreeBytes != 3000*kb {
|
|
t.Errorf("swap parsed wrong: %+v", m)
|
|
}
|
|
}
|
|
|
|
func TestParseLoadavg(t *testing.T) {
|
|
l := parseLoadavg("0.42 0.55 0.61 1/234 5678")
|
|
if l.Load1 != 0.42 || l.Load5 != 0.55 || l.Load15 != 0.61 {
|
|
t.Errorf("got %+v", l)
|
|
}
|
|
bad := parseLoadavg("garbage")
|
|
if bad.Load1 != 0 || bad.Load5 != 0 || bad.Load15 != 0 {
|
|
t.Error("short input should yield zero LoadInfo")
|
|
}
|
|
}
|
|
|
|
func TestCpuFreqMHz(t *testing.T) {
|
|
root := t.TempDir()
|
|
// Hardware limits live under cpu0 only.
|
|
write(t, root, "cpu0/cpufreq", "cpuinfo_min_freq", "400000") // 400 MHz
|
|
write(t, root, "cpu0/cpufreq", "cpuinfo_max_freq", "5137000") // 5137 MHz
|
|
// Per-core current frequencies (kHz). Core 2 has the peak.
|
|
write(t, root, "cpu0/cpufreq", "scaling_cur_freq", "2500000")
|
|
write(t, root, "cpu1/cpufreq", "scaling_cur_freq", "1100000")
|
|
write(t, root, "cpu2/cpufreq", "scaling_cur_freq", "3200000")
|
|
write(t, root, "cpu3/cpufreq", "scaling_cur_freq", "2800000")
|
|
|
|
min, max, cur := cpuFreqMHz(root)
|
|
if min != 400 {
|
|
t.Errorf("min = %d, want 400", min)
|
|
}
|
|
if max != 5137 {
|
|
t.Errorf("max = %d, want 5137", max)
|
|
}
|
|
if cur != 3200 {
|
|
t.Errorf("cur = %d, want 3200 (peak across cores)", cur)
|
|
}
|
|
|
|
// Absent cpufreq → all zeros.
|
|
emptyRoot := t.TempDir()
|
|
min, max, cur = cpuFreqMHz(emptyRoot)
|
|
if min != 0 || max != 0 || cur != 0 {
|
|
t.Errorf("absent cpufreq: got min=%d max=%d cur=%d, want all 0", min, max, cur)
|
|
}
|
|
}
|
|
|
|
func TestCPUModel(t *testing.T) {
|
|
x86 := "processor\t: 0\nmodel name\t: AMD Ryzen 7 7840U\ncpu MHz\t: 3000\n"
|
|
if got := cpuModel(x86); got != "AMD Ryzen 7 7840U" {
|
|
t.Errorf("x86: got %q", got)
|
|
}
|
|
arm := "processor\t: 0\nModel\t: Raspberry Pi 5 Model B\n"
|
|
if got := cpuModel(arm); got != "Raspberry Pi 5 Model B" {
|
|
t.Errorf("arm fallback: got %q", got)
|
|
}
|
|
if got := cpuModel("processor\t: 0\n"); got != "" {
|
|
t.Errorf("no model: got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestReadProcStat(t *testing.T) {
|
|
content := `cpu 100 20 30 400 10 5 3 2 0 0
|
|
cpu0 50 10 15 200 5 3 1 1 0 0
|
|
cpu1 50 10 15 200 5 2 2 1 0 0
|
|
intr 12345
|
|
`
|
|
f := filepath.Join(t.TempDir(), "stat")
|
|
if err := os.WriteFile(f, []byte(content), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cores := readProcStat(f)
|
|
if len(cores) != 2 {
|
|
t.Fatalf("want 2 cores, got %d", len(cores))
|
|
}
|
|
// cpu0: user=50 nice=10 sys=15 idle=200 iowait=5 irq=3 softirq=1 steal=1 guest=0 guest_nice=0
|
|
// total = 50+10+15+200+5+3+1+1+0+0 = 285
|
|
// idle = 200 + 5 = 205
|
|
if cores[0].core != 0 {
|
|
t.Errorf("core index = %d, want 0", cores[0].core)
|
|
}
|
|
if cores[0].total != 285 {
|
|
t.Errorf("core0 total = %d, want 285", cores[0].total)
|
|
}
|
|
if cores[0].idle != 205 {
|
|
t.Errorf("core0 idle = %d, want 205", cores[0].idle)
|
|
}
|
|
}
|
|
|
|
func TestComputeUsage(t *testing.T) {
|
|
prev := []cpuCoreTicks{
|
|
{core: 0, total: 1000, idle: 800},
|
|
{core: 1, total: 1000, idle: 900},
|
|
}
|
|
cur := []cpuCoreTicks{
|
|
{core: 0, total: 1100, idle: 850}, // 100 total delta, 50 idle delta → 50% usage
|
|
{core: 1, total: 1200, idle: 950}, // 200 total delta, 50 idle delta → 75% usage
|
|
}
|
|
usage := computeUsage(prev, cur)
|
|
if len(usage) != 2 {
|
|
t.Fatalf("want 2 entries, got %d", len(usage))
|
|
}
|
|
if usage[0].Core != 0 || usage[0].UsagePct != 50.0 {
|
|
t.Errorf("core0: got %+v, want 50%%", usage[0])
|
|
}
|
|
if usage[1].Core != 1 || usage[1].UsagePct != 75.0 {
|
|
t.Errorf("core1: got %+v, want 75%%", usage[1])
|
|
}
|
|
|
|
// Empty prev → empty result.
|
|
if got := computeUsage(nil, cur); len(got) != 0 {
|
|
t.Errorf("nil prev should yield empty, got %d", len(got))
|
|
}
|
|
}
|