Skip to content

Commit 60f9372

Browse files
fix(supabase_flutter): replace lifecycle state tracking with serial Future chain
Replace complex lifecycle reconnection logic (_disconnectFuture, _realtimeReconnectOperation, cancel flag) with a serial Future chain and target-state pattern. Operations are serialized via _pendingLifecycleOperation so disconnect and connect never overlap. _targetLifecycleState tracks the most recent lifecycle request, allowing stale operations to be skipped and reconnects to be cancelled if the app goes back to background. Add idempotency guards on both connect (isConnected) and disconnect (isConnected || isConnecting) paths. Add tests for rapid lifecycle flapping and cancellation edge cases.
1 parent 0156cc8 commit 60f9372

2 files changed

Lines changed: 118 additions & 70 deletions

File tree

packages/supabase_flutter/lib/src/supabase.dart

Lines changed: 50 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@ import 'package:supabase_flutter/src/supabase_auth.dart';
1313

1414
import 'hot_restart_cleanup_stub.dart'
1515
if (dart.library.js_interop) 'hot_restart_cleanup_web.dart';
16-
1716
import 'version.dart';
1817

1918
final _log = Logger('supabase.supabase_flutter');
@@ -169,14 +168,20 @@ class Supabase with WidgetsBindingObserver {
169168
/// Wraps the `recoverSession()` call so that it can be terminated when `dispose()` is called
170169
late CancelableOperation _restoreSessionCancellableOperation;
171170

172-
CancelableOperation<void>? _realtimeReconnectOperation;
171+
/// Serial queue for lifecycle operations (connect/disconnect). Each event
172+
/// appends via `.then()` so operations never overlap.
173+
Future<void> _pendingLifecycleOperation = Future.value();
173174

174-
Future<void>? _disconnectFuture;
175+
/// The most recently requested lifecycle state. Checked inside
176+
/// [_processLifecycle] after each `await` to skip stale operations
177+
/// (e.g. abort a reconnect if the app went back to background).
178+
AppLifecycleState? _targetLifecycleState;
175179

176180
StreamSubscription? _logSubscription;
177181

178182
/// Dispose the instance to free up resources.
179183
Future<void> dispose() async {
184+
_targetLifecycleState = null;
180185
await _restoreSessionCancellableOperation.cancel();
181186
_logSubscription?.cancel();
182187
client.dispose();
@@ -226,80 +231,59 @@ class Supabase with WidgetsBindingObserver {
226231
void didChangeAppLifecycleState(AppLifecycleState state) {
227232
switch (state) {
228233
case AppLifecycleState.resumed:
229-
onResumed();
230-
case AppLifecycleState.detached:
231234
case AppLifecycleState.paused:
232-
_realtimeReconnectOperation?.cancel();
233-
_disconnectFuture = Supabase.instance.client.realtime.disconnect();
235+
case AppLifecycleState.detached:
236+
_targetLifecycleState = state;
237+
_pendingLifecycleOperation = _pendingLifecycleOperation
238+
.then((_) => _processLifecycle(state))
239+
.catchError((_) {});
234240
default:
241+
break;
235242
}
236243
}
237244

238-
Future<void> onResumed() async {
245+
/// Processes a lifecycle state change. Operations are serialized via
246+
/// [_pendingLifecycleOp] so that disconnect and connect never overlap.
247+
///
248+
/// [captured] is the lifecycle state at the time the event was enqueued.
249+
/// If a newer event has arrived since, this one is skipped (stale).
250+
Future<void> _processLifecycle(AppLifecycleState captured) async {
251+
// Skip if a newer lifecycle event has superseded this one.
252+
if (captured != _targetLifecycleState) return;
253+
239254
final realtime = Supabase.instance.client.realtime;
240-
if (realtime.channels.isNotEmpty) {
241-
final disconnectFuture = _disconnectFuture;
242-
if (disconnectFuture != null) {
243-
// If a disconnect is still in progress from e.g.
244-
// [AppLifecycleState.paused] we should wait for it to finish before
245-
// reconnecting. This avoids accessing conn! which may be nullified.
246-
//
247-
// We clear _disconnectFuture only after the future completes (not
248-
// eagerly) so that a second resumed event (e.g. inactive → resumed)
249-
// still sees the in-progress disconnect and waits for it instead of
250-
// falling through to the else branch where connect() would no-op.
251-
252-
bool cancel = false;
253-
final connectFuture = disconnectFuture.then((_) async {
254-
// Only clear if not replaced by a newer disconnect from a
255-
// subsequent paused event.
256-
if (_disconnectFuture == disconnectFuture) {
257-
_disconnectFuture = null;
258-
}
259-
260-
// Make this connect cancelable so that it does not connect if the
261-
// disconnect took so long that the app is already in background
262-
// again.
263-
if (!cancel) {
264-
// ignore: invalid_use_of_internal_member
265-
await realtime.connect();
266-
for (final channel in realtime.channels) {
267-
// ignore: invalid_use_of_internal_member
268-
if (channel.isJoined) {
269-
// ignore: invalid_use_of_internal_member
270-
channel.forceRejoin();
271-
}
272-
}
273-
}
274-
}, onError: (error) {
275-
if (_disconnectFuture == disconnectFuture) {
276-
_disconnectFuture = null;
277-
}
278-
});
279-
_realtimeReconnectOperation = CancelableOperation.fromFuture(
280-
connectFuture,
281-
onCancel: () => cancel = true,
282-
);
283-
} else if (!realtime.isConnected) {
284-
// Reconnect if the socket is currently not connected.
285-
// When coming from [AppLifecycleState.paused] this should be the case,
286-
// but when coming from [AppLifecycleState.inactive] no disconnect
287-
// happened and therefore connection should still be intact and we
288-
// should not reconnect.
289255

290-
// ignore: invalid_use_of_internal_member
291-
await realtime.connect();
292-
for (final channel in realtime.channels) {
293-
// Only rejoin channels that think they are still joined and not
294-
// which were manually unsubscribed by the user while in background
256+
if (captured == AppLifecycleState.resumed) {
257+
// No channels subscribed — nothing to reconnect.
258+
if (realtime.channels.isEmpty) return;
295259

260+
// Already connected (e.g. coming from [AppLifecycleState.inactive]
261+
// where no disconnect happened).
262+
if (realtime.isConnected) return;
263+
264+
// ignore: invalid_use_of_internal_member
265+
await realtime.connect();
266+
267+
// Abort rejoin if app went back to background during connect.
268+
if (_targetLifecycleState != AppLifecycleState.resumed) return;
269+
270+
// Re-send join messages for channels that were previously joined.
271+
// After a disconnect/reconnect the WebSocket is fresh, but the
272+
// channel objects still have joined state — forceRejoin() restores
273+
// the server-side subscriptions.
274+
for (final channel in realtime.channels) {
275+
// ignore: invalid_use_of_internal_member
276+
if (channel.isJoined) {
296277
// ignore: invalid_use_of_internal_member
297-
if (channel.isJoined) {
298-
// ignore: invalid_use_of_internal_member
299-
channel.forceRejoin();
300-
}
278+
channel.forceRejoin();
301279
}
302280
}
281+
} else {
282+
// paused or detached — disconnect the WebSocket if it is active.
283+
if (realtime.isConnected ||
284+
realtime.connState == SocketStates.connecting) {
285+
await realtime.disconnect();
286+
}
303287
}
304288
}
305289
}

packages/supabase_flutter/test/lifecycle_test.dart

Lines changed: 68 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -159,16 +159,15 @@ void main() {
159159
Supabase.instance.didChangeAppLifecycleState(AppLifecycleState.paused);
160160
await Future<void>.delayed(Duration.zero);
161161

162-
// first resumed → captures _disconnectFuture
162+
// first resumed → queues reconnect after disconnect
163163
Supabase.instance.didChangeAppLifecycleState(AppLifecycleState.resumed);
164164
await Future<void>.delayed(Duration.zero);
165165

166-
// inactive → does nothing
166+
// inactive → does nothing (not a tracked lifecycle state)
167167
Supabase.instance.didChangeAppLifecycleState(AppLifecycleState.inactive);
168168
await Future<void>.delayed(Duration.zero);
169169

170-
// second resumed → should still see _disconnectFuture
171-
// (not eagerly cleared)
170+
// second resumed → queues another reconnect (idempotent)
172171
Supabase.instance.didChangeAppLifecycleState(AppLifecycleState.resumed);
173172
await Future<void>.delayed(Duration.zero);
174173

@@ -182,5 +181,70 @@ void main() {
182181
expect(realtime.connState, isNot(SocketStates.disconnecting));
183182
expect(realtime.conn, isNotNull);
184183
});
184+
185+
test(
186+
'rapid paused → resumed → paused → resumed '
187+
'ends up connected', () async {
188+
await initWithMockTransport();
189+
final realtime = Supabase.instance.client.realtime;
190+
191+
realtime.channel('test');
192+
193+
await connectAndReady(realtime);
194+
expect(realtime.connState, SocketStates.open);
195+
196+
// Rapid lifecycle flapping
197+
Supabase.instance.didChangeAppLifecycleState(AppLifecycleState.paused);
198+
Supabase.instance.didChangeAppLifecycleState(AppLifecycleState.resumed);
199+
Supabase.instance.didChangeAppLifecycleState(AppLifecycleState.paused);
200+
Supabase.instance.didChangeAppLifecycleState(AppLifecycleState.resumed);
201+
202+
// Complete all pending ready futures as they appear
203+
for (final c in readyCompleters) {
204+
if (!c.isCompleted) c.complete();
205+
}
206+
await Future<void>.delayed(const Duration(milliseconds: 200));
207+
208+
// Also complete any new ready futures created during processing
209+
for (final c in readyCompleters) {
210+
if (!c.isCompleted) c.complete();
211+
}
212+
await Future<void>.delayed(const Duration(milliseconds: 200));
213+
214+
expect(realtime.connState, SocketStates.open);
215+
});
216+
217+
test(
218+
'resumed then paused before connect completes '
219+
'cancels reconnect', () async {
220+
await initWithMockTransport();
221+
final realtime = Supabase.instance.client.realtime;
222+
223+
realtime.channel('test');
224+
225+
await connectAndReady(realtime);
226+
expect(realtime.connState, SocketStates.open);
227+
228+
// paused → triggers disconnect
229+
Supabase.instance.didChangeAppLifecycleState(AppLifecycleState.paused);
230+
await Future<void>.delayed(Duration.zero);
231+
232+
// resumed → queues reconnect
233+
Supabase.instance.didChangeAppLifecycleState(AppLifecycleState.resumed);
234+
await Future<void>.delayed(Duration.zero);
235+
236+
// paused again before connect completes → should cancel the
237+
// reconnect (target state is now paused)
238+
Supabase.instance.didChangeAppLifecycleState(AppLifecycleState.paused);
239+
240+
// Complete all pending ready futures
241+
for (final c in readyCompleters) {
242+
if (!c.isCompleted) c.complete();
243+
}
244+
await Future<void>.delayed(const Duration(milliseconds: 200));
245+
246+
// Should be disconnected since the last event was paused
247+
expect(realtime.connState, SocketStates.disconnected);
248+
});
185249
});
186250
}

0 commit comments

Comments
 (0)