Skip to content

Commit 268a953

Browse files
shirouclaude
andcommitted
[cpu][windows]: harden the cpu-total computation added in #2125
Follow-up to #2125, which stopped deriving cpu-total from GetSystemTimes and accumulates the per-processor counters instead. - Fall back to GetSystemTimes for cpu-total when perfInfo() fails. perfInfo() relies on the undocumented NtQuerySystemInformation, so Times(false) would now fail outright where that call is unavailable, even though the public API would still work. The processor-group problem #2125 fixes does not apply to the fallback, which is only taken when the group-aware query is unusable in the first place. - Resolve every proc with Find before calling it in perfInfo(). LazyProc.Call panics when it cannot resolve the proc, so on a host where NtQuerySystemInformation is missing the fallback above was unreachable: the process would go down instead. Only the Ex variant was probed before. - Return an error instead of an all-zero cpu-total when perfInfo() reports no processors. - Drop the accumulation of DpcTime and InterruptCount: TimesStat has no field for either, so the sums were never used. The struct fields stay, they are needed for the buffer layout passed to the Windows API. - Restore the note from #2110 explaining why the tick counts are summed as integers and converted to float64 only once. - Add TestTimesTotalMatchesPerCPUSum, asserting that cpu-total is the field-wise sum of the per-CPU stats. Its tolerance scales with the wall clock time actually measured between the two Times calls, so a descheduled test goroutine on a busy CI host does not make it flaky. - Add TestSystemTimes, covering the fallback directly. TimesWithContext only reaches it when perfInfo() fails, so it would otherwise ship untested. - Fix a stale comment in the psutil comparison test: Windows no longer derives cpu-total from GetSystemTimes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 1e34da6 commit 268a953

3 files changed

Lines changed: 152 additions & 15 deletions

File tree

cpu/cpu_psutil_test.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -82,8 +82,10 @@ func TestTimes_Against_Psutil(t *testing.T) {
8282
require.NoError(t, err)
8383

8484
// user/system/idle exist on all supported platforms and both sides
85-
// derive them identically (linux: /proc/stat; windows: GetSystemTimes
86-
// with system = kernel - idle; darwin: mach host statistics).
85+
// derive them from the same counters (linux: /proc/stat; darwin: mach host
86+
// statistics; windows: system = kernel - idle, which psutil reads via
87+
// GetSystemTimes while gopsutil sums the per-CPU counters — equivalent as
88+
// long as the host has a single processor group, see #2125).
8789
pt.AssertBracketedDelta(t, "User", before.User, got.User, after.User, cpuTimesSlack)
8890
pt.AssertBracketedDelta(t, "System", before.System, got.System, after.System, cpuTimesSlack)
8991
pt.AssertBracketedDelta(t, "Idle", before.Idle, got.Idle, after.Idle, cpuTimesSlack)

cpu/cpu_windows.go

Lines changed: 70 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -106,8 +106,17 @@ func Times(percpu bool) ([]TimesStat, error) {
106106
func TimesWithContext(_ context.Context, percpu bool) ([]TimesStat, error) {
107107
// Get the CPU performance counters per processor via Windows API
108108
stats, err := perfInfo()
109+
if err == nil && len(stats) == 0 {
110+
err = errors.New("no processor performance information returned")
111+
}
109112
if err != nil {
110-
return nil, err
113+
if percpu {
114+
return nil, err
115+
}
116+
// perfInfo relies on the undocumented NtQuerySystemInformation, which
117+
// may be unavailable in restricted environments. Fall back to the
118+
// public GetSystemTimes for the total rather than failing outright.
119+
return systemTimes()
111120
}
112121

113122
if percpu {
@@ -130,23 +139,64 @@ func TimesWithContext(_ context.Context, percpu bool) ([]TimesStat, error) {
130139
// machines with more than 64 logical processors when the current thread is
131140
// switched to another processor group as then the counters are not
132141
// monotonic anymore.
133-
var total win32_SystemProcessorPerformanceInformation
142+
//
143+
// Do all arithmetic on the integer tick counts and convert to float64 only
144+
// once. Converting each counter first loses precision on large counters and
145+
// can make the returned values non-monotonic. See issue #2110.
146+
var idle, kernel, user, interrupt int64
134147
for _, v := range stats {
135-
total.IdleTime += v.IdleTime
136-
total.KernelTime += v.KernelTime
137-
total.UserTime += v.UserTime
138-
total.DpcTime += v.DpcTime
139-
total.InterruptTime += v.InterruptTime
140-
total.InterruptCount += v.InterruptCount
148+
idle += v.IdleTime
149+
kernel += v.KernelTime
150+
user += v.UserTime
151+
interrupt += v.InterruptTime
141152
}
142153

143154
return []TimesStat{
144155
{
145156
CPU: "cpu-total",
146-
User: float64(total.UserTime) / ClocksPerSec,
147-
System: float64(total.KernelTime-total.IdleTime) / ClocksPerSec,
148-
Idle: float64(total.IdleTime) / ClocksPerSec,
149-
Irq: float64(total.InterruptTime) / ClocksPerSec,
157+
User: float64(user) / ClocksPerSec,
158+
System: float64(kernel-idle) / ClocksPerSec, // kernel time includes idle time
159+
Idle: float64(idle) / ClocksPerSec,
160+
Irq: float64(interrupt) / ClocksPerSec,
161+
},
162+
}, nil
163+
}
164+
165+
// systemTimes returns the combined CPU times reported by GetSystemTimes.
166+
//
167+
// This is only a fallback for TimesWithContext(ctx, false): GetSystemTimes is a
168+
// documented public API but returns the counters of the calling thread's
169+
// processor group only, so on hosts with more than 64 logical processors the
170+
// values are not monotonic once the thread is migrated to another group. It
171+
// also cannot report Irq, which is left at zero here.
172+
//
173+
// Whether perfInfo works is a property of the environment, so in practice a
174+
// process stays on one path for its whole lifetime. Should perfInfo fail only
175+
// intermittently on a multi-group host, the two paths cover a different number
176+
// of processors, and Percent may report one bogus sample before recovering.
177+
func systemTimes() ([]TimesStat, error) {
178+
var lpIdleTime, lpKernelTime, lpUserTime common.FILETIME
179+
// GetSystemTimes returns 0 for error, in which case we check err,
180+
// see https://pkg.go.dev/golang.org/x/sys/windows#LazyProc.Call
181+
r, _, err := common.ProcGetSystemTimes.Call(
182+
uintptr(unsafe.Pointer(&lpIdleTime)),
183+
uintptr(unsafe.Pointer(&lpKernelTime)),
184+
uintptr(unsafe.Pointer(&lpUserTime)))
185+
if r == 0 {
186+
return nil, err
187+
}
188+
189+
// See the note on integer arithmetic in TimesWithContext and issue #2110.
190+
idle := uint64(lpIdleTime.DwHighDateTime)<<32 | uint64(lpIdleTime.DwLowDateTime)
191+
user := uint64(lpUserTime.DwHighDateTime)<<32 | uint64(lpUserTime.DwLowDateTime)
192+
kernel := uint64(lpKernelTime.DwHighDateTime)<<32 | uint64(lpKernelTime.DwLowDateTime)
193+
194+
return []TimesStat{
195+
{
196+
CPU: "cpu-total",
197+
User: float64(user) / ClocksPerSec,
198+
System: float64(kernel-idle) / ClocksPerSec, // kernel time includes idle time
199+
Idle: float64(idle) / ClocksPerSec,
150200
},
151201
}, nil
152202
}
@@ -261,9 +311,16 @@ func perfInfo() ([]win32_SystemProcessorPerformanceInformation, error) {
261311
// (up to 64 logical CPUs per group). The non-Ex NtQuerySystemInformation only returns
262312
// data for the calling thread's group, so whenever the Ex variant is available we
263313
// iterate every active group and concatenate the results. See issue #887.
264-
if common.ProcNtQuerySystemInformationEx.Find() == nil {
314+
//
315+
// Every proc is resolved with Find before it is used: LazyProc.Call panics when it
316+
// cannot resolve, and a panic would take the caller's process down instead of
317+
// letting TimesWithContext fall back to GetSystemTimes.
318+
if common.ProcNtQuerySystemInformationEx.Find() == nil && procGetActiveProcessorGroupCount.Find() == nil {
265319
return perfInfoAllGroups()
266320
}
321+
if err := common.ProcNtQuerySystemInformation.Find(); err != nil {
322+
return nil, err
323+
}
267324
return perfInfoSingleGroup()
268325
}
269326

cpu/cpu_windows_test.go

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,18 @@ package cpu
66
import (
77
"context"
88
"testing"
9+
"time"
910

11+
"github.com/stretchr/testify/assert"
1012
"github.com/stretchr/testify/require"
1113
)
1214

15+
// timesTotalSlack is the per-logical-CPU margin added on top of the measured
16+
// wall clock time when comparing cpu-total against the sum of the per-CPU stats.
17+
// It only has to absorb the counter granularity, since the time the two Times
18+
// calls are actually apart is measured rather than assumed.
19+
const timesTotalSlack = 0.1 // seconds per logical CPU
20+
1321
// TestPerfInfoMatchesLogicalCount ensures perfInfo() returns one entry per logical
1422
// CPU on the host. This guards against regressions like issue #887 where only the
1523
// calling thread's processor group was reported on hosts with more than 64 CPUs.
@@ -22,3 +30,73 @@ func TestPerfInfoMatchesLogicalCount(t *testing.T) {
2230

2331
require.Len(t, info, n, "perfInfo must return one entry per logical CPU across all processor groups")
2432
}
33+
34+
// TestTimesTotalMatchesPerCPUSum ensures the cpu-total entry is the field-wise sum
35+
// of the per-CPU entries. Both are derived from the same perfInfo() counters, so
36+
// they must agree apart from the counters advancing between the two calls. It
37+
// guards the accumulation loop in TimesWithContext: a mis-mapped field, a dropped
38+
// counter or a skipped processor group shows up as a mismatch.
39+
//
40+
// Note this does not by itself detect a revert to a GetSystemTimes-based total:
41+
// that call reports the sum over the calling thread's processor group, which is
42+
// the whole machine on a single-group host, so User/System/Idle would still match.
43+
// Only the Irq assertion would catch it, and only once enough interrupt time has
44+
// accumulated, since GetSystemTimes cannot report it at all.
45+
func TestTimesTotalMatchesPerCPUSum(t *testing.T) {
46+
start := time.Now()
47+
48+
perCPU, err := Times(true)
49+
require.NoError(t, err)
50+
require.NotEmpty(t, perCPU)
51+
52+
total, err := Times(false)
53+
require.NoError(t, err)
54+
elapsed := time.Since(start)
55+
require.Len(t, total, 1)
56+
require.Equal(t, "cpu-total", total[0].CPU)
57+
58+
var want TimesStat
59+
for _, c := range perCPU {
60+
want.User += c.User
61+
want.System += c.System
62+
want.Idle += c.Idle
63+
want.Irq += c.Irq
64+
}
65+
66+
// Each logical CPU can advance by at most the wall clock time between the two
67+
// calls, so scale the tolerance by what actually elapsed. Assuming the calls
68+
// are close together would make this flaky whenever the test goroutine is
69+
// descheduled on a busy CI host.
70+
slack := (elapsed.Seconds() + timesTotalSlack) * float64(len(perCPU))
71+
assert.InDelta(t, want.User, total[0].User, slack)
72+
assert.InDelta(t, want.System, total[0].System, slack)
73+
assert.InDelta(t, want.Idle, total[0].Idle, slack)
74+
assert.InDelta(t, want.Irq, total[0].Irq, slack)
75+
}
76+
77+
// TestSystemTimes exercises the GetSystemTimes fallback directly. TimesWithContext
78+
// only reaches it when perfInfo() fails, which never happens on a healthy host, so
79+
// without this test the fallback would ship untested.
80+
func TestSystemTimes(t *testing.T) {
81+
total, err := systemTimes()
82+
require.NoError(t, err)
83+
require.Len(t, total, 1)
84+
85+
assert.Equal(t, "cpu-total", total[0].CPU)
86+
assert.Positive(t, total[0].User)
87+
assert.Positive(t, total[0].Idle)
88+
assert.GreaterOrEqual(t, total[0].System, 0.0)
89+
// GetSystemTimes reports no interrupt time, so Irq stays unset here. This is
90+
// the one field that differs from the perfInfo-based total.
91+
assert.Zero(t, total[0].Irq)
92+
93+
// The counters are cumulative since boot, so a second call must not go
94+
// backwards. This catches a broken FILETIME recombination, which would
95+
// otherwise only show up as an implausible absolute value.
96+
again, err := systemTimes()
97+
require.NoError(t, err)
98+
require.Len(t, again, 1)
99+
assert.GreaterOrEqual(t, again[0].User, total[0].User)
100+
assert.GreaterOrEqual(t, again[0].System, total[0].System)
101+
assert.GreaterOrEqual(t, again[0].Idle, total[0].Idle)
102+
}

0 commit comments

Comments
 (0)