From 52e3d7367830cb41200bc2fba7a7e19d399d481f Mon Sep 17 00:00:00 2001 From: Josh Creek <8179928+jcreek@users.noreply.github.com> Date: Tue, 1 Sep 2026 19:35:34 +0100 Subject: [PATCH] fix(multiplayer): reject websocket caps before upgrade --- multiplayer-next.md | 4 +++- server/api/events.go | 13 +++++++------ server/api/events_connection_test.go | 26 +++++++++++++++++++++++++- 3 files changed, 35 insertions(+), 8 deletions(-) diff --git a/multiplayer-next.md b/multiplayer-next.md index 7e7e6c47..95829c61 100644 --- a/multiplayer-next.md +++ b/multiplayer-next.md @@ -1461,7 +1461,9 @@ Observability redaction now adds content-aware protection on top of denylisted f The authenticated control-plane WebSocket now caps each player at two simultaneous connections, releasing capacity on disconnect; this complements the bounded per-player event queue and prevents connection fan-out from -becoming an unbounded account-level resource cost. +becoming an unbounded account-level resource cost. Over-limit attempts fail +before upgrade with `429 websocket_connection_limited`, rather than becoming +ambiguous post-upgrade disconnects. Proposal explicit-decline cooldowns are now durable: the declining player is requeued for recovery, but a subsequent queue create is rejected until the diff --git a/server/api/events.go b/server/api/events.go index 8269a2ea..11973783 100644 --- a/server/api/events.go +++ b/server/api/events.go @@ -161,6 +161,13 @@ func (s *Service) controlPlaneEvent(w http.ResponseWriter, r *http.Request) { if !ok { return } + hub := s.getEventHub() + subscriber := hub.subscribe(playerID) + if subscriber == nil { + writeError(w, http.StatusTooManyRequests, "websocket_connection_limited") + return + } + defer hub.unsubscribe(subscriber) hijacker, ok := w.(http.Hijacker) if !ok { writeError(w, http.StatusNotImplemented, "websocket_unavailable") @@ -178,12 +185,6 @@ func (s *Service) controlPlaneEvent(w http.ResponseWriter, r *http.Request) { if err := buffered.Flush(); err != nil { return } - subscriber := s.getEventHub().subscribe(playerID) - if subscriber == nil { - return - } - defer s.getEventHub().unsubscribe(subscriber) - var writeMu sync.Mutex done := make(chan struct{}) go func() { diff --git a/server/api/events_connection_test.go b/server/api/events_connection_test.go index 81ba948f..a5a51329 100644 --- a/server/api/events_connection_test.go +++ b/server/api/events_connection_test.go @@ -1,6 +1,10 @@ package api -import "testing" +import ( + "net/http" + "net/http/httptest" + "testing" +) func TestEventHubCapsConnectionsPerPlayerAndReleasesCapacity(t *testing.T) { hub := newEventHub() @@ -20,3 +24,23 @@ func TestEventHubCapsConnectionsPerPlayerAndReleasesCapacity(t *testing.T) { } hub.unsubscribe(second) } + +func TestEventConnectionCapReturnsHTTP429BeforeUpgrade(t *testing.T) { + service := &Service{SessionBackend: &sessionBackendSpy{}} + hub := service.getEventHub() + first := hub.subscribe("player-1") + second := hub.subscribe("player-1") + defer hub.unsubscribe(first) + defer hub.unsubscribe(second) + request := httptest.NewRequest(http.MethodGet, "/v1/events", nil) + request.Header.Set("Upgrade", "websocket") + request.Header.Set("Connection", "Upgrade") + request.Header.Set("Sec-WebSocket-Version", "13") + request.Header.Set("Sec-WebSocket-Key", "dGhlIHNhbXBsZSBub25jZQ==") + request.Header.Set("Authorization", "Bearer session-1:token-1") + recorder := httptest.NewRecorder() + service.controlPlaneEvent(recorder, request) + if recorder.Code != http.StatusTooManyRequests { + t.Fatalf("connection-cap status = %d, want 429", recorder.Code) + } +}