Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 23 additions & 3 deletions lib/src/core/room.dart
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,19 @@ class Room extends DisposableChangeNotifier with EventsEmittable<RoomEvent> {
late EventsListener<SignalEvent> _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
Expand Down Expand Up @@ -354,6 +367,7 @@ class Room extends DisposableChangeNotifier with EventsEmittable<RoomEvent> {
await NativeAudioManagement.start();

var didConnect = false;
_connectInProgress = true;
try {
await engine.connect(
_regionUrl ?? url,
Expand Down Expand Up @@ -393,6 +407,7 @@ class Room extends DisposableChangeNotifier with EventsEmittable<RoomEvent> {
rethrow;
}
} finally {
_connectInProgress = false;
if (!didConnect) {
await NativeAudioManagement.stop();
}
Expand Down Expand Up @@ -655,7 +670,10 @@ class Room extends DisposableChangeNotifier with EventsEmittable<RoomEvent> {
// 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();
}
Expand Down Expand Up @@ -1090,7 +1108,7 @@ class Room extends DisposableChangeNotifier with EventsEmittable<RoomEvent> {

extension RoomPrivateMethods on Room {
// resets internal state to a re-usable state
Future<void> _cleanUp({bool disposeLocalParticipant = true}) async {
Future<void> _cleanUp({bool disposeLocalParticipant = true, bool preservePreConnectAudio = false}) async {
logger.fine('[${objectId}] cleanUp()');

// clean up RemoteParticipants
Expand All @@ -1113,7 +1131,9 @@ extension RoomPrivateMethods on Room {

_activeSpeakers.clear();

await preConnectAudioBuffer.reset();
if (!preservePreConnectAudio) {
await preConnectAudioBuffer.reset();
}

// clean up engine
await engine.cleanUp();
Expand Down
9 changes: 9 additions & 0 deletions lib/src/preconnect/pre_connect_audio_buffer.dart
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@

import 'dart:async';

import 'package:meta/meta.dart';
import 'package:uuid/uuid.dart';

import '../audio/audio_frame_capture.dart';
Expand Down Expand Up @@ -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<void> startRecording({
Duration timeout = const Duration(seconds: 20),
}) async {
Expand Down
9 changes: 8 additions & 1 deletion lib/src/support/http_client.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<http.Response> sdkHttpGet(
Uri uri, {
Expand Down
100 changes: 100 additions & 0 deletions test/core/preconnect_failover_test.dart
Original file line number Diff line number Diff line change
@@ -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<void>.delayed(const Duration(milliseconds: 200));

expect(room.preConnectAudioBuffer.isRecording, isFalse);
});
}
8 changes: 8 additions & 0 deletions test/mock/websocket_mock.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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!;
Expand All @@ -45,6 +50,9 @@ class MockWebSocketConnector {

final error = connectError;
if (error != null) {
if (connectErrorOnce) {
connectError = null;
}
throw error;
}

Expand Down
Loading