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..172da562c 100644 --- a/lib/src/preconnect/pre_connect_audio_buffer.dart +++ b/lib/src/preconnect/pre_connect_audio_buffer.dart @@ -14,6 +14,7 @@ import 'dart:async'; +import 'package:meta/meta.dart'; import 'package:uuid/uuid.dart'; import '../audio/audio_frame_capture.dart'; @@ -108,6 +109,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..3756df2e8 --- /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; }