-
Notifications
You must be signed in to change notification settings - Fork 317
fix(tts): restart ChunkedStream retries under fresh request IDs #1994
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| '@livekit/agents': patch | ||
| --- | ||
|
|
||
| Restart chunked TTS retries with a fresh attempt queue so retried synthesis uses a fresh request ID and does not write to a closed failed-attempt queue. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -601,6 +601,7 @@ export abstract class ChunkedStream implements AsyncIterableIterator<Synthesized | |
| #inputTokens = 0; | ||
| #outputTokens = 0; | ||
| #startedTime: number; | ||
| #metricsQueue = new AsyncIterableQueue<SynthesizedAudio>(); | ||
|
|
||
| protected abortController = new AbortController(); | ||
|
|
||
|
|
@@ -625,7 +626,17 @@ export abstract class ChunkedStream implements AsyncIterableIterator<Synthesized | |
| // is run **after** the constructor has finished. Otherwise we get | ||
| // runtime error when trying to access class variables in the | ||
| // `run` method. | ||
| ThrowsPromise.resolve().then(() => this.mainTask().finally(() => this.queue.close())); | ||
| ThrowsPromise.resolve().then(() => this.mainTask().finally(() => this.#metricsQueue.close())); | ||
| } | ||
|
|
||
| private drainAttemptQueue(attemptQueue: AsyncIterableQueue<SynthesizedAudio>): Promise<void> { | ||
| return ThrowsPromise.resolve().then(async () => { | ||
| for await (const audio of attemptQueue) { | ||
| if (!this.#metricsQueue.closed) { | ||
| this.#metricsQueue.put(audio); | ||
| } | ||
| } | ||
| }); | ||
| } | ||
|
|
||
| private _mainTaskImpl = async (span: Span) => { | ||
|
|
@@ -636,8 +647,12 @@ export abstract class ChunkedStream implements AsyncIterableIterator<Synthesized | |
| }); | ||
|
|
||
| for (let i = 0; i < this._connOptions.maxRetry + 1; i++) { | ||
| const attemptQueue = new AsyncIterableQueue<SynthesizedAudio>(); | ||
| this.queue = attemptQueue; | ||
| const drainAttemptQueue = this.drainAttemptQueue(attemptQueue); | ||
|
|
||
| try { | ||
| return await tracer.startActiveSpan( | ||
| const result = await tracer.startActiveSpan( | ||
| async (attemptSpan) => { | ||
| attemptSpan.setAttribute(traceTypes.ATTR_RETRY_COUNT, i); | ||
| try { | ||
|
|
@@ -649,28 +664,34 @@ export abstract class ChunkedStream implements AsyncIterableIterator<Synthesized | |
| }, | ||
| { name: 'tts_request_run' }, | ||
| ); | ||
| if (!attemptQueue.closed) { | ||
| attemptQueue.close(); | ||
| } | ||
| await drainAttemptQueue; | ||
| return result; | ||
| } catch (error) { | ||
| if (!attemptQueue.closed) { | ||
| attemptQueue.close(); | ||
| } | ||
| await drainAttemptQueue; | ||
|
|
||
| if (error instanceof APIError) { | ||
| const retryInterval = intervalForRetry(this._connOptions, i); | ||
| const shouldRetry = | ||
| error.retryable && this._connOptions.maxRetry > 0 && i < this._connOptions.maxRetry; | ||
|
|
||
| if (this._connOptions.maxRetry === 0 || !error.retryable) { | ||
| if (!shouldRetry) { | ||
| this.emitError({ error, recoverable: false }); | ||
| throw error; | ||
| } else if (i === this._connOptions.maxRetry) { | ||
| this.emitError({ error, recoverable: false }); | ||
| throw new APIConnectionError({ | ||
| message: `failed to generate TTS completion after ${this._connOptions.maxRetry + 1} attempts`, | ||
| options: { retryable: false }, | ||
| }); | ||
| } else { | ||
| // Don't emit error event for recoverable errors during retry loop | ||
| // to avoid ERR_UNHANDLED_ERROR or premature session termination | ||
| this.logger.warn( | ||
| { tts: this.#tts.label, attempt: i + 1, error }, | ||
| `failed to generate TTS completion, retrying in ${retryInterval}ms`, | ||
| ); | ||
| } | ||
|
Comment on lines
+679
to
685
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔍 Retry exhaustion now throws the original error instead of a wrapped APIConnectionError The old code had a dedicated branch for Was this helpful? React with 👍 or 👎 to provide feedback. |
||
|
|
||
| const retryInterval = intervalForRetry(this._connOptions, i); | ||
| // Don't emit error event for recoverable errors during retry loop | ||
| // to avoid ERR_UNHANDLED_ERROR or premature session termination | ||
| this.logger.warn( | ||
| { tts: this.#tts.label, attempt: i + 1, error }, | ||
| `failed to generate TTS completion, retrying in ${retryInterval}ms`, | ||
| ); | ||
|
|
||
| if (retryInterval > 0) { | ||
| await delay(retryInterval); | ||
| } | ||
|
|
@@ -727,7 +748,7 @@ export abstract class ChunkedStream implements AsyncIterableIterator<Synthesized | |
| let ttfb: bigint = BigInt(-1); | ||
| let requestId = ''; | ||
|
|
||
| for await (const audio of this.queue) { | ||
| for await (const audio of this.#metricsQueue) { | ||
| audio.frame.userdata[USERDATA_TTS_STARTED_TIME] = this.#startedTime; | ||
| this.output.put(audio); | ||
| requestId = audio.requestId; | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔴 Closing a chunked TTS stream can crash with an unhandled exception when buffered audio is still being drained
The metrics queue is not closed when the stream is torn down (
close()atagents/src/tts/tts.ts:805), so the metrics loop keeps running and tries to write to the already-closed output queue (this.output.put(audio)atagents/src/tts/tts.ts:753), which throws "Queue is closed".Impact: Calling
close()on a chunked TTS stream while audio is still being forwarded causes an unhandled promise rejection that can crash the process.Mechanism: close() leaves #metricsQueue open while output is already closed
Before this PR,
close()closedthis.queue, which was the same queuemonitorMetrics()iterated. So closing the stream immediately terminated the metrics loop.After this PR,
monitorMetrics()iteratesthis.#metricsQueue(line 751), butclose()at line 805-810 only closesthis.queue(the current per-attempt queue) andthis.output. It does NOT closethis.#metricsQueue.The sequence that triggers the crash:
close()is called →this.outputis closed (line 807)drainAttemptQueue()(line 632-639) is still running concurrently, forwarding remaining items from the attempt queue into#metricsQueuemonitorMetrics()picks up those items from#metricsQueueand callsthis.output.put(audio)at line 753AsyncIterableQueue.put()(agents/src/utils.ts:327-331) checksthis.#closedand throwsError('Queue is closed')monitorMetrics()promise which is fire-and-forget (called at line 623), resulting in an unhandled promise rejectionEven without the crash,
#metricsQueuenot being closed meansmonitorMetricshangs untilmainTaskeventually finishes and itsfinallyblock closes#metricsQueue(line 629), which is a resource leak compared to the pre-PR behavior.(Refers to lines 805-810)
Was this helpful? React with 👍 or 👎 to provide feedback.