Add idle-session TTL and cap to bound session memory growth - #19
Conversation
transports/sessionAuth are only reaped via transport.onclose, which never fires for a session that's abandoned rather than closed cleanly - each one pins a full McpServer instance, so long-lived deployments can accumulate unbounded memory over time. Adds an idle-session TTL sweep (mirrors the existing kv-store.ts pattern), a hard session cap as an independent backstop, and session count/RSS on /health so this is observable going forward.
|
bookstack-mcp/src/session-registry.ts Lines 51 to 57 in d4faded Watch for: after a sweep,
Two smaller ones, not blocking:
Close-before-delete in both eviction paths plus a |
sweepExpired() and purgeIfExpired() dropped the transports/lastSeen bookkeeping without ever calling transport.close() first, unlike shutdown() (which already does close-then-delete correctly). That leaked the transport (and the McpServer + tool closures it holds) on every TTL-based eviction, silently working around the memory-growth fix just added in d4faded. Add a shared evict() helper that closes best-effort before calling delete(), and use it from both eviction paths. Also give the fake transport in the tests a close() spy so a missing close() call is actually detectable, and assert it fires from both the periodic sweep and the lazy on-access purge. Flagged in review. Not addressed here, tracked as follow-up: atCapacity()/ size() don't run the purge check first, and there's a race between atCapacity() and register().
|
Both blockers are fixed. The test is a real spy rather than a stub:
Two new ones from this push, neither blocking:
Still open from last round, still not blocking: Watch for: eviction closes the transport now, so an idle client is disconnected server-side at 30m instead of just forgotten — its next POST gets Good to merge. |
Summary
transports/sessionAuth, nowSessionRegistry) was only ever cleaned up viatransport.onclose. A session that gets abandoned instead of closed cleanly (client vanishes, network drop, no clean shutdown) never fires that callback, so its entry — and the fullMcpServerinstance + tool closures it pins — is never released. Over the lifetime of a long-running deployment this is unbounded memory growth with no cap.SessionRegistry, mirroring the existing periodic-sweep pattern already used inoauth/kv-store.ts'sInMemoryKvStore: asetInterval(unref'd) evicts sessions idle past the TTL, with a lazy expiry check on access as a backstop between sweep ticks.sweepExpired()and the lazypurgeIfExpired()) now close the transport before dropping it from bookkeeping, via a sharedevict()helper — matching the close-then-delete orderingshutdown()already used.evict()closes fire-and-forget (not awaited, unlikeshutdown()), sincesweepExpired()runs inside asetIntervalcallback with no process-exit coordination to synchronize with — intentional, not a regression.BOOKSTACK_MAX_SESSIONS) as an independent backstop to the TTL logic — new session creation is rejected once at capacity./healthnow reports the current session count andprocess.memoryUsage().rss, and the server logs the configured idle TTL and session cap on startup..env.example:BOOKSTACK_SESSION_IDLE_TTL_MS(default 30 minutes) andBOOKSTACK_MAX_SESSIONS(default 1000).Test plan
src/session-registry.test.ts:dispose().src/util/semaphore.test.ts) passes unchanged.npm run type-checkpasses.SessionRegistryclass, not exercised under live load or a real abandoned-connection scenario.