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
1 change: 1 addition & 0 deletions .changes/engine-terminal-closure
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
patch type="fixed" "Announce engine closure before room disposal callbacks and when reconnection gives up, and reject data channel buffer waits started after closure"
28 changes: 24 additions & 4 deletions lib/src/core/engine.dart
Original file line number Diff line number Diff line change
Expand Up @@ -242,14 +242,20 @@ class Engine extends Disposable with EventsEmittable<EngineEvent> {
_setUpSignalListeners();

onDispose(() async {
_isClosed = true;
await cleanUp();
await events.dispose();
await _signalListener.dispose();
_reliableReceivedState.dispose();
});
}

@override
Future<bool> dispose() {
// Disposal callbacks run in reverse registration order and may await engine work.
markClosed();
return super.dispose();
}

Future<void> connect(
String url,
String token, {
Expand Down Expand Up @@ -395,6 +401,11 @@ class Engine extends Disposable with EventsEmittable<EngineEvent> {
}

Future<void> waitForBufferStatusLow(Reliability kind) async {
// A wait started after closure cannot observe the closing event.
if (_isClosed || isDisposed) {
throw 'Engine disconnected';
}

final Completer<void> completer = Completer();

if (isBufferStatusLow(kind) == true) {
Expand Down Expand Up @@ -1129,7 +1140,7 @@ class Engine extends Disposable with EventsEmittable<EngineEvent> {

if (_reconnectAttempts >= _reconnectCount) {
logger.fine('reconnectAttempts exceeded, disconnecting...');
_isClosed = true;
markClosed();
await cleanUp();

events.emit(
Expand Down Expand Up @@ -1241,6 +1252,7 @@ class Engine extends Disposable with EventsEmittable<EngineEvent> {
unawaited(handleReconnect(ClientDisconnectReason.reconnectRetry));
} else {
logger.fine('attemptReconnect: disconnecting...');
markClosed();
// clean up before emitting, room's EngineDisconnectedEvent handler
// drops the event while fullReconnectOnNext is still true and
// cleanUp() is what resets it
Expand Down Expand Up @@ -1644,11 +1656,19 @@ class Engine extends Disposable with EventsEmittable<EngineEvent> {
events.emit(EngineRoomMovedEvent(response: event.response));
});

// Announce terminal closure before tearing down transports or event listeners.
// A full reconnect also disposes transports, but must keep the engine open.
@internal
void markClosed() {
if (_isClosed) return;
_isClosed = true;
events.emit(EngineClosingEvent());
}

Future<void> disconnect({
DisconnectReason reason = DisconnectReason.clientInitiated,
}) async {
_isClosed = true;
events.emit(EngineClosingEvent());
markClosed();
if (connectionState == ConnectionState.connected) {
await signalClient.sendLeave();
} else {
Expand Down
7 changes: 7 additions & 0 deletions lib/src/core/room.dart
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,13 @@ class Room extends DisposableChangeNotifier with EventsEmittable<RoomEvent> {
});
}

@override
Future<bool> dispose() {
// Room cleanup tears down transports before Engine.dispose() is reached.
engine.markClosed();
return super.dispose();
}

/// prepareConnection should be called as soon as the page is loaded, in order
/// to speed up the connection attempt. This function will
/// - perform DNS resolution and pre-warm the DNS cache
Expand Down
173 changes: 173 additions & 0 deletions test/core/engine_closing_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
// 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: 5))
library;

import 'dart:async';

import 'package:flutter_test/flutter_test.dart';

import 'package:livekit_client/livekit_client.dart';
import 'package:livekit_client/src/core/engine.dart';
import 'package:livekit_client/src/internal/events.dart';
import 'package:livekit_client/src/types/internal.dart';
import '../mock/e2e_container.dart';
import '../mock/peerconnection_mock.dart';

void main() {
TestWidgetsFlutterBinding.ensureInitialized();

late E2EContainer container;
late List<EngineEvent> events;

setUp(() async {
resetMockDataChannels();
container = E2EContainer();
await container.connectRoom();
events = [];
container.engine.events.listen(events.add);
});

tearDown(() async {
await container.dispose();
});

test('disposing the room announces engine closure', () async {
await container.room.dispose();

expect(events.whereType<EngineClosingEvent>(), hasLength(1));
expect(container.engine.isClosed, isTrue);
});

test('room closure is announced while the first transport teardown is blocked', () async {
final teardownStarted = Completer<void>();
final releaseTeardown = Completer<void>();
container.engine.publisher!.onDispose(() async {
teardownStarted.complete();
await releaseTeardown.future;
});

final disposing = container.room.dispose();
await teardownStarted.future;
final closedDuringTeardown = container.engine.isClosed;
final closingEventsDuringTeardown = events.whereType<EngineClosingEvent>().length;
final emitterDisposedDuringTeardown = container.engine.events.isDisposed;
releaseTeardown.complete();
await disposing;

expect(closedDuringTeardown, isTrue);
expect(closingEventsDuringTeardown, 1);
expect(emitterDisposedDuringTeardown, isFalse);
});

for (final owner in ['room', 'engine']) {
test('$owner is marked closed before later disposal callbacks run', () async {
final disposable = owner == 'room' ? container.room : container.engine;
bool? closedInCallback;
disposable.onDispose(() async {
closedInCallback = container.engine.isClosed;
});

if (owner == 'room') {
await container.room.dispose();
} else {
await container.engine.dispose();
}

expect(closedInCallback, isTrue);
expect(events.whereType<EngineClosingEvent>(), hasLength(1));
});
}

test('engine closure is delivered while disposing with its emitter still alive', () async {
bool? engineDisposedWhenDelivered;
bool? emitterDisposedWhenDelivered;
container.engine.events.once<EngineClosingEvent>((_) {
engineDisposedWhenDelivered = container.engine.isDisposed;
emitterDisposedWhenDelivered = container.engine.events.isDisposed;
});

await container.engine.dispose();

expect(engineDisposedWhenDelivered, isTrue);
expect(emitterDisposedWhenDelivered, isFalse);
expect(container.engine.events.isDisposed, isTrue);
});

test('an unrecoverable reconnect announces closure before disconnection', () async {
container.wsConnector.connectError = CertificatePinningException(
'Certificate pin mismatch',
host: 'www.example.com',
);
final disconnected = container.engine.events.waitFor<EngineDisconnectedEvent>(
duration: const Duration(seconds: 1),
);

await container.engine.attemptReconnect(ClientDisconnectReason.reconnectRetry);
expect((await disconnected).reason, DisconnectReason.signalingConnectionFailure);

expect(container.engine.isClosed, isTrue);
expect(events.whereType<EngineClosingEvent>(), hasLength(1));
expect(
events.indexWhere((event) => event is EngineClosingEvent),
lessThan(events.indexWhere((event) => event is EngineDisconnectedEvent)),
);
});

test('exhausting reconnect attempts announces engine closure', () async {
// A generic resume failure stays recoverable without escalating to a full reconnect.
container.wsConnector.connectError = Exception('temporary connection failure');
final disconnected = container.engine.events.waitFor<EngineDisconnectedEvent>(
duration: const Duration(seconds: 1),
);

// Drive the attempts directly rather than waiting through the retry delays.
for (var i = 0; i < defaultRetryDelaysInMs.length; i++) {
await container.engine.attemptReconnect(ClientDisconnectReason.reconnectRetry);
}
expect((await disconnected).reason, DisconnectReason.reconnectAttemptsExceeded);

expect(container.engine.isClosed, isTrue);
expect(events.whereType<EngineClosingEvent>(), hasLength(1));
expect(
events.indexWhere((event) => event is EngineClosingEvent),
lessThan(events.indexWhere((event) => event is EngineDisconnectedEvent)),
);
});

test('a recoverable reconnect does not announce engine closure', () async {
container.wsConnector.connectError = Exception('temporary connection failure');

await container.engine.attemptReconnect(ClientDisconnectReason.reconnectRetry);

expect(container.engine.isClosed, isFalse);
expect(events.whereType<EngineClosingEvent>(), isEmpty);
expect(events.whereType<EngineDisconnectedEvent>(), isEmpty);
});

test('a buffer wait started after disposal rejects even when the channel reports low', () async {
await container.room.dispose();

await expectLater(container.engine.waitForBufferStatusLow(Reliability.reliable), throwsA('Engine disconnected'));
});

test('disconnect followed by disposal announces closure only once', () async {
await container.engine.disconnect();
await expectLater(container.engine.waitForBufferStatusLow(Reliability.reliable), throwsA('Engine disconnected'));
await container.room.dispose();

expect(events.whereType<EngineClosingEvent>(), hasLength(1));
});
}
Loading