From 11a9f0fd763d450971f54632ae519d0dc7b9f1d4 Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Mon, 6 Jul 2026 17:35:39 +0900 Subject: [PATCH 1/2] Emit a single disconnected event when connecting fails --- .changes/single-disconnect-event | 1 + lib/src/core/engine.dart | 20 +++---- test/core/disconnect_event_test.dart | 83 ++++++++++++++++++++++++++++ 3 files changed, 92 insertions(+), 12 deletions(-) create mode 100644 .changes/single-disconnect-event create mode 100644 test/core/disconnect_event_test.dart diff --git a/.changes/single-disconnect-event b/.changes/single-disconnect-event new file mode 100644 index 000000000..f5f54f66c --- /dev/null +++ b/.changes/single-disconnect-event @@ -0,0 +1 @@ +patch type="fixed" "Emit a single disconnected event when connecting fails" diff --git a/lib/src/core/engine.dart b/lib/src/core/engine.dart index 6129a2acc..4a6a32099 100644 --- a/lib/src/core/engine.dart +++ b/lib/src/core/engine.dart @@ -269,7 +269,9 @@ class Engine extends Disposable with EventsEmittable { logger.fine('Connect Error $error'); events.emit(EngineDisconnectedEvent( - reason: DisconnectReason.joinFailure, + reason: error is CertificatePinningException + ? DisconnectReason.signalingConnectionFailure + : DisconnectReason.joinFailure, )); rethrow; } @@ -1367,18 +1369,12 @@ class Engine extends Disposable with EventsEmittable { if (event.reason == DisconnectReason.disconnected && !_isClosed) { await handleReconnect(ClientDisconnectReason.signal, reconnectReason: lk_models.ReconnectReason.RR_SIGNAL_DISCONNECTED); - } else if (event.reason == DisconnectReason.signalingConnectionFailure) { - // while reconnecting, attemptReconnect owns disconnect handling and - // emits EngineDisconnectedEvent itself, relaying here as well would - // race it with a duplicate event. _attemptingReconnect covers the - // window where cleanUp() has already reset _isReconnecting but - // attemptReconnect has not finished its error handling yet - if (!_isReconnecting && !_attemptingReconnect) { - events.emit(EngineDisconnectedEvent( - reason: event.reason, - )); - } } + // signalingConnectionFailure is intentionally not relayed as + // EngineDisconnectedEvent here. The signal client emits it while the + // connect() call is failing, so connect()'s own catch (initial connect) + // or attemptReconnect (reconnect) already emits the engine event, and + // relaying here produced a duplicate disconnect per failure. }) ..on((event) async { if (subscriber == null) { diff --git a/test/core/disconnect_event_test.dart b/test/core/disconnect_event_test.dart new file mode 100644 index 000000000..79db9709e --- /dev/null +++ b/test/core/disconnect_event_test.dart @@ -0,0 +1,83 @@ +// 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/support/websocket.dart'; +import '../mock/e2e_container.dart'; + +const exampleUri = 'ws://www.example.com'; +const token = 'token'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late E2EContainer container; + + setUp(() { + container = E2EContainer(); + }); + + tearDown(() async { + await container.dispose(); + }); + + test('emits exactly one disconnected event when pinning fails on initial connect', () async { + container.wsConnector.connectError = + CertificatePinningException('Certificate pin mismatch', host: 'www.example.com'); + + final disconnectedEvents = []; + container.room.events.listen((event) { + if (event is RoomDisconnectedEvent) { + disconnectedEvents.add(event); + } + }); + + await expectLater( + container.room.connect(exampleUri, token), + throwsA(isA()), + ); + + // allow all pending event deliveries to complete + await Future.delayed(const Duration(milliseconds: 100)); + + expect(disconnectedEvents, hasLength(1)); + expect(disconnectedEvents.single.reason, DisconnectReason.signalingConnectionFailure); + }); + + test('emits exactly one disconnected event when initial connect fails', () async { + container.wsConnector.connectError = WebSocketException('Failed to connect'); + + final disconnectedEvents = []; + container.room.events.listen((event) { + if (event is RoomDisconnectedEvent) { + disconnectedEvents.add(event); + } + }); + + await expectLater( + container.room.connect(exampleUri, token), + throwsA(isA()), + ); + + await Future.delayed(const Duration(milliseconds: 100)); + + expect(disconnectedEvents, hasLength(1)); + expect(disconnectedEvents.single.reason, DisconnectReason.joinFailure); + }); +} From a42afaccc12d670b964add0730768ff652a58545 Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Tue, 7 Jul 2026 01:19:17 +0900 Subject: [PATCH 2/2] Suppress connect failure disconnect event during reconnects --- lib/src/core/engine.dart | 15 +++++++++----- test/core/disconnect_event_test.dart | 31 ++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 5 deletions(-) diff --git a/lib/src/core/engine.dart b/lib/src/core/engine.dart index 4a6a32099..cf2c4c162 100644 --- a/lib/src/core/engine.dart +++ b/lib/src/core/engine.dart @@ -268,11 +268,16 @@ class Engine extends Disposable with EventsEmittable { } catch (error) { logger.fine('Connect Error $error'); - events.emit(EngineDisconnectedEvent( - reason: error is CertificatePinningException - ? DisconnectReason.signalingConnectionFailure - : DisconnectReason.joinFailure, - )); + // during a reconnect this connect() runs inside restartConnection and + // attemptReconnect owns disconnect emission, emitting here as well + // would produce two events for one failure + if (!_isReconnecting && !_attemptingReconnect) { + events.emit(EngineDisconnectedEvent( + reason: error is CertificatePinningException + ? DisconnectReason.signalingConnectionFailure + : DisconnectReason.joinFailure, + )); + } rethrow; } } diff --git a/test/core/disconnect_event_test.dart b/test/core/disconnect_event_test.dart index 79db9709e..a89bf3561 100644 --- a/test/core/disconnect_event_test.dart +++ b/test/core/disconnect_event_test.dart @@ -18,7 +18,9 @@ library; import 'package:flutter_test/flutter_test.dart'; import 'package:livekit_client/livekit_client.dart'; +import 'package:livekit_client/src/internal/events.dart'; import 'package:livekit_client/src/support/websocket.dart'; +import 'package:livekit_client/src/types/internal.dart'; import '../mock/e2e_container.dart'; const exampleUri = 'ws://www.example.com'; @@ -80,4 +82,33 @@ void main() { expect(disconnectedEvents, hasLength(1)); expect(disconnectedEvents.single.reason, DisconnectReason.joinFailure); }); + + test('emits exactly one disconnected event when pinning fails during a full reconnect', () async { + await container.connectRoom(); + + final engineDisconnectedEvents = []; + container.engine.events.listen((event) { + if (event is EngineDisconnectedEvent) { + engineDisconnectedEvents.add(event); + } + }); + final roomDisconnectedEvents = []; + container.room.events.listen((event) { + if (event is RoomDisconnectedEvent) { + roomDisconnectedEvents.add(event); + } + }); + + container.wsConnector.connectError = + CertificatePinningException('Certificate pin mismatch', host: 'www.example.com'); + container.engine.fullReconnectOnNext = true; + await container.engine.attemptReconnect(ClientDisconnectReason.reconnectRetry); + + await Future.delayed(const Duration(milliseconds: 100)); + + expect(engineDisconnectedEvents, hasLength(1)); + expect(engineDisconnectedEvents.single.reason, DisconnectReason.signalingConnectionFailure); + expect(roomDisconnectedEvents, hasLength(1)); + expect(roomDisconnectedEvents.single.reason, DisconnectReason.signalingConnectionFailure); + }); }