- Status: done
- Date: 2026-06-01
- Specs touched: none changed (realizes
BRAIN_UI_PROTOCOL.md# Stream cap, lines 170/188/261 — already specified; no divergence)
Implements the ≤16-concurrent-SSE-streams-per-session cap (issue #47) that BRAIN_UI_PROTOCOL.md locks but nothing enforced. Surfaced as a follow-up by system-live-sse.md, which added /api/v1/system/live ("counts against the ≤16-stream cap") while the cap itself was unbuilt — there was no counter, no 429 path, and no per-session stream tracking anywhere, not even for the older /api/v1/events stream. Closes issue #47.
The cap is cross-cutting — it must span every raw SSE handler and key off the session, not the endpoint — so it lives on Server, not inside either handler.
internal/api/streamcap.go(new) —streamCap: amap[token]intof live streams guarded by a mutex, withacquire(token) (release, ok)andrelease(token).acquirerefuses (ok=false) once a session is atmaxStreamsPerSession(16);releasefrees one slot and drops the map entry when a session's last stream closes (so churning sessions don't leak keys).beginStream(w, r)is the one-call front door for a raw SSE handler: it resolves the session viaauth.FromContext, writes401if absent, reserves a slot, writes429if the session is capped, and otherwise returns areleasethe handler defers.writeStreamCapExceededmirrorswriteUnauthenticated's hand-written error shape (the SSE handlers sit outside huma).internal/api/api.go— both raw SSE handlers (events,systemLive) now open withrelease, ok := s.beginStream(w, r); if !ok { return }; defer release()before any stream headers are written, so a refused stream gets a clean401/429status with no200/event-stream headers leaking first.systemLive's prior standaloneauth.FromContextbelt-and-suspenders check folds intobeginStream(no behaviour lost — it still double-guards auth, and now also keys the cap).Servergained astreams *streamCapfield, constructed inNewServer(no signature change → no test-caller churn).
The slot is freed by the deferred release() when the handler returns, which happens on disconnect (r.Context().Done()) — so a closed tab or dropped connection frees its slot.
BRAIN_UI_PROTOCOL.md# Stream cap (line 170, locked at line 261): "Brain enforces ≤16 concurrent SSE streams per session … Excess connections receive429 Too Many Requests." Realized exactly: the 17th concurrent stream on a session is refused with429.BRAIN_UI_PROTOCOL.md:188— "[system/live] still counts against the ≤16-stream cap." Both/api/v1/eventsand/api/v1/system/liveincrement one shared per-session count, so the cap is a per-session budget, not per-endpoint.- Auth is the
malmo_sessioncookie (AUTH.md): the cap keys onIdentity.Session.Tokenfromauth.FromContext, the same identity the middleware attaches. A second session has its own budget. - CLAUDE.md # Go code discipline: small self-contained type for one concern, no premature abstraction (the limit is a const, injectable only so a test can shrink it); no new dependency; the
429body is hand-written to match the sibling raw-handler error, not a new error framework.
Retry-After: 0on the429.BRAIN_UI_PROTOCOL.md# Rate limiting locked decision requiresRetry-Afteron every429; for the stream cap the slot frees when a tab closes rather than after a fixed delay, so the value is0. The response body uses the locked{code: "rate-limited", message, details: {scope: "session"}}envelope.- The cap counts only the two raw SSE handlers that exist today (
events,system/live). The per-resource log/progress tails inBRAIN_UI_PROTOCOL.mdPattern C stream 1 (/api/v1/jobs/:id/log,/api/v1/apps/:id/log,/api/v1/services/:svc/log) are not implemented yet; when they land they must also callbeginStreamto participate — noted here so it isn't silently assumed handled.beginStreamis the single seam to wire them through. - Not exercised under a real browser. Verified over real HTTP with the Go client (long-lived streams held open concurrently); the
EventSource-stops-on-429 behaviour is a browser property, not tested here. - Schema/
oasdiffimpact: none. The SSE endpoints are raw mux handlers outside huma, so they're absent from the generated OpenAPI; adding a429path changes no schema and is additive regardless (BRAIN_UI_PROTOCOL.md# CI enforcement).
internal/api/streamcap_test.go(new) — unit tests onstreamCap(acquire up to the limit then reject, a release frees exactly one slot, per-token independence, map entry dropped at zero, and the production limit pinned to the spec-locked 16) plus the Done-when end-to-end over real HTTP: filling a session's budget across both/api/v1/eventsand/api/v1/system/liverefuses the next stream with429; a second session keeps its own full budget; closing a stream frees a slot for a new one. Race-clean. The HTTP test shrinks the cap to 3 (swappingServer.streamsbefore any stream opens) so it holds a handful of connections, not 17.
- Wire the log/progress-tail SSE handlers through
beginStreamwhen they're built, so all SSE streams share the per-session count. - Rate-limit / abuse posture (
BRAIN_UI_PROTOCOL.md# Public-API posture,NEXT.md): the stream cap is a per-session backstop, not a request rate limiter — the broader public-API rate-limit story is still aNEXT.mdfollow-up.