Skip to content

Reconcile the participant roster after a signal resume - #2096

Open
1egoman wants to merge 5 commits into
mainfrom
fix/clt-3323-resume-roster-reconciliation
Open

1egoman wants to merge 5 commits into
mainfrom
fix/clt-3323-resume-roster-reconciliation

Conversation

@1egoman

@1egoman 1egoman commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

Warning

This pull request was LLM generated and has only been lightly reviewed by Ryan. @xianshijing-lk had asked I port this fix to the web sdk but I haven't done a deep dive on the specific issue, and beyond running the LLM generated tests have not actually exercised this yet.

A more thorough review of this needs to occur before it could be merged.

The problem

The SDK keeps remote participants that are no longer in the room. This occurs after a signal resume. A node migration or a short signal failure causes a resume.

The server sends a DISCONNECTED update for each participant that leaves. The SDK does not get this update if the signal connection is down at that time. A full reconnect corrects this, because the SDK builds the roster again from the JoinResponse. A resume does not build the roster again. Therefore the participants stay in room.remoteParticipants for an unlimited time.

CLT-3322 changed the migration procedure. A migration now does a resume and not a full reconnect. Before that change, the full reconnect removed all participants and thus hid this defect.

The change

Room keeps a record of each identity in the participant updates during a resume. At the end of the resume, Room removes each remote participant that is not in that record. Room sends a ParticipantDisconnected event for each participant that it removes.

The record is a union of all of the updates in the resume. The server sends the full participant list after the ReconnectResponse. But the server can also send other updates immediately before and after that list. Therefore one update alone is not sufficient.

Room does this procedure before it calls updateSubscriptions() and before it sends the Reconnected event. Thus the SDK does not subscribe again to the tracks of a participant that left. Thus an application also does not see a stale roster in a reconnected room.

Room discards the record if the resume becomes a full reconnect, and also at a disconnect.

This change is equivalent to the mechanism in the Rust SDK (rtc_session.rs::finish_resume and room/mod.rs::reconcile_absent_participants).

The tests

Room.test.ts has six new tests. Three of these tests fail before the change:

  • The SDK removes a participant that left while the signal connection was down.
  • The SDK collects the identities from updates that the server sends at different times in the resume.
  • The SDK sends ParticipantDisconnected before Reconnected.

Three more tests pass before and after the change. These tests show the limits of the new procedure:

  • The SDK keeps a participant that joins during the resume.
  • The SDK removes no participant if there is no resume.
  • The SDK does not use the roster of a resume that became a full reconnect.

subscriberBlackScreen.test.ts simulates a resume in two tests. These simulations did not include a participant update. A server always sends the participant list after a resume. The first commit corrects these two simulations. The assertions stay the same.

Attention for the reviewer

The new procedure has no gate on the server version. If a server does not send the participant list after a resume, the SDK removes all remote participants. The Rust SDK has the same behavior. CLT-3323 records this behavior of the server.

The commits

Read the commits in this sequence:

  1. test(e2ee) — correct the two resume simulations. Read this commit quickly.
  2. fix(room) — 42 lines. This is the full change in behavior. Give your full attention to this commit.
  3. test(room) — the three tests that fail before commit 2.
  4. test(room) — the three tests that show the limits.
  5. chore — the changeset.

Verification

  • npx tsc --noEmit gives no error.
  • npx vitest run --exclude 'smoke-tests/**' gives 818 tests that pass and 1 test that the runner skips. The smoke-tests/ suite does not run, because @playwright/test is not available. This suite also does not run on main.

🤖 Generated with Claude Code

The two tests that simulate a signal resume fake a resume in which the server
sends no participant updates at all, which never happens on the wire: the
server replays the full roster after the ReconnectResponse. Extract the inline
TrackInfo into jakeTrackInfo() and add replayRosterDuringResume() so the
simulation matches the real sequence. No assertions change.
A signal resume — unlike a full reconnect — never rebuilds the roster from a
JoinResponse, and DISCONNECTED updates for participants who left while the
link was down went to a socket we no longer had. Those participants stayed in
room.remoteParticipants indefinitely. Migration now resumes rather than fully
reconnecting, so this is no longer masked by handleRestarting unwinding every
participant.

Track the identities seen in participant updates for the duration of a resume
and, once it settles, synthesize disconnects for any remote participant absent
from that set. The set accumulates a union rather than trusting a single
update as the snapshot: the server replays the roster after the
ReconnectResponse but can interleave batched updates around it, so no one
update is identifiable as the snapshot. Reconciliation runs before
updateSubscriptions() and RoomEvent.Reconnected, so we neither resubscribe to
departed tracks nor let consumers observe a reconnected room with a stale
roster.

Mirrors the mechanism in rust-sdks (rtc_session.rs::finish_resume,
room/mod.rs::reconcile_absent_participants).
Three tests that fail without the reconciliation: a participant that left
while the link was down is removed, identities are accumulated across updates
interleaved during the resume rather than taken from a single snapshot, and
ParticipantDisconnected is emitted before Reconnected.
Three tests that pass with or without the reconciliation, pinning down its
blast radius: participants that join during the resume are kept, a routine
participant update outside a resume evicts nobody, and a resume that escalates
to a full reconnect does not later apply the abandoned resume partial roster.
@changeset-bot

changeset-bot Bot commented Sep 11, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 4deb05b

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

This PR includes changesets to release 1 package
Name Type
livekit-client Patch

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

@1egoman 1egoman changed the title fix(room): reconcile the participant roster after a signal resume Reconcile the participant roster after a signal resume Sep 11, 2026
@github-actions

Copy link
Copy Markdown
Contributor

size-limit report 📦

Path Size
dist/livekit-client.esm.mjs 111.34 KB (+0.15% 🔺)
dist/livekit-client.umd.js 120.49 KB (+0.07% 🔺)

@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.

Devin Review

Comment thread src/room/Room.ts
.on(EngineEvent.Resuming, () => {
this.clearConnectionReconcile();
this.isResuming = true;
this.resumeSeenIdentities = new Set();

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.

🔴 Legacy resumes evict every participant

When an older server omits the roster replay, resumeSeenIdentities stays empty and disconnects every remote participant. Active tracks and application state disappear while those participants remain.

Learn more

A signal resume starts an empty identity set unconditionally. Older LiveKit servers can complete the same resume protocol without sending a full participant update, so an empty set cannot distinguish an empty room from a missing snapshot. The Resumed handler then treats every existing participant as absent and invokes reconcileParticipantsAfterResume, which unpublishes their tracks and emits disconnect events.

Example: Alice and Bob are connected through a server that does not replay participant updates after resume. A brief signal outage completes successfully, but the set remains empty. Alice's SDK disconnects Bob locally even though Bob never left.

Recommended fix: Gate reconciliation on a server capability or minimum server version that guarantees roster replay. If no reliable version boundary exists, add an explicit snapshot-complete signal or carry the authoritative roster in the reconnect response; do not interpret the absence of participant updates as an empty roster.

Devin Review

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

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.

This is a good point, I wonder if (since it sounds like this was a behavior change) this needs to be gated on the SFU version? Or applied wholesale for all SFUs irrespective of version?

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.

1 participant