From f279a6ca6a042eeaa337a179e6f8b86378fddf4a Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Wed, 25 Mar 2026 14:18:12 +0900 Subject: [PATCH 01/11] Fix deferred track listener leak on reconnect Use events.once instead of events.on so the deferred EngineTrackAddedEvent fires only on the next connected event and does not persist across future reconnects. --- .changes/fix-deferred-track-listener-leak | 1 + lib/src/core/engine.dart | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) create mode 100644 .changes/fix-deferred-track-listener-leak diff --git a/.changes/fix-deferred-track-listener-leak b/.changes/fix-deferred-track-listener-leak new file mode 100644 index 000000000..fa6955d75 --- /dev/null +++ b/.changes/fix-deferred-track-listener-leak @@ -0,0 +1 @@ +patch type="fixed" "Fix deferred track listener leak across reconnects" diff --git a/lib/src/core/engine.dart b/lib/src/core/engine.dart index d5c113cbc..744c0bf9f 100644 --- a/lib/src/core/engine.dart +++ b/lib/src/core/engine.dart @@ -725,7 +725,7 @@ class Engine extends Disposable with EventsEmittable { signalClient.connectionState == ConnectionState.connecting) { final track = event.track; final receiver = event.receiver; - events.on((event) async { + events.once((event) async { Timer(const Duration(milliseconds: 10), () { events.emit(EngineTrackAddedEvent( track: track, From 0947f468027f71171d8118d2d35bd7f48154410b Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Wed, 25 Mar 2026 14:18:01 +0900 Subject: [PATCH 02/11] Fix region failover condition precedence Ensure the _regionUrlProvider null check gates both exception branches so ConnectException does not bypass the guard and dereference a null provider. --- .changes/fix-region-failover-condition | 1 + lib/src/core/room.dart | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) create mode 100644 .changes/fix-region-failover-condition diff --git a/.changes/fix-region-failover-condition b/.changes/fix-region-failover-condition new file mode 100644 index 000000000..722a10933 --- /dev/null +++ b/.changes/fix-region-failover-condition @@ -0,0 +1 @@ +patch type="fixed" "Fix region failover condition allowing null provider dereference" diff --git a/lib/src/core/room.dart b/lib/src/core/room.dart index e68c9c062..67b182627 100644 --- a/lib/src/core/room.dart +++ b/lib/src/core/room.dart @@ -302,8 +302,8 @@ class Room extends DisposableChangeNotifier with EventsEmittable { ); } catch (e) { logger.warning('could not connect to $url $e'); - if (_regionUrlProvider != null && e is WebSocketException || - (e is ConnectException && e.reason != ConnectionErrorReason.NotAllowed)) { + if (_regionUrlProvider != null && + (e is WebSocketException || (e is ConnectException && e.reason != ConnectionErrorReason.NotAllowed))) { String? nextUrl; try { nextUrl = await _regionUrlProvider!.getNextBestRegionUrl(); From 1980ebe58b46e18858a33446a38c0de87091ab75 Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Wed, 25 Mar 2026 14:17:48 +0900 Subject: [PATCH 03/11] Fix reconnect counter initialization Initialize the reconnect attempt counter so the first reconnect path does not hit a null assertion before retry handling begins. --- .changes/fix-reconnect-counter | 1 + lib/src/core/engine.dart | 12 ++++++------ 2 files changed, 7 insertions(+), 6 deletions(-) create mode 100644 .changes/fix-reconnect-counter diff --git a/.changes/fix-reconnect-counter b/.changes/fix-reconnect-counter new file mode 100644 index 000000000..7291fd827 --- /dev/null +++ b/.changes/fix-reconnect-counter @@ -0,0 +1 @@ +patch type="fixed" "Fix reconnect counter null assertion on first reconnect attempt" diff --git a/lib/src/core/engine.dart b/lib/src/core/engine.dart index 744c0bf9f..f7765338f 100644 --- a/lib/src/core/engine.dart +++ b/lib/src/core/engine.dart @@ -121,7 +121,7 @@ class Engine extends Disposable with EventsEmittable { late EventsListener _signalListener = signalClient.createListener(synchronized: true); - int? reconnectAttempts; + int reconnectAttempts = 0; Timer? reconnectTimeout; DateTime? reconnectStart; @@ -1003,7 +1003,7 @@ class Engine extends Disposable with EventsEmittable { reconnectStart = DateTime.timestamp(); } - if (reconnectAttempts! >= _reconnectCount) { + if (reconnectAttempts >= _reconnectCount) { logger.fine('reconnectAttempts exceeded, disconnecting...'); _isClosed = true; await cleanUp(); @@ -1014,14 +1014,14 @@ class Engine extends Disposable with EventsEmittable { return; } - var delay = defaultRetryDelaysInMs[reconnectAttempts!]; + var delay = defaultRetryDelaysInMs[reconnectAttempts]; // Add random jitter to prevent thundering herd on reconnect - if (reconnectAttempts! > 1) { + if (reconnectAttempts > 1) { delay += math.Random().nextInt(1000); } events.emit(EngineAttemptReconnectEvent( - attempt: reconnectAttempts! + 1, + attempt: reconnectAttempts + 1, maxAttempts: _reconnectCount, nextRetryDelaysInMs: delay, )); @@ -1090,7 +1090,7 @@ class Engine extends Disposable with EventsEmittable { attemptingReconnect = false; _isReconnecting = false; } catch (e) { - reconnectAttempts = reconnectAttempts! + 1; + reconnectAttempts = reconnectAttempts + 1; bool recoverable = true; if (e is WebSocketException || e is MediaConnectException) { // cannot resume connection, need to do full reconnect From 761d051822f4e7151c29853a4084d039f809a582 Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Wed, 25 Mar 2026 14:25:27 +0900 Subject: [PATCH 04/11] Fix sendSyncState returning void instead of Future Change return type from void to Future so callers can await errors and the async body is not silently fire-and-forget. --- .changes/fix-send-sync-state-return-type | 1 + lib/src/core/engine.dart | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) create mode 100644 .changes/fix-send-sync-state-return-type diff --git a/.changes/fix-send-sync-state-return-type b/.changes/fix-send-sync-state-return-type new file mode 100644 index 000000000..4bdd6f3bd --- /dev/null +++ b/.changes/fix-send-sync-state-return-type @@ -0,0 +1 @@ +patch type="fixed" "Fix sendSyncState returning void instead of Future" diff --git a/lib/src/core/engine.dart b/lib/src/core/engine.dart index f7765338f..3702e458d 100644 --- a/lib/src/core/engine.dart +++ b/lib/src/core/engine.dart @@ -1230,7 +1230,7 @@ class Engine extends Disposable with EventsEmittable { } @internal - void sendSyncState({ + Future sendSyncState({ required lk_rtc.UpdateSubscription subscription, required Iterable? publishTracks, required List trackSidsDisabled, From 0a66516fa967409515d05617a37f5a94190c9e41 Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Wed, 25 Mar 2026 14:25:11 +0900 Subject: [PATCH 05/11] Fix waitForBufferStatusLow busy-wait after engine close Check completer.isCompleted in the while loop so the polling stops when the EngineClosingEvent fires, instead of spinning forever. --- .changes/fix-buffer-status-busy-wait | 1 + lib/src/core/engine.dart | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) create mode 100644 .changes/fix-buffer-status-busy-wait diff --git a/.changes/fix-buffer-status-busy-wait b/.changes/fix-buffer-status-busy-wait new file mode 100644 index 000000000..9f048b1d1 --- /dev/null +++ b/.changes/fix-buffer-status-busy-wait @@ -0,0 +1 @@ +patch type="fixed" "Fix waitForBufferStatusLow busy-wait after engine close" diff --git a/lib/src/core/engine.dart b/lib/src/core/engine.dart index 3702e458d..a5caa1a7c 100644 --- a/lib/src/core/engine.dart +++ b/lib/src/core/engine.dart @@ -357,7 +357,7 @@ class Engine extends Disposable with EventsEmittable { events.once((e) => onClosing()); - while (!_dcBufferStatus[kind]!) { + while (!completer.isCompleted && !_dcBufferStatus[kind]!) { await Future.delayed(const Duration(milliseconds: 10)); } if (completer.isCompleted) { From ddb5718161dfe77690767f349ff14f2d87efffea Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Wed, 25 Mar 2026 14:24:55 +0900 Subject: [PATCH 06/11] Fix string interpolation in forceRelay log messages Wrap event.response.clientConfiguration.forceRelay in ${} so the full property path is interpolated instead of printing the Event object. --- .changes/fix-log-interpolation | 1 + lib/src/core/engine.dart | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) create mode 100644 .changes/fix-log-interpolation diff --git a/.changes/fix-log-interpolation b/.changes/fix-log-interpolation new file mode 100644 index 000000000..8919c3a8e --- /dev/null +++ b/.changes/fix-log-interpolation @@ -0,0 +1 @@ +patch type="fixed" "Fix string interpolation in forceRelay log messages" diff --git a/lib/src/core/engine.dart b/lib/src/core/engine.dart index a5caa1a7c..d81a8cee9 100644 --- a/lib/src/core/engine.dart +++ b/lib/src/core/engine.dart @@ -1281,7 +1281,7 @@ class Engine extends Disposable with EventsEmittable { logger.fine('onConnected subscriberPrimary: ${_subscriberPrimary}, ' 'serverVersion: ${event.response.serverVersion}, ' 'iceServers: ${event.response.iceServers}, ' - 'forceRelay: $event.response.clientConfiguration.forceRelay'); + 'forceRelay: ${event.response.clientConfiguration.forceRelay}'); final rtcConfiguration = await _buildRtcConfiguration( serverResponseForceRelay: event.response.clientConfiguration.forceRelay, @@ -1313,7 +1313,7 @@ class Engine extends Disposable with EventsEmittable { logger.fine('Handle ReconnectResponse: ' 'iceServers: ${event.response.iceServers}, ' - 'forceRelay: $event.response.clientConfiguration.forceRelay, ' + 'forceRelay: ${event.response.clientConfiguration.forceRelay}, ' 'lastMessageSeq: ${event.response.lastMessageSeq}'); final rtcConfiguration = await _buildRtcConfiguration( From 2043b79626bfe2e04155e7260805ef292da06f19 Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Wed, 25 Mar 2026 14:24:31 +0900 Subject: [PATCH 07/11] Fix connected server address using wrong peer connection Use the pc parameter passed to _handleGettingConnectedServerAddress instead of always using publisher.pc, so subscriber connections report the correct remote address. --- .changes/fix-connected-server-address | 1 + lib/src/core/engine.dart | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) create mode 100644 .changes/fix-connected-server-address diff --git a/.changes/fix-connected-server-address b/.changes/fix-connected-server-address new file mode 100644 index 000000000..e55d6ccea --- /dev/null +++ b/.changes/fix-connected-server-address @@ -0,0 +1 @@ +patch type="fixed" "Fix connected server address using wrong peer connection" diff --git a/lib/src/core/engine.dart b/lib/src/core/engine.dart index d81a8cee9..e857d573a 100644 --- a/lib/src/core/engine.dart +++ b/lib/src/core/engine.dart @@ -829,7 +829,7 @@ class Engine extends Disposable with EventsEmittable { Future _handleGettingConnectedServerAddress(rtc.RTCPeerConnection pc) async { try { - final remoteAddress = await getConnectedAddress(publisher!.pc); + final remoteAddress = await getConnectedAddress(pc); logger.fine('Connected address: $remoteAddress'); if (_connectedServerAddress == null || _connectedServerAddress != remoteAddress) { _connectedServerAddress = remoteAddress; From e87dacc2f536c3809987296adac359c1fd075f18 Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Wed, 25 Mar 2026 14:31:20 +0900 Subject: [PATCH 08/11] Fix premature publication dispose during unpublish Remove the first pub.dispose() call in removePublishedTrack so track cleanup (stop, removeTrack, negotiate, onUnpublish) runs before the track is disposed. Fixes both LocalParticipant and RemoteParticipant. --- .changes/fix-premature-publication-dispose | 1 + lib/src/participant/local.dart | 2 -- lib/src/participant/remote.dart | 2 -- 3 files changed, 1 insertion(+), 4 deletions(-) create mode 100644 .changes/fix-premature-publication-dispose diff --git a/.changes/fix-premature-publication-dispose b/.changes/fix-premature-publication-dispose new file mode 100644 index 000000000..e97a95a32 --- /dev/null +++ b/.changes/fix-premature-publication-dispose @@ -0,0 +1 @@ +patch type="fixed" "Fix premature publication dispose during unpublish" diff --git a/lib/src/participant/local.dart b/lib/src/participant/local.dart index 56544de00..e785b72fa 100644 --- a/lib/src/participant/local.dart +++ b/lib/src/participant/local.dart @@ -546,8 +546,6 @@ class LocalParticipant extends Participant { logger.warning('Publication not found $trackSid'); return; } - await pub.dispose(); - final track = pub.track; if (track != null) { if (room.roomOptions.stopLocalTrackOnUnpublish) { diff --git a/lib/src/participant/remote.dart b/lib/src/participant/remote.dart index 95ebd82b7..5a8c9af62 100644 --- a/lib/src/participant/remote.dart +++ b/lib/src/participant/remote.dart @@ -297,8 +297,6 @@ class RemoteParticipant extends Participant { logger.warning('Publication not found $trackSid'); return; } - await pub.dispose(); - final track = pub.track; // if has track if (track != null) { From d3d38fb7df0afcf8a2daf134717987d915fcfc51 Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Wed, 25 Mar 2026 14:44:20 +0900 Subject: [PATCH 09/11] Await sendSyncState call in room Add missing await after sendSyncState return type was changed to Future, fixing the unawaited_futures analyzer warning. --- lib/src/core/room.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/src/core/room.dart b/lib/src/core/room.dart index 67b182627..f274a0289 100644 --- a/lib/src/core/room.dart +++ b/lib/src/core/room.dart @@ -971,7 +971,7 @@ class Room extends DisposableChangeNotifier with EventsEmittable { } } - engine.sendSyncState( + await engine.sendSyncState( subscription: lk_rtc.UpdateSubscription( participantTracks: [], trackSids: trackSids, From d67dbe4d13e98c1b0aea25c1aebdc86d9b7bafd7 Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Wed, 25 Mar 2026 14:54:41 +0900 Subject: [PATCH 10/11] Refactoring --- lib/src/core/engine.dart | 65 ++++++++++++++++++++-------------------- 1 file changed, 32 insertions(+), 33 deletions(-) diff --git a/lib/src/core/engine.dart b/lib/src/core/engine.dart index e857d573a..97700d65d 100644 --- a/lib/src/core/engine.dart +++ b/lib/src/core/engine.dart @@ -121,20 +121,19 @@ class Engine extends Disposable with EventsEmittable { late EventsListener _signalListener = signalClient.createListener(synchronized: true); - int reconnectAttempts = 0; - - Timer? reconnectTimeout; - DateTime? reconnectStart; + int _reconnectAttempts = 0; + Timer? _reconnectTimeout; + DateTime? _reconnectStart; bool _isClosed = false; bool get isClosed => _isClosed; - bool get isPendingReconnect => reconnectStart != null && reconnectTimeout != null; + bool get isPendingReconnect => _reconnectStart != null && _reconnectTimeout != null; final int _reconnectCount = defaultRetryDelaysInMs.length; - bool attemptingReconnect = false; + bool _attemptingReconnect = false; RegionUrlProvider? _regionUrlProvider; @@ -179,17 +178,17 @@ class Engine extends Disposable with EventsEmittable { return null; } - void clearReconnectTimeout() { - if (reconnectTimeout != null) { - reconnectTimeout?.cancel(); - reconnectTimeout = null; + void _clearReconnectTimeout() { + if (_reconnectTimeout != null) { + _reconnectTimeout?.cancel(); + _reconnectTimeout = null; } } - void clearPendingReconnect() { - clearReconnectTimeout(); - reconnectAttempts = 0; - reconnectStart = null; + void _clearPendingReconnect() { + _clearReconnectTimeout(); + _reconnectAttempts = 0; + _reconnectStart = null; } Engine({ @@ -290,7 +289,7 @@ class Engine extends Disposable with EventsEmittable { await signalClient.cleanUp(); fullReconnectOnNext = false; - attemptingReconnect = false; + _attemptingReconnect = false; // Reset reliability state _reliableDataSequence = 1; @@ -298,7 +297,7 @@ class Engine extends Disposable with EventsEmittable { _reliableReceivedState.clear(); _isReconnecting = false; - clearPendingReconnect(); + _clearPendingReconnect(); } @internal @@ -999,11 +998,11 @@ class Engine extends Disposable with EventsEmittable { _isReconnecting = true; - if (reconnectAttempts == 0) { - reconnectStart = DateTime.timestamp(); + if (_reconnectAttempts == 0) { + _reconnectStart = DateTime.timestamp(); } - if (reconnectAttempts >= _reconnectCount) { + if (_reconnectAttempts >= _reconnectCount) { logger.fine('reconnectAttempts exceeded, disconnecting...'); _isClosed = true; await cleanUp(); @@ -1014,26 +1013,26 @@ class Engine extends Disposable with EventsEmittable { return; } - var delay = defaultRetryDelaysInMs[reconnectAttempts]; + var delay = defaultRetryDelaysInMs[_reconnectAttempts]; // Add random jitter to prevent thundering herd on reconnect - if (reconnectAttempts > 1) { + if (_reconnectAttempts > 1) { delay += math.Random().nextInt(1000); } events.emit(EngineAttemptReconnectEvent( - attempt: reconnectAttempts + 1, + attempt: _reconnectAttempts + 1, maxAttempts: _reconnectCount, nextRetryDelaysInMs: delay, )); - clearReconnectTimeout(); + _clearReconnectTimeout(); if (token != null && _regionUrlProvider != null) { // token may have been refreshed, we do not want to recreate the regionUrlProvider // since the current engine may have inherited a regional url _regionUrlProvider!.updateToken(token!); } - logger.fine('WebSocket reconnecting in $delay ms, retry times $reconnectAttempts'); - reconnectTimeout = Timer(Duration(milliseconds: delay), () async { + logger.fine('WebSocket reconnecting in $delay ms, retry times $_reconnectAttempts'); + _reconnectTimeout = Timer(Duration(milliseconds: delay), () async { await attemptReconnect( reason, reconnectReason: reconnectReason, @@ -1051,7 +1050,7 @@ class Engine extends Disposable with EventsEmittable { } // guard for attempting reconnection multiple times while one attempt is still not finished - if (attemptingReconnect) { + if (_attemptingReconnect) { return; } @@ -1065,7 +1064,7 @@ class Engine extends Disposable with EventsEmittable { } try { - attemptingReconnect = true; + _attemptingReconnect = true; if (await signalClient.networkIsAvailable() == false) { logger.fine('no internet connection, waiting...'); @@ -1086,11 +1085,11 @@ class Engine extends Disposable with EventsEmittable { reconnectReason: reconnectReason, ); } - clearPendingReconnect(); - attemptingReconnect = false; + _clearPendingReconnect(); + _attemptingReconnect = false; _isReconnecting = false; } catch (e) { - reconnectAttempts = reconnectAttempts + 1; + _reconnectAttempts = _reconnectAttempts + 1; bool recoverable = true; if (e is WebSocketException || e is MediaConnectException) { // cannot resume connection, need to do full reconnect @@ -1111,7 +1110,7 @@ class Engine extends Disposable with EventsEmittable { await cleanUp(); } } finally { - attemptingReconnect = false; + _attemptingReconnect = false; } } @@ -1336,7 +1335,7 @@ class Engine extends Disposable with EventsEmittable { }) ..on((event) async { logger.fine('Signal connected'); - reconnectAttempts = 0; + _reconnectAttempts = 0; events.emit(const EngineConnectedEvent()); }) ..on((event) async { @@ -1456,7 +1455,7 @@ class Engine extends Disposable with EventsEmittable { logger.fine('disconnect: Cancel the reconnection processing!'); await signalClient.cleanUp(); await _signalListener.cancelAll(); - clearPendingReconnect(); + _clearPendingReconnect(); } await cleanUp(); events.emit(EngineDisconnectedEvent(reason: reason)); From a204aac986e05fd669574b8abe6ebc00ccae2059 Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Sun, 29 Mar 2026 19:54:46 +0800 Subject: [PATCH 11/11] adjust changes entry --- .changes/fix-send-sync-state-return-type | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changes/fix-send-sync-state-return-type b/.changes/fix-send-sync-state-return-type index 4bdd6f3bd..ed91cbb36 100644 --- a/.changes/fix-send-sync-state-return-type +++ b/.changes/fix-send-sync-state-return-type @@ -1 +1 @@ -patch type="fixed" "Fix sendSyncState returning void instead of Future" +patch type="fixed" "Fix sendSyncState using async void and swallowing sync-state preparation errors"