Skip to content

fix(stt): notice a half-open websocket in the baseten, fireworksai and gradium streams - #7943

Closed
Rehansanjay wants to merge 1 commit into
livekit:mainfrom
Rehansanjay:fix/stt-halfopen-polling-timeout
Closed

Rehansanjay wants to merge 1 commit into
livekit:mainfrom
Rehansanjay:fix/stt-halfopen-polling-timeout

Conversation

@Rehansanjay

Copy link
Copy Markdown
Contributor

Follow-up to #7913 and #7914, and a correction: in #7914 I listed these three as not vulnerable. They are.

What I got wrong

I wrote that baseten, fireworksai and gradium bound the read with asyncio.wait_for(ws.receive(), timeout=5) and were therefore safe. They do call it. The handler is:

except asyncio.TimeoutError:
    if closing_ws:
        break
    continue      # round again, forever

A polling timeout, not a liveness bound. It fires every five seconds, the loop carries on, and a socket that died without a FIN/RST is never escalated. I read the wait_for and never checked what the handler did with it.

Measured rather than argued

I extended @DEVJHAWAR11's probe from #7900 to every streaming STT plugin in the tree. A local server accepts the upgrade and then answers nothing, pings included; each plugin is fed silence and timed until it raises. No API keys, no network.

plugin before after
baseten never — 75s, still waiting 48.1s
fireworksai never — 75s 47.0s
gradium never — 75s 47.0s

For reference, the plugins that already pass a heartbeat: smallestai 46.4s, telnyx 46.9s, deepgram 47.0s. The harness agrees with #7900's number for deepgram — 47.0s against his 45.4s.

The change

heartbeat=30.0 on each connect, matching the eleven plugins that already have it, plus a raising ERROR branch. That second half matters: all three sent an ERROR frame into their not TEXT branch, which logs and continues, so the receive loop would end normally, _run would break instead of reconnecting, and the base stream only reports a failure when _run raises. The heartbeat alone would have detected the dead socket and the stream would still have died quietly — the same hole #7913 found in inworld, openai and elevenlabs.

Tests

One per plugin asserting the socket is opened with the heartbeat, following tests/test_plugin_soniox_stt.py. gradium's goes in its existing file; baseten and fireworksai had no STT test module.

One existing test needed widening rather than fixing: gradium's fake session declared ws_connect(self, url, headers) positionally and now sees the new kwarg.

The before/after measurement above is the real verification — it exercises the whole receive path against a genuinely half-open socket, which a unit test cannot.

Two things the measurement found that are not this PR's business

soniox and simplismart detect the dead socket and never surface it. Soniox's own log:

 5001ms  connection established
51278ms  error frame: ServerTimeoutError('No PONG received after 15.0 seconds')
51279ms  WebSocket closed; requesting reconnect
51282ms  connection established     <- straight back into the dead state

It detects at ~46s, reconnects, and repeats indefinitely. conn_options.max_retry=0 does not bound it, because that is the plugin's own reconnect loop rather than the base class's retry — so a FallbackAdapter is never told and the caller stays deaf. simplismart logs "connection lost" at ~50.6s and likewise never raises. Whether an unbounded internal reconnect should ever surface failure reads as a policy question for a maintainer, so I have left both alone.

Six plugins could not be measured, and I would rather report that than guess: meta's constructor requires an absolute wss://; slng's endpoint validation rejected every local shape I tried; sarvam's session handshake answers 403 against my local server; microsoft-ai, nabrah and xai expose no endpoint override at all.

…d gradium streams

Follow-up to livekit#7913 and livekit#7914. In livekit#7914 I claimed these three were already safe
because they bound the read with `asyncio.wait_for(ws.receive(), timeout=5)`.
They do call it, but the handler is:

    except asyncio.TimeoutError:
        if closing_ws:
            break
        continue

That is a polling timeout, not a liveness bound: it fires every five seconds,
the loop carries on, and a socket that died without a FIN/RST is never
escalated. I read the `wait_for` and never checked what the handler did with it.

Measured, rather than argued. livekit#7900's probe extended to every streaming STT
plugin, against a local server that accepts the upgrade and then answers
nothing, pings included:

    before            after
    baseten      75s (never)   48.1s
    fireworksai  75s (never)   47.0s
    gradium      75s (never)   47.0s

against 46.4s / 46.9s / 47.0s for smallestai, telnyx and deepgram, which already
pass a heartbeat. The harness agrees with livekit#7900 on deepgram: 47.0s against his
45.4s.

Each gets `heartbeat=30.0`, matching the eleven plugins that already have it, and
a raising ERROR branch -- their `not TEXT` branches logged the frame and
continued, which is the hole livekit#7913 found elsewhere, so the heartbeat alone would
have detected the dead socket and still died quietly.

One existing test needed widening: gradium's fake session took `ws_connect(self,
url, headers)` positionally and now sees the new kwarg.
@Rehansanjay
Rehansanjay requested a review from a team as a code owner October 19, 2026 06:08

@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: 1 flag

Not posted on this PR by your GitHub settings — view it in Devin Review. (Configure)

Devin Review

@Rehansanjay

Copy link
Copy Markdown
Contributor Author

The measurement behind this is now a repo rather than a number in a comment: https://github.com/Rehansanjay/livekit-stt-halfopen

It is @DEVJHAWAR11's probe from #7900 extended to all nineteen streaming STT plugins, with the harness, the per-plugin construction table and the raw results. Against d4cc4a941: three plugins fail over in ~46-47s, two detect the dead socket and never propagate it, eight never notice inside a 75-second watch, and six cannot be pointed at a local server from their public constructors — those are listed with the reason rather than guessed at.

The mechanism turns out to be one parameter. Every plugin that raised passes heartbeat= to ws_connect; every plugin that never noticed does not; and the two that detect-but-stay-silent pass one and then log the ERROR frame instead of raising it. The heartbeat column is read with a paren-balanced parse of each call, after an earlier text-search version of that audit got three plugins wrong.

Re-runnable from any livekit/agents checkout, so the numbers in this PR do not have to be taken on trust.

Copy link
Copy Markdown
Member

Thanks! Please remove the three test_socket_is_opened_with_a_heartbeat tests — they only check a kwarg on a half-built stream with exceptions suppressed; a half-open-socket test like #8023's would be welcome instead.

Copy link
Copy Markdown
Member

Thanks @Rehansanjay for this! #8123 added a built-in liveness monitor to these STT websockets, which covers this, so closing.

@davidzhao davidzhao closed this Oct 26, 2026
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.

2 participants