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:
@@ -16,6 +16,7 @@
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "../util/serial_job_lane.h"
|
||||
#include "lite_client_bridge.h"
|
||||
#include "lite_connection_service.h"
|
||||
#include "lite_wallet_lifecycle_service.h"
|
||||
@@ -27,9 +28,11 @@
|
||||
#include "wallet_capabilities.h"
|
||||
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <condition_variable>
|
||||
#include <cstdint>
|
||||
#include <functional>
|
||||
#include <map>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <optional>
|
||||
@@ -133,6 +136,22 @@ struct LiteEncryptionResult {
|
||||
std::string error;
|
||||
};
|
||||
|
||||
// Main-thread completion callbacks of the *Async wallet ops. Each fires exactly once: inline when the op is rejected
|
||||
// up front, from pumpWalletOps() when the backend call finishes, or with ok=false from failPendingWalletOps()/retire().
|
||||
using LiteNewAddressCallback = std::function<void(const LiteNewAddressResult&)>;
|
||||
using LiteImportCallback = std::function<void(const LiteImportResult&)>;
|
||||
using LiteEncryptionCallback = std::function<void(const LiteEncryptionResult&)>;
|
||||
|
||||
// Server switch vs an in-flight wallet op. litelib_execute always runs on the GLOBAL client, so retiring a controller
|
||||
// mid-op would let the op finish (and save) on the old client while the new client owns the same wallet file. The
|
||||
// switch therefore waits for the op, but at most this long, so an op stuck behind a dead server can't pin the user to
|
||||
// that server. Pure; unit-tested.
|
||||
inline constexpr double kLiteServerSwitchMaxDeferSeconds = 15.0;
|
||||
inline bool liteServerSwitchMayProceed(bool walletOpInFlight, double deferredSeconds)
|
||||
{
|
||||
return !walletOpInFlight || deferredSeconds >= kLiteServerSwitchMaxDeferSeconds;
|
||||
}
|
||||
|
||||
class LiteWalletController {
|
||||
public:
|
||||
LiteWalletController(WalletCapabilities capabilities,
|
||||
@@ -229,10 +248,39 @@ public:
|
||||
// True while the initial sync OR a rescan is actively scanning (not yet complete).
|
||||
bool scanInProgress() const { return syncStarted_ && !(syncDone_ && syncDone_->load()); }
|
||||
|
||||
// Generate a new address (shielded if true, else transparent) via the backend. Fast (local
|
||||
// key derivation), safe to call on the UI thread; the next refresh lists the new address.
|
||||
// Generate a new address (shielded if true, else transparent) via the backend. BLOCKING: the backend's
|
||||
// do_new_address ends in do_save, which waits on the sync_lock a scan holds for its whole duration and every
|
||||
// refresh fetch holds briefly. Tests/tools only; the UI uses newAddressAsync().
|
||||
LiteNewAddressResult newAddress(bool shielded);
|
||||
|
||||
// --- Non-blocking wallet ops for the UI thread (wallet-op lane) ---------------------------------------------------
|
||||
// new address / import / encrypt+save / decrypt+save run FIFO on one abandonable background lane; the callback is
|
||||
// delivered on the main thread by pumpWalletOps() (App::update() calls it every tick). Return true if queued; false
|
||||
// if rejected (callback already invoked inline with ok=false). Secrets are copied into a holder that is wiped after
|
||||
// the backend call (or when a queued job is dropped) and the by-value parameter is wiped in place.
|
||||
bool newAddressAsync(bool shielded, LiteNewAddressCallback onDone);
|
||||
bool importKeyAsync(std::string spendingOrViewingKey, LiteImportCallback onDone);
|
||||
bool encryptWalletAsync(std::string passphrase, LiteEncryptionCallback onDone);
|
||||
bool decryptWalletAsync(std::string passphrase, LiteEncryptionCallback onDone);
|
||||
int pumpWalletOps() { return opLane_.pump(); } // main thread
|
||||
bool walletOpInProgress() const { return !pendingOps_.empty(); } // main thread (gating/animation)
|
||||
bool hasWalletOpCompletions() const { return opLane_.hasCompletions(); } // any thread (idle-loop wake)
|
||||
// Deliver ok=false(reason) to every undelivered op callback now; their late backend results are dropped.
|
||||
void failPendingWalletOps(const std::string& reason);
|
||||
// Bounded wait for the lane to drain (App::shutdown). True if idle.
|
||||
bool waitWalletOpsIdle(std::chrono::milliseconds timeout) { return opLane_.waitIdle(timeout); }
|
||||
|
||||
// Server switch (UI thread, non-blocking): deliver ops that already finished, stop a running op before its next
|
||||
// backend command, abandon queued ops, fail pending callbacks (reason), stop the refresh
|
||||
// worker's NEXT cycle, mark the controller retired (its destructor then skips the final save) and shut this backend
|
||||
// client down while it is still the global one. The destructor still joins the worker, which may sit inside one
|
||||
// backend call for minutes against a dead server, so App destroys a retired controller on a background thread.
|
||||
void retire(const std::string& reason);
|
||||
|
||||
// URL of the server the open wallet is actually connected to (failover may differ from the pinned one).
|
||||
// Main thread; empty until a wallet opens.
|
||||
const std::string& connectedServerUrl() const { return connectedServerUrl_; }
|
||||
|
||||
// --- M4: spend & backup ---------------------------------------------------------------
|
||||
// Build & broadcast a transaction (send) or shield all transparent funds. Proving can take
|
||||
// seconds, so the public entry points are ASYNCHRONOUS: they run the blocking core on a
|
||||
@@ -303,10 +351,16 @@ public:
|
||||
bool takeRefreshedModel(LiteWalletAppRefreshModel& out);
|
||||
|
||||
private:
|
||||
void onLifecycleResult(const LiteWalletLifecycleResult& result);
|
||||
// deferBackground=true marks the wallet open (status, walletOpen_, persist) but leaves startSync()/startWorker()
|
||||
// to the caller, so pumpLifecycleResult() can apply the passphrase before any scan holds the backend sync_lock.
|
||||
void onLifecycleResult(const LiteWalletLifecycleResult& result, bool deferBackground = false);
|
||||
void startWorker();
|
||||
void stopWorker();
|
||||
void requestStop(); // non-blocking half of stopWorker(): flips running_ under wakeMutex_ + notify
|
||||
void workerLoop();
|
||||
// honorStop=true (worker thread only): stop between backend commands once running_ is false, and skip the
|
||||
// remaining syncing reads after the first backend error (reported through syncReadFailed, if given).
|
||||
std::optional<LiteWalletAppRefreshModel> refreshModelImpl(bool honorStop, bool* syncReadFailed);
|
||||
// Launch `op` (a self-contained broadcast that must NOT capture `this`) on the detached
|
||||
// broadcast thread. Returns false if a broadcast is already running.
|
||||
bool startBroadcast(std::function<LiteBroadcastResult()> op);
|
||||
@@ -317,7 +371,7 @@ private:
|
||||
//
|
||||
// Concurrent execute() is intentionally NOT guarded by a C++ mutex here. The design relies
|
||||
// on overlapping calls (the detached sync thread runs the long `sync` while the refresh
|
||||
// worker polls `syncstatus`, and the UI thread may call `new`). litelib serializes wallet
|
||||
// worker polls `syncstatus`, and the wallet-op lane may call `new`). litelib serializes wallet
|
||||
// access internally: LightClient.wallet is an Arc<RwLock<LightWallet>> (do_new_address takes
|
||||
// write(), do_balance takes read(), do_sync_internal takes read()/write() and writes
|
||||
// sync_status separately), so concurrent execute() calls cannot corrupt state. A coarse
|
||||
@@ -402,6 +456,22 @@ private:
|
||||
void applyPendingLifecycleSecurity(); // encrypt (create/restore) or unlock (open), then wipe
|
||||
void clearPendingLifecycleSecurity(); // wipe + reset (rejected request / failed open)
|
||||
|
||||
// Wallet-op lane (see newAddressAsync). Jobs capture the shared bridge + value copies, never `this`; completions
|
||||
// capture `this` but only pumpWalletOps() runs them. Abandoned by retire() and by destruction (never joined).
|
||||
util::SerialJobLane opLane_;
|
||||
std::map<std::uint64_t, std::function<void(const std::string&)>> pendingOps_; // main thread; id -> deliver failure
|
||||
std::uint64_t nextOpId_ = 1; // main thread
|
||||
template <class R>
|
||||
bool submitWalletOp(std::function<R()> core,
|
||||
std::function<void(const R&)> onDone,
|
||||
std::function<R(const std::string&)> makeFailure);
|
||||
bool submitEncryptionChange(std::string& passphrase, LiteEncryptionCallback onDone, bool encrypt);
|
||||
std::atomic<bool> retired_{false};
|
||||
// Set by retire(). Every wallet-op job holds a copy: once set, an op runs no further backend command
|
||||
// (litelib_execute always runs on the GLOBAL client, which after a server switch is the NEW one) and fails instead.
|
||||
std::shared_ptr<std::atomic<bool>> opsRetired_ = std::make_shared<std::atomic<bool>>(false);
|
||||
std::string connectedServerUrl_; // main thread
|
||||
|
||||
// Joinable background refresh worker (fast iterations: syncstatus, plus data once synced).
|
||||
std::thread worker_;
|
||||
std::atomic<bool> running_{false};
|
||||
|
||||
Reference in New Issue
Block a user