From 614b87f7e1e31bbe9b8f089d2168770dd0cd1dbf Mon Sep 17 00:00:00 2001 From: Josh Creek <8179928+jcreek@users.noreply.github.com> Date: Tue, 1 Sep 2026 21:24:38 +0100 Subject: [PATCH] fix(multiplayer): bound websocket writes --- multiplayer-next.md | 2 ++ server/api/events.go | 15 +++++++++++++-- server/api/events_test.go | 19 +++++++++++++++++++ 3 files changed, 34 insertions(+), 2 deletions(-) diff --git a/multiplayer-next.md b/multiplayer-next.md index a4d573bf..17b996db 100644 --- a/multiplayer-next.md +++ b/multiplayer-next.md @@ -1461,6 +1461,8 @@ The control plane now exports bounded Prometheus-compatible API request counters The authenticated event stream now rejects client data/reserved opcodes and oversized control frames at the parser boundary; only RFC 6455 close, ping, and pong frames are accepted from clients, preserving the bounded v1 stream contract. +Event delivery also applies a bounded write deadline, so a client that stops reading cannot strand the event handler after the bounded subscriber queue evicts it. + `make verify-multiplayer-local` now provides one cloud-free regression gate for the current implementation: the complete Go suite, the Godot harness, OpenAPI parsing, and the migration/Fleet/Kubernetes/supply-chain checks. It fails clearly when the configured Godot executable is unavailable and does not weaken or replace the existing Phase 6/ENet gates; PostgreSQL, Redis, Steam, Agones, and multi-process Internet gates remain separate. That local gate now also runs `go test -race ./...`, `go vet ./...`, and each declared domain fuzz target for a bounded 2-second interval, aligning the one-command gate with the separately recorded 8.46 verification requirements. diff --git a/server/api/events.go b/server/api/events.go index d90e215d..d99bb382 100644 --- a/server/api/events.go +++ b/server/api/events.go @@ -23,6 +23,7 @@ const ( maxWebSocketFrame = 64 << 10 eventQueueCapacity = 32 webSocketIdleLimit = 2 * time.Minute + webSocketWriteLimit = 10 * time.Second webSocketMessageLimit = 120 webSocketMessageWindow = time.Minute maxEventConnectionsPerPlayer = 2 @@ -198,7 +199,7 @@ func (s *Service) controlPlaneEvent(w http.ResponseWriter, r *http.Request) { return } writeMu.Lock() - err := writeWebSocketFrame(connection, 0x1, payload) + err := writeWebSocketFrameWithDeadline(connection, 0x1, payload, webSocketWriteLimit) writeMu.Unlock() if err != nil { return @@ -284,7 +285,7 @@ func readWebSocketFrames(connection net.Conn, writeMu *sync.Mutex) { } if opcode == 0x9 { writeMu.Lock() - _ = writeWebSocketFrame(connection, 0xA, nil) + _ = writeWebSocketFrameWithDeadline(connection, 0xA, nil, webSocketWriteLimit) writeMu.Unlock() } } @@ -381,3 +382,13 @@ func writeWebSocketFrame(connection net.Conn, opcode byte, payload []byte) error _, err := connection.Write(payload) return err } + +func writeWebSocketFrameWithDeadline(connection net.Conn, opcode byte, payload []byte, timeout time.Duration) error { + if timeout <= 0 { + return errors.New("invalid websocket write timeout") + } + if err := connection.SetWriteDeadline(time.Now().Add(timeout)); err != nil { + return err + } + return writeWebSocketFrame(connection, opcode, payload) +} diff --git a/server/api/events_test.go b/server/api/events_test.go index 7376055c..850624bc 100644 --- a/server/api/events_test.go +++ b/server/api/events_test.go @@ -3,6 +3,7 @@ package api import ( "bufio" "bytes" + "net" "net/http" "net/http/httptest" "testing" @@ -25,6 +26,24 @@ func TestWebSocketReaderRejectsOversizedControlFrame(t *testing.T) { } } +func TestWebSocketWriterDoesNotBlockForeverOnSlowClient(t *testing.T) { + sender, receiver := net.Pipe() + defer sender.Close() + defer receiver.Close() + done := make(chan error, 1) + go func() { + done <- writeWebSocketFrameWithDeadline(sender, 0x1, bytes.Repeat([]byte{'x'}, maxWebSocketFrame), 20*time.Millisecond) + }() + select { + case err := <-done: + if err == nil { + t.Fatal("write to a non-reading client unexpectedly succeeded") + } + case <-time.After(time.Second): + t.Fatal("write to a non-reading client blocked past its deadline") + } +} + func TestWebSocketHandshakeRequiresRFC6455Version(t *testing.T) { service := &Service{} request := httptest.NewRequest(http.MethodGet, "/v1/events", nil)