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

@@ -95,12 +95,12 @@ LiteWalletController::LiteWalletController(WalletCapabilities capabilities,
LiteConnectionSettings connectionSettings,
LiteClientBridge bridge,
LiteWalletControllerOptions options)
: bridge_(std::move(bridge)),
lifecycle_(capabilities, connectionSettings, &bridge_,
: bridge_(std::make_shared<LiteClientBridge>(std::move(bridge))),
lifecycle_(capabilities, connectionSettings, bridge_.get(),
LiteWalletLifecycleOptions{options.allowBridgeCalls}),
gateway_(capabilities, connectionSettings, &bridge_,
gateway_(capabilities, connectionSettings, bridge_.get(),
LiteWalletGatewayOptions{options.allowBridgeCalls}),
sync_(capabilities, connectionSettings, &bridge_,
sync_(capabilities, connectionSettings, bridge_.get(),
LiteSyncServiceOptions{options.allowBridgeCalls})
{
status_ = lifecycle_.status();
@@ -108,7 +108,11 @@ LiteWalletController::LiteWalletController(WalletCapabilities capabilities,
LiteWalletController::~LiteWalletController()
{
stopWorker();
stopWorker(); // joins the fast poll worker (short iterations)
// The sync thread may be blocked in an uninterruptible full scan; detach it. It holds
// shared refs (bridge_ + syncDone_), so it stays safe and the bridge survives until it
// finishes — the process is exiting, so a late litelib_shutdown is harmless.
if (syncThread_.joinable()) syncThread_.detach();
}
std::unique_ptr<LiteWalletController> LiteWalletController::createLinked(
@@ -133,30 +137,51 @@ void LiteWalletController::onLifecycleResult(const LiteWalletLifecycleResult& re
}
}
LiteSyncStartResult LiteWalletController::startSync()
void LiteWalletController::startSync()
{
auto result = sync_.startSync(LiteSyncStartRequest{});
if (result.syncStarted) syncStarted_ = true;
return result;
if (syncLaunched_) return;
syncLaunched_ = true;
syncStarted_ = true;
// The backend `sync` command is a blocking, uninterruptible full chain scan, so run it on
// a detached thread. Capture shared refs (not the controller) so it is safe to outlive us.
auto bridge = bridge_;
auto done = syncDone_;
syncThread_ = std::thread([bridge, done] {
if (bridge) bridge->execute("sync", ""); // blocks until synced (or errors out)
done->store(true);
});
}
std::optional<LiteWalletAppRefreshModel> LiteWalletController::refreshModel()
{
if (!walletOpen_.load()) return std::nullopt;
// Poll sync status first so the refresh bundle (and the mapped sync model) carries it.
LiteWalletRefreshRequest request;
// syncstatus is fast (reads shared state the sync thread updates). Poll it every time.
const auto syncResult = sync_.pollSyncStatus(LiteSyncStatusRequest{});
if (!syncDone_->load()) {
// Sync still running: publish progress only. Data queries (balance/list) would block
// until the chain is synced, so don't issue them yet.
if (!syncResult.ok) return std::nullopt;
LiteWalletAppRefreshModel model;
model.hasSyncStatus = true;
model.sync.walletHeight = syncResult.syncStatus.syncedBlocks;
model.sync.chainHeight = syncResult.syncStatus.totalBlocks;
model.sync.progress = syncResult.syncStatus.progress;
model.sync.complete = syncResult.syncStatus.complete;
return model;
}
// Synced: full refresh (balance/addresses/transactions are fast now).
LiteWalletRefreshRequest request;
if (syncResult.ok) {
request.haveSyncStatus = true;
request.syncStatus = syncResult.syncStatus;
}
const auto refreshResult = gateway_.refresh(request);
if (refreshResult.bundle.successfulCommandCount == 0 && !request.haveSyncStatus) {
return std::nullopt;
}
const auto mapped = mapLiteWalletRefreshResult(refreshResult);
if (!mapped.ok) return std::nullopt;
return mapped.model;

View File

@@ -85,10 +85,12 @@ public:
LiteWalletLifecycleResult restoreWallet(LiteWalletRestoreRequest request);
bool syncStarted() const { return syncStarted_; }
bool syncComplete() const { return syncDone_ && syncDone_->load(); }
// Begin background sync on the backend (idempotent enough to call once a wallet is ready;
// also invoked automatically when a lifecycle op produces a ready wallet).
LiteSyncStartResult startSync();
// 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
// op produces a ready wallet; safe to call once.
void startSync();
// Poll sync status + fetch balance/addresses/transactions, and apply the result into the
// app's WalletState. Returns true if state was updated. Safe no-op when no wallet is open.
@@ -110,7 +112,10 @@ private:
void stopWorker();
void workerLoop();
LiteClientBridge bridge_; // the single owned bridge; services below borrow &bridge_
// The bridge is shared (not just owned) so the detached, uninterruptible sync thread can
// safely outlive the controller: it holds a ref, so the underlying bridge is destroyed
// (and litelib_shutdown called) only once BOTH the controller and a running sync release it.
std::shared_ptr<LiteClientBridge> bridge_;
LiteWalletLifecycleService lifecycle_;
LiteWalletGateway gateway_;
LiteSyncService sync_;
@@ -119,14 +124,19 @@ private:
std::atomic<bool> syncStarted_{false};
WalletBackendStatus status_; // written only on the main thread (lifecycle ops)
// Background refresh worker.
// Detached background sync (backend `sync` is a blocking, uninterruptible full scan).
std::thread syncThread_;
bool syncLaunched_ = false;
std::shared_ptr<std::atomic<bool>> syncDone_ = std::make_shared<std::atomic<bool>>(false);
// Joinable background refresh worker (fast iterations: syncstatus, plus data once synced).
std::thread worker_;
std::atomic<bool> running_{false};
std::mutex wakeMutex_;
std::condition_variable wakeCv_;
std::mutex modelMutex_;
std::optional<LiteWalletAppRefreshModel> pendingModel_; // guarded by modelMutex_
static constexpr int kRefreshIntervalMs = 4000;
static constexpr int kRefreshIntervalMs = 2000;
};
} // namespace wallet