fix(tts): probe a skipped provider for recovery on the streamed path - #6684
Conversation
|
Thanks for picking this up so fast — and for the extra detail about it not being only the first provider. That's a better framing than mine: it's any instance reached before something has awaited. The fix is correct for the bug, and your regression test fails without it and passes with it (I ran One thing moving the call into
A/B on the same probe, with The probe drives one failing request to mark What survives is a real synthesis against the provider you just closed, plus an A guard that holds because async def aclose(self) -> None:
+ # Set before the sweep: `_try_recovery` is synchronous, so a probe is either
+ # already in a slot (and cancelled below) or refused by this flag. A stream
+ # still in flight runs its `finally` after this returns, and must not start a
+ # probe that nothing is left to cancel.
+ self._closed = True
+
for tts_status in self._status: def _try_recovery(self, tts: TTS) -> None:
assert isinstance(self._tts, FallbackAdapter)
+ if self._tts._closed:
+ return
+plus With that added: the leak probe reports The STT sibling does not need this — Happy to push this as a commit to your branch if that's easier, or as a follow-up PR once this lands — your call. One note on the base you're on: |
|
You're right, and thank you — that's a regression my change introduced, not a pre-existing one. Applied in cba3fd1. I reproduced it before taking the patch: with the guard stubbed out, the probe survives The reasoning for the flag holds for exactly the reason you give: Guarded both stream types as you suggested. Also confirmed your read on the STT sibling — both Added No need for a follow-up PR — it's in this one. Appreciate you running the branch and doing the A/B; that was a genuinely good catch. |
|
Verified
Nothing further from me. Thanks for the quick turnaround, and for confirming the STT read rather than just taking it. One unrelated heads-up, since it touches the same slot this guard reads: |
|
Thanks for re-running it, and for A/B'ing my test rather than just the fix — stubbing only the streamed guard is the right way to check it isn't passing incidentally, especially given the Noted on #6683, and one detail to add to your merge-order flag: I've put a note in the PR description so whoever merges sees the interaction. Happy for #6683 to land first if that's easier for the maintainers; I'll rebase this one. |
_try_recovery needs text to synthesize, but _pushed_tokens is only filled once _forward_input_task runs. An instance skipped for being unavailable is reached before anything has awaited, so the probe was dropped and the instance never came back - a process using only stream() stayed pinned to its fallback after a single transient failure, while the chunked path recovered on the next request. The probes now start after the input has been consumed, so they have the text they need.
aclose() cancels the recovery slots once, but a streamed request still in flight runs its finally afterwards, and the probe started there had nothing left to cancel it - a live synthesis against a provider that was just closed, plus an availability_changed emit on a closed adapter. A flag set before the sweep refuses it: _try_recovery is synchronous, so a probe is either already in a slot and cancelled by the sweep, or refused here, with no window in between. Applied to both stream types to keep them symmetric.
|
@LHMQ878 #6683 landed first, so this is the rebase I owed you. It's done and verified locally; I'm held up pushing it by a token-scope problem on my side (my OAuth token lacks The resolution is exactly as small as you predicted, and your heads-up meant nothing was a surprise:
|
|
@biztex thanks for doing the rebase — dual-slot sweep + both guards unchanged is exactly what I expected from your note. Good to know the push blocker is just the workflow scope on your token; nothing needed from me here. |
cba3fd1 to
e35eb00
Compare
Fixes #6678.
Problem
FallbackSynthesizeStream._try_recoverystarts by copying_pushed_tokensand returns when it is empty._pushed_tokensis filled by_forward_input_task, which is created just above the provider loop but has not run yet: for an instance skipped byif tts_status.available or all_failed, nothing between thecreate_taskand the_try_recovery(tts)call awaits, so the probe is dropped for want of text.The instance is only skipped when it is already unavailable, which is exactly when it needs probing — so once a provider fails, a process that only uses
stream()never re-probes it and stays pinned to its fallback for the rest of its life. The chunked path is unaffected because it has the text up front, which is why the same provider recovers there on the very next request.Thanks @LHMQ878 for the diagnosis — it was precisely right, down to the line.
Fix
The probes are started after the input has been consumed, in the
finallythat already awaitsinput_task, so they have the text they need. Instances are collected during the loop instead of being probed inside it; the success path returns through the samefinally, so a provider skipped ahead of the one that worked is still probed.Moving the call into
finallyintroduced a second problem, also found by @LHMQ878:aclose()cancels the recovery slots exactly once, and afinallyalso runs on cancellation — so a stream still in flight when the adapter closes could create a probe in a slot nothing was left to sweep, leaving a live synthesis against a provider that was just closed.FallbackAdapter._closedis set before the sweep and refused in_try_recovery; because_try_recoveryis synchronous, a probe is either already in a slot (and cancelled by the sweep) or refused by the flag, with no window between. Both stream types carry the guard so they don't drift.Ordering is otherwise unchanged: an instance that fails during a request already had text by the time it was probed, and now gets probed a moment later with the same text. The STT adapter calls
_try_recoveryfrom its loop body rather than afinally, so it has no equivalent post-close path and is untouched.Verification
test_tts_recover_on_streamed_pathdrives two streamed requests: the first marks the primary unavailable, the second skips it and must still probe it. Times out waiting for the recovery event onmain, passes with the fix.test_no_recovery_probe_after_closeleaves a request mid-flight (noend_input()), closes the adapter, and asserts no slot holds a live task. Fails without the close guard.ruff format --check,ruff check,check_types.py(mypy strict) and the fullpytest --unitsuite pass locally.Merge order
This overlaps #6683, which splits
_TTSStatus.recovering_taskintorecovering_synthesize_task/recovering_stream_task. Whichever lands second needs a small rebase:_closedand the two guards are unaffected, butaclose()sweeps two slots instead of one, andtest_no_recovery_probe_after_closechecks both rather than one. Happy for #6683 to go first — I'll rebase.