CLAUDE: Fix infinite WebSocket reconnection loop (HIGH-002)

After max reconnection attempts (10), the stuck state detector was
continuing to retry forever. Now:

- Add permanentlyFailed state flag to track when we've given up
- Set flag and stop stuck state detector when max attempts reached
- Add manualRetry() method for UI to reset state and try again
- Expose permanentlyFailed in public API for error boundary (HIGH-001)

Also marks CRIT-002 and CRIT-002-BACKEND as completed in project plan.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Cal Corum 2026-01-13 20:49:04 -06:00
parent 89a63af2a8
commit 1a7e464990
2 changed files with 52 additions and 5 deletions

View File

@ -47,8 +47,8 @@
"description": "myTeamId is hardcoded to null with a TODO comment, breaking the substitution panel's ability to determine which team's lineup to display. Users cannot see or manage their own team's substitutions.", "description": "myTeamId is hardcoded to null with a TODO comment, breaking the substitution panel's ability to determine which team's lineup to display. Users cannot see or manage their own team's substitutions.",
"category": "critical", "category": "critical",
"priority": 2, "priority": 2,
"completed": false, "completed": true,
"tested": false, "tested": true,
"dependencies": ["CRIT-002-BACKEND"], "dependencies": ["CRIT-002-BACKEND"],
"files": [ "files": [
{ {
@ -67,8 +67,8 @@
"description": "Backend dependency for CRIT-002. The /api/auth/me endpoint needs to return the user's team ownership information so frontend can determine if user owns home/away team.", "description": "Backend dependency for CRIT-002. The /api/auth/me endpoint needs to return the user's team ownership information so frontend can determine if user owns home/away team.",
"category": "critical", "category": "critical",
"priority": 2, "priority": 2,
"completed": false, "completed": true,
"tested": false, "tested": true,
"dependencies": [], "dependencies": [],
"files": [ "files": [
{ {

View File

@ -85,6 +85,7 @@ const isConnected = ref(false)
const isConnecting = ref(false) const isConnecting = ref(false)
const connectionError = ref<string | null>(null) const connectionError = ref<string | null>(null)
const lastConnectionAttempt = ref<number | null>(null) const lastConnectionAttempt = ref<number | null>(null)
const permanentlyFailed = ref(false) // Set when max reconnection attempts reached
// Reset reactive state on client hydration to ensure clean slate // Reset reactive state on client hydration to ensure clean slate
if (import.meta.client) { if (import.meta.client) {
@ -93,6 +94,7 @@ if (import.meta.client) {
isConnecting.value = false isConnecting.value = false
connectionError.value = null connectionError.value = null
lastConnectionAttempt.value = null lastConnectionAttempt.value = null
permanentlyFailed.value = false
console.log('[WebSocket] Reactive state reset on client hydration') console.log('[WebSocket] Reactive state reset on client hydration')
} }
@ -133,6 +135,7 @@ export function useWebSocket() {
const state = getClientState() const state = getClientState()
return ( return (
!isConnected.value && !isConnected.value &&
!permanentlyFailed.value &&
canConnect.value && canConnect.value &&
state.reconnectionAttempts < MAX_RECONNECTION_ATTEMPTS state.reconnectionAttempts < MAX_RECONNECTION_ATTEMPTS
) )
@ -252,6 +255,7 @@ export function useWebSocket() {
isConnected.value = false isConnected.value = false
isConnecting.value = false isConnecting.value = false
connectionError.value = null connectionError.value = null
permanentlyFailed.value = false
state.reconnectionAttempts = 0 state.reconnectionAttempts = 0
} }
@ -283,6 +287,7 @@ export function useWebSocket() {
isConnected.value = false isConnected.value = false
isConnecting.value = false isConnecting.value = false
connectionError.value = null connectionError.value = null
permanentlyFailed.value = false
state.reconnectionAttempts = 0 state.reconnectionAttempts = 0
// Reconnect after a short delay // Reconnect after a short delay
@ -291,6 +296,30 @@ export function useWebSocket() {
}, 100) }, 100)
} }
/**
* Manual retry after permanent failure.
* Resets all connection state and attempts fresh connection.
* Use this from UI when user clicks "Retry" after max attempts reached.
*/
function manualRetry() {
// SSR guard
if (!import.meta.client) return
const state = getClientState()
console.log('[WebSocket] Manual retry requested - resetting failed state')
// Reset all state
permanentlyFailed.value = false
connectionError.value = null
state.reconnectionAttempts = 0
// Restart stuck state detection
startStuckStateDetection()
// Attempt connection
forceReconnect()
}
/** /**
* Reconnect with exponential backoff * Reconnect with exponential backoff
*/ */
@ -300,8 +329,18 @@ export function useWebSocket() {
const state = getClientState() const state = getClientState()
// Check if we've hit the max attempts
if (state.reconnectionAttempts >= MAX_RECONNECTION_ATTEMPTS) {
console.error(`[WebSocket] Max reconnection attempts (${MAX_RECONNECTION_ATTEMPTS}) reached - giving up`)
permanentlyFailed.value = true
connectionError.value = `Connection failed after ${MAX_RECONNECTION_ATTEMPTS} attempts. Click retry to try again.`
// Stop the stuck state detector since we've given up
stopStuckStateDetection()
return
}
if (!shouldReconnect.value) { if (!shouldReconnect.value) {
console.log('[WebSocket] Reconnection not needed or max attempts reached') console.log('[WebSocket] Reconnection not needed')
return return
} }
@ -690,6 +729,12 @@ export function useWebSocket() {
if (state.stuckStateCheckInterval) return if (state.stuckStateCheckInterval) return
state.stuckStateCheckInterval = setInterval(() => { state.stuckStateCheckInterval = setInterval(() => {
// Don't attempt if we've permanently failed - user must manually retry
if (permanentlyFailed.value) {
console.log('[WebSocket] Stuck state check skipped - permanently failed')
return
}
// Try to connect if not connected - don't check auth state // Try to connect if not connected - don't check auth state
// Backend will authenticate via cookies // Backend will authenticate via cookies
if (!isConnected.value && !isConnecting.value) { if (!isConnected.value && !isConnecting.value) {
@ -754,11 +799,13 @@ export function useWebSocket() {
isConnected: readonly(isConnected), isConnected: readonly(isConnected),
isConnecting: readonly(isConnecting), isConnecting: readonly(isConnecting),
connectionError: readonly(connectionError), connectionError: readonly(connectionError),
permanentlyFailed: readonly(permanentlyFailed),
canConnect, canConnect,
// Actions // Actions
connect, connect,
disconnect, disconnect,
forceReconnect, forceReconnect,
manualRetry,
} }
} }