Skip to content

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

Merged
smoghe-bw merged 9 commits into
mainfrom
fix/graceful-ice-and-signaling-reconnect
Sep 23, 2026
Merged

smoghe-bw merged 9 commits into
mainfrom
fix/graceful-ice-and-signaling-reconnect

Conversation

@smoghe-bw

@smoghe-bw smoghe-bw commented Sep 8, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Part of VAPI-4011. Makes the SDK survive a signaling reconnect and a publish-peer ICE failure.

Reconnect

  • rpc-websockets reconnects with a new WebSocket and fires open again. signaling.ts now passes isReconnect on the init event and registers beforeunload only once.
  • On a reconnect, init() resets the subscribe SDP revision and the pending track metadata, rebuilds both peer connections, and republishes every published stream. Without the revision reset, the new gateway handler's first subscribe offers (revision 1, 2, …) were dropped as "outdated".
  • The two onconnectionstatechange handlers are merged. The disconnected log was dead code because the second assignment overwrote the first.

ICE restart: the gateway owns it

  • RETRY_ICE_ON_FAILED stays false. The gateway rejects client offers until its peer is connected, so a client retry on a failed peer cannot succeed. The retry code is kept, runs on the publish peer only, catches offer errors, and stops once init() has replaced the connection.
  • The gateway sends its ICE-restart offer as an sdpOffer notification with peerType: "publish". Before this PR the SDK applied every sdpOffer to the subscribing connection. Now handleSdpOffer sends publish offers to the publishing connection, under publishMutex, and answers with answerSdp(sdp, "publish"). A missing peerType still means subscribe, for older gateways.
  • A separate publish SDP revision guards against out-of-date offers. It resets on every init(), because every new gateway connection starts its counter at 0.

Test plan

🤖 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
smoghe-bw and others added 2 commits September 9, 2026 14:34
… 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>
@smoghe-bw smoghe-bw changed the title VAPI-3917 fix(signaling): rebuild peer connections and re-publish media on signaling reconnect VAPI-3979 fix(signaling): rebuild peer connections and re-publish media on signaling reconnect Sep 17, 2026
Main's VAPI-3929 (republish-on-reconnect) landed in the meantime with its
own republishStreams() - a more complete version of this branch's manual
re-publish logic (it also retains codecPreferences and reacquires ended
tracks). Reconciled by keeping this branch's explicit isReconnect signal
(passed from signaling.ts's reconnect detection) and using it to gate
main's republishStreams() instead of the old size-based heuristic, then
dropped this branch's now-redundant manual re-publish block.

Updated the reconnect-replay tests accordingly to pass isReconnect=true,
since main's tests were written against the "any re-init with published
streams" heuristic rather than an explicit reconnect signal.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
smoghe-bw pushed a commit that referenced this pull request Sep 23, 2026
Keeps the test valid once init() republishes only on a reconnect (#18).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@smoghe-bw smoghe-bw changed the title VAPI-3979 fix(signaling): rebuild peer connections and re-publish media on signaling reconnect VAPI-4011 fix(signaling): rebuild peer connections and re-publish media on signaling reconnect Sep 23, 2026
… gateway

- Disable the client ICE-restart retry again: the gateway rejects client
  offers until its peer is connected, so a retry on a failed peer can
  never succeed, and the gateway runs its own ICE restart.
- Apply "publish" sdpOffer notifications (the gateway's ICE-restart
  offer) to the publishing connection under publishMutex and answer them
  as "publish". Before this every sdpOffer went to the subscribing
  connection. A missing peerType still means subscribe.
- Track a separate publish SDP revision and reset it on every init().
- Stop a stale retry loop once init() has replaced the publishing
  connection.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
smoghe-bw added a commit that referenced this pull request Sep 23, 2026
…isconnect (#21)

* fix(v1): harden unpublish against races, unknown ids, and disconnect

- Run unpublish's transceiver cleanup and renegotiation under one
  publishMutex section; do the same for publish's attach + negotiate.
- unpublish with ids that match no published stream is now a no-op
  instead of unpublishing every stream.
- Stop local tracks first, wait for the publish peer to be connected,
  and reject with a clear error if renegotiation still fails.
- unpublish after disconnect stops tracks locally instead of throwing.
- republishStreams skips streams unpublished mid-reconnect and stops
  any tracks it reacquired for them.
- AudioLevelDetector gains stop(); unpublish releases its AudioContext
  and sampling interval.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* test(v1): pass isReconnect in the unpublish-during-reacquire test

Keeps the test valid once init() republishes only on a reconnect (#18).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(v1): release publishMutex while unpublish waits for the publish peer

The wait can last 10 s and would otherwise block publish() and gateway ICE-restart offers.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: smoghe-bw <smoghe-bw>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@smoghe-bw
smoghe-bw merged commit dc6d3e9 into main Sep 23, 2026
5 checks passed
@smoghe-bw
smoghe-bw deleted the fix/graceful-ice-and-signaling-reconnect branch September 23, 2026 20:45
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.

3 participants