diff --git a/.changeset/stale-agent-timeout.md b/.changeset/stale-agent-timeout.md new file mode 100644 index 000000000..854fb107e --- /dev/null +++ b/.changeset/stale-agent-timeout.md @@ -0,0 +1,5 @@ +--- +'@livekit/components-react': patch +--- + +Fix the agent-connect timeout leaking when a session ends early, which could fail the next session before its agent had a chance to connect. diff --git a/packages/react/src/hooks/useAgent.test.ts b/packages/react/src/hooks/useAgent.test.ts new file mode 100644 index 000000000..890a39689 --- /dev/null +++ b/packages/react/src/hooks/useAgent.test.ts @@ -0,0 +1,49 @@ +import { act, cleanup, renderHook } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { DEFAULT_AGENT_CONNECT_TIMEOUT_MILLISECONDS, useAgentTimeoutIdStore } from './useAgent'; + +const TIMEOUT_MS = DEFAULT_AGENT_CONNECT_TIMEOUT_MILLISECONDS; + +beforeEach(() => { + vi.useFakeTimers(); +}); + +afterEach(() => { + cleanup(); + vi.useRealTimers(); +}); + +describe('useAgentTimeoutIdStore', () => { + test('ending a session early clears its timeout so it cannot fail the next session', () => { + const { result } = renderHook(() => useAgentTimeoutIdStore()); + + // Like useAgent's effect, hold on to the callbacks from the first render rather than reading + // the latest ones off result.current. + const { startAgentTimeout, clearAgentTimeout } = result.current; + + // Start a session, end it after 5s, then start a new one at 6s. + act(() => startAgentTimeout(TIMEOUT_MS)); + act(() => vi.advanceTimersByTime(5_000)); + act(() => clearAgentTimeout()); + act(() => vi.advanceTimersByTime(1_000)); + act(() => startAgentTimeout(TIMEOUT_MS)); + + // When the first session's timer would have fired, nothing should have failed. + act(() => vi.advanceTimersByTime(TIMEOUT_MS - 6_000 + 1)); + expect(result.current.agentTimeoutFailureReason).toBeNull(); + + // The second session's own timer should fire 6s later. + act(() => vi.advanceTimersByTime(6_000)); + expect(result.current.agentTimeoutFailureReason).toBe('Agent did not join the room.'); + }); + + test('start and clear callbacks keep the same identity across renders', () => { + const { result } = renderHook(() => useAgentTimeoutIdStore()); + const { startAgentTimeout, clearAgentTimeout } = result.current; + + act(() => result.current.startAgentTimeout(TIMEOUT_MS)); + + expect(result.current.startAgentTimeout).toBe(startAgentTimeout); + expect(result.current.clearAgentTimeout).toBe(clearAgentTimeout); + }); +}); diff --git a/packages/react/src/hooks/useAgent.ts b/packages/react/src/hooks/useAgent.ts index 903a7b7d1..6b15f9b61 100644 --- a/packages/react/src/hooks/useAgent.ts +++ b/packages/react/src/hooks/useAgent.ts @@ -20,7 +20,7 @@ import { useRemoteParticipants } from './useRemoteParticipants'; import { UseSessionReturn } from './useSession'; // FIXME: make this 10 seconds once room dispatch booting info is discoverable -const DEFAULT_AGENT_CONNECT_TIMEOUT_MILLISECONDS = 20_000; +export const DEFAULT_AGENT_CONNECT_TIMEOUT_MILLISECONDS = 20_000; /** @see https://github.com/livekit/agents/blob/65170238db197f62f479eb7aaef1c0e18bfad6e7/livekit-agents/livekit/agents/voice/events.py#L97 */ type AgentSdkStates = 'initializing' | 'idle' | 'listening' | 'thinking' | 'speaking'; @@ -315,9 +315,9 @@ export const useAgentTimeoutIdStore = (): { const [agentTimeoutFailureReason, setAgentTimeoutFailureReason] = React.useState( null, ); - const [agentTimeoutId, setAgentTimeoutId] = React.useState | null>( - null, - ); + // Stored in a ref (not state) so that start / clear always see the current timer, even when + // called through a callback reference captured in an earlier render. + const agentTimeoutIdRef = React.useRef | null>(null); const agentStateRef = React.useRef('connecting'); const agentParticipantExistsRef = React.useRef(false); @@ -339,29 +339,26 @@ export const useAgentTimeoutIdStore = (): { return { agentTimeoutFailureReason, - startAgentTimeout: React.useCallback( - (agentConnectTimeoutMilliseconds?: number) => { - if (agentTimeoutId) { - clearTimeout(agentTimeoutId); - } - - setAgentTimeoutFailureReason(null); - setAgentTimeoutId(startAgentConnectedTimeout(agentConnectTimeoutMilliseconds)); - agentStateRef.current = 'connecting'; - agentParticipantExistsRef.current = false; - }, - [agentTimeoutId], - ), + startAgentTimeout: React.useCallback((agentConnectTimeoutMilliseconds?: number) => { + if (agentTimeoutIdRef.current) { + clearTimeout(agentTimeoutIdRef.current); + } + + setAgentTimeoutFailureReason(null); + agentTimeoutIdRef.current = startAgentConnectedTimeout(agentConnectTimeoutMilliseconds); + agentStateRef.current = 'connecting'; + agentParticipantExistsRef.current = false; + }, []), clearAgentTimeout: React.useCallback(() => { - if (agentTimeoutId) { - clearTimeout(agentTimeoutId); + if (agentTimeoutIdRef.current) { + clearTimeout(agentTimeoutIdRef.current); + agentTimeoutIdRef.current = null; } setAgentTimeoutFailureReason(null); - setAgentTimeoutId(null); agentStateRef.current = 'connecting'; agentParticipantExistsRef.current = false; - }, [agentTimeoutId]), + }, []), clearAgentTimeoutFailureReason: React.useCallback(() => { setAgentTimeoutFailureReason(null); }, []),