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
+22 -5
View File
@@ -325,6 +325,7 @@ public:
void beginBackgroundStop(const char* reason) override
{
app_.abortMaintenanceCalls(); // a long call holds the curl lock disconnect() needs
if (app_.rpc_ && app_.rpc_->isConnected()) app_.rpc_->disconnect();
app_.onDisconnected(reason ? reason : "Daemon lifecycle");
App& app = app_;
@@ -1289,13 +1290,28 @@ void App::update()
if (!rpc) return [this](){ opid_poll_in_progress_ = false; };
json result;
services::NetworkRefreshService::OperationStatusPollResult parsed;
const auto filter = services::NetworkRefreshService::buildOperationFilterParams(opids);
if (!filter) return [this](){ opid_poll_in_progress_ = false; };
try {
rpc::RPCClient::TraceScope trace("Send tab / Operation status");
// No per-opid filter: this daemon rejects z_getoperationstatus(["opid"]) with
// "JSON value is not an array as expected", which left completed sends stuck on
// "Waiting for operation". The no-arg form returns ALL operations;
// parseOperationStatusPoll() filters down to the opids we're tracking.
result = rpc->call("z_getoperationstatus", json::array());
// Filtered to our opids: the call takes cs_main + cs_wallet and builds a status object for
// every op it reports, and the daemon retains finished ops, so the unfiltered form grew
// with every send, shield and chat message. Its one argument is an ARRAY of ids,
// [[id, ...]]; the old ["opid"] attempt failed with "JSON value is not an array as
// expected" because params[0] was a string. If a daemon still answers the filter with an
// error, fall back to the unfiltered form for this connection rather than wedge sends on
// "Waiting for operation"; parseOperationStatusPoll() ignores ops we don't track.
if (!opid_filter_unsupported_.load(std::memory_order_relaxed)) {
try {
result = rpc->call("z_getoperationstatus", *filter);
} catch (const rpc::RpcError& e) {
if (e.kind() == rpc::RpcFailureKind::Warmup) throw;
result = rpc->call("z_getoperationstatus", json::array());
opid_filter_unsupported_.store(true, std::memory_order_relaxed);
}
} else {
result = rpc->call("z_getoperationstatus", json::array());
}
// Parse INSIDE the guard: a malformed/type-anomalous element must never abort the
// poll and leave opid_poll_in_progress_ stuck true for the whole connected session.
parsed = services::NetworkRefreshService::parseOperationStatusPoll(result, opids);
@@ -7496,6 +7512,7 @@ void App::restartDaemon()
// Disconnect RPC so the loading overlay appears
if (decision.disconnectRpc && rpc_ && rpc_->isConnected()) {
abortMaintenanceCalls(); // a long call holds the curl lock disconnect() needs
rpc_->disconnect();
}
onDisconnected("Daemon restart");