VAPI-3929: republish retained streams after a websocket reconnect - #20
Open
stampercasey wants to merge 1 commit into
Open
VAPI-3929: republish retained streams after a websocket reconnect#20stampercasey wants to merge 1 commit into
stampercasey wants to merge 1 commit into
Conversation
When the gateway closes a device's websocket (deploy drain, instance shutdown, media-server loss, heartbeat death) the SDK auto-reconnects and re-emits "init", which builds a fresh, trackless publishing peer connection. Nothing replayed the already-published local streams onto it, so the session came back fully connected but silent: the gateway never saw RTP, never populated publishedTracks, and every subsequent requestOutboundConnection was rejected as "endpoint not eligible". Retain each published stream's codec preferences and acquisition constraints, and on reconnect re-attach every retained stream to the new peer connection followed by a single renegotiation. Tracks that ended while the websocket was down are re-acquired first: an ended track attaches happily and produces valid-looking SDP, but its sender never emits RTP, which leaves the endpoint stuck in exactly the same way. On a first connect nothing is retained and the replay is a no-op. Failures that leave the session unable to publish are reported through a new onError callback rather than leaving the application believing it is healthy. A fatal handshake error on a reconnect (403/409) is surfaced the same way, since by then the connect() promise it used to reject has long since resolved.
✅ Snyk checks have passed. No issues have been found so far.
💻 Catch issues earlier using the plugins for VS Code, JetBrains IDEs, Visual Studio, and Eclipse. |
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.
Summary
When the gateway closes an endpoint's websocket (deploy-drain eviction, instance shutdown, media-server loss, heartbeat death), the SDK auto-reconnects and re-emits
init, which rebuilds a fresh, trackless publishing peer connection. Nothing replayed the already-published local streams onto it, so the reconnected session came up half-alive: websocket connected, both peer connectionsconnected, SIP parking leg re-established and acked, but no published media.The gateway's eligibility predicate requires at least one published track, populated only when it sees RTP from the client. With no republished track the endpoint never became eligible again and every outbound call request was rejected with
-32098 endpoint not eligible.Observed in production on 2026-09-04: a deploy drain evicted 32 idle endpoints and 32 of 32 never became eligible again, some dead for 18 minutes. Reproduced in lab, where the post-eviction reconnect completes every gateway-driven step and the client-initiated
offerSdpcarrying the mic track never arrives.This restores an unmerged fix (PR #15, closed 2026-08-24, absent from published 0.6.0/0.7.0/0.8.0) rebased onto current
main, and closes three gaps in it.Changes
republishStreams()re-attaches every retained stream to the new publishing peer connection and renegotiates once for all of them. Strict no-op on a first connect.PublishedStreamnow retainscodecPreferences; previously the replay could only re-attach without them, silently changing negotiated codecs after a reconnect.reacquireEndedTracks()re-acquires viagetUserMediausing the retained constraints and swaps the fresh tracks into the sameMediaStream, so the stream id and the object the application holds stay valid.localDtmfSendersis keyed by stream id and guarded with!has(id). Since the replay reuses the id, the guard short-circuited and the map kept theRTCDTMFSenderfrom the closed peer connection, sosendDtmfafter a reconnect went nowhere. The map is cleared before re-attaching.onErrorcallback. The SDK had no error channel at all (there is a standing// TODO: emit this as an error from an EventEmitter), and these failures happen on a signaling event where a throw becomes an unhandled rejection. Fires on republish failure and on a gateway handshake refusal. With no handler registered it logs, so existing applications are unaffected.Signaling now also emits
fatalErroron the fatal-handshake path: during a reconnect the existingreject()targets a long-resolved promise, so the application was left holding a session that would never come back.Test plan
npm test- 86 passed, 86 total;prettier --check .clean;tsc --noEmitcleanfatalErroremitNotes
Part of a three-SDK change implemented to a shared contract; the Swift and Kotlin SDKs get the same semantics. All three keep the application's stream handle valid across a reconnect and expose the same
onErrorshape.🤖 Generated with Claude Code