Files
pad/internal/server/stream_admission.go
T
xarmian 7825f3afc5 fix(mcp): MCP's long-lived streams keep alive and take a stream admission slot (BUG-3835) (#2197)
* fix(mcp): MCP's long-lived streams keep alive and take a stream admission slot (BUG-3835)

An idle MCP event stream made no write, so #2182's write deadline never
fired on it: a client that stopped reading but held TCP open pinned a
handler goroutine and its session until a notification happened to be
written, and a signed-in client could open any number of them.

- A ': keepalive' comment at the /events cadence on any response the
  transport commits as 200 text/event-stream, written through the same
  bounded writer. mcp-go writes each SSE event with one Write, so a mutex
  at Write boundaries keeps keepalives between events. No keepalive after
  a JSON-RPC response (the final event of a POST stream) or after the
  handler returns. mcp-go's own heartbeat is not used: it sends ping
  requests every client must answer.
- The shared streamAdmission gate, keyed by streamPrincipal, taken at
  request entry for the two long-lived doors: GET /mcp and a
  subscriptions/listen POST. A refusal is the same plain 429
  sse_limit_exceeded with Retry-After 5, sent before the transport runs. A
  tool call upgraded to SSE mid-call is not counted: a refusal there would
  land after its side effects. Listen is detected by decoding the whole
  body exactly as mcp-go does (encoding/json into a "method" field), not by
  a fixed-size peek that key order could evade.
- Docs, config, the startup log and the metric help say the budget now
  covers MCP's long-lived streams.

Credential expiry for /mcp streams is BUG-3837.

Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr

* docs(mcp): the keepalive's shutdown window, the event-store premise and the chatgpt mount, stated precisely (BUG-3835)

Codex r1 (Medium, proposed declined): a keepalive tick between the
transport's return and end() can add one comment frame; it is still inside
mcpBoundWrites, which joins the keepalive goroutine before returning, so
net/http never sees a write after the handler. The comments now say so.
Codex r1 (Low): GET /mcp/chatgpt is admitted too; the docs named only /mcp.
Hunter r1: the one-Write premise holds only without an event store
(writeSSEEventRaw writes twice); stated at the premise. Detection of a
stalled idle client takes up to about three keepalive intervals; stated in
docs/api.md.

Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr

* fix(mcp): a panicking transport stops the keepalive; MCP streams are stated long-lived without a credential re-check (BUG-3835)

Hunter r2 (Low-Medium): the keepalive shutdown ran after next.ServeHTTP
without defer, so a panicking transport left it running past the handler,
with chi's Recoverer swallowing the panic. Now deferred; pinned by
TestMCP_BUG3835_PanickingTransportStopsTheKeepalive (red when not deferred).

Hunter r2 (Medium): docs/api.md and two code comments said no MCP route is
long-lived. The MCP transport's GET and subscriptions/listen streams are,
and do not yet re-check their credential; that is BUG-3837.

Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr

* docs(mcp): the keepalive catches dead peers; a live non-reader is bounded by the budget, not detected (BUG-3835)

r3 hunter Medium: with default socket buffers a comment every 30s takes days
to fill them, so the "caught within about three keepalive intervals" claim
held only in the shrunk-buffer tests. api.md, the file header comment and the
stalled-reader test comment now say what the keepalive catches and what the
budget bounds. Codex r3 Low: deployment.md's stream gauge and checklist now
include MCP's long-lived streams. The end()/ended comments state the
one-frame window between the transport's return and end().

Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr

* fix(mcp): the admission body read is in the body census; dead-peer detection is stated as TCP gives it (BUG-3835)

r4 blind hunter:
- M1: mcpLongLivedRequest reads the request body and was not in the body
  reader census, so TestEveryRequestBodyReaderIsAccountedFor failed. It is
  now listed, with the reason and the second-buffer cost the lead accepted.
- M3: a half-open peer accepts keepalive writes into the socket buffer until
  TCP gives up on it; "within one keepalive plus the write deadline" held only
  in the shrunk-buffer tests. api.md and the header comment now say so.
- L3: the panicking-transport test waits for its first keepalive, read under
  the writer's mutex, instead of sleeping a fixed 10ms (M7 still red).

Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr

* fix(mcp): admission reads a POST body only when its protocol header is modern; r6 fixes (BUG-3835)

Pad serves the legacy MCP protocol versions only, and mcp-go serves a
subscriptions/listen only on a modern-era POST whose Mcp-Protocol-Version
header is modern and matches _meta. So a POST without a modern header is
never a listen, and admission no longer reads its whole body a second time
(r6 hunter M1, gate approved by the lead). The docs, the metrics help, the
startup log and the census reason say listen is counted once the modern era
is served. api.md states the keepalive outlives a proxy idle timeout until
BUG-3837, the deployment note says the per-user bucket is shared with the
browser's /events, and a test pins isFinalSSEEvent against the real
transport's response frame.

Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr

* fix(mcp): admission looks for a listen only on a protocol version the transport serves; r7 fixes (BUG-3835)

The listen gate now also requires the header's version to be one the mount's
transport serves, read through mcp-go's SupportsProtocolVersion (forwarded
through the ChatGPT wrapper). A client probing an unserved modern version at a
full budget reaches mcp-go and gets the 400 it falls back on, not a 429 (r7
ruling). Pad serves the legacy versions only, so no POST body is read for
admission today, which the docs now say. New tests pin that mcp-go never
serves a listen without a modern header (with a control that it serves a
well-formed one), that an unserved modern version is not read or counted, and
that both production mounts report their served versions.

Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr

* test(mcp): the served-version gate is checked against the transports pad server mounts (BUG-3835)

r8 L2 (lead ruling): internal/mcp's wiring test builds its own transports,
so a shim around either production binding would silently send admission
back to counting every modern version. Server.MCPMountServesVersion exposes
the gate's read of the transport the server holds, and a cmd/pad test drives
wireMCP and asserts both /mcp and /mcp/chatgpt report their versions. Shim
mutants on each binding turn it red.

Claude-Session: https://claude.ai/code/session_012QxKpjqXTpU84auY5zZ8FR

* docs(mcp): the bus and mcpBoundWrites comments name the MCP stream slot and keepalive (BUG-3835)

r5 hunter 3 and hunter 6's doc half, accepted by the lead (16:04Z):
internal/events/bus.go said streamAdmission is acquired by "both
endpoints", falsified once MCP's long-lived streams acquire it too, and
mcpBoundWrites' doc comment did not mention the admission slot or the
keepalive. Comment-only.

Claude-Session: https://claude.ai/code/session_012QxKpjqXTpU84auY5zZ8FR

* docs(api): the stream budget's 429 is stated once, and the MCP keepalive has its own paragraph (BUG-3835)

r5 hunter 2, accepted by the lead (16:04Z): the admission paragraph stated
the 429 refusal twice, the second time after the keepalive text it no
longer fitted. Docs-only.

Claude-Session: https://claude.ai/code/session_012QxKpjqXTpU84auY5zZ8FR
2026-10-15 13:18:36 -04:00

218 lines
8.4 KiB
Go

package server
import (
"net/http"
"strconv"
"sync"
)
// streamAdmission bounds how many long-held streaming connections this
// process serves, globally and per user (BUG-2726).
//
// WHY IT IS NOT ON THE BUSES. Pad has two SSE endpoints, backed by two
// different buses: /api/v1/events (workspace-scoped, events.EventBus) and
// /api/v1/events/stream (user-scoped, watchevents.Bus). Each bus can bound
// its OWN subscribers atomically, and events.EventBus already does. Neither
// can bound the two together, because a shared global budget is a property
// of the PROCESS — the goroutine, the socket, the subscription and (for
// the watch stream) the presence registration cost the same whichever
// endpoint opened them. Putting the global on one bus would have let a
// user exhaust the machine through the other one while every configured
// limit still read as satisfied.
//
// So the global bound moves HERE, and events.EventBus.SubscribeIfAllowed
// no longer takes one at all. The per-WORKSPACE bound stays on that bus,
// because it is genuinely workspace-scoped and the watch stream has no
// coherent workspace to count against.
//
// MCP (BUG-3835): the transport's long-lived streams, a GET stream and a
// subscriptions/listen POST, on either MCP mount, take a slot here too, at request entry
// (admitMCPStream). A tool call upgraded to SSE mid-call does not.
//
// COMPATIBILITY: PAD_SSE_MAX_CONNECTIONS now covers both endpoints, so
// an operator who tuned it for one is bounding both and may reach the
// limit sooner. Deliberate — a knob that silently bounded half the
// connections it named fails invisibly, where this one announces itself
// and is tunable. The startup log reports the effective limits.
//
// TOCTOU: acquire takes the lock, checks and RESERVES in one critical
// section, exactly as events.EventBus.SubscribeIfAllowed does. Two
// concurrent requests cannot both pass a check for the last slot.
type streamAdmission struct {
mu sync.Mutex
total int
perUser map[string]int
maxTotal int
maxUser int
}
func newStreamAdmission(maxTotal, maxUser int) *streamAdmission {
return &streamAdmission{
perUser: map[string]int{},
maxTotal: maxTotal,
maxUser: maxUser,
}
}
// setLimits updates the bounds in place.
//
// In place rather than by replacing the gate, because a replacement
// silently OVER-GRANTS capacity: the new gate starts at zero while the
// old one still holds every open connection's slot, so those connections
// stop counting against the limit.
//
// The GAUGE would not expose that — a replaced gate is still reachable
// through Server.admission(), so the scrape reads the new gate and looks
// plausible while the budget is wrong. Which is why the test asserts
// admission behaviour instead.
func (a *streamAdmission) setLimits(maxTotal, maxUser int) {
a.mu.Lock()
a.maxTotal = maxTotal
a.maxUser = maxUser
a.mu.Unlock()
}
// admissionRefusal names which bound refused a connection, for the log
// line and the error message. Bounded values, safe to expose.
type admissionRefusal string
const (
admissionRefusalNone admissionRefusal = ""
admissionRefusalGlobal admissionRefusal = "global"
admissionRefusalUser admissionRefusal = "per_user"
)
// acquire reserves one stream slot for a PRINCIPAL. The returned release
// MUST be called when the connection ends; it is idempotent so a handler
// can defer it unconditionally.
//
// The principal is a user id where there is one. Where there is not —
// legacy workspace-scoped tokens and the fresh-install no-auth window,
// both of which /api/v1/events accepts — the caller supplies a
// workspace-derived key instead (see streamPrincipal). Skipping the bound
// for those callers would let ONE legacy-token holder fill the budget and
// 429 everyone else; bucketing them under a single empty string would
// make unrelated anonymous callers evict each other. Per workspace is the
// finest granularity actually available.
//
// The residual trade, stated rather than hidden: two legacy tokens for
// the SAME workspace share a bucket and can evict each other at the
// per-user limit. That is strictly better than unbounded, and the
// per-workspace bound already treats them as one population anyway.
//
// An empty principal still skips the per-user bound. It should not occur
// — both call sites supply one — and the guard is there so a future
// caller that forgets cannot accidentally collapse every connection into
// a single bucket.
func (a *streamAdmission) acquire(principal string) (release func(), refusal admissionRefusal) {
a.mu.Lock()
defer a.mu.Unlock()
if a.maxTotal > 0 && a.total >= a.maxTotal {
return nil, admissionRefusalGlobal
}
if a.maxUser > 0 && principal != "" && a.perUser[principal] >= a.maxUser {
return nil, admissionRefusalUser
}
a.total++
if principal != "" {
a.perUser[principal]++
}
var once sync.Once
return func() {
once.Do(func() {
a.mu.Lock()
defer a.mu.Unlock()
a.total--
if principal != "" {
a.perUser[principal]--
// Delete at zero so the map does not grow without bound
// on a deployment where many users connect once. Leaving
// zero entries behind would make this a slow leak keyed
// by user id.
if a.perUser[principal] <= 0 {
delete(a.perUser, principal)
}
}
})
}, admissionRefusalNone
}
// heldTotal returns the number of held slots. Backs
// pad_stream_connections_active as a scrape-time collector — see
// metrics.RegisterStreamConnectionsCollector for why that replaced a
// pushed gauge.
func (a *streamAdmission) heldTotal() int {
a.mu.Lock()
defer a.mu.Unlock()
return a.total
}
// streamLimitRetryAfterSeconds is the Retry-After a refused streaming
// connection is told to wait.
//
// A hint, not a promise: nothing reserves the slot, and a client that
// waits exactly this long may be refused again. It is here because a bare
// 429 tells a client only that it lost, and every client then picks its
// own guess — the CLI monitor's ladder starts at 5s, so this matches it
// rather than inventing a third number.
//
// Browsers' EventSource ignores Retry-After entirely and retries on its
// own schedule; that gap is BUG-2733, not something a header can close.
const streamLimitRetryAfterSeconds = 5
// writeStreamLimitExceeded answers a refused streaming connection.
//
// One helper for every streaming door (both SSE endpoints and MCP's
// long-lived streams), so the refusal contract cannot drift
// between them — status, error code, message and Retry-After are the
// same thing said once.
func writeStreamLimitExceeded(w http.ResponseWriter) {
w.Header().Set("Retry-After", strconv.Itoa(streamLimitRetryAfterSeconds))
writeError(w, http.StatusTooManyRequests, "sse_limit_exceeded", "Streaming connection limit reached")
}
// writeCollabLimitExceeded answers a collab WebSocket dial refused because
// the principal already holds PAD_COLLAB_MAX_PER_USER sockets (BUG-1308).
// Its own code rather than sse_limit_exceeded: the knob that lifts it is a
// different one. Retry-After matches the stream hint for the same reason that
// one exists; a browser WebSocket cannot read either, and the web provider's
// jittered backoff is what spaces its retries.
func writeCollabLimitExceeded(w http.ResponseWriter) {
w.Header().Set("Retry-After", strconv.Itoa(streamLimitRetryAfterSeconds))
writeError(w, http.StatusTooManyRequests, "collab_limit_exceeded", "Collaboration connection limit reached")
}
// counts returns the current global total and this user's count, for log
// lines. Snapshot only — never use it to decide admission, which is
// acquire's job under one lock.
func (a *streamAdmission) counts(principal string) (total, user int) {
a.mu.Lock()
defer a.mu.Unlock()
return a.total, a.perUser[principal]
}
// streamPrincipal is the key a request is bounded under: its user id when
// one resolved, otherwise a workspace-derived key for the callers that
// have no user — legacy workspace-scoped tokens (whose token carries a
// workspace id) and the fresh-install no-auth window (which does not, so
// the resolved workspace stands in).
//
// The "ws:" prefix keeps the two namespaces from ever colliding: user ids
// are UUIDs, so an unprefixed workspace id could in principle be read as
// one.
func streamPrincipal(r *http.Request, workspaceID string) string {
if uid := currentUserID(r); uid != "" {
return uid
}
if tokenWS := tokenWorkspaceID(r); tokenWS != "" {
return "ws:" + tokenWS
}
if workspaceID != "" {
return "ws:" + workspaceID
}
return ""
}