Skip to content
1 change: 1 addition & 0 deletions .changes/reconnect-request-dispatch
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
patch type="fixed" "Reconnect requests are no longer dropped when one attempt is already running, and a reason that requires a full reconnect is no longer lost when a later request replaces it"
1 change: 1 addition & 0 deletions .changes/resume-severed-signal-retry
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
patch type="fixed" "A resume whose signal connection drops before it completes is retried instead of being reported as reconnected"
60 changes: 55 additions & 5 deletions lib/src/core/engine.dart
Original file line number Diff line number Diff line change
Expand Up @@ -114,8 +114,18 @@ class Engine extends Disposable with EventsEmittable<EngineEvent> {
String? _connectedServerAddress;
String? get connectedServerAddress => _connectedServerAddress;

/// A *pending* full-reconnect request. Consumed at the start of each
/// reconnect attempt, so it is false while an attempt runs unless a new
/// request arrived mid-attempt — use [isFullReconnectInProgress] to ask
/// what the running attempt is doing.
bool fullReconnectOnNext = false;

bool _attemptIsFullReconnect = false;

/// Whether the reconnect attempt currently running is a full reconnect
/// (as opposed to a resume). False when no attempt is in flight.
bool get isFullReconnectInProgress => _attemptIsFullReconnect;

// server-provided ice servers
List<RTCIceServer> _serverProvidedIceServers = [];

Expand Down Expand Up @@ -1148,6 +1158,15 @@ class Engine extends Disposable with EventsEmittable<EngineEvent> {
fullReconnectOnNext = true;
}

// Consume the flag up front: this attempt's mode is now fixed, and from
// here a `true` value unambiguously means a *new* full-reconnect request
// arrived while we were running (e.g. a server RECONNECT leave during a
// resume), which the finally block dispatches. Mirrors client-sdk-js.
final fullReconnect = fullReconnectOnNext;
fullReconnectOnNext = false;
_attemptIsFullReconnect = fullReconnect;

var succeeded = false;
try {
_attemptingReconnect = true;

Expand All @@ -1163,7 +1182,7 @@ class Engine extends Disposable with EventsEmittable<EngineEvent> {
);
}

if (fullReconnectOnNext) {
if (fullReconnect) {
await restartConnection();
} else {
await resumeConnection(
Expand All @@ -1174,11 +1193,14 @@ class Engine extends Disposable with EventsEmittable<EngineEvent> {
_clearPendingReconnect();
_attemptingReconnect = false;
_isReconnecting = false;
succeeded = true;
} catch (e) {
_reconnectAttempts = _reconnectAttempts + 1;
logger.fine('attemptReconnect: ${fullReconnect ? 'full reconnect' : 'resume'} failed: $e');
bool recoverable = true;
if (e is WebSocketException || e is MediaConnectException) {
// cannot resume connection, need to do full reconnect
if (fullReconnect || e is WebSocketException || e is MediaConnectException) {
// a failed full reconnect stays a full reconnect; a resume that failed
// at the transport or media layer cannot be resumed again
fullReconnectOnNext = true;
}

Expand Down Expand Up @@ -1206,6 +1228,15 @@ class Engine extends Disposable with EventsEmittable<EngineEvent> {
}
} finally {
_attemptingReconnect = false;
_attemptIsFullReconnect = false;

// A full reconnect requested while this attempt was running that a
// successful attempt didn't act on — dispatch it now. The failure path
// already retries, so only the success path needs this.
if (succeeded && fullReconnectOnNext && !_isClosed) {
logger.fine('attemptReconnect: full reconnect requested mid-attempt, dispatching');
unawaited(handleReconnect(ClientDisconnectReason.reconnectRetry));
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
Comment on lines +1236 to +1238

@devin-ai-integration devin-ai-integration Bot Sep 14, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Resume reconnect requests still disappear

When a resume request arrives during a successful attempt, fullReconnectOnNext remains false and blocks redispatch. _clearPendingReconnect cancels that request's timer, so the new disconnection gets no reconnect attempt.

Learn more

A reconnect request can arrive after the running attempt has passed the operation that prompted it but before that attempt finishes. handleReconnect schedules the new request regardless of its mode. The successful attempt then calls _clearPendingReconnect, cancelling that timer. This condition only preserves requests represented by fullReconnectOnNext, so resume requests still disappear.

Example: A resume reconnects signaling and receives SignalReconnectedEvent. Before its peer-connection work finishes, signaling disconnects again and schedules a resume. The first attempt succeeds, cancels the second timer, and emits success although signaling is now disconnected.

Recommended fix: Track whether any reconnect request arrived during the running attempt separately from its full-reconnect escalation. On success, dispatch the pending request with its captured reason and reconnectReason; preserve full escalation independently.

Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

}
}
}

Expand Down Expand Up @@ -1264,6 +1295,18 @@ class Engine extends Disposable with EventsEmittable<EngineEvent> {
logger.fine('resumeConnection: primary connected');
}

// The socket can drop while the peer connections were being restored. A
// resume that ends with a dead signal connection is a failure, not a
// success: throwing here lets the retry path run another resume instead of
// reporting the room as reconnected and cancelling the pending request.
// Mirrors the re-check in client-sdk-js and rust-sdks.
if (signalClient.connectionState != ConnectionState.connected) {
throw ConnectException(
'resumeConnection: signal connection severed during resume',
reason: ConnectionErrorReason.InternalError,
);
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
}

_isReconnecting = false;
events.emit(const EngineResumedEvent());
}
Expand Down Expand Up @@ -1311,7 +1354,10 @@ class Engine extends Disposable with EventsEmittable<EngineEvent> {
await ensurePublisherConnected();
}

fullReconnectOnNext = false;
// fullReconnectOnNext is not cleared here. attemptReconnect consumed the
// request that started this restart, so a true value at this point is a
// new request (e.g. a RECONNECT leave from the node we just joined) that
// the finally block in attemptReconnect dispatches once we return.
_regionUrlProvider?.resetAttempts();
events.emit(const EngineRestartedEvent());
} catch (error) {
Expand Down Expand Up @@ -1449,7 +1495,11 @@ class Engine extends Disposable with EventsEmittable<EngineEvent> {
})
..on<SignalConnectedEvent>((event) async {
logger.fine('Signal connected');
_reconnectAttempts = 0;
// The attempt counter is not reset here. A resume opens its socket before
// the peer connections are restored, so a reset on socket connect would
// let an attempt that fails afterwards start again from zero and never
// reach the retry limit. _clearPendingReconnect resets it once an attempt
// has fully succeeded, and cleanUp on disconnect.
events.emit(const EngineConnectedEvent());
})
..on<SignalConnectingEvent>((event) async {
Expand Down
11 changes: 8 additions & 3 deletions lib/src/core/room.dart
Original file line number Diff line number Diff line change
Expand Up @@ -507,7 +507,7 @@ class Room extends DisposableChangeNotifier with EventsEmittable<RoomEvent> {
info: event.response.participant,
);

if (engine.fullReconnectOnNext) {
if (engine.isFullReconnectInProgress) {
await _localParticipant!.updateFromInfo(event.response.participant);
}

Expand All @@ -522,7 +522,7 @@ class Room extends DisposableChangeNotifier with EventsEmittable<RoomEvent> {

if (connectOptions.protocolVersion.index >= ProtocolVersion.v8.index &&
engine.fastConnectOptions != null &&
!engine.fullReconnectOnNext) {
!engine.isFullReconnectInProgress) {
final options = engine.fastConnectOptions!;

final audio = options.microphone;
Expand Down Expand Up @@ -651,7 +651,12 @@ class Room extends DisposableChangeNotifier with EventsEmittable<RoomEvent> {
notifyListeners();
})
..on<EngineDisconnectedEvent>((event) async {
if (!engine.fullReconnectOnNext || event.reason == DisconnectReason.clientInitiated) {
// Suppress while a full reconnect is either pending or running — the
// engine is going to re-establish the session, this is not a real
// disconnect. Both flags are needed since the attempt consumes the
// pending one when it starts.
if ((!engine.fullReconnectOnNext && !engine.isFullReconnectInProgress) ||
event.reason == DisconnectReason.clientInitiated) {
await _cleanUp(disposeLocalParticipant: false);
events.emit(RoomDisconnectedEvent(reason: event.reason));
notifyListeners();
Expand Down
Loading
Loading