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:
+51
-6
@@ -751,6 +751,22 @@ bool sendErrorNeedsRescan(const std::string& raw)
|
||||
}
|
||||
} // namespace
|
||||
|
||||
void App::retireLiteWallet()
|
||||
{
|
||||
if (!lite_wallet_) return;
|
||||
lite_wallet_->retire("lite wallet reconnecting"); // non-blocking (see LiteWalletController::retire)
|
||||
// Destroy off the UI thread: ~LiteWalletController joins the poll worker, which may be inside a backend call that
|
||||
// does not return for minutes (no connect/request deadline). Nobody waits for this thread; it owns the controller
|
||||
// outright and touches nothing else of App. On quit the process _Exit()s, so it is never joined.
|
||||
auto old = std::move(lite_wallet_);
|
||||
try {
|
||||
std::thread([c = std::move(old)]() mutable { c.reset(); }).detach();
|
||||
} catch (...) {
|
||||
// No thread: the controller was already destroyed inside the failed std::thread constructor, i.e. on this
|
||||
// thread — the pre-fix blocking behaviour, but no crash.
|
||||
}
|
||||
}
|
||||
|
||||
void App::rebuildLiteWallet(bool force)
|
||||
{
|
||||
if (!supportsLiteBackend() || !settings_) return;
|
||||
@@ -758,15 +774,33 @@ void App::rebuildLiteWallet(bool force)
|
||||
// mid-sync), the new server selection is already persisted to settings and will take effect
|
||||
// the next time the controller is built (next launch, or before another wallet is opened).
|
||||
// Rebuilding would discard the open wallet and its in-flight, uninterruptible sync — which is
|
||||
// exactly what the Network tab's apply-immediately server switch wants (force=true). Replacing
|
||||
// lite_wallet_ destroys the old controller; its destructor detaches the uninterruptible sync
|
||||
// thread (which keeps the shared bridge alive) and only joins the short poll worker, so this
|
||||
// does not block. The app's auto-open loop then reopens the wallet against the new server.
|
||||
// exactly what the Network tab's apply-immediately server switch wants (force=true). The old
|
||||
// controller is retired (retireLiteWallet) and destroyed on a background thread: its destructor
|
||||
// joins the poll worker, which can sit for minutes inside a backend call against a dead server,
|
||||
// so it must never run on the UI thread. The app's auto-open loop then reopens the wallet
|
||||
// against the new server.
|
||||
if (!force && lite_wallet_ && lite_wallet_->walletOpen()) return;
|
||||
|
||||
// Never retire a controller with a wallet op (new address / import / encrypt / decrypt) in flight: litelib_execute
|
||||
// always runs on the GLOBAL client, so the op would still finish — and save — on the old client while the new
|
||||
// client owns the same wallet file, after its UI callback was told it failed. Defer the switch (update() retries
|
||||
// every tick) until the op has been delivered, but at most kLiteServerSwitchMaxDeferSeconds so an op stuck behind
|
||||
// a dead server can't pin the user to it.
|
||||
const bool opInFlight = lite_wallet_ && lite_wallet_->walletOpInProgress();
|
||||
const auto nowTp = std::chrono::steady_clock::now();
|
||||
if (opInFlight && !lite_rebuild_pending_) lite_rebuild_deferred_at_ = nowTp;
|
||||
const double deferredFor = opInFlight
|
||||
? std::chrono::duration<double>(nowTp - lite_rebuild_deferred_at_).count() : 0.0;
|
||||
if (!wallet::liteServerSwitchMayProceed(opInFlight, deferredFor)) {
|
||||
lite_rebuild_pending_ = true;
|
||||
return;
|
||||
}
|
||||
lite_rebuild_pending_ = false;
|
||||
|
||||
const auto liteConn = wallet::liteConnectionSettingsFromAppSettings(*settings_);
|
||||
wallet::liteLog(std::string(force ? "Lite controller rebuilt" : "Lite controller built") +
|
||||
" (preferred server: " + liteConn.stickyServerUrl + ")");
|
||||
retireLiteWallet(); // no-op on the first build
|
||||
lite_wallet_ = wallet::LiteWalletController::createLinked(
|
||||
walletCapabilities(), liteConn, resolveLiteRolloutDecision(*settings_));
|
||||
lite_wallet_->setPersistCallback([this]() { settings_->save(); });
|
||||
@@ -865,6 +899,11 @@ void App::update()
|
||||
lite_wallet_->pumpAsyncOpen();
|
||||
// Likewise finalize any completed async lifecycle request (Settings-page create/open/restore).
|
||||
lite_wallet_->pumpLifecycleResult();
|
||||
// Deliver finished wallet ops (new address / import / encrypt / decrypt) to their UI callbacks.
|
||||
lite_wallet_->pumpWalletOps();
|
||||
// A server switch deferred behind an in-flight wallet op: retry it. This may replace lite_wallet_; the rest of
|
||||
// this block then runs on the new controller, exactly as on the tick after a Network-tab switch.
|
||||
if (lite_rebuild_pending_) rebuildLiteWallet(/*force=*/true);
|
||||
|
||||
// Auto-open an existing wallet asynchronously with server failover (initialize_existing
|
||||
// needs no passphrase — it just loads the file and contacts the server). Running it off
|
||||
@@ -886,7 +925,8 @@ void App::update()
|
||||
// shows progress for it: keep frames coming while any of it is in flight.
|
||||
if (lite_wallet_->openInProgress() || lite_wallet_->lifecycleRequestInProgress() ||
|
||||
lite_wallet_->scanInProgress() || lite_wallet_->broadcastInProgress() ||
|
||||
lite_wallet_->consoleCommandInProgress()) {
|
||||
lite_wallet_->consoleCommandInProgress() || lite_wallet_->walletOpInProgress() ||
|
||||
lite_rebuild_pending_) {
|
||||
ui::material::RequestProgressFrame();
|
||||
}
|
||||
|
||||
@@ -7357,6 +7397,10 @@ void App::renderLoadingOverlay(float contentH)
|
||||
|
||||
void App::shutdown()
|
||||
{
|
||||
// Lite: let an in-flight wallet op (e.g. Encrypt's save) finish. Bounded: the window is already hidden and
|
||||
// main.cpp has a 15 s watchdog. The backend writes tmp + rename, so being cut off never corrupts the wallet.
|
||||
if (lite_wallet_) lite_wallet_->waitWalletOpsIdle(std::chrono::milliseconds(2500));
|
||||
|
||||
// Full node: persist whatever a refresh changed since beginShutdown() (everything, on the direct-exit path),
|
||||
// then drain + join the history writer. Bounded like the settings flush: on a wedged disk the in-flight write
|
||||
// is left running (one autocommit INSERT in WAL mode: a cut-off write keeps the previous snapshot) and _Exit
|
||||
@@ -7503,7 +7547,8 @@ void App::verifyDebugCredential(const std::string& secret, std::function<void(bo
|
||||
bool App::hasPendingBackgroundResults() const {
|
||||
return (worker_ && worker_->hasPendingResults())
|
||||
|| (fast_worker_ && fast_worker_->hasPendingResults())
|
||||
|| (http_worker_ && http_worker_->hasPendingResults());
|
||||
|| (http_worker_ && http_worker_->hasPendingResults()) // g3: market-data GETs
|
||||
|| (lite_wallet_ && lite_wallet_->hasWalletOpCompletions()); // g1: lite wallet-op lane
|
||||
}
|
||||
|
||||
std::string App::transactionSendProgressText() const
|
||||
|
||||
Reference in New Issue
Block a user