Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/stale-agent-timeout.md
Original file line number Diff line number Diff line change
@@ -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.
49 changes: 49 additions & 0 deletions packages/react/src/hooks/useAgent.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
39 changes: 18 additions & 21 deletions packages/react/src/hooks/useAgent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -315,9 +315,9 @@ export const useAgentTimeoutIdStore = (): {
const [agentTimeoutFailureReason, setAgentTimeoutFailureReason] = React.useState<string | null>(
null,
);
const [agentTimeoutId, setAgentTimeoutId] = React.useState<ReturnType<typeof setTimeout> | 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<ReturnType<typeof setTimeout> | null>(null);

const agentStateRef = React.useRef<AgentState>('connecting');
const agentParticipantExistsRef = React.useRef(false);
Expand All @@ -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);
}, []),
Expand Down
Loading