Repository navigation
fix(assemblyai): bound the STT read side on a half-open socket - #7547
Closed
devjoinedthechat wants to merge 2 commits into
Closed
devjoinedthechat wants to merge 2 commits into
devjoinedthechat wants to merge 2 commits into
Conversation
`_connect_ws` opens the socket without `heartbeat`, and aiohttp defaults it to `None`, so nothing probes the connection. `recv_task` then has no liveness bound: it does read with a 5s timeout, but the `asyncio.TimeoutError` branch only leaves the loop when `closing_ws` marks a graceful shutdown, and otherwise logs every third timeout and continues. So on a half-open connection (no FIN, no RST) the read side waits on a socket that will never speak again, and the only thing that can end the stream is `send_task`'s write eventually failing, which waits on the kernel's TCP timeout -- and nothing writes at all while no audio is being pushed. The retry in `SpeechStream._main_task` is exception driven, so until then it never gets a turn, while the plugin logs "no messages received" every 15s and the session believes STT is live. A ping is the only probe that silence does not suppress: the plugin opts into neither `session_heartbeat` nor `acknowledge_silence`, so the server is quiet while nobody speaks, and bounding the loop on read inactivity instead would recycle healthy connections. `heartbeat=30.0` matches deepgram, soniox, telnyx, xai, simplismart and sarvam, and the v3 endpoint already sees pings from AssemblyAI's own Python SDK, which connects with the websockets library's default keepalive (a ping every 20s, closed if no pong arrives within 20s). Separately, `WSMsgType.ERROR` -- the shape a heartbeat timeout arrives in -- fell through to the `!= TEXT` branch, which logs an unexpected message type and continues. aiohttp feeds exactly one ERROR and then reports CLOSED, so the stream did still end, but through the close: raised as a generic "closed unexpectedly" with status_code 1006 and the reason on `ws.exception()` discarded. Raising from the ERROR frame keeps that reason as the cause and stays retryable. That half is diagnostics, not recovery, and is the same gap livekit#7206 fixed for deepgram v1. Same class as livekit#7206, livekit#7357, livekit#7359, livekit#7364 and livekit#7369; the send side of this plugin was already covered by livekit#6467.
aiohttp returns the error in the ERROR frame's `data` for every ERROR, but for a protocol error (`WebSocketError`, such as a malformed frame) it only sets the close code and never stores the error on `ws.exception()`. Reading `ws.exception()` alone therefore raised with no cause in that case. Read `msg.data` first, fall back to `ws.exception()`, and include the close code in the log and the error. Heartbeat timeouts and other transport errors behave as before and stay retryable.
Member
|
Thanks @devjoinedthechat for this! #8123 added a built-in liveness monitor to AssemblyAI STT, which covers this, so closing. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
livekit-plugins-assemblyaiopens its STT socket withoutheartbeat, so the read side has no liveness bound on a half-open connection (no FIN, no RST). Same class as #7206 (Deepgram v1), #7357 (Soniox), #7359 (Telnyx), #7364 (xAI) and #7369 (Simplismart). The send side of this plugin was already covered by #6467, so this is only the read side.The gap
_connect_wspasses onlyheaders, and aiohttp defaultsheartbeattoNone, so nothing probes the connection.Unlike the plugins above,
recv_taskhere does not park — it reads withasyncio.wait_for(ws.receive(), timeout=5). But theasyncio.TimeoutErrorbranch leaves the loop only whenclosing_wsmarks a graceful shutdown; otherwise it increments a counter, logs every third timeout, and continues. There is no other exit, and the retry inSpeechStream._main_taskonly runs when_run()raises anAPIError(stt/stt.py:471-477).So on a dead socket the only thing that can end the stream is
send_task's write failing, which waits on the kernel's TCP timeout — and nothing writes at all while no audio is being pushed. Meanwhile the plugin logsno messages receivedevery 15s and the session goes on believing STT is live. The stall is visible in the log but never acted on.Why a heartbeat and not a read-inactivity bound
The counter is already there, so giving up after N timeouts looks like the smaller change. It isn't safe: the plugin opts into neither
session_heartbeatnoracknowledge_silence, so the server is quiet while nobody speaks, read inactivity is indistinguishable from a quiet caller, and a plugin-side give-up would recycle healthy connections. A ping is the only liveness probe silence does not suppress.heartbeat=30.0is parity withdeepgram,soniox,telnyx,xai,simplismartandsarvam.Turning on
session_heartbeatand bounding read inactivity would also work, but it changes the session parameters sent to AssemblyAI and adds a message type for the plugin to handle. The transport ping needs neither.The v3 endpoint already answers pings in normal use: AssemblyAI's own Python SDK connects with the
websocketslibrary's default keepalive, a ping every 20s and a close if no pong arrives within 20s (AsyncRealTimeTranscriberpasses only headers; the threading client has had the same keepalive since websockets 15.0; keepalive defaults). Both connect to the samewss://streaming.assemblyai.com/v3/wsas this plugin. I have no AssemblyAI key, so this rests on the SDK's behaviour rather than a live run.Second, smaller change: keep the reason
WSMsgType.ERROR— the shape a heartbeat timeout arrives in — fell through to themsg.type != aiohttp.WSMsgType.TEXTbranch, which logs an unexpected message type and continues.I want to be precise that this half is diagnostics, not recovery. aiohttp feeds exactly one ERROR and then reports CLOSED for every later read (
_handle_ping_pong_exception→_set_closed, thenreceive()returnsWS_CLOSED_MESSAGE), so the stream did still end — through the close, raised as a genericAssemblyAI connection closed unexpectedlywithstatus_code=1006and the reason onws.exception()discarded. Running the new test against the parent commit shows exactly that:Raising from the ERROR frame keeps
ws.exception()as the cause and stays retryable, so_main_taskstill reconnects and the error says why. Same gap #7206 fixed for Deepgram v1.raiserather thanbreakis deliberate:_runawaitsasyncio.gather(send_task, recv_task)andsend_tasksits onself._input_ch, so arecv_taskthat returned normally would leavegatherwaiting. It also matches what this file already does on the neighbouring paths —APIStatusErrorfor an unexpected close,APIConnectionErrorfromsend_task.The existing timeout warnings stay; they still separate "no audio reaching us" from "nothing coming back", which a heartbeat does not answer.
Tests
Two regression tests in
tests/test_plugin_assemblyai_stt.py, both failing on the parent commit:test_socket_is_opened_with_a_heartbeat— records the kwargs_connect_wspasses; on the parent they are['headers']andheartbeatisNone.test_error_frame_surfaces_the_transport_cause— drives_runagainst a socket that mirrors aiohttp's real sequence, one ERROR then CLOSED. On the parent it raises the bare-1006APIStatusErrorabove; with this change theServerTimeoutErroris the raised error's__cause__.uv run pytest tests/test_plugin_assemblyai_stt.py --plugin assemblyai→ 122 passed.make check(ruff format, ruff, mypy strict over 685 files) clean.uv run pytest --unit --audio_eotgives the same result on this branch and onmain: 3609 passed, 5 skipped, 9 errors.Per
REVIEW.md, the new log line keeps sensitive values out:session=is a generated correlation id, and the%ris a transport exception on an already-established connection —recv_taskonly runs after_connect_wsreturned, so it cannot be a handshake error carrying theAuthorizationheader.