Skip to content

fix(google): don't cut streaming TTS off at the connect timeout - #6986

Open
biztex wants to merge 1 commit into
livekit:mainfrom
biztex:fix/google-tts-stream-deadline
Open

fix(google): don't cut streaming TTS off at the connect timeout#6986
biztex wants to merge 1 commit into
livekit:mainfrom
biztex:fix/google-tts-stream-deadline

Conversation

@biztex

@biztex biztex commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

Problem

SynthesizeStream._run_stream passes conn_options.timeout (10 s by default, documented as the connect timeout) as timeout= of streaming_synthesize(). For a gapic streaming method that is the gRPC deadline of the whole bidi call (google.api_coregrpc.aio.StreamStreamMultiCallable(timeout=...), "duration of time in seconds to allow for the RPC"), and the request generator keeps the call open for as long as the LLM produces text for the segment. Any reply that takes longer than 10 s to generate and synthesize is ended with DeadlineExceededAPITimeoutError mid-utterance, and because audio was already emitted the base class doesn't retry ("TTS failed after partial audio was already sent to the user, skip retrying") — the rest of the turn is dropped. use_streaming=True is the default, so this hits every long reply.

The deadline has a second effect: grpc only cancels its request-poller task in cancel(), never when the deadline fires. So when the LLM is still producing text at that moment, the poller is mid-__anext__ on input_gen and the finally: await input_gen.aclose() fails with RuntimeError: aclose(): asynchronous generator is already running (the traceback in #2951), leaking the poller.

Fix

  • Open the RPC without a deadline (like stt.py's streaming_recognize), and use conn_options.timeout only to bound the wait for the first audio chunk (asyncio.wait_for on the first anext), still surfacing it as APITimeoutError. Subsequent chunks are unbounded, since the gaps between them follow the LLM's pace.
  • Let grpc own the request generator's lifetime: it finalizes it when the call completes or is cancelled (which is what a first-chunk timeout does), so the explicit aclose() that raced with grpc's poller is gone.

ChunkedStream is untouched: there the kwarg is a per-request deadline for a single unary synthesize_speech, which is what it should be.

Tests

tests/test_plugin_google_tts.py drives tts.stream() against a fake client that mirrors grpc.aio (request iterator consumed by a background task, timeout enforced as a whole-call deadline, cancelling a read cancels the RPC and its poller):

  • test_streaming_not_cut_off_by_connect_timeout: three sentences pushed over ~0.45 s with timeout=0.2 — every sentence reaches the fake and every audio byte is delivered. On main it fails with the aclose() RuntimeError above and a leaked poller task.
  • test_streaming_first_response_timeout: a server that never answers while the input is still open raises APITimeoutError within the timeout, with no leaked tasks.

ruff / mypy (strict) clean.

Fixes #3347
Fixes the timeout part of #5117 (its chirp_3 voice-name error is a separate config/model-support question).
Also fixes the aclose(): asynchronous generator is already running failure reported in #2951.

SynthesizeStream passed conn_options.timeout (10s by default, documented
as the connect timeout) as the timeout of streaming_synthesize(). For a
gapic streaming method that value is the gRPC deadline of the whole bidi
call, and the request generator keeps the call open for as long as the
LLM produces text for the segment. Any reply that takes longer than 10s
to generate and synthesize was therefore ended with DeadlineExceeded
mid-utterance. Since audio had already been emitted the base class does
not retry, so the rest of the reply was dropped ("TTS failed after
partial audio was already sent to the user, skip retrying").

The deadline also left grpc's request poller parked on the sentence
stream, so the finally-block aclose() of the request generator raced
with it and failed with "aclose(): asynchronous generator is already
running" whenever the LLM was still producing text.

Open the RPC without a deadline and only bound the wait for the first
audio chunk with conn_options.timeout, mapping it to APITimeoutError as
before. Let grpc own the request generator's lifetime: it finalizes it
when the call completes or is cancelled, which is what happens on the
first-chunk timeout.

The tests drive tts.stream() against a fake client that mirrors grpc.aio
(background request poller, whole-call deadline, read cancellation
cancels the RPC): a reply produced over longer than the connect timeout
is synthesized in full, and a server that never answers still raises
APITimeoutError within the timeout.
@biztex
biztex requested a review from a team as a code owner August 26, 2026 10:27

@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: No Issues Found

Devin Review analyzed this PR and found no potential bugs to report.

View in Devin Review to see 2 additional findings.

Open in Devin Review

except asyncio.TimeoutError:
raise APITimeoutError() from None

while resp is not None:

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.

nit: maybe pushing the first chunk and then async for resp in resp_iter?

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.

Google TTS (LiveKit Plugin) stops mid-way while LLM continues generating text

2 participants