From 75d511cbd5656a3ca660810959767a1efe2d945f Mon Sep 17 00:00:00 2001 From: Shreyas G Gowda Date: Tue, 8 Sep 2026 20:13:44 +0530 Subject: [PATCH 1/3] fix(websocket): prevent debater race condition --- backend/websocket/websocket.go | 48 +++++++++++++++------------ backend/websocket/websocket_test.go | 50 +++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+), 21 deletions(-) diff --git a/backend/websocket/websocket.go b/backend/websocket/websocket.go index 79470aba..e7d42f90 100644 --- a/backend/websocket/websocket.go +++ b/backend/websocket/websocket.go @@ -143,7 +143,26 @@ func countDebaters(room *Room) int { } return count } +func tryAddClient(room *Room, conn *websocket.Conn, client *Client) bool { + room.Mutex.Lock() + defer room.Mutex.Unlock() + if !client.IsSpectator { + currentDebaters := 0 + for _, existing := range room.Clients { + if !existing.IsSpectator { + currentDebaters++ + } + } + + if currentDebaters >= 2 { + return false + } + } + + room.Clients[conn] = client + return true +} func countSpectators(room *Room) int { room.Mutex.Lock() defer room.Mutex.Unlock() @@ -283,24 +302,9 @@ func WebsocketHandler(c *gin.Context) { return } - // Check if this is a spectator connection (they want to receive video streams) - // Allow spectators to connect even if room has 2 debaters + // Check if this is a spectator connection. + // Spectators are allowed to connect even if the room has 2 debaters. isSpectator := strings.EqualFold(c.Query("spectator"), "true") - room.Mutex.Lock() - currentDebaters := 0 - for _, existing := range room.Clients { - if !existing.IsSpectator { - currentDebaters++ - } - } - maxDebaters := 2 - if !isSpectator && currentDebaters >= maxDebaters { - room.Mutex.Unlock() - log.Printf("[ws] rejecting debater %s for room %s: already full", email, roomID) - conn.Close() - return - } - room.Mutex.Unlock() if avatarURL == "" { avatarURL = "https://api.dicebear.com/9.x/big-ears/svg?seed=Nolan" @@ -335,10 +339,12 @@ func WebsocketHandler(c *gin.Context) { // Mark as spectator if needed (we can add a field to Client struct for this) // For now, we'll handle it through the message handlers - // Send current participants to the new client - room.Mutex.Lock() - room.Clients[conn] = client - room.Mutex.Unlock() + // Atomically check the debater limit and register the client. + if !tryAddClient(room, conn, client) { + log.Printf("[ws] rejecting debater %s for room %s: already full", email, roomID) + conn.Close() + return + } // Send participants list to newly connected client participantsMsg := buildParticipantsMessage(room) diff --git a/backend/websocket/websocket_test.go b/backend/websocket/websocket_test.go index d065d1c2..47170bcb 100644 --- a/backend/websocket/websocket_test.go +++ b/backend/websocket/websocket_test.go @@ -1,6 +1,7 @@ package websocket import ( + "sync" "testing" gorilla "github.com/gorilla/websocket" @@ -37,3 +38,52 @@ func TestBuildParticipantsMessageIncludesRecoverableRoomState(t *testing.T) { t.Fatalf("expected role=for, got %#v", participant["role"]) } } + +func TestTryAddClientDoesNotExceedDebaterLimit(t *testing.T) { + room := &Room{ + Clients: make(map[*gorilla.Conn]*Client), + } + + // Start with one debater already in the room. + existingConn := &gorilla.Conn{} + room.Clients[existingConn] = &Client{ + Conn: existingConn, + IsSpectator: false, + } + + const attempts = 100 + + var wg sync.WaitGroup + var accepted int + var acceptedMutex sync.Mutex + + for i := 0; i < attempts; i++ { + wg.Add(1) + + go func() { + defer wg.Done() + + conn := &gorilla.Conn{} + client := &Client{ + Conn: conn, + IsSpectator: false, + } + + if tryAddClient(room, conn, client) { + acceptedMutex.Lock() + accepted++ + acceptedMutex.Unlock() + } + }() + } + + wg.Wait() + + if accepted != 1 { + t.Fatalf("expected exactly one additional debater to be accepted, got %d", accepted) + } + + if countDebaters(room) != 2 { + t.Fatalf("expected room to contain exactly 2 debaters, got %d", countDebaters(room)) + } +} From 022fb6cb33d33977cb7a11d9c73c5f5ee4d3071a Mon Sep 17 00:00:00 2001 From: Shreyas G Gowda Date: Tue, 8 Sep 2026 23:22:49 +0530 Subject: [PATCH 2/3] fix(websocket): snapshot clients before iteration --- backend/websocket/websocket.go | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/backend/websocket/websocket.go b/backend/websocket/websocket.go index e7d42f90..9a0ffffe 100644 --- a/backend/websocket/websocket.go +++ b/backend/websocket/websocket.go @@ -351,7 +351,7 @@ func WebsocketHandler(c *gin.Context) { client.SafeWriteJSON(participantsMsg) // Send existing participants' detailed info to the new client - for connRef, existing := range room.Clients { + for _, existing := range snapshotRecipients(room, nil) { payload := map[string]interface{}{ "id": existing.UserID, "username": existing.Username, @@ -360,18 +360,13 @@ func WebsocketHandler(c *gin.Context) { "avatarUrl": existing.AvatarURL, "elo": existing.Elo, } + detailMessage := map[string]interface{}{ "type": "userDetails", "userDetails": payload, } - if connRef == conn { - // Already sent this client's participant data; ensure they have their own detail payload too - client.SafeWriteJSON(detailMessage) - } else { - // Send existing participant info to the new client - client.SafeWriteJSON(detailMessage) - } + client.SafeWriteJSON(detailMessage) } // Prepare detailed payload for the new client to broadcast to others From 9d981b94b2fc39f35921a57fe4d0a1f80016da02 Mon Sep 17 00:00:00 2001 From: Shreyas G Gowda Date: Thu, 10 Sep 2026 08:10:07 +0530 Subject: [PATCH 3/3] docs(websocket): document debater admission fix --- backend/websocket/websocket.go | 2 ++ backend/websocket/websocket_test.go | 2 ++ 2 files changed, 4 insertions(+) diff --git a/backend/websocket/websocket.go b/backend/websocket/websocket.go index 9a0ffffe..bc8c01be 100644 --- a/backend/websocket/websocket.go +++ b/backend/websocket/websocket.go @@ -143,6 +143,8 @@ func countDebaters(room *Room) int { } return count } + +// tryAddClient atomically checks the debater limit and adds the client to the room. func tryAddClient(room *Room, conn *websocket.Conn, client *Client) bool { room.Mutex.Lock() defer room.Mutex.Unlock() diff --git a/backend/websocket/websocket_test.go b/backend/websocket/websocket_test.go index 47170bcb..aabaf432 100644 --- a/backend/websocket/websocket_test.go +++ b/backend/websocket/websocket_test.go @@ -39,6 +39,8 @@ func TestBuildParticipantsMessageIncludesRecoverableRoomState(t *testing.T) { } } +// TestTryAddClientDoesNotExceedDebaterLimit verifies that concurrent debater +// join attempts cannot exceed the room's two-debater limit. func TestTryAddClientDoesNotExceedDebaterLimit(t *testing.T) { room := &Room{ Clients: make(map[*gorilla.Conn]*Client),