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
+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);