What changed, and why it matters
This commit fixes a background-sync bug in a cryptocurrency wallet app. Previously, when a background or foreground sync task finished, the wallet's scanning thread could keep running and its progress would be discarded because the wallet was never closed. The change explicitly pauses scanning and saves (checkpoints) the wallet before the task ends, and it also stops early if the sync gets stuck. There is no attacker-controlled behavior here; it is a reliability/bug-fix patch.
Treat as a normal bug-fix commit. No security response required. Reviewers may want to confirm that `pauseRefresh()` and `store()` are safe to call from a background isolate and that `store()` failures are handled appropriately in `pauseSyncAndStore()`.
Security signals we found
No security-relevant signals present: change is a reliability/bug-fix for background synchronization
No input validation, authentication, cryptography, or network trust changes observed
No memory safety, injection, or privilege escalation patterns in diff
Evidence from the diff
The patch adds WalletModel.pauseSyncAndStore(), which calls the underlying wallet FFI’s pauseRefresh() and then store(). It invokes this before a WorkManager background isolate exits and before a foreground sync service is destroyed. It also restructures runTxNotifier() to avoid opening an existing wallet when background sync is disabled for full-node mode, and adds a stuck-progress detector (12 × 5 s polls) to end a background run early if syncedHeight stops advancing. The changes are defensive and aimed at preventing lost scan progress and wasted battery.
Changed components
lib/models/wallet_model.dartlib/periodic_tasks.dartlib/services/foreground_sync_service.dartInspect captured patch +73 / −4
diff --git a/lib/models/wallet_model.dart b/lib/models/wallet_model.dart
index edb63aa..e5b918b 100644
--- a/lib/models/wallet_model.dart
+++ b/lib/models/wallet_model.dart
@@ -1332,6 +1332,23 @@ class WalletModel with ChangeNotifier {
}
}
+ /// Stops the native scan thread and checkpoints what it managed to scan.
+ ///
+ /// Background isolates call this before they finish. Nothing closes the
+ /// wallet when a background task returns, so a refresh left running keeps
+ /// pulling blocks past the end of the task, and everything scanned since the
+ /// last periodic checkpoint would go with the isolate.
+ Future<void> pauseSyncAndStore() async {
+ if (_w2Wallet == null || !_daemonInitialized) return;
+
+ log(LogLevel.info, 'Pausing refresh and storing the wallet');
+
+ // Sets the refresh-enabled flag; the scan thread stops at its next check.
+ _w2Wallet!.pauseRefresh();
+
+ await store();
+ }
+
Future<bool> store() async {
final walletFfiAddr = _w2Wallet!.ffiAddress();
diff --git a/lib/periodic_tasks.dart b/lib/periodic_tasks.dart
index 11a8a39..184dab1 100644
--- a/lib/periodic_tasks.dart
+++ b/lib/periodic_tasks.dart
@@ -16,6 +16,15 @@ class PeriodicTasks {
/// under Android's ~10-minute WorkManager budget to persist + notify.
const _backgroundSyncBudget = Duration(minutes: 9);
+/// How often a background run checks on the scan it is waiting for.
+const _backgroundSyncPollInterval = Duration(seconds: 5);
+
+/// Consecutive polls without the synced height moving before a run gives up on
+/// the rest of its budget. The window has to outlast a refresh cycle: in LWS
+/// mode the height only moves when the 20s cycle reloads stats, so a shorter
+/// one would read a healthy run as stuck.
+const _backgroundSyncStuckPolls = 12;
+
/// WorkManager's minimum periodic interval.
const _minSyncIntervalMinutes = 15;
@@ -28,18 +37,24 @@ Future<bool> runTxNotifier() async {
// Load the connection first so the correct-mode wallet file is opened.
await wallet.loadPersistedConnection();
- await wallet.openExisting();
final backgroundSync =
await SharedPreferencesService.get<bool>(SharedPreferencesKeys.backgroundSyncEnabled) ??
false;
// A full-node scan is heavy and only runs when Background Sync is on; an LWS
- // wallet always syncs (server-side, cheap).
+ // wallet always syncs (server-side, cheap). Decided before the wallet is
+ // opened: this task stays scheduled for notifications alone, so a node wallet
+ // with Background Sync off lands here every cycle, and opening the wallet
+ // (with its cached-stats read) is the expensive part of a run that is about
+ // to do nothing. Leaving one open would also give the model's own timers
+ // something to connect.
if (wallet.connectionType == 'node' && !backgroundSync) {
return true;
}
+ await wallet.openExisting();
+
if (wallet.usingTor) {
await TorService.sharedInstance.start();
await TorService.sharedInstance.waitUntilConnected().timeout(
@@ -61,13 +76,38 @@ Future<bool> runTxNotifier() async {
// Keep the isolate alive so the on-device scan keeps advancing, up to the OS
// budget. The wallet's own timers drive the refresh + checkpoint; we just
- // wait (and bail early once it's synced).
+ // wait, and stop early once it's synced or once it stops getting anywhere.
final deadline = DateTime.now().add(_backgroundSyncBudget);
+ var lastSyncedHeight = wallet.syncedHeight;
+ var stuckPolls = 0;
+
while (DateTime.now().isBefore(deadline)) {
if (wallet.isConnected && wallet.isSynced) break;
- await Future.delayed(const Duration(seconds: 5));
+
+ final syncedHeight = wallet.syncedHeight;
+
+ if (syncedHeight != lastSyncedHeight) {
+ lastSyncedHeight = syncedHeight;
+ stuckPolls = 0;
+ } else if (++stuckPolls >= _backgroundSyncStuckPolls) {
+ // Unreachable server, dead Tor circuit, stalled scan: holding the wake-up
+ // open for the rest of the budget just spends battery to learn nothing.
+ log(
+ LogLevel.warn,
+ '[Background sync] No progress in '
+ '${_backgroundSyncStuckPolls * _backgroundSyncPollInterval.inSeconds}s; ending run early.',
+ );
+ break;
+ }
+
+ await Future.delayed(_backgroundSyncPollInterval);
}
+ // Stop the scan and checkpoint it before the isolate goes: nothing closes
+ // this wallet, so a refresh left running keeps pulling blocks after the task
+ // returns, and the last partial cycle of scanning would be thrown away.
+ await wallet.pauseSyncAndStore();
+
final notify =
await SharedPreferencesService.get<bool>(SharedPreferencesKeys.notificationsEnabled) ?? false;
diff --git a/lib/services/foreground_sync_service.dart b/lib/services/foreground_sync_service.dart
index c08dd3f..f2dc164 100644
--- a/lib/services/foreground_sync_service.dart
+++ b/lib/services/foreground_sync_service.dart
@@ -69,7 +69,19 @@ class _SyncTaskHandler extends TaskHandler {
@override
Future<void> onDestroy(DateTime timestamp) async {
+ final wallet = _wallet;
_wallet = null;
+
+ // Stop the scan and checkpoint it: the service is going away but nothing
+ // closes the wallet, and the scanning done since the last checkpoint would
+ // otherwise be lost.
+ if (wallet != null) {
+ try {
+ await wallet.pauseSyncAndStore();
+ } catch (e) {
+ log(LogLevel.warn, '[FG sync] Failed to store on shutdown: $e');
+ }
+ }
}
}
Why this scored 25/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.