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