Skip to content

Commit 61f7cd1

Browse files
fix(realtime): prevent null check crash in connect() during rapid lifecycle transitions (#1321)
* fix(realtime): prevent null check crash in connect() during rapid lifecycle transitions Capture the WebSocketChannel in a local variable before awaiting ready, then add a staleness guard (conn != localConn) after the await to bail out if disconnect() ran concurrently. This prevents the TypeError when conn is nullified between the await and the stream.listen() call. Also replace the conn!.sink.done access in onResumed() with a stored _disconnectFuture to eliminate a second NPE risk during lifecycle flapping. Fixes #1320 * style(realtime): use explicit type for localConn variable * fix(realtime): add staleness guard in catch block and check connState in post-await guard Address review feedback: - Add conn != localConn guard inside the catch block to avoid firing error handlers for a stale connection attempt. - Strengthen the post-await guard to also check connState == connecting, preventing connect() from proceeding if disconnect() has already set connState to disconnecting but hasn't yet nulled conn. * style(realtime): fix formatting in channel_test.dart for 80-char line limit * fix(supabase_flutter): clear _disconnectFuture after completion instead of eagerly Address review feedback from icnahom: if a second resumed event fires while disconnect is still in progress (e.g. paused → resumed → inactive → resumed), the eagerly-nulled _disconnectFuture causes the second resumed to fall into the else branch where connect() no-ops because conn is still non-null. Move _disconnectFuture = null into the .then() callback with a guard to avoid overwriting a newer future set by a subsequent paused event. * test(supabase_flutter): add lifecycle reconnection tests Add transport field to RealtimeClientOptions (non-breaking, additive) so tests can inject a mock WebSocket transport. Add two lifecycle tests: - paused → resumed reconnects after disconnect completes - paused → resumed → inactive → resumed still reconnects (validates _disconnectFuture is not eagerly cleared)
1 parent 4cf97d8 commit 61f7cd1

8 files changed

Lines changed: 372 additions & 29 deletions

File tree

packages/realtime_client/lib/src/realtime_client.dart

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -178,11 +178,16 @@ class RealtimeClient {
178178
log('transport', 'connecting to $endPointURL', null);
179179
log('transport', 'connecting', null, Level.FINE);
180180
connState = SocketStates.connecting;
181-
conn = transport(endPointURL, headers);
181+
final WebSocketChannel localConn = transport(endPointURL, headers);
182+
conn = localConn;
182183

183184
try {
184-
await conn!.ready;
185+
await localConn.ready;
185186
} catch (error) {
187+
// Bail out if disconnect() ran or a new connect() started during await
188+
if (conn != localConn) {
189+
return;
190+
}
186191
// Don't schedule a reconnect and emit error if connection has been
187192
// closed by the user or [disconnect] waits for the connection to be
188193
// ready before closing it.
@@ -195,10 +200,15 @@ class RealtimeClient {
195200
return;
196201
}
197202

203+
// Guard: bail out if disconnect() ran during the await
204+
if (conn != localConn || connState != SocketStates.connecting) {
205+
return;
206+
}
207+
198208
connState = SocketStates.open;
199209

200210
_onConnOpen();
201-
conn!.stream.listen(
211+
localConn.stream.listen(
202212
// incoming messages
203213
(message) => onConnMessage(message as String),
204214
onError: _onConnError,

packages/realtime_client/test/channel_test.dart

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -221,7 +221,8 @@ void main() {
221221
expect(callbackOtherCalled, 1);
222222
});
223223

224-
test('maintains type safety after off() - '
224+
test(
225+
'maintains type safety after off() - '
225226
'reproduces web hot restart issue', () {
226227
// This test reproduces the issue where .where().toList() returns
227228
// List<dynamic> on Flutter web during hot restart, causing a

packages/realtime_client/test/socket_test.dart

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import 'dart:async';
12
import 'dart:convert';
23
import 'dart:io';
34

@@ -643,4 +644,131 @@ void main() {
643644
verifyNever(() => mockedSink.add(any()));
644645
});
645646
});
647+
648+
group('connect/disconnect race condition', () {
649+
test(
650+
'connect does not crash if disconnect nullifies conn during await ready',
651+
() async {
652+
final readyCompleter = Completer<void>();
653+
final mockedSocketChannel = MockIOWebSocketChannel();
654+
final mockedSink = MockWebSocketSink();
655+
656+
when(() => mockedSocketChannel.ready)
657+
.thenAnswer((_) => readyCompleter.future);
658+
when(() => mockedSocketChannel.sink).thenReturn(mockedSink);
659+
when(() => mockedSink.close(any(), any()))
660+
.thenAnswer((_) => Future.value());
661+
when(() => mockedSink.close()).thenAnswer((_) => Future.value());
662+
663+
final socket = RealtimeClient(
664+
socketEndpoint,
665+
transport: (url, headers) => mockedSocketChannel,
666+
);
667+
668+
// Start connect — it will suspend at await ready
669+
final connectFuture = socket.connect();
670+
671+
// Start disconnect (also suspends on ready since state is connecting)
672+
final disconnectFuture = socket.disconnect();
673+
674+
// Now complete the ready future — both connect and disconnect can proceed
675+
readyCompleter.complete();
676+
await disconnectFuture;
677+
await connectFuture;
678+
679+
// Should NOT have transitioned to open because disconnect nullified conn
680+
expect(socket.connState, isNot(SocketStates.open));
681+
expect(socket.conn, isNull);
682+
});
683+
684+
test('connect bails out when connState changes during await ready',
685+
() async {
686+
final readyCompleter = Completer<void>();
687+
final mockedSocketChannel = MockIOWebSocketChannel();
688+
final mockedSink = MockWebSocketSink();
689+
690+
when(() => mockedSocketChannel.ready)
691+
.thenAnswer((_) => readyCompleter.future);
692+
when(() => mockedSocketChannel.sink).thenReturn(mockedSink);
693+
when(() => mockedSink.close(any(), any()))
694+
.thenAnswer((_) => Future.value());
695+
when(() => mockedSink.close()).thenAnswer((_) => Future.value());
696+
697+
final socket = RealtimeClient(
698+
socketEndpoint,
699+
transport: (url, headers) => mockedSocketChannel,
700+
);
701+
702+
// Start connect
703+
final connectFuture = socket.connect();
704+
705+
// Start disconnect — also awaits ready
706+
final disconnectFuture = socket.disconnect();
707+
708+
// Complete ready — both proceed
709+
readyCompleter.complete();
710+
await disconnectFuture;
711+
await connectFuture;
712+
713+
expect(socket.connState, isNot(SocketStates.open));
714+
});
715+
716+
test('rapid connect-disconnect-connect cycle does not crash', () async {
717+
final readyCompleter1 = Completer<void>();
718+
final mockedSocketChannel1 = MockIOWebSocketChannel();
719+
final mockedSink1 = MockWebSocketSink();
720+
721+
when(() => mockedSocketChannel1.ready)
722+
.thenAnswer((_) => readyCompleter1.future);
723+
when(() => mockedSocketChannel1.sink).thenReturn(mockedSink1);
724+
when(() => mockedSink1.close(any(), any()))
725+
.thenAnswer((_) => Future.value());
726+
when(() => mockedSink1.close()).thenAnswer((_) => Future.value());
727+
728+
final readyCompleter2 = Completer<void>();
729+
final mockedSocketChannel2 = MockIOWebSocketChannel();
730+
final mockedSink2 = MockWebSocketSink();
731+
final streamController2 = StreamController<dynamic>.broadcast();
732+
733+
when(() => mockedSocketChannel2.ready)
734+
.thenAnswer((_) => readyCompleter2.future);
735+
when(() => mockedSocketChannel2.sink).thenReturn(mockedSink2);
736+
when(() => mockedSocketChannel2.stream)
737+
.thenAnswer((_) => streamController2.stream);
738+
when(() => mockedSink2.close(any(), any()))
739+
.thenAnswer((_) => Future.value());
740+
when(() => mockedSink2.close()).thenAnswer((_) => Future.value());
741+
742+
var callCount = 0;
743+
final socket = RealtimeClient(
744+
socketEndpoint,
745+
transport: (url, headers) {
746+
callCount++;
747+
if (callCount == 1) return mockedSocketChannel1;
748+
return mockedSocketChannel2;
749+
},
750+
);
751+
752+
// First connect — suspends at await ready
753+
final connectFuture1 = socket.connect();
754+
755+
// Start disconnect (also suspends on ready)
756+
final disconnectFuture = socket.disconnect();
757+
758+
// Complete the first ready — both proceed, connect bails out
759+
readyCompleter1.complete();
760+
await disconnectFuture;
761+
await connectFuture1;
762+
763+
// Second connect with a fresh mock
764+
readyCompleter2.complete();
765+
await socket.connect();
766+
767+
expect(socket.connState, SocketStates.open);
768+
expect(socket.conn, mockedSocketChannel2);
769+
770+
await socket.disconnect();
771+
await streamController2.close();
772+
});
773+
});
646774
}

packages/supabase/lib/src/realtime_client_options.dart

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,14 @@ class RealtimeClientOptions {
1717
/// the timeout to trigger push timeouts
1818
final Duration? timeout;
1919

20+
/// Custom WebSocket transport factory for the RealtimeClient.
21+
final WebSocketTransport? transport;
22+
2023
/// {@macro realtime_client_options}
2124
const RealtimeClientOptions({
2225
this.eventsPerSecond,
2326
this.logLevel,
2427
this.timeout,
28+
this.transport,
2529
});
2630
}

packages/supabase/lib/src/supabase_client.dart

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -341,6 +341,7 @@ class SupabaseClient {
341341
httpClient: _authHttpClient,
342342
timeout: options.timeout ?? RealtimeConstants.defaultTimeout,
343343
customAccessToken: accessToken,
344+
transport: options.transport,
344345
);
345346
}
346347

packages/supabase_flutter/lib/src/supabase.dart

Lines changed: 37 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,8 @@ class Supabase with WidgetsBindingObserver {
9898
_instance._logSubscription = Logger('supabase').onRecord.listen((record) {
9999
if (record.level >= Level.INFO) {
100100
debugPrint(
101-
'${record.loggerName}: ${record.level.name}: ${record.message} ${record.error ?? ""}');
101+
'${record.loggerName}: ${record.level.name}: ${record.message} ${record.error ?? ""}',
102+
);
102103
}
103104
});
104105
}
@@ -138,9 +139,7 @@ class Supabase with WidgetsBindingObserver {
138139
// Wrap `recoverSession()` in a `CancelableOperation` so that it can be canceled in dispose
139140
// if still in progress
140141
_instance._restoreSessionCancellableOperation =
141-
CancelableOperation.fromFuture(
142-
supabaseAuth.recoverSession(),
143-
);
142+
CancelableOperation.fromFuture(supabaseAuth.recoverSession());
144143
}
145144

146145
_log.info('***** Supabase init completed *****');
@@ -172,6 +171,8 @@ class Supabase with WidgetsBindingObserver {
172171

173172
CancelableOperation<void>? _realtimeReconnectOperation;
174173

174+
Future<void>? _disconnectFuture;
175+
175176
StreamSubscription? _logSubscription;
176177

177178
/// Dispose the instance to free up resources.
@@ -197,7 +198,7 @@ class Supabase with WidgetsBindingObserver {
197198
}) {
198199
final headers = {
199200
...Constants.defaultHeaders,
200-
if (customHeaders != null) ...customHeaders
201+
if (customHeaders != null) ...customHeaders,
201202
};
202203
client = SupabaseClient(
203204
supabaseUrl,
@@ -229,41 +230,52 @@ class Supabase with WidgetsBindingObserver {
229230
case AppLifecycleState.detached:
230231
case AppLifecycleState.paused:
231232
_realtimeReconnectOperation?.cancel();
232-
Supabase.instance.client.realtime.disconnect();
233+
_disconnectFuture = Supabase.instance.client.realtime.disconnect();
233234
default:
234235
}
235236
}
236237

237238
Future<void> onResumed() async {
238239
final realtime = Supabase.instance.client.realtime;
239240
if (realtime.channels.isNotEmpty) {
240-
if (realtime.connState == SocketStates.disconnecting &&
241-
realtime.conn != null) {
242-
// If the socket is still disconnecting from e.g.
241+
final disconnectFuture = _disconnectFuture;
242+
if (disconnectFuture != null) {
243+
// If a disconnect is still in progress from e.g.
243244
// [AppLifecycleState.paused] we should wait for it to finish before
244-
// reconnecting.
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.
245251

246252
bool cancel = false;
247-
final connectFuture = realtime.conn!.sink.done.then(
248-
(_) async {
249-
// Make this connect cancelable so that it does not connect if the
250-
// disconnect took so long that the app is already in background
251-
// again.
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+
}
252259

253-
if (!cancel) {
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) {
254267
// ignore: invalid_use_of_internal_member
255-
await realtime.connect();
256-
for (final channel in realtime.channels) {
268+
if (channel.isJoined) {
257269
// ignore: invalid_use_of_internal_member
258-
if (channel.isJoined) {
259-
// ignore: invalid_use_of_internal_member
260-
channel.forceRejoin();
261-
}
270+
channel.forceRejoin();
262271
}
263272
}
264-
},
265-
onError: (error) {},
266-
);
273+
}
274+
}, onError: (error) {
275+
if (_disconnectFuture == disconnectFuture) {
276+
_disconnectFuture = null;
277+
}
278+
});
267279
_realtimeReconnectOperation = CancelableOperation.fromFuture(
268280
connectFuture,
269281
onCancel: () => cancel = true,

packages/supabase_flutter/pubspec.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ dev_dependencies:
3030
sdk: flutter
3131
flutter_lints: ^3.0.1
3232
path: ^1.8.3
33+
web_socket_channel: '>=2.3.0 <4.0.0'
3334

3435
platforms:
3536
android:

0 commit comments

Comments
 (0)