Skip to content

Commit 4ed8a8d

Browse files
reubenyapclaude
andcommitted
fix: recover stalled wallet syncs via idle watchdog and progress reorder
Two targeted changes to _refresh() in the base Wallet<T> class, which affects all wallet types routing through the shared refresh flow (BTC, LTC, Firo, Monero, ETH, Cardano, Solana, etc. — but NOT MimbleWimbleCoin, ETH tokens, or SOL tokens, which override refresh()). 1. Idle watchdog on the refresh body. A Timer.periodic checks every 30s whether _fireRefreshPercentChange has been called within the last 10 minutes. If not, the refresh is assumed wedged, the watchdog throws TimeoutException, and the existing catch/finally releases refreshMutex. Previously the mutex stayed locked forever when any sub-operation hung, making every subsequent periodic sync bail out at the isLocked check until the app was force-closed. Idle-based (rather than wall-clock) means slow-but-active syncs don't get killed: a full Spark anonymity set download on a poor connection keeps firing per-sector progress and keeps the watchdog fed. Only truly silent periods trip it. Per-call hang detection is still the responsibility of the underlying adapters (e.g. electrum's connectionTimeout / aliveTimerDuration at 60s each); this watchdog only catches what slips through those layers. Note: the watchdog does not cancel in-flight work; it only unblocks the outer future so the mutex can be released. The orphaned work eventually resolves or errors on its own. 2. Fire progress updates *after* the awaited work completes rather than before. The 0.65 and 0.70 calls previously fired immediately after kicking off updateUTXOs/updateTransactions, making the bar appear stuck at those values while the real work was still running. The ~65% stall in Firo syncs was the most visible manifestation of this; other wallets had the same cosmetic issue. The refresh body is also extracted to a private _doRefreshWork(viewOnly) helper to keep _refresh() focused on mutex/event/watchdog/error handling. No behavioural change from the extraction. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 243015b commit 4ed8a8d

1 file changed

Lines changed: 133 additions & 86 deletions

File tree

lib/wallets/wallet/wallet.dart

Lines changed: 133 additions & 86 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,10 @@ abstract class Wallet<T extends CryptoCurrency> {
108108
Timer? _periodicRefreshTimer;
109109
Timer? _networkAliveTimer;
110110

111+
/// Timestamp of the last _fireRefreshPercentChange during an active
112+
/// refresh. Consumed by the idle watchdog in _refresh() to detect hangs.
113+
DateTime? _lastRefreshProgress;
114+
111115
bool _shouldAutoSync = false;
112116

113117
bool _isConnected = false;
@@ -603,6 +607,7 @@ abstract class Wallet<T extends CryptoCurrency> {
603607
}
604608

605609
void _fireRefreshPercentChange(double percent) {
610+
_lastRefreshProgress = DateTime.now();
606611
if (this is ElectrumXInterface) {
607612
(this as ElectrumXInterface?)?.refreshingPercent = percent;
608613
}
@@ -641,95 +646,45 @@ abstract class Wallet<T extends CryptoCurrency> {
641646
);
642647
}
643648

644-
// add some small buffer before making calls.
645-
// this can probably be removed in the future but was added as a
646-
// debugging feature
647-
await Future<void>.delayed(const Duration(milliseconds: 300));
648-
649-
// TODO: [prio=low] handle this differently. Extra modification of this file for coin specific functionality should be avoided.
650-
final Set<String> codesToCheck = {};
651-
if (this is PaynymInterface && !viewOnly) {
652-
// isSegwit does not matter here at all
653-
final myCode = await (this as PaynymInterface).getPaymentCode(
654-
isSegwit: false,
655-
);
656-
657-
final nym = await PaynymIsApi().nym(myCode.toString());
658-
if (nym.value != null) {
659-
for (final follower in nym.value!.followers) {
660-
codesToCheck.add(follower.code);
661-
}
662-
for (final following in nym.value!.following) {
663-
codesToCheck.add(following.code);
664-
}
649+
// Idle watchdog: trips when _fireRefreshPercentChange hasn't been
650+
// called for idleThreshold, signalling that the refresh is wedged.
651+
// Slow-but-active syncs (e.g. a big Spark anonymity set download that
652+
// keeps firing per-sector progress) keep the watchdog fed and aren't
653+
// killed. Per-call hang detection is still the responsibility of the
654+
// underlying adapters (e.g. electrum's connectionTimeout). This only
655+
// catches what slips through those layers and would otherwise hold
656+
// refreshMutex locked until the app is force-closed.
657+
const idleThreshold = Duration(minutes: 10);
658+
_lastRefreshProgress = DateTime.now();
659+
final watchdogCompleter = Completer<void>();
660+
final watchdog = Timer.periodic(const Duration(seconds: 30), (timer) {
661+
if (watchdogCompleter.isCompleted) {
662+
timer.cancel();
663+
return;
665664
}
666-
}
667-
668-
_fireRefreshPercentChange(0);
669-
await updateChainHeight();
670-
671-
if (this is BitcoinFrostWallet) {
672-
await (this as BitcoinFrostWallet).lookAhead();
673-
}
674-
675-
_fireRefreshPercentChange(0.1);
676-
677-
// TODO: [prio=low] handle this differently. Extra modification of this file for coin specific functionality should be avoided.
678-
if (this is MultiAddressInterface) {
679-
if (info.otherData[WalletInfoKeys.reuseAddress] != true) {
680-
await (this as MultiAddressInterface)
681-
.checkReceivingAddressForTransactions();
682-
}
683-
}
684-
685-
_fireRefreshPercentChange(0.2);
686-
687-
// TODO: [prio=low] handle this differently. Extra modification of this file for coin specific functionality should be avoided.
688-
if (this is MultiAddressInterface) {
689-
if (info.otherData[WalletInfoKeys.reuseAddress] != true) {
690-
await (this as MultiAddressInterface)
691-
.checkChangeAddressForTransactions();
665+
final last = _lastRefreshProgress;
666+
if (last == null) return;
667+
if (DateTime.now().difference(last) >= idleThreshold) {
668+
timer.cancel();
669+
watchdogCompleter.completeError(
670+
TimeoutException(
671+
'Wallet refresh for $walletId idle for '
672+
'${idleThreshold.inMinutes} min',
673+
idleThreshold,
674+
),
675+
);
692676
}
677+
});
678+
679+
try {
680+
await Future.any([
681+
_doRefreshWork(viewOnly),
682+
watchdogCompleter.future,
683+
]);
684+
} finally {
685+
watchdog.cancel();
686+
_lastRefreshProgress = null;
693687
}
694-
_fireRefreshPercentChange(0.3);
695-
if (this is SparkInterface && !viewOnly) {
696-
// this should be called before updateTransactions()
697-
await (this as SparkInterface).refreshSparkData((0.3, 0.6));
698-
}
699-
700-
if (this is NamecoinWallet) {
701-
await updateUTXOs();
702-
_fireRefreshPercentChange(0.6);
703-
await (this as NamecoinWallet).checkAutoRegisterNameNewOutputs();
704-
_fireRefreshPercentChange(0.70);
705-
await updateTransactions();
706-
} else {
707-
final fetchFuture = updateTransactions();
708-
_fireRefreshPercentChange(0.6);
709-
final utxosRefreshFuture = updateUTXOs();
710-
_fireRefreshPercentChange(0.65);
711-
await utxosRefreshFuture;
712-
_fireRefreshPercentChange(0.70);
713-
await fetchFuture;
714-
}
715-
716-
// TODO: [prio=low] handle this differently. Extra modification of this file for coin specific functionality should be avoided.
717-
if (!viewOnly && this is PaynymInterface && codesToCheck.isNotEmpty) {
718-
await (this as PaynymInterface).checkForNotificationTransactionsTo(
719-
codesToCheck,
720-
);
721-
// check utxos again for notification outputs
722-
await updateUTXOs();
723-
}
724-
_fireRefreshPercentChange(0.80);
725-
726-
// await getAllTxsToWatch();
727-
728-
_fireRefreshPercentChange(0.90);
729-
730-
await updateBalance();
731-
732-
_fireRefreshPercentChange(1.0);
733688

734689
completer.complete();
735690
} catch (error, strace) {
@@ -750,6 +705,98 @@ abstract class Wallet<T extends CryptoCurrency> {
750705
}
751706
}
752707

708+
Future<void> _doRefreshWork(bool viewOnly) async {
709+
// add some small buffer before making calls.
710+
// this can probably be removed in the future but was added as a
711+
// debugging feature
712+
await Future<void>.delayed(const Duration(milliseconds: 300));
713+
714+
// TODO: [prio=low] handle this differently. Extra modification of this file for coin specific functionality should be avoided.
715+
final Set<String> codesToCheck = {};
716+
if (this is PaynymInterface && !viewOnly) {
717+
// isSegwit does not matter here at all
718+
final myCode = await (this as PaynymInterface).getPaymentCode(
719+
isSegwit: false,
720+
);
721+
722+
final nym = await PaynymIsApi().nym(myCode.toString());
723+
if (nym.value != null) {
724+
for (final follower in nym.value!.followers) {
725+
codesToCheck.add(follower.code);
726+
}
727+
for (final following in nym.value!.following) {
728+
codesToCheck.add(following.code);
729+
}
730+
}
731+
}
732+
733+
_fireRefreshPercentChange(0);
734+
await updateChainHeight();
735+
736+
if (this is BitcoinFrostWallet) {
737+
await (this as BitcoinFrostWallet).lookAhead();
738+
}
739+
740+
_fireRefreshPercentChange(0.1);
741+
742+
// TODO: [prio=low] handle this differently. Extra modification of this file for coin specific functionality should be avoided.
743+
if (this is MultiAddressInterface) {
744+
if (info.otherData[WalletInfoKeys.reuseAddress] != true) {
745+
await (this as MultiAddressInterface)
746+
.checkReceivingAddressForTransactions();
747+
}
748+
}
749+
750+
_fireRefreshPercentChange(0.2);
751+
752+
// TODO: [prio=low] handle this differently. Extra modification of this file for coin specific functionality should be avoided.
753+
if (this is MultiAddressInterface) {
754+
if (info.otherData[WalletInfoKeys.reuseAddress] != true) {
755+
await (this as MultiAddressInterface)
756+
.checkChangeAddressForTransactions();
757+
}
758+
}
759+
_fireRefreshPercentChange(0.3);
760+
if (this is SparkInterface && !viewOnly) {
761+
// this should be called before updateTransactions()
762+
await (this as SparkInterface).refreshSparkData((0.3, 0.6));
763+
}
764+
765+
if (this is NamecoinWallet) {
766+
await updateUTXOs();
767+
_fireRefreshPercentChange(0.6);
768+
await (this as NamecoinWallet).checkAutoRegisterNameNewOutputs();
769+
_fireRefreshPercentChange(0.70);
770+
await updateTransactions();
771+
} else {
772+
final fetchFuture = updateTransactions();
773+
_fireRefreshPercentChange(0.6);
774+
final utxosRefreshFuture = updateUTXOs();
775+
await utxosRefreshFuture;
776+
_fireRefreshPercentChange(0.65);
777+
await fetchFuture;
778+
_fireRefreshPercentChange(0.70);
779+
}
780+
781+
// TODO: [prio=low] handle this differently. Extra modification of this file for coin specific functionality should be avoided.
782+
if (!viewOnly && this is PaynymInterface && codesToCheck.isNotEmpty) {
783+
await (this as PaynymInterface).checkForNotificationTransactionsTo(
784+
codesToCheck,
785+
);
786+
// check utxos again for notification outputs
787+
await updateUTXOs();
788+
}
789+
_fireRefreshPercentChange(0.80);
790+
791+
// await getAllTxsToWatch();
792+
793+
_fireRefreshPercentChange(0.90);
794+
795+
await updateBalance();
796+
797+
_fireRefreshPercentChange(1.0);
798+
}
799+
753800
Future<void> exit() async {
754801
Logging.instance.i("exit called on $walletId");
755802
_periodicRefreshTimer?.cancel();

0 commit comments

Comments
 (0)