From eaa46889c25cf1c447d220ce51bc9ad33840bed0 Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Thu, 29 Oct 2026 17:13:23 +0800 Subject: [PATCH 1/2] Announce engine closure on terminal lifecycle paths --- .changes/engine-terminal-closure | 1 + lib/src/core/engine.dart | 21 +++++- test/core/engine_closing_test.dart | 116 +++++++++++++++++++++++++++++ 3 files changed, 134 insertions(+), 4 deletions(-) create mode 100644 .changes/engine-terminal-closure create mode 100644 test/core/engine_closing_test.dart diff --git a/.changes/engine-terminal-closure b/.changes/engine-terminal-closure new file mode 100644 index 000000000..1a8d6e5ce --- /dev/null +++ b/.changes/engine-terminal-closure @@ -0,0 +1 @@ +patch type="fixed" "Announce engine closure when a room is disposed or reconnection gives up, and reject data channel buffer waits started after closure" diff --git a/lib/src/core/engine.dart b/lib/src/core/engine.dart index 0f46a92f8..5ffdaa966 100644 --- a/lib/src/core/engine.dart +++ b/lib/src/core/engine.dart @@ -242,7 +242,7 @@ class Engine extends Disposable with EventsEmittable { _setUpSignalListeners(); onDispose(() async { - _isClosed = true; + _markClosed(); await cleanUp(); await events.dispose(); await _signalListener.dispose(); @@ -395,6 +395,11 @@ class Engine extends Disposable with EventsEmittable { } Future waitForBufferStatusLow(Reliability kind) async { + // A wait started after closure cannot observe the closing event. + if (_isClosed || isDisposed) { + throw 'Engine disconnected'; + } + final Completer completer = Completer(); if (isBufferStatusLow(kind) == true) { @@ -1129,7 +1134,7 @@ class Engine extends Disposable with EventsEmittable { if (_reconnectAttempts >= _reconnectCount) { logger.fine('reconnectAttempts exceeded, disconnecting...'); - _isClosed = true; + _markClosed(); await cleanUp(); events.emit( @@ -1241,6 +1246,7 @@ class Engine extends Disposable with EventsEmittable { 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 @@ -1644,11 +1650,18 @@ class Engine extends Disposable with EventsEmittable { 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. + void _markClosed() { + if (_isClosed) return; + _isClosed = true; + events.emit(EngineClosingEvent()); + } + Future disconnect({ DisconnectReason reason = DisconnectReason.clientInitiated, }) async { - _isClosed = true; - events.emit(EngineClosingEvent()); + _markClosed(); if (connectionState == ConnectionState.connected) { await signalClient.sendLeave(); } else { diff --git a/test/core/engine_closing_test.dart b/test/core/engine_closing_test.dart new file mode 100644 index 000000000..fbde44407 --- /dev/null +++ b/test/core/engine_closing_test.dart @@ -0,0 +1,116 @@ +// 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 '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 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(), hasLength(1)); + expect(container.engine.isClosed, 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( + 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(), 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( + 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(), 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(), isEmpty); + expect(events.whereType(), 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(), hasLength(1)); + }); +} From 6f0346bd0091a2c12beeb3559bd61fe5d3ed2dc5 Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Fri, 30 Oct 2026 03:52:55 +0800 Subject: [PATCH 2/2] Signal engine closure before disposal callbacks --- .changes/engine-terminal-closure | 2 +- lib/src/core/engine.dart | 17 ++++++--- lib/src/core/room.dart | 7 ++++ test/core/engine_closing_test.dart | 57 ++++++++++++++++++++++++++++++ 4 files changed, 77 insertions(+), 6 deletions(-) diff --git a/.changes/engine-terminal-closure b/.changes/engine-terminal-closure index 1a8d6e5ce..0429b4483 100644 --- a/.changes/engine-terminal-closure +++ b/.changes/engine-terminal-closure @@ -1 +1 @@ -patch type="fixed" "Announce engine closure when a room is disposed or reconnection gives up, and reject data channel buffer waits started after closure" +patch type="fixed" "Announce engine closure before room disposal callbacks and when reconnection gives up, and reject data channel buffer waits started after closure" diff --git a/lib/src/core/engine.dart b/lib/src/core/engine.dart index 5ffdaa966..198775eeb 100644 --- a/lib/src/core/engine.dart +++ b/lib/src/core/engine.dart @@ -242,7 +242,6 @@ class Engine extends Disposable with EventsEmittable { _setUpSignalListeners(); onDispose(() async { - _markClosed(); await cleanUp(); await events.dispose(); await _signalListener.dispose(); @@ -250,6 +249,13 @@ class Engine extends Disposable with EventsEmittable { }); } + @override + Future dispose() { + // Disposal callbacks run in reverse registration order and may await engine work. + markClosed(); + return super.dispose(); + } + Future connect( String url, String token, { @@ -1134,7 +1140,7 @@ class Engine extends Disposable with EventsEmittable { if (_reconnectAttempts >= _reconnectCount) { logger.fine('reconnectAttempts exceeded, disconnecting...'); - _markClosed(); + markClosed(); await cleanUp(); events.emit( @@ -1246,7 +1252,7 @@ class Engine extends Disposable with EventsEmittable { unawaited(handleReconnect(ClientDisconnectReason.reconnectRetry)); } else { logger.fine('attemptReconnect: disconnecting...'); - _markClosed(); + markClosed(); // clean up before emitting, room's EngineDisconnectedEvent handler // drops the event while fullReconnectOnNext is still true and // cleanUp() is what resets it @@ -1652,7 +1658,8 @@ class Engine extends Disposable with EventsEmittable { // Announce terminal closure before tearing down transports or event listeners. // A full reconnect also disposes transports, but must keep the engine open. - void _markClosed() { + @internal + void markClosed() { if (_isClosed) return; _isClosed = true; events.emit(EngineClosingEvent()); @@ -1661,7 +1668,7 @@ class Engine extends Disposable with EventsEmittable { Future disconnect({ DisconnectReason reason = DisconnectReason.clientInitiated, }) async { - _markClosed(); + markClosed(); if (connectionState == ConnectionState.connected) { await signalClient.sendLeave(); } else { diff --git a/lib/src/core/room.dart b/lib/src/core/room.dart index d9ca2843b..3d2605662 100644 --- a/lib/src/core/room.dart +++ b/lib/src/core/room.dart @@ -242,6 +242,13 @@ class Room extends DisposableChangeNotifier with EventsEmittable { }); } + @override + Future 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 diff --git a/test/core/engine_closing_test.dart b/test/core/engine_closing_test.dart index fbde44407..dbcbf3934 100644 --- a/test/core/engine_closing_test.dart +++ b/test/core/engine_closing_test.dart @@ -15,6 +15,8 @@ @Timeout(Duration(seconds: 5)) library; +import 'dart:async'; + import 'package:flutter_test/flutter_test.dart'; import 'package:livekit_client/livekit_client.dart'; @@ -49,6 +51,61 @@ void main() { expect(container.engine.isClosed, isTrue); }); + test('room closure is announced while the first transport teardown is blocked', () async { + final teardownStarted = Completer(); + final releaseTeardown = Completer(); + 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().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(), hasLength(1)); + }); + } + + test('engine closure is delivered while disposing with its emitter still alive', () async { + bool? engineDisposedWhenDelivered; + bool? emitterDisposedWhenDelivered; + container.engine.events.once((_) { + 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',