diff --git a/PowerSync/PowerSync.Common/CHANGELOG.md b/PowerSync/PowerSync.Common/CHANGELOG.md index f786650..ed08898 100644 --- a/PowerSync/PowerSync.Common/CHANGELOG.md +++ b/PowerSync/PowerSync.Common/CHANGELOG.md @@ -1,5 +1,9 @@ # PowerSync.Common Changelog +## 0.1.3 (unreleased) + +- Fix streaming sync retry loop reconnecting with no delay after an exception, ignoring `RetryDelayMs`. + ## 0.1.2 - Add support for MacCatalyst. diff --git a/PowerSync/PowerSync.Common/Client/Sync/Stream/StreamingSyncImplementation.cs b/PowerSync/PowerSync.Common/Client/Sync/Stream/StreamingSyncImplementation.cs index b1abb88..0dc6417 100644 --- a/PowerSync/PowerSync.Common/Client/Sync/Stream/StreamingSyncImplementation.cs +++ b/PowerSync/PowerSync.Common/Client/Sync/Stream/StreamingSyncImplementation.cs @@ -369,6 +369,11 @@ protected async Task StreamingSync(CancellationToken? signal, PowerSyncConnectio break; } iterationResult = await StreamingSyncIteration(nestedCts.Token, options); + + if (iterationResult.ImmediateRestart == true || iterationResult.LegacyRetry == true) + { + shouldDelayRetry = false; + } } catch (Exception ex) { @@ -413,20 +418,15 @@ protected async Task StreamingSync(CancellationToken? signal, PowerSyncConnectio nestedCts = new CancellationTokenSource(); } - if (iterationResult != null && (iterationResult.ImmediateRestart != true && iterationResult.LegacyRetry != true)) + if (shouldDelayRetry) { - UpdateSyncStatus(new SyncStatusOptions { Connected = false, Connecting = true }); - // On error, wait a little before retrying - if (shouldDelayRetry) - { - await DelayRetry(); - } + await DelayRetry(); } } } diff --git a/PowerSync/PowerSync.Maui/CHANGELOG.md b/PowerSync/PowerSync.Maui/CHANGELOG.md index 79b9c7b..76f8a88 100644 --- a/PowerSync/PowerSync.Maui/CHANGELOG.md +++ b/PowerSync/PowerSync.Maui/CHANGELOG.md @@ -1,5 +1,9 @@ # PowerSync.Maui Changelog +## 0.1.3 (unreleased) + +- Upstream PowerSync.Common version bump (See Powersync.Common changelog 0.1.3 for more information) + ## 0.1.2 - Add support for MacCatalyst. diff --git a/Tests/PowerSync/PowerSync.Common.Tests/Client/Sync/StreamingSyncRetryTests.cs b/Tests/PowerSync/PowerSync.Common.Tests/Client/Sync/StreamingSyncRetryTests.cs new file mode 100644 index 0000000..659af87 --- /dev/null +++ b/Tests/PowerSync/PowerSync.Common.Tests/Client/Sync/StreamingSyncRetryTests.cs @@ -0,0 +1,106 @@ +namespace PowerSync.Common.Tests.Client.Sync; + +using System.Collections.Concurrent; + +using PowerSync.Common.Client; +using PowerSync.Common.Client.Connection; +using PowerSync.Common.Client.Sync.Stream; +using PowerSync.Common.Tests.Utils; +using PowerSync.Common.Tests.Utils.Sync; + +/// +/// dotnet test -v n --framework net8.0 --filter "StreamingSyncRetryTests" +/// +public class StreamingSyncRetryTests +{ + [Fact(Timeout = 15000)] + public async Task RetryLoop_AppliesDelayBetweenFailedAttempts() + { + const int retryDelayMs = 200; + const double tolerance = 0.75; + + var attemptTimes = new ConcurrentQueue(); + var attemptSignal = new SemaphoreSlim(0); + + var dbFilename = $"sync-retry-{Guid.NewGuid():N}.db"; + var throwing = new ThrowingRemote(new TestConnector(), attemptTimes, attemptSignal); + + var db = new PowerSyncDatabase(new PowerSyncDatabaseOptions + { + Database = new SQLOpenOptions { DbFilename = dbFilename }, + Schema = TestSchemaTodoList.AppSchema, + RemoteFactory = _ => throwing + }); + + try + { + await db.Init(); + + // Fire-and-forget: Connect() awaits Connected=true, which never fires + // because every iteration throws. The retry loop runs in the background. + _ = db.Connect( + new TestConnector(), + new PowerSyncConnectionOptions { RetryDelayMs = retryDelayMs } + ); + + for (int i = 0; i < 4; i++) + { + Assert.True( + await attemptSignal.WaitAsync(TimeSpan.FromSeconds(5)), + $"Did not observe attempt #{i + 1} within timeout — retry loop is not running" + ); + } + + var timestamps = attemptTimes.ToArray(); + Assert.True(timestamps.Length >= 4); + + for (int i = 1; i < timestamps.Length; i++) + { + var deltaMs = (timestamps[i] - timestamps[i - 1]).TotalMilliseconds; + Assert.True( + deltaMs >= retryDelayMs * tolerance, + $"Retry gap #{i} was {deltaMs:F0}ms, expected >= {retryDelayMs * tolerance:F0}ms (RetryDelayMs={retryDelayMs})" + ); + } + } + finally + { + await db.Disconnect(); + await db.Close(); + DatabaseUtils.CleanDb(dbFilename); + } + } +} + +internal sealed class ThrowingRemote : Remote +{ + private readonly ConcurrentQueue timestamps; + private readonly SemaphoreSlim signal; + + public ThrowingRemote( + IPowerSyncBackendConnector connector, + ConcurrentQueue timestamps, + SemaphoreSlim signal + ) : base(connector) + { + this.timestamps = timestamps; + this.signal = signal; + } + + public override Task PostStreamRaw(SyncStreamOptions options) + { + timestamps.Enqueue(DateTime.UtcNow); + signal.Release(); + throw new HttpRequestException( + "HTTP InternalServerError: simulated [PSYNC_S2305] from ThrowingRemote" + ); + } + + public override Task Get(string path, Dictionary? headers = null) + { + var response = new StreamingSyncImplementation.ApiResponse( + new StreamingSyncImplementation.ResponseData("1") + ); + return Task.FromResult((T)(object)response); + } +}