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:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user