@@ -3,6 +3,7 @@ import 'dart:convert' as convert;
3
3
import 'dart:io' ;
4
4
5
5
import 'package:http/http.dart' as http;
6
+ import 'package:powersync/src/abort_controller.dart' ;
6
7
import 'package:powersync/src/exceptions.dart' ;
7
8
import 'package:powersync/src/log_internal.dart' ;
8
9
@@ -39,6 +40,10 @@ class StreamingSyncImplementation {
39
40
40
41
SyncStatus lastStatus = const SyncStatus ();
41
42
43
+ AbortController ? _abort;
44
+
45
+ bool _safeToClose = true ;
46
+
42
47
StreamingSyncImplementation (
43
48
{required this .adapter,
44
49
required this .credentialsCallback,
@@ -50,34 +55,74 @@ class StreamingSyncImplementation {
50
55
statusStream = _statusStreamController.stream;
51
56
}
52
57
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
+
53
87
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 ;
71
112
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);
77
118
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
+ }
80
123
}
124
+ } finally {
125
+ _abort! .completeAbort ();
81
126
}
82
127
}
83
128
@@ -206,6 +251,10 @@ class StreamingSyncImplementation {
206
251
bool haveInvalidated = false ;
207
252
208
253
await for (var line in merged) {
254
+ if (aborted) {
255
+ break ;
256
+ }
257
+
209
258
_updateStatus (connected: true , connecting: false );
210
259
if (line is Checkpoint ) {
211
260
targetCheckpoint = line;
@@ -338,7 +387,18 @@ class StreamingSyncImplementation {
338
387
request.headers['Authorization' ] = "Token ${credentials .token }" ;
339
388
request.body = convert.jsonEncode (data);
340
389
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
+
342
402
if (res.statusCode == 401 ) {
343
403
if (invalidCredentialsCallback != null ) {
344
404
await invalidCredentialsCallback !();
@@ -350,6 +410,9 @@ class StreamingSyncImplementation {
350
410
351
411
// Note: The response stream is automatically closed when this loop errors
352
412
await for (var line in ndjson (res.stream)) {
413
+ if (aborted) {
414
+ break ;
415
+ }
353
416
yield parseStreamingSyncLine (line as Map <String , dynamic >);
354
417
}
355
418
}
0 commit comments