Skip to content

fix(tts): restart ChunkedStream retries under fresh request IDs#1994

Open
rosetta-livekit-bot[bot] wants to merge 1 commit into
mainfrom
labia-costed-meres
Open

fix(tts): restart ChunkedStream retries under fresh request IDs#1994
rosetta-livekit-bot[bot] wants to merge 1 commit into
mainfrom
labia-costed-meres

Conversation

@rosetta-livekit-bot

@rosetta-livekit-bot rosetta-livekit-bot Bot commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Summary

Testing

  • pnpm build:agents
  • pnpm test -- agents/src/tts
  • pnpm exec prettier --check agents/src/tts/tts.ts .changeset/fresh-tts-chunked-retries.md
  • cue-cli text-mode runtime validation with a temporary local agent: asserted an assistant conversation_item_added framework event.

Ported from livekit/agents#6346

Original PR description

Problem

test_tts_synthesize[deepgram] failed on main (run): a transient connection error mid-response caused ChunkedStream to retry after partial audio had already been emitted, so the consumer received frames from two attempts with mixed request_ids — and frames from the failed attempt could still trickle out after the retry started, interleaving the two attempts.

Changes

  • ChunkedStream._main_task: settle the emitter (aclose) before retrying, so no stale frames from the failed attempt are delivered once the retry begins. The retry restarts synthesis under a fresh request_id, signaling downstream that any partial audio from the failed attempt is stale. Also skip retries for non-retryable errors (matching SynthesizeStream).
  • tests/test_tts.py: _do_synthesis now accepts a request_id restart — it asserts request_ids don't interleave between attempts and validates completeness/audio on the final attempt's frames. New test test_tts_synthesize_retry_after_partial_audio covers fail-once-then-succeed: exactly one retry, recoverable error event, two non-interleaved request_ids, full audio from the retry, one metrics event.
  • tests/fake_tts.py: fake_exception_count option to fail the first N attempts, and yield control between pushes so partial audio reaches the consumer like a real provider streaming over the network.

Notes

SynthesizeStream still refuses to retry after partial audio (#5242); making it restart under a fresh request_id like this would be a follow-up.

@rosetta-livekit-bot rosetta-livekit-bot Bot requested a review from a team as a code owner July 8, 2026 11:11
@changeset-bot

changeset-bot Bot commented Jul 8, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 716bd2b

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 36 packages
Name Type
@livekit/agents Patch
@livekit/agents-plugin-anam Patch
@livekit/agents-plugin-anthropic Patch
@livekit/agents-plugin-assemblyai Patch
@livekit/agents-plugin-baseten Patch
@livekit/agents-plugin-bey Patch
@livekit/agents-plugin-cartesia Patch
@livekit/agents-plugin-cerebras Patch
@livekit/agents-plugin-deepgram Patch
@livekit/agents-plugin-did Patch
@livekit/agents-plugin-elevenlabs Patch
@livekit/agents-plugin-fishaudio Patch
@livekit/agents-plugin-google Patch
@livekit/agents-plugin-hedra Patch
@livekit/agents-plugin-hume Patch
@livekit/agents-plugin-inworld Patch
@livekit/agents-plugin-lemonslice Patch
@livekit/agents-plugin-liveavatar Patch
@livekit/agents-plugin-livekit Patch
@livekit/agents-plugin-minimax Patch
@livekit/agents-plugin-mistral Patch
@livekit/agents-plugin-mistralai Patch
@livekit/agents-plugin-neuphonic Patch
@livekit/agents-plugin-openai Patch
@livekit/agents-plugin-perplexity Patch
@livekit/agents-plugin-phonic Patch
@livekit/agents-plugin-resemble Patch
@livekit/agents-plugin-rime Patch
@livekit/agents-plugin-runway Patch
@livekit/agents-plugin-sarvam Patch
@livekit/agents-plugin-silero Patch
@livekit/agents-plugin-soniox Patch
@livekit/agents-plugin-tavus Patch
@livekit/agents-plugins-test Patch
@livekit/agents-plugin-trugen Patch
@livekit/agents-plugin-xai Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@rosetta-livekit-bot rosetta-livekit-bot Bot requested a review from u9g July 8, 2026 11:12

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 2 potential issues.

Open in Devin Review

Comment thread agents/src/tts/tts.ts

Copy link
Copy Markdown
Contributor

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() at agents/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) at agents/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() closed this.queue, which was the same queue monitorMetrics() iterated. So closing the stream immediately terminated the metrics loop.

After this PR, monitorMetrics() iterates this.#metricsQueue (line 751), but close() at line 805-810 only closes this.queue (the current per-attempt queue) and this.output. It does NOT close this.#metricsQueue.

The sequence that triggers the crash:

  1. close() is called → this.output is closed (line 807)
  2. drainAttemptQueue() (line 632-639) is still running concurrently, forwarding remaining items from the attempt queue into #metricsQueue
  3. monitorMetrics() picks up those items from #metricsQueue and calls this.output.put(audio) at line 753
  4. AsyncIterableQueue.put() (agents/src/utils.ts:327-331) checks this.#closed and throws Error('Queue is closed')
  5. This exception is thrown inside the monitorMetrics() promise which is fire-and-forget (called at line 623), resulting in an unhandled promise rejection

Even without the crash, #metricsQueue not being closed means monitorMetrics hangs until mainTask eventually finishes and its finally block closes #metricsQueue (line 629), which is a resource leak compared to the pre-PR behavior.

(Refers to lines 805-810)

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment thread agents/src/tts/tts.ts
Comment on lines +679 to 685
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`,
);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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 i === maxRetry that threw new APIConnectionError({ message: 'failed to generate TTS completion after N attempts', options: { retryable: false } }). The new shouldRetry logic at agents/src/tts/tts.ts:679-684 collapses this into a single !shouldRetry check that throws the original APIError. This changes the error type that callers receive on retry exhaustion from APIConnectionError to APIError. Meanwhile, SynthesizeStream._mainTaskImpl at agents/src/tts/tts.ts:284-289 still uses the old pattern with the APIConnectionError wrapper. If any caller distinguishes between these error types (e.g., FallbackChunkedStream.run() at agents/src/tts/fallback_adapter.ts:386 checks instanceof APIError || instanceof APIConnectionError), the behavioral change could matter. Worth confirming this inconsistency between ChunkedStream and SynthesizeStream is intentional.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

0 participants