diff --git a/res/fonts/NotoSansCJK-Subset.ttf b/res/fonts/NotoSansCJK-Subset.ttf index 3a5d5fb..309a1ef 100644 Binary files a/res/fonts/NotoSansCJK-Subset.ttf and b/res/fonts/NotoSansCJK-Subset.ttf differ diff --git a/res/lang/de.json b/res/lang/de.json index ac94309..cc99988 100644 --- a/res/lang/de.json +++ b/res/lang/de.json @@ -100,6 +100,8 @@ "appx_no_bundled_daemon_to_install": "Dieser Build enthält keinen mitgelieferten Daemon zur Installation", "appx_no_embedded_daemon_to_install": "Dieser Build enthält keinen eingebetteten Daemon zur Installation", "appx_node_busy_restarting": "Der Node ist mit dem Neustart beschäftigt — versuchen Sie es gleich erneut.", + "appx_node_busy_try_later": "Der Node ist mit einer langen Aufgabe beschäftigt (etwa dem Neuaufbau der Note-Witnesses). Versuchen Sie es erneut, wenn sie abgeschlossen ist; ein Stoppen jetzt könnte diese Arbeit von vorn beginnen lassen.", + "appx_node_not_stopped": "Der Node wurde nicht rechtzeitig beendet, daher wurde nichts geändert. Versuchen Sie es in ein paar Minuten erneut.", "appx_not_next_word": " — das ist nicht das nächste Wort", "appx_payment_request_loaded": "Zahlungsanforderung geladen", "appx_pool_miner_connected_and_hashing": "Pool-Miner verbunden und aktiv.", diff --git a/res/lang/es.json b/res/lang/es.json index a5f2602..6905756 100644 --- a/res/lang/es.json +++ b/res/lang/es.json @@ -100,6 +100,8 @@ "appx_no_bundled_daemon_to_install": "Esta versión no incluye un daemon para instalar", "appx_no_embedded_daemon_to_install": "Esta versión no incluye un daemon integrado para instalar", "appx_node_busy_restarting": "El nodo está ocupado reiniciándose; inténtalo de nuevo en un momento.", + "appx_node_busy_try_later": "El nodo está ocupado terminando una tarea larga (como reconstruir los testigos de notas). Vuelve a intentarlo cuando termine; detenerlo ahora podría hacer que empiece ese trabajo de nuevo.", + "appx_node_not_stopped": "El nodo no se detuvo a tiempo, así que no se cambió nada. Vuelve a intentarlo en unos minutos.", "appx_not_next_word": " — esa no es la siguiente palabra", "appx_payment_request_loaded": "Solicitud de pago cargada", "appx_pool_miner_connected_and_hashing": "Minero de pool conectado y calculando hashes.", diff --git a/res/lang/fr.json b/res/lang/fr.json index 4dbf6c6..08a70dc 100644 --- a/res/lang/fr.json +++ b/res/lang/fr.json @@ -100,6 +100,8 @@ "appx_no_bundled_daemon_to_install": "Cette version ne contient aucun démon intégré à installer", "appx_no_embedded_daemon_to_install": "Cette version ne contient aucun démon embarqué à installer", "appx_node_busy_restarting": "Le nœud est occupé à redémarrer — réessayez dans un instant.", + "appx_node_busy_try_later": "Le nœud termine une longue tâche (comme la reconstruction des témoins de notes). Réessayez une fois qu'elle est finie ; l'arrêter maintenant pourrait lui faire recommencer ce travail.", + "appx_node_not_stopped": "Le nœud ne s'est pas arrêté à temps, rien n'a donc été modifié. Réessayez dans quelques minutes.", "appx_not_next_word": " — ce n'est pas le mot suivant", "appx_payment_request_loaded": "Demande de paiement chargée", "appx_pool_miner_connected_and_hashing": "Mineur de pool connecté et en cours de hachage.", diff --git a/res/lang/ja.json b/res/lang/ja.json index dc624fa..e487fbc 100644 --- a/res/lang/ja.json +++ b/res/lang/ja.json @@ -100,6 +100,8 @@ "appx_no_bundled_daemon_to_install": "このビルドにはインストールできる同梱デーモンがありません", "appx_no_embedded_daemon_to_install": "このビルドにはインストールできる組み込みデーモンがありません", "appx_node_busy_restarting": "ノードが再起動中でビジー状態です。少し待ってから再試行してください。", + "appx_node_busy_try_later": "ノードは長時間の処理(ノートウィットネスの再構築など)を実行中です。完了してからもう一度お試しください。今停止すると、その処理を最初からやり直すことがあります。", + "appx_node_not_stopped": "ノードが時間内に停止しなかったため、何も変更されませんでした。数分後にもう一度お試しください。", "appx_not_next_word": " — 次の単語ではありません", "appx_payment_request_loaded": "支払いリクエストを読み込みました", "appx_pool_miner_connected_and_hashing": "プールマイナーが接続し、ハッシュ計算中です。", diff --git a/res/lang/ko.json b/res/lang/ko.json index 1a6db75..2573d1b 100644 --- a/res/lang/ko.json +++ b/res/lang/ko.json @@ -100,6 +100,8 @@ "appx_no_bundled_daemon_to_install": "이 빌드에는 설치할 번들 데몬이 없습니다", "appx_no_embedded_daemon_to_install": "이 빌드에는 설치할 내장 데몬이 없습니다", "appx_node_busy_restarting": "노드가 재시작 중입니다 — 잠시 후 다시 시도하세요.", + "appx_node_busy_try_later": "노드가 긴 작업(노트 위트니스 재구성 등)을 마무리하는 중입니다. 끝난 뒤 다시 시도하세요. 지금 중지하면 그 작업을 처음부터 다시 할 수 있습니다.", + "appx_node_not_stopped": "노드가 제때 중지되지 않아 아무것도 변경되지 않았습니다. 몇 분 후 다시 시도하세요.", "appx_not_next_word": " — 다음 단어가 아닙니다", "appx_payment_request_loaded": "결제 요청을 불러왔습니다", "appx_pool_miner_connected_and_hashing": "풀 채굴기가 연결되어 해싱 중입니다.", diff --git a/res/lang/pt.json b/res/lang/pt.json index 6fdc571..75b862a 100644 --- a/res/lang/pt.json +++ b/res/lang/pt.json @@ -100,6 +100,8 @@ "appx_no_bundled_daemon_to_install": "Esta versão não tem daemon incluído para instalar", "appx_no_embedded_daemon_to_install": "Esta versão não tem daemon embutido para instalar", "appx_node_busy_restarting": "O nó está ocupado reiniciando — tente novamente em instantes.", + "appx_node_busy_try_later": "O nó está ocupado concluindo uma tarefa longa (como reconstruir as testemunhas de notas). Tente novamente quando terminar; pará-lo agora pode fazê-lo recomeçar esse trabalho.", + "appx_node_not_stopped": "O nó não parou a tempo, por isso nada foi alterado. Tente novamente em alguns minutos.", "appx_not_next_word": " — essa não é a próxima palavra", "appx_payment_request_loaded": "Solicitação de pagamento carregada", "appx_pool_miner_connected_and_hashing": "Minerador de pool conectado e processando hashes.", diff --git a/res/lang/ru.json b/res/lang/ru.json index 4557bd9..4e6bb2a 100644 --- a/res/lang/ru.json +++ b/res/lang/ru.json @@ -100,6 +100,8 @@ "appx_no_bundled_daemon_to_install": "В этой сборке нет встроенного демона для установки", "appx_no_embedded_daemon_to_install": "В этой сборке нет встроенного демона для установки", "appx_node_busy_restarting": "Узел занят перезапуском — попробуйте ещё раз через мгновение.", + "appx_node_busy_try_later": "Узел занят долгой задачей (например, перестройкой свидетелей заметок). Повторите попытку, когда она завершится; если остановить его сейчас, эту работу, возможно, придётся начать заново.", + "appx_node_not_stopped": "Узел не остановился вовремя, поэтому ничего не изменено. Повторите попытку через несколько минут.", "appx_not_next_word": " — это не следующее слово", "appx_payment_request_loaded": "Запрос на оплату загружен", "appx_pool_miner_connected_and_hashing": "Пул-майнер подключён и вычисляет хеши.", diff --git a/res/lang/zh.json b/res/lang/zh.json index 00d4bc5..55de767 100644 --- a/res/lang/zh.json +++ b/res/lang/zh.json @@ -100,6 +100,8 @@ "appx_no_bundled_daemon_to_install": "此版本没有可安装的捆绑守护进程", "appx_no_embedded_daemon_to_install": "此版本没有可安装的内嵌守护进程", "appx_node_busy_restarting": "节点正忙于重启——请稍后再试。", + "appx_node_busy_try_later": "节点正忙于完成一项耗时任务(例如重建票据见证)。请在完成后重试;现在停止它可能导致这项工作从头开始。", + "appx_node_not_stopped": "节点未能及时停止,因此未做任何更改。请几分钟后重试。", "appx_not_next_word": " — 这不是下一个词", "appx_payment_request_loaded": "支付请求已加载", "appx_pool_miner_connected_and_hashing": "矿池矿工已连接并正在计算哈希。", diff --git a/scripts/add_missing_translations.py b/scripts/add_missing_translations.py index 8e73547..191841f 100644 --- a/scripts/add_missing_translations.py +++ b/scripts/add_missing_translations.py @@ -25,6 +25,26 @@ def load_english_keys(): # All translations for the 158 missing keys TRANSLATIONS = { + "appx_node_busy_try_later": { + "de": "Der Node ist mit einer langen Aufgabe beschäftigt (etwa dem Neuaufbau der Note-Witnesses). Versuchen Sie es erneut, wenn sie abgeschlossen ist; ein Stoppen jetzt könnte diese Arbeit von vorn beginnen lassen.", + "es": "El nodo está ocupado terminando una tarea larga (como reconstruir los testigos de notas). Vuelve a intentarlo cuando termine; detenerlo ahora podría hacer que empiece ese trabajo de nuevo.", + "fr": "Le nœud termine une longue tâche (comme la reconstruction des témoins de notes). Réessayez une fois qu'elle est finie ; l'arrêter maintenant pourrait lui faire recommencer ce travail.", + "pt": "O nó está ocupado concluindo uma tarefa longa (como reconstruir as testemunhas de notas). Tente novamente quando terminar; pará-lo agora pode fazê-lo recomeçar esse trabalho.", + "ru": "Узел занят долгой задачей (например, перестройкой свидетелей заметок). Повторите попытку, когда она завершится; если остановить его сейчас, эту работу, возможно, придётся начать заново.", + "zh": "节点正忙于完成一项耗时任务(例如重建票据见证)。请在完成后重试;现在停止它可能导致这项工作从头开始。", + "ja": "ノードは長時間の処理(ノートウィットネスの再構築など)を実行中です。完了してからもう一度お試しください。今停止すると、その処理を最初からやり直すことがあります。", + "ko": "노드가 긴 작업(노트 위트니스 재구성 등)을 마무리하는 중입니다. 끝난 뒤 다시 시도하세요. 지금 중지하면 그 작업을 처음부터 다시 할 수 있습니다.", + }, + "appx_node_not_stopped": { + "de": "Der Node wurde nicht rechtzeitig beendet, daher wurde nichts geändert. Versuchen Sie es in ein paar Minuten erneut.", + "es": "El nodo no se detuvo a tiempo, así que no se cambió nada. Vuelve a intentarlo en unos minutos.", + "fr": "Le nœud ne s'est pas arrêté à temps, rien n'a donc été modifié. Réessayez dans quelques minutes.", + "pt": "O nó não parou a tempo, por isso nada foi alterado. Tente novamente em alguns minutos.", + "ru": "Узел не остановился вовремя, поэтому ничего не изменено. Повторите попытку через несколько минут.", + "zh": "节点未能及时停止,因此未做任何更改。请几分钟后重试。", + "ja": "ノードが時間内に停止しなかったため、何も変更されませんでした。数分後にもう一度お試しください。", + "ko": "노드가 제때 중지되지 않아 아무것도 변경되지 않았습니다. 몇 분 후 다시 시도하세요.", + }, # --- Daemon shutdown hardening: stopping the node on quit (never kill unasked) --- "shutdown_leave_running": { "de": "Wallet schließen – Node weiterlaufen lassen", "es": "Cerrar la cartera y dejar el nodo en marcha", diff --git a/src/app.cpp b/src/app.cpp index 56b9b98..649dd25 100644 --- a/src/app.cpp +++ b/src/app.cpp @@ -315,7 +315,7 @@ class AppDaemonLifecycleRuntime final : public daemon::DaemonController::Lifecyc public: explicit AppDaemonLifecycleRuntime(App& app) : app_(app) {} - void stopDaemonWithPolicy() override { app_.stopEmbeddedDaemon(); } + bool stopDaemonWithPolicy() override { return app_.stopEmbeddedDaemon() == daemon::StopOutcome::Exited; } bool startDaemon() override { return app_.startEmbeddedDaemon(); } void resetOutputOffset() override { @@ -323,13 +323,13 @@ public: app_.resetDaemonBusy(); } - void requestRpcStopAndDisconnect(const char* context, const char* reason) override + void beginBackgroundStop(const char* reason) override { - if (app_.rpc_ && app_.rpc_->isConnected()) { - app_.sendStopCommandSafely(*app_.rpc_, context); - app_.rpc_->disconnect(); - } + if (app_.rpc_ && app_.rpc_->isConnected()) app_.rpc_->disconnect(); app_.onDisconnected(reason ? reason : "Daemon lifecycle"); + App& app = app_; + app.async_tasks_.submit("background-stop-daemon", + [&app](const util::AsyncTaskManager::Token&) { app.stopEmbeddedDaemon(); }); } int deleteBlockchainData() override @@ -1614,6 +1614,7 @@ void App::render() if (wizard_phase_ != WizardPhase::None && wizard_phase_ != WizardPhase::Done) { renderFirstRunWizard(); renderDaemonStopConfirm(); // a quit during the wizard still gets its "keep the node running?" prompt + ui::Notifications::instance().render(); return; } @@ -1643,6 +1644,19 @@ void App::render() } if (pending_reinstall_node_busy_.exchange(false, std::memory_order_relaxed)) ui::Notifications::instance().warning(TR("appx_daemon_reinstall_node_busy"), 12.0f); + if (const int failed = pending_stop_failed_.exchange(0, std::memory_order_relaxed)) { + if ((failed & kStopFailedUndoRescan) && daemon_controller_) { + // The rescan/repair never started: disarm its flags so a later start doesn't run it unasked. + daemon_controller_->setRescanOnNextStart(false); + daemon_controller_->setZapOnNextStart(false); + state_.sync.rescanning = false; + state_.sync.rescan_status.clear(); + rescan_confirmed_active_ = false; + user_initiated_rescan_ = false; + } + if ((failed & kStopFailedNotify) && !shutting_down_) + ui::Notifications::instance().warning(TR("appx_node_not_stopped"), 12.0f); + } } @@ -5554,22 +5568,23 @@ void App::chooseStopAction(daemon::StopUserChoice choice) } } -daemon::StopOutcome App::stopEmbeddedDaemon(StopMode mode) +daemon::StopOutcome App::stopEmbeddedDaemon(StopMode mode, StopScope scope) { - if (!daemon_controller_) return daemon::StopOutcome::Exited; - - auto shutdownDecision = daemon_controller_->shutdownDecision( - false, settings_ && settings_->getStopExternalDaemon()); - if (shutdownDecision.action == daemon::DaemonController::ShutdownAction::DisconnectOnly) { - DEBUG_LOGF("stopEmbeddedDaemon: %s — skipping\n", shutdownDecision.logReason); - return daemon::StopOutcome::StillRunning; + if (scope == StopScope::Policy) { + if (!daemon_controller_) return daemon::StopOutcome::Exited; + auto shutdownDecision = daemon_controller_->shutdownDecision( + false, settings_ && settings_->getStopExternalDaemon()); + if (shutdownDecision.action == daemon::DaemonController::ShutdownAction::DisconnectOnly) { + DEBUG_LOGF("stopEmbeddedDaemon: %s — skipping\n", shutdownDecision.logReason); + return daemon::StopOutcome::StillRunning; + } } // One stop at a time: a second caller (say the shutdown thread while a restart task is stopping the // node) waits for the first. Background stops let go as soon as the app starts quitting, so the // shutdown never waits long behind one. std::lock_guard single(stop_mutex_); - daemon::EmbeddedDaemon* d = daemon_controller_->daemon(); + daemon::EmbeddedDaemon* d = daemon_controller_ ? daemon_controller_->daemon() : nullptr; // beginStop() also marks the stop as requested (an early exit is not a crash) and makes this session // the only reaper. Only a process we launched and still hold is "owned": a lock-gated start, an // external node and a node we already let go of are not, and are never waited on, signalled or killed. @@ -5654,52 +5669,23 @@ bool App::isEmbeddedDaemonRunning() const bool App::stopDaemonForWalletSwitch() { - // "Owned" means we spawned the node this session and hold a live process handle. That is the ONLY - // reliable ownership signal here: externalDaemonDetected() is latched only inside EmbeddedDaemon::start(), - // so when the app just direct-connects to an already-running, config-provided node (the common - // keep-node-running case) start() is never called and that flag stays false even though we clearly did - // not start the node. Gate on the handle instead. - const bool owned = daemon_controller_ && isEmbeddedDaemonRunning(); - if (owned) { - // We spawned it — stopEmbeddedDaemon() waits for the process to exit (up to 120s, never killing - // it), so on Exited the datadir lock + RPC port are released; a short port check just confirms. - // Still running means the switch must not go ahead: the old node holds the wallet and the lock. - if (stopEmbeddedDaemon() != daemon::StopOutcome::Exited) return shutting_down_; - for (int i = 0; i < 200 && daemon::EmbeddedDaemon::isRpcPortInUse() && !shutting_down_; ++i) - std::this_thread::sleep_for(std::chrono::milliseconds(100)); - return shutting_down_ || !daemon::EmbeddedDaemon::isRpcPortInUse(); - } - - // Adopted OR direct-connected: no process handle, so RPC "stop" is the only lever, and - // stopEmbeddedDaemon()'s temp connection (autoDetectConfig) can't be trusted here. Send a graceful RPC - // "stop" over the exact creds we're connected with (saved_config_) — guaranteed to reach our node, and - // only OUR node accepts them, so a foreign dragonxd (different rpcpassword) is a safe no-op. switchToWallet - // disconnects rpc_ before this worker runs, so build a fresh temporary connection. - bool sent = false; - { - auto tmp = std::make_unique(); - if (tmp->connect(saved_config_.host, saved_config_.port, saved_config_.rpcuser, - saved_config_.rpcpassword, saved_config_.use_tls)) { - sent = sendStopCommandSafely(*tmp, "wallet-switch stop"); - tmp->disconnect(); - } - } - DEBUG_LOGF("[App] wallet-switch stop of unowned node (saved_config_): %s\n", sent ? "sent" : "FAILED"); - - // CRITICAL: wait for the node to FULLY EXIT before the caller starts the replacement — NOT just for the - // RPC port. On Windows isRpcPortInUse() is a connect() probe that reads "free" the moment the daemon - // stops accepting RPC (early in shutdown), but the process keeps the DATADIR LOCK until it exits, and a - // graceful shutdown can take 60-90s when the node's network threads block on peer timeouts. Starting a - // replacement into a still-locked datadir fails ("Cannot obtain a lock…") and wedges the switch. So gate - // on the PROCESS being gone (isDaemonProcessRunning) AND the port free. Bounded ~120s while stopping; if - // we couldn't even send the stop (foreign node / bad creds → its port never frees), only a brief grace. - const int maxTicks = sent ? 1200 : 50; - auto stillUp = []() { + // Stop whatever node we're on: ours, or the adopted/external one we're connected to. "Owned" can't be + // read from externalDaemonDetected() (it's latched only inside EmbeddedDaemon::start(), so a node we + // direct-connected to reads as ours); StopScope::Connected covers both — our process handle when we have + // one, else a graceful "stop" with the exact credentials we connected with (only our node accepts them, + // so a foreign dragonxd is a safe no-op). Bounded, never killed, abandoned when the app starts quitting. + const daemon::StopOutcome outcome = stopEmbeddedDaemon(StopMode::Background, StopScope::Connected); + // CRITICAL: the caller swaps wallet.dat or starts a replacement next, so it may go ahead only once the node + // has fully EXITED. dragonxd unbinds RPC early in its shutdown but keeps wallet.dat and the datadir lock + // until the process exits, which can take minutes. A wait abandoned because the app is quitting proves + // nothing: report "not stopped" then too. + if (outcome != daemon::StopOutcome::Exited || shutting_down_) return false; + auto stillUp = [] { return daemon::EmbeddedDaemon::isRpcPortInUse() || daemon::EmbeddedDaemon::isDaemonProcessRunning(); }; - for (int i = 0; i < maxTicks && stillUp() && !shutting_down_; ++i) + for (int i = 0; i < 200 && stillUp() && !shutting_down_; ++i) // up to 20s for the port to free std::this_thread::sleep_for(std::chrono::milliseconds(100)); - return shutting_down_ || !stillUp(); + return !shutting_down_ && !stillUp(); } void App::rescanBlockchain() @@ -5728,6 +5714,7 @@ void App::rescanBlockchain() ui::Notifications::instance().warning(TR("appx_node_busy_restarting")); return; } + if (refuseIfNodeBusy()) return; DEBUG_LOGF("[App] Starting blockchain rescan - stopping daemon first\n"); ui::Notifications::instance().info(TR("appx_restarting_daemon_rescan_flag")); @@ -5753,7 +5740,8 @@ void App::rescanBlockchain() DEBUG_LOGF("[App] Stopping daemon for rescan...\n"); AppDaemonLifecycleRuntime runtime(*this); daemon::AsyncLifecycleTaskContext context(token, shutting_down_); - daemon_controller_->executeLifecycleOperation(decision, runtime, context); + if (daemon_controller_->executeLifecycleOperation(decision, runtime, context).stopFailed) + pending_stop_failed_.fetch_or(kStopFailedNotify | kStopFailedUndoRescan); }); } @@ -5780,6 +5768,7 @@ void App::repairWallet() ui::Notifications::instance().warning(TR("appx_node_busy_restarting")); return; } + if (refuseIfNodeBusy()) return; DEBUG_LOGF("[App] Starting wallet repair (-zapwallettxes=2) - stopping daemon first\n"); ui::Notifications::instance().info(TR("appx_restarting_daemon_zapwallettxes")); @@ -5804,7 +5793,8 @@ void App::repairWallet() DEBUG_LOGF("[App] Stopping daemon for wallet repair...\n"); AppDaemonLifecycleRuntime runtime(*this); daemon::AsyncLifecycleTaskContext context(token, shutting_down_); - daemon_controller_->executeLifecycleOperation(decision, runtime, context); + if (daemon_controller_->executeLifecycleOperation(decision, runtime, context).stopFailed) + pending_stop_failed_.fetch_or(kStopFailedNotify | kStopFailedUndoRescan); }); } @@ -5831,6 +5821,7 @@ void App::reinstallBundledDaemon() ui::Notifications::instance().warning(TR("appx_daemon_reinstall_in_progress")); return; } + if (refuseIfNodeBusy()) return; DEBUG_LOGF("[App] Reinstalling bundled daemon binary — stopping daemon first\n"); ui::Notifications::instance().info(TR("appx_installing_bundled_daemon")); @@ -5838,14 +5829,9 @@ void App::reinstallBundledDaemon() async_tasks_.submit("reinstall-daemon", [this](const util::AsyncTaskManager::Token& token) { AppDaemonLifecycleRuntime runtime(*this); daemon::AsyncLifecycleTaskContext ctx(token, shutting_down_); - // Stop the running node so its binary is free. stopDaemonWithPolicy() covers a wallet-managed - // daemon; if we're instead attached to an external/leftover dragonxd (daemon_controller_ is - // null, so stopEmbeddedDaemon() returns immediately), send an explicit RPC stop so that node - // exits too — otherwise it keeps the RPC port and the fresh daemon can't start. - if (!daemon_controller_ && rpc_ && rpc_->isConnected()) { - sendStopCommandSafely(*rpc_, "reinstall-daemon"); - } - runtime.stopDaemonWithPolicy(); + // Stop the running node so its binary is free: ours, or the external/leftover dragonxd we're attached + // to (it would keep the RPC port and the fresh daemon couldn't start). Bounded, never killed. + stopEmbeddedDaemon(StopMode::Background, StopScope::Connected); // Wait (bounded, ~120s) for the old node's PROCESS to exit, not just its RPC port: dragonxd unbinds // RPC first and can take a minute or more to flush and close its wallet. Until it exits its exe is // locked on Windows (the overwrite would fail) and it holds the datadir lock (a restart would fail). @@ -5892,6 +5878,7 @@ void App::deleteBlockchainData() ui::Notifications::instance().warning(TR("appx_blockchain_maintenance_in_progress")); return; } + if (refuseIfNodeBusy()) return; DEBUG_LOGF("[App] Deleting blockchain data - stopping daemon first\n"); ui::Notifications::instance().info(TR("appx_stopping_daemon_deleting_blockchain")); @@ -5902,6 +5889,11 @@ void App::deleteBlockchainData() AppDaemonLifecycleRuntime runtime(*this); daemon::AsyncLifecycleTaskContext context(token, shutting_down_); auto result = daemon_controller_->executeLifecycleOperation(decision, runtime, context); + if (result.stopFailed) { + DEBUG_LOGF("[App] Blockchain data NOT deleted: the node did not exit\n"); + pending_stop_failed_.fetch_or(kStopFailedNotify); + return; + } DEBUG_LOGF("[App] Blockchain data deleted (%d items removed), restarting daemon...\n", result.deletedItems); // Hand the count to the main thread for a completion toast (Notifications isn't thread-safe). pending_delete_result_.store(result.deletedItems, std::memory_order_relaxed); @@ -6196,8 +6188,12 @@ void App::noteDaemonRpcResult(bool answered, rpc::RpcFailureKind kind) bool App::rpcBusyWhileAlive() const { - if (rpc_busy_since_ == std::chrono::steady_clock::time_point{}) return false; - if (std::chrono::steady_clock::now() - rpc_busy_since_ < std::chrono::seconds(30)) return false; + // Two clocks: the connect/core-refresh results, and the busy gate. The gate hears every lane (since S6 the + // first timeout often lands on another lane, after which the core refresh is only ever Deferred). + const bool resultsBusy = rpc_busy_since_ != std::chrono::steady_clock::time_point{} && + std::chrono::steady_clock::now() - rpc_busy_since_ >= std::chrono::seconds(30); + const bool gateBusy = rpc_busy_gate_ && rpc_busy_gate_->busySeconds() >= 30.0; + if (!resultsBusy && !gateBusy) return false; return (daemon_controller_ && daemon_controller_->isRunning()) || daemon::EmbeddedDaemon::isRpcPortInUse(); } @@ -6210,12 +6206,27 @@ bool App::shouldConfirmDaemonStop() const settings_ && settings_->getKeepDaemonRunning(), settings_ && settings_->getStopExternalDaemon()); const bool stops = decision.action == daemon::DaemonController::ShutdownAction::StopDaemon; - // The tracker reads OUR node's output; once that process is gone its last state means nothing. No - // daemon version suppresses this: v1.3.0 does not checkpoint a witness rebuild, and nothing survives + // No daemon version suppresses this: v1.3.0 does not checkpoint a witness rebuild, and nothing survives // a kill. + return stops && nodeBusyForStop(); +} + +bool App::nodeBusyForStop() const +{ + // The tracker reads OUR node's output; once that process is gone its last state means nothing. An + // external node's busyness shows only as RPC that has stopped answering. daemon::DaemonBusyTracker ours; - if (daemon_controller_->isRunning()) ours = daemon_busy_; - return daemon::shouldConfirmStop(stops, ours, rpcBusyWhileAlive()); + if (daemon_controller_ && daemon_controller_->isRunning()) ours = daemon_busy_; + return daemon::shouldConfirmStop(true, ours, rpcBusyWhileAlive()); +} + +bool App::refuseIfNodeBusy() +{ + if (!nodeBusyForStop()) return false; + DEBUG_LOGF("[App] refused: the node is busy (%s) — stopping it now would make it start over\n", + daemon_busy_.rebuilding() ? "rebuilding witnesses" : "not answering RPC"); + ui::Notifications::instance().warning(TR("appx_node_busy_try_later"), 10.0f); + return true; } void App::renderDaemonStopConfirm() @@ -7295,6 +7306,7 @@ void App::restartDaemon() daemon::DaemonController::LifecycleOperation::ManualRestart, isUsingEmbeddedDaemon(), daemon_controller_ != nullptr, isEmbeddedDaemonRunning(), daemon_restarting_.load()); if (!decision.allowed) return; + if (refuseIfNodeBusy()) return; daemon_restarting_ = true; daemon_controller_->prepareLifecycleOperation(decision, settings_.get()); @@ -7314,8 +7326,14 @@ void App::restartDaemon() async_tasks_.submit(decision.taskName, [this, decision](const util::AsyncTaskManager::Token& token) { AppDaemonLifecycleRuntime runtime(*this); daemon::AsyncLifecycleTaskContext context(token, shutting_down_); - daemon_controller_->executeLifecycleOperation(decision, runtime, context); + const auto result = daemon_controller_->executeLifecycleOperation(decision, runtime, context); + // Re-arm the connect loop either way: it reconnects to the new node, or to the old one still running. daemon_restarting_ = false; + if (result.stopFailed) { + DEBUG_LOGF("[App] Daemon restart cancelled: the node did not exit; not starting a second one\n"); + pending_stop_failed_.fetch_or(kStopFailedNotify); + return; + } DEBUG_LOGF("[App] Daemon restart complete — waiting for RPC...\n"); }); } diff --git a/src/app.h b/src/app.h index 1fc2327..dd491e6 100644 --- a/src/app.h +++ b/src/app.h @@ -200,6 +200,12 @@ public: // The node is alive and its RPC has only timed out / been refused as busy for >= 30s (a rebuild that // logged no marker, e.g. an external node whose output we can't read). bool rpcBusyWhileAlive() const; + // The node is rebuilding witnesses (our own node's log says so) or has been too busy to answer for a + // while: stopping it now either waits up to an hour or makes it start over. UI thread only. + bool nodeBusyForStop() const; + // For an operation that would stop the node (restart, rescan, switch, reinstall, bootstrap, ...): when + // nodeBusyForStop(), tell the user to try again later and return true; the caller then does nothing. + bool refuseIfNodeBusy(); // Record how an RPC to the node just went, for rpcBusyWhileAlive(): busy kinds (ReplyTimeout / // QueueFull) start the clock, an answer stops it, anything else leaves it alone. void noteDaemonRpcResult(bool answered, rpc::RpcFailureKind kind); @@ -497,12 +503,16 @@ public: Background, // restart/rescan/switch/...: up to 120s, and let go as soon as the app starts quitting Fallback, // the synchronous shutdown fallback on the UI thread: up to 20s }; - daemon::StopOutcome stopEmbeddedDaemon(StopMode mode = StopMode::Background); - // Stop the node specifically for a wallet switch, which MUST free the RPC port to relaunch on - // -wallet=. Unlike stopEmbeddedDaemon() (whose DisconnectOnly policy leaves an adopted/external - // daemon running), this sends a graceful RPC "stop" — using our own connection creds, so only our - // daemon obeys it — even to an adopted daemon, then waits (bounded) for the port to actually release. - // Returns true once the RPC port is free (or we're shutting down). + // Which node it may stop. Policy: our own, or an external one only when the "stop external daemon" + // setting says so. Connected: also the node we're connected to (adopted or external), with the + // credentials we connected with — a wallet switch, a reinstall and decrypt must free the port, the binary + // and wallet.dat. Only a node that accepts those credentials obeys. + enum class StopScope { Policy, Connected }; + daemon::StopOutcome stopEmbeddedDaemon(StopMode mode = StopMode::Background, StopScope scope = StopScope::Policy); + // Stop the node for a wallet switch / adopt / restore / rebuild, which swap wallet.dat or relaunch on + // -wallet=. Unlike stopEmbeddedDaemon()'s default policy (which leaves an adopted/external node + // running) it stops the node we're connected to too (StopScope::Connected). True only once the process + // has exited and the RPC port is free; false if it is still running, or the app started quitting. bool stopDaemonForWalletSwitch(); bool isEmbeddedDaemonRunning() const; bool isUsingEmbeddedDaemon() const { return supportsEmbeddedDaemon() && use_embedded_daemon_; } @@ -1078,6 +1088,10 @@ private: // and resets it to -1. Atomic because the worker thread writes it and the UI thread reads/clears it. std::atomic pending_delete_result_{-1}; std::atomic pending_reinstall_node_busy_{false}; // reinstall gave up: the old node never exited + // A worker stopped the node for an operation and it didn't exit: the operation was cancelled. Told (and, + // for a rescan/repair, its armed state undone) on the UI thread. Bits: kStopFailed*. + static constexpr int kStopFailedNotify = 1, kStopFailedUndoRescan = 2; + std::atomic pending_stop_failed_{0}; // Encryption state check timeout float encryption_check_timer_ = 0.0f; diff --git a/src/app_network.cpp b/src/app_network.cpp index 4d3446e..88070a6 100644 --- a/src/app_network.cpp +++ b/src/app_network.cpp @@ -1391,6 +1391,7 @@ void App::switchToWallet(const std::string& walletFile, bool stopDaemonConfirmed ui::Notifications::instance().warning("Finish or cancel the pending send before switching wallets."); return; } + if (refuseIfNodeBusy()) return; // W1-1: verify the target wallet file actually exists before switching. dragonxd auto-CREATES a // fresh empty wallet for a missing -wallet=, so without this a moved/deleted wallet file would // silently "open" as a brand-new empty wallet with a zero balance — looking exactly like fund loss. @@ -5071,6 +5072,7 @@ void App::locateSweepTxid() // restart with -rescan so the new keys pick up the swept funds. Runs on a background thread. void App::beginAdoptSeedWallet() { + if (refuseIfNodeBusy()) return; // stays on its current step; the user retries once the node is free seed_migration_step_ = SeedMigrationStep::Adopting; seed_migration_status_ = "Restarting with your new wallet — this rescan can take a while…"; { std::lock_guard lk(seed_migration_mutex_); seed_migration_adopt_done_ = false; } @@ -5186,6 +5188,7 @@ void App::restoreOriginalWallet() ui::Notifications::instance().warning(TR("wallet_restore_busy")); return; } + if (refuseIfNodeBusy()) return; // Keep the recovery dialog OPEN and drive it into the Working phase — it shows progress and the // honest outcome in place (pumpWalletRestore flips it to Done/Failed). Presentation only; every // file-safety step below is unchanged. @@ -5353,6 +5356,7 @@ void App::rebuildWalletDatabase() return; } if (daemon_restarting_) { ui::Notifications::instance().warning(TR("wallet_restore_busy")); return; } + if (refuseIfNodeBusy()) return; const std::string helper = findWalletRebuildHelper(); if (helper.empty()) { ui::Notifications::instance().error(TR("wallet_rebuild_no_helper"), 15.0f); return; } diff --git a/src/app_security.cpp b/src/app_security.cpp index 8ba1ba2..819e231 100644 --- a/src/app_security.cpp +++ b/src/app_security.cpp @@ -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, diff --git a/src/app_wizard.cpp b/src/app_wizard.cpp index 2b30807..e10f04c 100644 --- a/src/app_wizard.cpp +++ b/src/app_wizard.cpp @@ -77,6 +77,7 @@ void App::restartWizard() return; } + if (refuseIfNodeBusy()) return; // the wizard stops the node DEBUG_LOGF("[App] Restarting setup wizard — stopping daemon...\n"); // Reset crash counter for fresh wizard attempt @@ -874,7 +875,7 @@ void App::renderFirstRunWizard() { ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(ui::material::OnPrimary())); ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, 8.0f * dp); ImGui::BeginDisabled(!supportsFullNodeLifecycleActions()); - if (ui::material::TactileButton(TR("retry"), ImVec2(btnW2, btnH2))) { + if (ui::material::TactileButton(TR("retry"), ImVec2(btnW2, btnH2)) && !refuseIfNodeBusy()) { // Stop embedded daemon before bootstrap to avoid chain data corruption stopDaemonForBootstrap(); bootstrap_ = std::make_unique(); @@ -974,9 +975,12 @@ void App::renderFirstRunWizard() { } } wizard_stop_status_ = TR("wiz_daemon_waiting_stop"); - for (int i = 0; i < 60 && !token.cancelled(); i++) { + // Stopped means the PROCESS is gone, not just its RPC port: dragonxd unbinds RPC + // early in its shutdown and holds the datadir lock until it exits. + for (int i = 0; i < 120 && !token.cancelled(); i++) { std::this_thread::sleep_for(std::chrono::seconds(1)); - if (!daemon::EmbeddedDaemon::isRpcPortInUse()) { + if (!daemon::EmbeddedDaemon::isRpcPortInUse() && + !daemon::EmbeddedDaemon::isDaemonProcessRunning()) { wizard_stop_status_ = TR("wiz_daemon_stopped_ok"); wizard_stopping_external_ = false; return; @@ -1058,7 +1062,7 @@ void App::renderFirstRunWizard() { ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(ui::material::OnPrimary())); ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, 8.0f * dp); ImGui::BeginDisabled(!supportsFullNodeLifecycleActions()); - if (ui::material::TactileButton(TR("download"), ImVec2(dlBtnW, btnH2))) { + if (ui::material::TactileButton(TR("download"), ImVec2(dlBtnW, btnH2)) && !refuseIfNodeBusy()) { // Stop embedded daemon before bootstrap to avoid chain data corruption stopDaemonForBootstrap(); bootstrap_ = std::make_unique(); @@ -1077,7 +1081,7 @@ void App::renderFirstRunWizard() { ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(ui::material::OnSurface())); ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, 8.0f * dp); ImGui::BeginDisabled(!supportsFullNodeLifecycleActions()); - if (ui::material::TactileButton(TR("bootstrap_mirror"), ImVec2(mirrorW, btnH2))) { + if (ui::material::TactileButton(TR("bootstrap_mirror"), ImVec2(mirrorW, btnH2)) && !refuseIfNodeBusy()) { stopDaemonForBootstrap(); bootstrap_ = std::make_unique(); std::string dataDir = util::Platform::getDragonXDataDir(); diff --git a/src/daemon/daemon_controller.h b/src/daemon/daemon_controller.h index ea03c90..245cac1 100644 --- a/src/daemon/daemon_controller.h +++ b/src/daemon/daemon_controller.h @@ -63,17 +63,21 @@ public: class LifecycleRuntime { public: virtual ~LifecycleRuntime() = default; - virtual void stopDaemonWithPolicy() = 0; + // Stop the node and wait (bounded, never killing it) for it to exit. False: it is still running + // (or we stopped waiting), and nothing that needs it gone may run. + virtual bool stopDaemonWithPolicy() = 0; virtual bool startDaemon() = 0; virtual int deleteBlockchainData() = 0; virtual void resetOutputOffset() = 0; - virtual void requestRpcStopAndDisconnect(const char* context, const char* reason) = 0; + // Disconnect and stop the node off this thread; whoever needs it gone waits for the process to exit. + virtual void beginBackgroundStop(const char* reason) = 0; }; struct LifecycleExecutionResult { bool completed = false; bool cancelled = false; bool stopped = false; + bool stopFailed = false; // the node didn't exit: the operation was cancelled before changing anything bool started = false; int deletedItems = 0; }; @@ -194,22 +198,24 @@ public: switch (decision.operation) { case LifecycleOperation::BootstrapStop: + // The download takes minutes; the bootstrap worker waits for the process to exit before it + // touches chain data. if (decision.wasRunning) { - runtime.requestRpcStopAndDisconnect("Bootstrap daemon stop", "Bootstrap"); + runtime.beginBackgroundStop("Bootstrap"); result.stopped = true; } result.completed = true; return result; case LifecycleOperation::ManualRestart: - if (decision.wasRunning) { - runtime.stopDaemonWithPolicy(); - result.stopped = true; - } - break; case LifecycleOperation::Rescan: case LifecycleOperation::RepairWallet: case LifecycleOperation::DeleteBlockchainData: - runtime.stopDaemonWithPolicy(); + if (decision.operation == LifecycleOperation::ManualRestart && !decision.wasRunning) break; + // Never start a second node, or delete chain data, under one that hasn't exited. + if (!runtime.stopDaemonWithPolicy()) { + result.stopFailed = true; + return result; + } result.stopped = true; break; } @@ -255,6 +261,9 @@ public: bool abandonOnShutdown = false; // stop waiting once the app starts shutting down (background callers) double idleOfferSec = 600.0; // delivered + no CPU/I-O progress for this long: offer Kill }; + // A bounded stop that has no way to reach the node (a node we don't own refused our credentials, or our + // own did with every source and there is no signal) gives up after this long rather than its full budget. + static constexpr double kUndeliverableGraceSec = 5.0; // What the stop loop reports for display. struct StopStatus { @@ -342,6 +351,7 @@ public: StopUserChoice choice = StopUserChoice::None; bool killOffered = false; // the Kill offer is on screen right now bool shuttingDown = false; // the app began shutting down / the task was cancelled + bool undeliverable = false; // no stop delivered, none will be sent, and no signal to send }; static inline StopWaitDecision evaluateStopWait(const StopPolicy& p, const StopWaitSample& s) @@ -352,6 +362,8 @@ public: return StopWaitDecision::ForceKill; if (p.abandonOnShutdown && s.shuttingDown) return StopWaitDecision::GiveUp; if (p.maxWaitSec > 0 && s.elapsedSec >= p.maxWaitSec) return StopWaitDecision::GiveUp; + if (p.maxWaitSec > 0 && s.undeliverable && s.elapsedSec >= kUndeliverableGraceSec) + return StopWaitDecision::GiveUp; if (p.userChoices && p.owned && s.delivered && !s.rebuildActive && s.idleSec >= p.idleOfferSec) return StopWaitDecision::OfferForceKill; return StopWaitDecision::Wait; @@ -406,6 +418,8 @@ public: s.choice = rt.userChoice(); s.killOffered = offered; s.shuttingDown = task.cancelled() || task.shuttingDown(); + s.undeliverable = delivery.finished() && !delivery.delivered() && !signalled && + !(policy.gracefulSignal && policy.owned); switch (evaluateStopWait(policy, s)) { case StopWaitDecision::Done: diff --git a/src/daemon/embedded_daemon.cpp b/src/daemon/embedded_daemon.cpp index 47122e2..7639e7c 100644 --- a/src/daemon/embedded_daemon.cpp +++ b/src/daemon/embedded_daemon.cpp @@ -38,6 +38,7 @@ #include #include #ifdef __APPLE__ +#include #include #endif #endif @@ -1664,8 +1665,20 @@ bool EmbeddedDaemon::isDaemonProcessRunning() if (isDaemonProcessIdentity(comm, exe)) return true; } return false; +#elif defined(__APPLE__) + // Match executables named dragonxd. Not the RPC port: dragonxd frees it early in its shutdown, long + // before the process exits and lets go of wallet.dat and the datadir lock. + const int count = proc_listallpids(nullptr, 0); + if (count <= 0) return isPortInUse(std::atoi(DRAGONX_DEFAULT_RPC_PORT)); // can't list: best effort + std::vector pids(static_cast(count) + 64); // room for processes started meanwhile + const int n = proc_listallpids(pids.data(), static_cast(pids.size() * sizeof(pid_t))); + char path[PROC_PIDPATHINFO_MAXSIZE]; + for (int i = 0; i < n && i < static_cast(pids.size()); ++i) { + if (pids[i] <= 0 || proc_pidpath(pids[i], path, sizeof(path)) <= 0) continue; + if (isDaemonProcessIdentity("", fs::path(path).filename().string())) return true; + } + return false; #else - // macOS has no /proc; fall back to the RPC-port probe (best-effort). return isPortInUse(std::atoi(DRAGONX_DEFAULT_RPC_PORT)); #endif } diff --git a/src/services/wallet_security_workflow_executor.cpp b/src/services/wallet_security_workflow_executor.cpp index 80ce722..60e4fd5 100644 --- a/src/services/wallet_security_workflow_executor.cpp +++ b/src/services/wallet_security_workflow_executor.cpp @@ -64,8 +64,10 @@ WalletSecurityWorkflowExecutor::Outcome WalletSecurityWorkflowExecutor::restartD if (!waitForMs(preRestartDelayMs)) return {false, "", false}; if (daemon.isUsingEmbeddedDaemon()) { - daemon.stopEmbeddedDaemon(); + const bool stopped = daemon.stopEmbeddedDaemon(); if (daemon.cancelled() || daemon.shuttingDown()) return {false, "", false}; + // Never start a second node over one that hasn't exited (it holds wallet.dat and the datadir lock). + if (!stopped) return {false, "The node did not stop in time, so it was not restarted.", false}; if (!waitForMs(embeddedRestartSettleMs)) return {false, "", false}; daemon.startEmbeddedDaemon(); } diff --git a/src/services/wallet_security_workflow_executor.h b/src/services/wallet_security_workflow_executor.h index 559d61c..b767a85 100644 --- a/src/services/wallet_security_workflow_executor.h +++ b/src/services/wallet_security_workflow_executor.h @@ -49,7 +49,7 @@ public: public: virtual ~DaemonGateway() = default; virtual bool isUsingEmbeddedDaemon() const = 0; - virtual void stopEmbeddedDaemon() = 0; + virtual bool stopEmbeddedDaemon() = 0; // true: the node exited (false: still running; never killed) virtual bool startEmbeddedDaemon() = 0; virtual bool cancelled() const = 0; virtual bool shuttingDown() const = 0; diff --git a/src/util/bootstrap.cpp b/src/util/bootstrap.cpp index a8491c6..35ea463 100644 --- a/src/util/bootstrap.cpp +++ b/src/util/bootstrap.cpp @@ -162,11 +162,17 @@ void Bootstrap::start(const std::string& dataDir, const std::string& url) { worker_running_ = false; return; } - // Step 3: Ensure daemon is fully stopped before touching chain data + // Step 3: Ensure daemon is fully stopped before touching chain data. Gone means the PROCESS has + // exited, not just its RPC port: dragonxd unbinds RPC early in its shutdown and keeps its files + // (and the datadir lock) until it exits — deleting chain data under it corrupts it, or fails on + // Windows' locked files. { setProgress(State::Extracting, "Waiting for daemon to stop..."); + auto daemonUp = [] { + return daemon::EmbeddedDaemon::isRpcPortInUse() || daemon::EmbeddedDaemon::isDaemonProcessRunning(); + }; int waited = 0; - while (daemon::EmbeddedDaemon::isRpcPortInUse() && waited < 60 && !cancel_requested_) { + while (daemonUp() && waited < 120 && !cancel_requested_) { std::this_thread::sleep_for(std::chrono::seconds(1)); waited++; if (waited % 5 == 0) @@ -177,7 +183,7 @@ void Bootstrap::start(const std::string& dataDir, const std::string& url) { worker_running_ = false; return; } - if (daemon::EmbeddedDaemon::isRpcPortInUse()) { + if (daemonUp()) { setProgress(State::Failed, "Daemon is still running — stop it before using bootstrap"); worker_running_ = false; return; diff --git a/src/util/i18n.cpp b/src/util/i18n.cpp index 3dbeea2..c6b0d58 100644 --- a/src/util/i18n.cpp +++ b/src/util/i18n.cpp @@ -616,6 +616,8 @@ void I18n::loadBuiltinEnglish() strings_["stopconfirm_busy_body"] = "It has been too busy to answer the wallet for %s, probably because of a long operation such as a witness rebuild. Stopping it now can make its next start redo that work.\n\nRecommended: close the wallet and leave the node running. It finishes on its own."; strings_["stopconfirm_stop_wait"] = "Stop node & wait"; strings_["appx_daemon_reinstall_node_busy"] = "The node is still running, so the daemon was not reinstalled. Try again once it has stopped."; + strings_["appx_node_busy_try_later"] = "The node is busy finishing a long task (such as rebuilding note witnesses). Try again when it is done; stopping it now could make it start that work over."; + strings_["appx_node_not_stopped"] = "The node didn't stop in time, so nothing was changed. Try again in a few minutes."; strings_["reduce_motion"] = "Reduce Motion"; strings_["tt_reduce_motion"] = "Disable animated transitions and balance lerp for accessibility"; strings_["ago"] = "ago"; diff --git a/tests/test_phase4.cpp b/tests/test_phase4.cpp index 9eb783c..4ac7988 100644 --- a/tests/test_phase4.cpp +++ b/tests/test_phase4.cpp @@ -465,10 +465,14 @@ public: std::vector 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({"rpc-stop-disconnect"})); - EXPECT_EQ(bootstrapRuntime.rpcStopContext, std::string("Bootstrap daemon stop")); + EXPECT_TRUE(bootstrapRuntime.calls == std::vector({"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({"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({"start"})); + } + MockDaemonLifecycleRuntime cancelRuntime; MockDaemonLifecycleTask cancelTask; cancelTask.cancelAfterSleeps = 1;