From 711a2c7cf76c1e962e7af709ea8d7b3baa22248a Mon Sep 17 00:00:00 2001 From: Haileyesus Date: Mon, 23 Feb 2026 10:58:37 +0300 Subject: [PATCH] fix(shell): prevent duplicate websocket connects with synchronous lock The shell connection hook relied on React state (isConnecting/isConnected) as the only guard for connect attempts. Because state updates are asynchronous, rapid connect triggers could race before isConnecting became true and create duplicate WebSocket instances. This change adds a synchronous ref lock (connectingRef) that is checked immediately in connectToShell and connectWebSocket. connectToShell now sets connectingRef.current = true before invoking connectWebSocket so concurrent calls cannot pass between state updates. connectWebSocket now: - returns early when a connection is already locked - sets connectingRef.current = true when creating a socket - clears connectingRef.current alongside setIsConnecting(false) in onopen, onclose, onerror, and catch - clears connectingRef.current when no WebSocket URL is available disconnectFromShell also resets connectingRef to keep lock/state behavior consistent across manual disconnect flows. --- .../shell/hooks/useShellConnection.ts | 154 ++++++++++-------- 1 file changed, 84 insertions(+), 70 deletions(-) diff --git a/src/components/shell/hooks/useShellConnection.ts b/src/components/shell/hooks/useShellConnection.ts index f7b62c9f..4fdec9dc 100644 --- a/src/components/shell/hooks/useShellConnection.ts +++ b/src/components/shell/hooks/useShellConnection.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import type { MutableRefObject } from 'react'; import type { FitAddon } from '@xterm/addon-fit'; import type { Terminal } from '@xterm/xterm'; @@ -50,6 +50,7 @@ export function useShellConnection({ }: UseShellConnectionOptions): UseShellConnectionResult { const [isConnected, setIsConnected] = useState(false); const [isConnecting, setIsConnecting] = useState(false); + const connectingRef = useRef(false); const handleProcessCompletion = useCallback( (output: string) => { @@ -101,92 +102,104 @@ export function useShellConnection({ [handleProcessCompletion, setAuthUrl, terminalRef], ); - const connectWebSocket = useCallback(() => { - if (isConnecting || isConnected) { - return; - } - - try { - const wsUrl = getShellWebSocketUrl(); - if (!wsUrl) { + const connectWebSocket = useCallback( + (isConnectionLocked = false) => { + if ((connectingRef.current && !isConnectionLocked) || isConnecting || isConnected) { return; } - const socket = new WebSocket(wsUrl); - wsRef.current = socket; + try { + const wsUrl = getShellWebSocketUrl(); + if (!wsUrl) { + connectingRef.current = false; + setIsConnecting(false); + return; + } - socket.onopen = () => { - setIsConnected(true); - setIsConnecting(false); - setAuthUrl(''); + connectingRef.current = true; - window.setTimeout(() => { - const currentTerminal = terminalRef.current; - const currentFitAddon = fitAddonRef.current; - const currentProject = selectedProjectRef.current; - if (!currentTerminal || !currentFitAddon || !currentProject) { - return; - } + const socket = new WebSocket(wsUrl); + wsRef.current = socket; - currentFitAddon.fit(); + socket.onopen = () => { + setIsConnected(true); + setIsConnecting(false); + connectingRef.current = false; + setAuthUrl(''); - sendSocketMessage(wsRef.current, { - type: 'init', - projectPath: currentProject.fullPath || currentProject.path || '', - sessionId: isPlainShellRef.current ? null : selectedSessionRef.current?.id || null, - hasSession: isPlainShellRef.current ? false : Boolean(selectedSessionRef.current), - provider: isPlainShellRef.current - ? 'plain-shell' - : selectedSessionRef.current?.__provider || 'claude', - cols: currentTerminal.cols, - rows: currentTerminal.rows, - initialCommand: initialCommandRef.current, - isPlainShell: isPlainShellRef.current, - }); - }, TERMINAL_INIT_DELAY_MS); - }; + window.setTimeout(() => { + const currentTerminal = terminalRef.current; + const currentFitAddon = fitAddonRef.current; + const currentProject = selectedProjectRef.current; + if (!currentTerminal || !currentFitAddon || !currentProject) { + return; + } - socket.onmessage = (event) => { - const rawPayload = typeof event.data === 'string' ? event.data : String(event.data ?? ''); - handleSocketMessage(rawPayload); - }; + currentFitAddon.fit(); - socket.onclose = () => { + sendSocketMessage(wsRef.current, { + type: 'init', + projectPath: currentProject.fullPath || currentProject.path || '', + sessionId: isPlainShellRef.current ? null : selectedSessionRef.current?.id || null, + hasSession: isPlainShellRef.current ? false : Boolean(selectedSessionRef.current), + provider: isPlainShellRef.current + ? 'plain-shell' + : selectedSessionRef.current?.__provider || 'claude', + cols: currentTerminal.cols, + rows: currentTerminal.rows, + initialCommand: initialCommandRef.current, + isPlainShell: isPlainShellRef.current, + }); + }, TERMINAL_INIT_DELAY_MS); + }; + + socket.onmessage = (event) => { + const rawPayload = typeof event.data === 'string' ? event.data : String(event.data ?? ''); + handleSocketMessage(rawPayload); + }; + + socket.onclose = () => { + setIsConnected(false); + setIsConnecting(false); + connectingRef.current = false; + clearTerminalScreen(); + }; + + socket.onerror = () => { + setIsConnected(false); + setIsConnecting(false); + connectingRef.current = false; + }; + } catch { setIsConnected(false); setIsConnecting(false); - clearTerminalScreen(); - }; - - socket.onerror = () => { - setIsConnected(false); - setIsConnecting(false); - }; - } catch { - setIsConnected(false); - setIsConnecting(false); - } - }, [ - clearTerminalScreen, - fitAddonRef, - handleSocketMessage, - initialCommandRef, - isConnected, - isConnecting, - isPlainShellRef, - selectedProjectRef, - selectedSessionRef, - setAuthUrl, - terminalRef, - wsRef, - ]); + connectingRef.current = false; + } + }, + [ + clearTerminalScreen, + fitAddonRef, + handleSocketMessage, + initialCommandRef, + isConnected, + isConnecting, + isPlainShellRef, + selectedProjectRef, + selectedSessionRef, + setAuthUrl, + terminalRef, + wsRef, + ], + ); const connectToShell = useCallback(() => { - if (!isInitialized || isConnected || isConnecting) { + if (!isInitialized || isConnected || isConnecting || connectingRef.current) { return; } + connectingRef.current = true; setIsConnecting(true); - connectWebSocket(); + connectWebSocket(true); }, [connectWebSocket, isConnected, isConnecting, isInitialized]); const disconnectFromShell = useCallback(() => { @@ -194,6 +207,7 @@ export function useShellConnection({ clearTerminalScreen(); setIsConnected(false); setIsConnecting(false); + connectingRef.current = false; setAuthUrl(''); }, [clearTerminalScreen, closeSocket, setAuthUrl]);