Skip to content

fix: session migration triggers a full reconnect instead of a resume - #1197

Merged
hiroshihorie merged 4 commits into
mainfrom
sxian/CLT-3322/flutter-session-migration-triggers-a-full-reconnect-insteadOf-resume
Sep 14, 2026
Merged

fix: session migration triggers a full reconnect instead of a resume#1197
hiroshihorie merged 4 commits into
mainfrom
sxian/CLT-3322/flutter-session-migration-triggers-a-full-reconnect-insteadOf-resume

Conversation

@xianshijing-lk

Copy link
Copy Markdown
Contributor

Fixes CLT-3322.

Problem

When the server initiates a node migration, the SDK performs a full reconnect instead of a resume: RoomReconnectingEvent is emitted, every RemoteParticipant is torn down (one ParticipantDisconnectedEvent each), the client re-joins, and RoomConnectedEvent fires again. It should emit RoomResumingEvent and keep the session intact.

Reported by a customer testing Room.sendSimulateScenario(migration: true) on 2.12.0:

Room reconnecting
Participant disconnected: scanner_camera
Participant disconnected: operator_camera
Participant disconnected: additional_camera
...
Room connected: 48263580-7cfd-499d-a45c-02741ba74483

Migrations are routine on Cloud, so every Flutter client sees its remote participants disappear and re-join, subscriptions rebuilt, and per-participant UI state lost.

Root cause

For a migration the server sends LeaveRequest{Action: RESUME, Reason: MIGRATION} (livekit/pkg/rtc/participant.go, MaybeStartMigration) and then closes the signal socket. RESUME means: reconnect with reconnect=1, keep the session.

Engine's leave handler did the right thing — cleared fullReconnectOnNext and called handleReconnect(ClientDisconnectReason.leaveReconnect). But attemptReconnect immediately re-set the flag:

if (_clientConfiguration?.resumeConnection == DISABLED ||
    [ClientDisconnectReason.leaveReconnect,   // <- always true for a leave-driven reconnect
     ClientDisconnectReason.negotiationFailed,
     ClientDisconnectReason.peerConnectionFailed].contains(reason)) {
  fullReconnectOnNext = true;
}

That list predates protocol v13 (added in #439, when a leave with can_reconnect could only mean a full reconnect). The v13 RESUME branch was ported from client-sdk-js in #574 but the escalation was never updated — so the resume branch has been dead code ever since and every RESUME leave ended up in restartConnection().

Neither reference SDK behaves this way:

  • client-sdk-jsRTCEngine.attemptReconnect escalates only for resumeConnection === DISABLED or a never-connected PeerConnection.
  • rust-sdkson_session_event routes Action::Resume straight into a resume cycle.

Changes

lib/src/core/engine.dart:

  1. Drop leaveReconnect from the escalation list in attemptReconnect. The callers that genuinely need a full reconnect — the RECONNECT leave branch and connection_check/checks/checker.dart — already set fullReconnectOnNext = true themselves.
  2. Stop forcing fullReconnectOnNext = false in the RESUME branch. JS and Rust both treat an escalation as sticky, so a resume that already failed at the media level isn't downgraded back into a resume loop.

test/mock/peerconnection_mock.dart: implement setConfiguration (it threw UnimplementedError; the resume path applies the ReconnectResponse ICE servers to both transports).

Tests

New test/core/leave_action_test.dart:

  • RESUME (migration) → RoomResumingEvent, no RoomReconnectingEvent, no ParticipantDisconnectedEvent, remote participants retained, fullReconnectOnNext back to false.
  • RECONNECTRoomReconnectingEvent and participants dropped, as before.

Confirmed the RESUME test fails against the pre-fix code (times out waiting for RoomReconnectedEvent, because the engine re-joins instead of resuming). Full suite (409 tests), flutter analyze, dart format and import_sorter all clean.

Note for app developers

RoomResumingEvent is the Flutter analog of JS's SignalReconnecting; RoomReconnectingEvent means a full reconnect. Both paths end in RoomReconnectedEvent.

Follow-ups (not in this PR)

  • A full-reconnect request arriving while a reconnect attempt is in flight is dropped: attemptReconnect early-returns on _attemptingReconnect, and the successful attempt's _clearPendingReconnect() cancels the queued retry, leaving fullReconnectOnNext stale-true (which also suppresses the next legitimate RoomDisconnectedEvent). JS consumes the flag at attempt start and re-dispatches in finally; that can't be copied verbatim here because Room reads engine.fullReconnectOnNext during the restart's join to skip fast-connect republishing.
  • Flutter emits RoomConnectedEvent again on a full reconnect (driven off EngineJoinResponseEvent); JS only emits Reconnected. Changing that is a public-behavior change.

🤖 Generated with Claude Code

…ting

For a node migration the server sends `LeaveRequest{Action: RESUME,
Reason: MIGRATION}`, which asks the client to reconnect with `reconnect=1`
and keep its session. The engine's leave handler did exactly that, but
`attemptReconnect` then unconditionally escalated any `leaveReconnect`
into a full reconnect:

    if (... || [ClientDisconnectReason.leaveReconnect, ...].contains(reason)) {
      fullReconnectOnNext = true;
    }

That list predates protocol v13 (#439), when a leave with `can_reconnect`
could only mean a full reconnect. The v13 RESUME branch ported in #574
never updated it, so the resume branch has been dead code since: every
RESUME leave ran `restartConnection()`, emitting `RoomReconnectingEvent`,
dropping every `RemoteParticipant` and re-joining.

Drop `leaveReconnect` from the escalation list — the callers that do need
a full reconnect (the RECONNECT leave branch, the connection check) set
`fullReconnectOnNext` themselves. Also stop forcing the flag to false in
the RESUME branch: client-sdk-js and rust-sdks both treat an escalation as
sticky, so a resume that already failed at the media level is not
downgraded back into a resume loop.

Adds `test/core/leave_action_test.dart` covering both leave actions, and
implements `setConfiguration` on the mock peer connection (the resume path
applies the `ReconnectResponse` ICE servers).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
devin-ai-integration[bot]

This comment was marked as resolved.

`reconnect=1` is the query parameter the server actually keys off to
distinguish a resume from a re-join, and it was the only part of the
resume contract the test wasn't checking.

Also documents why the socket close that follows the Leave is not
simulated: a bare socket drop reconnects with reason `signal`, which
resumes on its own, so delivering the close before the leave-driven
attempt runs makes the test pass even when the leave action is ignored.
In production the close arrives a round-trip later and never wins that
race, which is why the reported bug reproduced.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
handleReconnect replaces the pending retry timer together with its reason,
and the reason based escalation ran only when that timer fired. A
Leave{RESUME} arriving right after a peer connection failure therefore
swapped the reason to leaveReconnect and the failed connection was resumed
instead of restarted. Now that leaveReconnect no longer escalates on its
own, decide the escalation in handleReconnect so a later request cannot
drop it. The server side resumeConnection switch stays in attemptReconnect
so the latest ClientConfiguration wins.
The RECONNECT test now answers the re-join and waits for
RoomReconnectedEvent instead of tearing down mid restart, and asserts the
signal URL carries no reconnect flag. Add cases for a stale
fullReconnectOnNext, for resumeConnection DISABLED from the server, and for
a Leave{RESUME} racing a pending peer failure retry. The RESUME test also
checks the ReconnectResponse configuration reached both transports.

E2EContainer gains answerJoin() and a clientConfiguration option so tests
can drive a full reconnect and shape the join response.

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Devin Review found 1 new potential issue.

Devin Review

Comment thread lib/src/core/engine.dart
Comment on lines +1076 to +1080
if ([
ClientDisconnectReason.negotiationFailed,
ClientDisconnectReason.peerConnectionFailed,
].contains(reason)) {
fullReconnectOnNext = true;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Successful resume leaves stale escalation

During an active resume, a peer failure makes handleReconnect set fullReconnectOnNext for a retry that can be canceled. attemptReconnect clears the retry after success, but not the flag. A later disconnect is suppressed, or the next resume becomes a full reconnect.

Learn more

A peer connection can report failure while resumeConnection is still restoring ICE. This block records the required full reconnect immediately and schedules a retry. If the active resume then reaches connected state, attemptReconnect cancels that retry but leaves fullReconnectOnNext true. The room subsequently drops an EngineDisconnectedEvent because the disconnect handler treats the flag as an active restart.

Example: A signal reconnect starts, then the primary peer connection briefly reports failed before its ICE restart reaches connected. The resume succeeds and its queued full reconnect is canceled. The next ordinary signal loss emits no RoomDisconnectedEvent; alternatively, a later migration performs an unnecessary full reconnect.

Recommended fix: Track a full-reconnect request separately from the flag consumed by the active attempt. After an attempt succeeds, either dispatch any escalation recorded during that attempt or clear it explicitly; do not cancel its retry while retaining only fullReconnectOnNext. Add a regression test where peerConnectionFailed arrives after _attemptingReconnect becomes true and the active resume subsequently succeeds.

Devin Review

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

@hiroshihorie
hiroshihorie merged commit d245718 into main Sep 14, 2026
15 checks passed
@hiroshihorie
hiroshihorie deleted the sxian/CLT-3322/flutter-session-migration-triggers-a-full-reconnect-insteadOf-resume branch September 14, 2026 07:26
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