fix(upstream): stop leaking a health-check goroutine on every reconnect - #947
Conversation
startBackgroundMonitoring() spawned a backgroundHealthCheck goroutine unconditionally. It is reached from Connect(), and tryReconnect() re-enters Connect() after tearing down only the core client -- it never calls the managed Client's Disconnect(), which is the sole caller of stopBackgroundMonitoring(). So every reconnect added another 30s ticker that lived until the process exited, and none of the previous ones stopped. Each leaked ticker probes liveness with ListTools(), so the cost scales with the upstream's tool-catalog size. On an affected install one server with a large catalog was being probed by ~16 concurrent tickers -- 32.5 calls/min against a configured interval of 2/min -- pushing ~80 kB per probe. The downstream MCP audit log had accumulated 72 GB, and the daily row count grew steadily between restarts, which is the signature of the accumulation. Guard the start so it is idempotent. Safe without extra locking: both call sites already hold mc.mu, and stopBackgroundMonitoring() resets monitoringStarted and recreates the stop channel, so a later reconnect still gets a fresh monitor. Note the tempting alternative -- having tryReconnect() call the managed Disconnect() -- deadlocks on mc.mu and discards the retry/backoff state that the adjacent ResetForReconnect() deliberately preserves. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
Dumbris
left a comment
There was a problem hiding this comment.
Thank you for this contribution — this is a model bug report and fix. The diagnosis of the reconnect path (tryReconnect() tearing down only the core client while stopBackgroundMonitoring() is only reachable from the managed Disconnect()) is exactly right, and the field data quantifying the impact made it easy to confirm.
I verified locally: the regression test fails on main (expected: 1, actual: 3) and passes with the guard, stable across 30 -race runs; go test -race ./internal/upstream/... and the strict CI linter are clean. Also appreciated the write-up of the rejected alternative — calling the managed Disconnect() from tryReconnect() would indeed self-join on monitoringWG.Wait() since tryReconnect runs inside the health-check goroutine.
Merging. 🙏
Pull Request
Description
startBackgroundMonitoring()spawns abackgroundHealthCheckgoroutine unconditionally, so a managed client can end up with many concurrent health-check tickers.The path that leaks:
startBackgroundMonitoring()is reached fromConnect()(client.go:321).tryReconnect()tears down only the core client —mc.coreClient.Disconnect()— then callsmc.Connect(ctx)again.stopBackgroundMonitoring()is only ever called from the managed client'sDisconnect()(client.go:353), which that path never reaches.So every reconnect adds another 30-second ticker and none of the earlier ones ever stop. They accumulate for the life of the process and reset only on restart.
Because each ticker probes liveness via
ListTools(), the cost scales with the upstream's tool-catalog size. On the install where I found this, onestreamable-httpserver with a large catalog was being probed by ~16 concurrent tickers — 32.5 calls/min against a configured 2/min, at roughly 80 kB per response. Downstream, the MCP audit log on the server being probed had grown to 72 GB across 911ktools/listrows, and its daily row count climbed steadily between restarts (10k/day → 65k/day over ten days, then back to near-zero after a restart) — the signature of the accumulation rather than a fixed-rate load.The fix
Make the start idempotent:
No extra locking is needed: both call sites already hold
mc.mu(Connect()takes it atclient.go:317,Disconnect()defers it), andstopBackgroundMonitoring()already resetsmonitoringStartedand recreates the stop channel, so a later reconnect still gets a fresh monitor.I want to flag the alternative I rejected, in case it looks more natural to you: having
tryReconnect()call the managedDisconnect()instead ofmc.coreClient.Disconnect(). That deadlocks onmc.mu, and it would discard the retry/backoff state that the adjacentResetForReconnect()call deliberately preserves.Effect after deploying this
Measured on the affected install, same server, before and after the rebuild:
Testing
internal/upstream/managed/monitoring_leak_test.gocallsstartBackgroundMonitoring()three times and asserts the goroutine delta. It fails onmain(expected: 1, actual: 3) and passes with the guard, then assertsstopBackgroundMonitoring()returns the count to baseline.What I ran locally:
go test -race ./internal/upstream/...— pass (this is the changed package and its neighbours)go test ./internal/...— every package that completed passes (46 of them).internal/server,internal/trayandinternal/tuidid not finish inside my sandbox's time limit, so I'm relying on CI for those; nothing in this change touches them.go build ./...,go vet ./internal/upstream/managed/,gofmt -l— all cleanI did not run
scripts/run-all-tests.shend-to-end (the E2E portion needs a built binary driving live servers, which my environment couldn't host).🤖 Generated with Claude Code
https://claude.ai/code/session_01GsbwvZM4fxioyop4PM7z4d