From 348cb9d6377e39bf67e76b9e79adc7f9769e6e3a Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 3 Dec 2025 02:45:16 +0000 Subject: [PATCH] Fix loading state bugs and server crash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend fixes: - Fix server crash when logging large session_state messages (186 agents) - Use safe logging that extracts only message/stack properties - Prevents TypeError in util.inspect on circular refs or deep objects Frontend loading state fixes: - Add reactive WebSocket update to prevent stale session.ws references - Add timeout/retry wrapper for session_state loading (3 retries × 15s) - Add hasEverReceivedSessionState flag to track cached data across reconnects - Implement data-aware loading (isInitialLoad vs isRevalidating) - Show reconnecting badge instead of hiding agents on reconnect Debug improvements: - Add detailed connection state logging in useAggregatedAgents - Add connection status logging in AgentInputArea - Track connecting reasons for easier debugging Fixes: - Agent screen no longer shows false "Offline" toast on reconnect - Home screen shows loading immediately (no empty state flash) - Home screen keeps agents visible during reconnect with badge - Stuck loading has timeout protection with graceful degradation - Server no longer crashes when sending session_state with many agents 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- packages/app/src/app/index.tsx | 38 +++++++++- .../app/src/components/agent-input-area.tsx | 7 ++ .../contexts/daemon-connections-context.tsx | 28 +++++-- packages/app/src/contexts/session-context.tsx | 76 +++++++++++++++++-- .../app/src/hooks/use-aggregated-agents.ts | 72 ++++++++++++++++-- packages/app/src/stores/session-store.ts | 32 ++++++++ .../server/src/server/websocket-server.ts | 21 ++++- 7 files changed, 244 insertions(+), 30 deletions(-) diff --git a/packages/app/src/app/index.tsx b/packages/app/src/app/index.tsx index 7c022f155..dd643fcfd 100644 --- a/packages/app/src/app/index.tsx +++ b/packages/app/src/app/index.tsx @@ -1,4 +1,4 @@ -import { View, ActivityIndicator } from "react-native"; +import { View, ActivityIndicator, Text } from "react-native"; import { useState, useCallback, useMemo, useRef, useEffect } from "react"; import { useFocusEffect } from "@react-navigation/native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; @@ -16,7 +16,11 @@ import { endNavigationTiming, HOME_NAVIGATION_KEY } from "@/utils/navigation-tim export default function HomeScreen() { const insets = useSafeAreaInsets(); const { theme } = useUnistyles(); - const { agents: aggregatedAgents, isLoading } = useAggregatedAgents(); + const { + agents: aggregatedAgents, + isInitialLoad, + isRevalidating + } = useAggregatedAgents(); const [showCreateModal, setShowCreateModal] = useState(false); const [showImportModal, setShowImportModal] = useState(false); const [createModalMounted, setCreateModalMounted] = useState(false); @@ -114,12 +118,21 @@ export default function HomeScreen() { {/* Content Area with Keyboard Animation */} - {isLoading ? ( + {isInitialLoad ? ( ) : hasAgents ? ( - + + + + {isRevalidating && ( + + + Reconnecting... + + )} + ) : ( ({ alignItems: "center", justifyContent: "center", }, + revalidatingBadge: { + position: "absolute", + top: 8, + right: 8, + flexDirection: "row", + alignItems: "center", + gap: 8, + paddingHorizontal: 12, + paddingVertical: 6, + backgroundColor: theme.colors.muted, + borderRadius: 16, + opacity: 0.9, + }, + revalidatingText: { + fontSize: 12, + color: theme.colors.mutedForeground, + }, })); diff --git a/packages/app/src/components/agent-input-area.tsx b/packages/app/src/components/agent-input-area.tsx index d91941be5..2070415cf 100644 --- a/packages/app/src/components/agent-input-area.tsx +++ b/packages/app/src/components/agent-input-area.tsx @@ -272,6 +272,13 @@ export function AgentInputArea({ agentId, serverId }: AgentInputAreaProps) { }, [discardFailedDictation]); const dictationToast = useMemo(() => { + console.log('[AgentInputArea] Connection status:', { + isConnected: connectionStatus.isConnected, + wsIsConnected: ws.isConnected, + wsIsConnecting: ws.isConnecting, + hasSession: !!session, + }); + if (!connectionStatus.isConnected) { return { variant: "warning", diff --git a/packages/app/src/contexts/daemon-connections-context.tsx b/packages/app/src/contexts/daemon-connections-context.tsx index 2db4e693d..08190a599 100644 --- a/packages/app/src/contexts/daemon-connections-context.tsx +++ b/packages/app/src/contexts/daemon-connections-context.tsx @@ -3,11 +3,11 @@ import type { ReactNode } from "react"; import { useDaemonRegistry, type DaemonProfile } from "./daemon-registry-context"; export type ConnectionState = - | { status: "idle"; lastError: null; lastOnlineAt: string | null; sessionReady: false } - | { status: "connecting"; lastError: null; lastOnlineAt: string | null; sessionReady: false } - | { status: "online"; lastError: null; lastOnlineAt: string; sessionReady: boolean } - | { status: "offline"; lastError: string | null; lastOnlineAt: string | null; sessionReady: false } - | { status: "error"; lastError: string; lastOnlineAt: string | null; sessionReady: false }; + | { status: "idle"; lastError: null; lastOnlineAt: string | null; sessionReady: false; hasEverReceivedSessionState: false } + | { status: "connecting"; lastError: null; lastOnlineAt: string | null; sessionReady: false; hasEverReceivedSessionState: boolean } + | { status: "online"; lastError: null; lastOnlineAt: string; sessionReady: boolean; hasEverReceivedSessionState: boolean } + | { status: "offline"; lastError: string | null; lastOnlineAt: string | null; sessionReady: false; hasEverReceivedSessionState: boolean } + | { status: "error"; lastError: string; lastOnlineAt: string | null; sessionReady: false; hasEverReceivedSessionState: boolean }; export type ConnectionStatus = ConnectionState["status"]; @@ -36,6 +36,7 @@ function createDefaultConnectionState(): ConnectionState { lastError: null, lastOnlineAt: null, sessionReady: false, + hasEverReceivedSessionState: false, }; } @@ -45,20 +46,31 @@ function resolveNextConnectionState( ): ConnectionState { switch (update.status) { case "idle": - return { status: "idle", lastError: null, lastOnlineAt: existing.lastOnlineAt, sessionReady: false }; + return { + status: "idle", + lastError: null, + lastOnlineAt: existing.lastOnlineAt, + sessionReady: false, + hasEverReceivedSessionState: false, + }; case "connecting": return { status: "connecting", lastError: null, lastOnlineAt: update.lastOnlineAt ?? existing.lastOnlineAt, sessionReady: false, + hasEverReceivedSessionState: existing.hasEverReceivedSessionState ?? false, }; case "online": + const currentSessionReady = update.sessionReady ?? (existing.status === "online" ? existing.sessionReady : false); + return { status: "online", lastError: null, lastOnlineAt: update.lastOnlineAt, - sessionReady: update.sessionReady ?? (existing.status === "online" ? existing.sessionReady : false), + sessionReady: currentSessionReady, + hasEverReceivedSessionState: + currentSessionReady || existing.hasEverReceivedSessionState || false, }; case "offline": return { @@ -66,6 +78,7 @@ function resolveNextConnectionState( lastError: update.lastError ?? null, lastOnlineAt: update.lastOnlineAt ?? existing.lastOnlineAt, sessionReady: false, + hasEverReceivedSessionState: existing.hasEverReceivedSessionState ?? false, }; case "error": return { @@ -73,6 +86,7 @@ function resolveNextConnectionState( lastError: update.lastError, lastOnlineAt: update.lastOnlineAt ?? existing.lastOnlineAt, sessionReady: false, + hasEverReceivedSessionState: existing.hasEverReceivedSessionState ?? false, }; } } diff --git a/packages/app/src/contexts/session-context.tsx b/packages/app/src/contexts/session-context.tsx index 559a8e551..4e0c25007 100644 --- a/packages/app/src/contexts/session-context.tsx +++ b/packages/app/src/contexts/session-context.tsx @@ -246,6 +246,7 @@ export function SessionProvider({ children, serverUrl, serverId }: SessionProvid const providerModelRequestIdsRef = useRef>(new Map()); const hasHydratedSnapshotRef = useRef(false); const hasRequestedInitialSnapshotRef = useRef(false); + const sessionStateTimeoutRef = useRef | null>(null); // Buffer for streaming audio chunks interface AudioChunk { @@ -261,6 +262,11 @@ export function SessionProvider({ children, serverUrl, serverId }: SessionProvid initializeSession(serverId, ws, audioPlayer); }, [serverId, ws, audioPlayer, initializeSession]); + const updateSessionWebSocket = useSessionStore((state) => state.updateSessionWebSocket); + useEffect(() => { + updateSessionWebSocket(serverId, ws); + }, [serverId, ws, updateSessionWebSocket]); + // Connection status tracking useEffect(() => { if (ws.isConnected) { @@ -496,14 +502,62 @@ export function SessionProvider({ children, serverUrl, serverId }: SessionProvid return; } hasRequestedInitialSnapshotRef.current = true; - ws.send({ - type: "session", - message: { - type: "load_conversation_request", - conversationId: ws.conversationId ?? "", - }, - }); - }, [wsIsConnected, ws]); + + const MAX_RETRIES = 3; + const TIMEOUT_MS = 15000; + const RETRY_DELAY_MS = 2000; + + let retryCount = 0; + + const requestSessionState = () => { + console.log(`[Session] Requesting session_state (attempt ${retryCount + 1}/${MAX_RETRIES + 1})`, { serverId }); + + ws.send({ + type: "session", + message: { + type: "load_conversation_request", + conversationId: ws.conversationId ?? "", + }, + }); + + if (sessionStateTimeoutRef.current) { + clearTimeout(sessionStateTimeoutRef.current); + } + + sessionStateTimeoutRef.current = setTimeout(() => { + if (retryCount < MAX_RETRIES) { + retryCount++; + console.warn(`[Session] session_state timeout, retrying in ${RETRY_DELAY_MS}ms`, { + serverId, + attempt: retryCount, + maxRetries: MAX_RETRIES, + }); + + setTimeout(() => { + requestSessionState(); + }, RETRY_DELAY_MS); + } else { + console.error(`[Session] session_state failed after ${MAX_RETRIES} retries`, { serverId }); + + setHasHydratedAgents(serverId, true); + updateConnectionStatus(serverId, { + status: "online", + lastOnlineAt: new Date().toISOString(), + sessionReady: true + }); + } + }, TIMEOUT_MS); + }; + + requestSessionState(); + + return () => { + if (sessionStateTimeoutRef.current) { + clearTimeout(sessionStateTimeoutRef.current); + sessionStateTimeoutRef.current = null; + } + }; + }, [wsIsConnected, ws, serverId, setHasHydratedAgents, updateConnectionStatus]); // WebSocket message handlers - directly update Zustand store useEffect(() => { @@ -511,6 +565,12 @@ export function SessionProvider({ children, serverUrl, serverId }: SessionProvid const unsubSessionState = ws.on("session_state", (message) => { if (message.type !== "session_state") return; + + if (sessionStateTimeoutRef.current) { + clearTimeout(sessionStateTimeoutRef.current); + sessionStateTimeoutRef.current = null; + } + const { agents: agentsList, commands: commandsList } = message.payload; console.log("[Session] ✅ Received session_state:", agentsList.length, "agents,", commandsList.length, "commands"); diff --git a/packages/app/src/hooks/use-aggregated-agents.ts b/packages/app/src/hooks/use-aggregated-agents.ts index 10fdc7f55..8ccbd2c5b 100644 --- a/packages/app/src/hooks/use-aggregated-agents.ts +++ b/packages/app/src/hooks/use-aggregated-agents.ts @@ -11,6 +11,8 @@ export interface AggregatedAgent extends AgentDirectoryEntry { export interface AggregatedAgentsResult { agents: AggregatedAgent[]; isLoading: boolean; + isInitialLoad: boolean; + isRevalidating: boolean; } export function useAggregatedAgents(): AggregatedAgentsResult { @@ -55,17 +57,71 @@ export function useAggregatedAgents(): AggregatedAgentsResult { return rightTime - leftTime; }); - const isLoading = Array.from(connectionStates.values()).some(c => { - // Only these states count as "loading": - // 1. Actively connecting - // 2. Online but session not yet received - if (c.status === 'connecting') return true; - if (c.status === 'online' && !c.sessionReady) return true; + // Check if we have any cached data + const hasAnyData = allAgents.length > 0; + + // Check if any connection is currently loading + const connectingReasons: string[] = []; + const isConnecting = Array.from(connectionStates.entries()).some(([id, c]) => { + const shortId = id.substring(0, 20); + + // First-time connection (never received session state) + if (c.status === 'connecting' && !c.hasEverReceivedSessionState) { + connectingReasons.push(`${shortId}: first-time connecting`); + return true; + } + if (c.status === 'online' && !c.hasEverReceivedSessionState) { + connectingReasons.push(`${shortId}: online but no session_state yet`); + return true; + } + + // Reconnecting (have received session state before) + if (c.status === 'connecting' && c.hasEverReceivedSessionState) { + connectingReasons.push(`${shortId}: reconnecting`); + return true; + } + if (c.status === 'online' && !c.sessionReady && c.hasEverReceivedSessionState) { + connectingReasons.push(`${shortId}: online but sessionReady=false (waiting for session_state)`); + return true; + } - // Offline/error = not loading, just unavailable (normal state) return false; }); - return { agents: allAgents, isLoading }; + // isInitialLoad: Loading for the first time (no cached data) + const isInitialLoad = isConnecting && !hasAnyData; + + // isRevalidating: Loading but we have cached data (reconnecting) + const isRevalidating = isConnecting && hasAnyData; + + // isLoading: Generic loading flag (either initial or revalidating) + const isLoading = isConnecting; + + const connectionStatesArray = Array.from(connectionStates.entries()).map(([id, state]) => ({ + id: id.substring(0, 20) + (id.length > 20 ? '...' : ''), + status: state.status, + sessionReady: state.sessionReady, + hasEverReceivedSessionState: state.hasEverReceivedSessionState, + })); + + console.log('[useAggregatedAgents] States:', { + hasAnyData, + isConnecting, + isInitialLoad, + isRevalidating, + totalConnectionStates: connectionStates.size, + connectingReasons: connectingReasons.length > 0 ? connectingReasons : 'none', + }); + + console.log('[useAggregatedAgents] Connection States Detail:', + JSON.stringify(connectionStatesArray, null, 2) + ); + + return { + agents: allAgents, + isLoading, + isInitialLoad, + isRevalidating, + }; }, [sessions, connectionStates]); } diff --git a/packages/app/src/stores/session-store.ts b/packages/app/src/stores/session-store.ts index 0183fe303..48f75af5c 100644 --- a/packages/app/src/stores/session-store.ts +++ b/packages/app/src/stores/session-store.ts @@ -247,6 +247,7 @@ interface SessionStoreActions { initializeSession: (serverId: string, ws: UseWebSocketReturn, audioPlayer: ReturnType) => void; clearSession: (serverId: string) => void; getSession: (serverId: string) => SessionState | undefined; + updateSessionWebSocket: (serverId: string, ws: UseWebSocketReturn) => void; // Audio state setIsPlayingAudio: (serverId: string, playing: boolean) => void; @@ -383,6 +384,37 @@ export const useSessionStore = create()( }); }, + updateSessionWebSocket: (serverId, ws) => { + set((prev) => { + const session = prev.sessions[serverId]; + + if (!session) { + return prev; + } + + if (session.ws === ws) { + return prev; + } + + logSessionStoreUpdate("updateSessionWebSocket", serverId, { + wasNull: session.ws === null, + isNowConnected: ws.isConnected, + isNowConnecting: ws.isConnecting, + }); + + return { + ...prev, + sessions: { + ...prev.sessions, + [serverId]: { + ...session, + ws, + }, + }, + }; + }); + }, + getSession: (serverId) => { return get().sessions[serverId]; }, diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index 00c1172ca..f099f9faa 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -135,7 +135,10 @@ export class VoiceAssistantWebSocketServer { // Set up error handler ws.on("error", async (error) => { - console.error(`[WS] Client error:`, error); + // Safe error logging + const errorMessage = error instanceof Error ? error.message : "Unknown error"; + const errorStack = error instanceof Error ? error.stack : undefined; + console.error(`[WS] Client error:`, { message: errorMessage, stack: errorStack }); const session = this.sessions.get(ws); if (!session) return; @@ -162,7 +165,14 @@ export class VoiceAssistantWebSocketServer { // Validate with Zod const message = WSInboundMessageSchema.parse(parsed); - console.log(`[WS] Received message type: ${message.type}`, message); + // Safe logging that handles large objects without crashing + const messageSummary = { + type: message.type, + ...(message.type === "session" && message.message ? { + sessionMessageType: message.message.type, + } : {}), + }; + console.log(`[WS] Received message:`, messageSummary); // Handle WebSocket-level messages switch (message.type) { @@ -198,8 +208,13 @@ export class VoiceAssistantWebSocketServer { return; } } catch (error) { - console.error("[WS] Failed to parse/handle message:", error); + // Safe error logging - avoid crashing on large/circular objects const errorMessage = error instanceof Error ? error.message : "Unknown error"; + const errorStack = error instanceof Error ? error.stack : undefined; + console.error("[WS] Failed to parse/handle message:", { + message: errorMessage, + stack: errorStack, + }); // Send error to client this.sendToClient( ws,