Skip to content

fix: stop putting the subscriber into restartingIce on a reconnect - #2054

Open
xianshijing-lk wants to merge 2 commits into
mainfrom
sxian/bound-subscriber-ice-restart-window
Open

fix: stop putting the subscriber into restartingIce on a reconnect#2054
xianshijing-lk wants to merge 2 commits into
mainfrom
sxian/bound-subscriber-ice-restart-window

Conversation

@xianshijing-lk

@xianshijing-lk xianshijing-lk commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

Problem

PCTransportManager.triggerIceRestart() put the subscriber into restartingIce on every reconnect:

async triggerIceRestart() {
  if (this.subscriber) {
    this.subscriber.restartingIce = true;   // set here...
  }
  ...
}

but the only thing that clears it is PCTransport.setRemoteDescription() — and the server re-offers the subscriber only when the reconnect actually moved the participant to a different node. On the far more common signal-only resume (WS blip, proxy timeout) no offer arrives, so nothing ever clears it, and addIceCandidate takes the queueing branch for the rest of the session:

if (this.pc.remoteDescription && !this.restartingIce) {
  return this.pc.addIceCandidate(candidate);   // never reached again
}
this.pendingCandidates.push(candidate);        // every candidate, forever

The subscriber therefore stops adopting any new network path the server proposes, and media can stall until some unrelated negotiation happens to flush the queue.

There was a second problem with the same flag. It was set after client.reconnect() had already reopened the signalling link, so if the server's offer arrived first the window opened behind it — withholding the new generation's candidates during the very wait that decides whether the reconnect succeeded, turning a safeguard into a stall.

Fix: don't set it

The flag exists to stop candidates for a new ICE generation being applied against the old remote description. The server does not send them in that order, on either path:

  • Same-node resumeResumeParticipantICERestartcreateAndSendOffer{ICERestart}clearLocalDescriptionSent() sets cacheLocalCandidates, and candidates are only flushed by localDescriptionSent() after OnOffer.
  • Reconnect landing on another node — the participant starts in MigrateStateInit, where onICECandidate drops subscriber candidates outright. That state is left in onSyncState, immediately before Negotiate(false) creates and sends the offer — at which point SetLocalDescription has not run, so gathering has not started and no candidate exists to send.

Either way the offer precedes the candidates it belongs to, so queueing them protects nothing while causing both defects above.

The publisher keeps its own restartingIce: it is set alongside an offer that will certainly be answered, so setRemoteDescription always clears it. That one is sound and untouched.

Testing

pnpm test — 716 passed.

One test, in PCTransportManager.test.ts: does not stop the subscriber applying remote candidates. It asserts triggerIceRestart() leaves subscriber.restartingIce false, which is exactly the reintroduction this change guards against — verified by re-adding the assignment, which fails it.

Since the change is a removal, there is no new behaviour to cover beyond that guard, and PCTransport.test.ts is byte-identical to main again.

History of this PR

The first commit bounded the window (added a finishRestartingIce and paired it across every exit). That fixed the leak but kept the ordering hazard and still withheld candidates on every signal-only resume. Reading the server settled that the flag has no upside at all, so the second commit removes it instead. The net diff against main is now +47/−4 in src/.

Context

Found while fixing the equivalent bug in the Rust SDK, which had inherited this pattern from here. Both SDKs now leave the subscriber alone across a reconnect.

`triggerIceRestart` set `subscriber.restartingIce = true` on every resume, but
only `setRemoteDescription` cleared it -- and the server re-offers the subscriber
only when the resume actually moved the participant to a different node. After
the far more common signal-only resume no offer arrives, so the flag stayed set
for the lifetime of the transport and `addIceCandidate` queued every subsequent
remote candidate instead of applying it, leaving the subscriber unable to adopt
any new network path the server proposed. Media could then stall until some
unrelated negotiation happened to flush the queue.

The publisher never had this problem because it sets the same flag alongside an
offer it will certainly receive an answer to. The subscriber's is speculative, so
it needs an explicit close.

Split the speculative flag out of `triggerIceRestart` into
`beginSubscriberIceRestart`, so it sits next to the matching
`finishSubscriberIceRestart` in the resume and the pairing is visible at the call
site. `finishRestartingIce` clears the flag and applies whatever queued behind
it; where the server did re-offer, the description has already cleared it and the
call is a no-op. The resume wraps both the ICE restart and the reconnect wait in
try/finally so every exit closes the window, including a publisher offer that
throws before we ever wait.

Candidates queued because no remote description exists yet stay queued -- there
is still nothing to apply them against.

This also drops the flag from the `updateConfiguration(config, iceRestart)` path,
which called `triggerIceRestart` fire-and-forget with no close and so had no way
to bound the window. Nothing currently passes `iceRestart`, so this removes a
trap rather than changing behaviour.

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

changeset-bot Bot commented Aug 17, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: efdbd39

The changes in this PR will be included in the next version bump.

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

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 1 potential issue.

View 1 additional finding in Devin Review.

Open in Devin Review

Comment thread src/room/RTCEngine.ts Outdated
Comment on lines +1481 to +1483
} finally {
this.pcManager.finishSubscriberIceRestart();
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Reconnect cleanup can crash with a type error, hiding the real reconnect failure

The cleanup step that closes the candidate-buffering window assumes the connection manager still exists (this.pcManager.finishSubscriberIceRestart() at src/room/RTCEngine.ts:1482), so if the room is torn down while the reconnect is still waiting, the cleanup itself throws and replaces the real error.
Impact: A disconnect during a reconnect attempt can surface a confusing internal error instead of the actual reconnection failure.

Manager reference can be cleared during the awaited reconnect wait

resumeConnection null-checks this.pcManager at src/room/RTCEngine.ts:1428, but the reference is cleared by cleanup (this.pcManager = undefined at src/room/RTCEngine.ts:461) which can run while waitForPCReconnected() is awaited. The surrounding code acknowledges this: waitForPCReconnected re-checks if (!this.pcManager) at src/room/RTCEngine.ts:1513. Because the new finally block dereferences the field unconditionally, a TypeError thrown there supersedes whatever error the try body was propagating. Using optional chaining avoids it.

Suggested change
} finally {
this.pcManager.finishSubscriberIceRestart();
}
} finally {
this.pcManager?.finishSubscriberIceRestart();
}
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

@github-actions

github-actions Bot commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

size-limit report 📦

Path Size
dist/livekit-client.esm.mjs 105.54 KB (-0.01% 🔽)
dist/livekit-client.umd.js 114.63 KB (-0.02% 🔽)

Supersedes the bounding approach in this PR's first commit. Tracing the server
shows the flag guards a sequence it cannot produce, so removal is both simpler
and safer than managing the window.

On a same-node resume the server ICE-restarts the subscriber via
`clearLocalDescriptionSent`, which buffers its local candidates until after the
offer has been sent. On a reconnect that lands on another node the participant
starts in `MigrateStateInit`, where subscriber candidates are dropped outright,
and that state is only left immediately before the offer is created -- at which
point ICE gathering has not started. Either way the offer precedes the candidates
it belongs to, so queueing them protects nothing.

Against that, setting the flag cost two defects. It outlived the reconnect
whenever the server did not re-offer -- the common signal-only resume -- leaving
the subscriber queueing every later candidate for the rest of the session. And
because it was set after the signalling link had already been reopened, an offer
that arrived first would leave the window opening *behind* it, withholding the
new generation's candidates during the very wait that decides whether the
reconnect succeeded.

The publisher keeps its own `restartingIce`: it is set alongside an offer that
will certainly be answered, so `setRemoteDescription` always clears it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@xianshijing-lk xianshijing-lk changed the title fix: bound the subscriber's ICE-restart window to the resume fix: stop putting the subscriber into restartingIce on a reconnect Aug 17, 2026
*/
async triggerIceRestart() {
this.iceLog.warn('triggering ICE restart');
if (this.subscriber) {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What this fixes: after any reconnect, the subscriber silently stops applying
remote ICE candidates for the rest of the session.

triggerIceRestart() set subscriber.restartingIce = true. That flag tells
addIceCandidate to park candidates in pendingCandidates instead of applying
them, and the only code that clears it is setRemoteDescription — which runs on
the subscriber only when the server re-offers it, i.e. only when the reconnect
moved the participant to a different node. After an ordinary signal-only resume
no offer arrives, so the flag stays set for the transport's lifetime and every
later candidate is parked. The subscriber can no longer adopt a new network path
the server proposes (route change, NAT rebind, switch to relay), so media can
stall until some unrelated negotiation happens to flush the queue.

The fix: stop setting it. The queueing existed to stop candidates for a new
ICE generation being applied against the old remote description, but the server
never sends them in that order — a same-node resume buffers its candidates until
after the offer is sent, and a reconnect landing on another node drops subscriber
candidates until immediately before the offer is created. So the flag had no
upside and one permanent downside.

After this change nothing can set subscriber.restartingIce at all: the only
= true is in createAndSendOffer, reachable only for the publisher. The
publisher's own use is sound and untouched — it's set as an offer is sent, and
the answer clears it.

The line worth making sure a reviewer sees is the asymmetry: the publisher sets the flag alongside an offer that will certainly be answered, so it always clears; the subscriber's was set on the expectation of an offer that usually never comes. That's the whole bug in one sentence.

@cnderrauber cnderrauber left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

but the only thing that clears it is PCTransport.setRemoteDescription() — and the server re-offers the subscriber only when the reconnect actually moved the participant to a different node.

Sfu will send offer in the signal-only resume case, does it not arrive in the test?

The flag exists to stop candidates for a new ICE generation being applied against the old remote description. The server does not send them in that order

Agree with the conclusion that sfu will not send candidate before offer since @boks1971 has made an event queue to make sure all these happen in order.

Looks good to me!

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