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
8 changes: 6 additions & 2 deletions ui/src/components/EnhancedWebSocketStatus.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,9 +51,13 @@ export default function EnhancedWebSocketStatus({
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
const [countdown, setCountdown] = useState<number | null>(null);

// Calculate reconnection delay (exponential backoff: 2^attempt seconds, max 30s)
// Calculate reconnection delay - matches the pattern in WebSocket hooks
// 30s, 15s, 15s, then 60s for all subsequent attempts
const getReconnectDelay = (attempt: number) => {
return Math.min(Math.pow(2, attempt), 30);
if (attempt === 0) return 30; // 30 seconds for first retry
if (attempt === 1) return 15; // 15 seconds for second retry
if (attempt === 2) return 15; // 15 seconds for third retry
return 60; // 60 seconds for all subsequent retries
};

// Countdown timer for reconnection
Expand Down
8 changes: 7 additions & 1 deletion ui/src/components/WebSocketErrorBoundary.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,14 +34,18 @@ interface State {
dismissed: boolean; // Track if user has dismissed the error
}

const WS_ERROR_DISMISSED_KEY = 'streamspace_ws_error_dismissed';

export default class WebSocketErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
// Check if error was previously dismissed (persists across page navigation)
const wasDismissed = localStorage.getItem(WS_ERROR_DISMISSED_KEY) === 'true';
this.state = {
hasError: false,
error: null,
errorInfo: null,
dismissed: false,
dismissed: wasDismissed,
};
}

Expand All @@ -67,6 +71,8 @@ export default class WebSocketErrorBoundary extends Component<Props, State> {
}

handleReset = () => {
// Store dismissed state in localStorage so it persists across page navigation
localStorage.setItem(WS_ERROR_DISMISSED_KEY, 'true');
this.setState({
hasError: false,
error: null,
Expand Down
33 changes: 24 additions & 9 deletions ui/src/hooks/useEnterpriseWebSocket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,18 @@
onClose,
onOpen,
autoReconnect = true,
reconnectInterval = 3000,
reconnectInterval = 3000, // Not used with custom backoff

Check notice

Code scanning / CodeQL

Unused variable, import, function or class Note

Unused variable reconnectInterval.

Copilot Autofix

AI 7 months ago

To fix the problem, remove the reconnectInterval variable from the destructured assignment in the useEnterpriseWebSocket options (lines 37-45). No other references or usages exist, so no other code changes are needed. The related comment ("// Not used with custom backoff") may also be safely removed, as it becomes irrelevant once the variable is gone. Ensure that surrounding commas and code remain syntactically correct after the removal.


Suggested changeset 1
ui/src/hooks/useEnterpriseWebSocket.ts

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/ui/src/hooks/useEnterpriseWebSocket.ts b/ui/src/hooks/useEnterpriseWebSocket.ts
--- a/ui/src/hooks/useEnterpriseWebSocket.ts
+++ b/ui/src/hooks/useEnterpriseWebSocket.ts
@@ -40,7 +40,6 @@
     onClose,
     onOpen,
     autoReconnect = true,
-    reconnectInterval = 3000, // Not used with custom backoff
     maxReconnectAttempts = 10,
   } = options;
 
EOF
@@ -40,7 +40,6 @@
onClose,
onOpen,
autoReconnect = true,
reconnectInterval = 3000, // Not used with custom backoff
maxReconnectAttempts = 10,
} = options;

Copilot is powered by AI and may make mistakes. Always verify output.
maxReconnectAttempts = 10,
} = options;

// Custom backoff pattern: 30s, 15s, 15s, then 60s for all subsequent attempts
const getReconnectDelay = (attemptNumber: number): number => {
if (attemptNumber === 0) return 30000; // 30 seconds for first retry
if (attemptNumber === 1) return 15000; // 15 seconds for second retry
if (attemptNumber === 2) return 15000; // 15 seconds for third retry
return 60000; // 60 seconds for all subsequent retries
};

const [isConnected, setIsConnected] = useState(false);
const [lastMessage, setLastMessage] = useState<WebSocketMessage | null>(null);
const [reconnectAttempts, setReconnectAttempts] = useState(0);
Expand Down Expand Up @@ -153,15 +161,16 @@
autoReconnect &&
currentAttempts < maxReconnectAttempts
) {
// console.log(
// `[WebSocket] Attempting reconnection in ${reconnectInterval}ms (attempt ${currentAttempts + 1}/${maxReconnectAttempts})`
// );
const delay = getReconnectDelay(currentAttempts);
console.log(
`[WebSocket] Attempting reconnection in ${delay / 1000}s (attempt ${currentAttempts + 1}/${maxReconnectAttempts})`
);

reconnectTimeoutRef.current = setTimeout(() => {
reconnectAttemptsRef.current += 1;
setReconnectAttempts((prev) => prev + 1);
connect();
}, reconnectInterval);
}, delay);
} else if (currentAttempts >= maxReconnectAttempts) {
console.error('[WebSocket] Max reconnection attempts reached');
}
Expand All @@ -173,9 +182,8 @@
}, [
getWebSocketUrl,
autoReconnect,
reconnectInterval,
maxReconnectAttempts,
]);
]); // Removed reconnectInterval since we use getReconnectDelay

const disconnect = useCallback(() => {
// console.log('[WebSocket] Disconnecting...');
Expand Down Expand Up @@ -265,11 +273,18 @@
autoReconnect: true,
});

// Store handler in ref to avoid re-running effect when handler changes
const handlerRef = useRef(handler);

useEffect(() => {
handlerRef.current = handler;
}, [handler]);

useEffect(() => {
if (enabled && lastMessage && lastMessage.type === eventType) {
handler(lastMessage.data);
handlerRef.current(lastMessage.data);
}
}, [lastMessage, eventType, handler, enabled]);
}, [lastMessage, eventType, enabled]);
}

// Predefined hooks for enterprise events
Expand Down
18 changes: 13 additions & 5 deletions ui/src/hooks/useWebSocket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export function useWebSocket({
onError,
onOpen,
onClose,
reconnectInterval = 3000,
reconnectInterval = 3000, // Not used with custom backoff
maxReconnectAttempts = 10,
}: UseWebSocketOptions): UseWebSocketReturn {
const [isConnected, setIsConnected] = useState(false);
Expand All @@ -40,6 +40,14 @@ export function useWebSocket({
const shouldReconnectRef = useRef(true);
const reconnectAttemptsRef = useRef(0);

// Custom backoff pattern: 30s, 15s, 15s, then 60s for all subsequent attempts
const getReconnectDelay = (attemptNumber: number): number => {
if (attemptNumber === 0) return 30000; // 30 seconds for first retry
if (attemptNumber === 1) return 15000; // 15 seconds for second retry
if (attemptNumber === 2) return 15000; // 15 seconds for third retry
return 60000; // 60 seconds for all subsequent retries
};

// Store callbacks in refs to avoid reconnection when they change
const onMessageRef = useRef(onMessage);
const onErrorRef = useRef(onError);
Expand Down Expand Up @@ -94,11 +102,11 @@ export function useWebSocket({
setIsConnected(false);
onCloseRef.current?.();

// Attempt reconnection with exponential backoff
// Attempt reconnection with custom backoff pattern
const currentAttempts = reconnectAttemptsRef.current;
if (shouldReconnectRef.current && currentAttempts < maxReconnectAttempts) {
const delay = Math.min(reconnectInterval * Math.pow(1.5, currentAttempts), 30000);
// console.log(`Reconnecting in ${delay}ms (attempt ${currentAttempts + 1}/${maxReconnectAttempts})`);
const delay = getReconnectDelay(currentAttempts);
console.log(`Reconnecting in ${delay / 1000}s (attempt ${currentAttempts + 1}/${maxReconnectAttempts})`);

reconnectTimeoutRef.current = setTimeout(() => {
reconnectAttemptsRef.current += 1;
Expand All @@ -114,7 +122,7 @@ export function useWebSocket({
} catch (error) {
console.error('Failed to create WebSocket connection:', error);
}
}, [url, reconnectInterval, maxReconnectAttempts]);
}, [url, maxReconnectAttempts]); // Removed reconnectInterval since we use getReconnectDelay

const sendMessage = useCallback((message: any) => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
Expand Down
61 changes: 33 additions & 28 deletions ui/src/pages/SharedSessions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -114,38 +114,43 @@ export default function SharedSessions() {
// Real-time session updates via WebSocket with notifications
// Wrap callback in useCallback to prevent reconnection loop
const handleSessionsUpdate = useCallback((updatedSessions: any[]) => {
if (!currentUser?.id || sessions.length === 0) return;
if (!currentUser?.id) return;

// Update shared sessions with real-time data and show notifications for changes
const updatedSharedSessions = sessions.map((sharedSession) => {
const updated = updatedSessions.find((s: any) => s.id === sharedSession.id);
if (updated) {
// Check if state changed
const prevState = prevStatesRef.current.get(sharedSession.id);
if (updated.state !== prevState && prevState !== undefined) {
// Show notification for state changes
addNotification({
message: `${sharedSession.templateName} (${sharedSession.ownerUsername}): ${prevState} → ${updated.state}`,
severity: updated.state === 'running' ? 'success' : updated.state === 'hibernated' ? 'warning' : 'error',
priority: updated.state === 'terminated' ? 'high' : 'medium',
title: 'Shared Session Updated',
});
}
// Update shared sessions using the callback form of setState to avoid dependency on sessions state
setSessions((currentSessions) => {
if (currentSessions.length === 0) return currentSessions;

// Update state tracking
prevStatesRef.current.set(sharedSession.id, updated.state);
// Update shared sessions with real-time data and show notifications for changes
const updatedSharedSessions = currentSessions.map((sharedSession) => {
const updated = updatedSessions.find((s: any) => s.id === sharedSession.id);
if (updated) {
// Check if state changed
const prevState = prevStatesRef.current.get(sharedSession.id);
if (updated.state !== prevState && prevState !== undefined) {
// Show notification for state changes
addNotification({
message: `${sharedSession.templateName} (${sharedSession.ownerUsername}): ${prevState} → ${updated.state}`,
severity: updated.state === 'running' ? 'success' : updated.state === 'hibernated' ? 'warning' : 'error',
priority: updated.state === 'terminated' ? 'high' : 'medium',
title: 'Shared Session Updated',
});
}

return {
...sharedSession,
state: updated.state,
url: updated.status?.url || sharedSession.url,
};
}
return sharedSession;
});
// Update state tracking
prevStatesRef.current.set(sharedSession.id, updated.state);

setSessions(updatedSharedSessions);
}, [currentUser?.id, sessions, addNotification]);
return {
...sharedSession,
state: updated.state,
url: updated.status?.url || sharedSession.url,
};
}
return sharedSession;
});

return updatedSharedSessions;
});
}, [currentUser?.id, addNotification]);

const baseWebSocket = useSessionsWebSocket(handleSessionsUpdate);

Expand Down
Loading