diff --git a/frontend/src/components/room/RoomPage.tsx b/frontend/src/components/room/RoomPage.tsx index 2b8b217..8948f28 100644 --- a/frontend/src/components/room/RoomPage.tsx +++ b/frontend/src/components/room/RoomPage.tsx @@ -50,6 +50,8 @@ function RoomPage({ interviewType: propInterviewType }: RoomPageProps) { const [endedByMe, setEndedByMe] = useState(false); const [endedByOther, setEndedByOther] = useState(false); const endedByMeRef = useRef(false); + const [connectionState, setConnectionState] = useState<'connected' | 'reconnecting' | 'failed'>('connected'); + const shouldReconnectRef = useRef(true); const handleJoinRoom = async () => { if (!userName.trim() || !roomId) return; @@ -142,130 +144,196 @@ function RoomPage({ interviewType: propInterviewType }: RoomPageProps) { joinWithStoredData(); }, [roomId, userId, navigate]); - // Setup WebSocket connection and handlers when the user successfully joins the room + // Setup WebSocket connection and handlers when the user successfully joins the room. + // Automatically reconnects on close/error with exponential backoff. The server-side + // ping/pong (PingPeriod=30s, PongWait=60s) keeps healthy connections alive through + // NAT/proxy idle-timeouts; this client logic handles the rare case where a connection + // really does die (server restart, network drop, laptop sleep, etc.). useEffect(() => { if (!isJoined || !roomId) return; - const websocket = new WebSocket(`${WS_URL}/ws/${roomId}?userId=${userId}`); - setWs(websocket); - - websocket.onopen = async () => { - websocket.send(JSON.stringify({ - userId, - type: 'user_joined', - payload: { - userId, - roomId, - userName - } - })); - } - websocket.onclose = () => { - } + shouldReconnectRef.current = true; + setConnectionState('connected'); - websocket.onmessage = (event) => { - const message = JSON.parse(event.data); - - switch (message.type) { - case 'user_joined': - setUsers(prevUsers => [ - ...prevUsers, - { - userId: message.payload.userId, - userName: message.payload.userName, - cursorPosition: { - lineNumber: 1, - column: 1 - }, - selection: null + let attempt = 0; + let activeWs: WebSocket | null = null; + let reconnectTimer: ReturnType | null = null; + + const attachHandlers = (websocket: WebSocket) => { + websocket.onopen = () => { + attempt = 0; + setConnectionState('connected'); + websocket.send(JSON.stringify({ + userId, + type: 'user_joined', + payload: { userId, roomId, userName }, + })); + }; + + websocket.onmessage = (event) => { + const message = JSON.parse(event.data); + + switch (message.type) { + case 'user_joined': + // De-dup: on reconnect, other clients may still have us in their + // user list (their server-side cleanup may not have fired). Skip + // adding a duplicate and skip the join toast in that case. + setUsers(prevUsers => { + if (prevUsers.some(u => u.userId === message.payload.userId)) return prevUsers; + const joinToastId = ++toastIdRef.current; + setToasts(prev => [...prev, { id: joinToastId, message: `${message.payload.userName} joined the room` }]); + setTimeout(() => { + setToasts(prev => prev.filter(t => t.id !== joinToastId)); + }, 3000); + return [ + ...prevUsers, + { + userId: message.payload.userId, + userName: message.payload.userName, + cursorPosition: { lineNumber: 1, column: 1 }, + selection: null, + }, + ]; + }); + break; + + case 'user_left': + setUsers(prevUsers => prevUsers.filter(u => u.userId !== message.userId)); + break; + + case 'cursor_update': + setUsers(prevUsers => + prevUsers.map(u => + u.userId === message.userId + ? { ...u, cursorPosition: { lineNumber: message.payload.lineNumber, column: message.payload.column } } + : u + ) + ); + break; + + case 'selection_update': + setUsers(prevUsers => + prevUsers.map(u => + u.userId === message.userId + ? { + ...u, + selection: message.payload.startLineNumber === message.payload.endLineNumber && + message.payload.startColumn === message.payload.endColumn + ? null + : { + startLineNumber: message.payload.startLineNumber, + startColumn: message.payload.startColumn, + endLineNumber: message.payload.endLineNumber, + endColumn: message.payload.endColumn, + }, + } + : u + ) + ); + break; + + case 'code_update': + setCode(message.payload.code); + break; + + case 'interview_ended': + // Don't try to reconnect after an explicit end — the room is gone. + shouldReconnectRef.current = false; + if (!endedByMeRef.current) { + setEndedByOther(true); } - ]); - // Show toast for new user joining - const joinToastId = ++toastIdRef.current; - setToasts(prev => [...prev, { id: joinToastId, message: `${message.payload.userName} joined the room` }]); - setTimeout(() => { - setToasts(prev => prev.filter(t => t.id !== joinToastId)); - }, 3000); - break; - - case 'user_left': - setUsers(prevUsers => prevUsers.filter(u => u.userId !== message.userId)); - break; - - case 'cursor_update': - setUsers(prevUsers => - prevUsers.map(u => - u.userId === message.userId - ? { ...u, cursorPosition: { lineNumber: message.payload.lineNumber, column: message.payload.column } } - : u - ) - ); - break; - - case 'selection_update': - setUsers(prevUsers => - prevUsers.map(u => - u.userId === message.userId - ? { - ...u, - selection: message.payload.startLineNumber === message.payload.endLineNumber && - message.payload.startColumn === message.payload.endColumn - ? null - : { - startLineNumber: message.payload.startLineNumber, - startColumn: message.payload.startColumn, - endLineNumber: message.payload.endLineNumber, - endColumn: message.payload.endColumn - } - } - : u - ) - ); - break; - - case 'code_update': - setCode(message.payload.code); - break; - - case 'interview_ended': - // Suppress the "ended by other" popup for the user who actually - // ended it — they get their own success popup from the API call. - if (!endedByMeRef.current) { - setEndedByOther(true); + break; + + case 'visibility_change': { + const user = users.find(u => u.userId === message.userId); + const name = message.payload.userName || user?.userName || 'Someone'; + const isVisible = message.payload.isVisible; + const toastMessage = isVisible + ? `${name} returned to goderpad` + : `${name} exited goderpad`; + const newToastId = ++toastIdRef.current; + setToasts(prev => [...prev, { id: newToastId, message: toastMessage }]); + setTimeout(() => { + setToasts(prev => prev.filter(t => t.id !== newToastId)); + }, 3000); + break; } - break; - - case 'visibility_change': { - const user = users.find(u => u.userId === message.userId); - const name = message.payload.userName || user?.userName || 'Someone'; - const isVisible = message.payload.isVisible; - const toastMessage = isVisible - ? `${name} returned to goderpad` - : `${name} exited goderpad`; - const newToastId = ++toastIdRef.current; - setToasts(prev => [...prev, { id: newToastId, message: toastMessage }]); - // Auto-remove toast after 3 seconds - setTimeout(() => { - setToasts(prev => prev.filter(t => t.id !== newToastId)); - }, 3000); - break; + + default: + break; } + }; + + websocket.onclose = () => { + if (!shouldReconnectRef.current) return; + scheduleReconnect(); + }; + + websocket.onerror = () => { + // onclose fires right after; let it handle reconnect scheduling. + }; + }; - default: - break; + const scheduleReconnect = () => { + if (!shouldReconnectRef.current) return; + if (attempt >= 10) { + setConnectionState('failed'); + return; } - } + setConnectionState('reconnecting'); + const delay = Math.min(1000 * Math.pow(2, attempt), 30000); + attempt += 1; + reconnectTimer = setTimeout(() => { connect(); }, delay); + }; + + const connect = async () => { + if (!shouldReconnectRef.current) return; + + // On reconnect attempts (not the initial connect), re-join via HTTP to + // refresh the server's user record and pull the latest state. The + // server's JoinRoom tears down our stale User if it's still around + // (see services/room.go), so we don't leak goroutines. + if (attempt > 0) { + const response = await joinRoom(userId, userName, roomId); + if (!shouldReconnectRef.current) return; + if (!response.ok) { + // Room may be gone (expired or ended) or server is down; back off. + scheduleReconnect(); + return; + } + // Trust the server's view. Any local edits made during the disconnect + // window are lost — same behavior as a manual refresh today. + setCode(response.data.document || DEFAULT_CODE); + setUsers(response.data.users || []); + const serverLanguage: string = response.data.language || (interviewType === 'leetcode' ? 'python' : 'react'); + setLanguage(serverLanguage); + } + + const websocket = new WebSocket(`${WS_URL}/ws/${roomId}?userId=${userId}`); + activeWs = websocket; + setWs(websocket); + attachHandlers(websocket); + }; + + connect(); return () => { - // Send user_left before closing - if (websocket.readyState === WebSocket.OPEN) { - websocket.send(JSON.stringify({ - userId, - type: 'user_left', - payload: { roomId } - })); + shouldReconnectRef.current = false; + if (reconnectTimer) { + clearTimeout(reconnectTimer); + reconnectTimer = null; + } + if (activeWs) { + if (activeWs.readyState === WebSocket.OPEN) { + activeWs.send(JSON.stringify({ + userId, + type: 'user_left', + payload: { roomId }, + })); + } + activeWs.close(); + activeWs = null; } - websocket.close(); setWs(null); }; }, [isJoined, roomId]); @@ -377,14 +445,41 @@ function RoomPage({ interviewType: propInterviewType }: RoomPageProps) { ))} + {connectionState !== 'connected' && ( +
+
+ {connectionState === 'reconnecting' && ( + + + + + )} + + {connectionState === 'reconnecting' ? 'reconnecting…' : 'connection failed — please refresh'} + +
+
+ )} + setShowEndConfirmModal(false)} - onAttemptStart={() => { endedByMeRef.current = true; }} - onAttemptError={() => { endedByMeRef.current = false; }} + onAttemptStart={() => { + endedByMeRef.current = true; + shouldReconnectRef.current = false; + }} + onAttemptError={() => { + endedByMeRef.current = false; + shouldReconnectRef.current = true; + }} onEnded={() => { endedByMeRef.current = true; + shouldReconnectRef.current = false; setEndedByMe(true); setShowEndConfirmModal(false); }} diff --git a/handlers/ws.go b/handlers/ws.go index 1173dcb..d0cc571 100644 --- a/handlers/ws.go +++ b/handlers/ws.go @@ -54,6 +54,16 @@ func WebSocketHandler(c *gin.Context) { user.Conn = conn + // Keepalive: refresh the read deadline whenever a pong arrives. Combined + // with the server-side ping ticker in user.HandleBroadcasts, this keeps + // the connection alive through NAT/proxy idle-timeouts and lets us + // detect dead connections within PongWait. + conn.SetReadDeadline(time.Now().Add(models.PongWait)) + conn.SetPongHandler(func(string) error { + conn.SetReadDeadline(time.Now().Add(models.PongWait)) + return nil + }) + // Send current cursor positions and selections of all other users to the newly connected user for _, otherUser := range room.GetCurrentUsers() { if otherUser.UserID != userID { diff --git a/models/user.go b/models/user.go index a9123ba..d9e6147 100644 --- a/models/user.go +++ b/models/user.go @@ -3,10 +3,22 @@ package models import ( "log" "sync" + "time" "github.com/gorilla/websocket" ) +// WebSocket keepalive timings. +// - PingPeriod is small enough to keep NAT/proxy idle-timeouts (often 30-60s) +// from severing the connection while a tab is backgrounded. +// - PongWait is the deadline for receiving a pong reply; if it elapses the +// read loop errors and the connection is torn down. +const ( + WriteWait = 10 * time.Second + PongWait = 60 * time.Second + PingPeriod = 30 * time.Second +) + type User struct { UserID string `json:"userId"` Name string `json:"userName"` @@ -85,17 +97,27 @@ func (u *User) GetSelection() *SelectionRange { // this function reads incoming messages from the Send channel and sends to the user's websocket connection func (u *User) HandleBroadcasts() { + pingTicker := time.NewTicker(PingPeriod) + defer pingTicker.Stop() for { select { case <-u.done: return case msg := <-u.Send: if u.Conn != nil { + u.Conn.SetWriteDeadline(time.Now().Add(WriteWait)) err := u.Conn.WriteJSON(msg) if err != nil { log.Println("Error writing to websocket:", err) } } + case <-pingTicker.C: + if u.Conn != nil { + u.Conn.SetWriteDeadline(time.Now().Add(WriteWait)) + if err := u.Conn.WriteMessage(websocket.PingMessage, nil); err != nil { + log.Println("Error sending ping:", err) + } + } } } } diff --git a/services/room.go b/services/room.go index 5b463af..f78db62 100644 --- a/services/room.go +++ b/services/room.go @@ -34,6 +34,18 @@ 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. + if existing, ok := room.CheckUserExists(userID); ok { + if existing.Conn != nil { + existing.Conn.Close() + } + room.RemoveUser(userID) + existing.Close() + } + user := models.CreateUser(userID, name) room.AddUser(user)