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:
2026-10-02 11:22:39 -05:00
co-authored by Claude Opus 5.5
parent 1215ded1a0
commit e20b70d73f
22 changed files with 320 additions and 122 deletions
+82 -8
View File
@@ -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;