Repository navigation
fix(tts): detect a stalled websocket in six streaming TTS plugins - #8035
Rehansanjay wants to merge 1 commit into
Conversation
resemble, soniox and xai were reported unmeasurable on the strength of a constructor scan that looked for base_url/ws_url/url/endpoint. soniox takes websocket_url; resemble and xai keep theirs in a module constant the probe can patch. All three measure at the full 75s watch. All seven are fixed in livekit/agents#8035.
|
Converting this to a draft. The automated review found three problems and all three are real β the measurement that motivated this holds, but the fix is wrong, and it would trade a rare failure for a common one. The deadline starts before any text is sent. This is the one that matters. In async def _send_task(ws):
async for data in self._input_ch: # waits for the LLM
...
async def _recv_task(ws):
async for msg in ws: # deadline already runningWith I argued the opposite in the description above: " Pooled sockets keep the first request's deadline.
What this meansA connection-level So the deadline has to be armed after the first send and reset on each frame, which is a per-plugin change to the receive loops rather than one line at connect, and for That is a bigger change than this PR, and where the deadline should live looks like a maintainer's call rather than mine β in the base class alongside The underlying problem is unchanged and still worth fixing. Seven plugins β Thanks for the review β the second finding in particular saved a regression that my own test setup would not have caught, because the probe pushes text immediately and so never exercises a slow first token. |
c885a4a to
22bd98c
Compare
baseten, murf, neuphonic, resemble, telnyx and xai have no liveness check on their TTS websocket. If the socket goes half-open after connecting -- the provider, or a NAT in between, gone without a FIN or an RST -- the text is pushed, no audio comes back, nothing raises, and the agent is silently mute for the rest of the call. `tts.FallbackAdapter` is never told. livekit#8123 added `utils.aio.WebSocketMonitor` and a `liveness_timeout` connect option, and every streaming STT plugin now reads through it. That helper is not STT-specific and the TTS side has not adopted it, so this does the same thing here: connect with `autoping=False`, build one monitor per connection, and read through `monitor.receive()`. baseten and telnyx read with `async for msg in ws`, which cannot carry a monitor, so both become explicit loops; their existing close and error branches are unchanged. Measured against a local server that accepts the websocket upgrade and then sends nothing and answers no pings, with the default `liveness_timeout` of 6.0: | plugin | before | after | | --------- | ---------- | ----- | | baseten | never, 75s | 7.6s | | murf | never, 75s | 6.2s | | neuphonic | never, 75s | 6.1s | | resemble | never, 75s | 6.1s | | telnyx | never, 75s | 6.1s | | xai | never, 75s | 6.1s | And against a peer that answers pings but returns no audio -- a provider that is simply slow -- all six stay connected past 20s, because the pongs keep resetting the timer. That case is why this reads through the monitor rather than putting a plain deadline on the receive: a passive deadline cannot tell a slow provider from a dead one, and would cut off a healthy stream waiting on a slow first token. Probe and raw numbers: https://github.com/Rehansanjay/livekit-tts-halfopen
22bd98c to
61234a7
Compare
There was a problem hiding this comment.
Devin Review found 1 new potential issue.
1 flag not posted on this PR by your GitHub settings β view it in Devin Review. (Configure)
| monitor = utils.aio.WebSocketMonitor( | ||
| ws, liveness_timeout=self._conn_options.liveness_timeout | ||
| ) | ||
| while True: | ||
| msg = await monitor.receive() |
There was a problem hiding this comment.
π΄ Silent provider leaves speech stream waiting
When a provider stops sending audio but answers pings, monitor.receive() waits indefinitely. WebSocketMonitor resets its deadline on every PONG, so the stream never reaches fallback.
Learn more
The receive loop now checks transport activity rather than waiting for application audio. WebSocketMonitor.receive consumes PONG frames and resets its timer each time. A reachable provider can keep answering pings while its synthesis pipeline stalls, so no audio arrives and the receive loop never fails. The same replacement appears in the Murf, NeuPhonic, Resemble, Telnyx, and xAI streams.
Example: A provider accepts the text, stops producing audio, and answers each ping. With timeout=10 and liveness_timeout=6, the stream remains blocked past ten seconds because each PONG renews the monitor's six-second deadline.
Recommended fix: Keep the monitor for detecting dead connections, but add a separate per-request audio or synthesis-progress deadline in the receive loops. Start it when text is sent for providers that can remain idle before input; do not let PONG frames renew it.
Was this helpful? React with π or π to provide feedback.
baseten,murf,neuphonic,resemble,telnyxandxaihave no liveness check on their TTS websocket.If the socket goes half-open after connecting β the provider, or a NAT in between, gone without a FIN or an RST β the text is pushed, no audio comes back, nothing raises, and the agent is silently mute for the rest of the call.
tts.FallbackAdapteris never told.Rewritten on top of #8123
This PR previously put a deadline on the receive itself. #8123 landed a better answer:
utils.aio.WebSocketMonitorplus aliveness_timeoutconnect option, now used by every streaming STT plugin.That helper is not STT-specific, and the TTS side has not adopted it. So this is now the same change for TTS β connect with
autoping=False, build one monitor per connection, read through it:basetenandtelnyxread withasync for msg in ws, which cannot carry a monitor, so both become explicit loops. Their existing close and error branches are unchanged.Measured
A local server accepts the websocket upgrade and then sends nothing and answers no pings. Default
liveness_timeoutof 6.0:And the case that decides whether the mechanism is the right one β a peer that answers pings but returns no audio, i.e. a provider that is simply slow:
Text is pushed in that test, so plugins whose receive loop waits on "input sent" really do start their monitor rather than passing by not running.
That contrast is the argument for the monitor over a plain receive deadline, and it is the flaw the review found in the earlier revision here: a passive deadline cannot tell a slow provider from a dead one, so it would cut off a healthy stream waiting on a slow first token. Probing distinguishes them. It also means the
input_sent_eventgate the earlier revision added tobasetenis no longer needed, so that is gone.37 tests across the existing suites for these plugins pass.
ruff checkandruff format --checkclean.Not included
sonioxβ its_recv_loopruns for the whole life of a persistent connection, including between requests, and itsexcept Exceptioncatches the timeout and fails every pending request. Bounding that read needs a deadline scoped to an outstanding request, which is a larger change. Happy to take it separately.inworldβ hasreceive(timeout=60.0)whose handlercontinues, so it polls rather than bounding anything, and the hardcoded 60 ignores the connect options. That is a behaviour change rather than a missing check.respeecherβ rejects any non-wss://url before opening a socket, so it could not be pointed at a local server and I could not measure it. Reading the source it does pass a timeout tows.receive(), so it looks bounded, but that is a source read rather than a measurement.Probe, per-plugin construction table and raw numbers: https://github.com/Rehansanjay/livekit-tts-halfopen