stacklok / stacklok/toolhive

vMCP: hang the per-session list_changed resync worker off the MultiSession instead of a hand-synced registry

Open
#6,418 0 comments 0 reactions 1 assignee View on GitHub

@premctl is already working on this.

Since Sep 9, 2026.

enhancement
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:

  1. handleSessionRegistrationImpl — the add after CreateSession succeeds
  2. the same function's error-path deferremove on registration failure
  3. binding-failure termination — remove
  4. pruneOnTerminateSessionIDManager.Terminate — a thin server.SessionIdManager wrapper that exists only so an SDK-initiated HTTP DELETE (which reaches Terminate without passing through any other server code) deregisters
  5. runListChangedResync's liveness guard — the lazy prune for sessions that ended with no Terminate call 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:

  • healthResyncRegistry and all five sync sites
  • pruneOnTerminateSessionIDManager (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

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.