Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 65 additions & 3 deletions frontend/src/components/room/RoomPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,9 +53,54 @@ function RoomPage({ interviewType: propInterviewType }: RoomPageProps) {
const [connectionState, setConnectionState] = useState<'connected' | 'reconnecting' | 'failed'>('connected');
const shouldReconnectRef = useRef(true);

// Duplicate-tab detection: if another tab in this browser already has this
// room open (same origin → same userId in localStorage), block joining here
// and prompt the user back to their original tab. Without this, both tabs
// race to be the active connection for the same userID and the server thrashes.
const [duplicateTabDetected, setDuplicateTabDetected] = useState(false);
const [tabCheckComplete, setTabCheckComplete] = useState(false);
const tabIdRef = useRef<string>(crypto.randomUUID());
const isJoinedRef = useRef(false);
useEffect(() => { isJoinedRef.current = isJoined; }, [isJoined]);

// Detect another tab already in this room before we try to join. Sends a
// ping on a per-room BroadcastChannel; any already-joined tab answers with
// a pong. If we hear a pong within the grace window we flip duplicateTab
// and stop the join flow. Otherwise we mark the check complete and the
// auto-join effect proceeds normally.
useEffect(() => {
if (!roomId) return;
if (typeof BroadcastChannel === 'undefined') {
setTabCheckComplete(true);
return;
}

const channel = new BroadcastChannel(`goderpad-room-${roomId}`);

const onMessage = (e: MessageEvent) => {
if (!e.data || e.data.from === tabIdRef.current) return;
if (e.data.type === 'ping' && isJoinedRef.current) {
channel.postMessage({ type: 'pong', from: tabIdRef.current });
} else if (e.data.type === 'pong') {
setDuplicateTabDetected(true);
setTabCheckComplete(true);
}
};
channel.addEventListener('message', onMessage);

channel.postMessage({ type: 'ping', from: tabIdRef.current });
const timer = setTimeout(() => setTabCheckComplete(true), 300);

return () => {
clearTimeout(timer);
channel.removeEventListener('message', onMessage);
channel.close();
};
}, [roomId]);

const handleJoinRoom = async () => {
if (!userName.trim() || !roomId) return;
if (!userName.trim() || !roomId || duplicateTabDetected) return;

setIsLoading(true);
const response = await joinRoom(userId, userName, roomId);
setIsLoading(false);
Expand Down Expand Up @@ -104,6 +149,7 @@ function RoomPage({ interviewType: propInterviewType }: RoomPageProps) {
navigate('/');
return;
}
if (!tabCheckComplete || duplicateTabDetected) return;

const storedData = localStorage.getItem(`goderpad-cookie-${roomId}`);
if (!storedData) return;
Expand Down Expand Up @@ -142,7 +188,7 @@ function RoomPage({ interviewType: propInterviewType }: RoomPageProps) {
};

joinWithStoredData();
}, [roomId, userId, navigate]);
}, [roomId, userId, navigate, tabCheckComplete, duplicateTabDetected]);

// Setup WebSocket connection and handlers when the user successfully joins the room.
// Automatically reconnects on close/error with exponential backoff. The server-side
Expand Down Expand Up @@ -385,6 +431,22 @@ function RoomPage({ interviewType: propInterviewType }: RoomPageProps) {
}, [ws, isJoined, userId, userName]);

if (!isJoined) {
if (duplicateTabDetected) {
return (
<Popup
message="you're already in this room in another tab!"
buttonText="ok"
isOpen={true}
onClickButton={() => {
// window.close() only works on tabs the script itself opened, so
// it's a best-effort; fall back to navigating home if the browser
// blocks it.
window.close();
setTimeout(() => navigate('/'), 100);
}}
/>
);
}
return (<>
<Popup
message="sorry, an error occurred trying to join the room"
Expand Down
11 changes: 6 additions & 5 deletions handlers/ws.go
Original file line number Diff line number Diff line change
Expand Up @@ -182,12 +182,13 @@ func readBroadcastsFromUser(user *models.User, room *models.Room) {
func closeUserConnection(user *models.User, room *models.Room) {
userID := user.UserID // Save before closing

// First remove user from room so they don't receive their own leave message
room.RemoveUser(userID)
// Only remove the map entry if it still points to THIS user. A concurrent
// duplicate join (same userID, e.g. the user opened the room in a second
// tab) may have already replaced us with a new User+Conn; in that case the
// new tab is the live one and we must not evict it or fire user_left.
removed := room.RemoveUserIfSame(userID, user)

// Broadcast user_left to remaining users. Skip if the room is ending
// or already torn down to avoid blocking forever or panicking on send.
if !room.IsEnded() {
if removed && !room.IsEnded() {
select {
case room.Broadcast <- models.BroadcastMessage{
UserID: userID,
Expand Down
16 changes: 15 additions & 1 deletion models/room.go
Original file line number Diff line number Diff line change
Expand Up @@ -120,11 +120,25 @@ func (r *Room) RemoveUser(userID string) {
r.mu.Lock()
defer r.mu.Unlock()
if _, exists := r.Users[userID]; exists {
delete(r.Users, userID)
delete(r.Users, userID)
metrics.RoomUsersTotal.Dec()
}
}

// RemoveUserIfSame deletes the map entry only if it still references the
// given User pointer. Used by closeUserConnection so a stale reader goroutine
// from a dropped tab doesn't evict a new tab that just took over the same userID.
func (r *Room) RemoveUserIfSame(userID string, user *User) bool {
r.mu.Lock()
defer r.mu.Unlock()
if current, exists := r.Users[userID]; exists && current == user {
delete(r.Users, userID)
metrics.RoomUsersTotal.Dec()
return true
}
return false
}

func (r *Room) CheckUserExists(userID string) (*User, bool) {
r.mu.Lock()
defer r.mu.Unlock()
Expand Down
19 changes: 13 additions & 6 deletions services/room.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,16 +34,23 @@ func JoinRoom(userID, name, roomID string) (map[string]any, error) {
return nil, models.ErrRoomNotFound
}

// Reconnect path: if the user is already in the room (their old conn
// hasn't been cleaned up yet — e.g., the client noticed the drop
// before the server's pong-timeout fired), tear down the stale User
// so we don't leak its HandleBroadcasts goroutine or hold a dead Conn.
// Reconnect / duplicate-tab path: a User entry already exists for this
// userID (e.g. client reconnect after a transient drop, or the user
// opened the room in a second tab). Tear down the old one so we don't
// leak its HandleBroadcasts goroutine or hold a dead Conn.
if existing, ok := room.CheckUserExists(userID); ok {
if existing.Conn != nil {
// There's an active reader goroutine on this Conn. Closing the
// Conn wakes its ReadJSON and triggers its deferred
// closeUserConnection, which (with RemoveUserIfSame) safely
// cleans up the map entry and User channels. Doing those steps
// here too would race the reader and double-close the channels.
existing.Conn.Close()
} else {
// No active reader to clean up after itself — do it ourselves.
room.RemoveUser(userID)
existing.Close()
}
room.RemoveUser(userID)
existing.Close()
}

user := models.CreateUser(userID, name)
Expand Down
Loading