perf(lite): move wallet ops and server switches off the UI thread

New address, Import key, Encrypt and Remove encryption called the
backend on the UI thread. Each ends in the backend's save, which waits on
the lock a scan holds for its whole duration and every refresh read holds
briefly, so during a scan the window froze until the scan ended. A create
or restore with a passphrase encrypted and saved just after its first
scan had started: for a restore, a freeze for the whole initial scan. A
server switch destroyed the old controller on the UI thread and joined
its refresh worker, which can sit for minutes in a read against a dead
server (each balance, notes or list read retries for ~62 s).

- The four ops run one at a time on a background lane and their results
  are applied on the main thread; Settings shows "Working…" meanwhile.
  Key and passphrase copies are wiped after the call, or when a queued
  op is dropped.
- A create or restore encrypts and saves (an open unlocks) before the
  first scan starts, so the save takes milliseconds and the scan runs on
  the encrypted file.
- Import, Encrypt, Remove encryption and Receive's New address are
  disabled while a scan runs ("Wait for blockchain to sync"); Redownload
  blocks is disabled while an op is queued.
- A switch retires the old controller without blocking (queued ops
  dropped, pending ones reported failed, its client shut down) and
  destroys it on a background thread. It no longer saves on the way out:
  the new client re-scans the blocks since the last save, as after a
  restart.
- A switch waits for an op in flight, at most 15 s, so the op can't
  finish and save on the old client while the new one owns the wallet
  file. The Network tab already shows the new selection meanwhile.
- The refresh worker stops between backend commands, skips the remaining
  server reads once one fails, and then retries after 4, 8, then 15 s
  instead of every 2 s.
- Quit waits up to 2.5 s for an op in flight, e.g. Encrypt's save.

Encrypt and Remove encryption now report a failed save: the backend
returns it as a JSON error that the old check never saw, so the failure
was reported as success. The trimmed copy of a key typed into Import is
now wiped too, and stopping the refresh worker can no longer miss its
wakeup.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-09 21:09:48 -05:00
co-authored by Claude Opus 5.5
parent b2a57a61a0
commit 55975901a2
11 changed files with 1023 additions and 147 deletions
+22 -14
View File
@@ -3688,22 +3688,30 @@ void App::createNewTAddress(std::function<void(const std::string&)> callback)
void App::createNewAddress(bool shielded, std::function<void(const std::string&)> callback)
{
const char* typeStr = shielded ? "shielded" : "transparent";
auto& targetList = shielded ? state_.z_addresses : state_.t_addresses;
// Lite build: derive locally via the controller (fast, no network). The backend auto-saves
// new addresses; the next lite refresh lists it with a balance.
// Lite build: the key is derived locally, but `new` ends in the backend's do_save(), which waits on its sync_lock
// (held for a whole scan and by every refresh fetch). It runs on the controller's wallet-op lane and the result is
// applied here on the main thread. The callback fires exactly once: inline if no wallet is open, or with "" if the
// controller is retired first.
if (lite_wallet_) {
const auto result = lite_wallet_->newAddress(shielded);
if (result.ok) {
AddressInfo info;
info.address = result.address;
info.type = typeStr;
info.balance = 0.0;
targetList.push_back(info);
state_.addresses.push_back(info);
address_list_dirty_ = true;
}
if (callback) callback(result.ok ? result.address : std::string());
lite_wallet_->newAddressAsync(shielded,
[this, shielded, typeStr, callback](const wallet::LiteNewAddressResult& result) {
if (result.ok) {
// A refresh applied between the backend call and this delivery may already list it.
const bool known = std::any_of(state_.addresses.begin(), state_.addresses.end(),
[&](const AddressInfo& a) { return a.address == result.address; });
if (!known) {
AddressInfo info;
info.address = result.address;
info.type = typeStr;
info.balance = 0.0;
(shielded ? state_.z_addresses : state_.t_addresses).push_back(info);
state_.addresses.push_back(info);
address_list_dirty_ = true;
}
}
if (callback) callback(result.ok ? result.address : std::string());
});
return;
}