@@ -3,6 +3,7 @@ import 'dart:convert' as convert;
33import 'dart:io' ;
44
55import 'package:http/http.dart' as http;
6+ import 'package:powersync/src/abort_controller.dart' ;
67import 'package:powersync/src/exceptions.dart' ;
78import 'package:powersync/src/log_internal.dart' ;
89
@@ -39,6 +40,10 @@ class StreamingSyncImplementation {
3940
4041 SyncStatus lastStatus = const SyncStatus ();
4142
43+ AbortController ? _abort;
44+
45+ bool _safeToClose = true ;
46+
4247 StreamingSyncImplementation (
4348 {required this .adapter,
4449 required this .credentialsCallback,
@@ -50,34 +55,74 @@ class StreamingSyncImplementation {
5055 statusStream = _statusStreamController.stream;
5156 }
5257
58+ /// Close any active streams.
59+ Future <void > abort () async {
60+ // If streamingSync() hasn't been called yet, _abort will be null.
61+ var future = _abort? .abort ();
62+ // This immediately triggers a new iteration in the merged stream, allowing us
63+ // to break immediately.
64+ // However, we still need to close the underlying stream explicitly, otherwise
65+ // the break will wait for the next line of data received on the stream.
66+ _localPingController.add (null );
67+ // According to the documentation, the behavior is undefined when calling
68+ // close() while requests are pending. However, this is no other
69+ // known way to cancel open streams, and this appears to end the stream with
70+ // a consistent ClientException if a request is open.
71+ // We avoid closing the client while opening a request, as that does cause
72+ // unpredicable uncaught errors.
73+ if (_safeToClose) {
74+ _client.close ();
75+ }
76+ // wait for completeAbort() to be called
77+ await future;
78+
79+ // Now close the client in all cases not covered above
80+ _client.close ();
81+ }
82+
83+ bool get aborted {
84+ return _abort? .aborted ?? false ;
85+ }
86+
5387 Future <void > streamingSync () async {
54- crudLoop ();
55- var invalidCredentials = false ;
56- while (true ) {
57- _updateStatus (connecting: true );
58- try {
59- if (invalidCredentials && invalidCredentialsCallback != null ) {
60- // This may error. In that case it will be retried again on the next
61- // iteration.
62- await invalidCredentialsCallback !();
63- invalidCredentials = false ;
64- }
65- await streamingSyncIteration ();
66- // Continue immediately
67- } catch (e, stacktrace) {
68- final message = _syncErrorMessage (e);
69- isolateLogger.warning ('Sync error: $message ' , e, stacktrace);
70- invalidCredentials = true ;
88+ try {
89+ _abort = AbortController ();
90+ crudLoop ();
91+ var invalidCredentials = false ;
92+ while (! aborted) {
93+ _updateStatus (connecting: true );
94+ try {
95+ if (invalidCredentials && invalidCredentialsCallback != null ) {
96+ // This may error. In that case it will be retried again on the next
97+ // iteration.
98+ await invalidCredentialsCallback !();
99+ invalidCredentials = false ;
100+ }
101+ await streamingSyncIteration ();
102+ // Continue immediately
103+ } catch (e, stacktrace) {
104+ if (aborted && e is http.ClientException ) {
105+ // Explicit abort requested - ignore. Example error:
106+ // ClientException: Connection closed while receiving data, uri=http://localhost:8080/sync/stream
107+ return ;
108+ }
109+ final message = _syncErrorMessage (e);
110+ isolateLogger.warning ('Sync error: $message ' , e, stacktrace);
111+ invalidCredentials = true ;
71112
72- _updateStatus (
73- connected: false ,
74- connecting: true ,
75- downloading: false ,
76- downloadError: e);
113+ _updateStatus (
114+ connected: false ,
115+ connecting: true ,
116+ downloading: false ,
117+ downloadError: e);
77118
78- // On error, wait a little before retrying
79- await Future .delayed (retryDelay);
119+ // On error, wait a little before retrying
120+ // When aborting, don't wait
121+ await Future .any ([Future .delayed (retryDelay), _abort! .onAbort]);
122+ }
80123 }
124+ } finally {
125+ _abort! .completeAbort ();
81126 }
82127 }
83128
@@ -206,6 +251,10 @@ class StreamingSyncImplementation {
206251 bool haveInvalidated = false ;
207252
208253 await for (var line in merged) {
254+ if (aborted) {
255+ break ;
256+ }
257+
209258 _updateStatus (connected: true , connecting: false );
210259 if (line is Checkpoint ) {
211260 targetCheckpoint = line;
@@ -338,7 +387,18 @@ class StreamingSyncImplementation {
338387 request.headers['Authorization' ] = "Token ${credentials .token }" ;
339388 request.body = convert.jsonEncode (data);
340389
341- final res = await _client.send (request);
390+ http.StreamedResponse res;
391+ try {
392+ // Do not close the client during the request phase - this causes uncaught errors.
393+ _safeToClose = false ;
394+ res = await _client.send (request);
395+ } finally {
396+ _safeToClose = true ;
397+ }
398+ if (aborted) {
399+ return ;
400+ }
401+
342402 if (res.statusCode == 401 ) {
343403 if (invalidCredentialsCallback != null ) {
344404 await invalidCredentialsCallback !();
@@ -350,6 +410,9 @@ class StreamingSyncImplementation {
350410
351411 // Note: The response stream is automatically closed when this loop errors
352412 await for (var line in ndjson (res.stream)) {
413+ if (aborted) {
414+ break ;
415+ }
353416 yield parseStreamingSyncLine (line as Map <String , dynamic >);
354417 }
355418 }
0 commit comments