mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-03-07 06:57:40 +00:00
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:
@@ -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,92 +102,104 @@ export function useShellConnection({
|
|||||||
[handleProcessCompletion, setAuthUrl, terminalRef],
|
[handleProcessCompletion, setAuthUrl, terminalRef],
|
||||||
);
|
);
|
||||||
|
|
||||||
const connectWebSocket = useCallback(() => {
|
const connectWebSocket = useCallback(
|
||||||
if (isConnecting || isConnected) {
|
(isConnectionLocked = false) => {
|
||||||
return;
|
if ((connectingRef.current && !isConnectionLocked) || isConnecting || isConnected) {
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const wsUrl = getShellWebSocketUrl();
|
|
||||||
if (!wsUrl) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const socket = new WebSocket(wsUrl);
|
try {
|
||||||
wsRef.current = socket;
|
const wsUrl = getShellWebSocketUrl();
|
||||||
|
if (!wsUrl) {
|
||||||
|
connectingRef.current = false;
|
||||||
|
setIsConnecting(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
socket.onopen = () => {
|
connectingRef.current = true;
|
||||||
setIsConnected(true);
|
|
||||||
setIsConnecting(false);
|
|
||||||
setAuthUrl('');
|
|
||||||
|
|
||||||
window.setTimeout(() => {
|
const socket = new WebSocket(wsUrl);
|
||||||
const currentTerminal = terminalRef.current;
|
wsRef.current = socket;
|
||||||
const currentFitAddon = fitAddonRef.current;
|
|
||||||
const currentProject = selectedProjectRef.current;
|
|
||||||
if (!currentTerminal || !currentFitAddon || !currentProject) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
currentFitAddon.fit();
|
socket.onopen = () => {
|
||||||
|
setIsConnected(true);
|
||||||
|
setIsConnecting(false);
|
||||||
|
connectingRef.current = false;
|
||||||
|
setAuthUrl('');
|
||||||
|
|
||||||
sendSocketMessage(wsRef.current, {
|
window.setTimeout(() => {
|
||||||
type: 'init',
|
const currentTerminal = terminalRef.current;
|
||||||
projectPath: currentProject.fullPath || currentProject.path || '',
|
const currentFitAddon = fitAddonRef.current;
|
||||||
sessionId: isPlainShellRef.current ? null : selectedSessionRef.current?.id || null,
|
const currentProject = selectedProjectRef.current;
|
||||||
hasSession: isPlainShellRef.current ? false : Boolean(selectedSessionRef.current),
|
if (!currentTerminal || !currentFitAddon || !currentProject) {
|
||||||
provider: isPlainShellRef.current
|
return;
|
||||||
? '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) => {
|
currentFitAddon.fit();
|
||||||
const rawPayload = typeof event.data === 'string' ? event.data : String(event.data ?? '');
|
|
||||||
handleSocketMessage(rawPayload);
|
|
||||||
};
|
|
||||||
|
|
||||||
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);
|
setIsConnected(false);
|
||||||
setIsConnecting(false);
|
setIsConnecting(false);
|
||||||
clearTerminalScreen();
|
connectingRef.current = false;
|
||||||
};
|
}
|
||||||
|
},
|
||||||
socket.onerror = () => {
|
[
|
||||||
setIsConnected(false);
|
clearTerminalScreen,
|
||||||
setIsConnecting(false);
|
fitAddonRef,
|
||||||
};
|
handleSocketMessage,
|
||||||
} catch {
|
initialCommandRef,
|
||||||
setIsConnected(false);
|
isConnected,
|
||||||
setIsConnecting(false);
|
isConnecting,
|
||||||
}
|
isPlainShellRef,
|
||||||
}, [
|
selectedProjectRef,
|
||||||
clearTerminalScreen,
|
selectedSessionRef,
|
||||||
fitAddonRef,
|
setAuthUrl,
|
||||||
handleSocketMessage,
|
terminalRef,
|
||||||
initialCommandRef,
|
wsRef,
|
||||||
isConnected,
|
],
|
||||||
isConnecting,
|
);
|
||||||
isPlainShellRef,
|
|
||||||
selectedProjectRef,
|
|
||||||
selectedSessionRef,
|
|
||||||
setAuthUrl,
|
|
||||||
terminalRef,
|
|
||||||
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]);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user