Skip to content

fix(stt): notice a half-open websocket in the gladia, nabrah, sarvam and slng streams - #7914

Open
Rehansanjay wants to merge 2 commits into
livekit:mainfrom
Rehansanjay:fix/stt-halfopen-remaining
Open

Rehansanjay wants to merge 2 commits into
livekit:mainfrom
Rehansanjay:fix/stt-halfopen-remaining

Conversation

@Rehansanjay

@Rehansanjay Rehansanjay commented Oct 18, 2026 •

Copy link
Copy Markdown
Contributor

Follow-up to #7913. That PR closed this hole in the three plugins #7900 names; I then audited every streaming STT plugin for the same thing, and these four are the rest of it.

The bug, same as #7900

aiohttp defaults heartbeat to None, so a socket that dies without a FIN/RST leaves the receive side parked on ws.receive() forever. Nothing raises, the stream never reconnects, and stt.FallbackAdapter never switches. The agent stops hearing the user with no error anywhere.

The audit

plugin state on main this PR
gladia streaming connect unbounded heartbeat=30.0
nabrah unbounded heartbeat=30.0
sarvam (stt.py) unbounded heartbeat=30.0
slng unbounded, and the ERROR frame swallowed heartbeat=30.0 + raise on ERROR
gladia (streaming receive loop) the ERROR frame swallowed raise on ERROR
baseten, fireworksai, gradium bounded by asyncio.wait_for(ws.receive(), timeout=5) untouched
deepgram, elevenlabs, inworld, meta, microsoft-ai, openai, simplismart, smallestai, soniox, telnyx, xai already pass a heartbeat untouched
assemblyai unbounded left to #7547

Two plugins have more than one websocket, and only one each needed this:

  • gladia's batch connect already passes ClientWSTimeout(ws_receive=..., ws_close=10). Only the streaming connect in _run had no bound.
  • sarvam ships two streaming STTs and exports both. stt_streaming.py's RealtimeSpeechStream already passes a heartbeat; stt.py's SpeechStream, reached through STT, did not.

nabrah and sarvam already raise on an unexpected ERROR frame, so the heartbeat is all they were missing. gladia did not -- see the correction at the end. slng's not TEXT branch continued past it, which is the same hole #7913 found in the other three β€” so it raises now, keeping the cause from ws.exception() and matching the close branch above it.

Tests

nabrah and slng get the test from tests/test_plugin_soniox_stt.py: assert the socket is opened with the heartbeat. Both mutation-verified β€” removing the kwarg fails that plugin's test.

gladia and sarvam are not tested, and I'd rather say so than imply otherwise. Their connects are inline in _run / _run_connection rather than in a _connect_ws method, so they aren't reachable by the patterns this repo uses. Each change is the same kwarg as a bounded sibling connect in the same file. If you want them covered, the way is to lift those connects into methods, which is a bigger diff than the fix β€” happy to do it if you'd prefer that.

One thing worth flagging from the audit: baseten, fireworksai and gradium bound the read with a flat 5-second wait_for on receive(). That does catch a dead socket, but #5717 is about why a read timeout is the wrong instrument for STT, since a quiet user is the normal state. I haven't touched them, but they may be worth a look separately.

Correction after review

I originally wrote that gladia already raises on an unexpected ERROR frame. It does not. Its only ERROR branch is in the batch path, which is also the connect that was already bounded by ClientWSTimeout -- my audit matched that branch by text and reported the plugin as covered. The streaming _recv_messages_task had none, so the frame fell through to "Unexpected message type", the task ended normally, and _run broke instead of reconnecting: the heartbeat would have found the dead socket and the stream would still have died quietly.

Fixed in a follow-up commit with its own test, in a new file because tests/test_plugin_gladia_stt.py is gated behind pytest.mark.plugin("gladia"). I re-checked nabrah and sarvam by enclosing function rather than by text match, and both do raise in their streaming loops as described.

…and slng streams

Follow-up to livekit#7913, which closed this hole in the three plugins livekit#7900 names.
These four are the rest of it.

Without a heartbeat aiohttp never notices a socket that died without a FIN/RST:
the receive side parks on `ws.receive()` forever, nothing reports a failure, and
the stream neither reconnects nor lets a FallbackAdapter move on. The agent just
stops hearing the user.

gladia, nabrah and sarvam already raise on an unexpected ERROR frame, so the
heartbeat is all they were missing. slng swallowed it too -- its `not TEXT`
branch continued past it -- so it also raises now, keeping the cause from
`ws.exception()`, matching the close branch above it.

Two plugins have more than one websocket, and only one each needed this:

- gladia's batch connect already passes `ClientWSTimeout(ws_receive=...,
  ws_close=10)`; the streaming connect in `_run` had no bound.
- sarvam ships two streaming STTs and exports both. `stt_streaming.py`'s
  `RealtimeSpeechStream` already passes a heartbeat; `stt.py`'s `SpeechStream`
  did not.

Not vulnerable, and untouched: baseten, fireworksai and gradium each bound the
read with `asyncio.wait_for(ws.receive(), timeout=5)`; deepgram, elevenlabs,
inworld, meta, microsoft-ai, openai, simplismart, smallestai, soniox, telnyx and
xai already pass a heartbeat. assemblyai is covered by the open livekit#7547.

Tests for nabrah and slng assert the socket is opened with the heartbeat,
following tests/test_plugin_soniox_stt.py, and both fail without it. gladia's
and sarvam's connects are inline in `_run`/`_run_connection` rather than
`_connect_ws` methods, so they are not reachable by the test patterns in this
repo; each is the same kwarg as a bounded sibling connect in the same file.
@Rehansanjay
Rehansanjay requested a review from a team as a code owner October 18, 2026 05:40

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 1 potential issue.

1 flag not posted on this PR by your GitHub settings β€” view it in Devin Review. (Configure)

Devin Review

Comment on lines +885 to +886
session_url,
heartbeat=30.0,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

πŸ”΄ Gladia heartbeat failure ends transcription silently

When Gladia's new heartbeat times out, _recv_messages_task logs the ERROR frame and exits normally after the socket closes. _run then completes without raising, so the fallback adapter cannot switch providers.

Learn more

A heartbeat timeout closes the aiohttp socket and delivers an ERROR frame to the waiting reader. The Gladia receive loop logs that frame without raising and then finishes when the closed socket ends iteration in _recv_messages_task. _run treats normal receive-task completion as the end of the stream. No connection error reaches the base stream or fallback adapter.

Example: The Gladia peer stops answering without sending FIN or RST. After an unanswered ping, the receive task logs the ERROR, finishes, and the stream closes rather than signaling provider failure.

Recommended fix: Handle WSMsgType.ERROR in _recv_messages_task by raising APIConnectionError, using self._ws.exception() as the cause when available. Add a test that triggers an ERROR frame and verifies _run propagates a connection failure.

Devin Review


Was this helpful? React with πŸ‘ or πŸ‘Ž to provide feedback.

I claimed in the PR that gladia already raises on an unexpected ERROR frame. It
does not. Its only ERROR branch is in the batch path, which is also the connect
that was already bounded by ClientWSTimeout -- my scan matched that one and
reported the plugin as covered.

The streaming `_recv_messages_task` had no ERROR branch at all: the frame fell to
`logger.warning("Unexpected message type")`, the task ended normally, `_run` broke
out instead of reconnecting, and the base stream only reports a failure when
`_run` raises. So the heartbeat this PR adds would have detected the dead socket
and the stream would still have died quietly.

It now raises, keeping the cause from `ws.exception()`, matching nabrah and sarvam
-- both of which I re-checked, and whose streaming loops do raise as claimed.

Test in a new file rather than tests/test_plugin_gladia_stt.py, which is gated
behind `pytest.mark.plugin("gladia")` and would skip by default.

Raised by the automated review on the PR.
@Rehansanjay

Copy link
Copy Markdown
Contributor Author

Right, and it caught a false claim in my PR description. Fixed.

I wrote that gladia already raises on an unexpected ERROR frame. It does not. Its only ERROR branch is at stt.py:415, in the batch path β€” which is also the connect that was already bounded by ClientWSTimeout, so it was never the one at issue. My audit scan matched that branch and reported the plugin as covered. The streaming _recv_messages_task had no ERROR branch at all: the frame fell through to logger.warning("Unexpected message type"), the task ended normally, _run broke out instead of reconnecting, and the base stream only reports a failure when _run raises. So the heartbeat would have detected the dead socket and the stream would still have died quietly β€” the same hole #7913 found in inworld.

It raises now, keeping the cause from ws.exception(). Test in a new file, since tests/test_plugin_gladia_stt.py is gated behind pytest.mark.plugin("gladia") and would skip by default. Mutation-verified: DID NOT RAISE APIConnectionError without it.

I re-checked the other two the same way, by the enclosing function rather than by a text match:

  • nabrah β€” _recv_task, raises self._stream_failure(...) on ERROR βœ“
  • sarvam β€” _process_messages, raises APIConnectionError on ERROR βœ“

So the claim held for those two and only gladia was wrong. Updating the PR description to match.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant