fix(rpc): run rescans and key imports without the 30 s timeout

A rescan or key import holds the node's cs_main for the whole wallet
scan. On the shared clients it hit the 30 s curl timeout, reported a
failure that wasn't one, abandoned a daemon RPC slot and flipped the app
into "node busy".

- One long wallet RPC at a time (rescan, importprivkey, importaddress,
  importwallet, z_importkey, z_importviewingkey, z_importwallet) runs on
  its own ungated client with no client timeout (rpc/long_call_lane.h).
  It can be aborted from any thread, and an abort is never lost, even
  one that lands before the client opens.
- The GUI rescan, key import and sweep use it. The sweep's fund-moving
  body is unchanged; only the client it calls differs. runtimeRescan()
  returns bool, and a refused post-bootstrap reconcile stays armed.
- The console routes rescan and the import commands through the lane;
  its input stays usable meanwhile, and a refusal is printed like any
  other result. RPCClient gains callRaw(.., timeoutSec).
- Restart, wallet switch, delete-chain, bootstrap (now also from
  Settings) and the other node-stopping flows are refused while it runs,
  with a message saying a key import or rescan is still running; quit
  asks for confirmation. The lane is aborted at quit.
- A second import or rescan is refused before the key is copied anywhere.
  An import cut off after the request reached the node (a lost reply,
  or a proxy's 502/504 while the node still imports) says the key may
  already be in the wallet and to run Settings -> Rescan. Only the
  node's own HTTP-layer refusals (401, 503, work queue full, unhandled
  request) say that the key was not imported. A pure
  classifyLongCallCutOff() makes that choice.
- New strings in all 8 languages; console results stay English.

Tests: the long-method list and the console's routing; one lane at a
time; an abort lands whether it comes before the client opens or during
the call; a call with no client timeout outlasts a 3 s scan, where a 1 s
gated client times out and marks the node busy; classifyLongCallCutOff()
for every failure kind with and without a sent request, and from raw
HTTP replies (502, 504, a plain 500 -> cut off; 401, 503, queue full,
unhandled -> refused).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 13:22:00 -05:00
co-authored by Claude Opus 5.5
parent b3a57b07ad
commit 81733f5603
20 changed files with 776 additions and 25 deletions
+3
View File
@@ -723,6 +723,7 @@ set(APP_HEADERS
src/rpc/rpc_client.h
src/rpc/rpc_worker.h
src/rpc/connection.h
src/rpc/long_call_lane.h
src/rpc/types.h
src/config/settings.h
src/data/address_book.h
@@ -1267,6 +1268,8 @@ if(BUILD_TESTING)
src/daemon/lifecycle_adapters.cpp
src/daemon/embedded_daemon.cpp
src/rpc/connection.cpp
src/rpc/rpc_client.cpp
src/util/base64.cpp
src/config/settings.cpp
src/resources/embedded_resources.cpp
src/util/secure_vault.cpp
+8
View File
@@ -86,6 +86,9 @@
"appx_dragonxd_output": "dragonxd-Ausgabe",
"appx_encrypting_wallet": "Wallet wird verschlüsselt...",
"appx_fullnode_lifecycle_unavailable_lite": "Full-Node-Lebenszyklusaktionen sind im Lite-Build nicht verfügbar",
"appx_import_cancelled": "Abgebrochen: Der Node scannt möglicherweise noch. Wenn das Guthaben des Schlüssels nicht erscheint, führen Sie Einstellungen → Neu scannen aus.",
"appx_import_interrupted": "Der Import wurde unterbrochen, daher ist der Schlüssel möglicherweise schon in der Wallet. Wenn sein Guthaben nicht erscheint, führen Sie Einstellungen → Neu scannen aus.",
"appx_import_refused": "Der Node hat die Anfrage abgelehnt, daher wurde der Schlüssel nicht importiert.",
"appx_installing_bundled_daemon": "Mitgelieferter Daemon wird installiert — der Node stoppt, aktualisiert und startet neu...",
"appx_invalid_payment_uri_prefix": "Ungültige Zahlungs-URI: ",
"appx_ive_written_it_down": "Ich habe sie notiert",
@@ -134,6 +137,9 @@
"appx_use_settings_restart_daemon_hint": "Verwenden Sie Einstellungen > Daemon neu starten, um es erneut zu versuchen",
"appx_waiting_for_daemon_to_encrypt_wallet": "Warten, bis der Daemon das Wallet verschlüsselt...",
"appx_wallet_created_and_backed_up": "Wallet erstellt und gesichert.",
"appx_wallet_op_node_busy": "Der Node ist beschäftigt und antwortet nicht. Versuchen Sie den Import oder Neuscan erneut, wenn er fertig ist.",
"appx_wallet_op_running": "Ein Schlüsselimport oder Neuscan läuft noch. Versuchen Sie es erneut, wenn er abgeschlossen ist.",
"appx_wallet_op_shutting_down": "Die Wallet wird beendet.",
"appx_wallet_open_failed_prefix": "Öffnen des Wallets fehlgeschlagen: ",
"auto_shield": "Mining automatisch abschirmen",
"autoshield_by_node": "Ihr Node schützt für Sie gemäß Coinbase automatisch.",
@@ -1944,6 +1950,8 @@
"stopconfirm_rebuild_body": "Er läuft seit %s, und bei großen Wallets kann das etwa eine Stunde dauern. Wenn Sie den Node jetzt stoppen, wird entweder bis zum Ende des Neuaufbaus gewartet, oder der nächste Start muss den Neuaufbau samt Nachholen wiederholen.\n\nEmpfohlen: Wallet schließen und den Node weiterlaufen lassen. Er beendet die Arbeit selbstständig.",
"stopconfirm_rebuild_title": "Der Node baut die Note-Witnesses neu auf",
"stopconfirm_stop_wait": "Node stoppen & warten",
"stopconfirm_walletop_body": "Der Node durchsucht die Blockchain für einen Schlüsselimport oder Neuscan. Wenn Sie ihn jetzt stoppen, endet dieser Scan vorzeitig, und Guthaben, das er noch nicht erreicht hat, erscheint erst nach Einstellungen → Neu scannen.\n\nEmpfohlen: Wallet schließen und den Node weiterlaufen lassen. Er beendet den Scan selbstständig.",
"stopconfirm_walletop_title": "Ein Schlüsselimport oder Neuscan läuft",
"stratum_allowip_hint": "Miner von IP oder CIDR zulassen (leer = nur dieser Computer)",
"stratum_expose_warn": "Öffnet einen Mining-Port für das von Ihnen zugelassene Netzwerk — nur in einem vertrauenswürdigen LAN verwenden.",
"stratum_host": "Mining-Pool hosten (Stratum)",
+8
View File
@@ -86,6 +86,9 @@
"appx_dragonxd_output": "Salida de dragonxd",
"appx_encrypting_wallet": "Cifrando la cartera...",
"appx_fullnode_lifecycle_unavailable_lite": "Las acciones de ciclo de vida del nodo completo no están disponibles en la versión lite",
"appx_import_cancelled": "Cancelado: puede que el nodo siga escaneando. Si los fondos de la clave no aparecen, ejecuta Ajustes → Re-escanear.",
"appx_import_interrupted": "La importación se interrumpió, así que la clave puede que ya esté en la cartera. Si sus fondos no aparecen, ejecuta Ajustes → Re-escanear.",
"appx_import_refused": "El nodo rechazó la solicitud, así que la clave no se importó.",
"appx_installing_bundled_daemon": "Instalando el daemon incluido; el nodo se detendrá, se actualizará y se reiniciará...",
"appx_invalid_payment_uri_prefix": "URI de pago no válida: ",
"appx_ive_written_it_down": "Ya la anoté",
@@ -134,6 +137,9 @@
"appx_use_settings_restart_daemon_hint": "Usa Ajustes > Reiniciar daemon para intentarlo de nuevo",
"appx_waiting_for_daemon_to_encrypt_wallet": "Esperando a que el daemon cifre la cartera...",
"appx_wallet_created_and_backed_up": "Cartera creada y respaldada.",
"appx_wallet_op_node_busy": "El nodo está ocupado y no responde. Vuelve a intentar la importación o el reescaneo cuando termine.",
"appx_wallet_op_running": "Aún se está ejecutando una importación de clave o un reescaneo. Vuelve a intentarlo cuando termine.",
"appx_wallet_op_shutting_down": "La cartera se está cerrando.",
"appx_wallet_open_failed_prefix": "Error al abrir la cartera: ",
"auto_shield": "Auto-proteger minería",
"autoshield_by_node": "Tu nodo blinda automáticamente el coinbase minado por ti.",
@@ -1944,6 +1950,8 @@
"stopconfirm_rebuild_body": "Lleva %s en curso, y en carteras grandes puede tardar alrededor de una hora. Detener el nodo ahora o bien espera a que termine la reconstrucción, o bien hace que el próximo inicio la repita junto con cualquier puesta al día.\n\nRecomendado: cierra la cartera y deja el nodo en marcha. Terminará por su cuenta.",
"stopconfirm_rebuild_title": "El nodo está reconstruyendo los testigos de notas",
"stopconfirm_stop_wait": "Detener el nodo y esperar",
"stopconfirm_walletop_body": "El nodo está escaneando la cadena para una importación de clave o un reescaneo. Si lo detienes ahora, ese escaneo termina antes de tiempo, y los fondos a los que aún no ha llegado solo aparecerán tras Ajustes → Re-escanear.\n\nRecomendado: cierra la cartera y deja el nodo en marcha. Terminará el escaneo por su cuenta.",
"stopconfirm_walletop_title": "Hay una importación de clave o un reescaneo en curso",
"stratum_allowip_hint": "Permitir mineros desde IP o CIDR (en blanco = solo este equipo)",
"stratum_expose_warn": "Abre un puerto de minería a la red que permitas — úsalo solo en una LAN de confianza.",
"stratum_host": "Alojar un pool de minería (stratum)",
+8
View File
@@ -86,6 +86,9 @@
"appx_dragonxd_output": "Sortie de dragonxd",
"appx_encrypting_wallet": "Chiffrement du portefeuille...",
"appx_fullnode_lifecycle_unavailable_lite": "Les actions de cycle de vie du nœud complet ne sont pas disponibles dans la version allégée",
"appx_import_cancelled": "Annulé : le nœud est peut-être encore en train d'analyser. Si les fonds de la clé n'apparaissent pas, lancez Paramètres → Re-scanner.",
"appx_import_interrupted": "L'importation a été interrompue, la clé est donc peut-être déjà dans le portefeuille. Si ses fonds n'apparaissent pas, lancez Paramètres → Re-scanner.",
"appx_import_refused": "Le nœud a refusé la requête, la clé n'a donc pas été importée.",
"appx_installing_bundled_daemon": "Installation du démon intégré — le nœud va s'arrêter, se mettre à jour, puis redémarrer...",
"appx_invalid_payment_uri_prefix": "URI de paiement invalide : ",
"appx_ive_written_it_down": "Je les ai notés",
@@ -134,6 +137,9 @@
"appx_use_settings_restart_daemon_hint": "Utilisez Paramètres > Redémarrer le démon pour réessayer",
"appx_waiting_for_daemon_to_encrypt_wallet": "En attente du chiffrement du portefeuille par le démon...",
"appx_wallet_created_and_backed_up": "Portefeuille créé et sauvegardé.",
"appx_wallet_op_node_busy": "Le nœud est occupé et ne répond pas. Relancez l'importation ou la réanalyse une fois qu'il a fini.",
"appx_wallet_op_running": "Une importation de clé ou une réanalyse est encore en cours. Réessayez une fois qu'elle est finie.",
"appx_wallet_op_shutting_down": "Le portefeuille est en cours de fermeture.",
"appx_wallet_open_failed_prefix": "Échec de l'ouverture du portefeuille : ",
"auto_shield": "Auto-blindage du minage",
"autoshield_by_node": "Votre nœud blinde automatiquement les coinbase minées pour vous.",
@@ -1944,6 +1950,8 @@
"stopconfirm_rebuild_body": "Elle est en cours depuis %s, et pour un gros portefeuille elle peut prendre environ une heure. Arrêter le nœud maintenant attend la fin de la reconstruction, ou oblige son prochain démarrage à la refaire, rattrapage compris.\n\nRecommandé : fermez le portefeuille et laissez le nœud actif. Il terminera seul.",
"stopconfirm_rebuild_title": "Le nœud reconstruit les témoins de notes",
"stopconfirm_stop_wait": "Arrêter le nœud et attendre",
"stopconfirm_walletop_body": "Le nœud analyse la chaîne pour une importation de clé ou une réanalyse. L'arrêter maintenant met fin à cette analyse plus tôt, et les fonds qu'elle n'a pas encore atteints n'apparaîtront qu'après Paramètres → Re-scanner.\n\nRecommandé : fermez le portefeuille et laissez le nœud actif. Il terminera l'analyse seul.",
"stopconfirm_walletop_title": "Une importation de clé ou une réanalyse est en cours",
"stratum_allowip_hint": "Autoriser les mineurs depuis une IP ou un CIDR (vide = cet ordinateur uniquement)",
"stratum_expose_warn": "Ouvre un port de minage vers le réseau que vous autorisez — à utiliser uniquement sur un réseau local de confiance.",
"stratum_host": "Héberger un pool de minage (stratum)",
+8
View File
@@ -86,6 +86,9 @@
"appx_dragonxd_output": "dragonxd の出力",
"appx_encrypting_wallet": "ウォレットを暗号化しています...",
"appx_fullnode_lifecycle_unavailable_lite": "フルノードのライフサイクル操作はライトビルドでは利用できません",
"appx_import_cancelled": "キャンセルしました:ノードはまだスキャン中の可能性があります。鍵の資金が表示されない場合は、設定 → 再スキャン を実行してください。",
"appx_import_interrupted": "インポートが中断されたため、鍵はすでにウォレットにある可能性があります。資金が表示されない場合は、設定 → 再スキャン を実行してください。",
"appx_import_refused": "ノードが要求を拒否したため、鍵はインポートされませんでした。",
"appx_installing_bundled_daemon": "同梱デーモンをインストールしています。ノードが停止、更新、再起動されます...",
"appx_invalid_payment_uri_prefix": "無効な支払いURI: ",
"appx_ive_written_it_down": "書き留めました",
@@ -134,6 +137,9 @@
"appx_use_settings_restart_daemon_hint": "設定 > デーモンを再起動 から再試行してください",
"appx_waiting_for_daemon_to_encrypt_wallet": "デーモンによるウォレットの暗号化を待っています...",
"appx_wallet_created_and_backed_up": "ウォレットを作成し、バックアップしました。",
"appx_wallet_op_node_busy": "ノードは処理中で応答していません。完了してからインポートまたは再スキャンをもう一度お試しください。",
"appx_wallet_op_running": "鍵のインポートまたは再スキャンがまだ実行中です。完了してからもう一度お試しください。",
"appx_wallet_op_shutting_down": "ウォレットを終了しています。",
"appx_wallet_open_failed_prefix": "ウォレットを開けませんでした: ",
"auto_shield": "マイニング自動シールド",
"autoshield_by_node": "あなたのノードは、マイニングしたコインベースを自動的にシールドします。",
@@ -1944,6 +1950,8 @@
"stopconfirm_rebuild_body": "経過時間は %s で、大きなウォレットでは約 1 時間かかることがあります。今ノードを停止すると、再構築の完了まで待つか、次回の起動時に再構築とブロックの追いつきをやり直すことになります。\n\n推奨:ウォレットを閉じ、ノードは実行したままにしてください。ノードは自動的に完了します。",
"stopconfirm_rebuild_title": "ノードはノートウィットネスを再構築しています",
"stopconfirm_stop_wait": "ノードを停止して待つ",
"stopconfirm_walletop_body": "ノードは鍵のインポートまたは再スキャンのためにチェーンをスキャンしています。今停止するとスキャンが途中で終了し、まだスキャンされていない資金は 設定 → 再スキャン の後にのみ表示されます。\n\n推奨:ウォレットを閉じ、ノードは実行したままにしてください。ノードは自動的にスキャンを完了します。",
"stopconfirm_walletop_title": "鍵のインポートまたは再スキャンを実行中です",
"stratum_allowip_hint": "マイナーの接続を許可するIPまたはCIDR(空欄=このコンピューターのみ)",
"stratum_expose_warn": "許可したネットワークにマイニングポートを公開します — 信頼できるLANでのみ使用してください。",
"stratum_host": "マイニングプールをホストする(stratum)",
+8
View File
@@ -86,6 +86,9 @@
"appx_dragonxd_output": "dragonxd 출력",
"appx_encrypting_wallet": "지갑 암호화 중...",
"appx_fullnode_lifecycle_unavailable_lite": "풀노드 수명 주기 작업은 라이트 빌드에서 사용할 수 없습니다",
"appx_import_cancelled": "취소됨: 노드가 아직 스캔 중일 수 있습니다. 키의 자금이 보이지 않으면 설정 → 재스캔을 실행하세요.",
"appx_import_interrupted": "가져오기가 중단되어 키가 이미 지갑에 있을 수 있습니다. 자금이 보이지 않으면 설정 → 재스캔을 실행하세요.",
"appx_import_refused": "노드가 요청을 거부하여 키를 가져오지 않았습니다.",
"appx_installing_bundled_daemon": "번들 데몬을 설치하는 중 — 노드가 중지, 업데이트 후 다시 시작됩니다...",
"appx_invalid_payment_uri_prefix": "잘못된 결제 URI: ",
"appx_ive_written_it_down": "적어 두었습니다",
@@ -134,6 +137,9 @@
"appx_use_settings_restart_daemon_hint": "설정 > 데몬 다시 시작을 사용해 다시 시도하세요",
"appx_waiting_for_daemon_to_encrypt_wallet": "데몬이 지갑을 암호화하기를 기다리는 중...",
"appx_wallet_created_and_backed_up": "지갑이 생성되고 백업되었습니다.",
"appx_wallet_op_node_busy": "노드가 바빠서 응답하지 않습니다. 끝난 뒤 가져오기 또는 재스캔을 다시 시도하세요.",
"appx_wallet_op_running": "키 가져오기 또는 재스캔이 아직 실행 중입니다. 끝난 뒤 다시 시도하세요.",
"appx_wallet_op_shutting_down": "지갑을 종료하는 중입니다.",
"appx_wallet_open_failed_prefix": "지갑 열기 실패: ",
"auto_shield": "채굴 자동 차폐",
"autoshield_by_node": "노드가 채굴한 코인베이스를 자동으로 보호 처리합니다.",
@@ -1944,6 +1950,8 @@
"stopconfirm_rebuild_body": "%s 동안 진행 중이며, 큰 지갑은 약 1시간이 걸릴 수 있습니다. 지금 노드를 중지하면 재구성이 끝날 때까지 기다리거나, 다음 시작 시 재구성과 블록 따라잡기를 다시 해야 합니다.\n\n권장: 지갑을 닫고 노드는 계속 실행하세요. 노드가 스스로 마칩니다.",
"stopconfirm_rebuild_title": "노드가 노트 위트니스를 재구성하고 있습니다",
"stopconfirm_stop_wait": "노드 중지 후 대기",
"stopconfirm_walletop_body": "노드가 키 가져오기 또는 재스캔을 위해 체인을 스캔하고 있습니다. 지금 중지하면 스캔이 일찍 끝나고, 아직 스캔하지 못한 자금은 설정 → 재스캔 후에만 표시됩니다.\n\n권장: 지갑을 닫고 노드는 계속 실행하세요. 노드가 스스로 스캔을 마칩니다.",
"stopconfirm_walletop_title": "키 가져오기 또는 재스캔이 실행 중입니다",
"stratum_allowip_hint": "채굴자를 허용할 IP 또는 CIDR (비워두면 이 컴퓨터만 허용)",
"stratum_expose_warn": "허용한 네트워크로 채굴 포트를 엽니다 — 신뢰할 수 있는 LAN에서만 사용하세요.",
"stratum_host": "마이닝 풀 호스팅 (stratum)",
+8
View File
@@ -86,6 +86,9 @@
"appx_dragonxd_output": "saída do dragonxd",
"appx_encrypting_wallet": "Criptografando a carteira...",
"appx_fullnode_lifecycle_unavailable_lite": "As ações de ciclo de vida do nó completo não estão disponíveis na versão lite",
"appx_import_cancelled": "Cancelado: o nó ainda pode estar escaneando. Se os fundos da chave não aparecerem, execute Ajustes → Reescanear.",
"appx_import_interrupted": "A importação foi interrompida, então a chave pode já estar na carteira. Se os fundos dela não aparecerem, execute Ajustes → Reescanear.",
"appx_import_refused": "O nó recusou a solicitação, então a chave não foi importada.",
"appx_installing_bundled_daemon": "Instalando o daemon incluído — o nó vai parar, atualizar e reiniciar...",
"appx_invalid_payment_uri_prefix": "URI de pagamento inválida: ",
"appx_ive_written_it_down": "Já anotei",
@@ -134,6 +137,9 @@
"appx_use_settings_restart_daemon_hint": "Use Configurações > Reiniciar Daemon para tentar novamente",
"appx_waiting_for_daemon_to_encrypt_wallet": "Aguardando o daemon criptografar a carteira...",
"appx_wallet_created_and_backed_up": "Carteira criada e com backup feito.",
"appx_wallet_op_node_busy": "O nó está ocupado e não responde. Tente a importação ou o reescaneamento novamente quando terminar.",
"appx_wallet_op_running": "Ainda há uma importação de chave ou um reescaneamento em andamento. Tente novamente quando terminar.",
"appx_wallet_op_shutting_down": "A carteira está sendo encerrada.",
"appx_wallet_open_failed_prefix": "Falha ao abrir a carteira: ",
"auto_shield": "Auto-blindar mineração",
"autoshield_by_node": "Seu node blinda automaticamente o coinbase minerado para você.",
@@ -1944,6 +1950,8 @@
"stopconfirm_rebuild_body": "Está em andamento há %s, e em carteiras grandes pode levar cerca de uma hora. Parar o nó agora ou espera a reconstrução terminar, ou faz o próximo início refazê-la junto com qualquer recuperação.\n\nRecomendado: feche a carteira e deixe o nó em execução. Ele termina sozinho.",
"stopconfirm_rebuild_title": "O nó está reconstruindo as testemunhas de notas",
"stopconfirm_stop_wait": "Parar o nó e aguardar",
"stopconfirm_walletop_body": "O nó está escaneando a cadeia para uma importação de chave ou um reescaneamento. Pará-lo agora encerra esse escaneamento antes do tempo, e os fundos que ele ainda não alcançou só aparecerão depois de Ajustes → Reescanear.\n\nRecomendado: feche a carteira e deixe o nó em execução. Ele termina o escaneamento sozinho.",
"stopconfirm_walletop_title": "Uma importação de chave ou um reescaneamento está em andamento",
"stratum_allowip_hint": "Permitir mineradores a partir de IP ou CIDR (em branco = apenas este computador)",
"stratum_expose_warn": "Abre uma porta de mineração para a rede que você permitir — use apenas em uma LAN confiável.",
"stratum_host": "Hospedar um pool de mineração (stratum)",
+8
View File
@@ -86,6 +86,9 @@
"appx_dragonxd_output": "вывод dragonxd",
"appx_encrypting_wallet": "Шифрование кошелька...",
"appx_fullnode_lifecycle_unavailable_lite": "Действия жизненного цикла полного узла недоступны в lite-сборке",
"appx_import_cancelled": "Отменено: узел, возможно, ещё сканирует. Если средства ключа не появятся, запустите Настройки → Пересканировать.",
"appx_import_interrupted": "Импорт был прерван, поэтому ключ, возможно, уже в кошельке. Если его средства не появятся, запустите Настройки → Пересканировать.",
"appx_import_refused": "Узел отклонил запрос, поэтому ключ не был импортирован.",
"appx_installing_bundled_daemon": "Установка встроенного демона — узел остановится, обновится и перезапустится...",
"appx_invalid_payment_uri_prefix": "Неверный платёжный URI: ",
"appx_ive_written_it_down": "Я записал её",
@@ -134,6 +137,9 @@
"appx_use_settings_restart_daemon_hint": "Откройте Настройки > Перезапустить демон, чтобы повторить попытку",
"appx_waiting_for_daemon_to_encrypt_wallet": "Ожидание шифрования кошелька демоном...",
"appx_wallet_created_and_backed_up": "Кошелёк создан, резервная копия сохранена.",
"appx_wallet_op_node_busy": "Узел занят и не отвечает. Повторите импорт или повторное сканирование, когда он освободится.",
"appx_wallet_op_running": "Импорт ключа или повторное сканирование ещё выполняется. Повторите попытку, когда оно завершится.",
"appx_wallet_op_shutting_down": "Кошелёк завершает работу.",
"appx_wallet_open_failed_prefix": "Не удалось открыть кошелёк: ",
"auto_shield": "Авто-экранирование майнинга",
"autoshield_by_node": "Ваш узел автоматически защищает добытый coinbase за вас.",
@@ -1944,6 +1950,8 @@
"stopconfirm_rebuild_body": "Она выполняется уже %s, а для больших кошельков может занять около часа. Если остановить узел сейчас, он либо дождётся окончания перестройки, либо при следующем запуске выполнит её заново вместе с догонянием блоков.\n\nРекомендуется: закройте кошелёк и оставьте узел работать. Он завершит всё сам.",
"stopconfirm_rebuild_title": "Узел перестраивает свидетелей заметок",
"stopconfirm_stop_wait": "Остановить узел и подождать",
"stopconfirm_walletop_body": "Узел сканирует цепочку для импорта ключа или повторного сканирования. Если остановить его сейчас, это сканирование закончится раньше, а средства, до которых оно ещё не дошло, появятся только после Настройки → Пересканировать.\n\nРекомендуется: закройте кошелёк и оставьте узел работать. Он завершит сканирование сам.",
"stopconfirm_walletop_title": "Выполняется импорт ключа или повторное сканирование",
"stratum_allowip_hint": "Разрешить майнерам подключаться с IP или CIDR (пусто = только этот компьютер)",
"stratum_expose_warn": "Открывает порт для майнинга в разрешённую вами сеть — используйте только в доверенной локальной сети.",
"stratum_host": "Разместить майнинг-пул (stratum)",
+8
View File
@@ -86,6 +86,9 @@
"appx_dragonxd_output": "dragonxd 输出",
"appx_encrypting_wallet": "正在加密钱包…",
"appx_fullnode_lifecycle_unavailable_lite": "全节点生命周期操作在轻量版中不可用",
"appx_import_cancelled": "已取消:节点可能仍在扫描。如果该密钥的资金没有显示,请运行“设置 → 重新扫描”。",
"appx_import_interrupted": "导入被中断,该密钥可能已在钱包中。如果其资金没有显示,请运行“设置 → 重新扫描”。",
"appx_import_refused": "节点拒绝了请求,因此未导入该密钥。",
"appx_installing_bundled_daemon": "正在安装捆绑守护进程——节点将停止、更新并重启…",
"appx_invalid_payment_uri_prefix": "无效的支付 URI:",
"appx_ive_written_it_down": "我已抄写完毕",
@@ -134,6 +137,9 @@
"appx_use_settings_restart_daemon_hint": "请使用 设置 > 重启守护进程 重试",
"appx_waiting_for_daemon_to_encrypt_wallet": "正在等待守护进程加密钱包…",
"appx_wallet_created_and_backed_up": "钱包已创建并完成备份。",
"appx_wallet_op_node_busy": "节点正忙,暂时无法响应。请在完成后重新导入或重新扫描。",
"appx_wallet_op_running": "密钥导入或重新扫描仍在进行中。请在完成后重试。",
"appx_wallet_op_shutting_down": "钱包正在关闭。",
"appx_wallet_open_failed_prefix": "钱包打开失败:",
"auto_shield": "自动屏蔽挖矿",
"autoshield_by_node": "您的节点会自动为您屏蔽挖出的 coinbase。",
@@ -1944,6 +1950,8 @@
"stopconfirm_rebuild_body": "已运行 %s,大型钱包可能需要约一小时。现在停止节点,要么会等待重建完成,要么下次启动时需要重新进行重建以及区块追赶。\n\n建议:关闭钱包并保持节点运行,它会自行完成。",
"stopconfirm_rebuild_title": "节点正在重建票据见证",
"stopconfirm_stop_wait": "停止节点并等待",
"stopconfirm_walletop_body": "节点正在为密钥导入或重新扫描扫描区块链。现在停止它会提前结束这次扫描,尚未扫描到的资金只有在“设置 → 重新扫描”之后才会显示。\n\n建议:关闭钱包并保持节点运行,它会自行完成扫描。",
"stopconfirm_walletop_title": "密钥导入或重新扫描正在进行",
"stratum_allowip_hint": "允许来自指定 IP 或 CIDR 的矿工连接(留空 = 仅限本机)",
"stratum_expose_warn": "会向您允许的网络开放挖矿端口——仅在受信任的局域网中使用。",
"stratum_host": "托管矿池(stratum)",
+28 -7
View File
@@ -1513,7 +1513,7 @@ void App::update()
std::chrono::steady_clock::now() >= post_bootstrap_probe_retry_at_ &&
rpcConnected && state_.connected && !rpcBusy &&
!state_.warming_up && !state_.isLocked() && !state_.sync.rescanning &&
!runtime_rescan_active_ && !bootstrap_downloading_ &&
!runtime_rescan_active_ && !long_lane_.active() && !bootstrap_downloading_ &&
state_.sync.blocks > 1) { // wait until the tip is known so the probe has a real range
if (post_bootstrap_probe_attempts_ == 0)
ui::Notifications::instance().info(TR("appx_bootstrap_complete_reconciling"));
@@ -1527,7 +1527,12 @@ void App::update()
post_bootstrap_rescan_pending_ = false;
post_bootstrap_probe_attempts_ = 0;
if (!fullHistory) {
runtimeRescan(lowest); // bootstrapped/pruned: rescan from the snapshot base
// bootstrapped/pruned: rescan from the snapshot base. Refused (a key import or rescan is still
// running, the node is busy): keep the reconcile armed and try again in 30 s.
if (!runtimeRescan(lowest)) {
post_bootstrap_rescan_pending_ = true;
post_bootstrap_probe_retry_at_ = std::chrono::steady_clock::now() + std::chrono::seconds(30);
}
} else {
rescanBlockchain(); // full-history node: the -rescan restart works
}
@@ -6456,7 +6461,8 @@ void App::beginShutdown()
// with shutdown_confirmed_ set (and, for "keep node running", shutdown_keep_daemon_override_).
if (!shutdown_confirmed_ && shouldConfirmDaemonStop()) {
DEBUG_LOGF("beginShutdown: the node is %s — asking before stopping it\n",
node_rebuilding_ ? "rebuilding witnesses" : "too busy to answer RPC");
node_rebuilding_ ? "rebuilding witnesses"
: long_lane_.active() ? "running a key import or rescan" : "too busy to answer RPC");
pending_shutdown_confirm_ = true;
// The modal only renders in a visible frame: a quit from the taskbar while minimized (or behind
// other windows) would otherwise wait on a prompt nobody can see.
@@ -6490,6 +6496,7 @@ void App::beginShutdown()
if (rpc_) rpc_->requestAbort();
if (fast_rpc_) fast_rpc_->requestAbort();
abortRpcBusyCanary();
long_lane_.abort(); // a key import / rescan waiting with no client timeout; the node's own stop ends its scan
if (worker_) {
worker_->requestStop();
}
@@ -6811,12 +6818,19 @@ bool App::nodeBusyForStop() const
// external node's busyness shows only as RPC that has stopped answering.
daemon::DaemonBusyTracker ours;
if (busyTrackerIsLive()) ours = daemon_busy_;
return daemon::shouldConfirmStop(true, ours, rpcBusyWhileAlive());
// A key import or rescan on the long-call lane (C61) counts too: stopping the node would cut it off, and
// aborting the wallet's wait would not stop the node's scan, so the stop would wait for it anyway.
return daemon::shouldConfirmStop(true, ours, rpcBusyWhileAlive() || long_lane_.active());
}
bool App::refuseIfNodeBusy()
{
if (!nodeBusyForStop()) return false;
if (long_lane_.active()) {
DEBUG_LOGF("[App] refused: a key import or rescan is still running — stopping the node now would cut it off\n");
ui::Notifications::instance().warning(TR("appx_wallet_op_running"), 10.0f);
return true;
}
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);
@@ -6847,13 +6861,18 @@ void App::renderDaemonStopConfirm()
elapsed = formatBusyDuration(std::chrono::duration<double>(
std::chrono::steady_clock::now() - rpc_busy_since_).count());
const bool rebuilding = busy.rebuilding;
const bool walletOp = !rebuilding && long_lane_.active(); // a key import or rescan (C61)
if (Type().subtitle1()) ImGui::PushFont(Type().subtitle1());
ImGui::TextUnformatted(TR(rebuilding ? "stopconfirm_rebuild_title" : "stopconfirm_busy_title"));
ImGui::TextUnformatted(TR(rebuilding ? "stopconfirm_rebuild_title"
: walletOp ? "stopconfirm_walletop_title" : "stopconfirm_busy_title"));
if (Type().subtitle1()) ImGui::PopFont();
ImGui::Spacing();
char body[1024];
snprintf(body, sizeof(body), TR(rebuilding ? "stopconfirm_rebuild_body" : "stopconfirm_busy_body"),
elapsed.empty() ? "0:00" : elapsed.c_str());
if (walletOp)
snprintf(body, sizeof(body), "%s", TR("stopconfirm_walletop_body"));
else
snprintf(body, sizeof(body), TR(rebuilding ? "stopconfirm_rebuild_body" : "stopconfirm_busy_body"),
elapsed.empty() ? "0:00" : elapsed.c_str());
ImGui::PushTextWrapPos(ImGui::GetFontSize() * 26.0f);
ImGui::TextUnformatted(body);
ImGui::PopTextWrapPos();
@@ -7627,6 +7646,7 @@ void App::shutdown()
DEBUG_LOGF("Synchronous shutdown fallback...\n");
http_cancel_.store(true, std::memory_order_relaxed); // queued / in-flight market GETs end at once
async_tasks_.cancelAll();
long_lane_.abort(); // else joinAll() waits out a key import / rescan (no client timeout)
async_tasks_.joinAll();
if (rpc_) rpc_->requestAbort(); // unblock any in-flight curl before joining
if (fast_rpc_) fast_rpc_->requestAbort();
@@ -7659,6 +7679,7 @@ void App::shutdown()
if (shutdown_thread_.joinable()) {
shutdown_thread_.join();
}
long_lane_.abort(); // beginShutdown() already did; harmless again, and joinAll() must never wait out a scan
async_tasks_.joinAll();
// Join the RPC worker thread (was signaled in beginShutdown via requestStop)
if (worker_) {
+16 -1
View File
@@ -27,6 +27,7 @@
#include "daemon/stop_delivery.h"
#include "daemon/daemon_busy_detect.h"
#include "rpc/rpc_busy_gate.h"
#include "rpc/long_call_lane.h"
#include "services/history_scan_policy.h"
#include "services/connect_scan_gate.h"
#include "services/scan_policy.h"
@@ -249,6 +250,15 @@ public:
// 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();
// Long wallet RPCs (rescan, key imports; C61) run one at a time on long_lane_. Main thread. Decide a refusal
// BEFORE copying a key into a closure: true when one can't start now, with the reason in English in *why
// (the console's text) and as an i18n key in *trKey (the GUI's toast / status line).
bool longWalletOpRefused(std::string* why, const char** trKey = nullptr) const;
// Main thread. Runs body on the lane's own ungated client (no client timeout, aborted at quit) on an async
// task; body must catch everything and returns the continuation, which runs on the main thread once the lane
// is free again. false (body never runs) when the lane is already taken.
using LongWalletBody = std::function<std::function<void()>(rpc::RPCClient&)>;
bool runLongWalletTask(LongWalletBody body);
// 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);
@@ -651,7 +661,8 @@ public:
// Runtime rescanblockchain RPC starting at a snapshot-available height. Unlike the
// -rescan restart, this works on bootstrapped/pruned nodes (which lack pre-snapshot
// block data), reconciling the wallet's stale spent-state without a daemon restart.
void runtimeRescan(int startHeight);
// Runs on the long-call lane; false when it did not start (refused, not connected, one already running).
bool runtimeRescan(int startHeight);
// Probe for the lowest block height the node still has on disk, on worker_ (never on the UI thread).
// cb(ok, lowestHeight, fullHistory) runs on the UI thread: from worker_->drainResults(), or synchronously
// when there is nothing to probe. fullHistory==true when block 1 is readable (a normal, non-bootstrapped
@@ -1212,6 +1223,10 @@ private:
void startStartupFilesProvision();
void pollStartupFiles(); // UI thread, every update()
void renderStartupFilesChip(); // UI thread, top of render()
// The long wallet RPC lane (C61). Its task runs on async_tasks_, so it is declared BEFORE async_tasks_ and
// outlives the task threads. Aborted at quit (beginShutdown / shutdown); never to make way for another flow:
// those are refused while it is active (nodeBusyForStop), because an aborted client does not stop the scan.
rpc::LongCallLane long_lane_;
util::AsyncTaskManager async_tasks_;
bool pending_antivirus_dialog_ = false; // Show Windows Defender help dialog
+139 -17
View File
@@ -337,6 +337,57 @@ void App::pumpRpcBusyCanary()
});
}
bool App::longWalletOpRefused(std::string* why, const char** trKey) const
{
// The English texts equal the i18n source strings of the keys; the console shows them untranslated.
const char* key = nullptr;
const char* english = nullptr;
if (shutting_down_ || !worker_) {
key = "appx_wallet_op_shutting_down";
english = "The wallet is shutting down.";
} else if (long_lane_.active()) {
key = "appx_wallet_op_running";
english = "A key import or rescan is still running. Try again when it finishes.";
} else if (rpc_busy_gate_ && rpc_busy_gate_->busy()) {
key = "appx_wallet_op_node_busy";
english = "The node is busy and not answering. Try the import or rescan again when it finishes.";
}
if (!key) return false;
if (why) *why = english;
if (trKey) *trKey = key;
return true;
}
bool App::runLongWalletTask(LongWalletBody body)
{
if (!body || !long_lane_.tryBegin()) return false;
const rpc::ConnectionConfig cfg = (!saved_config_.rpcuser.empty() || !saved_config_.rpcpassword.empty())
? saved_config_ : rpc::Connection::autoDetectConfig(); // as the canary
if (shutting_down_) long_lane_.abort();
// One task name plus tryBegin()'s single flight: submit()'s join(name) only ever waits on the previous task's
// last few instructions (its continuation, which frees the lane, is posted at its very end).
async_tasks_.submit("Long wallet RPC", [this, cfg, body = std::move(body)](const util::AsyncTaskManager::Token& tok) mutable {
rpc::RPCClient client; // ungated on purpose: it never trips or waits on the busy gate
std::function<void()> cont;
if (tok.cancelled()) long_lane_.abort();
try {
long_lane_.run(client, cfg, [&](rpc::RPCClient& c) { cont = body(c); });
} catch (...) {
// The bodies catch everything themselves; this only keeps the lane from staying taken.
DEBUG_LOGF("[LongRPC] the call body threw\n");
}
body = nullptr; // destroy the closure (and its scrubbed key copy) on this thread
if (tok.cancelled() || !worker_) { long_lane_.end(); return; } // quitting: nobody to tell
worker_->post([this, cont = std::move(cont)]() -> rpc::RPCWorker::MainCb {
return [this, cont]() {
long_lane_.end();
if (cont) cont();
};
});
});
return true;
}
void App::tryConnect()
{
// Lite builds have no full node / RPC daemon, so never run the RPC connection state machine
@@ -5329,14 +5380,26 @@ void App::importPrivateKey(const std::string& rawKey, int startHeight,
"mistyped characters, and that this is a DragonX key (not another coin).", "");
return;
}
// One long wallet RPC at a time (C61): refuse before the key is copied into a closure.
{
std::string why;
const char* trKey = nullptr;
if (longWalletOpRefused(&why, &trKey)) {
DEBUG_LOGF("[App] key import refused: %s\n", why.c_str());
if (!key.empty()) sodium_memzero(&key[0], key.size());
if (callback) callback(false, TR(trKey), "");
return;
}
}
const bool viewing = services::WalletSecurityController::isViewingKey(key);
const bool shielded = services::WalletSecurityController::classifyPrivateKey(key)
== services::WalletSecurityController::KeyKind::Shielded;
// Run on the worker thread — import requests a full rescan (rescan=true), so the
// synchronous curl call can take many seconds; never block the UI thread on it.
worker_->post([this, key, viewing, shielded, startHeight, callback]() mutable -> rpc::RPCWorker::MainCb {
// Run on the long-call lane — import requests a full rescan (rescan=true), so the call can take
// minutes to hours: no client timeout, and never on the UI thread or a shared client.
const bool started = runLongWalletTask([this, key, viewing, shielded, startHeight, callback](rpc::RPCClient& c) mutable -> rpc::RPCWorker::MainCb {
std::string err, addr;
rpc::LongCallCutOff cutOff = rpc::LongCallCutOff::None; // C61: how a transport failure ended the call
try {
rpc::RPCClient::TraceScope trace("Settings / Import key");
std::string method;
@@ -5346,7 +5409,7 @@ void App::importPrivateKey(const std::string& rawKey, int startHeight,
else { method = "importprivkey"; params = {key, "", true}; }
// A start height (shielded RPCs only) rescans from that block instead of genesis.
if (startHeight > 0 && (viewing || shielded)) params.push_back(startHeight);
nlohmann::json r = rpc_->call(method, params);
nlohmann::json r = c.call(method, params, 0L); // 0 = no client timeout (the scan can take hours)
// Scrub the key out of the request params (the json holds its own copy of it).
if (params.is_array() && !params.empty() && params[0].is_string()) {
std::string& pk = params[0].get_ref<std::string&>();
@@ -5359,6 +5422,10 @@ void App::importPrivateKey(const std::string& rawKey, int startHeight,
addr = r.get<std::string>();
} catch (const std::exception& e) {
err = e.what();
// Cut off after the key may have reached the node: that is not a rejected key (D6 wording below).
// Only the node's own HTTP-layer refusals say the import never ran.
if (const auto* t = dynamic_cast<const rpc::RpcTransportError*>(&e))
cutOff = rpc::classifyLongCallCutOff(t->kind, t->requestSent);
} catch (...) {
// Non-std throw must not escape into the worker — the main-thread callback
// below would never run, leaving a stuck "Importing…" spinner.
@@ -5374,9 +5441,18 @@ void App::importPrivateKey(const std::string& rawKey, int startHeight,
err += " — check the key is for DragonX (not another coin or network) and has no missing "
"or altered characters.";
}
return [this, err, addr, callback]() {
return [this, err, addr, callback, cutOff]() {
if (!err.empty()) {
if (callback) callback(false, err, "");
// The node may still be scanning, and its z_importkey default ("whenkeyisnew") won't rescan a key
// it already has: say how to make the funds appear.
std::string msg = err;
switch (cutOff) {
case rpc::LongCallCutOff::Cancelled: msg = TR("appx_import_cancelled"); break;
case rpc::LongCallCutOff::Interrupted: msg = err + " — " + TR("appx_import_interrupted"); break;
case rpc::LongCallCutOff::Refused: msg = err + " — " + TR("appx_import_refused"); break;
case rpc::LongCallCutOff::None: break;
}
if (callback) callback(false, msg, "");
return;
}
invalidateAddressValidationCache();
@@ -5384,6 +5460,7 @@ void App::importPrivateKey(const std::string& rawKey, int startHeight,
if (callback) callback(true, "", addr);
};
});
if (!started && callback) callback(false, TR("appx_wallet_op_running"), "");
if (!key.empty()) sodium_memzero(&key[0], key.size()); // scrub the calling-frame copy
}
@@ -5413,6 +5490,18 @@ void App::sweepPrivateKey(const std::string& rawKey, int startHeight, int destMo
sweep_step_ = SweepStep::Error;
return;
}
// One long wallet RPC at a time (C61): refuse before Running and before the key is copied into a closure.
{
std::string why;
const char* trKey = nullptr;
if (longWalletOpRefused(&why, &trKey)) {
DEBUG_LOGF("[App] sweep refused: %s\n", why.c_str());
if (!key.empty()) sodium_memzero(&key[0], key.size());
sweep_step_ = SweepStep::Error;
sweep_status_ = TR(trKey);
return;
}
}
sweep_step_ = SweepStep::Running;
sweep_status_ = "Importing key & rescanning…";
@@ -5422,7 +5511,9 @@ void App::sweepPrivateKey(const std::string& rawKey, int startHeight, int destMo
const bool shielded = services::WalletSecurityController::classifyPrivateKey(key)
== services::WalletSecurityController::KeyKind::Shielded;
const double fee = DRAGONX_DEFAULT_FEE;
worker_->post([this, key, startHeight, destMode, destExisting, shielded, fee]() mutable -> rpc::RPCWorker::MainCb {
// The body below is today's worker body, unchanged except that it calls the lane's client c (the import
// with no client timeout) instead of rpc_: a fund-moving sequence, not split or reordered (C61 D3).
const bool started = runLongWalletTask([this, key, startHeight, destMode, destExisting, shielded, fee](rpc::RPCClient& c) mutable -> rpc::RPCWorker::MainCb {
std::string err, dest, sourceAddr, amountStr;
double amount = 0.0;
try {
@@ -5434,7 +5525,7 @@ void App::sweepPrivateKey(const std::string& rawKey, int startHeight, int destMo
bool preZok = false;
auto listZ = [&](std::vector<std::string>& out) {
out.clear();
auto la = rpc_->call("z_listaddresses");
auto la = c.call("z_listaddresses");
if (la.is_array()) for (auto& a : la) if (a.is_string()) out.push_back(a.get<std::string>());
};
if (shielded) { try { listZ(preZ); preZok = true; } catch (...) { preZok = false; } }
@@ -5445,7 +5536,7 @@ void App::sweepPrivateKey(const std::string& rawKey, int startHeight, int destMo
if (shielded) { method = "z_importkey"; params = {key, "yes"}; }
else { method = "importprivkey"; params = {key, "", true}; }
if (startHeight > 0 && shielded) params.push_back(startHeight);
nlohmann::json r = rpc_->call(method, params);
nlohmann::json r = c.call(method, params, 0L);
// Scrub the key out of the request params (the json holds its own copy of it).
if (params.is_array() && !params.empty() && params[0].is_string()) {
std::string& pk = params[0].get_ref<std::string&>();
@@ -5476,7 +5567,7 @@ void App::sweepPrivateKey(const std::string& rawKey, int startHeight, int destMo
// 3. Confirm the key holds spendable (confirmed) funds. z_getbalance covers t- and z-addrs.
auto balAt = [&](int minconf) -> double {
try {
nlohmann::json b = rpc_->call("z_getbalance", {sourceAddr, minconf});
nlohmann::json b = c.call("z_getbalance", {sourceAddr, minconf});
return b.is_number() ? b.get<double>()
: b.is_string() ? std::stod(b.get<std::string>()) : 0.0;
} catch (...) { return 0.0; }
@@ -5488,7 +5579,7 @@ void App::sweepPrivateKey(const std::string& rawKey, int startHeight, int destMo
if (bal <= fee) throw std::runtime_error("__DUST__");
// 4. Resolve the destination — a fresh shielded address by default, else the picked one.
if (destMode == 0) dest = rpc_->call("z_getnewaddress").get<std::string>();
if (destMode == 0) dest = c.call("z_getnewaddress").get<std::string>();
else dest = destExisting;
if (dest.empty()) throw std::runtime_error("No destination address for the sweep.");
@@ -5547,6 +5638,10 @@ void App::sweepPrivateKey(const std::string& rawKey, int startHeight, int destMo
});
};
});
if (!started) {
sweep_step_ = SweepStep::Error;
sweep_status_ = TR("appx_wallet_op_running");
}
if (!key.empty()) sodium_memzero(&key[0], key.size()); // scrub the calling-frame copy
}
@@ -7091,18 +7186,28 @@ void App::detectLowestAvailableBlockHeight(std::function<void(bool, int, bool)>
});
}
void App::runtimeRescan(int startHeight)
bool App::runtimeRescan(int startHeight)
{
if (!supportsFullNodeLifecycleActions()) {
ui::Notifications::instance().warning("Full-node lifecycle actions are unavailable in lite build");
return;
return false;
}
if (!state_.connected || !rpc_ || !rpc_->isConnected() || !worker_) {
ui::Notifications::instance().warning("Not connected to daemon");
return;
return false;
}
if (runtime_rescan_active_) return;
if (runtime_rescan_active_) return false;
if (startHeight < 0) startHeight = 0;
// One long wallet RPC at a time (C61).
{
std::string why;
const char* trKey = nullptr;
if (longWalletOpRefused(&why, &trKey)) {
DEBUG_LOGF("[App] runtime rescan refused: %s\n", why.c_str());
ui::Notifications::instance().warning(TR(trKey));
return false;
}
}
DEBUG_LOGF("[App] Starting runtime rescanblockchain from height %d\n", startHeight);
@@ -7110,6 +7215,11 @@ void App::runtimeRescan(int startHeight)
// completion via this RPC's callback; rescan_confirmed_active_ keeps the -rescan-style pollers
// from misreading state, and runtime_rescan_active_ suppresses the per-second pollers that
// would otherwise pile up behind the held lock.
const bool prevRescanning = state_.sync.rescanning;
const bool prevConfirmed = rescan_confirmed_active_;
const bool prevUserInitiated = user_initiated_rescan_.load();
const float prevProgress = state_.sync.rescan_progress;
const std::string prevStatus = state_.sync.rescan_status;
runtime_rescan_active_ = true;
state_.sync.rescanning = true;
rescan_confirmed_active_ = true;
@@ -7123,12 +7233,12 @@ void App::runtimeRescan(int startHeight)
ui::Notifications::instance().info("Rescanning blockchain from block " + std::to_string(startHeight) +
" — this can take a while.");
worker_->post([this, startHeight]() -> rpc::RPCWorker::MainCb {
const bool started = runLongWalletTask([this, startHeight](rpc::RPCClient& c) -> rpc::RPCWorker::MainCb {
bool ok = false;
std::string err;
try {
rpc::RPCClient::TraceScope trace("Settings / Runtime rescan");
rpc_->call("rescan", {startHeight}); // hush "rescan <height>"; blocks until the scan finishes
c.call("rescan", {startHeight}, 0L); // hush "rescan <height>"; blocks until the scan finishes (no client timeout)
ok = true;
} catch (const std::exception& e) {
err = e.what();
@@ -7150,6 +7260,18 @@ void App::runtimeRescan(int startHeight)
}
};
});
if (!started) {
// The lane was taken after all: undo the state set up above.
runtime_rescan_active_ = false;
state_.sync.rescanning = prevRescanning;
rescan_confirmed_active_ = prevConfirmed;
user_initiated_rescan_ = prevUserInitiated;
state_.sync.rescan_progress = prevProgress;
state_.sync.rescan_status = prevStatus;
ui::Notifications::instance().warning(TR("appx_wallet_op_running"));
return false;
}
return true;
}
} // namespace dragonx
+2
View File
@@ -281,6 +281,8 @@ const char* App::fullSweepBlockedReason() const
return "the seed-phrase backup dialog is open";
if (bootstrap_ || bootstrap_downloading_)
return "a bootstrap is in progress";
if (long_lane_.active() || import_in_progress_ || runtime_rescan_active_)
return "a key import or rescan is in progress";
return nullptr;
}
+113
View File
@@ -0,0 +1,113 @@
// DragonX Wallet - ImGui Edition
// Copyright 2024-2026 The Hush Developers
// Released under the GPLv3
#pragma once
// Long wallet RPCs (C61). A rescan or a key import holds the daemon's cs_main for as long as the wallet scan
// takes: minutes to hours on a big wallet. On the shared clients they used to hit the 30 s curl timeout, report
// a failure that wasn't one and flip the app into "node busy" (a reply timeout is recorded by the busy gate).
// They now run one at a time on a dedicated, ungated client with no client timeout, abortable from any thread.
#include "connection.h" // ConnectionConfig
#include "rpc_client.h"
#include <atomic>
#include <mutex>
#include <string>
namespace dragonx {
namespace rpc {
// The wallet RPCs that scan the chain inside the call: they run on the long-call lane, never on the shared
// clients (rpc_ / the console's fast lane), whose 30 s timeout they outlast.
inline bool isLongWalletMethod(const std::string& method)
{
return method == "rescan" || method == "importprivkey" || method == "importaddress" ||
method == "importwallet" || method == "z_importkey" || method == "z_importviewingkey" ||
method == "z_importwallet";
}
// How a long wallet call that threw an RpcTransportError ended, which picks the wording shown to the user.
enum class LongCallCutOff {
None, // the request never reached the node: its error is the whole story
Cancelled, // our own abort cut the transfer; the node may still be scanning
Interrupted, // the request was sent but no answer came back (a dropped connection, or a proxy's 502/504
// while the node still runs it): the key may already be in the wallet
Refused, // the node's HTTP layer turned it away before the method ran (401, 503, queue full,
// unhandled), so nothing was imported
};
inline LongCallCutOff classifyLongCallCutOff(RpcFailureKind kind, bool requestSent)
{
switch (kind) {
case RpcFailureKind::Aborted:
return LongCallCutOff::Cancelled;
case RpcFailureKind::Auth:
case RpcFailureKind::ShuttingDown:
case RpcFailureKind::QueueFull:
case RpcFailureKind::Unhandled:
return LongCallCutOff::Refused;
default:
// Any other HTTP status (a proxy's 502/504) or a lost reply proves nothing about the node's side.
return requestSent ? LongCallCutOff::Interrupted : LongCallCutOff::None;
}
}
// One long wallet RPC at a time on its own ungated client, with no client timeout, abortable from any thread.
class LongCallLane {
public:
// Main thread: claim the lane. false when a long call is already running. A new call starts un-aborted.
bool tryBegin()
{
bool idle = false;
if (!active_.compare_exchange_strong(idle, true)) return false;
abort_ = false;
return true;
}
// The call and its continuation are done: the lane is free again.
void end() { active_ = false; }
bool active() const { return active_.load(); }
bool aborted() const { return abort_.load(); }
// Any thread. Sticky until the next tryBegin(): a call that has not opened its client yet is aborted as soon
// as it does, and an in-flight transfer is cut through the client's progress callback (about a second).
void abort()
{
std::lock_guard<std::mutex> lk(mx_);
abort_ = true;
if (client_) client_->requestAbort();
}
// Lane thread. open() FIRST (it clears the client's abort flag), THEN publish the client, THEN re-check the
// sticky flag under the same mutex, so an abort that landed before or during open() is never erased.
// An open() failure is not handled here: the body's first call then throws "Not connected".
template <class Body>
void run(RPCClient& c, const ConnectionConfig& cfg, Body&& body)
{
c.open(cfg.host, cfg.port, cfg.rpcuser, cfg.rpcpassword, cfg.use_tls);
{
std::lock_guard<std::mutex> lk(mx_);
client_ = &c;
if (abort_) c.requestAbort();
}
struct Unpublish {
LongCallLane& lane;
~Unpublish()
{
std::lock_guard<std::mutex> lk(lane.mx_);
lane.client_ = nullptr;
}
} unpublish{*this};
body(c);
}
private:
std::mutex mx_;
RPCClient* client_ = nullptr; // guarded by mx_; set only while run() executes the body
std::atomic<bool> abort_{false};
std::atomic<bool> active_{false};
};
} // namespace rpc
} // namespace dragonx
+22
View File
@@ -585,6 +585,28 @@ std::string RPCClient::callRaw(const std::string& method, const json& params)
return out;
}
std::string RPCClient::callRaw(const std::string& method, const json& params, long timeoutSec)
{
std::lock_guard<std::recursive_mutex> lk(curl_mutex_);
if (!impl_->curl) {
throw std::runtime_error("Not connected");
}
// Temporarily override the timeout, as call(method, params, timeoutSec) does
long prevTimeout = 30L;
curl_easy_setopt(impl_->curl, CURLOPT_TIMEOUT, timeoutSec);
try {
std::string out = callRaw(method, params);
curl_easy_setopt(impl_->curl, CURLOPT_TIMEOUT, prevTimeout);
return out;
} catch (...) {
// Ensure timeout is always restored
curl_easy_setopt(impl_->curl, CURLOPT_TIMEOUT, prevTimeout);
throw;
}
}
void RPCClient::doRPC(const std::string& method, const json& params, Callback cb, ErrorCallback err)
{
try {
+6
View File
@@ -184,6 +184,12 @@ public:
*/
std::string callRaw(const std::string& method, const json& params = json::array());
/**
* @brief callRaw() with a custom timeout, restored to the default afterwards (like the 3-arg call())
* @param timeoutSec Timeout in seconds (0 = no timeout)
*/
std::string callRaw(const std::string& method, const json& params, long timeoutSec);
// High-level API methods - mirror the Qt version
// Info methods
@@ -299,6 +299,8 @@ private:
// ---- Shared: kick off download ----
static void startDownload(const std::string& url) {
if (!s_app || !s_app->supportsFullNodeLifecycleActions()) return;
// It stops the node first: not while it is busy or running a key import / rescan (as the wizard's bootstrap).
if (s_app->refuseIfNodeBusy()) return;
s_wasDaemonRunning = s_app->stopDaemonForBootstrap();
s_app->setBootstrapDownloading(true);
s_bootstrap = std::make_unique<util::Bootstrap>();
@@ -12,6 +12,7 @@
#include "../../data/wallet_state.h"
#include "../../rpc/rpc_client.h"
#include "../../rpc/rpc_worker.h"
#include "../../rpc/long_call_lane.h" // isLongWalletMethod
#include "../../daemon/embedded_daemon.h"
#include "../../daemon/xmrig_manager.h"
#include "../../wallet/lite_wallet_controller.h"
@@ -20,6 +21,7 @@
#include "../material/colors.h"
#include <nlohmann/json.hpp>
#include <sodium.h>
#include <algorithm>
#include <cstdio>
@@ -34,6 +36,19 @@ using namespace material;
// executors drain the same LiteDiagnostics ring, so declare it up front for the full-node drain.
namespace { ConsoleChannel liteLogChannel(const std::string& line); }
namespace {
// Zero every string in a console call's params: a key import's carry the key.
void scrubParamStrings(nlohmann::json& j)
{
if (j.is_string()) {
std::string& s = j.get_ref<std::string&>();
if (!s.empty()) sodium_memzero(&s[0], s.size());
} else if (j.is_structured()) {
for (auto& el : j) scrubParamStrings(el);
}
}
} // namespace
// ============================================================================
// FullNodeConsoleExecutor
// ============================================================================
@@ -61,6 +76,44 @@ void FullNodeConsoleExecutor::submit(const std::string& cmd)
}
return;
}
// Rescans and key imports scan the chain inside the call (C61): run them on the app's long-call lane (no
// client timeout, one at a time) instead of this lane's 30 s client. in_flight_ is left alone, so the input
// stays usable while one runs; the result (or the refusal, in English like every console result) is
// pushed as usual.
if (rpc::isLongWalletMethod(call.method)) {
std::string why;
bool refused = app_->longWalletOpRefused(&why);
if (!refused) {
refused = !app_->runLongWalletTask(
[this, method = call.method, params = call.params](rpc::RPCClient& c) mutable -> rpc::RPCWorker::MainCb {
std::string result_str;
bool is_error = false;
try {
rpc::RPCClient::TraceScope trace("Console tab / Long wallet command");
result_str = c.callRaw(method, params, 0L); // 0 = no client timeout
} catch (const std::exception& e) {
result_str = e.what();
is_error = true;
} catch (...) {
result_str = "RPC failed (unknown error)";
is_error = true;
}
scrubParamStrings(params);
return [this, result_str, is_error]() {
if (app_) app_->noteWalletMutation();
std::lock_guard<std::mutex> lk(results_mutex_);
results_.push_back({result_str, is_error});
};
});
if (refused) why = "A key import or rescan is still running. Try again when it finishes.";
}
scrubParamStrings(call.params);
if (refused) {
std::lock_guard<std::mutex> lk(results_mutex_);
results_.push_back({why, true});
}
return;
}
const std::string method = call.method;
const nlohmann::json params = call.params;
+9
View File
@@ -635,6 +635,15 @@ void I18n::loadBuiltinEnglish()
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.";
// Long wallet RPCs: one key import or rescan at a time, refusals and the quit prompt (C61)
strings_["appx_wallet_op_running"] = "A key import or rescan is still running. Try again when it finishes.";
strings_["appx_wallet_op_shutting_down"] = "The wallet is shutting down.";
strings_["appx_wallet_op_node_busy"] = "The node is busy and not answering. Try the import or rescan again when it finishes.";
strings_["appx_import_cancelled"] = "Cancelled: the node may still be scanning. If the key's funds don't appear, run Settings → Rescan.";
strings_["appx_import_interrupted"] = "The import was cut off, so the key may already be in the wallet. If its funds don't appear, run Settings → Rescan.";
strings_["appx_import_refused"] = "The node refused the request, so the key was not imported.";
strings_["stopconfirm_walletop_title"] = "A key import or rescan is running";
strings_["stopconfirm_walletop_body"] = "The node is scanning the chain for a key import or rescan. Stopping it now ends that scan early, and funds it has not reached yet appear only after Settings → Rescan.\n\nRecommended: close the wallet and leave the node running. It finishes the scan on its own.";
strings_["reduce_motion"] = "Reduce Motion";
strings_["tt_reduce_motion"] = "Disable animated transitions and balance lerp for accessibility";
strings_["ago"] = "ago";
+319
View File
@@ -4,8 +4,13 @@
#endif
#include <windows.h>
#else
#include <arpa/inet.h>
#include <netinet/in.h>
#include <poll.h>
#include <signal.h>
#include <sys/socket.h>
#include <sys/wait.h>
#include <unistd.h>
#endif
#include "chat/chat_crypto.h"
#include "chat/chat_identity.h"
@@ -29,6 +34,7 @@
#include "rpc/connection.h"
#include "rpc/rpc_failure.h"
#include "rpc/rpc_busy_gate.h"
#include "rpc/long_call_lane.h"
#include "resources/embedded_resources.h"
#include "resources/provision_state.h"
#include "services/history_scan_policy.h"
@@ -5458,6 +5464,312 @@ void testRpcBusyGate()
EXPECT_TRUE(hour.abandoned() <= 2);
}
// ---------------------------------------------------------------------------------------------------
// C61: long wallet RPCs (rescan, key imports) run one at a time on their own ungated client with no client
// timeout, and an abort is never lost, whenever it lands.
// ---------------------------------------------------------------------------------------------------
void testIsLongWalletMethod()
{
using dragonx::rpc::isLongWalletMethod;
for (const char* m : {"rescan", "importprivkey", "importaddress", "importwallet", "z_importkey",
"z_importviewingkey", "z_importwallet"})
EXPECT_TRUE(isLongWalletMethod(m));
for (const char* m : {"getinfo", "z_sendmany", "z_exportkey", "dumpprivkey", "getrescaninfo", "stop", ""})
EXPECT_FALSE(isLongWalletMethod(m));
// The console routes a typed rescan / import through the lane.
const auto rescan = dragonx::ui::BuildConsoleRpcCall("rescan 100");
EXPECT_TRUE(rescan.valid);
EXPECT_TRUE(isLongWalletMethod(rescan.method));
EXPECT_TRUE(isLongWalletMethod(dragonx::ui::BuildConsoleRpcCall("z_importkey \"secret-extended-key-main1x\" yes 5").method));
EXPECT_FALSE(isLongWalletMethod(dragonx::ui::BuildConsoleRpcCall("getinfo").method));
// One at a time; a new call starts un-aborted.
dragonx::rpc::LongCallLane lane;
EXPECT_FALSE(lane.active());
lane.abort(); // nothing running: sticky until the next tryBegin()
EXPECT_TRUE(lane.aborted());
EXPECT_TRUE(lane.tryBegin());
EXPECT_FALSE(lane.aborted());
EXPECT_TRUE(lane.active());
EXPECT_FALSE(lane.tryBegin());
lane.end();
EXPECT_FALSE(lane.active());
EXPECT_TRUE(lane.tryBegin());
lane.end();
}
// The wording of a key import that ended in a transport error: only the node's own HTTP-layer refusals say
// nothing was imported; a sent request with any other ending (a proxy's 502/504 while the node still runs
// the import, a dropped reply) says the key may be in and to run Rescan.
void testLongCallCutOff()
{
using dragonx::rpc::LongCallCutOff;
using dragonx::rpc::RpcFailureKind;
using dragonx::rpc::classifyLongCallCutOff;
const RpcFailureKind all[] = {
RpcFailureKind::None, RpcFailureKind::NotListening, RpcFailureKind::ConnectTimeout,
RpcFailureKind::ReplyTimeout, RpcFailureKind::QueueFull, RpcFailureKind::ShuttingDown,
RpcFailureKind::Unhandled, RpcFailureKind::Auth, RpcFailureKind::Warmup,
RpcFailureKind::AppError, RpcFailureKind::Aborted, RpcFailureKind::Deferred,
RpcFailureKind::Other,
};
for (RpcFailureKind k : all) {
for (bool sent : {false, true}) {
LongCallCutOff want;
if (k == RpcFailureKind::Aborted)
want = LongCallCutOff::Cancelled;
else if (k == RpcFailureKind::Auth || k == RpcFailureKind::ShuttingDown ||
k == RpcFailureKind::QueueFull || k == RpcFailureKind::Unhandled)
want = LongCallCutOff::Refused;
else
want = sent ? LongCallCutOff::Interrupted : LongCallCutOff::None;
const LongCallCutOff got = classifyLongCallCutOff(k, sent);
EXPECT_TRUE(got == want || (std::cerr << " classifyLongCallCutOff(" << dragonx::rpc::rpcFailureKindName(k)
<< ", sent=" << sent << ") = " << (int)got << "\n", false));
}
}
// End to end from the HTTP reply, as the import sees it.
using dragonx::rpc::classifyRpcFailure;
const auto viaHttp = [](long http, const std::string& body) {
return classifyLongCallCutOff(classifyRpcFailure(CURLE_OK, true, http, 0, body), true);
};
EXPECT_TRUE(viaHttp(502, "<html>Bad Gateway</html>") == LongCallCutOff::Interrupted);
EXPECT_TRUE(viaHttp(504, "Gateway Timeout") == LongCallCutOff::Interrupted);
EXPECT_TRUE(viaHttp(500, "Internal Server Error") == LongCallCutOff::Interrupted);
EXPECT_TRUE(viaHttp(401, "") == LongCallCutOff::Refused);
EXPECT_TRUE(viaHttp(503, "") == LongCallCutOff::Refused);
EXPECT_TRUE(viaHttp(500, "Work queue depth exceeded") == LongCallCutOff::Refused);
EXPECT_TRUE(viaHttp(500, "Unhandled request") == LongCallCutOff::Refused);
EXPECT_TRUE(classifyLongCallCutOff(classifyRpcFailure(CURLE_OPERATION_TIMEDOUT, true, 0, 0, ""), true) ==
LongCallCutOff::Interrupted);
EXPECT_TRUE(classifyLongCallCutOff(classifyRpcFailure(CURLE_COULDNT_CONNECT, false, 0, 0, ""), false) ==
LongCallCutOff::None);
EXPECT_TRUE(classifyLongCallCutOff(classifyRpcFailure(CURLE_ABORTED_BY_CALLBACK, true, 0, 0, ""), true) ==
LongCallCutOff::Cancelled);
}
#ifndef _WIN32
// A loopback JSON-RPC stub: answers each request delayMs after reading it (never, when delayMs < 0) with
// {"result":null,"error":null,"id":"x"}. One connection at a time; requests() counts those it has read.
class LoopbackRpcStub {
public:
explicit LoopbackRpcStub(int delayMs) : delay_ms_(delayMs)
{
fd_ = ::socket(AF_INET, SOCK_STREAM, 0);
sockaddr_in a{};
a.sin_family = AF_INET;
a.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
socklen_t len = sizeof(a);
if (fd_ < 0 || ::bind(fd_, reinterpret_cast<sockaddr*>(&a), sizeof(a)) != 0 || ::listen(fd_, 4) != 0 ||
::getsockname(fd_, reinterpret_cast<sockaddr*>(&a), &len) != 0)
return;
port_ = ntohs(a.sin_port);
thread_ = std::thread([this] { serve(); });
}
~LoopbackRpcStub()
{
stop_ = true;
if (thread_.joinable()) thread_.join();
if (fd_ >= 0) ::close(fd_);
}
int port() const { return port_; }
int requests() const { return requests_.load(); }
private:
void serve()
{
while (!stop_) {
pollfd lp{fd_, POLLIN, 0};
if (::poll(&lp, 1, 20) <= 0) continue;
const int c = ::accept(fd_, nullptr, nullptr);
if (c < 0) continue;
std::string req; // headers, then Content-Length bytes of body
for (;;) {
pollfd p{c, POLLIN, 0};
if (stop_ || ::poll(&p, 1, 20) < 0) break;
if (!(p.revents & POLLIN)) continue;
char buf[2048];
const ssize_t n = ::recv(c, buf, sizeof(buf), 0);
if (n <= 0) break;
req.append(buf, static_cast<size_t>(n));
const size_t h = req.find("\r\n\r\n");
const size_t cl = req.find("Content-Length:");
if (h != std::string::npos && cl != std::string::npos &&
req.size() >= h + 4 + std::strtoul(req.c_str() + cl + 15, nullptr, 10))
break;
}
++requests_;
const auto until = std::chrono::steady_clock::now() + std::chrono::milliseconds(std::max(delay_ms_, 0));
while (!stop_ && (delay_ms_ < 0 || std::chrono::steady_clock::now() < until))
std::this_thread::sleep_for(std::chrono::milliseconds(10));
if (!stop_) {
const std::string body = "{\"result\":null,\"error\":null,\"id\":\"x\"}";
const std::string resp = "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: " +
std::to_string(body.size()) + "\r\nConnection: close\r\n\r\n" + body;
::send(c, resp.data(), resp.size(), MSG_NOSIGNAL); // the client may have given up: no SIGPIPE
}
::close(c);
}
}
int delay_ms_;
int fd_ = -1;
int port_ = 0;
std::atomic<bool> stop_{false};
std::atomic<int> requests_{0};
std::thread thread_;
};
dragonx::rpc::ConnectionConfig loopbackStubConfig(const LoopbackRpcStub& stub)
{
dragonx::rpc::ConnectionConfig cfg;
cfg.host = "127.0.0.1";
cfg.port = std::to_string(stub.port());
cfg.rpcuser = "user";
cfg.rpcpassword = "pass";
return cfg;
}
// lane.run() + a no-timeout "rescan" on a thread; returns whether it finished within `seconds` and how the call
// ended. A call still blocked after that (an abort was lost) is cut through the client directly so the test
// can finish, and counts as a failure.
struct LaneCallResult {
bool finishedInTime = false;
dragonx::rpc::RpcFailureKind kind = dragonx::rpc::RpcFailureKind::Other;
double seconds = 0.0;
};
LaneCallResult runLaneCall(dragonx::rpc::LongCallLane& lane, const dragonx::rpc::ConnectionConfig& cfg,
const std::function<void()>& whileRunning, double seconds)
{
using namespace dragonx::rpc;
RPCClient client;
std::atomic<bool> done{false};
std::atomic<int> kind{static_cast<int>(RpcFailureKind::Other)};
const auto t0 = std::chrono::steady_clock::now();
std::thread t([&] {
lane.run(client, cfg, [&](RPCClient& c) {
try {
c.call("rescan", json::array({0}), 0L);
kind = static_cast<int>(RpcFailureKind::None);
} catch (const std::exception& e) {
kind = static_cast<int>(rpcFailureKindOf(e));
}
});
done = true;
});
if (whileRunning) whileRunning();
LaneCallResult r;
r.finishedInTime = waitUntilTrue([&] { return done.load(); }, seconds);
r.seconds = secondsSince(t0);
if (!r.finishedInTime) client.requestAbort(); // rescue a lost abort so the test ends
t.join();
r.kind = static_cast<RpcFailureKind>(kind.load());
return r;
}
void testLongCallLaneAbortBeforeOpen()
{
using namespace dragonx::rpc;
LoopbackRpcStub stub(-1); // never answers
EXPECT_TRUE(stub.port() > 0);
LongCallLane lane;
EXPECT_TRUE(lane.tryBegin());
lane.abort(); // e.g. quit right after the task was submitted: open() must not erase it
const LaneCallResult r = runLaneCall(lane, loopbackStubConfig(stub), nullptr, 3.0);
EXPECT_TRUE(r.finishedInTime);
EXPECT_TRUE(r.kind == RpcFailureKind::Aborted);
lane.end();
EXPECT_FALSE(lane.active());
}
void testLongCallLaneAbortDuringCall()
{
using namespace dragonx::rpc;
LoopbackRpcStub stub(-1); // never answers
LongCallLane lane;
EXPECT_TRUE(lane.tryBegin());
double abortAt = -1.0;
const auto t0 = std::chrono::steady_clock::now();
const LaneCallResult r = runLaneCall(lane, loopbackStubConfig(stub), [&] {
// The request is in flight (the stub has read it), then the abort lands from this thread.
EXPECT_TRUE(waitUntilTrue([&] { return stub.requests() >= 1; }, 3.0));
std::this_thread::sleep_for(std::chrono::milliseconds(300));
abortAt = secondsSince(t0);
lane.abort();
}, 6.0);
EXPECT_TRUE(r.finishedInTime);
EXPECT_TRUE(r.kind == RpcFailureKind::Aborted);
EXPECT_TRUE(abortAt >= 0.0 && r.seconds - abortAt < 3.0); // returns within 3 s of the abort
lane.end();
}
void testLongCallNoTimeout()
{
using namespace dragonx::rpc;
{
LoopbackRpcStub stub(3000); // a "scan" that takes 3 s
const ConnectionConfig cfg = loopbackStubConfig(stub);
RPCClient lane; // ungated, like the lane's client
EXPECT_TRUE(lane.open(cfg.host, cfg.port, cfg.rpcuser, cfg.rpcpassword, false));
const auto t0 = std::chrono::steady_clock::now();
bool ok = false;
try {
ok = lane.call("rescan", json::array({0}), 0L).is_null(); // 0 = no client timeout
} catch (const std::exception& e) {
std::cerr << "no-timeout call threw: " << e.what() << "\n";
}
EXPECT_TRUE(ok);
EXPECT_TRUE(secondsSince(t0) >= 2.5);
// Control: a 1 s client timeout gives up on the same call (ReplyTimeout), and on a client that shares the
// busy gate it flips the gate to busy and abandons a daemon slot. Why the lane has no gate and no timeout.
auto gate = std::make_shared<RpcBusyGate>();
RPCClient gated;
gated.setBusyGate(gate);
EXPECT_TRUE(gated.open(cfg.host, cfg.port, cfg.rpcuser, cfg.rpcpassword, false));
RpcFailureKind kind = RpcFailureKind::None;
try {
gated.call("rescan", json::array({0}), 1L);
} catch (const std::exception& e) {
kind = rpcFailureKindOf(e);
}
EXPECT_TRUE(kind == RpcFailureKind::ReplyTimeout);
EXPECT_TRUE(gate->busy());
EXPECT_EQ(gate->abandoned(), 1);
}
{
// The console's callRaw(.., timeoutSec) override, and both restore the 30 s default afterwards.
LoopbackRpcStub stub(1500);
const ConnectionConfig cfg = loopbackStubConfig(stub);
RPCClient c;
EXPECT_TRUE(c.open(cfg.host, cfg.port, cfg.rpcuser, cfg.rpcpassword, false));
std::string raw;
try {
raw = c.callRaw("rescan", json::array({0}), 0L);
} catch (const std::exception& e) {
raw = std::string("threw: ") + e.what();
}
EXPECT_EQ(raw, std::string("null"));
RpcFailureKind kind = RpcFailureKind::None;
try {
c.callRaw("rescan", json::array({0}), 1L);
} catch (const std::exception& e) {
kind = rpcFailureKindOf(e);
}
EXPECT_TRUE(kind == RpcFailureKind::ReplyTimeout);
bool ok = false;
try {
ok = c.call("getinfo").is_null(); // the default timeout again (30 s), not the 1 s override
} catch (const std::exception& e) {
std::cerr << "default-timeout call threw: " << e.what() << "\n";
}
EXPECT_TRUE(ok);
}
}
#endif // !_WIN32
void testRpcKindClasses()
{
using namespace dragonx::rpc;
@@ -16566,6 +16878,13 @@ int main()
testRefreshScheduler();
testRefreshSchedulerDeferFor();
testRpcBusyGate();
testIsLongWalletMethod();
testLongCallCutOff();
#ifndef _WIN32
testLongCallLaneAbortBeforeOpen();
testLongCallLaneAbortDuringCall();
testLongCallNoTimeout();
#endif
testRpcKindClasses();
testConnectRetryBackoff();
testFetchBackoff();