Feature: CPU Hot-Path Fix (A1 + B1 + B2)
Branch: 047-cpu-hotpath-fix
Generated from: spec.md, plan.md, research.md, data-model.md, contracts/sse-events.md, quickstart.md
TDD note: per
CLAUDE.md"Test-Driven Progress", every implementation task is preceded by a failing-test task. Each phase below interleaves tests and code in that order.
The single observable user story (S0) is "MCPProxy core stays under 5% CPU at idle when the macOS tray is the only active client." Inside the spec, it decomposes into five independent sub-stories:
- US1 (P1) —
service.GetScanSummarycaches "no scans found" so untouched servers don't re-scan the bucket. Highest CPU yield; ships value alone. - US2 (P1) —
emitServersChangedincludes the server list and stats in the event payload. Required before clients can stop refetching. - US3 (P1) —
serversChangedCoalescercollapses bursts. Reduces event volume; complements US2. - US4 (P2) — Swift tray consumes the embedded payload, falls back to refetch when absent. Realises the perf win on macOS.
- US5 (P2) — Vue Web UI consumes the embedded payload, falls back to refetch when absent. Realises the perf win in the browser.
US1 alone delivers the core CPU win even before US2-5 ship. US2+US4+US5 together eliminate the trip-back-to-server entirely.
- T001 Confirm working tree clean except for the in-progress pprof endpoint at
internal/server/server.go(already on branch); preserve those changes for the PR.
(none — no blocking prerequisites; existing event bus, scanner cache, and Swift/Vue clients are already in place.)
Goal: A future call to GetScanSummary(name) for an unscanned server completes without touching BBolt.
Independent test: Unit test mocks the storage layer with a call counter; asserts that 10 consecutive GetScanSummary("never-scanned") invocations result in exactly 1 storage call.
- T002 [P] [US1] Add failing unit test
TestGetScanSummary_CachesNegativeResultininternal/security/scanner/service_test.gothat asserts a mock storage's call counter equals 1 after 10 consecutiveGetScanSummary("never-scanned")calls. - T003 [P] [US1] Add failing unit test
TestGetScanSummary_DoesNotCacheOnTransientErrorininternal/security/scanner/service_test.gothat asserts non-errNoScanserrors do not populate the cache. - T004 [P] [US1] Add failing unit test
TestGetScanSummary_OverwritesNilSentinelOnRealScanininternal/security/scanner/service_test.gothat asserts a real scan summary replaces the nil sentinel. - T005 [US1] Introduce
var errNoScans = errors.New("no scan jobs found for server")ininternal/security/scanner/service.go; havefindLatestPassJobsreturnerrNoScansfor the empty-bucket case (replacing the currentfmt.Errorfconstructions on lines that say "no scan jobs found for server"). - T006 [US1] In
internal/security/scanner/service.goGetScanSummary, whenfindLatestPassJobsreturns an error matchingerrNoScansviaerrors.Is, calls.cacheScanSummary(serverName, nil)before returningnil. Do not cache on any other error. - T007 [US1] Run
go test -race ./internal/security/scanner/... -vand confirm T002–T004 now pass.
Goal: Subscribers of /events receive servers.changed events whose payload contains the full post-redaction server list and aggregate stats.
Independent test: Subscribe to a fake bus, trigger emitServersChanged("test", nil), assert the published event's payload has non-nil servers and stats matching what mgmt.ListServers returned.
- T008 [P] [US2] Add failing unit test
TestEmitServersChanged_PayloadIncludesServersininternal/runtime/event_bus_test.gothat asserts a published event has a non-nilpayload["servers"]slice andpayload["stats"]value, both reflecting a fakemgmt.ListServersreturn. - T009 [P] [US2] Add failing unit test
TestEmitServersChanged_FallsBackToNotifyOnlyWhenListServersFailsin the same file that asserts whenmgmt.ListServerserrors, the event still publishes with justpayload["reason"](noserverskey) and a Warn log line is emitted. - T010 [P] [US2] Add failing unit test
TestEmitServersChanged_RedactsSensitiveHeadersin the same file that asserts header values matching the existing redaction predicate appear masked inpayload["servers"]. - T011 [US2] In
internal/runtime/event_bus.goemitServersChanged, after mergingextraand settingreason, call the management service'sListServers(use the existingr.mgmtor equivalent injection point on*Runtime). On success, setpayload["servers"] = redactServerHeaders(servers)andpayload["stats"] = stats. On error, log Warn and skip the keys. - T012 [US2] If
redactServerHeadersis currently package-private tohttpapi, hoist a copy intointernal/runtime/redaction.go(or expose via a small adapter) so the runtime can call it without circular import. Keep behavior identical to the HTTP handler. - T013 [US2] Run
go test -race ./internal/runtime/... -vand confirm T008–T010 pass.
Goal: A storm of emitServersChanged calls within a 50 ms window publishes ≤ 1 event with the most recent payload.
Independent test: Fire 100 calls within 10 ms; assert the bus receives exactly 1 event in the next 50 ms whose reason matches the last call's reason.
- T014 [P] [US3] Add failing unit test
TestCoalescer_CollapsesBurstToSingleEventininternal/runtime/coalescer_test.go(new file) using a fake clock +flushNow()hook to drive the drainer deterministically. - T015 [P] [US3] Add failing unit test
TestCoalescer_LastWriteWinsasserting the published event'sreasonequals the last submitted call's reason. - T016 [P] [US3] Add failing unit test
TestCoalescer_FlushesOnShutdownasserting a final event publishes when the runtime's stop is invoked while one is pending. - T017 [P] [US3] Add failing unit test
TestCoalescer_NoStarvationOnSingleEventasserting a single submitted event publishes within ~1 interval period. - T018 [US3] In
internal/runtime/event_bus.go, addserversChangedCoalescerstruct withpending atomic.Pointer[Event],wake chan struct{},interval time.Duration, plus methodssubmit(*Event),flushNow()(test hook), and a private drainer goroutine that loops onselect { case <-time.After(interval): ...; case <-r.shutdown: ... }. - T019 [US3] In
internal/runtime/runtime.go, instantiate the coalescer inRuntime.New(interval 50 ms), start the drainer inRuntime.Start, and stop it inRuntime.Stop(final flush, then exit). - T020 [US3] In
emitServersChanged, replace the directr.publishEventcall withr.coalescer.submit(newEvent(EventTypeServersChanged, payload)). - T021 [US3] Run
go test -race ./internal/runtime/... -vand confirm T014–T017 pass.
Goal: When the Swift tray receives a servers.changed SSE event whose payload includes a servers array, it updates appState.servers directly. If the array is missing, it falls back to refreshServers().
Independent test: XCTest using a stubbed SSEClient that emits a synthetic event; assert appState mutates and no APIClient.listServers call fires.
- T022 [P] [US4] Add failing XCTest in
native/macos/MCPProxy/MCPProxyTests/SSEHandlerTests.swift(new file) namedtestServersChangedWithPayloadUpdatesStateWithoutRefetch. - T023 [P] [US4] Add failing XCTest
testServersChangedWithoutPayloadFallsBackToRefetchin the same file. - T024 [US4] In
native/macos/MCPProxy/MCPProxy/Core/CoreProcessManager.swift, modify thecase "servers.changed":branch ofhandleSSEEvent. Decode the event'spayloadfield; ifpayload.serversis present and non-null, decode into[Server](existing struct fromAPI/Models.swift), updateappState.serverson the main actor, and skip therefreshServers()call. Otherwise, keep current behavior. - T025 [US4] Update
native/macos/MCPProxy/MCPProxy/API/Models.swiftif needed to add aServersChangedPayloaddecode struct withservers: [Server]?andstats: ServerStats?(both optional for forward compat). - T026 [US4] Build the tray binary (see
quickstart.md) and confirm it runs against the v0 core (notify-only path) and the v1 core (payload path).
Goal: When the Web UI's SSE store receives a servers.changed event whose payload includes a servers array, it merges into the Pinia store directly. Missing array falls back to fetch('/api/v1/servers').
Independent test: Vitest using a fake EventSource emitting a synthetic event; assert store updates and no fetch call to /api/v1/servers fires.
- T027 [P] [US5] Add failing Vitest in
frontend/src/__tests__/sse-handler.test.ts(or the existing equivalent path) named'servers.changed with payload updates store without refetch'. - T028 [P] [US5] Add failing Vitest
'servers.changed without payload falls back to fetch'in the same file. - T029 [US5] Locate the SSE handler (likely
frontend/src/composables/useEventStream.tsorfrontend/src/stores/server.ts); modify theservers.changedbranch to decodepayload.servers/payload.stats, write to the store directly, and skip the refetch when both are present. - T030 [US5] Run
npm run test --prefix frontendand confirm T027–T028 pass.
- T031 Run
go test -race ./internal/... -v(full Go suite) and confirm green. - T032 Run
./scripts/test-api-e2e.shand confirm green. - T033 Run
./scripts/run-linter.shand confirm green. - T034 Run
go vet ./...andgofmt -l .(must produce no output). - T035 Build the personal-edition binary (
make build) and the server-edition (make build-server). - T036 Capture verification pprof on the same MCPProxy.app + 30-server scenario per
quickstart.md. Save artifacts tospecs/047-cpu-hotpath-fix/verification/cpu_post.pb.gz,cputime_delta.txt, andreport.html. Assert the thresholds inspec.md"Acceptance Criteria". - T037 Run
mcpproxy-ui-testMCP tools end-to-end:screenshot_status_bar_menu,list_menu_items(assert server names match config), trigger an enable/disable, capture screenshots before/after. Save underspecs/047-cpu-hotpath-fix/verification/tray-*.png. - T038 Run the Playwright Web UI sweep per CLAUDE.md "Verifying Web UI changes" pattern. Save the report HTML to
specs/047-cpu-hotpath-fix/verification/webui-report.html. - T039 Commit all of
internal/,native/macos/,frontend/,specs/047-cpu-hotpath-fix/(including verification artifacts) plus the pprof endpoint changes already on the branch. - T040 Push branch and
gh pr createwith a description summarising A1+B1+B2, observed CPU before/after, and links to the verification artifacts. - T041 Watch CI; iterate on failures (lint / test / build) until the PR is green.
Phase 1 (Setup)
└─ Phase 3 (US1) ──┐
├─→ Phase 8 (Polish)
Phase 4 (US2) ──┤
Phase 5 (US3) ──┤ (US3 depends on US2's emit path)
Phase 6 (US4) ──┤ (US4 depends on US2 having shipped)
Phase 7 (US5) ──┘ (US5 depends on US2 having shipped)
US1 is completely independent and can ship first. US2/US3 must be sequenced (US3 calls into US2's modified emit path). US4/US5 each depend on US2 being merged but are independent of each other.
- T002, T003, T004 (test files) — same file, different test functions; serialise.
- T008, T009, T010 — same.
- T014, T015, T016, T017 — same.
- T022 + T023, T027 + T028 — same.
- All [P] markers within a phase indicate the test functions can be authored in parallel as long as they all land before the implementation tasks in that phase.
US1 alone is the MVP. It cuts ~80% of the observed CPU and ships independently. US2-US5 lift the remaining ~20% by eliminating the round trip. We ship all five together in this PR per the user's preference, but the breakdown is preserved here so a partial revert is possible if needed.
- Every task is a markdown checkbox.
- Every task has a TaskID (T001–T041).
- Story labels [US1]–[US5] applied to phases 3–7 only.
- Setup/Foundational/Polish phases have no story label.
- Every task references a concrete file path or command.