Skip to content

VAPI-3917 fix(signaling): rebuild peer connections and re-publish media on signaling reconnect - #18

Open
smoghe-bw wants to merge 5 commits into
mainfrom
fix/graceful-ice-and-signaling-reconnect
Open

VAPI-3917 fix(signaling): rebuild peer connections and re-publish media on signaling reconnect#18
smoghe-bw wants to merge 5 commits into
mainfrom
fix/graceful-ice-and-signaling-reconnect

Conversation

@smoghe-bw

Copy link
Copy Markdown
Contributor

The problem

rpc-websockets auto-reconnects on drops (unlimited reconnect), creating a brand-new underlying WebSocket each time against the same JsonRpcClient instance. Every reconnect re-fires open, which re-runs setMediaPreferences and re-emits init (signaling.ts) - not just on the very first connect.

BandwidthRtc.init() reacted to every init the same way: build brand-new RTCPeerConnections for publish/subscribe. On a reconnect this silently dropped all media - the new publishing connection has no tracks, since whatever was published via publish() was only ever added to the old (now orphaned, never closed) connection's transceivers - and leaked the stale peer connections.

Also found and fixed a dead-code bug while in this area: setupNewPeerConnection set onconnectionstatechange to log the disconnected state, but setupPeerConnection immediately overwrote that same handler with a failed-only one right after, so the disconnected log path was unreachable.

The fix

  • signaling.ts: connect() now tracks whether open is the session's first firing or a reconnect, and passes isReconnect through on the init event. The beforeunload listener registration is guarded to only run once, so it no longer re-accumulates on every reconnect either.
  • bandwidthRtc.ts: init(setMediaPreferencesResponse, isReconnect) - on reconnect, closes the stale peer connections, resets subscribe-side bookkeeping (subscribingPeerConnectionSdpRevision, subscribeTrackMetadata, localDtmfSenders), rebuilds both peer connections, then re-adds every currently published MediaStream to the new publishing connection and re-offers so the far end keeps receiving media instead of silence.
  • Merged the two onconnectionstatechange handlers in setupPeerConnection/setupNewPeerConnection into one that handles both disconnected (log only) and failed (existing ICE-restart retry).

Known limitations (called out, not fixed here - scope creep beyond "stop dropping media")

  • Per-stream codecPreferences passed to the original publish() call aren't retained across a reconnect (PublishedStream never stored them), so a reconnect re-adds tracks with default codec preferences.
  • No synthetic onStreamUnavailable fires for pre-reconnect subscribed streams before the fresh ones arrive from the rebuilt subscribing connection - there's no class-level tracking of "currently available subscribed streams" to hang that off today.

Test plan

  • tsc --noEmit clean
  • jest src/v1 - all 65 pass, including:
    • signaling.test.ts: init now emits isReconnect=false on the first open and true on subsequent opens
    • bandwidthRtc.test.ts (init on signaling reconnect, new): first init doesn't touch old peer connections or re-publish; a reconnect closes stale connections, resets subscribe state, and re-publishes existing streams; a reconnect with nothing published doesn't re-offer

🤖 Generated with Claude Code

…aling reconnect

The websocket client auto-reconnects on drops (rpc-websockets, unlimited
reconnect), and every reconnect re-fires "open" - re-running
setMediaPreferences and re-emitting "init". BandwidthRtc.init() reacted to
that by building brand-new RTCPeerConnections every time, without closing
the stale ones or re-adding any already-published MediaStream tracks. A
signaling reconnect therefore silently dropped all media and orphaned the
old peer connections, even though the underlying connection is meant to
resume the same session.

signaling.ts now tracks whether an "open" is the first one or a reconnect
and passes that through on the "init" event. bandwidthRtc.ts's init()
closes the stale peer connections and resets subscribe-side bookkeeping on
a reconnect, then re-adds every currently published stream to the rebuilt
publishing connection and re-offers, so the far end keeps receiving media
instead of silence.

Also fixes a dead-code bug in setupPeerConnection/setupNewPeerConnection:
the "disconnected" connection-state handler was being immediately
overwritten by the "failed" handler set right after it, so the
disconnected-state log could never fire. Merged into one handler.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@smoghe-bw
smoghe-bw requested review from a team as code owners September 8, 2026 15:10
@bwappsec

bwappsec commented Sep 8, 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.

smoghe-bw and others added 3 commits September 8, 2026 16:24
Every handler in setupNewPeerConnection (and the merged
onconnectionstatechange handler in setupPeerConnection) caught errors but
only logged them when globalThis.window was set, silently discarding them
in any non-browser environment. logger.warn has no browser dependency
(just console + EventEmitter), so there was nothing this guard was
protecting against - it just meant every error here vanished with zero
trace outside a browser. Log unconditionally.

Also switched retryOffer in retryIceOnFailed to an async/await function
with braces instead of an implicit-return arrow expression, matching the
project's style.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
requestOutboundConnection, hangupConnection, acceptStream, declineStream,
offerSdp, and answerSdp only logged the outgoing call. If the gateway
rejected the RPC, the rejection propagated with no trace in this SDK's own
logs - silent unless the calling application happened to catch and log it
itself. Log a warning on rejection and rethrow, so callers still see the
same rejected promise.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@smoghe-bw smoghe-bw changed the title fix(signaling): rebuild peer connections and re-publish media on signaling reconnect VAPI-3917 fix(signaling): rebuild peer connections and re-publish media on signaling reconnect Sep 9, 2026
… methods

requestOutboundConnection, hangupConnection, acceptStream, declineStream,
offerSdp, and answerSdp were wrapped in async/try-catch+rethrow, out of
scope for this PR (signaling reconnect + ICE restart retry) and a
regression: wrapping a plain `this.ws?.call(...)` passthrough in `async`
means `await undefined` (when `this.ws` is null) resolves silently
instead of leaving the caller with a non-Promise value that blows up
immediately on `.then()`. Reverts to the direct passthrough.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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