Skip to content

Commit d96fffc

Browse files
reubenyapclaude
andcommitted
fix: recover stalled wallet syncs via refresh timeout and progress reorder
Two targeted changes to _refresh() in the base Wallet<T> class, which affects all wallet types that route 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. 60-minute master timeout on the refresh body. If any sub-operation hangs indefinitely (network death, OS-suspended sockets, wedged native-lib callbacks), TimeoutException now unwinds through the existing catch/finally and releases refreshMutex. Previously the mutex would remain locked forever, making every subsequent periodic sync bail out at the isLocked check until the app was force-closed. The timeout is intentionally generous. Per-call timeouts are handled by underlying adapters (e.g. electrum adapter's connectionTimeout / aliveTimerDuration at 60s each). The master timeout only needs to catch what slips through those layers. 60 minutes covers worst-case legitimate initial syncs (Monero restore, full Spark anonymity set download on a slow connection) while still recovering from true hangs within an hour. Note: .timeout() does not cancel in-flight work; it only completes the outer future. The intent is to recover the mutex. 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/timeout/error handling. No behavioral change from the extraction. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 243015b commit d96fffc

1 file changed

Lines changed: 105 additions & 88 deletions

File tree

lib/wallets/wallet/wallet.dart

Lines changed: 105 additions & 88 deletions
Original file line numberDiff line numberDiff line change
@@ -641,113 +641,130 @@ abstract class Wallet<T extends CryptoCurrency> {
641641
);
642642
}
643643

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-
);
644+
// Generous upper bound to recover the mutex from permanent hangs
645+
// (network death, suspended sockets, wedged native lib callbacks, etc.)
646+
// without false-positiving on legitimate slow initial syncs such as
647+
// a full Spark anonymity set download on a poor connection. Per-call
648+
// timeouts are the responsibility of the underlying adapters
649+
// (e.g. electrum adapter's connectionTimeout/aliveTimerDuration).
650+
const refreshTimeout = Duration(minutes: 60);
651+
await _doRefreshWork(viewOnly).timeout(
652+
refreshTimeout,
653+
onTimeout: () => throw TimeoutException(
654+
'Wallet refresh timed out for $walletId',
655+
refreshTimeout,
656+
),
657+
);
656658

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-
}
665-
}
659+
completer.complete();
660+
} catch (error, strace) {
661+
completer.completeError(error, strace);
662+
} finally {
663+
refreshMutex.release();
664+
if (!completer.isCompleted) {
665+
completer.completeError(
666+
"finally block hit before completer completed",
667+
StackTrace.current,
668+
);
666669
}
667670

668-
_fireRefreshPercentChange(0);
669-
await updateChainHeight();
670-
671-
if (this is BitcoinFrostWallet) {
672-
await (this as BitcoinFrostWallet).lookAhead();
673-
}
671+
Logging.instance.i(
672+
"Refresh for "
673+
"$walletId::${info.name}: ${DateTime.now().difference(start)}",
674+
);
675+
}
676+
}
674677

675-
_fireRefreshPercentChange(0.1);
678+
Future<void> _doRefreshWork(bool viewOnly) async {
679+
// add some small buffer before making calls.
680+
// this can probably be removed in the future but was added as a
681+
// debugging feature
682+
await Future<void>.delayed(const Duration(milliseconds: 300));
683+
684+
// TODO: [prio=low] handle this differently. Extra modification of this file for coin specific functionality should be avoided.
685+
final Set<String> codesToCheck = {};
686+
if (this is PaynymInterface && !viewOnly) {
687+
// isSegwit does not matter here at all
688+
final myCode = await (this as PaynymInterface).getPaymentCode(
689+
isSegwit: false,
690+
);
676691

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();
692+
final nym = await PaynymIsApi().nym(myCode.toString());
693+
if (nym.value != null) {
694+
for (final follower in nym.value!.followers) {
695+
codesToCheck.add(follower.code);
696+
}
697+
for (final following in nym.value!.following) {
698+
codesToCheck.add(following.code);
682699
}
683700
}
701+
}
684702

685-
_fireRefreshPercentChange(0.2);
703+
_fireRefreshPercentChange(0);
704+
await updateChainHeight();
686705

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();
692-
}
693-
}
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-
}
706+
if (this is BitcoinFrostWallet) {
707+
await (this as BitcoinFrostWallet).lookAhead();
708+
}
699709

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;
710+
_fireRefreshPercentChange(0.1);
711+
712+
// TODO: [prio=low] handle this differently. Extra modification of this file for coin specific functionality should be avoided.
713+
if (this is MultiAddressInterface) {
714+
if (info.otherData[WalletInfoKeys.reuseAddress] != true) {
715+
await (this as MultiAddressInterface)
716+
.checkReceivingAddressForTransactions();
714717
}
718+
}
715719

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();
720+
_fireRefreshPercentChange(0.2);
721+
722+
// TODO: [prio=low] handle this differently. Extra modification of this file for coin specific functionality should be avoided.
723+
if (this is MultiAddressInterface) {
724+
if (info.otherData[WalletInfoKeys.reuseAddress] != true) {
725+
await (this as MultiAddressInterface)
726+
.checkChangeAddressForTransactions();
723727
}
724-
_fireRefreshPercentChange(0.80);
728+
}
729+
_fireRefreshPercentChange(0.3);
730+
if (this is SparkInterface && !viewOnly) {
731+
// this should be called before updateTransactions()
732+
await (this as SparkInterface).refreshSparkData((0.3, 0.6));
733+
}
725734

726-
// await getAllTxsToWatch();
735+
if (this is NamecoinWallet) {
736+
await updateUTXOs();
737+
_fireRefreshPercentChange(0.6);
738+
await (this as NamecoinWallet).checkAutoRegisterNameNewOutputs();
739+
_fireRefreshPercentChange(0.70);
740+
await updateTransactions();
741+
} else {
742+
final fetchFuture = updateTransactions();
743+
_fireRefreshPercentChange(0.6);
744+
final utxosRefreshFuture = updateUTXOs();
745+
await utxosRefreshFuture;
746+
_fireRefreshPercentChange(0.65);
747+
await fetchFuture;
748+
_fireRefreshPercentChange(0.70);
749+
}
727750

728-
_fireRefreshPercentChange(0.90);
751+
// TODO: [prio=low] handle this differently. Extra modification of this file for coin specific functionality should be avoided.
752+
if (!viewOnly && this is PaynymInterface && codesToCheck.isNotEmpty) {
753+
await (this as PaynymInterface).checkForNotificationTransactionsTo(
754+
codesToCheck,
755+
);
756+
// check utxos again for notification outputs
757+
await updateUTXOs();
758+
}
759+
_fireRefreshPercentChange(0.80);
729760

730-
await updateBalance();
761+
// await getAllTxsToWatch();
731762

732-
_fireRefreshPercentChange(1.0);
763+
_fireRefreshPercentChange(0.90);
733764

734-
completer.complete();
735-
} catch (error, strace) {
736-
completer.completeError(error, strace);
737-
} finally {
738-
refreshMutex.release();
739-
if (!completer.isCompleted) {
740-
completer.completeError(
741-
"finally block hit before completer completed",
742-
StackTrace.current,
743-
);
744-
}
765+
await updateBalance();
745766

746-
Logging.instance.i(
747-
"Refresh for "
748-
"$walletId::${info.name}: ${DateTime.now().difference(start)}",
749-
);
750-
}
767+
_fireRefreshPercentChange(1.0);
751768
}
752769

753770
Future<void> exit() async {

0 commit comments

Comments
 (0)