Skip to content

Commit 60bd948

Browse files
fix: prevent mainTask hang when speech handle is interrupted after authorization (#1126)
Co-authored-by: Brian Yin <brian.yin@livekit.io>
1 parent 3305fad commit 60bd948

3 files changed

Lines changed: 200 additions & 1 deletion

File tree

.changeset/shiny-tigers-relate.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@livekit/agents": patch
3+
---
4+
5+
Prevent mainTask hang when speech handle is interrupted after authorization
Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
// SPDX-FileCopyrightText: 2025 LiveKit, Inc.
2+
//
3+
// SPDX-License-Identifier: Apache-2.0
4+
5+
/**
6+
* Regression tests for mainTask speech handle processing.
7+
*
8+
* When a speech handle is interrupted after _authorizeGeneration() but before the
9+
* reply task calls _markGenerationDone(), mainTask hangs on _waitForGeneration()
10+
* indefinitely. All subsequent speech handles queue behind it and the agent becomes
11+
* unresponsive.
12+
*
13+
* Fix: race _waitForGeneration() against the interrupt future via waitIfNotInterrupted().
14+
*
15+
* Related: #1124, #1089, #836
16+
*/
17+
import { Heap } from 'heap-js';
18+
import { describe, expect, it, vi } from 'vitest';
19+
import { Future } from '../utils.js';
20+
import { AgentActivity } from './agent_activity.js';
21+
import { SpeechHandle } from './speech_handle.js';
22+
23+
// Break circular dependency: agent_activity.ts → agent.js → beta/workflows/task_group.ts
24+
vi.mock('./agent.js', () => {
25+
class Agent {}
26+
class AgentTask extends Agent {}
27+
class StopResponse {}
28+
return {
29+
Agent,
30+
AgentTask,
31+
StopResponse,
32+
_getActivityTaskInfo: () => null,
33+
_setActivityTaskInfo: () => {},
34+
functionCallStorage: {
35+
getStore: () => undefined,
36+
enterWith: () => {},
37+
run: (_: unknown, fn: () => unknown) => fn(),
38+
},
39+
speechHandleStorage: {
40+
getStore: () => undefined,
41+
enterWith: () => {},
42+
},
43+
};
44+
});
45+
46+
vi.mock('../version.js', () => ({ version: '0.0.0-test' }));
47+
48+
async function raceTimeout(promise: Promise<unknown>, ms: number): Promise<'resolved' | 'timeout'> {
49+
let timer: ReturnType<typeof setTimeout>;
50+
const timeout = new Promise<'timeout'>((resolve) => {
51+
timer = setTimeout(() => resolve('timeout'), ms);
52+
});
53+
return Promise.race([promise.then(() => 'resolved' as const), timeout]).finally(() =>
54+
clearTimeout(timer),
55+
);
56+
}
57+
58+
/**
59+
* Build a minimal stand-in with just enough state for mainTask to run.
60+
*
61+
* mainTask accesses: q_updated, speechQueue, _currentSpeech, _schedulingPaused,
62+
* getDrainPendingSpeechTasks(), and logger. We provide stubs for all of these,
63+
* then bind the real AgentActivity.prototype.mainTask to this object.
64+
*/
65+
function buildMainTaskRunner() {
66+
const q_updated = new Future<void>();
67+
type HeapItem = [number, number, SpeechHandle];
68+
const speechQueue = new Heap<HeapItem>((a: HeapItem, b: HeapItem) => b[0] - a[0] || a[1] - b[1]);
69+
70+
const fakeActivity = {
71+
q_updated,
72+
speechQueue,
73+
_currentSpeech: undefined as SpeechHandle | undefined,
74+
_schedulingPaused: false,
75+
getDrainPendingSpeechTasks: () => [],
76+
logger: {
77+
info: () => {},
78+
debug: () => {},
79+
warn: () => {},
80+
error: () => {},
81+
},
82+
};
83+
84+
const mainTask = (AgentActivity.prototype as Record<string, unknown>).mainTask as (
85+
signal: AbortSignal,
86+
) => Promise<void>;
87+
88+
return {
89+
fakeActivity,
90+
mainTask: mainTask.bind(fakeActivity),
91+
speechQueue,
92+
q_updated,
93+
};
94+
}
95+
96+
describe('AgentActivity - mainTask', () => {
97+
it('should recover when speech handle is interrupted after authorization', async () => {
98+
const { fakeActivity, mainTask, speechQueue, q_updated } = buildMainTaskRunner();
99+
100+
const handle = SpeechHandle.create({ allowInterruptions: true });
101+
102+
speechQueue.push([SpeechHandle.SPEECH_PRIORITY_NORMAL, 1, handle]);
103+
handle._markScheduled();
104+
q_updated.resolve();
105+
106+
const ac = new AbortController();
107+
const mainTaskPromise = mainTask(ac.signal);
108+
109+
// Give mainTask time to pop the handle and call _authorizeGeneration
110+
await new Promise((r) => setTimeout(r, 50));
111+
112+
// Interrupt while waiting for generation
113+
handle.interrupt();
114+
115+
// Let mainTask react to the interrupt, then signal exit
116+
await new Promise((r) => setTimeout(r, 50));
117+
fakeActivity._schedulingPaused = true;
118+
fakeActivity.q_updated = new Future();
119+
fakeActivity.q_updated.resolve();
120+
ac.abort();
121+
122+
const result = await raceTimeout(mainTaskPromise, 2000);
123+
expect(result).toBe('resolved');
124+
});
125+
126+
it('should process next queued handle after an interrupted one', async () => {
127+
const { fakeActivity, mainTask, speechQueue, q_updated } = buildMainTaskRunner();
128+
129+
const handleA = SpeechHandle.create({ allowInterruptions: true });
130+
const handleB = SpeechHandle.create({ allowInterruptions: true });
131+
132+
speechQueue.push([SpeechHandle.SPEECH_PRIORITY_NORMAL, 1, handleA]);
133+
handleA._markScheduled();
134+
speechQueue.push([SpeechHandle.SPEECH_PRIORITY_NORMAL, 2, handleB]);
135+
handleB._markScheduled();
136+
q_updated.resolve();
137+
138+
const ac = new AbortController();
139+
const mainTaskPromise = mainTask(ac.signal);
140+
141+
// Wait for mainTask to pick up handle A
142+
await new Promise((r) => setTimeout(r, 50));
143+
144+
// Interrupt handle A
145+
handleA.interrupt();
146+
147+
// Wait for mainTask to move to handle B and authorize it
148+
await new Promise((r) => setTimeout(r, 50));
149+
150+
// Resolve handle B's generation (simulating normal reply task completion).
151+
// If mainTask is stuck on handle A (bug), handle B was never authorized and this
152+
// throws — we catch it and let the timeout assert the real failure.
153+
try {
154+
handleB._markGenerationDone();
155+
} catch {
156+
// Expected when fix is absent: handle B has no active generation
157+
}
158+
159+
// Let mainTask finish
160+
await new Promise((r) => setTimeout(r, 50));
161+
fakeActivity._schedulingPaused = true;
162+
fakeActivity.q_updated = new Future();
163+
fakeActivity.q_updated.resolve();
164+
ac.abort();
165+
166+
const result = await raceTimeout(mainTaskPromise, 2000);
167+
expect(result).toBe('resolved');
168+
});
169+
170+
it('should skip handles that were interrupted before being popped', async () => {
171+
const { fakeActivity, mainTask, speechQueue, q_updated } = buildMainTaskRunner();
172+
173+
const handle = SpeechHandle.create({ allowInterruptions: true });
174+
175+
// Interrupt before mainTask ever sees it
176+
handle.interrupt();
177+
178+
speechQueue.push([SpeechHandle.SPEECH_PRIORITY_NORMAL, 1, handle]);
179+
handle._markScheduled();
180+
q_updated.resolve();
181+
182+
const ac = new AbortController();
183+
const mainTaskPromise = mainTask(ac.signal);
184+
185+
await new Promise((r) => setTimeout(r, 50));
186+
fakeActivity._schedulingPaused = true;
187+
fakeActivity.q_updated = new Future();
188+
fakeActivity.q_updated.resolve();
189+
ac.abort();
190+
191+
const result = await raceTimeout(mainTaskPromise, 2000);
192+
expect(result).toBe('resolved');
193+
});
194+
});

agents/src/voice/agent_activity.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1052,7 +1052,7 @@ export class AgentActivity implements RecognitionHooks {
10521052

10531053
this._currentSpeech = speechHandle;
10541054
speechHandle._authorizeGeneration();
1055-
await speechHandle._waitForGeneration();
1055+
await speechHandle.waitIfNotInterrupted([speechHandle._waitForGeneration()]);
10561056
this._currentSpeech = undefined;
10571057
}
10581058

0 commit comments

Comments
 (0)