You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
_TTSStatus has a singlerecovering_task slot, but it is written by both of the TTS fallback adapter's recovery paths — FallbackChunkedStream._try_recovery and FallbackSynthesizeStream._try_recovery. Because each path guards on if tts_status.recovering_task is None or tts_status.recovering_task.done(), a probe in flight on one path silently suppresses probing on the other. A provider that is up again therefore stays marked unavailable for longer than it should — or, in a process that keeps one path continuously busy recovering, indefinitely.
_STTStatus deliberately does not do this: it keeps recovering_recognize_task and recovering_stream_task as separate fields, and aclose() cancels both. The TTS adapter is the odd one out.
Suppression (confirmed below). While one path's probe is in flight, the other path's _try_recovery sees a non-done task in the shared slot and returns without probing. A recovered provider is not detected on that path.
Overwrite / lost cancellation. When the second path does pass the guard (the first task having completed), it overwrites the slot. aclose() only cancels whatever is in the slot at that moment, so any task the slot no longer references cannot be cancelled by the adapter. This is the same class of problem as fix: cancel recovery tasks in LLM FallbackAdapter.aclose() #4921 on the LLM adapter, but reachable here without aclose() racing anything — the reference is simply dropped.
Reproduction
Both scripts run against plain bbf163f with no other patches. This does not depend on #6680 — the suppression is triggered entirely from the chunked path plus one stream() call.
probe_slot.py:
importasyncio, loggingfromlivekit.agentsimportAPIConnectionErrorfromlivekit.agents.ttsimportFallbackAdapterlogging.basicConfig(level=logging.CRITICAL)
fromtests.fake_ttsimportFakeTTSasyncdefmain() ->None:
# fails without pushing audio, so _try_recovery is actually reached;# fake_exception_count must cover the in-stream retry or it is never marked unavailablefake1=FakeTTS(fake_exception=APIConnectionError("fake1 down"), fake_exception_count=99)
fake2=FakeTTS(fake_audio_duration=1.0)
adapter=FallbackAdapter([fake1, fake2], max_retry_per_tts=0)
status=adapter._status[0]
try:
asyncwithadapter.synthesize("hello one") ass:
asyncfor_ins: passexceptException: passassertnotstatus.available, "PROBE INVALID: fake1 was never marked unavailable"# make the next chunked probe hang, so it is unambiguously in flightfake1.update_options(fake_exception=None, fake_timeout=100.0)
try:
asyncwithadapter.synthesize("hello two") ass:
asyncfor_ins: passexceptException: passhanging=status.recovering_taskasserthangingisnotNoneandnothanging.done(), "PROBE INVALID: no in-flight probe"# streamed request on the SAME adapter and provider indextry:
asyncwithadapter.stream() asst:
st.push_text("hello three"); st.end_input()
asyncfor_inst: passexceptException: passprint("slot unchanged (streamed path did not probe):", status.recovering_taskishanging)
asyncio.run(main())
Output:
after chunked request : recovering_task = <Task finished ...FallbackChunkedStream._try_recovery...>
in-flight chunked task: <Task pending ...fallback_adapter.py:189> done = False
after streamed request: recovering_task = <Task pending ...fallback_adapter.py:189>
>>> (B) SUPPRESSION: streamed path skipped probing because the chunked
probe still occupies the single shared slot.
The slot still holds the chunked task (fallback_adapter.py:189) after the streamed request — the streamed path never created one.
Control: the STT adapter, with separate slots, behaves differently
Same scenario against stt.FallbackAdapter (probe_slot_stt.py, using FakeSTT; note it has no fake_exception_count, and with max_retry_per_stt=0 it does not need one):
available after r1 : False
recovering_recognize : pending=True
recovering_stream : None
after streamed request :
recovering_recognize_task = pending
recovering_stream_task = <Task pending ...stt/fallback_adapter.py:442>
>>> STT DID probe on the streamed path while the recognize probe was in flight.
So the behaviour is not a global policy that recovery should be serialized per provider — the sibling adapter explicitly allows the two paths to probe concurrently. TTS differs only because of the shared field.
Suggested fix
Mirror _STTStatus: split the field into recovering_synthesize_task and recovering_stream_task, have each _try_recovery guard and assign its own, and cancel both in aclose(). That keeps the existing "one probe per path per provider" behaviour while removing the cross-path interference, and makes the two adapters structurally identical.
I mentioned this while filing #6678/#6680 as a follow-up I'd hold back rather than fold in; opening it separately now that I've confirmed it reproduces on its own at bbf163f. Happy to send the patch — it is a small, self-contained change, and it does not conflict with #6680 (which touches retry_text, not the slot). Let me know if you'd rather have it as part of that PR.
Environment
livekit-agents at bbf163f, Python 3.12, Windows. Probes use the in-repo tests/fake_tts.py / tests/fake_stt.py; no network and no API keys.
Summary
_TTSStatushas a singlerecovering_taskslot, but it is written by both of the TTS fallback adapter's recovery paths —FallbackChunkedStream._try_recoveryandFallbackSynthesizeStream._try_recovery. Because each path guards onif tts_status.recovering_task is None or tts_status.recovering_task.done(), a probe in flight on one path silently suppresses probing on the other. A provider that is up again therefore stays marked unavailable for longer than it should — or, in a process that keeps one path continuously busy recovering, indefinitely._STTStatusdeliberately does not do this: it keepsrecovering_recognize_taskandrecovering_stream_taskas separate fields, andaclose()cancels both. The TTS adapter is the odd one out.Where
livekit-agents/livekit/agents/tts/fallback_adapter.pyWriters:
FallbackChunkedStream._try_recovery— guard at :185, assignment at :201FallbackSynthesizeStream._try_recovery— guard at :452, assignment at :484Compare
livekit-agents/livekit/agents/stt/fallback_adapter.py:Consequences
_try_recoverysees a non-done task in the shared slot and returns without probing. A recovered provider is not detected on that path.aclose()only cancels whatever is in the slot at that moment, so any task the slot no longer references cannot be cancelled by the adapter. This is the same class of problem as fix: cancel recovery tasks in LLM FallbackAdapter.aclose() #4921 on the LLM adapter, but reachable here withoutaclose()racing anything — the reference is simply dropped.Reproduction
Both scripts run against plain
bbf163fwith no other patches. This does not depend on #6680 — the suppression is triggered entirely from the chunked path plus onestream()call.probe_slot.py:Output:
The slot still holds the chunked task (
fallback_adapter.py:189) after the streamed request — the streamed path never created one.Control: the STT adapter, with separate slots, behaves differently
Same scenario against
stt.FallbackAdapter(probe_slot_stt.py, usingFakeSTT; note it has nofake_exception_count, and withmax_retry_per_stt=0it does not need one):So the behaviour is not a global policy that recovery should be serialized per provider — the sibling adapter explicitly allows the two paths to probe concurrently. TTS differs only because of the shared field.
Suggested fix
Mirror
_STTStatus: split the field intorecovering_synthesize_taskandrecovering_stream_task, have each_try_recoveryguard and assign its own, and cancel both inaclose(). That keeps the existing "one probe per path per provider" behaviour while removing the cross-path interference, and makes the two adapters structurally identical.I mentioned this while filing #6678/#6680 as a follow-up I'd hold back rather than fold in; opening it separately now that I've confirmed it reproduces on its own at
bbf163f. Happy to send the patch — it is a small, self-contained change, and it does not conflict with #6680 (which touchesretry_text, not the slot). Let me know if you'd rather have it as part of that PR.Environment
livekit-agentsatbbf163f, Python 3.12, Windows. Probes use the in-repotests/fake_tts.py/tests/fake_stt.py; no network and no API keys.