Skip to content

VAPI-3929: republish retained streams after a websocket reconnect - #20

Open
stampercasey wants to merge 1 commit into
mainfrom
VAPI-3929-republish-on-reconnect
Open

VAPI-3929: republish retained streams after a websocket reconnect#20
stampercasey wants to merge 1 commit into
mainfrom
VAPI-3929-republish-on-reconnect

Conversation

@stampercasey

Copy link
Copy Markdown

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 connections connected, 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 offerSdp carrying 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

  • Replay on reconnect. 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.
  • Codec preferences survive the replay. PublishedStream now retains codecPreferences; previously the replay could only re-attach without them, silently changing negotiated codecs after a reconnect.
  • Ended tracks are re-acquired. A track that ended while the socket was down (device unplugged, OS revoked the mic) can still be attached and produces a valid-looking SDP offer, but its sender never emits RTP - indistinguishable from never republishing. reacquireEndedTracks() re-acquires via getUserMedia using the retained constraints and swaps the fresh tracks into the same MediaStream, so the stream id and the object the application holds stay valid.
  • DTMF senders are refreshed. localDtmfSenders is keyed by stream id and guarded with !has(id). Since the replay reuses the id, the guard short-circuited and the map kept the RTCDTMFSender from the closed peer connection, so sendDtmf after a reconnect went nowhere. The map is cleared before re-attaching.
  • Failures reach the application. New onError callback. 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 fatalError on the fatal-handshake path: during a reconnect the existing reject() 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 --noEmit clean
  • New coverage: no replay on first connect, replay on reconnect, exactly one renegotiation for three streams, codec preferences preserved, ended-track re-acquisition, no re-acquisition when live, derived constraints for an application-supplied stream, DTMF sender clearing, republish failure surfaced, re-acquisition failure surfaced, signaling fatalError emit
  • On-device confirmation against a real gateway-initiated close

Notes

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 onError shape.

🤖 Generated with Claude Code

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.
@stampercasey
stampercasey requested review from a team as code owners September 9, 2026 18:59
@bwappsec

bwappsec commented Sep 9, 2026

Copy link
Copy Markdown

Snyk checks have passed. No issues have been found so far.

Status Scan Engine Critical High Medium Low Total (0)
Open Source Security 0 0 0 0 0 issues
Licenses 0 0 0 0 0 issues
Code Security 0 0 0 0 0 issues

💻 Catch issues earlier using the plugins for VS Code, JetBrains IDEs, Visual Studio, and Eclipse.

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