Skip to content

Commit eae45ef

Browse files
Dumbrisclaude
andauthored
feat(047): cut idle CPU 92% — scanner cache + SSE payload embed + coalescer (#450)
* feat(047): cut idle CPU 92% — scanner cache + SSE payload embed + coalescer Profile of MCPProxy.app + 30-server config + 1.06 GB BBolt + 91k activity records showed 47% sustained CPU, with 56% of cycles inside bbolt.(*DB).View and 50%+ in encoding/json.checkValid. The driver: every tray-triggered GET /api/v1/servers walked the entire scan-job bucket via BoltDB.ListScanJobs and JSON-unmarshalled every record before filtering. This PR ships three fixes that take steady-state CPU from 47% (60s sample) to 3.78% on the same scenario, without any storage migration: - A1 (scanner negative cache): cacheScanSummary now stores a nil sentinel for servers with no scans, distinguished from "not in map" by the existing (value, ok) lookup. findLatestPassJobs returns errNoScans for the empty-bucket case so transient I/O failures don't pollute the cache. internal/security/scanner/service.go. - B1 (SSE payload embed): emitServersChanged calls the management service once per coalesced event window and embeds the post-redaction server list + stats in the payload. Subscribers (Swift tray, Web UI) consume directly and skip the GET /api/v1/servers refetch. internal/runtime/event_bus.go. - B2 (event coalescer): a single drainer goroutine + atomic.Pointer collapses retry-storm bursts into one publish per ~50ms, last-write- wins. internal/runtime/event_bus.go (new serversChangedCoalescer). Plus a pprof endpoint at /debug/pprof/ on the parent mux (API-key gated; zero overhead until hit) so future regressions can be diagnosed without a custom build. internal/server/server.go. Client wiring: the Swift tray (CoreProcessManager.swift) and the Vue Pinia servers store consume payload.servers when present and fall back to GET /api/v1/servers when running against an older core. New ServersChangedPayload + ServersChangedEnvelope decode structs in API/Models.swift. Verification (specs/047-cpu-hotpath-fix/verification/): bbolt.(*DB).View no longer in top-15 cum. encoding/json.checkValid flat down from 13.05% to 8.37%. Cumulative cputime delta 19s/60s -> 2.91s/60s. Web UI live console capture confirms zero refetch after servers.changed events. Tests: TDD-first across all three sub-changes — TestGetScanSummary_* (scanner), TestEmitServersChanged_* + TestCoalescer_* (runtime). Full go test -race ./internal/... green; -tags server suite green; linter clean. Pre-existing 10 failures in test-api-e2e.sh confirmed identical on main; unrelated to this PR. Out of scope (deferred to follow-up specs): in-memory (serverName, pass)->latestJobID index; activity-log retention/pagination; scan-job bucket re-keying; encoding/json replacement; PGO build. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(047): add visual + reactivity verification Live capture against the swap-in MCPProxy-047.app on the user's real 30-server config: Web UI (Chrome MCP): - Toggled context7 disable via REST. Cleared network log first. - 10 SSE servers.changed events processed by the new handler. - 0 follow-up GET /api/v1/servers requests (network log empty). - Visible UI deltas: header counter 14/30 -> 10/30, context7 card switched from "Connected (2 tools)/Disable" to "Disabled/Enable", Total Tools 123 -> 188. macOS Tray (mcpproxy-ui-test, driven via stdio JSON-RPC since the MCP server isn't registered in this session): - check_accessibility: trusted. - context7 menu state captured at three points: baseline: "Connected (2 tools)" / button "Disable" after disable + 2s: "Disabled" / button "Enable" after enable + 5s: "Connected (2 tools)" / button "Disable" - The accessibility tree renders directly from appState.servers, which is now populated by the embedded SSE payload — no HTTP refetch was issued. Three tray PNGs (baseline + after-disable + after-reenable) are saved alongside this report; they show the top-level menu (context7 lives in the Servers submenu so the visual deltas live in the accessibility tree, not the screenshot pixels — see visual.md). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(047): drop verification artifacts; restore web/frontend/dist Two PR-hygiene fixes: 1. Removed binary verification artifacts that the previous commits accidentally bundled into specs/047-cpu-hotpath-fix/verification/: pprof *.pb.gz profiles, *.txt dumps, and tray screenshots. Total ~6 MB of build output that doesn't belong in git history. The textual conclusions remain in report.md and visual.md; reproduce the artifacts locally via quickstart.md. 2. Restored web/frontend/dist/ to its main-branch state. The previous make build run produced a different set of hash-named asset files and orphaned the old ones; git interpreted that as deletions and I committed them. That cleanup was unintentional and out of scope for this PR — the dist/ contents at this branch tip now match main exactly. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Code <noreply@anthropic.com>
1 parent 09b7b36 commit eae45ef

20 files changed

Lines changed: 1696 additions & 12 deletions

File tree

CLAUDE.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -766,6 +766,8 @@ See `docs/prerelease-builds.md` for download instructions.
766766
- No new persistent storage. Diagnostic state lives on in-memory stateview snapshot. Fix-attempt audit rows reuse existing activity log (`ActivityBucket` in BBolt). Telemetry counters are in-memory only (consistent with spec 042). (044-diagnostics-taxonomy)
767767
- Markdown (agent instruction files, wiki articles); optionally shell or AppleScript helpers for bootstrap idempotency + Paperclip AI (paperclipai/paperclip, MIT) running locally on loopback :3100; Synapbus on kubic; Anthropic API via Paperclip's Claude Code subprocess adapter (045-paperclip-cockpit)
768768
- Paperclip's embedded Postgres (existing, port 54329); Synapbus DB (existing); no new storage in mcpproxy-go (045-paperclip-cockpit)
769+
- Go 1.24 (toolchain go1.24.10); Swift 5.9 (macOS 13+); TypeScript 5.9 / Vue 3.5 (frontend) + `go.etcd.io/bbolt` (existing), `go.uber.org/zap` (existing), `github.com/mark3labs/mcp-go` (existing). No new deps. (047-cpu-hotpath-fix)
770+
- BBolt (`~/.mcpproxy/config.db`) — read-only on the hot path; no schema change. (047-cpu-hotpath-fix)
769771
770772
## Recent Changes
771773
- 001-update-version-display: Added Go 1.24 (toolchain go1.24.10)

frontend/src/stores/servers.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -364,6 +364,16 @@ export const useServersStore = defineStore('servers', () => {
364364
function handleServersChanged(event: Event) {
365365
const customEvent = event as CustomEvent
366366
console.log('Servers changed event received, updating in background...', customEvent.detail)
367+
368+
// Spec 047: when the SSE payload includes the full server list, consume it
369+
// directly and skip the GET /api/v1/servers refetch. Fall back to refetch
370+
// when running against an older core that publishes notify-only events.
371+
const payload = customEvent.detail?.payload
372+
if (payload && Array.isArray(payload.servers)) {
373+
servers.value = mergeServers(servers.value, payload.servers as Server[])
374+
return
375+
}
376+
367377
// Silent background refresh to avoid scroll jumps and loading states
368378
fetchServers(true)
369379
}

internal/runtime/coalescer_test.go

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
package runtime
2+
3+
import (
4+
"context"
5+
"sync"
6+
"testing"
7+
"time"
8+
9+
"github.com/stretchr/testify/assert"
10+
"github.com/stretchr/testify/require"
11+
"go.uber.org/zap"
12+
13+
"github.com/smart-mcp-proxy/mcpproxy-go/internal/contracts"
14+
)
15+
16+
// Spec 047 — Phase 5 (US3): coalesce servers.changed bursts to ≤ 1 publish per
17+
// interval window, last-write-wins.
18+
19+
func newCoalescerTestRuntime(t *testing.T) *Runtime {
20+
t.Helper()
21+
logger, err := zap.NewDevelopment()
22+
require.NoError(t, err)
23+
rt := &Runtime{
24+
logger: logger,
25+
eventSubs: make(map[chan Event]struct{}),
26+
managementService: &fakeServersLister{servers: []*contracts.Server{{Name: "s1"}}, stats: &contracts.ServerStats{}},
27+
}
28+
rt.coalescer = newServersChangedCoalescer(rt, 50*time.Millisecond)
29+
return rt
30+
}
31+
32+
func collectEvents(ch <-chan Event, window time.Duration) []Event {
33+
deadline := time.After(window)
34+
var out []Event
35+
for {
36+
select {
37+
case evt, ok := <-ch:
38+
if !ok {
39+
return out
40+
}
41+
if evt.Type == EventTypeServersChanged {
42+
out = append(out, evt)
43+
}
44+
case <-deadline:
45+
return out
46+
}
47+
}
48+
}
49+
50+
func TestCoalescer_CollapsesBurstToSingleEvent(t *testing.T) {
51+
rt := newCoalescerTestRuntime(t)
52+
ctx, cancel := context.WithCancel(context.Background())
53+
defer cancel()
54+
rt.coalescer.start(ctx)
55+
56+
ch := rt.SubscribeEvents()
57+
defer rt.UnsubscribeEvents(ch)
58+
59+
// Fire 100 rapid updates within 10 ms.
60+
for i := 0; i < 100; i++ {
61+
rt.emitServersChanged("burst", map[string]any{"i": i})
62+
}
63+
64+
// Wait > 1 interval window for the drainer to publish.
65+
events := collectEvents(ch, 200*time.Millisecond)
66+
assert.LessOrEqual(t, len(events), 1, "expected ≤ 1 published event after burst, got %d", len(events))
67+
if len(events) == 1 {
68+
// Last submitted i was 99.
69+
assert.Equal(t, 99, events[0].Payload["i"], "last-write-wins: expected i=99")
70+
}
71+
}
72+
73+
func TestCoalescer_LastWriteWins(t *testing.T) {
74+
rt := newCoalescerTestRuntime(t)
75+
ctx, cancel := context.WithCancel(context.Background())
76+
defer cancel()
77+
rt.coalescer.start(ctx)
78+
79+
ch := rt.SubscribeEvents()
80+
defer rt.UnsubscribeEvents(ch)
81+
82+
rt.emitServersChanged("first", nil)
83+
rt.emitServersChanged("middle", nil)
84+
rt.emitServersChanged("last", nil)
85+
86+
events := collectEvents(ch, 200*time.Millisecond)
87+
require.Len(t, events, 1, "expected exactly 1 coalesced event")
88+
assert.Equal(t, "last", events[0].Payload["reason"])
89+
}
90+
91+
func TestCoalescer_FlushesOnShutdown(t *testing.T) {
92+
rt := newCoalescerTestRuntime(t)
93+
ctx, cancel := context.WithCancel(context.Background())
94+
rt.coalescer.start(ctx)
95+
96+
ch := rt.SubscribeEvents()
97+
defer rt.UnsubscribeEvents(ch)
98+
99+
// Submit one event, immediately cancel — drainer must flush before exiting.
100+
rt.emitServersChanged("right-before-shutdown", nil)
101+
cancel()
102+
103+
events := collectEvents(ch, 500*time.Millisecond)
104+
require.GreaterOrEqual(t, len(events), 1, "expected at least 1 event flushed on shutdown")
105+
assert.Equal(t, "right-before-shutdown", events[0].Payload["reason"])
106+
}
107+
108+
func TestCoalescer_NoStarvationOnSingleEvent(t *testing.T) {
109+
rt := newCoalescerTestRuntime(t)
110+
ctx, cancel := context.WithCancel(context.Background())
111+
defer cancel()
112+
rt.coalescer.start(ctx)
113+
114+
ch := rt.SubscribeEvents()
115+
defer rt.UnsubscribeEvents(ch)
116+
117+
rt.emitServersChanged("solo", nil)
118+
119+
events := collectEvents(ch, 250*time.Millisecond)
120+
require.Len(t, events, 1, "single event must publish within ~1 interval window")
121+
assert.Equal(t, "solo", events[0].Payload["reason"])
122+
}
123+
124+
func TestCoalescer_FlushNowSynchronousHook(t *testing.T) {
125+
// flushNow lets tests force a publish without sleeping. Useful for the
126+
// deterministic harness below: we submit, flushNow, assert immediately.
127+
rt := newCoalescerTestRuntime(t)
128+
129+
ch := rt.SubscribeEvents()
130+
defer rt.UnsubscribeEvents(ch)
131+
132+
rt.emitServersChanged("instant", nil)
133+
rt.coalescer.flushNow()
134+
135+
select {
136+
case evt := <-ch:
137+
assert.Equal(t, "instant", evt.Payload["reason"])
138+
case <-time.After(100 * time.Millisecond):
139+
t.Fatal("flushNow should have caused immediate publish")
140+
}
141+
}
142+
143+
func TestCoalescer_ConcurrentSubmittersAreSafe(t *testing.T) {
144+
rt := newCoalescerTestRuntime(t)
145+
ctx, cancel := context.WithCancel(context.Background())
146+
defer cancel()
147+
rt.coalescer.start(ctx)
148+
149+
ch := rt.SubscribeEvents()
150+
defer rt.UnsubscribeEvents(ch)
151+
152+
var wg sync.WaitGroup
153+
for i := 0; i < 20; i++ {
154+
wg.Add(1)
155+
go func(i int) {
156+
defer wg.Done()
157+
for j := 0; j < 50; j++ {
158+
rt.emitServersChanged("concurrent", map[string]any{"goroutine": i, "j": j})
159+
}
160+
}(i)
161+
}
162+
wg.Wait()
163+
164+
// 20 × 50 = 1000 submissions in << 1 interval. After the first window
165+
// elapses and a follow-up window, expect a small bounded number of events.
166+
events := collectEvents(ch, 200*time.Millisecond)
167+
assert.LessOrEqual(t, len(events), 5, "expected coalesced result; got %d events", len(events))
168+
}

internal/runtime/event_bus.go

Lines changed: 151 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,113 @@
11
package runtime
22

33
import (
4+
"context"
45
"strings"
6+
"sync"
7+
"sync/atomic"
58
"time"
69

10+
"go.uber.org/zap"
11+
12+
"github.com/smart-mcp-proxy/mcpproxy-go/internal/contracts"
13+
"github.com/smart-mcp-proxy/mcpproxy-go/internal/oauth"
714
"github.com/smart-mcp-proxy/mcpproxy-go/internal/telemetry"
815
)
916

17+
// serversChangedCoalescer collapses bursts of servers.changed events into at
18+
// most one publish per interval window, last-write-wins. See spec 047 §B2.
19+
//
20+
// The producer (emitServersChanged) builds a fully-realised Event and stores
21+
// it in `pending` (atomic.Pointer). Producers signal the drainer via the
22+
// `wake` channel. The drainer goroutine wakes on either a wake signal or its
23+
// own timer; on wake it sleeps until the interval has elapsed since the last
24+
// publish, then atomically swaps `pending` and publishes the swapped event.
25+
//
26+
// Tests can drive the coalescer synchronously via flushNow() instead of
27+
// sleeping for the interval.
28+
type serversChangedCoalescer struct {
29+
rt *Runtime
30+
pending atomic.Pointer[Event]
31+
wake chan struct{}
32+
interval time.Duration
33+
34+
// flush coordination
35+
flushMu sync.Mutex
36+
lastFlush time.Time
37+
}
38+
39+
func newServersChangedCoalescer(rt *Runtime, interval time.Duration) *serversChangedCoalescer {
40+
return &serversChangedCoalescer{
41+
rt: rt,
42+
wake: make(chan struct{}, 1),
43+
interval: interval,
44+
}
45+
}
46+
47+
// submit stores `evt` as the latest pending event (overwriting any prior
48+
// pending event) and signals the drainer.
49+
func (c *serversChangedCoalescer) submit(evt Event) {
50+
c.pending.Store(&evt)
51+
select {
52+
case c.wake <- struct{}{}:
53+
default:
54+
// Drainer is already armed.
55+
}
56+
}
57+
58+
// flushNow publishes any pending event immediately. Intended for tests and
59+
// for shutdown drain.
60+
func (c *serversChangedCoalescer) flushNow() {
61+
evt := c.pending.Swap(nil)
62+
if evt == nil {
63+
return
64+
}
65+
c.flushMu.Lock()
66+
c.lastFlush = time.Now()
67+
c.flushMu.Unlock()
68+
c.rt.publishEvent(*evt)
69+
}
70+
71+
// start launches the drainer goroutine. It exits when ctx is canceled, after
72+
// a final flush of any residual pending event.
73+
func (c *serversChangedCoalescer) start(ctx context.Context) {
74+
go func() {
75+
ticker := time.NewTicker(c.interval)
76+
defer ticker.Stop()
77+
for {
78+
select {
79+
case <-ctx.Done():
80+
c.flushNow()
81+
return
82+
case <-c.wake:
83+
// Coalesce: hold for at most one interval before publishing.
84+
select {
85+
case <-ctx.Done():
86+
c.flushNow()
87+
return
88+
case <-time.After(c.interval):
89+
c.flushNow()
90+
}
91+
case <-ticker.C:
92+
// Periodic safety net: if a producer raced past the wake
93+
// signal slot (channel was full), this catches it.
94+
if c.pending.Load() != nil {
95+
c.flushNow()
96+
}
97+
}
98+
}
99+
}()
100+
}
101+
102+
// serversLister is the minimal slice of the management service needed by
103+
// emitServersChanged to fetch the current server list. The runtime stores the
104+
// management service as interface{} (avoiding an import cycle), so this local
105+
// interface is the type assertion target. Implemented by
106+
// internal/management.(*service).
107+
type serversLister interface {
108+
ListServers(ctx context.Context) ([]*contracts.Server, *contracts.ServerStats, error)
109+
}
110+
10111
// containsAny reports whether s contains any of the listed substrings (case-insensitive).
11112
// Used by Spec 042 telemetry classification on error messages — only the
12113
// resulting enum category is ever recorded; the message itself is never.
@@ -54,12 +155,60 @@ func (r *Runtime) publishEvent(evt Event) {
54155
}
55156

56157
func (r *Runtime) emitServersChanged(reason string, extra map[string]any) {
57-
payload := make(map[string]any, len(extra)+1)
158+
payload := make(map[string]any, len(extra)+3)
58159
for k, v := range extra {
59160
payload[k] = v
60161
}
61162
payload["reason"] = reason
62-
r.publishEvent(newEvent(EventTypeServersChanged, payload))
163+
164+
// Spec 047: embed the current server list and stats so SSE subscribers
165+
// (Swift tray, Web UI) can update local state without a follow-up
166+
// GET /api/v1/servers round trip. On any error, fall back to notify-only —
167+
// older clients (and resilient new clients) handle the missing keys.
168+
if lister, ok := r.managementService.(serversLister); ok && lister != nil {
169+
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
170+
servers, stats, err := lister.ListServers(ctx)
171+
cancel()
172+
if err != nil {
173+
r.logger.Warn("emitServersChanged: ListServers failed; emitting notify-only event",
174+
zap.String("reason", reason),
175+
zap.Error(err))
176+
} else {
177+
redacted := make([]contracts.Server, 0, len(servers))
178+
for _, s := range servers {
179+
if s != nil {
180+
redacted = append(redacted, *s)
181+
}
182+
}
183+
r.redactServerHeaders(redacted)
184+
payload["servers"] = redacted
185+
payload["stats"] = stats
186+
}
187+
}
188+
189+
evt := newEvent(EventTypeServersChanged, payload)
190+
if r.coalescer != nil {
191+
r.coalescer.submit(evt)
192+
return
193+
}
194+
r.publishEvent(evt)
195+
}
196+
197+
// redactServerHeaders mirrors httpapi.(*Server).redactServerHeaders. It strips
198+
// sensitive header values (Authorization, Cookie, X-API-Key, ...) unless the
199+
// loaded config opts out via reveal_secret_headers: true. Centralizing this in
200+
// the runtime keeps SSE subscribers behind the same trust boundary as the
201+
// HTTP API.
202+
func (r *Runtime) redactServerHeaders(servers []contracts.Server) {
203+
cfg := r.Config()
204+
if cfg != nil && cfg.RevealSecretHeaders {
205+
return
206+
}
207+
for i := range servers {
208+
if len(servers[i].Headers) > 0 {
209+
servers[i].Headers = oauth.RedactStringHeaders(servers[i].Headers)
210+
}
211+
}
63212
}
64213

65214
func (r *Runtime) emitConfigReloaded(path string) {

0 commit comments

Comments
 (0)