From 764005da735805ed35b2350a733e345c06058133 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Mon, 12 Oct 2026 08:30:21 +1300 Subject: [PATCH 1/2] fix(react): clear agent-connect timeout when a session ends early useAgentTimeoutIdStore kept the timer id in React state, so the cleanup in useAgent called a clearAgentTimeout captured in an earlier render where the id was still null. The timer was never cleared, and if the session ended within the timeout window it could fire later and fail a newly started session. Store the timer id in a ref so start/clear always see the current timer. This also makes both callbacks stable. Fixes #1468 Co-Authored-By: Claude Opus 5.5 (1M context) --- .changeset/stale-agent-timeout.md | 5 ++ packages/react/src/hooks/useAgent.test.ts | 80 +++++++++++++++++++++++ packages/react/src/hooks/useAgent.ts | 37 +++++------ 3 files changed, 102 insertions(+), 20 deletions(-) create mode 100644 .changeset/stale-agent-timeout.md create mode 100644 packages/react/src/hooks/useAgent.test.ts 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..85cfd309b --- /dev/null +++ b/packages/react/src/hooks/useAgent.test.ts @@ -0,0 +1,80 @@ +import { act, cleanup, renderHook } from '@testing-library/react'; +import * as React from 'react'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { useAgentTimeoutIdStore } from './useAgent'; + +const TIMEOUT_MS = 20_000; + +/** Mirrors how useAgent drives the agent timeout store from the session connection state. */ +function useAgentTimeoutHarness(isSessionDisconnected: boolean) { + const store = useAgentTimeoutIdStore(); + const { startAgentTimeout, clearAgentTimeout } = store; + + React.useEffect(() => { + if (isSessionDisconnected) { + return; + } + + startAgentTimeout(TIMEOUT_MS); + return () => { + clearAgentTimeout(); + }; + // Intentionally matches useAgent's dependency list, which omits the store callbacks. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isSessionDisconnected]); + + return store; +} + +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, rerender } = renderHook( + ({ disconnected }) => useAgentTimeoutHarness(disconnected), + { initialProps: { disconnected: false } }, + ); + + // End the first session after 5s, then start a new one at 6s. + act(() => vi.advanceTimersByTime(5_000)); + rerender({ disconnected: true }); + act(() => vi.advanceTimersByTime(1_000)); + rerender({ disconnected: false }); + + // At 20s the first session's timer would have fired. + act(() => vi.advanceTimersByTime(14_000 + 1)); + expect(result.current.agentTimeoutFailureReason).toBeNull(); + + // The second session's own timer should fire at 26s. + act(() => vi.advanceTimersByTime(6_000)); + expect(result.current.agentTimeoutFailureReason).toBe('Agent did not join the room.'); + }); + + test('clearAgentTimeout from an earlier render still clears the active timer', () => { + const { result } = renderHook(() => useAgentTimeoutIdStore()); + const staleClearAgentTimeout = result.current.clearAgentTimeout; + + act(() => result.current.startAgentTimeout(TIMEOUT_MS)); + act(() => staleClearAgentTimeout()); + + act(() => vi.advanceTimersByTime(TIMEOUT_MS + 1)); + expect(result.current.agentTimeoutFailureReason).toBeNull(); + }); + + 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..9030f9165 100644 --- a/packages/react/src/hooks/useAgent.ts +++ b/packages/react/src/hooks/useAgent.ts @@ -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); }, []), From 74e7ea2ace0a137a584185f1df4c151ebdbe1c59 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Mon, 12 Oct 2026 08:55:55 +1300 Subject: [PATCH 2/2] test(react): exercise useAgentTimeoutIdStore directly Drop the effect harness and call the store's callbacks directly, using the exported DEFAULT_AGENT_CONNECT_TIMEOUT_MILLISECONDS for the timeout. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/react/src/hooks/useAgent.test.ts | 59 ++++++----------------- packages/react/src/hooks/useAgent.ts | 2 +- 2 files changed, 15 insertions(+), 46 deletions(-) diff --git a/packages/react/src/hooks/useAgent.test.ts b/packages/react/src/hooks/useAgent.test.ts index 85cfd309b..890a39689 100644 --- a/packages/react/src/hooks/useAgent.test.ts +++ b/packages/react/src/hooks/useAgent.test.ts @@ -1,30 +1,8 @@ import { act, cleanup, renderHook } from '@testing-library/react'; -import * as React from 'react'; import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; -import { useAgentTimeoutIdStore } from './useAgent'; +import { DEFAULT_AGENT_CONNECT_TIMEOUT_MILLISECONDS, useAgentTimeoutIdStore } from './useAgent'; -const TIMEOUT_MS = 20_000; - -/** Mirrors how useAgent drives the agent timeout store from the session connection state. */ -function useAgentTimeoutHarness(isSessionDisconnected: boolean) { - const store = useAgentTimeoutIdStore(); - const { startAgentTimeout, clearAgentTimeout } = store; - - React.useEffect(() => { - if (isSessionDisconnected) { - return; - } - - startAgentTimeout(TIMEOUT_MS); - return () => { - clearAgentTimeout(); - }; - // Intentionally matches useAgent's dependency list, which omits the store callbacks. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isSessionDisconnected]); - - return store; -} +const TIMEOUT_MS = DEFAULT_AGENT_CONNECT_TIMEOUT_MILLISECONDS; beforeEach(() => { vi.useFakeTimers(); @@ -37,37 +15,28 @@ afterEach(() => { describe('useAgentTimeoutIdStore', () => { test('ending a session early clears its timeout so it cannot fail the next session', () => { - const { result, rerender } = renderHook( - ({ disconnected }) => useAgentTimeoutHarness(disconnected), - { initialProps: { disconnected: false } }, - ); + 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; - // End the first session after 5s, then start a new one at 6s. + // Start a session, end it after 5s, then start a new one at 6s. + act(() => startAgentTimeout(TIMEOUT_MS)); act(() => vi.advanceTimersByTime(5_000)); - rerender({ disconnected: true }); + act(() => clearAgentTimeout()); act(() => vi.advanceTimersByTime(1_000)); - rerender({ disconnected: false }); + act(() => startAgentTimeout(TIMEOUT_MS)); - // At 20s the first session's timer would have fired. - act(() => vi.advanceTimersByTime(14_000 + 1)); + // 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 at 26s. + // 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('clearAgentTimeout from an earlier render still clears the active timer', () => { - const { result } = renderHook(() => useAgentTimeoutIdStore()); - const staleClearAgentTimeout = result.current.clearAgentTimeout; - - act(() => result.current.startAgentTimeout(TIMEOUT_MS)); - act(() => staleClearAgentTimeout()); - - act(() => vi.advanceTimersByTime(TIMEOUT_MS + 1)); - expect(result.current.agentTimeoutFailureReason).toBeNull(); - }); - test('start and clear callbacks keep the same identity across renders', () => { const { result } = renderHook(() => useAgentTimeoutIdStore()); const { startAgentTimeout, clearAgentTimeout } = result.current; diff --git a/packages/react/src/hooks/useAgent.ts b/packages/react/src/hooks/useAgent.ts index 9030f9165..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';