From ffa6316790f8f81538da0c41f11d0102afb69f75 Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Fri, 18 Sep 2026 23:31:38 +0800 Subject: [PATCH 1/2] fix: keep the pre-connect audio buffer through a region failover on connect When the first connect attempt fails, the engine reports a disconnect before Room.connect retries another region. The Room handled that disconnect like any other and reset the pre-connect audio buffer, so the retry connected without a recording buffer and the join response found nothing to publish. The participant ended up in the room with no microphone until they toggled it, even though they had joined unmuted. Track whether Room.connect is running and leave the buffer alone when the disconnect belongs to a connect attempt. A connect that fails for good still ends with the caller's own cleanup, and a disconnect outside of a connect attempt still resets the buffer. Tests: a failover where the first socket connect fails and the validate call answers 403 now keeps the buffer recording, and a server initiated leave still resets it. Adds small test hooks for the region provider, the buffer's recording state, the HTTP client and a fail-once websocket mock. --- lib/src/core/room.dart | 26 ++++- .../preconnect/pre_connect_audio_buffer.dart | 10 ++ lib/src/support/http_client.dart | 9 +- test/core/preconnect_failover_test.dart | 100 ++++++++++++++++++ test/mock/websocket_mock.dart | 8 ++ 5 files changed, 149 insertions(+), 4 deletions(-) create mode 100644 test/core/preconnect_failover_test.dart diff --git a/lib/src/core/room.dart b/lib/src/core/room.dart index 4ef28bbf0..51abcd1b3 100644 --- a/lib/src/core/room.dart +++ b/lib/src/core/room.dart @@ -128,6 +128,19 @@ class Room extends DisposableChangeNotifier with EventsEmittable { late EventsListener _signalListener; RegionUrlProvider? _regionUrlProvider; + + /// True while [connect] is running. A failed attempt tears the engine down + /// and reports a disconnect before [connect] retries another region, and + /// that cleanup must not throw away state the retry still needs. + bool _connectInProgress = false; + + /// Lets tests install a provider with known regions, so a failover can be + /// exercised without reaching a real LiveKit Cloud endpoint. + @visibleForTesting + set regionUrlProviderForTesting(RegionUrlProvider? provider) { + _regionUrlProvider = provider; + } + String? _regionUrl; // Agents @@ -354,6 +367,7 @@ class Room extends DisposableChangeNotifier with EventsEmittable { await NativeAudioManagement.start(); var didConnect = false; + _connectInProgress = true; try { await engine.connect( _regionUrl ?? url, @@ -393,6 +407,7 @@ class Room extends DisposableChangeNotifier with EventsEmittable { rethrow; } } finally { + _connectInProgress = false; if (!didConnect) { await NativeAudioManagement.stop(); } @@ -655,7 +670,10 @@ class Room extends DisposableChangeNotifier with EventsEmittable { // pending one when it starts. if ((!engine.fullReconnectOnNext && !engine.isFullReconnectInProgress) || event.reason == DisconnectReason.clientInitiated) { - await _cleanUp(disposeLocalParticipant: false); + // A failed first attempt lands here before connect() retries another + // region. The pre-connect audio buffer has to survive that, or the + // retry connects without ever publishing the microphone. + await _cleanUp(disposeLocalParticipant: false, preservePreConnectAudio: _connectInProgress); events.emit(RoomDisconnectedEvent(reason: event.reason)); notifyListeners(); } @@ -1090,7 +1108,7 @@ class Room extends DisposableChangeNotifier with EventsEmittable { extension RoomPrivateMethods on Room { // resets internal state to a re-usable state - Future _cleanUp({bool disposeLocalParticipant = true}) async { + Future _cleanUp({bool disposeLocalParticipant = true, bool preservePreConnectAudio = false}) async { logger.fine('[${objectId}] cleanUp()'); // clean up RemoteParticipants @@ -1113,7 +1131,9 @@ extension RoomPrivateMethods on Room { _activeSpeakers.clear(); - await preConnectAudioBuffer.reset(); + if (!preservePreConnectAudio) { + await preConnectAudioBuffer.reset(); + } // clean up engine await engine.cleanUp(); diff --git a/lib/src/preconnect/pre_connect_audio_buffer.dart b/lib/src/preconnect/pre_connect_audio_buffer.dart index b2994b170..e513074e2 100644 --- a/lib/src/preconnect/pre_connect_audio_buffer.dart +++ b/lib/src/preconnect/pre_connect_audio_buffer.dart @@ -14,6 +14,8 @@ import 'dart:async'; +import 'package:meta/meta.dart'; + import 'package:uuid/uuid.dart'; import '../audio/audio_frame_capture.dart'; @@ -108,6 +110,14 @@ class PreConnectAudioBuffer { /// Requires microphone permission. On iOS/macOS it is requested here while /// the app is in the foreground. Throws a [TrackCreateException] when it is /// denied or cannot be requested (app not in the foreground). + /// Puts the buffer into its recording state without touching the + /// microphone, so tests can check what the Room does with a live buffer + /// where no audio device exists. + @visibleForTesting + void markRecordingForTesting() { + _isRecording = true; + } + Future startRecording({ Duration timeout = const Duration(seconds: 20), }) async { diff --git a/lib/src/support/http_client.dart b/lib/src/support/http_client.dart index 22f5c1a22..8518b36d3 100644 --- a/lib/src/support/http_client.dart +++ b/lib/src/support/http_client.dart @@ -13,11 +13,18 @@ // limitations under the License. import 'package:http/http.dart' as http; +import 'package:meta/meta.dart'; import '../options.dart'; import 'http_client/io.dart' if (dart.library.js_interop) 'http_client/web.dart' as impl; -http.Client createSdkHttpClient(NetworkOptions networkOptions) => impl.createSdkHttpClient(networkOptions); +/// Replaces the HTTP client the SDK builds, so tests can answer the requests +/// the SDK makes on its own, such as the validate call after a failed connect. +@visibleForTesting +http.Client Function(NetworkOptions networkOptions)? sdkHttpClientFactoryForTesting; + +http.Client createSdkHttpClient(NetworkOptions networkOptions) => + sdkHttpClientFactoryForTesting?.call(networkOptions) ?? impl.createSdkHttpClient(networkOptions); Future sdkHttpGet( Uri uri, { diff --git a/test/core/preconnect_failover_test.dart b/test/core/preconnect_failover_test.dart new file mode 100644 index 000000000..b9461997c --- /dev/null +++ b/test/core/preconnect_failover_test.dart @@ -0,0 +1,100 @@ +// 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:http/http.dart' as http; +import 'package:http/testing.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/http_client.dart'; +import 'package:livekit_client/src/support/region_url_provider.dart'; +import 'package:livekit_client/src/support/websocket.dart'; + +import '../mock/e2e_container.dart'; + +const cloudUri = 'wss://test.livekit.cloud'; +const token = 'token'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late E2EContainer container; + + setUp(() { + container = E2EContainer(); + // After a failed socket connect the SDK validates the token over HTTP. A + // 403 there is how LiveKit Cloud signals that the project is not served + // from that region, and it is the case that fails over to another region. + sdkHttpClientFactoryForTesting = (_) => + MockClient((request) async => http.Response('project not allowed in this region.', 403)); + }); + + tearDown(() async { + sdkHttpClientFactoryForTesting = null; + await container.dispose(); + }); + + test('pre-connect audio buffer survives a region failover on the first attempt', () async { + final room = container.room; + + // Known regions, so a failed first attempt retries another region instead + // of failing the connect. + room.regionUrlProviderForTesting = RegionUrlProvider(url: cloudUri, token: token) + ..setServerReportedRegions( + lk_rtc.RegionSettings() + ..regions.add( + lk_rtc.RegionInfo() + ..region = 'other' + ..url = 'wss://other.livekit.cloud', + ), + ); + room.preConnectAudioBuffer.markRecordingForTesting(); + + // The first socket connect fails, the retry gets through. + container.wsConnector.connectError = WebSocketException('first region down'); + container.wsConnector.connectErrorOnce = true; + final connecting = room.connect(cloudUri, token); + await container.answerJoin(); + await connecting; + + expect(room.connectionState, ConnectionState.connected); + expect(container.wsConnector.uri.toString(), startsWith('wss://other.livekit.cloud')); + // The buffered microphone track is published from the join response, so + // the cleanup that runs for the failed attempt must leave the buffer alone. + expect(room.preConnectAudioBuffer.isRecording, isTrue); + }); + + test('pre-connect audio buffer is still reset when the connection drops', () async { + final room = container.room; + await container.connectRoom(); + room.preConnectAudioBuffer.markRecordingForTesting(); + + // The server asks the participant to leave, outside of any connect attempt. + container.wsConnector.onData( + lk_rtc.SignalResponse( + leave: lk_rtc.LeaveRequest( + action: lk_rtc.LeaveRequest_Action.DISCONNECT, + reason: lk_models.DisconnectReason.ROOM_DELETED, + ), + ).writeToBuffer(), + ); + await Future.delayed(const Duration(milliseconds: 200)); + + expect(room.preConnectAudioBuffer.isRecording, isFalse); + }); +} diff --git a/test/mock/websocket_mock.dart b/test/mock/websocket_mock.dart index 0917de753..77c188e0d 100644 --- a/test/mock/websocket_mock.dart +++ b/test/mock/websocket_mock.dart @@ -27,6 +27,11 @@ class MockWebSocketConnector { NetworkOptions? networkOptions; Object? connectError; + /// When true, [connectError] is thrown once and then cleared, so the next + /// connect succeeds. Models a first attempt that fails and a retry that gets + /// through. + bool connectErrorOnce = false; + WebSocketOnData get onData => handlers!.onData!; WebSocketOnDispose get onDispose => handlers!.onDispose!; @@ -45,6 +50,9 @@ class MockWebSocketConnector { final error = connectError; if (error != null) { + if (connectErrorOnce) { + connectError = null; + } throw error; } From f24ccda853d87672305ee047276f55120a4837bd Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Sat, 19 Sep 2026 00:47:34 +0800 Subject: [PATCH 2/2] style: sort imports --- lib/src/preconnect/pre_connect_audio_buffer.dart | 1 - test/core/preconnect_failover_test.dart | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/lib/src/preconnect/pre_connect_audio_buffer.dart b/lib/src/preconnect/pre_connect_audio_buffer.dart index e513074e2..172da562c 100644 --- a/lib/src/preconnect/pre_connect_audio_buffer.dart +++ b/lib/src/preconnect/pre_connect_audio_buffer.dart @@ -15,7 +15,6 @@ import 'dart:async'; import 'package:meta/meta.dart'; - import 'package:uuid/uuid.dart'; import '../audio/audio_frame_capture.dart'; diff --git a/test/core/preconnect_failover_test.dart b/test/core/preconnect_failover_test.dart index b9461997c..3756df2e8 100644 --- a/test/core/preconnect_failover_test.dart +++ b/test/core/preconnect_failover_test.dart @@ -18,13 +18,13 @@ library; import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; import 'package:http/testing.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/http_client.dart'; import 'package:livekit_client/src/support/region_url_provider.dart'; import 'package:livekit_client/src/support/websocket.dart'; - import '../mock/e2e_container.dart'; const cloudUri = 'wss://test.livekit.cloud';