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.
This commit is contained in:
Haileyesus
2026-02-23 10:58:37 +03:00
parent c025f27036
commit 711a2c7cf7

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import type { MutableRefObject } from 'react'; import type { MutableRefObject } from 'react';
import type { FitAddon } from '@xterm/addon-fit'; import type { FitAddon } from '@xterm/addon-fit';
import type { Terminal } from '@xterm/xterm'; import type { Terminal } from '@xterm/xterm';
@@ -50,6 +50,7 @@ export function useShellConnection({
}: UseShellConnectionOptions): UseShellConnectionResult { }: UseShellConnectionOptions): UseShellConnectionResult {
const [isConnected, setIsConnected] = useState(false); const [isConnected, setIsConnected] = useState(false);
const [isConnecting, setIsConnecting] = useState(false); const [isConnecting, setIsConnecting] = useState(false);
const connectingRef = useRef(false);
const handleProcessCompletion = useCallback( const handleProcessCompletion = useCallback(
(output: string) => { (output: string) => {
@@ -101,23 +102,29 @@ export function useShellConnection({
[handleProcessCompletion, setAuthUrl, terminalRef], [handleProcessCompletion, setAuthUrl, terminalRef],
); );
const connectWebSocket = useCallback(() => { const connectWebSocket = useCallback(
if (isConnecting || isConnected) { (isConnectionLocked = false) => {
if ((connectingRef.current && !isConnectionLocked) || isConnecting || isConnected) {
return; return;
} }
try { try {
const wsUrl = getShellWebSocketUrl(); const wsUrl = getShellWebSocketUrl();
if (!wsUrl) { if (!wsUrl) {
connectingRef.current = false;
setIsConnecting(false);
return; return;
} }
connectingRef.current = true;
const socket = new WebSocket(wsUrl); const socket = new WebSocket(wsUrl);
wsRef.current = socket; wsRef.current = socket;
socket.onopen = () => { socket.onopen = () => {
setIsConnected(true); setIsConnected(true);
setIsConnecting(false); setIsConnecting(false);
connectingRef.current = false;
setAuthUrl(''); setAuthUrl('');
window.setTimeout(() => { window.setTimeout(() => {
@@ -154,18 +161,22 @@ export function useShellConnection({
socket.onclose = () => { socket.onclose = () => {
setIsConnected(false); setIsConnected(false);
setIsConnecting(false); setIsConnecting(false);
connectingRef.current = false;
clearTerminalScreen(); clearTerminalScreen();
}; };
socket.onerror = () => { socket.onerror = () => {
setIsConnected(false); setIsConnected(false);
setIsConnecting(false); setIsConnecting(false);
connectingRef.current = false;
}; };
} catch { } catch {
setIsConnected(false); setIsConnected(false);
setIsConnecting(false); setIsConnecting(false);
connectingRef.current = false;
} }
}, [ },
[
clearTerminalScreen, clearTerminalScreen,
fitAddonRef, fitAddonRef,
handleSocketMessage, handleSocketMessage,
@@ -178,15 +189,17 @@ export function useShellConnection({
setAuthUrl, setAuthUrl,
terminalRef, terminalRef,
wsRef, wsRef,
]); ],
);
const connectToShell = useCallback(() => { const connectToShell = useCallback(() => {
if (!isInitialized || isConnected || isConnecting) { if (!isInitialized || isConnected || isConnecting || connectingRef.current) {
return; return;
} }
connectingRef.current = true;
setIsConnecting(true); setIsConnecting(true);
connectWebSocket(); connectWebSocket(true);
}, [connectWebSocket, isConnected, isConnecting, isInitialized]); }, [connectWebSocket, isConnected, isConnecting, isInitialized]);
const disconnectFromShell = useCallback(() => { const disconnectFromShell = useCallback(() => {
@@ -194,6 +207,7 @@ export function useShellConnection({
clearTerminalScreen(); clearTerminalScreen();
setIsConnected(false); setIsConnected(false);
setIsConnecting(false); setIsConnecting(false);
connectingRef.current = false;
setAuthUrl(''); setAuthUrl('');
}, [clearTerminalScreen, closeSocket, setAuthUrl]); }, [clearTerminalScreen, closeSocket, setAuthUrl]);