fix(livekit-agents): cancel in-flight recovery in the LLM fallback adapter's aclose - #6676
fix(livekit-agents): cancel in-flight recovery in the LLM fallback adapter's aclose#6676LHMQ878 wants to merge 1 commit into
Conversation
…apter's aclose `llm.FallbackAdapter._try_recovery` starts a background task that re-issues the request against a failed provider. The task is stored on `_LLMStatus`, but `aclose()` only detached the metrics handlers, so the recovery attempt outlived the adapter. Measured on bbf163f, with a primary that fails once and then blocks on the recovery request: after `await adapter.aclose()` the task is still pending, and releasing it lets it run to completion, flip `_status[0].available` back to True, and emit `llm_availability_changed` on an adapter the caller had already closed. Closing the adapter and then every provider — the documented teardown order — still left the recovery task pending on the loop, so it holds the request open and keeps the chat context alive past shutdown. The STT and TTS fallback adapters already cancel theirs (`stt/fallback_adapter.py:292`, `tts/fallback_adapter.py:133`), so this was the odd one out rather than a deliberate difference. Adds `test_aclose_cancels_in_flight_recovery`, confirmed failing on the unmodified tree.
|
|
|
Closing this as a duplicate of #4921, which has been open since February with the same one-file change to @bilalahmed14's PR should be the one that lands. For whatever it's worth to a reviewer, I did measure the behaviour before writing mine, and it may help move #4921 along:
If it's useful, I'm happy to contribute the regression test ( Separately, while comparing the three adapters I found what looks like a distinct bug in the TTS adapter: |
Summary
llm.FallbackAdapter's recovery task outlivesaclose().When a provider fails,
FallbackLLMStream._try_recoverystarts a background task that re-issues the request against it to see whether it has come back. The task is stored on_LLMStatus.recovering_task, butFallbackAdapter.aclose()only detached the metrics handlers:The two sibling adapters already cancel theirs, which is what makes this look like an oversight rather than a deliberate difference:
aclosestt.FallbackAdapter(stt/fallback_adapter.py:292)recovering_recognize_taskandrecovering_stream_tasktts.FallbackAdapter(tts/fallback_adapter.py:133)llm.FallbackAdapterMeasured on
bbf163fA primary that raises
APIConnectionErroron its first request and then blocks on the recovery request, so the attempt is still in flight when the adapter is closed:await adapter.aclose()_status[0].availableflipped back toTruepost-aclosellm_availability_changedemitted on the closed adapterThat last row is the practical one. Closing the adapter and then each LLM — the ordinary teardown order — still left
FallbackLLMStream._try_recovery.<locals>._recover_llm_taskpending, along with theLLMStreammain and metrics tasks it owns. So a real provider request stays open past shutdown, and the task keeps the chat context it was constructed with alive. It also means a_statusmutation and an event can land after the caller has stopped listening.Change
Cancel the recovery tasks in
aclose(), the same way the STT and TTS adapters do:Test
test_aclose_cancels_in_flight_recoveryintests/test_llm_fallback.py, confirmed red on the unmodified tree before being kept (AssertionError: expected aclose to cancel the in-flight recovery request, it outlives the stream that started it).It drives the real adapter: the primary fails, the fallback answers, the recovery attempt blocks, then
aclose()is called. Beyond asserting the task is done it also releases the blocked request afterwards and asserts the attempt did not resurrect — noavailableflip, no extrallm_availability_changed. Cancelling the task alone would satisfy adone()assertion even if the underlying request completed anyway, so the post-release assertions are what pin the actual behaviour.Verification
ruff checkandruff format --checkon both changed files — clean.mypy(repo config,strict) onlivekit.agents.llm— 1 diagnostic, identical with and without this change (a pre-existingimport-untypedforlivekit.plugins.google.utils, from that plugin not being installed in my environment).uv run pytest --unit: I could not install the full workspace on this machine —bithuman2.7.0 publishes no Windows wheel, souv sync --all-extras --devfails outright. I ran the suite against an editablelivekit-agentsinstead, which leaves 15 modules erroring on absent plugin packages. Captured the sortedFAILED/ERRORset with the change stashed and unstashed: byte-identical (15 collection errors + 4 failures intest_ivr_activity.pyandtest_tokenizer_xml_markup.py, all pre-existing and unrelated), 1567 passed both ways.tests/test_llm_fallback.pyis green: 3 passed.I did not touch
CHANGELOG.mdor any package manifest, per CONTRIBUTING.