From 937713bdd588ac4e6381bd55872fc99cd2955252 Mon Sep 17 00:00:00 2001 From: T-Crypt <132922589+T-Crypt@users.noreply.github.com> Date: Wed, 21 Oct 2026 23:51:10 -0600 Subject: [PATCH] fix(host): read CPU on Windows; leave out the load averages it has none of Windows filled RAM and swap but left CPU and the load averages at 0 while marking the reading supported, so the harbor drew CPU 0% and LOAD 0 0 0 as live. CPU now comes from GetSystemTimes over the interval since the last read, through the same formula Linux uses. Load becomes a pointer that is left out of the JSON where the platform has no load averages, and the harbor and berth status show n/a for it. --- CHANGELOG.md | 3 +++ internal/cli/status.go | 8 ++++-- internal/harbor/web/harbor.js | 3 ++- internal/host/cpu_test.go | 42 ++++++++++++++++++++++++++++++ internal/host/host.go | 28 ++++++++++++++------ internal/host/host_linux.go | 8 +++--- internal/host/host_test.go | 2 +- internal/host/host_windows.go | 30 +++++++++++++++++---- internal/host/host_windows_test.go | 15 ++++++++++- internal/proto/proto.go | 17 ++++++------ 10 files changed, 126 insertions(+), 30 deletions(-) create mode 100644 internal/host/cpu_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index f52387a..bae682f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -80,6 +80,9 @@ has a matching GitHub release whose body comes from its section below. ### Fixed +- Windows: the host's CPU is read (GetSystemTimes), so the harbor's machine meter and `berth status` show real CPU + use instead of 0%. Windows has no load averages: the node now leaves them out of its JSON and the harbor and + `berth status` say n/a instead of three zeros. Reported by @azrael-229 (#139). - The gateway's `/gui/` board launches an engine that has a `start` command but no `gui` URL (Strata, a llama-server). Its Launch button answered 404 and stayed on "Launching..."; it now starts the engine under a lease and shows Running once it answers. A refused start shows its reason instead of hanging. diff --git a/internal/cli/status.go b/internal/cli/status.go index a5916fd..c9f3ad9 100644 --- a/internal/cli/status.go +++ b/internal/cli/status.go @@ -287,10 +287,14 @@ func hostLine(c *Context, h *proto.Host) { c.Printf("%s no host telemetry on this platform\n", c.Style.Dim("host")) return } + load := "load n/a" + if h.Load != nil { + load = fmt.Sprintf("load %.2f %.2f %.2f", h.Load[0], h.Load[1], h.Load[2]) + } c.Printf("%s %s\n", c.Style.Dim("host"), - fmt.Sprintf("CPU %.0f%% of %d cores, RAM %s of %s, swap %s of %s, load %.2f %.2f %.2f", + fmt.Sprintf("CPU %.0f%% of %d cores, RAM %s of %s, swap %s of %s, %s", h.CPU, h.Cores, server.MiB(h.RAMUsed), server.MiB(h.RAMTotal), - server.MiB(h.SwapUsed), server.MiB(h.SwapTotal), h.Load[0], h.Load[1], h.Load[2])) + server.MiB(h.SwapUsed), server.MiB(h.SwapTotal), load)) } // alertsLine is the alert state the node holds: the node is the source of diff --git a/internal/harbor/web/harbor.js b/internal/harbor/web/harbor.js index a664694..b76d876 100644 --- a/internal/harbor/web/harbor.js +++ b/internal/harbor/web/harbor.js @@ -2007,7 +2007,8 @@ function renderMachine(s) { setMeter(machineRows.cpu, h.cpu_pct, 100, fmtPct(h.cpu_pct, 1)); setMeter(machineRows.ram, h.ram_used, h.ram_total, fmtGiB(h.ram_used) + " / " + fmtGiB(h.ram_total) + " GiB"); if (hasSwap) setMeter(machineRows.swap, h.swap_used, h.swap_total, fmtGiB(h.swap_used) + " / " + fmtGiB(h.swap_total) + " GiB"); - setText(machineRows.load.val, (h.load || []).map((v) => v.toFixed(2)).join(" ")); + // Windows reads no load averages: the node leaves them out, and the row says so. + setText(machineRows.load.val, na(h.load, (l) => l.map((v) => v.toFixed(2)).join(" "))); } // The CPU/RAM lines are history-driven and repaint on the heavy gate from // renderHeavy, so a status poll never rebuilds them; a poll that reads the diff --git a/internal/host/cpu_test.go b/internal/host/cpu_test.go new file mode 100644 index 0000000..69f0246 --- /dev/null +++ b/internal/host/cpu_test.go @@ -0,0 +1,42 @@ +// Copyright 2026 Trevin Tindall (T-Crypt) +// SPDX-License-Identifier: Apache-2.0 + +package host + +import ( + "encoding/json" + "strings" + "testing" +) + +func TestCPUPct(t *testing.T) { + for _, c := range []struct { + name string + prevIdle, prevTot, idle, tot uint64 + want float64 + }{ + {"first read has no interval", 0, 0, 800, 1000, 0}, + {"half busy", 800, 1000, 900, 1200, 50}, + {"all idle", 800, 1000, 1000, 1200, 0}, + {"all busy", 800, 1000, 800, 1200, 100}, + {"counters went back", 800, 1000, 100, 200, 0}, + {"no time passed", 800, 1000, 800, 1000, 0}, + } { + if got := cpuPct(c.prevIdle, c.prevTot, c.idle, c.tot); got != c.want { + t.Errorf("%s: cpuPct = %v, want %v", c.name, got, c.want) + } + } +} + +// A platform that reads no load averages says so by leaving them out, so a +// page shows n/a instead of three measured zeros. +func TestLoadUnmeasuredIsAbsent(t *testing.T) { + b, _ := json.Marshal(Stats{Supported: true}) + if strings.Contains(string(b), `"load"`) { + t.Fatalf("unmeasured load is in the JSON: %s", b) + } + b, _ = json.Marshal(Stats{Supported: true, Load: &[3]float64{1.5, 0.75, 0.25}}) + if !strings.Contains(string(b), `"load":[1.5,0.75,0.25]`) { + t.Fatalf("measured load = %s", b) + } +} diff --git a/internal/host/host.go b/internal/host/host.go index 1fb5315..582295e 100644 --- a/internal/host/host.go +++ b/internal/host/host.go @@ -10,14 +10,26 @@ package host import "sync" type Stats struct { - Supported bool `json:"supported"` - CPU float64 `json:"cpu_pct"` // all cores, since the previous Read - Cores int `json:"cores"` - RAMTotal uint64 `json:"ram_total"` - RAMUsed uint64 `json:"ram_used"` // total minus available - SwapTotal uint64 `json:"swap_total"` - SwapUsed uint64 `json:"swap_used"` - Load [3]float64 `json:"load"` + Supported bool `json:"supported"` + CPU float64 `json:"cpu_pct"` // all cores, since the previous Read + Cores int `json:"cores"` + RAMTotal uint64 `json:"ram_total"` + RAMUsed uint64 `json:"ram_used"` // total minus available + SwapTotal uint64 `json:"swap_total"` + SwapUsed uint64 `json:"swap_used"` + // Load is the 1, 5 and 15 minute load averages; nil where the platform + // has none (Windows), so the JSON leaves it out instead of saying 0. + Load *[3]float64 `json:"load,omitempty"` +} + +// cpuPct is the share of time not idle between two readings of cumulative +// counters. The first reading, or counters that did not move forward, +// give 0: there is no interval to measure. +func cpuPct(prevIdle, prevTot, idle, tot uint64) float64 { + if prevTot == 0 || tot <= prevTot || idle < prevIdle { + return 0 + } + return 100 * (1 - float64(idle-prevIdle)/float64(tot-prevTot)) } // Reader keeps the previous CPU counters so each Read returns usage over diff --git a/internal/host/host_linux.go b/internal/host/host_linux.go index c851141..1d0c62a 100644 --- a/internal/host/host_linux.go +++ b/internal/host/host_linux.go @@ -103,9 +103,7 @@ func (r *Reader) Read() Stats { idle += vals[4] // iowait } r.mu.Lock() - if r.tot > 0 && tot > r.tot { - s.CPU = 100 * (1 - float64(idle-r.idle)/float64(tot-r.tot)) - } + s.CPU = cpuPct(r.idle, r.tot, idle, tot) r.idle, r.tot = idle, tot r.mu.Unlock() } else if strings.HasPrefix(line, "cpu") { @@ -123,9 +121,11 @@ func (r *Reader) Read() Stats { } if b, err := os.ReadFile(r.root() + "/loadavg"); err == nil { f := strings.Fields(string(b)) + var load [3]float64 for i := 0; i < 3 && i < len(f); i++ { - s.Load[i], _ = strconv.ParseFloat(f[i], 64) + load[i], _ = strconv.ParseFloat(f[i], 64) } + s.Load = &load } return s } diff --git a/internal/host/host_test.go b/internal/host/host_test.go index 8c76189..f80fdc1 100644 --- a/internal/host/host_test.go +++ b/internal/host/host_test.go @@ -20,7 +20,7 @@ func TestRead(t *testing.T) { w("loadavg", "1.50 0.75 0.25 2/300 99\n") r := &Reader{ProcRoot: dir} s := r.Read() - if !s.Supported || s.Cores != 2 || s.RAMUsed != 750*1024 || s.SwapUsed != 60*1024 || s.Load[0] != 1.5 || s.CPU != 0 { + if !s.Supported || s.Cores != 2 || s.RAMUsed != 750*1024 || s.SwapUsed != 60*1024 || s.Load == nil || s.Load[0] != 1.5 || s.CPU != 0 { t.Fatalf("first read = %+v", s) } w("stat", "cpu 150 0 150 900 0 0 0 0 0 0\ncpu0 1\ncpu1 1\n") // +100 busy, +100 idle diff --git a/internal/host/host_windows.go b/internal/host/host_windows.go index bfb1c1c..204aca8 100644 --- a/internal/host/host_windows.go +++ b/internal/host/host_windows.go @@ -12,11 +12,12 @@ import ( "golang.org/x/sys/windows" ) -// GlobalMemoryStatusEx is not in golang.org/x/sys, so it is loaded by -// name. +// GlobalMemoryStatusEx and GetSystemTimes are not in golang.org/x/sys, +// so they are loaded by name. var ( kernel32 = windows.NewLazySystemDLL("kernel32.dll") procGlobalMemoryStatusEx = kernel32.NewProc("GlobalMemoryStatusEx") + procGetSystemTimes = kernel32.NewProc("GetSystemTimes") ) // memoryStatusEx is MEMORYSTATUSEX (sysinfoapi.h), what @@ -68,15 +69,34 @@ func MemPressure(root string) (float64, bool) { return 0, false } -// Read reports RAM and the page file from GlobalMemoryStatusEx and the -// core count from the runtime. CPU and the load averages have no Windows -// reading yet, so they stay 0; Cores is always the real one. +// systemTimes is GetSystemTimes as cumulative idle and total time, in +// 100 ns ticks. Kernel time already includes idle time, so the total is +// kernel plus user. +func systemTimes() (idle, tot uint64, ok bool) { + var i, k, u windows.Filetime + r, _, _ := procGetSystemTimes.Call(uintptr(unsafe.Pointer(&i)), uintptr(unsafe.Pointer(&k)), uintptr(unsafe.Pointer(&u))) + if r == 0 { + return 0, 0, false + } + ticks := func(f windows.Filetime) uint64 { return uint64(f.HighDateTime)<<32 | uint64(f.LowDateTime) } + return ticks(i), ticks(k) + ticks(u), true +} + +// Read reports RAM and the page file from GlobalMemoryStatusEx, CPU from +// GetSystemTimes over the interval since the last Read, and the core count +// from the runtime. Windows has no load averages, so Load stays nil. func (r *Reader) Read() Stats { m, ok := globalMemoryStatus() if !ok { return Stats{} } s := Stats{Supported: true, Cores: runtime.NumCPU()} + if idle, tot, ok := systemTimes(); ok { + r.mu.Lock() + s.CPU = cpuPct(r.idle, r.tot, idle, tot) + r.idle, r.tot = idle, tot + r.mu.Unlock() + } s.RAMTotal = m.TotalPhys if m.AvailPhys <= m.TotalPhys { s.RAMUsed = m.TotalPhys - m.AvailPhys diff --git a/internal/host/host_windows_test.go b/internal/host/host_windows_test.go index e0604d7..b29139d 100644 --- a/internal/host/host_windows_test.go +++ b/internal/host/host_windows_test.go @@ -5,7 +5,10 @@ package host -import "testing" +import ( + "testing" + "time" +) // A real Windows machine must read its own memory: the totals are the // installed RAM, the available figure sits inside it, and the page file @@ -34,4 +37,14 @@ func TestStatsWindows(t *testing.T) { if s.Cores <= 0 { t.Fatalf("Cores = %d, want the real count", s.Cores) } + if s.Load != nil { + t.Fatalf("Load = %v, want nil: Windows has no load averages", *s.Load) + } + r := &Reader{} + r.Read() + for end := time.Now().Add(200 * time.Millisecond); time.Now().Before(end); { + } + if s = r.Read(); s.CPU <= 0 || s.CPU > 100 { + t.Fatalf("CPU = %v after a busy interval, want a reading in (0, 100]", s.CPU) + } } diff --git a/internal/proto/proto.go b/internal/proto/proto.go index 8f8714b..c5359d7 100644 --- a/internal/proto/proto.go +++ b/internal/proto/proto.go @@ -327,14 +327,15 @@ type GPU struct { // Host is the machine the node runs on. type Host struct { - Supported bool `json:"supported"` - CPU float64 `json:"cpu_pct"` - Cores int `json:"cores"` - RAMTotal uint64 `json:"ram_total"` - RAMUsed uint64 `json:"ram_used"` - SwapTotal uint64 `json:"swap_total"` - SwapUsed uint64 `json:"swap_used"` - Load [3]float64 `json:"load"` + Supported bool `json:"supported"` + CPU float64 `json:"cpu_pct"` + Cores int `json:"cores"` + RAMTotal uint64 `json:"ram_total"` + RAMUsed uint64 `json:"ram_used"` + SwapTotal uint64 `json:"swap_total"` + SwapUsed uint64 `json:"swap_used"` + // Load is absent where the platform has no load averages (Windows). + Load *[3]float64 `json:"load,omitempty"` } // Sample is one point of the node's in-memory history (the graphs).