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
+60 -1
View File
@@ -21,7 +21,10 @@
#include <chrono>
#include <cstdlib>
#include <cstring>
#include <mutex>
#include <string>
#include <thread>
#include <vector>
namespace dragonx {
namespace test {
@@ -38,6 +41,34 @@ inline std::atomic<bool> g_liteFakeSendFails{false}; // when true, "send"/"shie
inline std::atomic<long> g_liteFakeSaveCount{0}; // counts "save" commands (persistence checks)
inline std::atomic<bool> g_liteFakeEncrypted{false}; // wallet-encryption state machine (encrypt/...)
inline std::atomic<bool> g_liteFakeLocked{false}; // spending-keys-locked state
inline std::atomic<bool> g_liteFakeBlockNew{false}; // "new" blocks (models do_save waiting on sync_lock)
inline std::atomic<bool> g_liteFakeBlockSave{false}; // "save" blocks
inline std::atomic<bool> g_liteFakeBlockBalance{false}; // "balance" blocks (a refresh stuck on a dead server)
inline std::atomic<bool> g_liteFakeBalanceError{false}; // "balance" returns the backend's plain-text sync error
inline std::atomic<long> g_liteFakeBalanceEntered{0}; // calls that reached "balance"
inline std::atomic<bool> g_liteFakeBlockEncrypt{false}; // "encrypt" blocks (before changing state)
inline std::atomic<bool> g_liteFakeBlockImport{false}; // "import"/"timport" block
inline std::atomic<bool> g_liteFakeImportFails{false}; // "import"/"timport" reject the key (real do_import_key shape)
inline std::atomic<bool> g_liteFakeLogEnabled{false};
inline std::mutex g_liteFakeLogMutex;
inline std::vector<std::string> g_liteFakeCommandLog; // execute() command names, in order, while enabled
inline void liteFakeWaitWhile(const std::atomic<bool>& flag)
{
while (flag.load()) std::this_thread::sleep_for(std::chrono::milliseconds(5));
}
inline std::vector<std::string> liteFakeTakeLog()
{
std::lock_guard<std::mutex> lk(g_liteFakeLogMutex);
std::vector<std::string> out;
out.swap(g_liteFakeCommandLog);
return out;
}
inline long liteFakeCount(const std::vector<std::string>& log, const char* command)
{
long n = 0;
for (const auto& c : log) if (c == command) ++n;
return n;
}
// initialize_existing fails for any server URL containing this substring (open-failover tests).
inline std::string g_liteFakeDeadServerSubstr;
// initialize_existing returns a warming-up (-28) error for URLs containing this substring.
@@ -50,6 +81,15 @@ inline void resetLiteFakeCounters()
g_liteFakeShutdownCalled = false;
g_liteFakeDeadServerSubstr.clear();
g_liteFakeWarmupServerSubstr.clear();
g_liteFakeBlockNew = false;
g_liteFakeBlockSave = false;
g_liteFakeBlockBalance = false;
g_liteFakeBalanceError = false;
g_liteFakeBlockEncrypt = false;
g_liteFakeBlockImport = false;
g_liteFakeImportFails = false;
g_liteFakeLogEnabled = false;
(void)liteFakeTakeLog();
}
inline char* liteFakeDup(const char* s)
@@ -106,10 +146,22 @@ inline char* liteFakeInitExisting(bool, const char* server)
}
inline char* liteFakeExecute(const char* command, const char* args)
{
if (command && g_liteFakeLogEnabled.load()) {
std::lock_guard<std::mutex> lk(g_liteFakeLogMutex);
g_liteFakeCommandLog.emplace_back(command);
}
// new-address generation returns a JSON array with the new address (type from args: zs/R).
if (command && std::strcmp(command, "new") == 0) {
liteFakeWaitWhile(g_liteFakeBlockNew);
return liteFakeDup(args && std::strcmp(args, "R") == 0 ? "[\"R1fakenew\"]" : "[\"zs1fakenew\"]");
}
if (command && std::strcmp(command, "save") == 0) liteFakeWaitWhile(g_liteFakeBlockSave); // counted below
if (command && std::strcmp(command, "balance") == 0) {
++g_liteFakeBalanceEntered;
liteFakeWaitWhile(g_liteFakeBlockBalance);
// Real shape of a failed do_sync: bare text, NO "Error:" prefix -> bridge ok=true, non-JSON value.
if (g_liteFakeBalanceError.load()) return liteFakeDup("Error getting latest block: simulated connect failure");
}
// A command named "boom" yields an "Error:"-prefixed response (the bridge's
// looksLikeError contract maps that to ok=false).
if (command && std::strcmp(command, "boom") == 0) {
@@ -158,8 +210,14 @@ inline char* liteFakeExecute(const char* command, const char* args)
if (std::strcmp(c, "seed") == 0)
return liteFakeDup("{\"seed\":\"fake seed phrase words\",\"birthday\":0}");
// import (shielded key) / timport (transparent WIF): do_import_* pretty-print JSON.
if (std::strcmp(c, "import") == 0 || std::strcmp(c, "timport") == 0)
if (std::strcmp(c, "import") == 0 || std::strcmp(c, "timport") == 0) {
liteFakeWaitWhile(g_liteFakeBlockImport);
// The real do_import_key error REPEATS the key: "Error: '<key>' was not recognized as ...".
if (g_liteFakeImportFails.load())
return liteFakeDup((std::string("Error: '") + (args ? args : "") +
"' was not recognized as either a spending key or a viewing key").c_str());
return liteFakeDup("{\"result\":\"success\",\"address\":\"zs1imported\"}");
}
if (std::strcmp(c, "save") == 0) {
++g_liteFakeSaveCount;
return liteFakeDup("{\"result\":\"success\"}");
@@ -168,6 +226,7 @@ inline char* liteFakeExecute(const char* command, const char* args)
// which removes keys from memory right after encrypting — verified via lite_smoke --encrypt);
// unlock/lock toggle locked; decrypt clears it; encryptionstatus reports {encrypted,locked}.
if (std::strcmp(c, "encrypt") == 0) {
liteFakeWaitWhile(g_liteFakeBlockEncrypt);
g_liteFakeEncrypted = true; g_liteFakeLocked = true;
return liteFakeDup("{\"result\":\"success\"}");
}