fix(lite): non-blocking, non-hanging sync (Finding B)

The backend `sync` command is a blocking, uninterruptible full chain scan (do_sync(true);
does not honor the shutdown flag), and balance/list block until synced. Previously
startSync() ran on the main thread (would freeze wallet creation) and the worker could
block, making the destructor join() hang at shutdown.

Redesign:
- bridge is now std::shared_ptr<LiteClientBridge>, shared with a detached sync thread so
  detaching is safe and litelib_shutdown isn't called while a running sync still holds the
  bridge; the controller's own ref prevents premature shutdown during normal operation.
- startSync() launches the blocking `sync` on a detached thread (non-blocking; never joined).
- refreshModel() gates on syncDone_: while syncing it publishes syncstatus progress only;
  once synced it does the full balance/addresses/list refresh (now fast).
- destructor joins only the fast poll worker and detaches the sync thread -> no hang.
- syncComplete() accessor added.

Tests (deterministic, via a blocking-sync fake; counters made atomic for the detached
thread): testLiteWalletControllerShutdownDoesNotHangDuringSync (destructor returns <1.5s
with sync blocked); refresh/worker tests wait for syncComplete()/a balance-bearing model.
Stable across repeated runs; lite+backend and full-node apps build clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-05 06:35:26 -05:00
parent 59c55e33f8
commit 3119440cd9
5 changed files with 112 additions and 30 deletions

View File

@@ -17,18 +17,22 @@
#include "wallet/lite_client_bridge.h"
#include <atomic>
#include <chrono>
#include <cstdlib>
#include <cstring>
#include <thread>
namespace dragonx {
namespace test {
// Owned-string accounting (C++17 inline vars: single definition across TUs).
inline long g_liteFakeAlloc = 0; // owned strings handed to the bridge
inline long g_liteFakeFreed = 0; // owned strings released via freeString
// Owned-string accounting (atomic: a detached sync thread may touch these concurrently).
inline std::atomic<long> g_liteFakeAlloc{0}; // owned strings handed to the bridge
inline std::atomic<long> g_liteFakeFreed{0}; // owned strings released via freeString
inline bool g_liteFakeWalletExists = true;
inline bool g_liteFakeServerOnline = true;
inline bool g_liteFakeShutdownCalled = false;
inline std::atomic<bool> g_liteFakeSyncBlock{false}; // when true, the "sync" command blocks
inline void resetLiteFakeCounters()
{
@@ -64,7 +68,12 @@ inline char* liteFakeExecute(const char* command, const char*)
// tests/fixtures/lite/result_parsers.json), so the gateway/sync refresh path parses.
if (command) {
const char* c = command;
if (std::strcmp(c, "sync") == 0) return liteFakeDup("{\"result\":\"success\"}");
if (std::strcmp(c, "sync") == 0) {
// Simulate the real backend's blocking full sync when requested, so tests can
// verify shutdown doesn't hang on an in-flight sync.
while (g_liteFakeSyncBlock.load()) std::this_thread::sleep_for(std::chrono::milliseconds(5));
return liteFakeDup("{\"result\":\"success\"}");
}
if (std::strcmp(c, "syncstatus") == 0) // real backend shape: "syncing" is a string
return liteFakeDup("{\"syncing\":\"true\",\"synced_blocks\":1000,\"total_blocks\":1000}");
if (std::strcmp(c, "balance") == 0)

View File

@@ -4634,6 +4634,12 @@ void testLiteWalletControllerRefreshPopulatesState()
EXPECT_TRUE(controller.walletOpen());
EXPECT_TRUE(controller.syncStarted()); // auto-started when the wallet became ready
// Sync runs on a detached thread; the full refresh (balance/addresses) only runs once it
// completes. Wait for it (instant with the fake) so the refresh is deterministic.
for (int i = 0; i < 500 && !controller.syncComplete(); ++i)
std::this_thread::sleep_for(std::chrono::milliseconds(5));
EXPECT_TRUE(controller.syncComplete());
dragonx::WalletState state;
EXPECT_TRUE(controller.refreshWalletState(state));
EXPECT_NEAR(state.privateBalance, 2.0, 1e-9);
@@ -4692,14 +4698,20 @@ void testLiteWalletControllerWorkerProducesModel()
LiteWalletController controller(caps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()));
EXPECT_TRUE(controller.createWallet(LiteWalletCreateRequest{}).walletReady); // auto-starts the worker
// The worker refreshes immediately on start; poll briefly (<=2s) for the produced model.
// The worker publishes progress-only models while syncing, then full models once synced.
// Poll until a full (balance-bearing) model arrives (sync is instant with the fake).
LiteWalletAppRefreshModel model;
bool got = false;
for (int i = 0; i < 200 && !got; ++i) {
got = controller.takeRefreshedModel(model);
if (!got) std::this_thread::sleep_for(std::chrono::milliseconds(10));
bool gotFull = false;
for (int i = 0; i < 500 && !gotFull; ++i) {
LiteWalletAppRefreshModel m;
if (controller.takeRefreshedModel(m) && m.hasBalance) {
model = m;
gotFull = true;
break;
}
std::this_thread::sleep_for(std::chrono::milliseconds(10));
}
EXPECT_TRUE(got);
EXPECT_TRUE(gotFull);
EXPECT_TRUE(model.hasBalance);
EXPECT_TRUE(model.hasAddresses);
@@ -4713,6 +4725,31 @@ void testLiteWalletControllerWorkerProducesModel()
EXPECT_FALSE(idle.takeRefreshedModel(none));
}
// 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()
{
using namespace dragonx::wallet;
const auto caps = makeWalletCapabilities(WalletBuildKind::Lite, /*embeddedDaemon*/ false, /*liteBackendLinked*/ true);
const auto conn = defaultLiteConnectionSettings();
dragonx::test::g_liteFakeSyncBlock.store(true); // make the backend "sync" block indefinitely
const auto start = std::chrono::steady_clock::now();
{
LiteWalletController controller(caps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()));
controller.createWallet(LiteWalletCreateRequest{}); // launches the (now-blocked) sync thread
EXPECT_TRUE(controller.syncStarted());
EXPECT_FALSE(controller.syncComplete());
// controller destructs here with the sync thread still blocked -> must return promptly.
}
const auto elapsedMs = std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now() - start).count();
EXPECT_TRUE(elapsedMs < 1500); // did not wait for the (blocked) sync to finish
dragonx::test::g_liteFakeSyncBlock.store(false); // release the detached sync thread
std::this_thread::sleep_for(std::chrono::milliseconds(50)); // let it unwind cleanly
}
} // namespace
int main()
@@ -4752,6 +4789,7 @@ int main()
testLiteSyncStatusParserRealShapes();
testLiteWalletControllerRefreshPopulatesState();
testLiteWalletControllerWorkerProducesModel();
testLiteWalletControllerShutdownDoesNotHangDuringSync();
testLiteBridgeRuntimeShutdownIsIdempotent();
testLiteBridgeRuntimeDestructorCallsShutdownOnce();
testLiteBridgeRuntimeShutdownWaitsForOwnedStringRelease();