Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 43 additions & 18 deletions internal/hal/detect_windows.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,18 @@ import (
"strings"
)

// wmic was removed from recent Windows 11 builds. These PowerShell CIM commands
// are the fallback when wmic is unavailable; each emits the same unquoted
// "header\ndata" CSV the wmic parsers already expect.
const (
cimCPUCommand = `$c = Get-CimInstance Win32_Processor | Select-Object -First 1; Write-Output 'Name,NumberOfCores,NumberOfLogicalProcessors,MaxClockSpeed'; Write-Output ('{0},{1},{2},{3}' -f $c.Name,$c.NumberOfCores,$c.NumberOfLogicalProcessors,$c.MaxClockSpeed)`
cimRAMCommand = `$o = Get-CimInstance Win32_OperatingSystem; Write-Output 'TotalVisibleMemorySize,FreePhysicalMemory'; Write-Output ('{0},{1}' -f $o.TotalVisibleMemorySize,$o.FreePhysicalMemory)`
cimSwapCommand = `$p = Get-CimInstance Win32_PageFileUsage | Select-Object -First 1; Write-Output 'AllocatedBaseSize'; if ($p) { Write-Output $p.AllocatedBaseSize }`
cimCPULoadCommand = `$c = Get-CimInstance Win32_Processor | Select-Object -First 1; Write-Output 'LoadPercentage'; Write-Output $c.LoadPercentage`
)

func trimBOM(s string) string { return strings.TrimPrefix(s, "\ufeff") }

func detectCPU(ctx context.Context, runner CommandRunner) CPUInfo {
info := CPUInfo{
Arch: runtime.GOARCH,
Expand All @@ -19,11 +31,14 @@ func detectCPU(ctx context.Context, runner CommandRunner) CPUInfo {

out, err := runner.Run(ctx, "wmic", "cpu", "get", "Name,NumberOfCores,NumberOfLogicalProcessors,MaxClockSpeed", "/format:csv")
if err != nil {
slog.Warn("wmic cpu detection failed, using defaults", "error", err)
return info
out, err = runner.Run(ctx, "powershell", "-NoProfile", "-Command", cimCPUCommand)
if err != nil {
slog.Warn("cpu detection failed (wmic and CIM unavailable), using defaults", "error", err)
return info
}
}

parseWMICCPU(string(out), &info)
parseWMICCPU(trimBOM(string(out)), &info)
return info
}

Expand All @@ -45,20 +60,20 @@ func parseWMICCPU(output string, info *CPUInfo) {
fields := splitCSV(lines[1])

if idx, ok := colIdx["Name"]; ok && idx < len(fields) {
info.Model = fields[idx]
info.Model = strings.TrimSpace(fields[idx])
}
if idx, ok := colIdx["NumberOfCores"]; ok && idx < len(fields) {
if n, err := strconv.Atoi(fields[idx]); err == nil {
if n, err := strconv.Atoi(strings.TrimSpace(fields[idx])); err == nil {
info.Cores = n
}
}
if idx, ok := colIdx["NumberOfLogicalProcessors"]; ok && idx < len(fields) {
if n, err := strconv.Atoi(fields[idx]); err == nil {
if n, err := strconv.Atoi(strings.TrimSpace(fields[idx])); err == nil {
info.Threads = n
}
}
if idx, ok := colIdx["MaxClockSpeed"]; ok && idx < len(fields) {
if mhz, err := strconv.ParseFloat(fields[idx], 64); err == nil {
if mhz, err := strconv.ParseFloat(strings.TrimSpace(fields[idx]), 64); err == nil {
info.FreqGHz = mhz / 1000.0
}
}
Expand All @@ -69,15 +84,22 @@ func detectRAM(ctx context.Context, runner CommandRunner) RAMInfo {

out, err := runner.Run(ctx, "wmic", "os", "get", "TotalVisibleMemorySize,FreePhysicalMemory", "/format:csv")
if err != nil {
slog.Warn("wmic RAM detection failed, using defaults", "error", err)
return info
out, err = runner.Run(ctx, "powershell", "-NoProfile", "-Command", cimRAMCommand)
if err != nil {
slog.Warn("RAM detection failed (wmic and CIM unavailable), using defaults", "error", err)
return info
}
}

parseWMICRAM(string(out), &info)
parseWMICRAM(trimBOM(string(out)), &info)

// Detect swap (pagefile) size
if swapOut, err := runner.Run(ctx, "wmic", "pagefile", "get", "AllocatedBaseSize", "/format:csv"); err == nil {
parseWMICSwap(string(swapOut), &info)
swapOut, swapErr := runner.Run(ctx, "wmic", "pagefile", "get", "AllocatedBaseSize", "/format:csv")
if swapErr != nil {
swapOut, swapErr = runner.Run(ctx, "powershell", "-NoProfile", "-Command", cimSwapCommand)
}
if swapErr == nil {
parseWMICSwap(trimBOM(string(swapOut)), &info)
}

return info
Expand Down Expand Up @@ -117,12 +139,12 @@ func parseWMICRAM(output string, info *RAMInfo) {
fields := splitCSV(lines[1])

if idx, ok := colIdx["TotalVisibleMemorySize"]; ok && idx < len(fields) {
if kb, err := strconv.ParseInt(fields[idx], 10, 64); err == nil {
if kb, err := strconv.ParseInt(strings.TrimSpace(fields[idx]), 10, 64); err == nil {
info.TotalMiB = int(kb / 1024)
}
}
if idx, ok := colIdx["FreePhysicalMemory"]; ok && idx < len(fields) {
if kb, err := strconv.ParseInt(fields[idx], 10, 64); err == nil {
if kb, err := strconv.ParseInt(strings.TrimSpace(fields[idx]), 10, 64); err == nil {
info.AvailableMiB = int(kb / 1024)
}
}
Expand All @@ -131,11 +153,14 @@ func parseWMICRAM(output string, info *RAMInfo) {
func collectCPUMetrics(ctx context.Context, runner CommandRunner) CPUMetrics {
out, err := runner.Run(ctx, "wmic", "cpu", "get", "LoadPercentage", "/format:csv")
if err != nil {
slog.Warn("wmic CPU metrics failed, using defaults", "error", err)
return CPUMetrics{}
out, err = runner.Run(ctx, "powershell", "-NoProfile", "-Command", cimCPULoadCommand)
if err != nil {
slog.Warn("CPU metrics failed (wmic and CIM unavailable), using defaults", "error", err)
return CPUMetrics{}
}
}

lines := nonEmptyLines(string(out))
lines := nonEmptyLines(trimBOM(string(out)))
if len(lines) < 2 {
return CPUMetrics{}
}
Expand All @@ -148,7 +173,7 @@ func collectCPUMetrics(ctx context.Context, runner CommandRunner) CPUMetrics {

fields := splitCSV(lines[1])
if idx, ok := colIdx["LoadPercentage"]; ok && idx < len(fields) {
if pct, err := strconv.ParseFloat(fields[idx], 64); err == nil {
if pct, err := strconv.ParseFloat(strings.TrimSpace(fields[idx]), 64); err == nil {
return CPUMetrics{UsagePercent: pct}
}
}
Expand Down
47 changes: 46 additions & 1 deletion internal/hal/detect_windows_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,52 @@

package hal

import "testing"
import (
"context"
"testing"
)

// On Windows 11 builds where wmic has been removed, CPU detection must fall
// back to PowerShell CIM. The mock runner returns ErrNotFound for the wmic
// command (not in its map), exercising the fallback path.
func TestDetectCPUFallsBackToCIMWhenWMICMissing(t *testing.T) {
runner := newMockRunner(map[string]mockResult{
"powershell -NoProfile -Command " + cimCPUCommand: {
output: []byte("Name,NumberOfCores,NumberOfLogicalProcessors,MaxClockSpeed\nAMD Ryzen AI Max+ 395 w/ Radeon 8060S Graphics,16,32,3000\n"),
},
})
info := detectCPU(context.Background(), runner)
if info.Model != "AMD Ryzen AI Max+ 395 w/ Radeon 8060S Graphics" {
t.Errorf("Model = %q, want CIM-detected name", info.Model)
}
if info.Cores != 16 || info.Threads != 32 {
t.Errorf("Cores/Threads = %d/%d, want 16/32", info.Cores, info.Threads)
}
if info.FreqGHz != 3.0 {
t.Errorf("FreqGHz = %v, want 3", info.FreqGHz)
}
}

func TestDetectRAMFallsBackToCIMWhenWMICMissing(t *testing.T) {
runner := newMockRunner(map[string]mockResult{
"powershell -NoProfile -Command " + cimRAMCommand: {
output: []byte("TotalVisibleMemorySize,FreePhysicalMemory\n134217728,67108864\n"),
},
"powershell -NoProfile -Command " + cimSwapCommand: {
output: []byte("AllocatedBaseSize\n8192\n"),
},
})
info := detectRAM(context.Background(), runner)
if info.TotalMiB != 131072 {
t.Errorf("TotalMiB = %d, want 131072 (128 GiB)", info.TotalMiB)
}
if info.AvailableMiB != 65536 {
t.Errorf("AvailableMiB = %d, want 65536", info.AvailableMiB)
}
if info.SwapTotalMiB != 8192 {
t.Errorf("SwapTotalMiB = %d, want 8192", info.SwapTotalMiB)
}
}

func TestParseWMICCPU(t *testing.T) {
tests := []struct {
Expand Down
Loading