Skip to content

Commit 50a650b

Browse files
authored
fix: address 5 Detail scan bugs from March 11 (reconnect, mutex leak, playout, ordering, retryability) (#1188)
1 parent 957b4f5 commit 50a650b

10 files changed

Lines changed: 305 additions & 37 deletions

File tree

.changeset/shy-pianos-sort.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+
fix: address 5 Detail scan bugs from March 11 (reconnect, mutex leak, playout, ordering, retryability)

agents/src/_exceptions.test.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
// SPDX-FileCopyrightText: 2026 LiveKit, Inc.
2+
//
3+
// SPDX-License-Identifier: Apache-2.0
4+
import { describe, expect, it } from 'vitest';
5+
import { APIStatusError } from './_exceptions.js';
6+
7+
describe('APIStatusError retryability defaults', () => {
8+
it('treats 408 as retryable by default', () => {
9+
const error = new APIStatusError({
10+
message: 'timeout',
11+
options: { statusCode: 408 },
12+
});
13+
expect(error.retryable).toBe(true);
14+
});
15+
16+
it('treats 429 as retryable by default', () => {
17+
const error = new APIStatusError({
18+
message: 'rate limited',
19+
options: { statusCode: 429 },
20+
});
21+
expect(error.retryable).toBe(true);
22+
});
23+
24+
it('keeps other 4xx responses non-retryable by default', () => {
25+
const error = new APIStatusError({
26+
message: 'not found',
27+
options: { statusCode: 404 },
28+
});
29+
expect(error.retryable).toBe(false);
30+
});
31+
32+
it('respects explicit retryable override', () => {
33+
const forceRetryable = new APIStatusError({
34+
message: 'force retry',
35+
options: { statusCode: 404, retryable: true },
36+
});
37+
const forceNonRetryable = new APIStatusError({
38+
message: 'force no retry',
39+
options: { statusCode: 429, retryable: false },
40+
});
41+
42+
expect(forceRetryable.retryable).toBe(true);
43+
expect(forceNonRetryable.retryable).toBe(false);
44+
});
45+
});

agents/src/_exceptions.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -79,8 +79,10 @@ export class APIStatusError extends APIError {
7979
options?: APIStatusErrorOptions;
8080
}) {
8181
const statusCode = options.statusCode ?? -1;
82-
// 4xx errors are not retryable
83-
const isRetryable = options.retryable ?? !(statusCode >= 400 && statusCode < 500);
82+
// 408/429 are transient even though they are 4xx, so keep them retryable by default.
83+
const isRetryable =
84+
options.retryable ??
85+
(statusCode === 408 || statusCode === 429 || !(statusCode >= 400 && statusCode < 500));
8486

8587
super(message, { body: options.body, retryable: isRetryable });
8688
this.name = 'APIStatusError';

agents/src/inference/stt.ts

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -543,13 +543,20 @@ export class SpeechStream<TModel extends STTModels> extends BaseSpeechStream {
543543
try {
544544
ws = await this.stt.connectWs(this.connOptions.timeoutMs);
545545

546-
const controller = this.abortController; // Use base class abortController for proper cancellation
547-
const sendTask = Task.from(({ signal }) => send(ws!, signal), controller);
548-
const wsListenerTask = Task.from(({ signal }) => createWsListener(ws!, signal), controller);
549-
const recvTask = Task.from(({ signal }) => recv(signal), controller);
546+
// Use a per-connection controller so reconnect loops don't inherit a permanently-aborted signal.
547+
const connController = new AbortController();
548+
const onStreamAbort = () => connController.abort();
549+
this.abortController.signal.addEventListener('abort', onStreamAbort);
550+
551+
const sendTask = Task.from(({ signal }) => send(ws!, signal), connController);
552+
const wsListenerTask = Task.from(
553+
({ signal }) => createWsListener(ws!, signal),
554+
connController,
555+
);
556+
const recvTask = Task.from(({ signal }) => recv(signal), connController);
550557
const waitReconnectTask = Task.from(
551558
({ signal }) => Promise.race([this.reconnectEvent.wait(), waitForAbort(signal)]),
552-
controller,
559+
connController,
553560
);
554561

555562
try {
@@ -564,13 +571,16 @@ export class SpeechStream<TModel extends STTModels> extends BaseSpeechStream {
564571
// Reconnect triggered - clear event and continue loop
565572
this.reconnectEvent.clear();
566573
} finally {
567-
// Cancel all tasks to ensure cleanup
574+
connController.abort();
575+
this.abortController.signal.removeEventListener('abort', onStreamAbort);
568576
await cancelAndWait(
569577
[sendTask, wsListenerTask, recvTask, waitReconnectTask],
570578
DEFAULT_CANCEL_TIMEOUT,
571579
);
572580
resourceCleanup();
573581
}
582+
583+
if (this.abortController.signal.aborted) break;
574584
} finally {
575585
// Ensure cleanup even if connectWs throws
576586
resourceCleanup();

agents/src/ipc/proc_pool.test.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
// SPDX-FileCopyrightText: 2026 LiveKit, Inc.
2+
//
3+
// SPDX-License-Identifier: Apache-2.0
4+
import { describe, expect, it, vi } from 'vitest';
5+
import type { RunningJobInfo } from '../job.js';
6+
import { type JobExecutor, JobStatus } from './job_executor.js';
7+
import { ProcPool } from './proc_pool.js';
8+
9+
function createMockExecutor() {
10+
const executor: JobExecutor = {
11+
started: true,
12+
userArguments: {},
13+
runningJob: undefined,
14+
status: JobStatus.RUNNING,
15+
start: vi.fn(async () => {}),
16+
join: vi.fn(async () => {}),
17+
initialize: vi.fn(async () => {}),
18+
close: vi.fn(async () => {}),
19+
launchJob: vi.fn(async () => {}),
20+
};
21+
return executor;
22+
}
23+
24+
describe('ProcPool warmed process lock handling', () => {
25+
it('releases lock token from the dequeued warmed process entry', async () => {
26+
const pool = new ProcPool('agent', 1, 1000, 1000, undefined, 0, 0);
27+
const unlock = vi.fn();
28+
const executor = createMockExecutor();
29+
const jobInfo = {
30+
acceptArguments: { name: 'n', identity: 'i', metadata: '' },
31+
job: { id: 'job-id' },
32+
url: 'wss://example.com',
33+
token: 'token',
34+
workerId: 'worker-id',
35+
} as unknown as RunningJobInfo;
36+
37+
await pool.warmedProcQueue.put({ proc: executor, unlock });
38+
await pool.launchJob(jobInfo);
39+
40+
expect(unlock).toHaveBeenCalledTimes(1);
41+
expect(executor.launchJob).toHaveBeenCalledWith(jobInfo);
42+
});
43+
44+
it('releases queued lock tokens during close', async () => {
45+
const pool = new ProcPool('agent', 1, 1000, 1000, undefined, 0, 0);
46+
const unlock = vi.fn();
47+
const executor = createMockExecutor();
48+
49+
await pool.warmedProcQueue.put({ proc: executor, unlock });
50+
pool.started = true;
51+
await pool.close();
52+
53+
expect(unlock).toHaveBeenCalledTimes(1);
54+
expect(executor.close).toHaveBeenCalledTimes(1);
55+
});
56+
57+
it('releases both init and proc locks when closed before proc starts', async () => {
58+
const pool = new ProcPool('agent', 1, 1000, 1000, undefined, 0, 0);
59+
const initUnlock = vi.fn();
60+
const procUnlock = vi.fn();
61+
pool.closed = true;
62+
pool.initMutex.lock = vi.fn(async () => initUnlock);
63+
64+
await pool.procWatchTask(procUnlock);
65+
66+
expect(initUnlock).toHaveBeenCalledTimes(1);
67+
expect(procUnlock).toHaveBeenCalledTimes(1);
68+
});
69+
});

agents/src/ipc/proc_pool.ts

Lines changed: 32 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,8 @@ export class ProcPool {
1919
controller = new AbortController();
2020
initMutex = new Mutex();
2121
procMutex?: MultiMutex;
22-
procUnlock?: () => void;
23-
warmedProcQueue = new Queue<JobExecutor>();
22+
// Keep each lock token paired with its warmed process so MultiMutex slots are always released correctly.
23+
warmedProcQueue = new Queue<{ proc: JobExecutor; unlock: () => void }>();
2424
inferenceExecutor?: InferenceExecutor;
2525
memoryWarnMB: number;
2626
memoryLimitMB: number;
@@ -56,11 +56,10 @@ export class ProcPool {
5656
async launchJob(info: RunningJobInfo) {
5757
let proc: JobExecutor;
5858
if (this.procMutex) {
59-
proc = await this.warmedProcQueue.get();
60-
if (this.procUnlock) {
61-
this.procUnlock();
62-
this.procUnlock = undefined;
63-
}
59+
const entry = await this.warmedProcQueue.get();
60+
proc = entry.proc;
61+
// Release exactly the slot that produced this warmed process.
62+
entry.unlock();
6463
} else {
6564
proc = new JobProcExecutor(
6665
this.agent,
@@ -80,7 +79,7 @@ export class ProcPool {
8079
await proc.launchJob(info);
8180
}
8281

83-
async procWatchTask() {
82+
async procWatchTask(procUnlock: () => void) {
8483
const proc = new JobProcExecutor(
8584
this.agent,
8685
this.inferenceExecutor,
@@ -97,23 +96,28 @@ export class ProcPool {
9796
this.executors.push(proc);
9897

9998
const unlock = await this.initMutex.lock();
100-
if (this.closed) {
101-
return;
102-
}
103-
104-
await proc.start();
99+
let procUnlockTransferred = false;
105100
try {
106-
await proc.initialize();
107-
await this.warmedProcQueue.put(proc);
108-
} catch {
109-
if (this.procUnlock) {
110-
this.procUnlock();
111-
this.procUnlock = undefined;
101+
if (this.closed) {
102+
return;
112103
}
113-
}
114104

115-
unlock();
116-
await proc.join();
105+
await proc.start();
106+
try {
107+
await proc.initialize();
108+
await this.warmedProcQueue.put({ proc, unlock: procUnlock });
109+
procUnlockTransferred = true;
110+
} catch {
111+
// Initialization failed before enqueue, so release the acquired slot immediately.
112+
}
113+
114+
await proc.join();
115+
} finally {
116+
unlock();
117+
if (!procUnlockTransferred) {
118+
procUnlock();
119+
}
120+
}
117121
} finally {
118122
const procIndex = this.executors.indexOf(proc);
119123
if (procIndex !== -1) {
@@ -136,8 +140,8 @@ export class ProcPool {
136140
async run(signal: AbortSignal) {
137141
if (this.procMutex) {
138142
while (!signal.aborted) {
139-
this.procUnlock = await this.procMutex.lock();
140-
const task = this.procWatchTask();
143+
const procUnlock = await this.procMutex.lock();
144+
const task = this.procWatchTask(procUnlock);
141145
this.tasks.push(task);
142146
task.finally(() => {
143147
const taskIndex = this.tasks.indexOf(task);
@@ -157,7 +161,10 @@ export class ProcPool {
157161
}
158162
this.closed = true;
159163
this.controller.abort();
160-
this.warmedProcQueue.items.forEach((e) => e.close());
164+
this.warmedProcQueue.items.forEach((e) => {
165+
e.unlock();
166+
e.proc.close();
167+
});
161168
this.executors.forEach((e) => e.close());
162169
await Promise.allSettled(this.tasks);
163170
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
// SPDX-FileCopyrightText: 2026 LiveKit, Inc.
2+
//
3+
// SPDX-License-Identifier: Apache-2.0
4+
import { describe, expect, it } from 'vitest';
5+
import { ChatContext, ChatMessage } from '../chat_context.js';
6+
import { groupToolCalls } from './utils.js';
7+
8+
describe('groupToolCalls', () => {
9+
it('preserves insertion order for non-assistant message groups', () => {
10+
const chatCtx = new ChatContext([
11+
ChatMessage.create({
12+
id: '10',
13+
role: 'user',
14+
content: 'first',
15+
}),
16+
ChatMessage.create({
17+
id: '2',
18+
role: 'system',
19+
content: 'second',
20+
}),
21+
]);
22+
23+
const groups = groupToolCalls(chatCtx);
24+
const itemIds = groups.map((group) => group.flatten()[0]!.id);
25+
26+
expect(itemIds).toEqual(['10', '2']);
27+
});
28+
});

agents/src/llm/provider_format/utils.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,8 @@ class ChatItemGroup {
5151
this.toolCalls.push(item);
5252
} else if (item.type === 'function_call_output') {
5353
this.toolOutputs.push(item);
54+
} else if (item.type === 'agent_handoff') {
55+
// provider formatters don't serialize handoff records into model input.
5456
}
5557
return this;
5658
}
@@ -153,6 +155,8 @@ export function groupToolCalls(chatCtx: ChatContext) {
153155
toolOutputs.push(item);
154156
} else {
155157
itemGroups[item.id] = ChatItemGroup.create().add(item);
158+
// User/system messages and agent_handoff items also need stable insertion indices.
159+
insertionOrder[item.id] = insertionIndex++;
156160
}
157161
}
158162

0 commit comments

Comments
 (0)