vMCP: hang the per-session list_changed resync worker off the MultiSession instead of a hand-synced registry
@premctl is already working on this.
Since Sep 9, 2026.
- Dominant language
- Go
- Stars
- 2.2k
- Forks
- 300
- Avg merge
- 1d 15h
- Merged PRs (30d)
- 184
Description
Background
#6196 (PR 1 of #5786) added healthResyncRegistry in pkg/vmcp/server/serve_health_resync.go: a second index of live sessions, holding each session's listChangedResyncWorker for the KindTools capability so a backend health transition can fan out a tools resync to already-connected sessions.
The registry has to be hand-synchronised from five sites:
handleSessionRegistrationImpl— theaddafterCreateSessionsucceeds- the same function's error-path
defer—removeon registration failure - binding-failure termination —
remove pruneOnTerminateSessionIDManager.Terminate— a thinserver.SessionIdManagerwrapper that exists only so an SDK-initiated HTTP DELETE (which reachesTerminatewithout passing through any other server code) deregistersrunListChangedResync's liveness guard — the lazy prune for sessions that ended with noTerminatecall at all
Why it's shaped that way
The two lifecycle events sessionmanager owns internally have no hook: TTL expiry, and node-local cache eviction (the eviction callback in sessionmanager/session_manager.go only calls sess.Close()). Site 5 exists to cover them, and site 4 exists to cover a path that bypasses the server entirely.
That indirection has a visible cost already: because the lazy prune only runs when a fan-out happens, the registration in site 1 must also be gated on health monitoring being enabled (s.backendHealth() != nil) — with no monitor there is no OnChange subscriber, so no fan-out, so the prune never runs, and any session ending without a server-observed Terminate would retain its worker closure (the SDK ClientSession plus the captured identity and forwarded-header map) for the process lifetime. That gate is a fix for a leak that only exists because the worker's lifetime is tracked separately from the session's.
Proposal
Attach the resync worker (the KindTools one, or the whole per-kind worker set from buildListChangedSink) to the MultiSession, so its lifetime is the session's, and let the health fan-out enumerate live sessions through the session manager instead of a parallel map.
That would delete, in one move:
healthResyncRegistryand all five sync sitespruneOnTerminateSessionIDManager(the wrapper's only reason to exist)- the health-monitoring-enabled gate on registration, and the leak it guards against
- the lazy-prune branch in
runListChangedResync
It also gives the optimizer-mode follow-up (PR 2 of #5786) a cleaner attachment point: that work needs per-session optimizer state rebuilt on a health change, which is exactly the kind of thing that wants to hang off the session rather than off another side table.
Notes
Raised by @aponcedeleonch in review of #6196: https://github.com/stacklok/toolhive/pull/6196#discussion_r3814706791 — "healthResyncRegistry is a second session index that has to be hand-synced from five sites, and the two lifecycle events sessionmanager owns internally have no hook, which is exactly why the gap exists. […] Worth a follow-up issue if you agree." Agreed, and filing as discussed — deliberately kept out of #6196 to avoid widening a review already at the size guideline.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Assessment
This issue has not been assessed yet.