Skip to content

plugins/deepgram: detect a silently dropped STT socket instead of hanging - #2470

Open
GregHolmes wants to merge 3 commits into
livekit:mainfrom
GregHolmes:fix/deepgram-stt-socket-liveness
Open

plugins/deepgram: detect a silently dropped STT socket instead of hanging#2470
GregHolmes wants to merge 3 commits into
livekit:mainfrom
GregHolmes:fix/deepgram-stt-socket-liveness

Conversation

@GregHolmes

Copy link
Copy Markdown

A half-open connection (no FIN, no RST) left both Deepgram STT streams sitting on a dead socket: the session stayed open, no transcripts arrived, and no error was emitted.

Three independent faults each had to be fixed before a drop could recover. I found the second and third only by running it against a live socket; adding the heartbeat alone changed nothing.

1. Nothing noticed the socket was gone

Node's ws never pings on its own, so send() keeps buffering and no close or error is emitted. Adds startWebSocketHeartbeat in _utils.js: ping every 30s, terminate if no pong arrives within 15s. Terminate rather than close, because a close handshake needs a peer that is still listening. Interval and deadline mirror aiohttp's heartbeat=30.0 in the Python plugin.

The KeepAlive v1 already sends is not a substitute. It is an application-level message that stops Deepgram timing the session out; it says nothing about whether the socket is still there.

2. stt.ts never observed its own monitor

Promise.all([sendTask(), listenTask.result, wsMonitor])

Task is not thenable, so Promise.all resolved the bare object instantly and wsMonitor's rejection was never seen. The retry in run() was unreachable for any socket failure, not just this one. With the heartbeat added but this line unchanged, the socket was correctly terminated and the stream still never reconnected.

assemblyai, inworld and sarvam all use wsMonitor.result here. xai/src/stt.ts:364 has the same bare-object bug; I have left it alone rather than widen this PR, but you may want to pick it up.

3. stt_v2.ts could not reconnect from a mid-session close at all

#recvTask resolves on close while #sendTask runs until its input ends, so Promise.all never settled and the stream sat there dropping audio. This one needs no half-open case: any clean server close does it. An unexpected close or error now rejects the race, the way v1's wsMonitor does, with #closingWs keeping our own CloseStream from tripping it.

Also

stt.ts cleared its keepalive interval only on the success path, leaking one interval per reconnect. Teardown now runs in a finally.

tts.ts is left alone: its recv already has an idle timeout.

Verification

Run against api.deepgram.com through a proxy that holds both sockets open and stops delivering, so the client's writes keep succeeding and nothing comes back:

v1 before v1 after v2 before v2 after
sockets opened 1 2 1 2
transcripts after the break 0 1 0 1
outcome silent for the whole window detected in 30s, recovered silent for the whole window detected in 30s, recovered

Four unit tests cover the heartbeat helper and fail without it. The three existing .test.ts files in this plugin do not run in my environment, before or after this change, because they import other unbuilt workspace plugins.

This is the JS counterpart to the same fix on the Python side, livekit/agents#7206.

🤖 Generated with Claude Code

…ging

A half-open connection (no FIN, no RST) left both STT streams sitting on a
dead socket: the session stayed open, no transcripts arrived, and no error
was emitted. Verified against api.deepgram.com through a proxy that holds
both sockets open and stops delivering. Before, on both streams: one
socket, no reconnect, silent for the full window. After: detected in 30s
and transcribing again within seconds.

Three independent faults had to be fixed before a drop could recover.

1. Nothing ever noticed. Node's `ws` never pings on its own, so `send()`
   just keeps buffering and no `close` or `error` is emitted. Adds
   `startWebSocketHeartbeat` in `_utils.js`: ping every 30s, terminate if
   no pong within 15s. Terminate rather than close, since a close
   handshake needs a peer that is still listening. Interval and deadline
   mirror aiohttp's `heartbeat=30.0` in the Python plugin.

   The `KeepAlive` v1 already sends is not a substitute: it is an
   application-level message that stops Deepgram timing the session out,
   but it cannot tell us whether the socket is still there.

2. `stt.ts` passed `wsMonitor` to `Promise.all`, not `wsMonitor.result`.
   `Task` is not thenable, so `Promise.all` resolved the object instantly
   and the monitor's rejection was never observed. The retry in `run()`
   was unreachable for any socket failure. assemblyai, inworld and sarvam
   all use `.result` here; xai has the same bare-object bug, untouched in
   this PR.

3. `stt_v2.ts` could not reconnect from a mid-session close at all.
   `#recvTask` resolves on close while `#sendTask` runs until its input
   ends, so `Promise.all` never settled and the stream sat there dropping
   audio. This needed no half-open case: any clean server close did it.
   An unexpected close or error now rejects the race, as v1's wsMonitor
   does, and `#closingWs` keeps our own CloseStream from tripping it.

Also fixes `stt.ts` clearing its keepalive interval only on the success
path, leaking one per reconnect. Teardown now runs in a `finally`.

`tts.ts` is left alone: its recv already has an idle timeout.

Four tests for the heartbeat helper, which fail without it.
@GregHolmes
GregHolmes requested a review from a team as a code owner September 10, 2026 10:41
@changeset-bot

changeset-bot Bot commented Sep 10, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 5e2dccb

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 38 packages
Name Type
@livekit/agents-plugin-deepgram Patch
@livekit/agents Patch
@livekit/agents-plugin-anam Patch
@livekit/agents-plugin-anthropic Patch
@livekit/agents-plugin-assemblyai Patch
@livekit/agents-plugin-azure Patch
@livekit/agents-plugin-baseten Patch
@livekit/agents-plugin-bey Patch
@livekit/agents-plugin-cartesia Patch
@livekit/agents-plugin-cerebras Patch
@livekit/agents-plugin-did Patch
@livekit/agents-plugin-elevenlabs Patch
@livekit/agents-plugin-fishaudio Patch
@livekit/agents-plugin-google Patch
@livekit/agents-plugin-hume Patch
@livekit/agents-plugin-inworld Patch
@livekit/agents-plugin-krisp Patch
@livekit/agents-plugin-lemonslice Patch
@livekit/agents-plugin-liveavatar Patch
@livekit/agents-plugin-livekit Patch
@livekit/agents-plugin-minimax Patch
@livekit/agents-plugin-mistral Patch
@livekit/agents-plugin-mistralai Patch
@livekit/agents-plugin-neuphonic Patch
@livekit/agents-plugin-openai Patch
@livekit/agents-plugin-perplexity Patch
@livekit/agents-plugin-phonic Patch
@livekit/agents-plugin-protoface Patch
@livekit/agents-plugin-resemble Patch
@livekit/agents-plugin-rime Patch
@livekit/agents-plugin-runway Patch
@livekit/agents-plugin-sarvam Patch
@livekit/agents-plugin-silero Patch
@livekit/agents-plugin-soniox Patch
@livekit/agents-plugin-tavus Patch
@livekit/agents-plugins-test Patch
@livekit/agents-plugin-trugen Patch
@livekit/agents-plugin-xai Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

devin-ai-integration[bot]

This comment was marked as resolved.

Promise.race observes the first settlement but never cancels the losers, so
a sender torn down with the socket stayed parked on input.next(). An
abandoned read is not inert: it resumes on the next put, shifts the frame
off the queue and hands it to a promise nobody awaits. Once reconnects
actually happen, every attempt left another consumer behind, stealing audio
from the live one.

AsyncIterableQueue.next already accepts an AbortSignal, so each attempt now
gets its own AbortController, the send loop reads through it, and teardown
aborts it and awaits the sender before the next socket opens. Also guards
the v1 CloseStream write behind a readyState check, since that path can now
run against a socket that is already gone.

Two tests pin the mechanism: an abandoned read steals the next item, a
cancelled one does not.

Re-verified end to end: v1 and v2 both still detect a blackholed socket in
30s and resume transcribing.
@GregHolmes
GregHolmes force-pushed the fix/deepgram-stt-socket-liveness branch from 71e8524 to c09fb44 Compare September 10, 2026 11:14
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