fix(daemon): never restart, delete or swap under a node that hasn't exited
Every operation that stops the node (restart, rescan, repair, delete chain data, reinstall, bootstrap, wallet switch/adopt/restore/rebuild, wizard restart, encrypt, decrypt) now: - refuses up front while the node is rebuilding witnesses or has not answered for 30s, with a "try again when it's done" warning, and sends no stop; - goes ahead only once the node's PROCESS has exited, not its RPC port. A stop that doesn't finish within 120s cancels the operation (warning; a cancelled rescan/repair disarms its flags); nothing is started over the old node, chain data isn't deleted, wallet.dat isn't swapped. Specifics: - stopDaemonForWalletSwitch() no longer reports "stopped" when the app is quitting (adopt could swap wallet.dat under a live node). - Decrypt waited a fixed 3s after "stop" before renaming wallet.dat; it now waits for the process to exit, and scrubs the key export if not. - Bootstrap waits for the process before cleanChainData and stops the node through the stop routine instead of the shared RPC client. - StopScope::Connected lets switch/reinstall/decrypt stop an adopted or external node through the same never-kill routine. - A bounded stop that can't reach the node (a foreign node refusing our credentials) gives up after 5s instead of 120s. - macOS: isDaemonProcessRunning() checks processes (libproc), not the RPC port. - rpcBusyWhileAlive() also reads the busy gate: since the gate, the first timeout usually lands on another lane and the 30s clock never started (also weakened the quit prompt for a silent rebuild). - Notifications render during the first-run wizard. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+82
-8
@@ -465,10 +465,14 @@ public:
|
||||
std::vector<std::string> calls;
|
||||
int deletedItems = 7;
|
||||
bool startResult = true;
|
||||
std::string rpcStopContext;
|
||||
bool stopResult = true; // false: the node didn't exit
|
||||
std::string disconnectReason;
|
||||
|
||||
void stopDaemonWithPolicy() override { calls.push_back("stop"); }
|
||||
bool stopDaemonWithPolicy() override
|
||||
{
|
||||
calls.push_back("stop");
|
||||
return stopResult;
|
||||
}
|
||||
bool startDaemon() override
|
||||
{
|
||||
calls.push_back("start");
|
||||
@@ -480,10 +484,9 @@ public:
|
||||
return deletedItems;
|
||||
}
|
||||
void resetOutputOffset() override { calls.push_back("reset-output"); }
|
||||
void requestRpcStopAndDisconnect(const char* context, const char* reason) override
|
||||
void beginBackgroundStop(const char* reason) override
|
||||
{
|
||||
calls.push_back("rpc-stop-disconnect");
|
||||
rpcStopContext = context ? context : "";
|
||||
calls.push_back("background-stop");
|
||||
disconnectReason = reason ? reason : "";
|
||||
}
|
||||
};
|
||||
@@ -599,9 +602,10 @@ public:
|
||||
int sleptMs = 0;
|
||||
int stopCalls = 0;
|
||||
int startCalls = 0;
|
||||
bool stopResult = true;
|
||||
|
||||
bool isUsingEmbeddedDaemon() const override { return usingEmbedded; }
|
||||
void stopEmbeddedDaemon() override { ++stopCalls; }
|
||||
bool stopEmbeddedDaemon() override { ++stopCalls; return stopResult; }
|
||||
bool startEmbeddedDaemon() override { ++startCalls; return true; }
|
||||
bool cancelled() const override { return isCancelled; }
|
||||
bool shuttingDown() const override { return isShuttingDown; }
|
||||
@@ -2841,6 +2845,15 @@ void testWalletSecurityWorkflowExecutor()
|
||||
EXPECT_FALSE(cancelled.ok);
|
||||
EXPECT_TRUE(cancelled.error.empty());
|
||||
|
||||
MockWorkflowDaemon stuckDaemon; // S11: never start a second node over one that hasn't exited
|
||||
stuckDaemon.stopResult = false;
|
||||
rpc.probeCalls = 0;
|
||||
auto stuckRestart = Executor::restartDaemonAndWait(stuckDaemon, rpc, 0, 0, 5);
|
||||
EXPECT_FALSE(stuckRestart.ok);
|
||||
EXPECT_FALSE(stuckRestart.error.empty());
|
||||
EXPECT_EQ(stuckDaemon.startCalls, 0);
|
||||
EXPECT_EQ(rpc.probeCalls, 0);
|
||||
|
||||
MockWorkflowDaemon failedDaemon;
|
||||
rpc.probeCalls = 0;
|
||||
rpc.probeSuccessOnCall = 99;
|
||||
@@ -3868,6 +3881,13 @@ void testEvaluateStopWait()
|
||||
unowned.owned = false;
|
||||
s = sample(); s.delivered = true; s.idleSec = 6000; s.killOffered = true; s.choice = DC::StopUserChoice::Kill;
|
||||
EXPECT_TRUE(DC::evaluateStopWait(unowned, s) == D::Wait); // never kill what we didn't launch
|
||||
// S11: no way to reach the node (refused our credentials, no signal): a bounded stop gives up after
|
||||
// a short grace instead of its whole budget; the shutdown screen keeps waiting (the user decides).
|
||||
s = sample(); s.undeliverable = true; s.elapsedSec = DC::kUndeliverableGraceSec - 0.1;
|
||||
EXPECT_TRUE(DC::evaluateStopWait(background, s) == D::Wait);
|
||||
s.elapsedSec = DC::kUndeliverableGraceSec;
|
||||
EXPECT_TRUE(DC::evaluateStopWait(background, s) == D::GiveUp);
|
||||
EXPECT_TRUE(DC::evaluateStopWait(shutdown, s) == D::Wait);
|
||||
}
|
||||
|
||||
// A scripted node for executeDaemonStop, on a virtual clock (seconds) that sleeps and sends advance.
|
||||
@@ -3977,6 +3997,30 @@ void testDaemonStopExecution()
|
||||
EXPECT_TRUE(DC::executeDaemonStop(shutdownStopPolicy(false), rt, task) == O::Exited);
|
||||
EXPECT_TRUE(rt.calls.empty());
|
||||
}
|
||||
{ // S11: a wallet switch stopping a node we don't own that refuses our credentials (a foreign dragonxd):
|
||||
// one attempt, then give up after the short grace, not the 120s budget. Never kill it.
|
||||
MockStopRuntime rt; MockStopTask task(rt);
|
||||
rt.sends = {R::Auth};
|
||||
DC::StopPolicy p;
|
||||
p.owned = false;
|
||||
p.maxWaitSec = 120;
|
||||
p.abandonOnShutdown = true;
|
||||
EXPECT_TRUE(DC::executeDaemonStop(p, rt, task) == O::StillRunning);
|
||||
EXPECT_EQ(rt.count("stop"), 1);
|
||||
EXPECT_EQ(rt.count("kill"), 0);
|
||||
EXPECT_TRUE(rt.clock >= DC::kUndeliverableGraceSec && rt.clock < DC::kUndeliverableGraceSec + 1.0);
|
||||
}
|
||||
{ // ...whereas our own node stays waited on (out of credentials, but SIGTERM still reaches it).
|
||||
MockStopRuntime rt; MockStopTask task(rt);
|
||||
rt.sends = {R::Auth};
|
||||
rt.hasSignal = true;
|
||||
rt.exitAfterDelivery = 20.0;
|
||||
DC::StopPolicy p;
|
||||
p.gracefulSignal = true;
|
||||
p.maxWaitSec = 120;
|
||||
EXPECT_TRUE(DC::executeDaemonStop(p, rt, task) == O::Exited);
|
||||
EXPECT_EQ(rt.count("sigterm"), 1);
|
||||
}
|
||||
{ // The 2026-09-30 12:21:44 -> 12:22:04 regression: the queue is full for a while. Retry until it
|
||||
// lands (Windows-like, no signal channel), then wait for the exit. Never kill.
|
||||
MockStopRuntime rt; MockStopTask task(rt);
|
||||
@@ -4379,6 +4423,7 @@ void testDaemonStopStringsTranslated()
|
||||
"node_busy_rpc_desc", "node_starting_title", "node_starting_busy_desc", "node_starting_launch_desc",
|
||||
"sb_node_busy", "stopconfirm_rebuild_title", "stopconfirm_rebuild_body", "stopconfirm_busy_title",
|
||||
"stopconfirm_busy_body", "stopconfirm_stop_wait", "appx_daemon_reinstall_node_busy", "node_busy_running",
|
||||
"appx_node_busy_try_later", "appx_node_not_stopped",
|
||||
};
|
||||
// printf conversions in order, "%%" excluded: what must survive translation.
|
||||
auto conversions = [](const std::string& s) {
|
||||
@@ -4483,10 +4528,39 @@ void testDaemonLifecycleExecution()
|
||||
auto bootstrapResult = DaemonController::executeLifecycleOperation(bootstrap, bootstrapRuntime, bootstrapTask);
|
||||
EXPECT_TRUE(bootstrapResult.completed);
|
||||
EXPECT_TRUE(bootstrapResult.stopped);
|
||||
EXPECT_TRUE(bootstrapRuntime.calls == std::vector<std::string>({"rpc-stop-disconnect"}));
|
||||
EXPECT_EQ(bootstrapRuntime.rpcStopContext, std::string("Bootstrap daemon stop"));
|
||||
EXPECT_TRUE(bootstrapRuntime.calls == std::vector<std::string>({"background-stop"}));
|
||||
EXPECT_EQ(bootstrapRuntime.disconnectReason, std::string("Bootstrap"));
|
||||
|
||||
// S11: a node that doesn't exit cancels the operation before it changes anything: no second node is
|
||||
// started over it, and chain data is not deleted under it.
|
||||
for (auto op : {DaemonController::LifecycleOperation::ManualRestart, DaemonController::LifecycleOperation::Rescan,
|
||||
DaemonController::LifecycleOperation::RepairWallet,
|
||||
DaemonController::LifecycleOperation::DeleteBlockchainData}) {
|
||||
auto decision = DaemonController::evaluateLifecycleOperation(op, true, true, true, false);
|
||||
MockDaemonLifecycleRuntime stuckRuntime;
|
||||
stuckRuntime.stopResult = false;
|
||||
MockDaemonLifecycleTask stuckTask;
|
||||
auto stuck = DaemonController::executeLifecycleOperation(decision, stuckRuntime, stuckTask);
|
||||
EXPECT_TRUE(stuck.stopFailed);
|
||||
EXPECT_FALSE(stuck.stopped);
|
||||
EXPECT_FALSE(stuck.started);
|
||||
EXPECT_FALSE(stuck.completed);
|
||||
EXPECT_EQ(stuck.deletedItems, 0);
|
||||
EXPECT_TRUE(stuckRuntime.calls == std::vector<std::string>({"stop"}));
|
||||
EXPECT_EQ(stuckTask.sleeps, 0);
|
||||
}
|
||||
{ // A restart with no node of ours running just starts one (nothing to stop).
|
||||
auto idle = DaemonController::evaluateLifecycleOperation(
|
||||
DaemonController::LifecycleOperation::ManualRestart, true, true, false, false);
|
||||
MockDaemonLifecycleRuntime idleRuntime;
|
||||
idleRuntime.stopResult = false;
|
||||
MockDaemonLifecycleTask idleTask;
|
||||
auto idleResult = DaemonController::executeLifecycleOperation(idle, idleRuntime, idleTask);
|
||||
EXPECT_FALSE(idleResult.stopFailed);
|
||||
EXPECT_TRUE(idleResult.started);
|
||||
EXPECT_TRUE(idleRuntime.calls == std::vector<std::string>({"start"}));
|
||||
}
|
||||
|
||||
MockDaemonLifecycleRuntime cancelRuntime;
|
||||
MockDaemonLifecycleTask cancelTask;
|
||||
cancelTask.cancelAfterSleeps = 1;
|
||||
|
||||
Reference in New Issue
Block a user