fix(mobile): don't let a frozen sync block syncing on resume (#29870)

* fix(mobile): don't let a frozen sync block syncing on resume

* share the task lists between the cancel paths

* fix analyzer issues in resume integration test

* isolate the resume e2e test and pin the worker pool setup
This commit is contained in:
Santo Shakil 2026-08-10 21:39:38 +06:00 committed by GitHub
parent 9862e50aab
commit 0ff47f4178
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 336 additions and 43 deletions

View file

@ -50,16 +50,39 @@ class BackgroundSyncManager {
this.onCloudIdSyncError,
});
// The tasks the app-resume path re-runs. One in-flight when the app was suspended
// stays referenced but frozen, so on resume the dedupe guards would hand back the
// stale task instead of syncing (#28082). Websocket and cloud-id are excluded - the
// resume path never restarts them. [_allTasks] builds on this so the lists can't drift.
List<Cancelable?> get _resumeSyncTasks => [_syncTask, _deviceAlbumSyncTask, _hashTask, _linkedAlbumSyncTask];
List<Cancelable?> get _allTasks => [_syncWebsocketTask, _cloudIdSyncTask, ..._resumeSyncTasks];
Future<void> cancel() async {
_syncQueued = false;
final tasks = [
_syncTask,
_syncWebsocketTask,
_cloudIdSyncTask,
_linkedAlbumSyncTask,
_deviceAlbumSyncTask,
_hashTask,
];
final tasks = _allTasks;
_syncTask = null;
_syncWebsocketTask = null;
_cloudIdSyncTask = null;
_linkedAlbumSyncTask = null;
_deviceAlbumSyncTask = null;
_hashTask = null;
await _cancelAll(tasks);
}
Future<void> cancelResumeSyncs() async {
_syncQueued = false;
final tasks = _resumeSyncTasks;
_syncTask = null;
_deviceAlbumSyncTask = null;
_hashTask = null;
_linkedAlbumSyncTask = null;
await _cancelAll(tasks);
}
// Cancels every task in [tasks] and waits for them to unwind. Callers null out
// their own fields first, so the sync guards see a clean slate immediately.
Future<void> _cancelAll(List<Cancelable?> tasks) async {
final futures = [
for (final task in tasks)
if (task != null) task.future,
@ -67,13 +90,6 @@ class BackgroundSyncManager {
for (final task in tasks) {
task?.cancel();
}
_syncTask = null;
_syncWebsocketTask = null;
_cloudIdSyncTask = null;
_linkedAlbumSyncTask = null;
_deviceAlbumSyncTask = null;
_hashTask = null;
try {
await Future.wait(futures);
} on CanceledError {
@ -84,14 +100,14 @@ class BackgroundSyncManager {
// No need to cancel the task, as it can also be run when the user logs out
Future<void> syncLocal({bool full = false}) {
if (_deviceAlbumSyncTask != null) {
return _deviceAlbumSyncTask!.future;
return _deviceAlbumSyncTask!.future.catchError((_) {}, test: (error) => error is CanceledError);
}
onLocalSyncStart?.call();
// We use a ternary operator to avoid [_deviceAlbumSyncTask] from being
// captured by the closure passed to [runInIsolateGentle].
_deviceAlbumSyncTask = full
final task = _deviceAlbumSyncTask = full
? runInIsolateGentle(
computation: (ref) => ref.read(localSyncServiceProvider).sync(full: true),
debugLabel: 'local-sync-full-true',
@ -101,37 +117,43 @@ class BackgroundSyncManager {
debugLabel: 'local-sync-full-false',
);
return _deviceAlbumSyncTask!
return task
.whenComplete(() {
_deviceAlbumSyncTask = null;
if (identical(_deviceAlbumSyncTask, task)) {
_deviceAlbumSyncTask = null;
}
onLocalSyncComplete?.call();
})
.catchError((error) {
onLocalSyncError?.call(error.toString());
_deviceAlbumSyncTask = null;
if (error is! CanceledError) {
onLocalSyncError?.call(error.toString());
}
});
}
Future<void> hashAssets() {
if (_hashTask != null) {
return _hashTask!.future;
return _hashTask!.future.catchError((_) {}, test: (error) => error is CanceledError);
}
onHashingStart?.call();
_hashTask = runInIsolateGentle(
final task = _hashTask = runInIsolateGentle(
computation: (ref) => ref.read(hashServiceProvider).hashAssets(),
debugLabel: 'hash-assets',
);
return _hashTask!
return task
.whenComplete(() {
onHashingComplete?.call();
_hashTask = null;
if (identical(_hashTask, task)) {
_hashTask = null;
}
})
.catchError((error) {
onHashingError?.call(error.toString());
_hashTask = null;
if (error is! CanceledError) {
onHashingError?.call(error.toString());
}
});
}
@ -143,11 +165,11 @@ class BackgroundSyncManager {
onRemoteSyncStart?.call();
_syncTask = runInIsolateGentle(
final task = _syncTask = runInIsolateGentle(
computation: (ref) => ref.read(syncStreamServiceProvider).sync(),
debugLabel: 'remote-sync',
);
return _syncTask!
return task
.then((result) {
final success = result ?? false;
onRemoteSyncComplete?.call(success);
@ -155,15 +177,21 @@ class BackgroundSyncManager {
return success;
})
.catchError((error) {
onRemoteSyncError?.call(error.toString());
if (error is! CanceledError) {
onRemoteSyncError?.call(error.toString());
}
_syncQueued = false;
return false;
})
// A task clears only its own slot: one that was cancelled and superseded by a
// fresh task (see cancelResumeSyncs) must not null the new task's slot.
.whenComplete(() {
_syncTask = null;
if (_syncQueued) {
_syncQueued = false;
unawaited(syncRemote());
if (identical(_syncTask, task)) {
_syncTask = null;
if (_syncQueued) {
_syncQueued = false;
unawaited(syncRemote());
}
}
});
}
@ -210,13 +238,21 @@ class BackgroundSyncManager {
Future<void> syncLinkedAlbum() {
if (_linkedAlbumSyncTask != null) {
return _linkedAlbumSyncTask!.future;
return _linkedAlbumSyncTask!.future.catchError((_) {}, test: (error) => error is CanceledError);
}
_linkedAlbumSyncTask = runInIsolateGentle(computation: syncLinkedAlbumsIsolated, debugLabel: 'linked-album-sync');
return _linkedAlbumSyncTask!.whenComplete(() {
_linkedAlbumSyncTask = null;
});
final task = _linkedAlbumSyncTask = runInIsolateGentle(
computation: syncLinkedAlbumsIsolated,
debugLabel: 'linked-album-sync',
);
return task
.whenComplete(() {
if (identical(_linkedAlbumSyncTask, task)) {
_linkedAlbumSyncTask = null;
}
})
// a cancelled resume sync is not a failure; absorb it so the websocket callers don't get an uncaught error
.catchError((_) {}, test: (error) => error is CanceledError);
}
Future<void> syncCloudIds() {

View file

@ -112,6 +112,12 @@ class AppLifeCycleNotifier extends StateNotifier<AppLifeCycleEnum> {
await Future.delayed(const Duration(milliseconds: 500));
final backgroundManager = _ref.read(backgroundSyncProvider);
// Drop any sync that froze mid-flight while the app was suspended so resume
// starts fresh instead of awaiting the stale task (#28082). cancelResumeSyncs
// clears the task refs synchronously, so the syncs below see a clean slate.
unawaited(backgroundManager.cancelResumeSyncs());
final isAlbumLinkedSyncEnable = _ref.read(appConfigProvider).backup.syncAlbums;
try {