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/disconnect-completes-on-socket-close
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
patch type="fixed" "disconnect() tears the room down locally after sending the Leave instead of waiting for the server to echo it, so it no longer stalls 10 s when the echo is lost"
27 changes: 18 additions & 9 deletions lib/src/core/engine.dart
Original file line number Diff line number Diff line change
Expand Up @@ -1512,6 +1512,7 @@ class Engine extends Disposable with EventsEmittable<EngineEvent> {
})
..on<SignalDisconnectedEvent>((event) async {
logger.fine('Signal disconnected ${event.reason}');
// after disconnect() the close is ours, cleanUp() already ran
if (event.reason == DisconnectReason.disconnected && !_isClosed) {
await handleReconnect(
ClientDisconnectReason.signal,
Expand Down Expand Up @@ -1600,6 +1601,12 @@ class Engine extends Disposable with EventsEmittable<EngineEvent> {
await handleReconnect(ClientDisconnectReason.leaveReconnect);
} else {
// DISCONNECT or v12 server with canReconnect=false
if (_isClosed) {
// the echo of our own Leave, or a server Leave that raced
// disconnect(). The local teardown has run or is running.
logger.fine('[Signal] Leave received after disconnect() started, ignoring');
return;
}
await signalClient.cleanUp();
fullReconnectOnNext = false;
await disconnect(reason: event.reason.toSDKType());
Expand All @@ -1622,17 +1629,19 @@ class Engine extends Disposable with EventsEmittable<EngineEvent> {
_isClosed = true;
events.emit(EngineClosingEvent());
if (connectionState == ConnectionState.connected) {
// Tell the server, then tear down locally without waiting for its Leave
// echo, the same as the other SDKs. The echo is not guaranteed: media
// nodes drop queued leave messages when they close the signal sink, and
// waiting for it cost a 10 s timeout whenever it was lost.
await signalClient.sendLeave();
} else {
if (isPendingReconnect) {
logger.fine('disconnect: Cancel the reconnection processing!');
await signalClient.cleanUp();
await _signalListener.cancelAll();
_clearPendingReconnect();
}
await cleanUp();
events.emit(EngineDisconnectedEvent(reason: reason));
} else if (isPendingReconnect) {
logger.fine('disconnect: Cancel the reconnection processing!');
await signalClient.cleanUp();
await _signalListener.cancelAll();
_clearPendingReconnect();
}
await cleanUp();
events.emit(EngineDisconnectedEvent(reason: reason));
}

void setRegionUrlProvider(RegionUrlProvider provider) {
Expand Down
25 changes: 18 additions & 7 deletions lib/src/core/room.dart
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,9 @@ class Room extends DisposableChangeNotifier with EventsEmittable<RoomEvent> {
final Engine engine;
// suppport for multiple event listeners
late final EventsListener<EngineEvent> _engineListener;

// true while disconnect() is tearing the room down itself
bool _disconnecting = false;
//
late EventsListener<SignalEvent> _signalListener;

Expand Down Expand Up @@ -655,7 +658,11 @@ class Room extends DisposableChangeNotifier with EventsEmittable<RoomEvent> {
// pending one when it starts.
if ((!engine.fullReconnectOnNext && !engine.isFullReconnectInProgress) ||
event.reason == DisconnectReason.clientInitiated) {
await _cleanUp(disposeLocalParticipant: false);
// disconnect() owns the teardown for the disconnect it started, so a
// connect() right after it returns cannot race this handler
if (!_disconnecting) {
await _cleanUp(disposeLocalParticipant: false);
}
events.emit(RoomDisconnectedEvent(reason: event.reason));
notifyListeners();
}
Expand Down Expand Up @@ -761,17 +768,21 @@ class Room extends DisposableChangeNotifier with EventsEmittable<RoomEvent> {
});

/// Disconnects from the room, notifying server of disconnection.
/// Leaves the room. Returns once the engine and the room are torn down, so
/// connect() may be called again immediately. Nothing here waits on the
/// server, the Leave request is best effort.
Future<void> disconnect() async {
final bool isPendingReconnect = engine.isPendingReconnect;
if (engine.isClosed && !isPendingReconnect && engine.connectionState == ConnectionState.disconnected) {
if (engine.isClosed && !engine.isPendingReconnect && engine.connectionState == ConnectionState.disconnected) {
logger.warning('Engine is already closed');
return;
}
await engine.disconnect();
if (!isPendingReconnect) {
await _engineListener.waitFor<EngineDisconnectedEvent>(duration: const Duration(seconds: 10));
_disconnecting = true;
try {
await engine.disconnect();
await _cleanUp();
} finally {
_disconnecting = false;
}
await _cleanUp();
}

Future<void> setE2EEEnabled(bool enabled) async {
Expand Down
81 changes: 81 additions & 0 deletions test/core/disconnect_event_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ 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/proto/livekit_models.pb.dart' as lk_models;
import 'package:livekit_client/src/proto/livekit_rtc.pb.dart' as lk_rtc;
import 'package:livekit_client/src/support/websocket.dart';
import 'package:livekit_client/src/types/internal.dart';
import '../mock/e2e_container.dart';
Expand Down Expand Up @@ -115,4 +117,83 @@ void main() {
expect(roomDisconnectedEvents, hasLength(1));
expect(roomDisconnectedEvents.single.reason, DisconnectReason.signalingConnectionFailure);
});

test('disconnect completes when the server closes the socket without echoing the leave', () async {
await container.connectRoom();
final disconnectedEvents = <RoomDisconnectedEvent>[];
container.room.events.on<RoomDisconnectedEvent>(disconnectedEvents.add);

final disconnecting = container.room.disconnect();
await Future<void>.delayed(const Duration(milliseconds: 10));
// media nodes drop queued leave messages on close, so only the close arrives
container.wsConnector.onDispose();

await disconnecting.timeout(const Duration(seconds: 2));
await Future<void>.delayed(const Duration(milliseconds: 50));

expect(container.room.connectionState, ConnectionState.disconnected);
expect(disconnectedEvents.map((e) => e.reason), [DisconnectReason.clientInitiated]);
});

test('disconnect with a leave echo followed by the socket close emits exactly one disconnected event', () async {
await container.connectRoom();
final disconnectedEvents = <RoomDisconnectedEvent>[];
container.room.events.on<RoomDisconnectedEvent>(disconnectedEvents.add);

final disconnecting = container.room.disconnect();
await Future<void>.delayed(const Duration(milliseconds: 10));
container.wsConnector.onData(
lk_rtc.SignalResponse(
leave: lk_rtc.LeaveRequest(
action: lk_rtc.LeaveRequest_Action.DISCONNECT,
reason: lk_models.DisconnectReason.CLIENT_INITIATED,
),
).writeToBuffer(),
);
await Future<void>.delayed(const Duration(milliseconds: 10));
// the real socket reports its close after the SDK disposed it
container.wsConnector.onDispose();

await disconnecting.timeout(const Duration(seconds: 2));
await Future<void>.delayed(const Duration(milliseconds: 50));

expect(container.room.connectionState, ConnectionState.disconnected);
expect(disconnectedEvents, hasLength(1));
});

test('disconnect returns with the room torn down, without waiting on the server', () async {
await container.connectRoom();
final disconnectedEvents = <RoomDisconnectedEvent>[];
container.room.events.on<RoomDisconnectedEvent>(disconnectedEvents.add);

// no leave echo, no socket close from the server, nothing at all
await container.room.disconnect().timeout(const Duration(seconds: 2));

expect(container.room.connectionState, ConnectionState.disconnected);
expect(container.engine.publisher, isNull);
expect(container.engine.subscriber, isNull);
expect(container.room.localParticipant, isNull);
await Future<void>.delayed(const Duration(milliseconds: 50));
expect(disconnectedEvents.map((e) => e.reason), [DisconnectReason.clientInitiated]);
});

test('connect again to the same room right after disconnect returns', () async {
// the case behind issue 553: cleanup from the first session racing the second connect
await container.connectRoom();
await container.room.disconnect();

final events = <RoomEvent>[];
container.room.events.on<RoomConnectedEvent>(events.add);
container.room.events.on<RoomDisconnectedEvent>(events.add);
await container.connectRoom();
await Future<void>.delayed(const Duration(milliseconds: 50));

expect(container.room.connectionState, ConnectionState.connected);
expect(container.room.localParticipant, isNotNull);
expect(events.whereType<RoomDisconnectedEvent>(), isEmpty, reason: 'no stale disconnect from the first session');
expect(events.whereType<RoomConnectedEvent>(), hasLength(1));

await container.room.disconnect().timeout(const Duration(seconds: 2));
expect(container.room.connectionState, ConnectionState.disconnected);
});
}
Loading