From da92be73ef9d237b6288cddd89f1f5e7240924fd Mon Sep 17 00:00:00 2001 From: Josh Creek <8179928+jcreek@users.noreply.github.com> Date: Tue, 1 Sep 2026 19:27:45 +0100 Subject: [PATCH] feat(multiplayer): cap websocket connections per player --- multiplayer-next.md | 5 +++++ server/api/events.go | 28 +++++++++++++++++++++------- server/api/events_connection_test.go | 22 ++++++++++++++++++++++ 3 files changed, 48 insertions(+), 7 deletions(-) create mode 100644 server/api/events_connection_test.go diff --git a/multiplayer-next.md b/multiplayer-next.md index 8cb8bda4..451c8986 100644 --- a/multiplayer-next.md +++ b/multiplayer-next.md @@ -1458,6 +1458,11 @@ That local gate now also runs `go test -race ./...`, `go vet ./...`, and each de Observability redaction now adds content-aware protection on top of denylisted field names: bearer values, compact JWT-like strings, PEM material, and long opaque mixed alphanumeric values are redacted recursively through arbitrary nested maps and string slices. Unknown-key credential canaries pass without leaking; false-positive risk is limited to custom long opaque fields, while canonical correlation IDs remain outside the free-form field map. +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. + ### Current local completion index (2026-09-01) The following Phase 8 slices have local implementation and verification evidence in this document: 8.29 dynamic allocated launch flags and endpoint handling; 8.30 allocator claim/reconciliation including provider-outcome recovery fencing; 8.31 signed assignment/roster validation; 8.35 initial-connect no-show and casual bot policy; 8.36 controlled drain and shutdown acknowledgment; 8.39–8.43 client state, assignment, profile, recovery, and idempotent action retry; 8.44 structured observability and content-aware redaction; 8.45 bounded API metrics export plus optional Prometheus scrape/alert rules; 8.46 normal/race/vet/fuzz coverage; 8.47–8.48 offline/testkit/Compose coverage; 8.50 atomic stalled-allocation recovery notifications; 8.51 the 10,000-client API load boundary; 8.52 the opt-in per-replica plus shared PostgreSQL regional allocator quota; and 8.53 the fail-closed promotion validator. Their remaining acceptance text is infrastructure or production dependent where explicitly noted below the corresponding row. diff --git a/server/api/events.go b/server/api/events.go index f07cb07d..8269a2ea 100644 --- a/server/api/events.go +++ b/server/api/events.go @@ -19,12 +19,13 @@ import ( ) const ( - webSocketGUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11" - maxWebSocketFrame = 64 << 10 - eventQueueCapacity = 32 - webSocketIdleLimit = 2 * time.Minute - webSocketMessageLimit = 120 - webSocketMessageWindow = time.Minute + webSocketGUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11" + maxWebSocketFrame = 64 << 10 + eventQueueCapacity = 32 + webSocketIdleLimit = 2 * time.Minute + webSocketMessageLimit = 120 + webSocketMessageWindow = time.Minute + maxEventConnectionsPerPlayer = 2 ) // ControlPlaneEvent is the server-to-client envelope defined by the v1 @@ -56,8 +57,18 @@ func newEventHub() *eventHub { } func (h *eventHub) subscribe(playerID string) *eventSubscriber { - subscriber := &eventSubscriber{playerID: playerID, queue: make(chan []byte, eventQueueCapacity)} h.mu.Lock() + connections := 0 + for subscriber := range h.subscribers { + if subscriber.playerID == playerID { + connections++ + } + } + if connections >= maxEventConnectionsPerPlayer { + h.mu.Unlock() + return nil + } + subscriber := &eventSubscriber{playerID: playerID, queue: make(chan []byte, eventQueueCapacity)} h.subscribers[subscriber] = struct{}{} h.mu.Unlock() return subscriber @@ -168,6 +179,9 @@ func (s *Service) controlPlaneEvent(w http.ResponseWriter, r *http.Request) { return } subscriber := s.getEventHub().subscribe(playerID) + if subscriber == nil { + return + } defer s.getEventHub().unsubscribe(subscriber) var writeMu sync.Mutex diff --git a/server/api/events_connection_test.go b/server/api/events_connection_test.go new file mode 100644 index 00000000..81ba948f --- /dev/null +++ b/server/api/events_connection_test.go @@ -0,0 +1,22 @@ +package api + +import "testing" + +func TestEventHubCapsConnectionsPerPlayerAndReleasesCapacity(t *testing.T) { + hub := newEventHub() + first := hub.subscribe("player-1") + second := hub.subscribe("player-1") + if first == nil || second == nil { + t.Fatal("connection within per-player cap was rejected") + } + if third := hub.subscribe("player-1"); third != nil { + t.Fatal("connection over per-player cap was accepted") + } + hub.unsubscribe(first) + if third := hub.subscribe("player-1"); third == nil { + t.Fatal("released connection capacity was not reusable") + } else { + hub.unsubscribe(third) + } + hub.unsubscribe(second) +}