perf(litebe): change-gate the idle lite refresh worker

The background 2 s refresh worker re-queried and re-copied the whole
wallet every cycle (a fresh TLS refresh plus a multi-megabyte JSON
build, copies and DOM parse), even when nothing had changed, so CPU and
the network never went idle.

The worker now skips the full refresh and the re-publish on a cycle that
is provably unchanged, while never letting the UI show stale confirmed
balance or transactions:

- The lite bridge keeps a monotonic mutation epoch, bumped after any
  command not on a read-only allow-list, so a local change made on any
  thread (send, shield, import, new address, encrypt/unlock/lock,
  console) is detected at one choke point.
- Each cycle the worker runs one cheap sync probe (one connection, the
  mandatory block ingest) and a local height read, and refreshes only
  when the scanned height advanced, the mutation epoch moved, the
  encryption state changed, a sync is in progress, or a ~10 s heartbeat
  elapsed; otherwise it skips. Direct/synchronous callers are not gated.
- A failed probe backs off instead of running a second sync, so a dead
  server is not retried back-to-back.

Confirmed funds can never be stale beyond one cycle (every confirmed
change advances the height or bumps the epoch); only unconfirmed mempool
rows can lag up to the heartbeat, and the spend path re-queries the
backend directly rather than reading the gated model. Average idle cost
drops from three data connections per cycle to about one.

Tests: a bridge mutation-epoch test and a worker change-gate test
(skip-when-unchanged; refresh on height advance, local mutation and
encryption change) against the fake backend.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-10-14 12:00:26 -05:00
co-authored by Claude Opus 4.8
parent 39292b1b1f
commit ef14518089
6 changed files with 241 additions and 14 deletions
+23 -2
View File
@@ -94,7 +94,8 @@ LiteClientBridge LiteClientBridge::linkedSdxl()
LiteClientBridge::LiteClientBridge(LiteClientBridge&& other) noexcept
: api_(other.api_),
unavailableReason_(std::move(other.unavailableReason_)),
shutdownCalled_(other.shutdownCalled_)
shutdownCalled_(other.shutdownCalled_),
mutationEpoch_(other.mutationEpoch_.load(std::memory_order_acquire))
{
other.api_ = {};
other.shutdownCalled_ = true;
@@ -107,6 +108,8 @@ LiteClientBridge& LiteClientBridge::operator=(LiteClientBridge&& other) noexcept
api_ = other.api_;
unavailableReason_ = std::move(other.unavailableReason_);
shutdownCalled_ = other.shutdownCalled_;
mutationEpoch_.store(other.mutationEpoch_.load(std::memory_order_acquire),
std::memory_order_release);
other.api_ = {};
other.shutdownCalled_ = true;
return *this;
@@ -158,11 +161,29 @@ LiteBridgeStringResult LiteClientBridge::initializeExisting(bool dangerous, cons
return takeOwnedString(api_.initializeExisting(dangerous, server.c_str()));
}
bool LiteClientBridge::commandIsReadOnly(const std::string& c)
{
// Allow-list of commands that never alter wallet-visible data. sync/rescan are included: their
// effect (new blocks) is observed through the worker's height signal, and the worker's own sync
// probe must not self-dirty. UNKNOWN or omitted commands default to "mutating" (bump), so a
// missing entry only ever costs one extra refresh -- never staleness.
static const char* const kReadOnly[] = {
"balance", "list", "notes", "addresses", "info", "height", "syncstatus",
"encryptionstatus", "seed", "export", "save", "sync", "rescan", "help",
"coinsupply", "saplingtree"};
for (const char* r : kReadOnly)
if (c == r) return true;
return false;
}
LiteBridgeStringResult LiteClientBridge::execute(const std::string& command, const std::string& args)
{
if (!available()) return unavailableResult();
if (command.empty()) return {false, {}, "lite command is empty"};
return takeOwnedString(api_.execute(command.c_str(), args.c_str()));
auto result = takeOwnedString(api_.execute(command.c_str(), args.c_str()));
if (!commandIsReadOnly(command)) // bump AFTER the command returns, even on failure (be conservative)
mutationEpoch_.fetch_add(1, std::memory_order_acq_rel);
return result;
}
void LiteClientBridge::shutdown()
+11
View File
@@ -5,6 +5,8 @@
#pragma once
#include <string>
#include <atomic>
#include <cstdint>
namespace dragonx {
namespace wallet {
@@ -69,15 +71,24 @@ public:
LiteBridgeStringResult execute(const std::string& command, const std::string& args);
void shutdown();
// Monotonic counter bumped AFTER every execute() of a command that could change wallet-visible
// data (anything not on the read-only allow-list). The refresh worker samples this to detect
// local mutations made on ANY thread without hooking each controller method. Compare-only (never
// cleared): the worker stores a baseline and refreshes whenever it differs, so a mutation can
// never be "consumed"/lost in a race.
std::uint64_t mutationEpoch() const { return mutationEpoch_.load(std::memory_order_acquire); }
private:
LiteClientBridge(LiteClientBridgeApi api, std::string unavailableReason);
LiteBridgeStringResult unavailableResult() const;
LiteBridgeStringResult takeOwnedString(char* rawValue) const;
static bool commandIsReadOnly(const std::string& command);
LiteClientBridgeApi api_;
std::string unavailableReason_;
bool shutdownCalled_ = false;
std::atomic<std::uint64_t> mutationEpoch_{0};
};
} // namespace wallet
+90 -10
View File
@@ -1587,25 +1587,105 @@ void LiteWalletController::stopWorker()
void LiteWalletController::workerLoop()
{
int failStreak = 0; // consecutive cycles whose syncing reads hit a backend error
int failStreak = 0; // consecutive cycles whose syncing reads hit a backend error
RefreshGate gate; // worker-local; haveFull=false => the FIRST cycle is always a full refresh
while (running_.load()) {
if (walletOpen_.load()) {
bool syncReadFailed = false;
auto model = refreshModelImpl(/*honorStop=*/true, &syncReadFailed);
if (model && running_.load()) {
std::lock_guard<std::mutex> lock(modelMutex_);
pendingModel_ = std::move(model);
const GateDecision decision = evaluateRefreshGate(gate, failStreak);
if (decision == GateDecision::Skip) {
// Nothing changed since the last full model: no gateway refresh, no re-publish.
// pendingModel_ is left untouched (the UI keeps the last applied model).
} else if (decision == GateDecision::ProbeFailed) {
// The cheap `sync` probe failed (server unreachable / behind / terminal reorg). Do NOT
// run a second do_sync this cycle; treat it as a failed cycle so the existing back-off
// applies and we don't double the dead-server stall. Next cycle takes the full path.
failStreak += 1;
} else { // DoFull
// Sample the change baselines BEFORE the data commands (race rule).
const std::uint64_t epoch0 = bridge_ ? bridge_->mutationEpoch() : 0;
const auto height0 = readScannedHeight();
bool syncReadFailed = false;
auto model = refreshModelImpl(/*honorStop=*/true, &syncReadFailed);
if (model && running_.load()) {
const auto& m = *model;
const bool full = m.hasBalance && m.hasSyncStatus && m.sync.complete
&& m.syncError.empty() && !syncReadFailed && height0.has_value();
gate.haveFull = full;
gate.height = height0.value_or(0);
gate.epoch = epoch0;
gate.encOk = m.hasEncryptionStatus;
gate.encrypted = m.encrypted;
gate.locked = m.locked;
gate.fullAt = std::chrono::steady_clock::now();
std::lock_guard<std::mutex> lock(modelMutex_);
pendingModel_ = std::move(model);
} else {
gate.haveFull = false; // syncing / failed / cancelled: next cycle is a full path
}
failStreak = syncReadFailed ? failStreak + 1 : 0;
}
failStreak = syncReadFailed ? failStreak + 1 : 0;
}
// Healthy: every 2 s. After a failed syncing read: 4 s, 8 s, then 15 s, so a dead server isn't retried
// back-to-back (each attempt is one do_sync with up to 62 s of backend retry back-off).
// Healthy: every 2 s. After a failed read (or a failed probe): 4 s, 8 s, then 15 s, so a dead
// server isn't retried back-to-back (each attempt is one do_sync with up to 62 s of back-off).
std::unique_lock<std::mutex> lock(wakeMutex_);
wakeCv_.wait_for(lock, std::chrono::milliseconds(liteRefreshDelayMs(failStreak, kRefreshIntervalMs)),
wakeCv_.wait_for(lock,
std::chrono::milliseconds(liteRefreshDelayMs(failStreak, refreshIntervalMs_.load())),
[this] { return !running_.load(); });
}
}
// Worker-thread only. Decide whether this cycle can skip the full refresh. A cycle is a Skip ONLY when
// the wallet is synced, healthy, and every cheap signal proves nothing changed since the last published
// full model. ANY failed/unparsable read, or any doubt, returns DoFull (fail toward freshness). Runs
// exactly one server-bound `sync` probe (the mandatory confirmed-block ingest); a probe failure returns
// ProbeFailed so the caller backs off without a second sync.
LiteWalletController::GateDecision
LiteWalletController::evaluateRefreshGate(RefreshGate& g, int failStreak)
{
if (!g.haveFull || failStreak != 0 || !bridge_ || !running_.load())
return GateDecision::DoFull;
if (!syncDone_->load() || scan_->failed.load()) {
g.haveFull = false;
return GateDecision::DoFull;
}
if (std::chrono::steady_clock::now() - g.fullAt
>= std::chrono::milliseconds(gateHeartbeatMs_.load()))
return GateDecision::DoFull; // heartbeat: bounded-staleness backstop
if (bridge_->mutationEpoch() != g.epoch) // local mutation since the baseline
return GateDecision::DoFull;
// The mandatory one-connection confirmed-block ingest. Without it the wallet never advances.
const auto probe = bridge_->execute("sync", "");
if (!running_.load()) return GateDecision::DoFull;
if (!probe.ok || !parseLiteSyncOpResponse(probe.value).ok) {
g.haveFull = false;
return GateDecision::ProbeFailed;
}
const auto h = readScannedHeight();
if (!h.has_value() || *h == 0 || *h != g.height)
return GateDecision::DoFull;
const auto enc = encryptionStatus();
if (!enc.ok || !g.encOk || enc.encrypted != g.encrypted || enc.locked != g.locked)
return GateDecision::DoFull;
if (bridge_->mutationEpoch() != g.epoch) return GateDecision::DoFull;
if (!syncDone_->load() || !running_.load()) { g.haveFull = false; return GateDecision::DoFull; }
return GateDecision::Skip; // provably unchanged
}
std::optional<std::uint64_t> LiteWalletController::readScannedHeight()
{
if (!bridge_) return std::nullopt;
const auto h = bridge_->execute("height", ""); // local read in the backend (no TLS connection)
if (!h.ok) return std::nullopt;
const auto parsed = parseLiteHeightResponse(h.value);
if (!parsed.ok || parsed.height.height == 0) return std::nullopt;
return static_cast<std::uint64_t>(parsed.height.height);
}
LiteWalletLifecycleResult LiteWalletController::createWallet(LiteWalletCreateRequest request)
{
auto result = lifecycle_.createWallet(request);
+19 -1
View File
@@ -259,6 +259,8 @@ public:
std::string scanError() const { return scan_->errorCopy(); }
// Tests only: first sync-retry delay in ms (default 5000); call before createWallet()/startSync().
void setSyncRetryBaseMsForTest(int ms) { scan_->retryBaseMs.store(ms); }
void setRefreshIntervalMsForTest(int ms) { refreshIntervalMs_.store(ms); }
void setGateHeartbeatMsForTest(int ms) { gateHeartbeatMs_.store(ms); }
// Launch the backend sync on a detached background thread (NON-blocking; the backend's
// `sync` command runs a full, uninterruptible chain scan). Auto-invoked when a lifecycle
@@ -513,7 +515,23 @@ private:
std::condition_variable wakeCv_;
std::mutex modelMutex_;
std::optional<LiteWalletAppRefreshModel> pendingModel_; // guarded by modelMutex_
static constexpr int kRefreshIntervalMs = 2000;
std::atomic<int> refreshIntervalMs_{2000};
// C23: max time an UNCHANGED synced wallet may go without a full refresh+publish. Bounds the
// staleness of same-height changes the cheap signals can't see (incoming mempool txs; pending
// t-address scans) and backstops any external state reset. Atomic for test override.
std::atomic<int> gateHeartbeatMs_{10000};
// Worker-thread-only snapshot of the last published FULL synced model (only workerLoop() touches it).
struct RefreshGate {
bool haveFull = false;
std::uint64_t height = 0;
std::uint64_t epoch = 0;
bool encOk = false, encrypted = false, locked = false;
std::chrono::steady_clock::time_point fullAt{};
};
enum class GateDecision { Skip, DoFull, ProbeFailed };
GateDecision evaluateRefreshGate(RefreshGate& gate, int failStreak);
std::optional<std::uint64_t> readScannedHeight();
};
} // namespace wallet
+4 -1
View File
@@ -43,6 +43,7 @@ inline std::atomic<long> g_liteFakeSyncCalls{0};
inline std::atomic<bool> g_liteFakeBadBalance{false}; // when true, "balance" returns invalid JSON
inline std::atomic<bool> g_liteFakeSendFails{false}; // when true, "send"/"shield" return {"error":..}
inline std::atomic<long> g_liteFakeSaveCount{0}; // counts "save" commands (persistence checks)
inline std::atomic<unsigned long long> g_liteFakeHeight{1000}; // settable last-scanned height (C23 gate test)
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)
@@ -87,6 +88,7 @@ inline void resetLiteFakeCounters()
{
g_liteFakeAlloc = 0;
g_liteFakeFreed = 0;
g_liteFakeHeight = 1000;
g_liteFakeShutdownCalled = false;
g_liteFakeDeadServerSubstr.clear();
g_liteFakeWarmupServerSubstr.clear();
@@ -215,7 +217,8 @@ inline char* liteFakeExecute(const char* command, const char* args)
if (std::strcmp(c, "list") == 0)
return liteFakeDup("[{\"txid\":\"faketx\",\"datetime\":1700000000,\"block_height\":990,"
"\"unconfirmed\":false,\"address\":\"zs1fakeaddr\",\"amount\":150000000,\"memo\":\"\"}]");
if (std::strcmp(c, "height") == 0) return liteFakeDup("{\"height\":1000}");
if (std::strcmp(c, "height") == 0)
return liteFakeDup(("{\"height\":" + std::to_string(g_liteFakeHeight.load()) + "}").c_str());
if (std::strcmp(c, "info") == 0)
return liteFakeDup("{\"chain_name\":\"main\",\"version\":\"sdxl-fake\",\"latest_block_height\":1000}");
// M4 spend/backup commands. send/shield report failure via {"error":..} (NOT an
+94
View File
@@ -11762,6 +11762,98 @@ void testLiteWalletControllerWorkerProducesModel()
EXPECT_FALSE(idle.takeRefreshedModel(none));
}
// C23: the background worker change-gates the refresh -- an unchanged, synced cycle skips the full
// gateway refresh + re-publish, but any real change (height advance, local mutation, encryption flip)
// forces a full refresh within a cycle. Confirmed-funds freshness is the overriding rule.
void testLiteWalletControllerWorkerChangeGate()
{
using namespace dragonx::wallet;
namespace t = dragonx::test;
const auto caps = makeWalletCapabilities(WalletBuildKind::Lite, false, true);
const auto conn = defaultLiteConnectionSettings();
t::resetLiteFakeCounters();
t::g_liteFakeLogEnabled = true;
LiteWalletController controller(caps, conn, LiteClientBridge::fromApi(t::makeFakeLiteApi()));
controller.setGateHeartbeatMsForTest(600000); // disable the heartbeat so only real signals refresh
controller.setRefreshIntervalMsForTest(20); // fast cycles
EXPECT_TRUE(controller.createWallet(LiteWalletCreateRequest{}).walletReady);
// Arm: wait for the first full (balance-bearing) model.
auto waitFull = [&](int maxMs) {
LiteWalletAppRefreshModel m; bool got = false;
for (int i = 0; i < maxMs / 5 && !got; ++i) {
if (controller.takeRefreshedModel(m) && m.hasBalance) got = true;
else std::this_thread::sleep_for(std::chrono::milliseconds(5));
}
return got;
};
EXPECT_TRUE(waitFull(3000));
// (1) Skip when nothing changed: settle, then over a window no data commands run and nothing republishes.
std::this_thread::sleep_for(std::chrono::milliseconds(120)); // settle into the Skip steady state
(void)t::liteFakeTakeLog();
{ LiteWalletAppRefreshModel drain; while (controller.takeRefreshedModel(drain)) {} }
std::this_thread::sleep_for(std::chrono::milliseconds(200));
auto idleLog = t::liteFakeTakeLog();
EXPECT_EQ(t::liteFakeCount(idleLog, "balance"), 0L);
EXPECT_EQ(t::liteFakeCount(idleLog, "list"), 0L);
EXPECT_EQ(t::liteFakeCount(idleLog, "notes"), 0L);
EXPECT_TRUE(t::liteFakeCount(idleLog, "sync") >= 1L); // the one-connection probe still runs each idle cycle
LiteWalletAppRefreshModel noPub;
EXPECT_FALSE(controller.takeRefreshedModel(noPub)); // no re-publish on idle cycles
// (2) Height advance -> full refresh + publish.
(void)t::liteFakeTakeLog();
t::g_liteFakeHeight = 1001;
std::this_thread::sleep_for(std::chrono::milliseconds(200));
auto advLog = t::liteFakeTakeLog();
EXPECT_TRUE(t::liteFakeCount(advLog, "balance") >= 1L);
EXPECT_TRUE(waitFull(2000));
// (3) Local mutation (new address) bumps the bridge epoch -> full refresh, even at the same height.
std::this_thread::sleep_for(std::chrono::milliseconds(120));
(void)t::liteFakeTakeLog();
{ LiteWalletAppRefreshModel drain; while (controller.takeRefreshedModel(drain)) {} }
controller.newAddress(/*shielded*/ true);
std::this_thread::sleep_for(std::chrono::milliseconds(200));
EXPECT_TRUE(t::liteFakeCount(t::liteFakeTakeLog(), "balance") >= 1L);
// (4) Encryption state change with no bridge mutation (signal 7, epoch unchanged) -> full refresh.
// The fake reports `locked` only while `encrypted`, so flip `encrypted` directly (not via a bridge
// command, so the mutation epoch stays put and ONLY the enc-status compare can trigger the refresh).
std::this_thread::sleep_for(std::chrono::milliseconds(120));
(void)t::liteFakeTakeLog();
{ LiteWalletAppRefreshModel drain; while (controller.takeRefreshedModel(drain)) {} }
t::g_liteFakeEncrypted = true;
t::g_liteFakeLocked = true;
std::this_thread::sleep_for(std::chrono::milliseconds(200));
EXPECT_TRUE(t::liteFakeCount(t::liteFakeTakeLog(), "balance") >= 1L);
t::g_liteFakeEncrypted = false;
t::g_liteFakeLocked = false;
t::g_liteFakeLogEnabled = false;
}
// C23: the lite bridge bumps its mutation epoch for wallet-mutating commands and leaves it unchanged
// for read-only ones, so the refresh worker can detect local mutations from any thread.
void testLiteClientBridgeMutationEpoch()
{
using namespace dragonx::wallet;
auto bridge = LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi());
const auto e0 = bridge.mutationEpoch();
for (const char* ro : {"balance", "height", "sync", "syncstatus", "encryptionstatus",
"save", "addresses", "list", "notes"})
bridge.execute(ro, "");
EXPECT_TRUE(bridge.mutationEpoch() == e0); // no read-only command bumps
for (const char* mut : {"send", "shield", "new", "import", "timport",
"encrypt", "unlock", "lock", "decrypt", "frobnicate"}) {
const auto before = bridge.mutationEpoch();
bridge.execute(mut, "");
EXPECT_TRUE(bridge.mutationEpoch() == before + 1); // each mutating (or unknown) command bumps exactly once
}
}
// M2b-3 hardening: the backend `sync` is a blocking, uninterruptible full scan. Destroying the
// controller while a sync is in flight must NOT hang (the sync thread is detached, not joined).
void testLiteWalletControllerShutdownDoesNotHangDuringSync()
@@ -18944,6 +19036,8 @@ int main()
testLiteWalletControllerRefreshPopulatesState();
testLiteWalletGatewayRefreshSkipsFailedCommand();
testLiteWalletControllerWorkerProducesModel();
testLiteWalletControllerWorkerChangeGate();
testLiteClientBridgeMutationEpoch();
testLiteWalletControllerShutdownDoesNotHangDuringSync();
testSerialJobLane();
testLiteGatewayRefreshPolicyHelpers();