perf(net): one balance scan per poll, filtered opstatus, a guarded merge scope

The Core poll ran z_gettotalbalance twice (minconf 0 and 1), each a whole-
wallet scan under dragonxd's cs_main. The spendable (minconf=1) call now
runs only after the display call succeeded, and only when something is
unconfirmed or pending or the exact total moved: about 95% of polls make
one scan instead of two. A forced post-send balance can no longer be lost
to a refused enqueue, and pending-send debits aren't re-applied each tick.

z_getoperationstatus is filtered to our opids with its real argument form
([[id,...]]; the old ["id"] was rejected), falling back to the unfiltered
call if a daemon refuses the filter. The Shield/Merge dialog's own polls
are filtered and session-guarded too, and finally reach Done; the dead
RPCClient wrappers are deleted.

The Merge/Consolidate scope scan becomes a tested state machine: one scan
at a time, failures latch behind a Retry button, only "node not ready"
retries by itself (10/30/60 s, at most 3), Lite never scans. Its 120 s
calls are maintenance calls: every main-thread disconnect aborts one in
flight first, so a lifecycle action no longer freezes the UI behind it.
An aborted merge that was already sent says it may still go through.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-09 11:06:34 -05:00
co-authored by Claude Opus 5.5
parent 4eed4283d6
commit 8b9a498eee
23 changed files with 1044 additions and 128 deletions
+54 -10
View File
@@ -687,7 +687,9 @@ void App::tryConnect()
void App::onConnected()
{
maint_abort_.store(false); // maintenance calls queued before this connect have already failed (FIFO)
state_.connected = true;
ui::ShieldDialog::invalidateScope(); // inputs read before this connect may be another wallet's
const bool completedSwitch = wallet_switch_pending_confirm_.exchange(false); // this connect confirms a switch
// A successful connect completes a switch — close/clear the progress modal (kept open through the
// stop → start → reconnect sequence, or already hidden via "continue in background").
@@ -837,6 +839,9 @@ void App::onDisconnected(const std::string& reason)
network_refresh_.resetJobs();
rescan_status_poll_in_progress_ = false;
opid_poll_in_progress_ = false;
opid_filter_unsupported_ = false; // a reconnect may be to a different daemon
last_total_balance_zat_.reset();
ui::ShieldDialog::invalidateScope();
address_validation_cache_dirty_ = true;
resetTransactionHistoryCacheSession();
@@ -937,6 +942,7 @@ void App::handleLostConnection(const std::string& reason)
DEBUG_LOGF("[Connection] %s — tearing down for reconnect\n", reason.c_str());
// Flip the main client's connected_ flag so update()'s else-branch re-enters
// tryConnect(). onDisconnected() alone only tears down the fast lane.
abortMaintenanceCalls(); // a long call holds the curl lock disconnect() needs
if (rpc_) rpc_->disconnect();
onDisconnected(reason);
}
@@ -989,6 +995,24 @@ bool App::effectivelySyncing() const
return std::time(nullptr) < sync_settle_until_;
}
void App::abortMaintenanceCalls()
{
// Both stick until the next connect, which is fine here: every caller disconnects next.
maint_abort_.store(true); // refuses maintenance calls still queued on the worker
if (rpc_ && maint_calls_.load() > 0) rpc_->requestAbort(); // cuts off the one in flight
}
bool App::walletScanReady() const
{
if (isLiteBuild() || capture_mode_ || shutting_down_.load()) return false;
// state_.warming_up, not rpc_->isWarmingUp(): the client's flag is set when a connect lands in warmup
// and stays set until the next reconnect, which kept this false for the whole session.
if (!rpc_ || !rpc_->isConnected()) return false;
if (!state_.connected || state_.warming_up || state_.isLocked()) return false;
if (state_.sync.rescanning || runtime_rescan_active_ || effectivelySyncing()) return false;
return !rpc_busy_gate_->busy();
}
// Adaptive throttle: the next balance poll must wait at least (lastScanCost / kBalanceDutyCycle) since
// the last one, so balance scanning can never occupy more than ~kBalanceDutyCycle of wall-clock. A
// cheap wallet (sub-cadence cost) is unaffected — the tab's Core timer stays the real cadence; a ~20s
@@ -1194,7 +1218,7 @@ void App::removePendingSendTransactions(const std::vector<std::string>& opids,
}
void App::applyPendingSendDelta(const std::string& fromAddress, double signedAmount,
bool includeAggregates)
bool includeAggregates, bool includeAddress)
{
// For a debit (signedAmount < 0) this clamps at >=0 exactly as the debit sites did. For a
// restore (signedAmount > 0) the clamp is a no-op — max(0, bal+amt) == bal+amt when bal,amt>=0 —
@@ -1211,7 +1235,7 @@ void App::applyPendingSendDelta(const std::string& fromAddress, double signedAmo
}
return false;
};
if (!applyToAddress(state_.z_addresses)) applyToAddress(state_.t_addresses);
if (includeAddress && !applyToAddress(state_.z_addresses)) applyToAddress(state_.t_addresses);
if (includeAggregates) {
if (!fromAddress.empty() && fromAddress[0] == 'z') {
state_.spendablePrivateBalance = std::max(0.0, state_.spendablePrivateBalance + signedAmount);
@@ -1244,11 +1268,11 @@ bool App::invokeSendResultCallback(const std::string& opid, bool ok,
return true;
}
void App::applyPendingSendBalanceDeltas(bool includeAggregateBalances)
void App::applyPendingSendBalanceDeltas(bool includeAggregateBalances, bool includeAddressBalances)
{
for (const auto& [opid, pending] : pending_send_info_) {
(void)opid;
applyPendingSendDelta(pending.from, -pending.amount, includeAggregateBalances);
applyPendingSendDelta(pending.from, -pending.amount, includeAggregateBalances, includeAddressBalances);
}
}
@@ -1458,6 +1482,7 @@ void App::switchToWallet(const std::string& walletFile, bool stopDaemonConfirmed
// start, so the daemon comes up on -wallet=<name>. WalletState clears on disconnect and the
// per-wallet caches re-key off the new identity (P1), so no previous-wallet data leaks through.
daemon_restarting_ = true;
abortMaintenanceCalls(); // a long call holds the curl lock disconnect() needs
if (rpc_ && rpc_->isConnected()) rpc_->disconnect();
onDisconnected("Switching wallet");
// The loaded wallet is changing — drop the old wallet's chat identity + decrypted store so it
@@ -1885,15 +1910,31 @@ void App::refreshCoreData(bool allowBalance)
// pending for after the unlock.
const bool includeBalance = allowBalance && !effectivelySyncing() &&
(force_balance_refresh_ || balanceRefreshDue());
if (includeBalance) force_balance_refresh_ = false;
// The forced request this poll carries. The result callback clears the flag only if the balance
// call was really sent and no newer request came in; clearing it here lost it whenever the enqueue
// was refused (a Core job already in flight) or the worker skipped the balance (behind, busy).
const std::uint32_t forcedSeq = (includeBalance && force_balance_refresh_) ? force_balance_seq_ : 0;
NetworkRefreshService::CoreRefreshPlan plan;
plan.includeBalance = includeBalance;
plan.needSpendable = NetworkRefreshService::spendableBalanceNeeded(
state_, !pending_send_info_.empty() || !pending_opids_.empty() || !send_txids_.empty(),
force_balance_refresh_);
plan.prevTotalZat = last_total_balance_zat_;
auto enqueued = network_refresh_.enqueue(services::NetworkRefreshService::Job::Core, *w, [this, rpc, tracePage, includeBalance]() -> rpc::RPCWorker::MainCb {
auto enqueued = network_refresh_.enqueue(services::NetworkRefreshService::Job::Core, *w, [this, rpc, tracePage, plan, forcedSeq]() -> rpc::RPCWorker::MainCb {
AppRefreshRpcGateway refreshRpc(*rpc, traceSource(tracePage, "Core refresh"));
auto result = NetworkRefreshService::collectCoreRefreshResult(refreshRpc, includeBalance);
return [this, result]() {
auto result = NetworkRefreshService::collectCoreRefreshResult(refreshRpc, plan);
return [this, result, forcedSeq]() {
try {
NetworkRefreshService::applyCoreRefreshResult(state_, result, std::time(nullptr));
applyPendingSendBalanceDeltas(true);
if (result.balanceOk) {
// Fresh aggregates: re-apply the pending sends' debits to them. The per-address
// balances are the address scan's to refresh and re-debit.
applyPendingSendBalanceDeltas(true, false);
}
last_total_balance_zat_ = NetworkRefreshService::nextTotalBaseline(result, last_total_balance_zat_);
if (forcedSeq != 0 && result.balanceAttempted && forcedSeq == force_balance_seq_)
force_balance_refresh_ = false;
// Feed the adaptive balance throttle + sync-settle hysteresis. Record the last scan's
// cost (0 when balance was skipped), and arm the settle window only on the
@@ -5182,6 +5223,7 @@ void App::beginAdoptSeedWallet()
{ std::lock_guard<std::mutex> lk(seed_migration_mutex_); seed_migration_adopt_done_ = false; }
// Gate the main-loop reconnect so tryConnect can't start the daemon while we swap wallet.dat.
daemon_restarting_ = true;
abortMaintenanceCalls(); // a long call holds the curl lock disconnect() needs
if (rpc_ && rpc_->isConnected()) rpc_->disconnect();
onDisconnected("Adopting seed wallet");
// Adopting swaps in a brand-new seed wallet — drop the legacy wallet's chat identity + store so
@@ -5302,6 +5344,7 @@ void App::restoreOriginalWallet()
{ std::lock_guard<std::mutex> lk(wallet_restore_mutex_); wallet_restore_done_ = false; }
daemon_restarting_ = true; // gate the reconnect loop while we swap files
connection_status_ = TR("sb_restarting_daemon");
abortMaintenanceCalls(); // a long call holds the curl lock disconnect() needs
if (rpc_ && rpc_->isConnected()) rpc_->disconnect();
onDisconnected("Restoring original wallet");
ui::Notifications::instance().info(TR("wallet_restore_started"), 12.0f);
@@ -5472,6 +5515,7 @@ void App::rebuildWalletDatabase()
{ std::lock_guard<std::mutex> lk(wallet_restore_mutex_); wallet_restore_done_ = false; }
daemon_restarting_ = true;
connection_status_ = TR("sb_restarting_daemon");
abortMaintenanceCalls(); // a long call holds the curl lock disconnect() needs
if (rpc_ && rpc_->isConnected()) rpc_->disconnect();
onDisconnected("Rebuilding wallet database");
ui::Notifications::instance().info(TR("wallet_rebuild_started"), 15.0f);
@@ -5813,7 +5857,7 @@ void App::submitZSendMany(const std::string& from, const std::string& to, double
// Force transaction list refresh so the sent tx appears immediately
transactions_dirty_ = true;
last_tx_block_height_ = -1;
force_balance_refresh_ = true; // the user's own send must update the balance now, past the throttle
requestForcedBalanceRefresh(); // the user's own send must update the balance now, past the throttle
network_refresh_.markWalletMutationRefresh();
// z_sendmany only returned an opid: the transaction is built/signed/
// broadcast asynchronously by the daemon. Defer the user-facing