fix(lite): refuse backend calls after a retired bridge is shut down; make the shutdown flag atomic

Concurrency audit (lite): LiteClientBridge::shutdownCalled_ was a plain bool, set by retire()/destruction
on one thread and read nowhere by the detached sync/console/broadcast threads. A retired controller's
in-flight execute() (send/save/sync) therefore still ran litelib_* against the PROCESS-GLOBAL client,
which may already belong to a NEW controller after a server switch.

- shutdownCalled_ is now std::atomic<bool>; shutdown() uses exchange() so exactly one caller tears the
  client down (idempotent) and the flag is published for the detached threads.
- execute() and initializeNew/NewFromPhrase/Existing refuse once the bridge is shut down (shutDownResult),
  so a stale command can't reach the global client.

Gating only — doSend/doShield, the JSON send payload, amounts/memo/recipients, persistAfterBroadcast,
encrypt/unlock/export and api_.execute argument passing are byte-identical when the bridge is live.
Test: testLiteClientBridgeRefusesAfterShutdown.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-10-19 15:07:18 -05:00
co-authored by Claude Opus 4.8
parent df98f98669
commit 099d5df3e1
3 changed files with 43 additions and 7 deletions
+21
View File
@@ -12155,6 +12155,26 @@ void testLiteClientBridgeMutationEpoch()
}
}
// A retired bridge is shut down; every later backend call must be refused (litelib_* would otherwise run
// on the process-global client, which may belong to a NEW controller by then). shutdown() is idempotent.
void testLiteClientBridgeRefusesAfterShutdown()
{
using namespace dragonx::wallet;
auto bridge = LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi());
EXPECT_FALSE(bridge.isShutdown());
EXPECT_TRUE(bridge.execute("balance", "").ok);
bridge.shutdown();
bridge.shutdown(); // idempotent (exchange): second call is a no-op
EXPECT_TRUE(bridge.isShutdown());
const auto before = bridge.mutationEpoch();
const auto s = bridge.execute("send", "[]");
EXPECT_FALSE(s.ok);
EXPECT_FALSE(bridge.execute("save", "").ok);
EXPECT_FALSE(bridge.initializeExisting(false, "https://x").ok);
EXPECT_FALSE(bridge.initializeNew(false, "https://x").ok);
EXPECT_TRUE(bridge.mutationEpoch() == before); // a refused send never reached the backend or bumped the epoch
}
// 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()
@@ -19514,6 +19534,7 @@ int main()
testLiteWalletControllerWorkerProducesModel();
testLiteWalletControllerWorkerChangeGate();
testLiteClientBridgeMutationEpoch();
testLiteClientBridgeRefusesAfterShutdown();
testLiteWalletControllerShutdownDoesNotHangDuringSync();
testSerialJobLane();
testLiteGatewayRefreshPolicyHelpers();