perf(net): hold wallet scans at connect, in the background and on tab entry
- Connect gate: after the node starts answering, wallet scans wait until the lock state is known and the node knows its tip (or the wallet has fewer than 500 transactions), at most 20 s. A node already behind latches the sync profile from the connect's getinfo. Meanwhile a list-only pass keeps the address list and the cached history up, and the session's first balance and per-address balances are read once even while catching up, so the wallet shows its last-synced values instead of 0. - Background: minimized, hidden, or covered without focus (3 s debounce, any input cancels) polls only the chain state every 15 s. Wallet scans, the mining poll, price, nudges and auto-lock interaction tracking pause; auto-lock now fires while minimized. Leaving a held profile marks every timer due again. - Tab entry marks the timers due instead of forcing scans; a stale list or a never-loaded history still loads on entry. - The scan entry points own the hard gates, failed enumerations keep the previous lists behind a retry hold, and failed balance calls never overwrite known balances with 0. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -247,9 +247,21 @@ NetworkRefreshService::WalletEncryptionResult NetworkRefreshService::parseWallet
|
||||
result.ok = false;
|
||||
}
|
||||
}
|
||||
if (walletInfo.contains("txcount") && walletInfo["txcount"].is_number_integer())
|
||||
result.txCount = walletInfo["txcount"].get<int>();
|
||||
return result;
|
||||
}
|
||||
|
||||
bool NetworkRefreshService::connectionInfoTipKnown(const ConnectionInfoResult& info)
|
||||
{
|
||||
return info.ok && info.longestChain && *info.longestChain > 0;
|
||||
}
|
||||
|
||||
bool NetworkRefreshService::connectionInfoShowsBehind(const ConnectionInfoResult& info)
|
||||
{
|
||||
return connectionInfoTipKnown(info) && info.blocks && *info.blocks < *info.longestChain - 2;
|
||||
}
|
||||
|
||||
NetworkRefreshService::WarmupPollResult NetworkRefreshService::collectWarmupPollResult(RefreshRpcGateway& rpc)
|
||||
{
|
||||
WarmupPollResult result;
|
||||
@@ -378,7 +390,7 @@ NetworkRefreshService::CoreRefreshResult NetworkRefreshService::collectCoreRefre
|
||||
// balance scans: they need the same cs_main, and each would only take another of its few RPC slots.
|
||||
bool balanceAttempted = false;
|
||||
bool spendableQueried = false;
|
||||
if (plan.includeBalance && !behind && !rpc::rpcKindMeansBusy(blockErrKind)) {
|
||||
if (plan.includeBalance && (!behind || plan.evenIfBehind) && !rpc::rpcKindMeansBusy(blockErrKind)) {
|
||||
// z_gettotalbalance is O(mapWallet) and holds the daemon's cs_main for its whole duration —
|
||||
// seconds on a large shielded wallet. Time it so the caller can throttle how often it polls
|
||||
// (balanceRefreshDue()), keeping balance scans from starving block connection.
|
||||
@@ -402,7 +414,9 @@ NetworkRefreshService::CoreRefreshResult NetworkRefreshService::collectCoreRefre
|
||||
const auto total = totalBalance.is_object() ? totalBalance.find("total") : totalBalance.end();
|
||||
if (total != totalBalance.end() && total->is_string()) totalZat = parseMoneyZat(total->get<std::string>());
|
||||
const bool totalMoved = !totalZat || !plan.prevTotalZat || *totalZat != *plan.prevTotalZat;
|
||||
if (plan.needSpendable || totalMoved) {
|
||||
// Not on the session's first read while the node is behind: nothing can be sent until it caught
|
||||
// up anyway, and it is a second whole-wallet scan into the catch-up.
|
||||
if ((plan.needSpendable || totalMoved) && !(behind && plan.evenIfBehind)) {
|
||||
spendableQueried = true;
|
||||
try { // If it fails, spendable degrades to display in apply.
|
||||
spendableBalance = rpc.call("z_gettotalbalance", json::array({1}));
|
||||
@@ -753,9 +767,12 @@ NetworkRefreshService::AddressRefreshSnapshot NetworkRefreshService::buildAddres
|
||||
|
||||
NetworkRefreshService::AddressRefreshResult NetworkRefreshService::collectAddressRefreshResult(
|
||||
RefreshRpcGateway& rpc,
|
||||
const AddressRefreshSnapshot& snapshot)
|
||||
const AddressRefreshSnapshot& snapshot,
|
||||
bool listOnly)
|
||||
{
|
||||
AddressRefreshResult result;
|
||||
result.listOnly = listOnly;
|
||||
|
||||
// Time the whole scan — z_listunspent (and per-address z_getbalance fallback) hold the daemon's
|
||||
// cs_main for the duration, seconds on a large shielded wallet. The measured cost feeds the
|
||||
// caller's adaptive throttle so the address poll can't starve block connection.
|
||||
@@ -797,9 +814,23 @@ NetworkRefreshService::AddressRefreshResult NetworkRefreshService::collectAddres
|
||||
DEBUG_LOGF("z_listaddresses error: %s\n", e.what());
|
||||
result.addressListOk = false; // enumeration failed → the shielded list may be falsely short
|
||||
result.replyTimedOut |= rpc::rpcFailureKindOf(e) == rpc::RpcFailureKind::ReplyTimeout;
|
||||
result.nodeBusy |= rpc::rpcKindMeansBusy(rpc::rpcFailureKindOf(e));
|
||||
}
|
||||
|
||||
try {
|
||||
json tList = rpc.call("getaddressesbyaccount", json::array({""}));
|
||||
result.transparentAddresses = parseTransparentAddressList(tList);
|
||||
} catch (const std::exception& e) {
|
||||
DEBUG_LOGF("getaddressesbyaccount error: %s\n", e.what());
|
||||
result.addressListOk = false; // enumeration failed → the transparent list may be falsely short
|
||||
result.replyTimedOut |= rpc::rpcFailureKindOf(e) == rpc::RpcFailureKind::ReplyTimeout;
|
||||
result.nodeBusy |= rpc::rpcKindMeansBusy(rpc::rpcFailureKindOf(e));
|
||||
}
|
||||
|
||||
// The balances need the lists they belong to: none in a list-only pass, and none when an enumeration
|
||||
// failed (the caller discards the whole pass and keeps the previous lists).
|
||||
const bool scanBalances = !listOnly && result.addressListOk;
|
||||
if (scanBalances) try {
|
||||
json unspent = rpc.call("z_listunspent", json::array({0, 9999999, false})); // minconf=0 → include 0-conf change
|
||||
applyShieldedBalancesFromUnspent(result.shieldedAddresses, unspent);
|
||||
// Retain a minimal view so a downstream consumer (chat note-budget) can reuse this scan instead
|
||||
@@ -822,8 +853,15 @@ NetworkRefreshService::AddressRefreshResult NetworkRefreshService::collectAddres
|
||||
}
|
||||
}
|
||||
} catch (const std::exception& e) {
|
||||
const auto kind = rpc::rpcFailureKindOf(e);
|
||||
result.replyTimedOut |= kind == rpc::RpcFailureKind::ReplyTimeout;
|
||||
if (rpc::rpcKindMeansBusy(kind)) {
|
||||
// The node is busy: every z_getbalance would be held back and read 0. Nothing more this pass.
|
||||
DEBUG_LOGF("z_listunspent: node busy (%s)\n", e.what());
|
||||
result.balancesOk = false;
|
||||
result.nodeBusy = true;
|
||||
} else {
|
||||
DEBUG_LOGF("z_listunspent unavailable (%s), falling back to z_getbalance\n", e.what());
|
||||
result.replyTimedOut |= rpc::rpcFailureKindOf(e) == rpc::RpcFailureKind::ReplyTimeout;
|
||||
for (auto& info : result.shieldedAddresses) {
|
||||
try { // display total (minconf=0, includes pending change)
|
||||
json total = rpc.call("z_getbalance", json::array({info.address, 0}));
|
||||
@@ -834,24 +872,23 @@ NetworkRefreshService::AddressRefreshResult NetworkRefreshService::collectAddres
|
||||
info.spendableBalance = (!conf.is_null()) ? conf.get<double>() : info.balance;
|
||||
} catch (...) { info.spendableBalance = info.balance; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
json tList = rpc.call("getaddressesbyaccount", json::array({""}));
|
||||
result.transparentAddresses = parseTransparentAddressList(tList);
|
||||
} catch (const std::exception& e) {
|
||||
DEBUG_LOGF("getaddressesbyaccount error: %s\n", e.what());
|
||||
result.addressListOk = false; // enumeration failed → the transparent list may be falsely short
|
||||
result.replyTimedOut |= rpc::rpcFailureKindOf(e) == rpc::RpcFailureKind::ReplyTimeout;
|
||||
}
|
||||
|
||||
try {
|
||||
if (scanBalances && !result.nodeBusy) try {
|
||||
json unspent = rpc.call("listunspent", json::array({0})); // minconf=0 → include 0-conf change
|
||||
applyTransparentBalancesFromUnspent(result.transparentAddresses, unspent);
|
||||
} catch (const std::exception& e) {
|
||||
DEBUG_LOGF("listunspent error: %s\n", e.what());
|
||||
result.balancesOk = false;
|
||||
result.replyTimedOut |= rpc::rpcFailureKindOf(e) == rpc::RpcFailureKind::ReplyTimeout;
|
||||
result.nodeBusy |= rpc::rpcKindMeansBusy(rpc::rpcFailureKindOf(e));
|
||||
}
|
||||
// A balance that wasn't read reads 0: never overwrite known balances with that. The pass degrades to
|
||||
// list-only (apply carries the previous balances), and its notes aren't a full view either.
|
||||
if (scanBalances && !result.balancesOk) {
|
||||
result.listOnly = true;
|
||||
result.unspentNotes.clear();
|
||||
}
|
||||
|
||||
result.scanMs = std::chrono::duration<double, std::milli>(
|
||||
@@ -1694,6 +1731,7 @@ void NetworkRefreshService::applyWalletEncryptionResult(WalletState& state, cons
|
||||
state.unlocked_until = 0;
|
||||
}
|
||||
state.encryption_state_known = true;
|
||||
if (result.txCount) state.wallet_tx_count = *result.txCount;
|
||||
}
|
||||
|
||||
void NetworkRefreshService::applyConnectionInitResult(WalletState& state, const ConnectionInitResult& result)
|
||||
@@ -1810,6 +1848,22 @@ void NetworkRefreshService::applyPriceRefreshFailure(WalletState& state,
|
||||
void NetworkRefreshService::applyAddressRefreshResult(WalletState& state,
|
||||
AddressRefreshResult&& result)
|
||||
{
|
||||
if (result.listOnly) {
|
||||
// No balance scan ran: keep each address's last known balances (a new address shows 0 until the
|
||||
// next full pass).
|
||||
auto carry = [](std::vector<AddressInfo>& fresh, const std::vector<AddressInfo>& previous) {
|
||||
std::unordered_map<std::string, const AddressInfo*> known;
|
||||
for (const auto& info : previous) known[info.address] = &info;
|
||||
for (auto& info : fresh) {
|
||||
const auto it = known.find(info.address);
|
||||
if (it == known.end()) continue;
|
||||
info.balance = it->second->balance;
|
||||
info.spendableBalance = it->second->spendableBalance;
|
||||
}
|
||||
};
|
||||
carry(result.shieldedAddresses, state.z_addresses);
|
||||
carry(result.transparentAddresses, state.t_addresses);
|
||||
}
|
||||
state.z_addresses = std::move(result.shieldedAddresses);
|
||||
state.t_addresses = std::move(result.transparentAddresses);
|
||||
// Keep the combined `addresses` view in sync with the authoritative z/t lists.
|
||||
|
||||
Reference in New Issue
Block a user