diff --git a/app/src/hooks/__tests__/useDaemonLifecycle.test.ts b/app/src/hooks/__tests__/useDaemonLifecycle.test.ts index d41b46e82..a7ba40c55 100644 --- a/app/src/hooks/__tests__/useDaemonLifecycle.test.ts +++ b/app/src/hooks/__tests__/useDaemonLifecycle.test.ts @@ -135,7 +135,84 @@ describe('useDaemonLifecycle', () => { }); }); + describe('retry scheduling', () => { + it('runs a scheduled retry using the latest daemon action', async () => { + const { useDaemonLifecycle } = await import('../useDaemonLifecycle'); + const uid = freshUser('retry'); + resetUser(uid); + setAutoStartEnabled(uid, true); + setDaemonStatus(uid, 'error'); + incrementConnectionAttempts(uid); + mockStartDaemon.mockResolvedValue({ result: { state: 'Running' }, logs: [] }); + + const { result } = renderHook(() => useDaemonLifecycle(uid)); + + expect(result.current.connectionAttempts).toBe(1); + await act(async () => { + await vi.advanceTimersByTimeAsync(2000); + }); + + expect(mockStartDaemon).toHaveBeenCalledTimes(1); + expect(result.current.connectionAttempts).toBe(0); + }); + }); + describe('background / foreground pause-resume', () => { + it('keeps lifecycle setup stable across daemon state updates', async () => { + const { useDaemonLifecycle } = await import('../useDaemonLifecycle'); + const uid = freshUser('stable-effect'); + resetUser(uid); + setAutoStartEnabled(uid, true); + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + const addEventListenerSpy = vi.spyOn(document, 'addEventListener'); + const removeEventListenerSpy = vi.spyOn(document, 'removeEventListener'); + + try { + const { unmount } = renderHook(() => useDaemonLifecycle(uid)); + + const lifecycleLogCount = (message: string) => + logSpy.mock.calls.filter(([logged]) => logged === message).length; + const visibilityAddCount = () => + addEventListenerSpy.mock.calls.filter(([event]) => event === 'visibilitychange').length; + const visibilityRemoveCount = () => + removeEventListenerSpy.mock.calls.filter(([event]) => event === 'visibilitychange') + .length; + + expect(lifecycleLogCount('[DaemonLifecycle] Setting up daemon lifecycle management')).toBe( + 1 + ); + expect(visibilityAddCount()).toBe(1); + + act(() => { + setDaemonStatus(uid, 'starting'); + setIsRecovering(uid, true); + incrementConnectionAttempts(uid); + setDaemonStatus(uid, 'running'); + setIsRecovering(uid, false); + }); + + expect(lifecycleLogCount('[DaemonLifecycle] Setting up daemon lifecycle management')).toBe( + 1 + ); + expect(lifecycleLogCount('[DaemonLifecycle] Cleaning up daemon lifecycle management')).toBe( + 0 + ); + expect(visibilityAddCount()).toBe(1); + expect(visibilityRemoveCount()).toBe(0); + + unmount(); + + expect(lifecycleLogCount('[DaemonLifecycle] Cleaning up daemon lifecycle management')).toBe( + 1 + ); + expect(visibilityRemoveCount()).toBe(1); + } finally { + logSpy.mockRestore(); + addEventListenerSpy.mockRestore(); + removeEventListenerSpy.mockRestore(); + } + }); + it('does not invoke startDaemon while hidden, resumes auto-start on visible', async () => { const { useDaemonLifecycle } = await import('../useDaemonLifecycle'); const uid = freshUser('vis'); diff --git a/app/src/hooks/useDaemonLifecycle.ts b/app/src/hooks/useDaemonLifecycle.ts index 1419dc2e9..eac5ff5df 100644 --- a/app/src/hooks/useDaemonLifecycle.ts +++ b/app/src/hooks/useDaemonLifecycle.ts @@ -25,7 +25,7 @@ const MAX_RETRY_DELAY_MS = 30000; // 30 seconds const AUTO_START_DELAY_MS = 3000; // 3 seconds after app start export const useDaemonLifecycle = (userId?: string) => { - const daemonHealth = useDaemonHealth(userId); + const { startDaemon } = useDaemonHealth(userId); const daemonState = useDaemonUserState(userId); const status = daemonState.status; @@ -38,6 +38,23 @@ export const useDaemonLifecycle = (userId?: string) => { const autoStartTimeoutRef = useRef | null>(null); const retryTimeoutRef = useRef | null>(null); const isMountedRef = useRef(true); + const latestLifecycleRef = useRef({ + isAutoStartEnabled, + status, + isRecovering, + connectionAttempts, + uid, + startDaemon, + }); + + latestLifecycleRef.current = { + isAutoStartEnabled, + status, + isRecovering, + connectionAttempts, + uid, + startDaemon, + }; // Calculate exponential backoff delay const calculateRetryDelay = useCallback((attempt: number): number => { @@ -47,6 +64,9 @@ export const useDaemonLifecycle = (userId?: string) => { // Auto-start daemon if enabled and conditions are met const attemptAutoStart = useCallback(async () => { + const { isAutoStartEnabled, status, isRecovering, connectionAttempts, uid, startDaemon } = + latestLifecycleRef.current; + if (!isTauri() || !isAutoStartEnabled || !isMountedRef.current) { return; } @@ -57,7 +77,7 @@ export const useDaemonLifecycle = (userId?: string) => { try { setIsRecovering(uid, true); - const result = await daemonHealth.startDaemon(); + const result = await startDaemon(); if (result?.result && result.result.state === 'Running') { console.log('[DaemonLifecycle] Auto-start successful'); @@ -73,10 +93,12 @@ export const useDaemonLifecycle = (userId?: string) => { setIsRecovering(uid, false); } } - }, [isAutoStartEnabled, status, isRecovering, connectionAttempts, uid, daemonHealth]); + }, []); // Retry connection with exponential backoff const scheduleRetry = useCallback(() => { + const { connectionAttempts, status, isRecovering } = latestLifecycleRef.current; + if (!isTauri() || !isMountedRef.current || isRecovering) { return; } @@ -105,11 +127,13 @@ export const useDaemonLifecycle = (userId?: string) => { retryTimeoutRef.current = setTimeout(async () => { if (!isMountedRef.current) return; + const { uid, startDaemon } = latestLifecycleRef.current; + try { setIsRecovering(uid, true); incrementConnectionAttempts(uid); - const result = await daemonHealth.startDaemon(); + const result = await startDaemon(); if (result?.result && result.result.state === 'Running') { console.log('[DaemonLifecycle] Retry successful'); @@ -125,12 +149,14 @@ export const useDaemonLifecycle = (userId?: string) => { setIsRecovering(uid, false); } }, retryDelay); - }, [connectionAttempts, status, isRecovering, calculateRetryDelay, uid, daemonHealth]); + }, [calculateRetryDelay]); // Handle visibility change (background/foreground) const handleVisibilityChange = useCallback(() => { if (!isTauri() || !isMountedRef.current) return; + const { isAutoStartEnabled, status, isRecovering } = latestLifecycleRef.current; + if (document.visibilityState === 'visible') { console.log('[DaemonLifecycle] App became visible - checking daemon status'); @@ -144,12 +170,13 @@ export const useDaemonLifecycle = (userId?: string) => { }, 1000); } } - }, [isAutoStartEnabled, status, isRecovering, attemptAutoStart]); + }, [attemptAutoStart]); // Main lifecycle effect useEffect(() => { if (!isTauri()) return; + isMountedRef.current = true; console.log('[DaemonLifecycle] Setting up daemon lifecycle management'); // Setup auto-start with delay on mount @@ -181,7 +208,7 @@ export const useDaemonLifecycle = (userId?: string) => { // Remove event listeners document.removeEventListener('visibilitychange', handleVisibilityChange); }; - }, [isAutoStartEnabled, attemptAutoStart, handleVisibilityChange]); + }, [isAutoStartEnabled, uid, attemptAutoStart, handleVisibilityChange]); // Retry effect - triggers when daemon goes into error state or connection fails useEffect(() => {