Skip to content

Commit d8a895d

Browse files
fix(api): Don't block subscription events during WebSocket reconnect (#7282)
1 parent 758333c commit d8a895d

3 files changed

Lines changed: 96 additions & 7 deletions

File tree

packages/api/amplify_api_dart/lib/src/graphql/web_socket/blocs/web_socket_bloc.dart

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,9 @@ class WebSocketBloc with AWSDebuggable, AmplifyLoggerMixin {
108108

109109
late WebSocketState _currentState;
110110

111+
/// The in-flight reconnection, if any. Ensures at most one runs at a time.
112+
Future<void>? _reconnectOperation;
113+
111114
/// OVERRIDES
112115
///
113116
///
@@ -378,12 +381,25 @@ class WebSocketBloc with AWSDebuggable, AmplifyLoggerMixin {
378381
/// First establishes there is a connection to AppSync
379382
/// Then clears web socket connection and restarts init workflow
380383
/// Sends [NetworkException] when unable to reach AppSync
384+
///
385+
/// Runs off the event queue so a slow reconnect doesn't block incoming
386+
/// events (e.g. subscription data, keep alives) from being processed.
381387
Stream<WebSocketState> _reconnect() async* {
382388
assert(
383389
_currentState is ReconnectingState,
384390
'Bloc should be set to connecting before starting reconnection.',
385391
);
386-
final state = _currentState;
392+
_reconnectOperation ??= _performReconnect(
393+
_currentState,
394+
).whenComplete(() => _reconnectOperation = null);
395+
396+
// TODO(dnys1): Yield broken on web debug build.
397+
yield* const Stream.empty();
398+
}
399+
400+
/// Pings AppSync with retry/back off and reinitializes the connection, or
401+
/// shuts down on failure. Runs off the event queue via [_reconnect].
402+
Future<void> _performReconnect(WebSocketState state) async {
387403
try {
388404
// Begin reconnection with retry/back off on ping endpoint
389405
final res = await state.options.retryOptions.retry(
@@ -395,16 +411,20 @@ class WebSocketBloc with AWSDebuggable, AmplifyLoggerMixin {
395411

396412
// **Ping succeeded**
397413

414+
// Bloc may have shut down during the ping, don't act on a closed bloc
415+
if (_isShuttingDown) return;
416+
398417
// Prep new connection
399418
await state.service.close();
400419
for (final bloc in state.subscriptionBlocs.values) {
401420
bloc.add(SubscriptionPendingEvent(bloc.currentState.request.id));
402421
}
403422

404423
// Init new connection
405-
add(const InitEvent());
424+
_safeAdd(const InitEvent());
406425
} on Exception catch (e, st) {
407-
// Ping failed, close down
426+
// Ping failed, nothing to do if already shutting down
427+
if (_isShuttingDown) return;
408428
_shutdownWithException(
409429
NetworkException(
410430
'Unable to recover network connection, web socket will close.',
@@ -414,11 +434,15 @@ class WebSocketBloc with AWSDebuggable, AmplifyLoggerMixin {
414434
st,
415435
);
416436
}
417-
418-
// TODO(dnys1): Yield broken on web debug build.
419-
yield* const Stream.empty();
420437
}
421438

439+
/// Whether the bloc is closed or shutting down.
440+
bool get _isShuttingDown =>
441+
_wsEventController.isClosed ||
442+
_currentState is PendingDisconnect ||
443+
_currentState is DisconnectedState ||
444+
_currentState is FailureState;
445+
422446
/// Sends registration message on ws channel when connected
423447
void _registerSubscriptionRequest(GraphQLRequest<Object?> request) {
424448
final currentState = _currentState;

packages/api/amplify_api_dart/test/web_socket/web_socket_bloc_test.dart

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,71 @@ void main() {
181181
await dataCompleter.future;
182182
});
183183

184+
test(
185+
'delivers incoming subscription data while a reconnect is in progress',
186+
() async {
187+
final firstData = Completer<String>();
188+
final dataDuringReconnect = Completer<String>();
189+
190+
final subscribeEvent = SubscribeEvent(subscriptionRequest, () {
191+
service!.channel.sink.add(mockDataString);
192+
});
193+
194+
final bloc = getWebSocketBloc();
195+
bloc.subscribe(subscribeEvent).listen((event) {
196+
if (!firstData.isCompleted) {
197+
firstData.complete(event.data);
198+
} else if (!dataDuringReconnect.isCompleted) {
199+
dataDuringReconnect.complete(event.data);
200+
}
201+
});
202+
203+
expect(await firstData.future, json.encode(mockSubscriptionData));
204+
205+
mockPollClient.induceTimeout = true;
206+
mockNetworkStreamController.add(ConnectivityStatus.disconnected);
207+
await expectLater(bloc.stream, emitsThrough(isA<ReconnectingState>()));
208+
209+
service!.channel.sink.add(mockDataString);
210+
211+
final data = await dataDuringReconnect.future.timeout(
212+
const Duration(seconds: 3),
213+
onTimeout: () => throw TimeoutException(
214+
'Subscription data was not delivered while a reconnect was in '
215+
'progress.',
216+
),
217+
);
218+
expect(data, json.encode(mockSubscriptionData));
219+
220+
mockPollClient.induceTimeout = false;
221+
},
222+
);
223+
224+
test('shuts down cleanly while a reconnect is in progress', () async {
225+
final established = Completer<void>();
226+
final bloc = getWebSocketBloc();
227+
bloc
228+
.subscribe(SubscribeEvent(subscriptionRequest, established.complete))
229+
.listen((_) {}, onError: (_) {});
230+
await established.future;
231+
232+
mockPollClient.induceTimeout = true;
233+
mockNetworkStreamController.add(ConnectivityStatus.disconnected);
234+
await expectLater(bloc.stream, emitsThrough(isA<ReconnectingState>()));
235+
236+
bloc.add(const ShutdownEvent());
237+
await bloc.done.future.timeout(
238+
const Duration(seconds: 3),
239+
onTimeout: () => throw TimeoutException(
240+
'Shutdown was blocked by an in-progress reconnect.',
241+
),
242+
);
243+
244+
mockPollClient.induceTimeout = false;
245+
await Future<void>.delayed(const Duration(seconds: 7));
246+
expect(bloc.done.isCompleted, isTrue);
247+
});
248+
184249
test('should throttle reconnect after repeated wifi toggling', () async {
185250
final blocReady = Completer<void>();
186251
final subscribeEvent = SubscribeEvent(

packages/authenticator/amplify_authenticator/example/test_driver/integration_test.dart

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,4 @@ import 'package:integration_test/integration_test_driver.dart';
55

66
// Required for running integration tests in the browser:
77
// https://docs.flutter.dev/cookbook/testing/integration/introduction#5b-web
8-
Future<void> main() => integrationDriver();
8+
Future<void> main() => integrationDriver(timeout: const Duration(minutes: 60));

0 commit comments

Comments
 (0)