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:
@@ -94,11 +94,11 @@ LiteClientBridge LiteClientBridge::linkedSdxl()
|
||||
LiteClientBridge::LiteClientBridge(LiteClientBridge&& other) noexcept
|
||||
: api_(other.api_),
|
||||
unavailableReason_(std::move(other.unavailableReason_)),
|
||||
shutdownCalled_(other.shutdownCalled_),
|
||||
shutdownCalled_(other.shutdownCalled_.load(std::memory_order_acquire)),
|
||||
mutationEpoch_(other.mutationEpoch_.load(std::memory_order_acquire))
|
||||
{
|
||||
other.api_ = {};
|
||||
other.shutdownCalled_ = true;
|
||||
other.shutdownCalled_.store(true, std::memory_order_release);
|
||||
}
|
||||
|
||||
LiteClientBridge& LiteClientBridge::operator=(LiteClientBridge&& other) noexcept
|
||||
@@ -107,11 +107,11 @@ LiteClientBridge& LiteClientBridge::operator=(LiteClientBridge&& other) noexcept
|
||||
shutdown();
|
||||
api_ = other.api_;
|
||||
unavailableReason_ = std::move(other.unavailableReason_);
|
||||
shutdownCalled_ = other.shutdownCalled_;
|
||||
shutdownCalled_.store(other.shutdownCalled_.load(std::memory_order_acquire), std::memory_order_release);
|
||||
mutationEpoch_.store(other.mutationEpoch_.load(std::memory_order_acquire),
|
||||
std::memory_order_release);
|
||||
other.api_ = {};
|
||||
other.shutdownCalled_ = true;
|
||||
other.shutdownCalled_.store(true, std::memory_order_release);
|
||||
return *this;
|
||||
}
|
||||
|
||||
@@ -140,6 +140,7 @@ bool LiteClientBridge::checkServerOnline(const std::string& server) const
|
||||
LiteBridgeStringResult LiteClientBridge::initializeNew(bool dangerous, const std::string& server)
|
||||
{
|
||||
if (!available()) return unavailableResult();
|
||||
if (isShutdown()) return shutDownResult();
|
||||
return takeOwnedString(api_.initializeNew(dangerous, server.c_str()));
|
||||
}
|
||||
|
||||
@@ -151,6 +152,7 @@ LiteBridgeStringResult LiteClientBridge::initializeNewFromPhrase(bool dangerous,
|
||||
bool overwrite)
|
||||
{
|
||||
if (!available()) return unavailableResult();
|
||||
if (isShutdown()) return shutDownResult();
|
||||
return takeOwnedString(api_.initializeNewFromPhrase(
|
||||
dangerous, server.c_str(), seed.c_str(), birthday, account, overwrite));
|
||||
}
|
||||
@@ -158,6 +160,7 @@ LiteBridgeStringResult LiteClientBridge::initializeNewFromPhrase(bool dangerous,
|
||||
LiteBridgeStringResult LiteClientBridge::initializeExisting(bool dangerous, const std::string& server)
|
||||
{
|
||||
if (!available()) return unavailableResult();
|
||||
if (isShutdown()) return shutDownResult();
|
||||
return takeOwnedString(api_.initializeExisting(dangerous, server.c_str()));
|
||||
}
|
||||
|
||||
@@ -180,6 +183,9 @@ LiteBridgeStringResult LiteClientBridge::execute(const std::string& command, con
|
||||
{
|
||||
if (!available()) return unavailableResult();
|
||||
if (command.empty()) return {false, {}, "lite command is empty"};
|
||||
// A retired controller's bridge is shut down; litelib_execute would run on the NEW global client
|
||||
// (a stale send/save/sync/console command). Refuse it here, once, for every detached thread.
|
||||
if (isShutdown()) return shutDownResult();
|
||||
auto result = takeOwnedString(api_.execute(command.c_str(), args.c_str()));
|
||||
if (!commandIsReadOnly(command)) // bump AFTER the command returns, even on failure (be conservative)
|
||||
mutationEpoch_.fetch_add(1, std::memory_order_acq_rel);
|
||||
@@ -188,11 +194,15 @@ LiteBridgeStringResult LiteClientBridge::execute(const std::string& command, con
|
||||
|
||||
void LiteClientBridge::shutdown()
|
||||
{
|
||||
if (shutdownCalled_) return;
|
||||
shutdownCalled_ = true;
|
||||
if (shutdownCalled_.exchange(true, std::memory_order_acq_rel)) return; // exactly one caller shuts the client down
|
||||
if (available()) api_.shutdown();
|
||||
}
|
||||
|
||||
LiteBridgeStringResult LiteClientBridge::shutDownResult()
|
||||
{
|
||||
return {false, {}, "lite client was shut down"};
|
||||
}
|
||||
|
||||
LiteBridgeStringResult LiteClientBridge::unavailableResult() const
|
||||
{
|
||||
return {false, {}, unavailableReason_.empty() ? "lite backend is unavailable" : unavailableReason_};
|
||||
|
||||
@@ -70,6 +70,10 @@ public:
|
||||
LiteBridgeStringResult initializeExisting(bool dangerous, const std::string& server);
|
||||
LiteBridgeStringResult execute(const std::string& command, const std::string& args);
|
||||
void shutdown();
|
||||
// True once shutdown() ran (retire() / destruction / move-from). After this every backend call
|
||||
// (execute / initialize*) is refused: litelib_* acts on the process-global client, which may by
|
||||
// then belong to a NEW controller.
|
||||
bool isShutdown() const { return shutdownCalled_.load(std::memory_order_acquire); }
|
||||
|
||||
// Monotonic counter bumped AFTER every execute() of a command that could change wallet-visible
|
||||
// data (anything not on the read-only allow-list). The refresh worker samples this to detect
|
||||
@@ -82,12 +86,13 @@ private:
|
||||
LiteClientBridge(LiteClientBridgeApi api, std::string unavailableReason);
|
||||
|
||||
LiteBridgeStringResult unavailableResult() const;
|
||||
static LiteBridgeStringResult shutDownResult();
|
||||
LiteBridgeStringResult takeOwnedString(char* rawValue) const;
|
||||
static bool commandIsReadOnly(const std::string& command);
|
||||
|
||||
LiteClientBridgeApi api_;
|
||||
std::string unavailableReason_;
|
||||
bool shutdownCalled_ = false;
|
||||
std::atomic<bool> shutdownCalled_{false}; // read by detached threads in execute() while retire() sets it
|
||||
std::atomic<std::uint64_t> mutationEpoch_{0};
|
||||
};
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user