diff --git a/docs/websocket-implementation.md b/docs/websocket-implementation.md index 6d2464c1..6819f7ca 100644 --- a/docs/websocket-implementation.md +++ b/docs/websocket-implementation.md @@ -796,7 +796,74 @@ sequenceDiagram - `isFilteringCorruptedItemsRef` prevents useEffect re-trigger loops - `lastCorruptionResyncRef` tracks cooldown timing -### 7. Subscription Error / Complete +### 7. iOS Background Connection Loss + +iOS Safari silently kills WebSocket connections when the browser is backgrounded. The `graphql-ws` library's keepalive (10s) eventually detects the dead socket, but during the detection delay the UI would remain interactive while mutations silently fail. This section describes the proactive detection and UI feedback mechanism. + +```mermaid +sequenceDiagram + participant U as User + participant UI as Queue Control Bar + participant PS as PersistentSession + participant GC as graphql-ws Client + participant WS as WebSocket + + Note over U,WS: User backgrounds iOS Safari + + WS--xGC: OS kills socket silently + GC->>PS: on.closed → onConnectionStateChange(false) + PS->>PS: setIsWebSocketConnected(false) + PS->>PS: isReconnecting = hasConnected && !isWebSocketConnected + PS->>UI: isReconnecting = true + UI->>U: Show "Reconnecting..." Snackbar notification + + Note over U,WS: User returns to app + + U->>PS: visibilitychange → "visible" + PS->>PS: Debounce 300ms + PS->>PS: triggerResync() + + Note over GC: graphql-ws detects dead socket, retries + + GC->>WS: Reconnect + WS-->>GC: Connected + GC->>PS: on.connected → onConnectionStateChange(true, isReconnect=true) + PS->>PS: Skip setIsWebSocketConnected(true) — defer to handleReconnect + GC->>PS: onReconnect → handleReconnect() + PS->>WS: joinSession + delta/full sync + WS-->>PS: Synced state + PS->>PS: setIsWebSocketConnected(true) + PS->>PS: isReconnecting = false + PS->>UI: isReconnecting = false + UI->>U: Snackbar auto-dismisses, mutations re-enabled +``` + +**Key mechanisms:** + +1. **Real-time WebSocket state tracking**: The `graphql-ws` client reports `connected`/`closed` events via `onConnectionStateChange`. The `isWebSocketConnected` state tracks the transport layer status. + +2. **Reconnect lock**: On reconnection, `onConnectionStateChange(true, isReconnect=true)` is deferred — the persistent session does NOT set `isWebSocketConnected=true` until `handleReconnect` completes its async resync (joinSession + delta/full sync). This prevents the UI from re-enabling mutations while stale state is still being replayed. + +3. **`visibilitychange` handler**: When the page becomes visible during an active session, a debounced (300ms) resync is triggered. This proactively detects dead sockets by attempting to use the connection, rather than waiting for the next keepalive ping. + +4. **Reconnecting notification**: A MUI `Snackbar` with an `Alert` (severity `info`) renders at the bottom-center of the screen while `isReconnecting` is true. The notification is non-blocking — users can still interact with the UI, but queue mutations are prevented by `viewOnlyMode`. The Snackbar automatically disappears when the connection is re-established (`isReconnecting` becomes false). + +**Derivation:** +```typescript +const isReconnecting = hasConnected && !isWebSocketConnected; +``` + +- `hasConnected` = "session was successfully joined at least once" +- `isWebSocketConnected` = "transport is currently connected AND resync is complete" +- During initial connection: `hasConnected=false` → `isReconnecting=false` (handled by existing `viewOnlyMode`) +- During reconnection resync: `isWebSocketConnected` stays `false` until `handleReconnect` finishes + +**Context threading:** +- `PersistentSessionContextType.isReconnecting` → `GraphQLQueueContextType.isReconnecting` → `QueueContextType.isReconnecting` +- Also incorporated into `viewOnlyMode`: `if (isReconnecting) return true` +- Bridge adapter passes `isReconnecting: isParty ? ps.isReconnecting : false` (local queue unaffected) + +### 8. Subscription Error / Complete ```mermaid sequenceDiagram diff --git a/package-lock.json b/package-lock.json index dc9ac208..771d0b84 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5178,7 +5178,7 @@ "version": "1.58.2", "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz", "integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "dependencies": { "playwright": "1.58.2" @@ -8627,7 +8627,7 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/bufferutil/-/bufferutil-4.1.0.tgz", "integrity": "sha512-ZMANVnAixE6AWWnPzlW2KpUrxhm9woycYvPOo67jWHyFowASTEd9s+QN1EIMsSDtwhIxN4sWE1jotpuDUIgyIw==", - "dev": true, + "devOptional": true, "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -11748,7 +11748,7 @@ "version": "1.58.2", "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "dependencies": { "playwright-core": "1.58.2" @@ -11767,7 +11767,7 @@ "version": "1.58.2", "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "playwright-core": "cli.js" diff --git a/packages/backend/src/graphql/resolvers/sessions/mutations.ts b/packages/backend/src/graphql/resolvers/sessions/mutations.ts index 40a146fd..c85b01c4 100644 --- a/packages/backend/src/graphql/resolvers/sessions/mutations.ts +++ b/packages/backend/src/graphql/resolvers/sessions/mutations.ts @@ -65,76 +65,81 @@ export const sessionMutations = { }, ctx: ConnectionContext ) => { - if (DEBUG) console.log(`[joinSession] START - connectionId: ${ctx.connectionId}, sessionId: ${sessionId}, username: ${username}, sessionName: ${sessionName}, initialQueueLength: ${initialQueue?.length || 0}`); + console.log(`[joinSession] connectionId: ${ctx.connectionId.slice(0, 8)}, sessionId: ${sessionId.slice(0, 8)}, authenticated: ${ctx.isAuthenticated}`); - await applyRateLimit(ctx, 10); // Limit session joins to prevent abuse + try { + await applyRateLimit(ctx, 10); // Limit session joins to prevent abuse - // Validate inputs - validateInput(SessionIdSchema, sessionId, 'sessionId'); - validateInput(BoardPathSchema, boardPath, 'boardPath'); - if (username) validateInput(UsernameSchema, username, 'username'); - if (avatarUrl) validateInput(AvatarUrlSchema, avatarUrl, 'avatarUrl'); - if (sessionName) validateInput(SessionNameSchema, sessionName, 'sessionName'); - if (initialQueue) validateInput(QueueArraySchema, initialQueue, 'initialQueue'); - if (initialCurrentClimb) validateInput(ClimbQueueItemSchema, initialCurrentClimb, 'initialCurrentClimb'); - - const result = await roomManager.joinSession( - ctx.connectionId, - sessionId, - boardPath, - username || undefined, - avatarUrl || undefined, - initialQueue, - initialCurrentClimb || null, - sessionName || undefined - ); - if (DEBUG) console.log(`[joinSession] roomManager.joinSession completed - clientId: ${result.clientId}, isLeader: ${result.isLeader}`); - - // Update context with session info - if (DEBUG) console.log(`[joinSession] Before updateContext - ctx.sessionId: ${ctx.sessionId}`); - updateContext(ctx.connectionId, { sessionId, userId: result.clientId }); - if (DEBUG) console.log(`[joinSession] After updateContext - ctx.sessionId: ${ctx.sessionId}`); - - // Auto-authorize user's ESP32 controllers for this session (if authenticated) - if (ctx.isAuthenticated && ctx.userId) { - authorizeUserControllersForSession(ctx.userId, sessionId); - } + // Validate inputs + validateInput(SessionIdSchema, sessionId, 'sessionId'); + validateInput(BoardPathSchema, boardPath, 'boardPath'); + if (username) validateInput(UsernameSchema, username, 'username'); + if (avatarUrl) validateInput(AvatarUrlSchema, avatarUrl, 'avatarUrl'); + if (sessionName) validateInput(SessionNameSchema, sessionName, 'sessionName'); + if (initialQueue) validateInput(QueueArraySchema, initialQueue, 'initialQueue'); + if (initialCurrentClimb) validateInput(ClimbQueueItemSchema, initialCurrentClimb, 'initialCurrentClimb'); - // Notify session about new user - const userJoinedEvent: SessionEvent = { - __typename: 'UserJoined', - user: { - id: result.clientId, - username: username || `User-${result.clientId.substring(0, 6)}`, - isLeader: result.isLeader, - avatarUrl: avatarUrl, - }, - }; - pubsub.publishSessionEvent(sessionId, userJoinedEvent); + const result = await roomManager.joinSession( + ctx.connectionId, + sessionId, + boardPath, + username || undefined, + avatarUrl || undefined, + initialQueue, + initialCurrentClimb || null, + sessionName || undefined + ); + if (DEBUG) console.log(`[joinSession] roomManager.joinSession completed - clientId: ${result.clientId}, isLeader: ${result.isLeader}`); - // Fetch session data for new fields - const sessionData = await roomManager.getSessionById(sessionId); + // Update context with session info + updateContext(ctx.connectionId, { sessionId, userId: result.clientId }); - return { - id: sessionId, - name: result.sessionName || null, - boardPath, - users: result.users, - queueState: { - sequence: result.sequence, - stateHash: result.stateHash, - queue: result.queue, - currentClimbQueueItem: result.currentClimbQueueItem, - }, - isLeader: result.isLeader, - clientId: result.clientId, - goal: sessionData?.goal || null, - isPublic: sessionData?.isPublic ?? true, - startedAt: sessionData?.startedAt?.toISOString() || null, - endedAt: sessionData?.endedAt?.toISOString() || null, - isPermanent: sessionData?.isPermanent ?? false, - color: sessionData?.color || null, - }; + // Auto-authorize user's ESP32 controllers for this session (if authenticated) + if (ctx.isAuthenticated && ctx.userId) { + authorizeUserControllersForSession(ctx.userId, sessionId); + } + + // Notify session about new user + const userJoinedEvent: SessionEvent = { + __typename: 'UserJoined', + user: { + id: result.clientId, + username: username || `User-${result.clientId.substring(0, 6)}`, + isLeader: result.isLeader, + avatarUrl: avatarUrl, + }, + }; + pubsub.publishSessionEvent(sessionId, userJoinedEvent); + + // Fetch session data for new fields + const sessionData = await roomManager.getSessionById(sessionId); + + console.log(`[joinSession] completed - connectionId: ${ctx.connectionId.slice(0, 8)}, clientId: ${result.clientId.slice(0, 8)}, users: ${result.users.length}`); + + return { + id: sessionId, + name: result.sessionName || null, + boardPath, + users: result.users, + queueState: { + sequence: result.sequence, + stateHash: result.stateHash, + queue: result.queue, + currentClimbQueueItem: result.currentClimbQueueItem, + }, + isLeader: result.isLeader, + clientId: result.clientId, + goal: sessionData?.goal || null, + isPublic: sessionData?.isPublic ?? true, + startedAt: sessionData?.startedAt?.toISOString() || null, + endedAt: sessionData?.endedAt?.toISOString() || null, + isPermanent: sessionData?.isPermanent ?? false, + color: sessionData?.color || null, + }; + } catch (err) { + console.error(`[joinSession] FAILED - connectionId: ${ctx.connectionId.slice(0, 8)}, sessionId: ${sessionId.slice(0, 8)}:`, err); + throw err; + } }, /** diff --git a/packages/backend/src/services/distributed-state.ts b/packages/backend/src/services/distributed-state.ts index 589ca280..d93d377e 100644 --- a/packages/backend/src/services/distributed-state.ts +++ b/packages/backend/src/services/distributed-state.ts @@ -861,12 +861,24 @@ export class DistributedStateManager { const [err, data] = results[i] as [Error | null, Record]; if (!err && data && data.connectionId) { const connection = this.hashToConnection(data); - users.push({ - id: connection.connectionId, - username: connection.username, - isLeader: connection.isLeader, - avatarUrl: connection.avatarUrl || undefined, - }); + // Validate required fields — username is String! in GraphQL schema. + // A missing username would cause null propagation and break the entire response. + if (connection.username) { + users.push({ + id: connection.connectionId, + username: connection.username, + isLeader: connection.isLeader, + avatarUrl: connection.avatarUrl || undefined, + }); + } else { + console.warn( + `[DistributedState] Skipping member ${connection.connectionId.slice(0, 8)} with missing username in session ${sessionId}` + ); + this.redis.srem(KEYS.sessionMembers(sessionId), memberIds[i]).catch(() => {}); + } + } else if (!err) { + // Connection hash expired — clean up stale member set entry + this.redis.srem(KEYS.sessionMembers(sessionId), memberIds[i]).catch(() => {}); } } } diff --git a/packages/backend/src/websocket/setup.ts b/packages/backend/src/websocket/setup.ts index 2fecac64..2a3da23c 100644 --- a/packages/backend/src/websocket/setup.ts +++ b/packages/backend/src/websocket/setup.ts @@ -185,8 +185,11 @@ export function setupWebSocketServer(httpServer: HttpServer): WebSocketServer { } } }, - onError: (_ctx: ServerContext, _id: string, _payload, errors) => { - console.error('GraphQL error:', errors); + onError: (ctx: ServerContext, _id: string, payload, errors) => { + const context = (ctx.extra as CustomExtra)?.context; + const connectionId = context?.connectionId?.slice(0, 8) || 'unknown'; + const operationName = payload?.operationName || 'anonymous'; + console.error(`[GraphQL] Error in operation "${operationName}" for connection ${connectionId}:`, errors); }, onComplete: (_ctx: ServerContext, _id: string, payload) => { if (DEBUG) { diff --git a/packages/shared-schema/src/operations.ts b/packages/shared-schema/src/operations.ts index 19508809..7bc76b1a 100644 --- a/packages/shared-schema/src/operations.ts +++ b/packages/shared-schema/src/operations.ts @@ -292,7 +292,7 @@ export const EVENTS_REPLAY = ` } ... on QueueItemAdded { sequence - addedItem: item { + item { ${QUEUE_ITEM_FIELDS} } position @@ -309,7 +309,7 @@ export const EVENTS_REPLAY = ` } ... on CurrentClimbChanged { sequence - currentItem: item { + item { ${QUEUE_ITEM_FIELDS} } clientId diff --git a/packages/web/app/components/graphql-queue/QueueContext.tsx b/packages/web/app/components/graphql-queue/QueueContext.tsx index daab6ac5..d7fe0f99 100644 --- a/packages/web/app/components/graphql-queue/QueueContext.tsx +++ b/packages/web/app/components/graphql-queue/QueueContext.tsx @@ -414,6 +414,7 @@ export const GraphQLQueueProvider = ({ parsedParams, boardDetails, children, bas const clientId = isPersistentSessionActive ? persistentSession.clientId : null; const isLeader = isPersistentSessionActive ? persistentSession.isLeader : false; const hasConnected = isPersistentSessionActive ? persistentSession.hasConnected : false; + const isReconnecting = isPersistentSessionActive ? persistentSession.isReconnecting : false; // Memoize users array to prevent unnecessary context value recreation // Note: persistentSession.users is already stable from the persistent session context const users = useMemo( @@ -614,8 +615,9 @@ export const GraphQLQueueProvider = ({ parsedParams, boardDetails, children, bas if (!sessionId) return false; // No session = local mode, not view-only if (!backendUrl) return false; // No backend = no view-only mode if (!hasConnected) return true; // Still connecting = view-only + if (isReconnecting) return true; // WebSocket dropped, disable until reconnected return false; // Once connected, everyone can modify the queue - }, [sessionId, backendUrl, hasConnected]); + }, [sessionId, backendUrl, hasConnected, isReconnecting]); const contextValue: GraphQLQueueContextType = useMemo( () => ({ @@ -649,6 +651,7 @@ export const GraphQLQueueProvider = ({ parsedParams, boardDetails, children, bas isLeader, isBackendMode: !!backendUrl, hasConnected, + isReconnecting, connectionError, disconnect: persistentSession.deactivateSession, @@ -843,6 +846,7 @@ export const GraphQLQueueProvider = ({ parsedParams, boardDetails, children, bas isLeader, users, hasConnected, + isReconnecting, connectionError, backendUrl, persistentSession, diff --git a/packages/web/app/components/graphql-queue/__tests__/graphql-client.test.ts b/packages/web/app/components/graphql-queue/__tests__/graphql-client.test.ts new file mode 100644 index 00000000..c37195a4 --- /dev/null +++ b/packages/web/app/components/graphql-queue/__tests__/graphql-client.test.ts @@ -0,0 +1,147 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Capture the `on` handlers passed to createClient so we can invoke them in tests +let capturedOnHandlers: { + connected?: () => void; + closed?: (event: unknown) => void; + error?: (error: unknown) => void; +} = {}; + +const mockDispose = vi.fn(); +const mockSubscribe = vi.fn(); + +vi.mock('graphql-ws', () => ({ + createClient: vi.fn((options: { on?: typeof capturedOnHandlers }) => { + capturedOnHandlers = options.on || {}; + return { + subscribe: mockSubscribe, + dispose: mockDispose, + }; + }), +})); + +import { createGraphQLClient } from '../graphql-client'; + +describe('createGraphQLClient', () => { + beforeEach(() => { + capturedOnHandlers = {}; + vi.clearAllMocks(); + }); + + describe('onConnectionStateChange callback', () => { + it('calls onConnectionStateChange(true, false) on first connection', () => { + const onConnectionStateChange = vi.fn(); + createGraphQLClient({ + url: 'ws://test', + onConnectionStateChange, + }); + + capturedOnHandlers.connected!(); + + expect(onConnectionStateChange).toHaveBeenCalledTimes(1); + expect(onConnectionStateChange).toHaveBeenCalledWith(true, false); + }); + + it('calls onConnectionStateChange(true, true) on reconnection', () => { + const onConnectionStateChange = vi.fn(); + createGraphQLClient({ + url: 'ws://test', + onConnectionStateChange, + }); + + // First connection + capturedOnHandlers.connected!(); + onConnectionStateChange.mockClear(); + + // Reconnection + capturedOnHandlers.connected!(); + + expect(onConnectionStateChange).toHaveBeenCalledTimes(1); + expect(onConnectionStateChange).toHaveBeenCalledWith(true, true); + }); + + it('calls onConnectionStateChange(false, false) on close', () => { + const onConnectionStateChange = vi.fn(); + createGraphQLClient({ + url: 'ws://test', + onConnectionStateChange, + }); + + capturedOnHandlers.closed!({ code: 1000 }); + + expect(onConnectionStateChange).toHaveBeenCalledTimes(1); + expect(onConnectionStateChange).toHaveBeenCalledWith(false, false); + }); + + it('does not call onReconnect on first connection', () => { + const onReconnect = vi.fn(); + createGraphQLClient({ + url: 'ws://test', + onReconnect, + }); + + capturedOnHandlers.connected!(); + + expect(onReconnect).not.toHaveBeenCalled(); + }); + + it('calls onReconnect on subsequent connections', () => { + const onReconnect = vi.fn(); + createGraphQLClient({ + url: 'ws://test', + onReconnect, + }); + + // First connection + capturedOnHandlers.connected!(); + expect(onReconnect).not.toHaveBeenCalled(); + + // Reconnection + capturedOnHandlers.connected!(); + expect(onReconnect).toHaveBeenCalledTimes(1); + }); + + it('fires onConnectionStateChange before onReconnect on reconnection', () => { + const callOrder: string[] = []; + const onConnectionStateChange = vi.fn(() => callOrder.push('stateChange')); + const onReconnect = vi.fn(() => callOrder.push('reconnect')); + + createGraphQLClient({ + url: 'ws://test', + onConnectionStateChange, + onReconnect, + }); + + // First connection + capturedOnHandlers.connected!(); + callOrder.length = 0; + + // Reconnection — stateChange should fire before reconnect + capturedOnHandlers.connected!(); + + expect(callOrder).toEqual(['stateChange', 'reconnect']); + }); + + it('reports correct sequence: connected → closed → reconnected', () => { + const calls: Array<[boolean, boolean]> = []; + const onConnectionStateChange = vi.fn( + (connected: boolean, isReconnect: boolean) => calls.push([connected, isReconnect]), + ); + + createGraphQLClient({ + url: 'ws://test', + onConnectionStateChange, + }); + + capturedOnHandlers.connected!(); // initial connect + capturedOnHandlers.closed!({}); // socket drops + capturedOnHandlers.connected!(); // reconnect + + expect(calls).toEqual([ + [true, false], // initial connect + [false, false], // socket drops + [true, true], // reconnect + ]); + }); + }); +}); diff --git a/packages/web/app/components/graphql-queue/graphql-client.ts b/packages/web/app/components/graphql-queue/graphql-client.ts index b1b4214e..71166597 100644 --- a/packages/web/app/components/graphql-queue/graphql-client.ts +++ b/packages/web/app/components/graphql-queue/graphql-client.ts @@ -33,6 +33,7 @@ export interface GraphQLClientOptions { url: string; authToken?: string | null; onReconnect?: () => void; + onConnectionStateChange?: (connected: boolean, isReconnect: boolean) => void; } /** @@ -53,7 +54,7 @@ export function createGraphQLClient( ? { url: urlOrOptions, onReconnect } : urlOrOptions; - const { url, authToken, onReconnect: onReconnectCallback } = options; + const { url, authToken, onReconnect: onReconnectCallback, onConnectionStateChange } = options; const clientId = ++clientCounter; @@ -82,8 +83,10 @@ export function createGraphQLClient( connectionParams: authToken ? { authToken } : undefined, on: { connected: () => { - if (DEBUG) console.log(`[GraphQL] Client #${clientId} connected (first: ${!hasConnectedOnce})`); - if (hasConnectedOnce && onReconnectCallback) { + const isReconnect = hasConnectedOnce; + if (DEBUG) console.log(`[GraphQL] Client #${clientId} connected (first: ${!isReconnect})`); + onConnectionStateChange?.(true, isReconnect); + if (isReconnect && onReconnectCallback) { if (DEBUG) console.log(`[GraphQL] Client #${clientId} reconnected, calling onReconnect`); onReconnectCallback(); } @@ -91,6 +94,7 @@ export function createGraphQLClient( }, closed: (event) => { if (DEBUG) console.log(`[GraphQL] Client #${clientId} closed`, event); + onConnectionStateChange?.(false, false); }, error: (error) => { if (DEBUG) console.log(`[GraphQL] Client #${clientId} error`, error); diff --git a/packages/web/app/components/persistent-session/__tests__/connection-state.test.ts b/packages/web/app/components/persistent-session/__tests__/connection-state.test.ts new file mode 100644 index 00000000..31d26ea6 --- /dev/null +++ b/packages/web/app/components/persistent-session/__tests__/connection-state.test.ts @@ -0,0 +1,171 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +/** + * Tests for the isReconnecting derivation logic and visibilitychange handler. + * + * The actual derivation lives in PersistentSessionProvider as: + * const isReconnecting = hasConnected && !isWebSocketConnected; + * + * These tests verify the derivation truth table and the visibilitychange + * debounce behavior as isolated logic (no full provider rendering needed). + */ + +describe('isReconnecting derivation', () => { + // Pure logic extracted from the provider — tests the truth table + function deriveIsReconnecting(hasConnected: boolean, isWebSocketConnected: boolean): boolean { + return hasConnected && !isWebSocketConnected; + } + + it('is false during initial connection (hasConnected=false)', () => { + // Before joinSession succeeds, hasConnected is false + expect(deriveIsReconnecting(false, false)).toBe(false); + }); + + it('is false when first connected (hasConnected=true, ws=true)', () => { + // After successful initial connection + expect(deriveIsReconnecting(true, true)).toBe(false); + }); + + it('is true when socket drops after established connection', () => { + // hasConnected=true (we were connected), isWebSocketConnected=false (socket dropped) + expect(deriveIsReconnecting(true, false)).toBe(true); + }); + + it('is false when socket reconnects and resync completes', () => { + // Both true — connection is healthy again + expect(deriveIsReconnecting(true, true)).toBe(false); + }); + + it('is false during initial auth loading (neither connected)', () => { + expect(deriveIsReconnecting(false, true)).toBe(false); + }); +}); + +describe('reconnect lock timing', () => { + /** + * Simulates the state transitions during reconnection to verify + * that isReconnecting stays true until handleReconnect completes. + * + * The key invariant: on.connected fires onConnectionStateChange(true, isReconnect=true) + * but the persistent session SKIPS setting isWebSocketConnected=true. Only + * handleReconnect's finally block sets it after resync. + */ + + it('keeps isReconnecting=true during reconnection resync', () => { + let isWebSocketConnected = true; + let hasConnected = true; + const derive = () => hasConnected && !isWebSocketConnected; + + // Initial state: connected + expect(derive()).toBe(false); + + // Socket drops: on.closed fires + isWebSocketConnected = false; + expect(derive()).toBe(true); + + // Socket reconnects: on.connected fires with isReconnect=true + // The callback SKIPS setting isWebSocketConnected=true (deferred) + // isWebSocketConnected stays false during resync + expect(derive()).toBe(true); + + // handleReconnect completes resync: finally block sets isWebSocketConnected=true + isWebSocketConnected = true; + expect(derive()).toBe(false); + }); + + it('sets isWebSocketConnected=true on initial connect (not deferred)', () => { + let isWebSocketConnected = false; + let hasConnected = false; + const derive = () => hasConnected && !isWebSocketConnected; + + // on.connected fires with isReconnect=false — sets immediately + isWebSocketConnected = true; + expect(derive()).toBe(false); + + // joinSession succeeds + hasConnected = true; + expect(derive()).toBe(false); + }); +}); + +describe('visibilitychange handler', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('debounces resync trigger with 300ms delay', () => { + const triggerResync = vi.fn(); + let debounceTimer: ReturnType | null = null; + + // Simulate the handler logic from persistent-session-context.tsx + const handleVisibilityChange = (visibilityState: string) => { + if (visibilityState === 'visible') { + if (debounceTimer) clearTimeout(debounceTimer); + debounceTimer = setTimeout(() => { + triggerResync(); + }, 300); + } + }; + + handleVisibilityChange('visible'); + + // Before debounce expires + expect(triggerResync).not.toHaveBeenCalled(); + + // After 300ms + vi.advanceTimersByTime(300); + expect(triggerResync).toHaveBeenCalledTimes(1); + }); + + it('cancels previous debounce on rapid visibility changes', () => { + const triggerResync = vi.fn(); + let debounceTimer: ReturnType | null = null; + + const handleVisibilityChange = (visibilityState: string) => { + if (visibilityState === 'visible') { + if (debounceTimer) clearTimeout(debounceTimer); + debounceTimer = setTimeout(() => { + triggerResync(); + }, 300); + } + }; + + // Rapid background/foreground cycling + handleVisibilityChange('visible'); + vi.advanceTimersByTime(100); + handleVisibilityChange('visible'); + vi.advanceTimersByTime(100); + handleVisibilityChange('visible'); + + // Only 200ms since last call — no trigger yet + vi.advanceTimersByTime(200); + expect(triggerResync).not.toHaveBeenCalled(); + + // 300ms since last call — triggers once + vi.advanceTimersByTime(100); + expect(triggerResync).toHaveBeenCalledTimes(1); + }); + + it('does not trigger on hidden visibility state', () => { + const triggerResync = vi.fn(); + let debounceTimer: ReturnType | null = null; + + const handleVisibilityChange = (visibilityState: string) => { + if (visibilityState === 'visible') { + if (debounceTimer) clearTimeout(debounceTimer); + debounceTimer = setTimeout(() => { + triggerResync(); + }, 300); + } + }; + + handleVisibilityChange('hidden'); + vi.advanceTimersByTime(500); + + expect(triggerResync).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/web/app/components/persistent-session/board-session-bridge.tsx b/packages/web/app/components/persistent-session/board-session-bridge.tsx index 76174457..9adbc242 100644 --- a/packages/web/app/components/persistent-session/board-session-bridge.tsx +++ b/packages/web/app/components/persistent-session/board-session-bridge.tsx @@ -38,6 +38,40 @@ const BoardSessionBridge: React.FC = ({ boardDetailsRef.current = boardDetails; parsedParamsRef.current = parsedParams; + // Track session IDs that failed to connect (exhausted retries or definitive error). + // Prevents infinite reactivation loop: when PersistentSessionContext clears activeSession + // after exhausting retries, the bridge would otherwise see the URL still has ?session=xxx + // and call activateSession() again, restarting the entire failing cycle. + const failedSessionIdsRef = React.useRef>(new Set()); + const prevSessionIdFromUrlRef = React.useRef(null); + const hadActiveSessionRef = React.useRef(false); + + // Clear failed tracking when URL session param changes (user navigated to a different session) + useEffect(() => { + if (sessionIdFromUrl !== prevSessionIdFromUrlRef.current) { + if (prevSessionIdFromUrlRef.current) { + failedSessionIdsRef.current.delete(prevSessionIdFromUrlRef.current); + } + prevSessionIdFromUrlRef.current = sessionIdFromUrl; + } + }, [sessionIdFromUrl]); + + // Track when we've had an active session + useEffect(() => { + if (activeSession) { + hadActiveSessionRef.current = true; + } + }, [activeSession]); + + // Detect when activeSession is cleared while URL still has session param — mark as failed. + // Only triggers after activeSession was previously set (not on initial mount when it starts null). + useEffect(() => { + if (!activeSession && sessionIdFromUrl && hadActiveSessionRef.current) { + failedSessionIdsRef.current.add(sessionIdFromUrl); + hadActiveSessionRef.current = false; + } + }, [activeSession, sessionIdFromUrl]); + // Activate or update session when we have a session param and board details // This effect handles: // 1. Initial session activation when joining via shared link @@ -45,6 +79,11 @@ const BoardSessionBridge: React.FC = ({ // Note: Navigation within the same board (e.g., swiping between climbs) should NOT trigger reconnection useEffect(() => { if (sessionIdFromUrl && boardDetailsRef.current) { + // Don't reactivate a session that just failed — prevents infinite retry loop + if (failedSessionIdsRef.current.has(sessionIdFromUrl)) { + return; + } + // Activate session when URL has session param and either: // - Session ID changed // - Board configuration path changed (e.g., navigating to different board/layout/size/sets) diff --git a/packages/web/app/components/persistent-session/persistent-session-context.tsx b/packages/web/app/components/persistent-session/persistent-session-context.tsx index 4381a8d5..d6ada7ad 100644 --- a/packages/web/app/components/persistent-session/persistent-session-context.tsx +++ b/packages/web/app/components/persistent-session/persistent-session-context.tsx @@ -212,6 +212,9 @@ export interface PersistentSessionContextType { // Trigger a resync with the server (useful when corrupted data is detected) triggerResync: () => void; + // True when we had a working connection but the WebSocket dropped + isReconnecting: boolean; + // Session ending with summary (elevated from GraphQLQueueProvider) endSessionWithSummary: () => void; liveSessionStats: SessionLiveStats | null; @@ -252,6 +255,7 @@ export const PersistentSessionProvider: React.FC<{ children: React.ReactNode }> const [session, setSession] = useState(null); const [isConnecting, setIsConnecting] = useState(false); const [hasConnected, setHasConnected] = useState(false); + const [isWebSocketConnected, setIsWebSocketConnected] = useState(false); const [error, setError] = useState(null); // Queue state synced from backend @@ -756,6 +760,12 @@ export const PersistentSessionProvider: React.FC<{ children: React.ReactNode }> if (DEBUG) console.log('[PersistentSession] Reconnection complete, clientId:', sessionData.clientId); } finally { isReconnectingRef.current = false; + // Mark WebSocket as connected now that resync is complete (or failed). + // This was deferred from the on.connected callback to keep isReconnecting=true + // until state is fully recovered, preventing mutations on stale state. + if (mountedRef.current && connectionGenerationRef.current === connectionGeneration) { + setIsWebSocketConnected(true); + } } } @@ -793,6 +803,17 @@ export const PersistentSessionProvider: React.FC<{ children: React.ReactNode }> // Use ref for auth token - it's set once auth loading completes authToken: wsAuthTokenRef.current, onReconnect: handleReconnect, + onConnectionStateChange: (connected, isReconnect) => { + if (mountedRef.current && connectionGenerationRef.current === connectionGeneration) { + if (connected && isReconnect) { + // On reconnection, don't set isWebSocketConnected=true yet. + // handleReconnect will set it after the async resync completes, + // keeping isReconnecting=true until state is fully recovered. + return; + } + setIsWebSocketConnected(connected); + } + }, }); if (!mountedRef.current) { @@ -971,6 +992,7 @@ export const PersistentSessionProvider: React.FC<{ children: React.ReactNode }> setClient(null); setSession(null); setHasConnected(false); + setIsWebSocketConnected(false); setIsConnecting(false); if (retryConnectTimeout) { clearTimeout(retryConnectTimeout); @@ -979,6 +1001,32 @@ export const PersistentSessionProvider: React.FC<{ children: React.ReactNode }> // Note: username, avatarUrl, wsAuthToken are accessed via refs to prevent reconnection on changes }, [activeSession, isAuthLoading, handleQueueEvent, handleSessionEvent]); + // Proactive reconnection detection on iOS foreground return + // When the browser comes back from background, trigger a resync to detect dead sockets + useEffect(() => { + if (!activeSession || !hasConnected) return; + + let debounceTimer: ReturnType | null = null; + + const handleVisibilityChange = () => { + if (document.visibilityState === 'visible') { + if (debounceTimer) clearTimeout(debounceTimer); + debounceTimer = setTimeout(() => { + if (triggerResyncRef.current) { + if (DEBUG) console.log('[PersistentSession] Page became visible, triggering resync'); + triggerResyncRef.current(); + } + }, 300); + } + }; + + document.addEventListener('visibilitychange', handleVisibilityChange); + return () => { + document.removeEventListener('visibilitychange', handleVisibilityChange); + if (debounceTimer) clearTimeout(debounceTimer); + }; + }, [activeSession, hasConnected]); + // Periodic state hash verification // Runs every 60 seconds to detect state drift and auto-resync if needed useEffect(() => { @@ -1272,6 +1320,9 @@ export const PersistentSessionProvider: React.FC<{ children: React.ReactNode }> } }, [activeSession, deactivateSession]); + // Derived: true when we previously had a working connection but the WebSocket dropped + const isReconnecting = hasConnected && !isWebSocketConnected; + const value = useMemo( () => ({ activeSession, @@ -1303,6 +1354,7 @@ export const PersistentSessionProvider: React.FC<{ children: React.ReactNode }> subscribeToQueueEvents, subscribeToSessionEvents, triggerResync, + isReconnecting, endSessionWithSummary, liveSessionStats, sessionSummary, @@ -1335,6 +1387,7 @@ export const PersistentSessionProvider: React.FC<{ children: React.ReactNode }> subscribeToQueueEvents, subscribeToSessionEvents, triggerResync, + isReconnecting, endSessionWithSummary, liveSessionStats, sessionSummary, diff --git a/packages/web/app/components/queue-control/queue-bridge-context.tsx b/packages/web/app/components/queue-control/queue-bridge-context.tsx index b300bc23..db56c177 100644 --- a/packages/web/app/components/queue-control/queue-bridge-context.tsx +++ b/packages/web/app/components/queue-control/queue-bridge-context.tsx @@ -227,6 +227,7 @@ function usePersistentSessionQueueAdapter(): { isLeader: ps.isLeader, isBackendMode: true, hasConnected: ps.hasConnected, + isReconnecting: isParty ? ps.isReconnecting : false, connectionError: ps.error, disconnect: ps.deactivateSession, @@ -248,6 +249,7 @@ function usePersistentSessionQueueAdapter(): { parsedParams, isParty, ps.hasConnected, + ps.isReconnecting, ps.activeSession?.sessionId, ps.deactivateSession, ps.session?.goal, diff --git a/packages/web/app/components/queue-control/queue-control-bar.module.css b/packages/web/app/components/queue-control/queue-control-bar.module.css index 4661eefa..4fa9d5d3 100644 --- a/packages/web/app/components/queue-control/queue-control-bar.module.css +++ b/packages/web/app/components/queue-control/queue-control-bar.module.css @@ -159,3 +159,4 @@ .thumbnailEnter { animation: thumbnailFadeIn 120ms ease-out; } + diff --git a/packages/web/app/components/queue-control/queue-control-bar.tsx b/packages/web/app/components/queue-control/queue-control-bar.tsx index e20cf1c4..3705d456 100644 --- a/packages/web/app/components/queue-control/queue-control-bar.tsx +++ b/packages/web/app/components/queue-control/queue-control-bar.tsx @@ -6,6 +6,9 @@ import MuiCard from '@mui/material/Card'; import CardContent from '@mui/material/CardContent'; import Box from '@mui/material/Box'; import Stack from '@mui/material/Stack'; +import Snackbar from '@mui/material/Snackbar'; +import Alert from '@mui/material/Alert'; +import CircularProgress from '@mui/material/CircularProgress'; import SwipeableDrawer from '../swipeable-drawer/swipeable-drawer'; import SyncOutlined from '@mui/icons-material/SyncOutlined'; import DeleteOutlined from '@mui/icons-material/DeleteOutlined'; @@ -88,7 +91,7 @@ const QueueControlBar: React.FC = ({ boardDetails, angle } const isViewPage = pathname.includes('/view/'); const isListPage = pathname.includes('/list'); const isPlayPage = pathname.includes('/play/'); - const { currentClimb, mirrorClimb, queue, setQueue, getNextClimbQueueItem, getPreviousClimbQueueItem, setCurrentClimbQueueItem, viewOnlyMode } = useQueueContext(); + const { currentClimb, mirrorClimb, queue, setQueue, getNextClimbQueueItem, getPreviousClimbQueueItem, setCurrentClimbQueueItem, viewOnlyMode, isReconnecting } = useQueueContext(); const { mode } = useColorMode(); const isDark = mode === 'dark'; @@ -433,6 +436,20 @@ const QueueControlBar: React.FC = ({ boardDetails, angle } boardDetails={boardDetails} angle={angle} /> + + {/* Non-blocking notification while WebSocket reconnects */} + + } + > + Reconnecting... + + ); }; diff --git a/packages/web/app/components/queue-control/types.ts b/packages/web/app/components/queue-control/types.ts index bb417377..671a6f68 100644 --- a/packages/web/app/components/queue-control/types.ts +++ b/packages/web/app/components/queue-control/types.ts @@ -78,6 +78,7 @@ export interface QueueContextType { isLeader?: boolean; isBackendMode?: boolean; hasConnected?: boolean; + isReconnecting?: boolean; connectionError?: Error | null; disconnect?: () => void; addToQueue: (climb: Climb) => void;