fix: Fix sync once hanging forever when opened again too quickly after closing (#98)

This commit is contained in:
Marcelo Mendonça Soares 2026-07-09 01:15:19 -03:00 • committed by GitHub
commit d3b9a66efd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 79 additions and 3 deletions

View file

@ -75,7 +75,12 @@ class CrdtSyncClient {
mode: CrdtSyncPeerMode.follower, mode: CrdtSyncPeerMode.follower,
) )
.listen( .listen(
outboundChanges.addIfNotClosed, (event) {
outboundChanges.addIfNotClosed(event);
if (once && event is CrdtSyncClose) {
unawaited(outboundChanges.closeOrSkip());
}
},
onDone: doneCompleter.completeOrSkip, onDone: doneCompleter.completeOrSkip,
onError: doneCompleter.completeErrorOrSkip, onError: doneCompleter.completeErrorOrSkip,
cancelOnError: true, cancelOnError: true,
@ -117,8 +122,15 @@ extension on StreamController<CrdtSyncStreamEvent> {
} }
Future<void> closeOrSkip() async { Future<void> closeOrSkip() async {
if (isClosed) return; if (!isClosed) {
await close(); unawaited(close());
}
// The done event is only delivered once the stream has a listener. If the
// method stream never listened (e.g. opening it failed), awaiting [done]
// would hang forever.
if (hasListener) {
await done;
}
} }
} }

View file

@ -85,6 +85,29 @@ void main() {
}, },
); );
test(
'when an unauthenticated client calls syncOnce '
'then the unauthorized error is surfaced without waiting for stream teardown.',
() async {
final unauthenticatedClient = client.Client(
'http://localhost:${rawServerSession.server.port}',
);
await expectLater(
unauthenticatedClient.crdt
.syncOnce(clientSession)
.timeout(const Duration(seconds: 3)),
throwsA(
predicate<Object>(
(error) =>
error.toString() ==
'ServerpodClientException: Unauthorized, statusCode = 401',
),
),
);
},
);
test( test(
'when client syncOnce is called repeatedly on a reused client ' 'when client syncOnce is called repeatedly on a reused client '
'then the rounds complete promptly instead of stalling on teardown.', 'then the rounds complete promptly instead of stalling on teardown.',
@ -132,6 +155,27 @@ void main() {
}, },
); );
test(
'when a running syncContinuously session is cancelled '
'then method stream teardown does not close the shared websocket.',
() async {
final stderr = await captureStderr(() async {
final syncSession = testClient.crdt.syncContinuously(clientSession);
await Future<void>.delayed(const Duration(milliseconds: 300));
await syncSession.cancel();
await syncSession.done;
await Future<void>.delayed(const Duration(milliseconds: 100));
});
expect(stderr, isNot(contains('WebSocketConnectionClosed')));
expect(
stderr,
isNot(contains('Message posted when web socket connection is closed')),
);
},
);
test( test(
'when client syncContinuously runs ' 'when client syncContinuously runs '
'then neither side keeps sending frames while idle.', 'then neither side keeps sending frames while idle.',
@ -847,6 +891,26 @@ void main() {
await expectLater(syncSession.done, completes); await expectLater(syncSession.done, completes);
}, },
); );
test(
'when a running syncContinuously session is cancelled '
'then method stream teardown does not close the shared websocket.',
() async {
final stderr = await captureStderr(() async {
final syncSession = testClient.crdt.syncContinuously(clientSession);
await syncSession.cancel();
await syncSession.done;
await Future<void>.delayed(const Duration(milliseconds: 100));
});
expect(stderr, isNot(contains('WebSocketConnectionClosed')));
expect(
stderr,
isNot(contains('Message posted when web socket connection is closed')),
);
},
);
}); });
group('and an inserted server person', () { group('and an inserted server person', () {