-
Notifications
You must be signed in to change notification settings - Fork 257
fix: session migration triggers a full reconnect instead of a resume #1197
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
hiroshihorie
merged 4 commits into
main
from
sxian/CLT-3322/flutter-session-migration-triggers-a-full-reconnect-insteadOf-resume
Sep 14, 2026
Merged
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
86ebcb4
fix: resume the session on a migration Leave instead of full reconnec…
xianshijing-lk 53e077d
test: assert the resume re-opens the signal socket with reconnect=1
xianshijing-lk f16c2f2
Decide reconnect escalation when the request is made
hiroshihorie 1471d11
Cover the full reconnect leave paths end to end
hiroshihorie File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| patch type="fixed" "Session migration (server `Leave{action: RESUME}`) now resumes the session instead of escalating to a full reconnect, so remote participants are no longer dropped and re-added" |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,225 @@ | ||
| // Copyright 2026 LiveKit, Inc. | ||
| // | ||
| // Licensed under the Apache License, Version 2.0 (the "License"); | ||
| // you may not use this file except in compliance with the License. | ||
| // You may obtain a copy of the License at | ||
| // | ||
| // http://www.apache.org/licenses/LICENSE-2.0 | ||
| // | ||
| // Unless required by applicable law or agreed to in writing, software | ||
| // distributed under the License is distributed on an "AS IS" BASIS, | ||
| // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
| // See the License for the specific language governing permissions and | ||
| // limitations under the License. | ||
|
|
||
| @Timeout(Duration(seconds: 10)) | ||
| library; | ||
|
|
||
| import 'package:flutter_test/flutter_test.dart'; | ||
|
|
||
| import 'package:livekit_client/livekit_client.dart'; | ||
| import 'package:livekit_client/src/proto/livekit_models.pb.dart' as lk_models; | ||
| import 'package:livekit_client/src/proto/livekit_rtc.pb.dart' as lk_rtc; | ||
| import 'package:livekit_client/src/support/websocket.dart'; | ||
| import 'package:livekit_client/src/types/internal.dart'; | ||
| import '../mock/e2e_container.dart'; | ||
| import '../mock/peerconnection_mock.dart'; | ||
| import '../mock/websocket_mock.dart'; | ||
|
|
||
| void main() { | ||
| TestWidgetsFlutterBinding.ensureInitialized(); | ||
|
|
||
| late E2EContainer container; | ||
| late Room room; | ||
| late MockWebSocketConnector ws; | ||
|
|
||
| setUp(() { | ||
| resetMockDataChannels(); | ||
| container = E2EContainer(); | ||
| room = container.room; | ||
| ws = container.wsConnector; | ||
| }); | ||
|
|
||
| tearDown(() async { | ||
| await container.dispose(); | ||
| }); | ||
|
|
||
| /// Connect and inject one remote participant so the tests can observe | ||
| /// whether the roster survives the reconnect. | ||
| Future<void> connectWithRemoteParticipant({lk_models.ClientConfiguration? clientConfiguration}) async { | ||
| await container.connectRoom(clientConfiguration: clientConfiguration); | ||
| await container.simulateRemoteParticipantJoin('bob'); | ||
| expect(room.remoteParticipants, hasLength(1)); | ||
| } | ||
|
|
||
| /// Feed a server-initiated `LeaveRequest` into the signal connection. | ||
| void sendLeave(lk_rtc.LeaveRequest_Action action, lk_models.DisconnectReason reason) { | ||
| ws.onData( | ||
| lk_rtc.SignalResponse( | ||
| leave: lk_rtc.LeaveRequest(action: action, reason: reason), | ||
| ).writeToBuffer(), | ||
| ); | ||
| } | ||
|
|
||
| /// Wait until the SDK has opened a *new* websocket (the reconnect attempt). | ||
| Future<void> waitForNewSignalConnection(WebSocketEventHandlers? previous) async { | ||
| for (var i = 0; i < 200 && identical(ws.handlers, previous); i++) { | ||
| await Future<void>.delayed(const Duration(milliseconds: 10)); | ||
| } | ||
| expect(identical(ws.handlers, previous), isFalse, reason: 'SDK never re-opened the signal connection'); | ||
| } | ||
|
|
||
| /// Answer a resume attempt the way the receiving node would. | ||
| Future<void> answerResume(WebSocketEventHandlers? previous) async { | ||
| await waitForNewSignalConnection(previous); | ||
| expect( | ||
| ws.uri?.queryParameters['reconnect'], | ||
| '1', | ||
| reason: 'a resume must re-open the signal connection with reconnect=1', | ||
| ); | ||
| ws.onData(lk_rtc.SignalResponse(reconnect: lk_rtc.ReconnectResponse()).writeToBuffer()); | ||
| } | ||
|
|
||
| /// Answer a full reconnect attempt: the SDK re-joins, so it gets a JoinResponse. | ||
| Future<void> answerFullReconnect(WebSocketEventHandlers? previous) async { | ||
| await waitForNewSignalConnection(previous); | ||
| expect( | ||
| ws.uri?.queryParameters.containsKey('reconnect'), | ||
| isFalse, | ||
| reason: 'a full reconnect must re-join without reconnect=1', | ||
| ); | ||
| await container.answerJoin(); | ||
| } | ||
|
|
||
| void expectFullReconnect(List<RoomEvent> roomEvents) { | ||
| expect( | ||
| roomEvents.whereType<RoomReconnectingEvent>(), | ||
| isNotEmpty, | ||
| reason: 'a full reconnect must emit RoomReconnectingEvent', | ||
| ); | ||
| expect(roomEvents.whereType<RoomResumingEvent>(), isEmpty); | ||
| expect(roomEvents.whereType<ParticipantDisconnectedEvent>(), hasLength(1)); | ||
| expect(roomEvents.whereType<RoomReconnectedEvent>(), hasLength(1)); | ||
| expect(room.remoteParticipants, isEmpty); | ||
| expect(container.engine.fullReconnectOnNext, isFalse); | ||
| } | ||
|
|
||
| test('Leave{RESUME} (node migration) resumes and keeps remote participants', () async { | ||
| await connectWithRemoteParticipant(); | ||
|
|
||
| final roomEvents = <RoomEvent>[]; | ||
| final sub = room.events.listen(roomEvents.add); | ||
| final previousHandlers = ws.handlers; | ||
|
|
||
| // The server also drops the socket right after the Leave, but that is | ||
| // deliberately not simulated here: a bare socket drop reconnects with reason | ||
| // `signal`, which resumes on its own. Delivering it before the leave-driven | ||
| // attempt runs (in production it arrives a round-trip later, so it never | ||
| // wins) makes this test pass even when the leave action is ignored entirely. | ||
| sendLeave(lk_rtc.LeaveRequest_Action.RESUME, lk_models.DisconnectReason.MIGRATION); | ||
|
|
||
| await answerResume(previousHandlers); | ||
| await room.events.waitFor<RoomReconnectedEvent>(duration: const Duration(seconds: 5)); | ||
| await sub(); | ||
|
|
||
| expect( | ||
| roomEvents.whereType<RoomResumingEvent>(), | ||
| isNotEmpty, | ||
| reason: 'a migration must resume the session', | ||
| ); | ||
| expect( | ||
| roomEvents.whereType<RoomReconnectingEvent>(), | ||
| isEmpty, | ||
| reason: 'RoomReconnectingEvent signals a full reconnect, which drops session state', | ||
| ); | ||
| expect( | ||
| roomEvents.whereType<ParticipantDisconnectedEvent>(), | ||
| isEmpty, | ||
| reason: 'a migration must not kick out remote participants', | ||
| ); | ||
| expect(room.remoteParticipants, hasLength(1)); | ||
| expect(container.engine.fullReconnectOnNext, isFalse); | ||
|
|
||
| // The ICE servers from the ReconnectResponse must reach both transports. | ||
| final publisher = container.engine.publisher?.pc as MockPeerConnection?; | ||
| final subscriber = container.engine.subscriber?.pc as MockPeerConnection?; | ||
| expect(publisher?.appliedConfiguration, isNotNull); | ||
| expect(subscriber?.appliedConfiguration, isNotNull); | ||
| }); | ||
|
|
||
| test('Leave{RECONNECT} performs a full reconnect', () async { | ||
| await connectWithRemoteParticipant(); | ||
|
|
||
| final roomEvents = <RoomEvent>[]; | ||
| final sub = room.events.listen(roomEvents.add); | ||
| final previousHandlers = ws.handlers; | ||
|
|
||
| sendLeave(lk_rtc.LeaveRequest_Action.RECONNECT, lk_models.DisconnectReason.SERVER_SHUTDOWN); | ||
|
|
||
| await answerFullReconnect(previousHandlers); | ||
| await room.events.waitFor<RoomReconnectedEvent>(duration: const Duration(seconds: 5)); | ||
| await sub(); | ||
|
|
||
| expectFullReconnect(roomEvents); | ||
| }); | ||
|
|
||
| test('Leave{RESUME} does not downgrade a pending full reconnect', () async { | ||
| await connectWithRemoteParticipant(); | ||
|
|
||
| final roomEvents = <RoomEvent>[]; | ||
| final sub = room.events.listen(roomEvents.add); | ||
| final previousHandlers = ws.handlers; | ||
|
|
||
| // An earlier failure already decided the next attempt must be a full | ||
| // reconnect. The server asking for a resume must not undo that. | ||
| container.engine.fullReconnectOnNext = true; | ||
| sendLeave(lk_rtc.LeaveRequest_Action.RESUME, lk_models.DisconnectReason.MIGRATION); | ||
|
|
||
| await answerFullReconnect(previousHandlers); | ||
| await room.events.waitFor<RoomReconnectedEvent>(duration: const Duration(seconds: 5)); | ||
| await sub(); | ||
|
|
||
| expectFullReconnect(roomEvents); | ||
| }); | ||
|
|
||
| test('Leave{RESUME} performs a full reconnect when the server disabled resume', () async { | ||
| await connectWithRemoteParticipant( | ||
| clientConfiguration: lk_models.ClientConfiguration( | ||
| resumeConnection: lk_models.ClientConfigSetting.DISABLED, | ||
| ), | ||
| ); | ||
|
|
||
| final roomEvents = <RoomEvent>[]; | ||
| final sub = room.events.listen(roomEvents.add); | ||
| final previousHandlers = ws.handlers; | ||
|
|
||
| sendLeave(lk_rtc.LeaveRequest_Action.RESUME, lk_models.DisconnectReason.MIGRATION); | ||
|
|
||
| await answerFullReconnect(previousHandlers); | ||
| await room.events.waitFor<RoomReconnectedEvent>(duration: const Duration(seconds: 5)); | ||
| await sub(); | ||
|
|
||
| expectFullReconnect(roomEvents); | ||
| }); | ||
|
|
||
| test('a Leave{RESUME} arriving before a peer failure retry keeps the escalation', () async { | ||
| await connectWithRemoteParticipant(); | ||
|
|
||
| final roomEvents = <RoomEvent>[]; | ||
| final sub = room.events.listen(roomEvents.add); | ||
| final previousHandlers = ws.handlers; | ||
|
|
||
| // The peer connection failure schedules a retry that must be a full | ||
| // reconnect. The Leave replaces that pending retry with its own reason; | ||
| // the escalation decided for the failure must survive the swap. | ||
| final failure = container.engine.handleReconnect(ClientDisconnectReason.peerConnectionFailed); | ||
| sendLeave(lk_rtc.LeaveRequest_Action.RESUME, lk_models.DisconnectReason.MIGRATION); | ||
| await failure; | ||
|
|
||
| await answerFullReconnect(previousHandlers); | ||
| await room.events.waitFor<RoomReconnectedEvent>(duration: const Duration(seconds: 5)); | ||
| await sub(); | ||
|
|
||
| expectFullReconnect(roomEvents); | ||
| }); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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
handleReconnectsetfullReconnectOnNextfor 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
resumeConnectionis 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 leavesfullReconnectOnNexttrue. The room subsequently drops anEngineDisconnectedEventbecause the disconnect handler treats the flag as an active restart.Example: A signal reconnect starts, then the primary peer connection briefly reports
failedbefore its ICE restart reachesconnected. The resume succeeds and its queued full reconnect is canceled. The next ordinary signal loss emits noRoomDisconnectedEvent; 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 wherepeerConnectionFailedarrives after_attemptingReconnectbecomes true and the active resume subsequently succeeds.Was this helpful? React with 👍 or 👎 to provide feedback.