Skip to content

Commit 52a24c8

Browse files
authored
Merge pull request #2128 from shirou/feat/follow-up-2125
[cpu][windows]: harden the cpu-total computation added in #2125
2 parents 1e34da6 + 268a953 commit 52a24c8

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)