plugins/deepgram: detect a silently dropped STT socket instead of hanging - #2470
Open
GregHolmes wants to merge 3 commits into
Open
plugins/deepgram: detect a silently dropped STT socket instead of hanging#2470GregHolmes wants to merge 3 commits into
GregHolmes wants to merge 3 commits into
Conversation
…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.
🦋 Changeset detectedLatest commit: 5e2dccb The changes in this PR will be included in the next version bump. This PR includes changesets to release 38 packages
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 |
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
force-pushed
the
fix/deepgram-stt-socket-liveness
branch
from
September 10, 2026 11:14
71e8524 to
c09fb44
Compare
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.
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
wsnever pings on its own, sosend()keeps buffering and nocloseorerroris emitted. AddsstartWebSocketHeartbeatin_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'sheartbeat=30.0in the Python plugin.The
KeepAlivev1 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.tsnever observed its own monitorTaskis not thenable, soPromise.allresolved the bare object instantly andwsMonitor's rejection was never seen. The retry inrun()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,inworldandsarvamall usewsMonitor.resulthere.xai/src/stt.ts:364has 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.tscould not reconnect from a mid-session close at all#recvTaskresolves on close while#sendTaskruns until its input ends, soPromise.allnever 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'swsMonitordoes, with#closingWskeeping our ownCloseStreamfrom tripping it.Also
stt.tscleared its keepalive interval only on the success path, leaking one interval per reconnect. Teardown now runs in afinally.tts.tsis left alone: its recv already has an idle timeout.Verification
Run against
api.deepgram.comthrough a proxy that holds both sockets open and stops delivering, so the client's writes keep succeeding and nothing comes back:Four unit tests cover the heartbeat helper and fail without it. The three existing
.test.tsfiles 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