Skip to content

Commit 5848dba

Browse files
committed
fix(adapters): a slow helper no longer blanks power/gpu metrics
The helper socket call and the in-process fallback shared the adapter's 1s deadline, so a slow helper — macOS powermetrics can take ~1s on an M-series Pro/Max — ate the whole budget and the fallback never ran. The privileged adapter timed out and reported power/gpu/vram/npu as errored (seen live on an M3 Max the moment the helper was started). Bound the helper call to a slice of the deadline so there's always time left for the in-process read. The helper stays additive under latency, not just when it returns empty. On Apple Silicon the daemon reads SMC/IOReport unprivileged, so running the helper there is now harmless rather than destructive.
1 parent afa515f commit 5848dba

3 files changed

Lines changed: 78 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,19 @@ and the project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
77

88
## [Unreleased]
99

10+
## [2.4.1] - 2026-07-01
11+
12+
### Fixed
13+
- **A slow privileged helper no longer blanks every power/GPU metric.** The
14+
helper socket call and the in-process fallback shared the adapter's 1 s
15+
deadline, so a slow helper — notably macOS `powermetrics`, which can take ~1 s
16+
on an M-series Pro/Max — consumed the whole budget and the fallback never ran:
17+
the privileged adapter timed out and reported power/GPU/VRAM/NPU as errored.
18+
The helper call is now bounded to a slice of the deadline, leaving time for the
19+
in-process read, so the helper stays additive under latency. Matters most on
20+
Apple Silicon, where the daemon reads SMC/IOReport unprivileged and the helper
21+
is optional — running it must never make things worse.
22+
1023
## [2.4.0] - 2026-07-01
1124

1225
### Added

app/internal/adapters/helper.go

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ package adapters
55

66
import (
77
"context"
8+
"time"
89

910
"heimdall/app/internal/domain"
1011
"heimdall/app/internal/helper"
@@ -51,7 +52,14 @@ func (h Helper) Collect(ctx context.Context) ([]domain.Metric, error) {
5152
// read IOReport) must NOT shadow a working in-process source — otherwise
5253
// running the helper on a Mac, where IOReport is unprivileged, blanks out the
5354
// power/gpu the daemon was reading itself.
54-
if ms, err := client.Collect(ctx); err == nil && anyOK(ms) {
55+
//
56+
// The helper call is bounded to a slice of the deadline so a *slow* helper
57+
// (e.g. macOS powermetrics, which can take ~1s) can't consume the whole
58+
// adapter budget and starve the in-process fallback below — the helper stays
59+
// additive, never subtractive, even under latency.
60+
hctx, cancel := helperCallContext(ctx)
61+
defer cancel()
62+
if ms, err := client.Collect(hctx); err == nil && anyOK(ms) {
5563
return ms, nil
5664
}
5765
direct := h.Direct
@@ -67,6 +75,23 @@ func (h Helper) Collect(ctx context.Context) ([]domain.Metric, error) {
6775
}, nil
6876
}
6977

78+
// helperCallContext caps the helper socket call so it leaves budget for the
79+
// in-process fallback within the adapter deadline. Without a deadline it applies
80+
// a sane default; when the deadline is already near, the helper still gets a
81+
// token slice so a healthy helper isn't starved on a tight cycle.
82+
func helperCallContext(ctx context.Context) (context.Context, context.CancelFunc) {
83+
const reserve = 350 * time.Millisecond
84+
dl, ok := ctx.Deadline()
85+
if !ok {
86+
return context.WithTimeout(ctx, 650*time.Millisecond)
87+
}
88+
remaining := time.Until(dl)
89+
if remaining <= reserve {
90+
return context.WithTimeout(ctx, remaining/2)
91+
}
92+
return context.WithTimeout(ctx, remaining-reserve)
93+
}
94+
7095
func anyOK(ms []domain.Metric) bool {
7196
for _, m := range ms {
7297
if m.Status == domain.StatusOK {

app/internal/adapters/helper_test.go

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"context"
88
"path/filepath"
99
"testing"
10+
"time"
1011

1112
"heimdall/app/internal/domain"
1213
"heimdall/app/internal/helper"
@@ -40,6 +41,44 @@ func TestHelperAdapterNeedsHelperWhenAbsent(t *testing.T) {
4041
}
4142
}
4243

44+
// blockingClient models a reachable but slow helper (e.g. macOS powermetrics):
45+
// it never answers, blocking until its context is cancelled.
46+
type blockingClient struct{}
47+
48+
func (blockingClient) Collect(ctx context.Context) ([]domain.Metric, error) {
49+
<-ctx.Done()
50+
return nil, ctx.Err()
51+
}
52+
53+
// A slow helper must not consume the whole adapter deadline and blank every
54+
// privileged metric — the adapter bounds the helper call and falls back to the
55+
// in-process reading with time to spare.
56+
func TestHelperAdapterFallsBackWhenHelperSlow(t *testing.T) {
57+
a := Helper{
58+
Client: blockingClient{},
59+
Direct: func(context.Context) []domain.Metric {
60+
return []domain.Metric{{Name: "power.total", Status: domain.StatusOK, Gauge: 42}}
61+
},
62+
}
63+
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
64+
defer cancel()
65+
start := time.Now()
66+
ms, err := a.Collect(ctx)
67+
if err != nil {
68+
t.Fatalf("collect: %v", err)
69+
}
70+
if time.Since(start) >= 500*time.Millisecond {
71+
t.Error("did not fall back before the adapter deadline (helper ate the budget)")
72+
}
73+
got := make(map[string]domain.Metric, len(ms))
74+
for _, m := range ms {
75+
got[m.Name] = m
76+
}
77+
if m := got["power.total"]; m.Status != domain.StatusOK || m.Gauge != 42 {
78+
t.Fatalf("power.total = %+v, want the in-process 42W fallback", m)
79+
}
80+
}
81+
4382
// With no helper socket but in-process privilege (e.g. sudo), the adapter
4483
// returns the directly collected metrics instead of needs-helper.
4584
func TestHelperAdapterUsesDirectWhenPrivileged(t *testing.T) {

0 commit comments

Comments
 (0)