Skip to content
Open
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
69 changes: 68 additions & 1 deletion docs/websocket-implementation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

135 changes: 70 additions & 65 deletions packages/backend/src/graphql/resolvers/sessions/mutations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
},

/**
Expand Down
24 changes: 18 additions & 6 deletions packages/backend/src/services/distributed-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -861,12 +861,24 @@ export class DistributedStateManager {
const [err, data] = results[i] as [Error | null, Record<string, string>];
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(() => {});
}
}
}
Expand Down
7 changes: 5 additions & 2 deletions packages/backend/src/websocket/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
4 changes: 2 additions & 2 deletions packages/shared-schema/src/operations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -292,7 +292,7 @@ export const EVENTS_REPLAY = `
}
... on QueueItemAdded {
sequence
addedItem: item {
item {
${QUEUE_ITEM_FIELDS}
}
position
Expand All @@ -309,7 +309,7 @@ export const EVENTS_REPLAY = `
}
... on CurrentClimbChanged {
sequence
currentItem: item {
item {
${QUEUE_ITEM_FIELDS}
}
clientId
Expand Down
6 changes: 5 additions & 1 deletion packages/web/app/components/graphql-queue/QueueContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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(
() => ({
Expand Down Expand Up @@ -649,6 +651,7 @@ export const GraphQLQueueProvider = ({ parsedParams, boardDetails, children, bas
isLeader,
isBackendMode: !!backendUrl,
hasConnected,
isReconnecting,
connectionError,
disconnect: persistentSession.deactivateSession,

Expand Down Expand Up @@ -843,6 +846,7 @@ export const GraphQLQueueProvider = ({ parsedParams, boardDetails, children, bas
isLeader,
users,
hasConnected,
isReconnecting,
connectionError,
backendUrl,
persistentSession,
Expand Down
Loading
Loading