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
+24 -13
View File
@@ -214,7 +214,7 @@ public:
: app_(app), token_(token) {}
bool isUsingEmbeddedDaemon() const override { return app_.isUsingEmbeddedDaemon(); }
void stopEmbeddedDaemon() override { app_.stopEmbeddedDaemon(); }
bool stopEmbeddedDaemon() override { return app_.stopEmbeddedDaemon() == daemon::StopOutcome::Exited; }
bool startEmbeddedDaemon() override { return app_.startEmbeddedDaemon(); }
bool cancelled() const override { return token_.cancelled(); }
bool shuttingDown() const override { return app_.isShuttingDown(); }
@@ -292,8 +292,13 @@ void App::restartDaemonAfterEncryption(const char* taskName, bool announceRestar
for (int i = 0; i < 20 && !token.cancelled() && !shutting_down_; ++i)
std::this_thread::sleep_for(std::chrono::milliseconds(100));
if (!token.cancelled() && !shutting_down_) {
stopEmbeddedDaemon();
if (!token.cancelled() && !shutting_down_) startEmbeddedDaemon();
if (stopEmbeddedDaemon() == daemon::StopOutcome::Exited) {
if (!token.cancelled() && !shutting_down_) startEmbeddedDaemon();
} else if (!shutting_down_) {
// Still running: never start a second node over it. The connect loop goes back to it.
DEBUG_LOGF("[App] encryption restart: the node did not exit; not restarting it\n");
pending_stop_failed_.fetch_or(kStopFailedNotify);
}
}
} catch (...) {}
daemon_restarting_ = false; // re-arm reconnect (tryConnect runs from the update loop)
@@ -306,6 +311,7 @@ void App::restartDaemonAfterEncryption(const char* taskName, bool announceRestar
void App::encryptWalletWithPassphrase(const std::string& passphrase) {
if (!rpc_ || !rpc_->isConnected()) return;
if (refuseIfNodeBusy()) return; // encrypting ends in a node restart
encrypt_in_progress_ = true;
encrypt_status_ = TR("sec_encrypting_wallet");
@@ -1561,7 +1567,9 @@ void App::renderDecryptWalletDialog() {
float btnW = (ImGui::GetContentRegionAvail().x - ImGui::GetStyle().ItemSpacing.x) * 0.5f;
ImGui::BeginDisabled(!valid || decryptState.inProgress);
if (ui::material::TactileButton(TR("settings_remove_encryption"), ImVec2(btnW, 40)) || (enterPressed && valid)) {
// Decrypting stops and restarts the node: not while it is busy (refuseIfNodeBusy() only on a click).
if ((ui::material::TactileButton(TR("settings_remove_encryption"), ImVec2(btnW, 40)) || (enterPressed && valid)) &&
!refuseIfNodeBusy()) {
std::string passphrase(decrypt_pass_buf_);
memset(decrypt_pass_buf_, 0, sizeof(decrypt_pass_buf_));
wallet_security_workflow_.start(std::chrono::steady_clock::now());
@@ -1613,15 +1621,18 @@ void App::renderDecryptWalletDialog() {
// Continue with step 3
worker_->post([this, filePlan]() -> rpc::RPCWorker::MainCb {
WalletSecurityDecryptRpcAdapter decryptRpc(rpc_.get(),
[this](rpc::RPCClient& client, const char* context) {
return sendStopCommandSafely(client, context);
});
services::WalletSecurityWorkflowExecutor::stopDaemon(decryptRpc);
// Wait for daemon to fully stop
for (int i = 0; i < 30 && !shutting_down_; ++i)
std::this_thread::sleep_for(std::chrono::milliseconds(100));
// Stop the node and wait for its PROCESS to exit before wallet.dat is
// renamed: dragonxd keeps the file open until it exits, which can take
// minutes on a large wallet (a fixed 3s wait let the rename run under a
// live node). Ours or the one we're connected to; bounded, never killed.
const bool stopped = stopEmbeddedDaemon(StopMode::Background, StopScope::Connected) ==
daemon::StopOutcome::Exited;
if (!stopped) {
App::scrubAndRemoveExport(filePlan.exportPath); // nothing will import it
return [this]() {
wallet_security_workflow_.fail(TR("appx_node_not_stopped"));
};
}
return [this, filePlan]() {
wallet_security_workflow_.advanceTo(DecryptStep::BackupWallet,