Skip to content

Commit 051581b

Browse files
authored
Emit a single disconnected event when connecting fails (#1126)
## Summary Follow-up to #1065. Ensures the SDK emits exactly one `RoomDisconnectedEvent` per failed connection attempt. Previously a failed initial connect produced two `EngineDisconnectedEvent`s, and therefore two `RoomDisconnectedEvent`s and two room cleanups: 1. The signal client emits `SignalDisconnectedEvent(signalingConnectionFailure)` while `connect()` is failing (the validate path has done this since #406, and the certificate pinning path in #1065 follows the same pattern), which the engine relayed as `EngineDisconnectedEvent`. 2. `Engine.connect`'s catch then emits `EngineDisconnectedEvent(joinFailure)` for the same failure. ## Changes - `Engine.connect`'s catch is now the single emitter for initial connect failures. It picks the reason by error type: `signalingConnectionFailure` for `CertificatePinningException`, `joinFailure` otherwise. - The `signalingConnectionFailure` relay in the engine's signal listener is removed (with a comment explaining why). During reconnects the engine's reconnect handling already owns disconnect emission, so the relay only ever produced duplicates. This also removes the `_isReconnecting`/`_attemptingReconnect` guard that #1065 added to suppress the relay during reconnects, since there is no longer anything to suppress. - `SignalDisconnectedEvent` at the signal level is unchanged, only the engine-level relay is removed. ## Behavior change Apps listening for `RoomDisconnectedEvent` now receive one event per failed connect instead of two. The reasons are unchanged for the common case (`joinFailure`); certificate pinning failures surface as `signalingConnectionFailure`. Apps that specifically depended on receiving both events for a single failure would see one. ## Testing Added room-level tests through the mock e2e container asserting exactly one `RoomDisconnectedEvent` per failed initial connect, for both a certificate pinning failure (`signalingConnectionFailure`) and a generic websocket failure (`joinFailure`). Both tests fail against the previous code (two events observed) and pass with this change.
1 parent d4c870a commit 051581b

3 files changed

Lines changed: 130 additions & 14 deletions

File tree

.changes/single-disconnect-event

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
patch type="fixed" "Emit a single disconnected event when connecting fails"

lib/src/core/engine.dart

Lines changed: 15 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -268,9 +268,16 @@ class Engine extends Disposable with EventsEmittable<EngineEvent> {
268268
} catch (error) {
269269
logger.fine('Connect Error $error');
270270

271-
events.emit(EngineDisconnectedEvent(
272-
reason: DisconnectReason.joinFailure,
273-
));
271+
// during a reconnect this connect() runs inside restartConnection and
272+
// attemptReconnect owns disconnect emission, emitting here as well
273+
// would produce two events for one failure
274+
if (!_isReconnecting && !_attemptingReconnect) {
275+
events.emit(EngineDisconnectedEvent(
276+
reason: error is CertificatePinningException
277+
? DisconnectReason.signalingConnectionFailure
278+
: DisconnectReason.joinFailure,
279+
));
280+
}
274281
rethrow;
275282
}
276283
}
@@ -1367,18 +1374,12 @@ class Engine extends Disposable with EventsEmittable<EngineEvent> {
13671374
if (event.reason == DisconnectReason.disconnected && !_isClosed) {
13681375
await handleReconnect(ClientDisconnectReason.signal,
13691376
reconnectReason: lk_models.ReconnectReason.RR_SIGNAL_DISCONNECTED);
1370-
} else if (event.reason == DisconnectReason.signalingConnectionFailure) {
1371-
// while reconnecting, attemptReconnect owns disconnect handling and
1372-
// emits EngineDisconnectedEvent itself, relaying here as well would
1373-
// race it with a duplicate event. _attemptingReconnect covers the
1374-
// window where cleanUp() has already reset _isReconnecting but
1375-
// attemptReconnect has not finished its error handling yet
1376-
if (!_isReconnecting && !_attemptingReconnect) {
1377-
events.emit(EngineDisconnectedEvent(
1378-
reason: event.reason,
1379-
));
1380-
}
13811377
}
1378+
// signalingConnectionFailure is intentionally not relayed as
1379+
// EngineDisconnectedEvent here. The signal client emits it while the
1380+
// connect() call is failing, so connect()'s own catch (initial connect)
1381+
// or attemptReconnect (reconnect) already emits the engine event, and
1382+
// relaying here produced a duplicate disconnect per failure.
13821383
})
13831384
..on<SignalOfferEvent>((event) async {
13841385
if (subscriber == null) {
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
// Copyright 2026 LiveKit, Inc.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
@Timeout(Duration(seconds: 5))
16+
library;
17+
18+
import 'package:flutter_test/flutter_test.dart';
19+
20+
import 'package:livekit_client/livekit_client.dart';
21+
import 'package:livekit_client/src/internal/events.dart';
22+
import 'package:livekit_client/src/support/websocket.dart';
23+
import 'package:livekit_client/src/types/internal.dart';
24+
import '../mock/e2e_container.dart';
25+
26+
const exampleUri = 'ws://www.example.com';
27+
const token = 'token';
28+
29+
void main() {
30+
TestWidgetsFlutterBinding.ensureInitialized();
31+
32+
late E2EContainer container;
33+
34+
setUp(() {
35+
container = E2EContainer();
36+
});
37+
38+
tearDown(() async {
39+
await container.dispose();
40+
});
41+
42+
test('emits exactly one disconnected event when pinning fails on initial connect', () async {
43+
container.wsConnector.connectError =
44+
CertificatePinningException('Certificate pin mismatch', host: 'www.example.com');
45+
46+
final disconnectedEvents = <RoomDisconnectedEvent>[];
47+
container.room.events.listen((event) {
48+
if (event is RoomDisconnectedEvent) {
49+
disconnectedEvents.add(event);
50+
}
51+
});
52+
53+
await expectLater(
54+
container.room.connect(exampleUri, token),
55+
throwsA(isA<CertificatePinningException>()),
56+
);
57+
58+
// allow all pending event deliveries to complete
59+
await Future<void>.delayed(const Duration(milliseconds: 100));
60+
61+
expect(disconnectedEvents, hasLength(1));
62+
expect(disconnectedEvents.single.reason, DisconnectReason.signalingConnectionFailure);
63+
});
64+
65+
test('emits exactly one disconnected event when initial connect fails', () async {
66+
container.wsConnector.connectError = WebSocketException('Failed to connect');
67+
68+
final disconnectedEvents = <RoomDisconnectedEvent>[];
69+
container.room.events.listen((event) {
70+
if (event is RoomDisconnectedEvent) {
71+
disconnectedEvents.add(event);
72+
}
73+
});
74+
75+
await expectLater(
76+
container.room.connect(exampleUri, token),
77+
throwsA(isA<Exception>()),
78+
);
79+
80+
await Future<void>.delayed(const Duration(milliseconds: 100));
81+
82+
expect(disconnectedEvents, hasLength(1));
83+
expect(disconnectedEvents.single.reason, DisconnectReason.joinFailure);
84+
});
85+
86+
test('emits exactly one disconnected event when pinning fails during a full reconnect', () async {
87+
await container.connectRoom();
88+
89+
final engineDisconnectedEvents = <EngineDisconnectedEvent>[];
90+
container.engine.events.listen((event) {
91+
if (event is EngineDisconnectedEvent) {
92+
engineDisconnectedEvents.add(event);
93+
}
94+
});
95+
final roomDisconnectedEvents = <RoomDisconnectedEvent>[];
96+
container.room.events.listen((event) {
97+
if (event is RoomDisconnectedEvent) {
98+
roomDisconnectedEvents.add(event);
99+
}
100+
});
101+
102+
container.wsConnector.connectError =
103+
CertificatePinningException('Certificate pin mismatch', host: 'www.example.com');
104+
container.engine.fullReconnectOnNext = true;
105+
await container.engine.attemptReconnect(ClientDisconnectReason.reconnectRetry);
106+
107+
await Future<void>.delayed(const Duration(milliseconds: 100));
108+
109+
expect(engineDisconnectedEvents, hasLength(1));
110+
expect(engineDisconnectedEvents.single.reason, DisconnectReason.signalingConnectionFailure);
111+
expect(roomDisconnectedEvents, hasLength(1));
112+
expect(roomDisconnectedEvents.single.reason, DisconnectReason.signalingConnectionFailure);
113+
});
114+
}

0 commit comments

Comments
 (0)