Files
pad/internal/server/middleware_mcp_session.go
T
xarmian 40621ff58d feat(metrics): session-id-keyed TTL sweep for mcp_active_sessions (TASK-1120) (#400)
* feat(metrics): session-id-keyed TTL sweep for mcp_active_sessions (TASK-1120)

Replaces the naive +1/-1 active-sessions accounting from TASK-961.
The old logic bumped on JSON-RPC `initialize` and decremented on HTTP
DELETE — but a client that crashed, lost network, or restarted
mid-session never emitted DELETE, so the gauge drifted upward
monotonically until the pad-cloud server restarted.

Approach:

- `internal/server/middleware_mcp_session.go` (new) — mcpSessionTracker
  is an in-memory map keyed by Mcp-Session-Id (the canonical header
  set by mcp-go's StreamableHTTPServer on initialize responses and
  echoed by the client on subsequent requests). Touch updates
  lastSeen on insert + refresh; evict removes; periodic sweep evicts
  entries older than the TTL.
- Gauge is `Set(len(sessions))` via an onChange callback — single
  consistent observation per state-changing op, no risk of gauge
  drifting from map size on a multi-evict sweep.
- Lifecycle: spawned by SetMCPTransport (alongside startMCPAuditWriter),
  shut down from Server.Stop. Idempotent on both sides.
- Configurable via PAD_MCP_SESSION_TTL (default 30m) and
  PAD_MCP_SESSION_SWEEP_INTERVAL (default 5m). cmd/pad calls
  Server.SetMCPSessionTrackerConfig before SetMCPTransport.

Other changes:

- `recordMCPCallMetrics` no longer touches the active-sessions gauge.
  Updated comment + signature kept (callers pass the same args; the
  unused params are explicitly underscored).
- `MCPAuditLog` middleware now calls trackMCPSession after
  next.ServeHTTP — single new line in the audit hot path.
- `TestMCPAudit_BufferFull_DropsAndIncrementsCounter` updated to also
  shut down the new session tracker before bg.Wait(), since
  SetMCPTransport now spawns two goroutines on srv.bg.

Test coverage (16 tests, all green under -race):
- Tracker unit: touch insert/dedup, empty-id no-op, evict
  remove/non-existent, sweep eviction with single onChange,
  nil-onChange safety, concurrent touch/evict, run() clean shutdown.
- Server-side integration: lifecycle happy path (initialize → call →
  DELETE leaves gauge at 0), failed initialize doesn't open,
  no-session-id no-op, nil tracker safety, idempotent start, DELETE
  evicts on any status (transient 5xx on shutdown still counts).
- Regression guard: TestRecordMCPCallMetrics_DoesNotTouchSessionGauge
  pins that the audit-side helper has migrated off the gauge.

Parent: PLAN-943. Follow-up to TASK-961 (PR #398). Closes the
"sessions drift on client crashes" caveat documented in the metric's
help text + the Grafana panel description.

* fix(metrics): emit Mcp-Session-Id + serialize gauge updates per Codex review (round 1)

Two findings from Codex review on PR #400:

1. WithStateLess(true) wired StatelessSessionIdManager whose Generate()
   returns "" — mcp-go never set the Mcp-Session-Id response header
   in production, so the new tracker no-op'd on every initialize and
   the active-sessions gauge stayed at 0.

   Fix: introduce padMCPGenerateOnlySessionIDManager in cmd/pad/main.go.
   Generates a UUID per initialize (so the response carries the
   header — tracker can observe), but Validate accepts ANY incoming
   value (including empty / arbitrary). Preserves the original
   "stateless server, every request stands alone" contract while
   making the session-id observable. Documented why mcp-go's two
   shipped stateless managers don't fit (one breaks observability,
   the other breaks back-compat for clients that never echo the ID).

2. touch / evict / sweep computed `len(sessions)` under the mutex
   then released the lock BEFORE invoking onChange. Two concurrent
   inserts could compute (n=1, n=2) under the lock and then race the
   callback writes — last writer wins on the gauge, leaving it
   permanently inconsistent with the map size.

   Fix: hold the mutex across onChange. Trade-off documented: any
   future onChange that re-enters the tracker would deadlock, but
   that's a clear failure mode rather than silent metric corruption.
   Added TestMCPSessionTracker_OnChangeUnderLock that asserts a
   strictly-monotonic observation sequence under 32-goroutine
   concurrent inserts; passes 5x in a row under -race.
2026-05-03 17:40:18 -04:00

294 lines
10 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package server
import (
"log/slog"
"sync"
"time"
)
// MCP session tracker (PLAN-943 TASK-1120).
//
// Replaces the naive "+1 on initialize, -1 on HTTP DELETE" gauge
// accounting that TASK-961 shipped. The naive scheme drifted up over
// time because clients that crash, lose network, or restart mid-
// session never emit the spec'd DELETE — the gauge was monotonically
// non-decreasing under real-world failures.
//
// This tracker keeps an in-memory map keyed by the canonical
// `Mcp-Session-Id` header (set by mcp-go's StreamableHTTPServer on
// every initialize response and echoed by the client on every
// subsequent request). Every request that carries a session-id
// touches the entry's lastSeen; explicit DELETE evicts; and a
// periodic sweeper evicts entries older than the configured TTL.
//
// Gauge semantics:
//
// - The active-sessions Prometheus gauge is set to len(sessions)
// by the onChange callback on every state-changing op. Set
// (not Inc/Dec) so concurrent observers always see a value
// consistent with the post-op map state — no possibility of
// gauge ≠ map size after a sweep evicts N at once.
// - onChange fires only when the size actually changes, so a
// touch on an existing entry doesn't churn the gauge.
//
// Bounds:
//
// - One tracker per Server (see s.mcpSessions, wired by
// startMCPSessionTracker). Cross-process aggregation is not in
// scope; pad-cloud's Prometheus federates per-node series and
// the dashboard sums by job.
// - The map can grow unbounded between sweeps if a flood of
// sessions opens with no DELETE; bounded in practice by the
// OAuth token-issuance rate × the TTL window, which is
// sub-MB even pessimistically. If we ever see this become a
// real cap, the sweeper interval can be lowered without API
// impact.
const (
// defaultMCPSessionTTL is the default eviction window. Conservative
// enough that a long-idle agent session (Claude Desktop sitting
// open overnight while the user is in meetings) doesn't get
// double-counted, but tight enough that a crashed client clears
// within an hour. Override via PAD_MCP_SESSION_TTL.
defaultMCPSessionTTL = 30 * time.Minute
// defaultMCPSessionSweepInterval is how often the sweeper walks
// the map. Smaller than the TTL so a crashed session is evicted
// within ttl + sweep_interval at worst. Override via
// PAD_MCP_SESSION_SWEEP_INTERVAL.
defaultMCPSessionSweepInterval = 5 * time.Minute
)
// mcpSessionTracker is the session lifecycle bookkeeper described
// above. Field-level concurrency: mu guards sessions; ttl is
// immutable post-construction; stop is signalled-once via stopped.
type mcpSessionTracker struct {
mu sync.Mutex
sessions map[string]time.Time
ttl time.Duration
stop chan struct{}
stopped sync.Once
onChange func(count int)
}
// newMCPSessionTracker constructs a tracker with the given TTL and
// optional onChange callback. ttl <= 0 falls back to the default;
// onChange may be nil (the tracker still works, just without gauge
// updates — useful in tests).
func newMCPSessionTracker(ttl time.Duration, onChange func(count int)) *mcpSessionTracker {
if ttl <= 0 {
ttl = defaultMCPSessionTTL
}
return &mcpSessionTracker{
sessions: make(map[string]time.Time),
ttl: ttl,
stop: make(chan struct{}),
onChange: onChange,
}
}
// touch inserts or refreshes an entry. No-op on empty id (defensive:
// callers pre-filter, but a missing header should never bump anything).
// Fires onChange only when the size actually changes (insert, not
// refresh) so the gauge doesn't churn on every per-session tool call.
//
// Critical: onChange is called WHILE holding mu, not after the
// unlock. Codex round 1 on PR #400 caught the race — releasing the
// lock before the callback lets two concurrent inserts compute
// (n=1, n=2) under the lock, then race to write Set(1) and Set(2)
// on the gauge. Last writer wins on the gauge, but the map state
// is "2 sessions" — gauge would permanently disagree with size.
// Holding the lock serializes the (compute n, observe n) pair so
// every onChange invocation reflects a consistent map snapshot.
//
// Safety: onChange is the gauge.Set closure wired by
// startMCPSessionTracker; it doesn't reach back into the tracker so
// there's no re-entrancy risk. If a future caller wires an onChange
// that DID re-enter (e.g. calls touch from the callback), this will
// deadlock — that's a deliberate trade-off (correctness over
// re-entrancy) and the deadlock is a clear failure mode rather than
// silent metric corruption.
func (t *mcpSessionTracker) touch(id string) {
if id == "" {
return
}
t.mu.Lock()
defer t.mu.Unlock()
_, existed := t.sessions[id]
t.sessions[id] = time.Now().UTC()
if !existed && t.onChange != nil {
t.onChange(len(t.sessions))
}
}
// evict removes an entry. No-op on empty id or unknown id. Fires
// onChange only when an entry was actually removed. See touch's
// comment for why onChange runs under the lock.
func (t *mcpSessionTracker) evict(id string) {
if id == "" {
return
}
t.mu.Lock()
defer t.mu.Unlock()
_, existed := t.sessions[id]
delete(t.sessions, id)
if existed && t.onChange != nil {
t.onChange(len(t.sessions))
}
}
// sweep walks the map, evicts entries older than ttl, and returns
// the eviction count. onChange fires once at the end with the new
// total — single observation regardless of how many were evicted,
// which avoids spurious gauge oscillation on a large sweep. Same
// lock-held-across-callback contract as touch / evict.
func (t *mcpSessionTracker) sweep() int {
cutoff := time.Now().UTC().Add(-t.ttl)
t.mu.Lock()
defer t.mu.Unlock()
var evicted int
for id, last := range t.sessions {
if last.Before(cutoff) {
delete(t.sessions, id)
evicted++
}
}
if evicted > 0 && t.onChange != nil {
t.onChange(len(t.sessions))
}
return evicted
}
// size returns the current entry count. Used by tests and by
// startMCPSessionTracker's initial gauge prime.
func (t *mcpSessionTracker) size() int {
t.mu.Lock()
defer t.mu.Unlock()
return len(t.sessions)
}
// run drives the periodic sweeper at the given interval. Exits on
// stop. Tracked via Server.bg so Stop() can drain in-flight sweeps
// before the process exits.
func (t *mcpSessionTracker) run(interval time.Duration) {
if interval <= 0 {
interval = defaultMCPSessionSweepInterval
}
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-t.stop:
return
case <-ticker.C:
if n := t.sweep(); n > 0 {
slog.Debug("mcp session sweep evicted stale entries", "evicted", n, "remaining", t.size())
}
}
}
}
// shutdown signals the sweeper to exit. Idempotent — safe to call
// from Server.Stop alongside other shutdown paths that may have
// already fired during a test that exercises shutdown twice.
func (t *mcpSessionTracker) shutdown() {
t.stopped.Do(func() {
close(t.stop)
})
}
// SetMCPSessionTrackerConfig stashes ttl + sweep-interval overrides
// for the session tracker before it's started. Either may be 0 to
// keep the package default. Must be called before SetMCPTransport
// (which spawns the tracker via startMCPSessionTracker); calling
// after has no effect because the tracker reads these fields once
// at construction time.
//
// Wired by cmd/pad from PAD_MCP_SESSION_TTL /
// PAD_MCP_SESSION_SWEEP_INTERVAL so operators can tune the gauge's
// staleness floor without recompiling.
func (s *Server) SetMCPSessionTrackerConfig(ttl, sweepInterval time.Duration) {
s.mcpSessionTTL = ttl
s.mcpSessionSweepInterval = sweepInterval
}
// startMCPSessionTracker constructs the tracker + spawns the sweep
// goroutine. Called once at startup from SetMCPTransport (alongside
// startMCPAuditWriter). No-op if already started — supports the
// test pattern where multiple Server instances over a shared store
// would otherwise double-spawn.
//
// Reads ttl + sweep interval from the Server fields populated by
// SetMCPSessionTrackerConfig. Zero values fall back to the package
// defaults.
func (s *Server) startMCPSessionTracker() {
if s.mcpSessions != nil {
return
}
onChange := func(int) {} // no-op default — replaced below if metrics are wired
if s.metrics != nil {
gauge := s.metrics.MCPActiveSessions
onChange = func(n int) {
gauge.Set(float64(n))
}
}
s.mcpSessions = newMCPSessionTracker(s.mcpSessionTTL, onChange)
sweepInterval := s.mcpSessionSweepInterval
s.goAsync(func() {
s.mcpSessions.run(sweepInterval)
})
}
// stopMCPSessionTracker signals the sweeper to exit. Called from
// Server.Stop. Idempotent.
func (s *Server) stopMCPSessionTracker() {
if s.mcpSessions == nil {
return
}
s.mcpSessions.shutdown()
}
// trackMCPSession is the per-request hook the audit middleware calls
// after next.ServeHTTP. It pulls the session id from the response
// header (set by mcp-go on initialize responses) or the request
// header (echoed by the client on subsequent requests), then either
// touches or evicts based on the request method.
//
// Method semantics:
// - HTTP DELETE on /mcp is the spec'd session-end signal. Evict
// unconditionally; failed DELETEs don't matter (the client
// considers the session over either way).
// - Anything else is a per-message call; touch updates lastSeen.
//
// Status filter: only touch on successful or unknown-status responses
// (httpStatus 0 covers the no-status case from inner handlers that
// never called WriteHeader). A failed initialize doesn't open a
// session — touching anyway would re-introduce the original drift bug
// in a subtler form. classifyMCPResult collapses 0+200 to "ok" for
// the audit row's status, so this matches the audit row's view.
//
// No-op when the tracker isn't wired (selfhost / tests).
const mcpSessionIDHeader = "Mcp-Session-Id"
func (s *Server) trackMCPSession(reqHeader, respHeader func(string) string, method string, httpStatus int) {
if s.mcpSessions == nil {
return
}
id := respHeader(mcpSessionIDHeader)
if id == "" {
id = reqHeader(mcpSessionIDHeader)
}
if id == "" {
return
}
if method == "DELETE" {
s.mcpSessions.evict(id)
return
}
if httpStatus != 0 && (httpStatus < 200 || httpStatus >= 300) {
return
}
s.mcpSessions.touch(id)
}