Compare commits
24
Commits
v2.0.1
...
a3361020d1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a3361020d1 | ||
|
|
e20b70d73f | ||
|
|
1215ded1a0 | ||
|
|
0f25c67600 | ||
|
|
0c821333c8 | ||
|
|
98fa2154b4 | ||
|
|
6b14098bb2 | ||
|
|
821bebd13e | ||
|
|
5993488176 | ||
|
|
b1d5806f9e | ||
|
|
a091bbc52f | ||
|
|
d8175b7dc1 | ||
|
|
2c04b88e19 | ||
|
|
bade958849 | ||
|
|
9339bb0663 | ||
|
|
7b6e8a8a8e | ||
|
|
46189da0da | ||
|
|
bc6c1de7d6 | ||
|
|
ec88dac459 | ||
|
|
d058e5fc2a | ||
|
|
86b9d2e00d | ||
|
|
0056c46929 | ||
|
|
1151141271 | ||
|
|
fae8b20fb7 |
+43
-3
@@ -15,7 +15,7 @@ if(APPLE)
|
||||
endif()
|
||||
|
||||
project(ObsidianDragon
|
||||
VERSION 2.0.1
|
||||
VERSION 2.0.2
|
||||
LANGUAGES C CXX
|
||||
DESCRIPTION "DragonX Cryptocurrency Wallet"
|
||||
)
|
||||
@@ -26,7 +26,7 @@ set(DRAGONX_VERSION_SUFFIX "")
|
||||
# ObsidianDragonLite is versioned INDEPENDENTLY of the full-node app above. The active variant's
|
||||
# version flows to the generated header, the Windows .rc/manifest, and build.sh's release names via
|
||||
# DRAGONX_APP_VERSION* (resolved in the lite/full block below).
|
||||
set(DRAGONX_LITE_VERSION "1.1.0")
|
||||
set(DRAGONX_LITE_VERSION "1.1.1")
|
||||
set(DRAGONX_LITE_VERSION_SUFFIX "")
|
||||
|
||||
# C++17 standard
|
||||
@@ -45,6 +45,7 @@ option(DRAGONX_ENABLE_EMBEDDED_DAEMON "Enable embedded dragonxd support" ON)
|
||||
option(DRAGONX_BUILD_LITE "Build ObsidianDragonLite variant without full-node features" OFF)
|
||||
option(DRAGONX_ENABLE_LITE_BACKEND "Enable real lite wallet backend integration" OFF)
|
||||
option(DRAGONX_ENABLE_CHAT "Enable the HushChat protocol/UI integration" ON)
|
||||
option(DRAGONX_BUILD_FAKE_DAEMON "Build the fake slow dragonxd test stub (tools/fake_dragonxd); never shipped" OFF)
|
||||
set(DRAGONX_LITE_BACKEND_LIBRARY "" CACHE FILEPATH "Path to a prebuilt SDXL-compatible lite backend library")
|
||||
set(DRAGONX_LITE_BACKEND_INCLUDE_DIR "" CACHE PATH "Optional include directory for SDXL-compatible lite backend headers")
|
||||
set(DRAGONX_LITE_BACKEND_EXTRA_LIBS "" CACHE STRING "Additional libraries needed by the SDXL-compatible lite backend")
|
||||
@@ -154,6 +155,11 @@ if(DRAGONX_ENABLE_LITE_BACKEND)
|
||||
set(DRAGONX_LITE_BACKEND_READY ON)
|
||||
endif()
|
||||
|
||||
# Windows (mingw cross) builds skip the large test target unless asked for with -DBUILD_TESTING=ON
|
||||
# (scripts/test-windows.sh does that in its own build/windows-tests tree), so release builds are unchanged.
|
||||
if(WIN32 AND NOT DEFINED BUILD_TESTING)
|
||||
set(BUILD_TESTING OFF CACHE BOOL "Build the testing tree.")
|
||||
endif()
|
||||
include(CTest)
|
||||
|
||||
# Output directories
|
||||
@@ -265,7 +271,11 @@ if(WIN32)
|
||||
set(BUILD_CURL_EXE OFF CACHE BOOL "" FORCE)
|
||||
set(BUILD_SHARED_LIBS OFF CACHE BOOL "" FORCE)
|
||||
set(CURL_STATICLIB ON CACHE BOOL "" FORCE)
|
||||
set(BUILD_TESTING OFF CACHE BOOL "" FORCE)
|
||||
# Keep curl's own tests out, without switching off ObsidianDragonTests for every Windows build:
|
||||
# hide BUILD_TESTING behind a normal variable while curl configures, then restore our cache value
|
||||
# (curl's cmake_dependent_option(BUILD_TESTING ...) FORCE-writes the OFF it saw into the cache).
|
||||
set(_dragonx_build_testing "${BUILD_TESTING}")
|
||||
set(BUILD_TESTING OFF)
|
||||
set(CURL_DISABLE_LDAP ON CACHE BOOL "" FORCE)
|
||||
set(CURL_DISABLE_LDAPS ON CACHE BOOL "" FORCE)
|
||||
set(CURL_USE_SCHANNEL ON CACHE BOOL "" FORCE) # Use Windows native SSL
|
||||
@@ -274,6 +284,8 @@ if(WIN32)
|
||||
set(CURL_ZLIB OFF CACHE BOOL "" FORCE)
|
||||
set(HTTP_ONLY ON CACHE BOOL "" FORCE)
|
||||
FetchContent_MakeAvailable(curl)
|
||||
unset(BUILD_TESTING)
|
||||
set(BUILD_TESTING "${_dragonx_build_testing}" CACHE BOOL "Build the testing tree." FORCE)
|
||||
set(CURL_LIBRARIES libcurl_static)
|
||||
set(CURL_INCLUDE_DIRS ${curl_SOURCE_DIR}/include ${curl_BINARY_DIR}/lib/curl)
|
||||
else()
|
||||
@@ -1105,6 +1117,27 @@ else()
|
||||
message(STATUS "wallet-rebuild helper: OFF (no Berkeley DB found; set DRAGONX_BDB_ROOT for release builds)")
|
||||
endif()
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# fake_dragonxd — a deliberately slow, scriptable dragonxd stand-in that reproduces the daemon-stop
|
||||
# incident (usage and the incident it models: tools/fake_dragonxd/README.md).
|
||||
# Test tooling only: OFF by default, never packaged, and written to <build>/fake_dragonxd/ so its
|
||||
# "dragonxd" name can't shadow the real bin/dragonxd.
|
||||
# -----------------------------------------------------------------------------
|
||||
if(DRAGONX_BUILD_FAKE_DAEMON)
|
||||
add_executable(fake_dragonxd tools/fake_dragonxd/fake_dragonxd.cpp)
|
||||
target_link_libraries(fake_dragonxd PRIVATE nlohmann_json::nlohmann_json)
|
||||
if(WIN32)
|
||||
target_link_libraries(fake_dragonxd PRIVATE ws2_32)
|
||||
else()
|
||||
find_package(Threads REQUIRED)
|
||||
target_link_libraries(fake_dragonxd PRIVATE Threads::Threads)
|
||||
endif()
|
||||
set_target_properties(fake_dragonxd PROPERTIES
|
||||
OUTPUT_NAME dragonxd
|
||||
RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/fake_dragonxd)
|
||||
message(STATUS "fake_dragonxd test stub: ON (${CMAKE_BINARY_DIR}/fake_dragonxd/)")
|
||||
endif()
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Tests
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -1211,6 +1244,13 @@ if(BUILD_TESTING)
|
||||
if(UNIX)
|
||||
target_link_libraries(ObsidianDragonTests PRIVATE ${CMAKE_DL_LIBS})
|
||||
endif()
|
||||
if(WIN32)
|
||||
target_link_libraries(ObsidianDragonTests PRIVATE ws2_32 crypt32 wldap32 psapi iphlpapi shell32 ole32 advapi32 bcrypt)
|
||||
endif()
|
||||
# i18n.cpp includes the generated embedded/lang_*.h headers; a tree that builds only this target needs them too.
|
||||
if(TARGET copy_langs)
|
||||
add_dependencies(ObsidianDragonTests copy_langs)
|
||||
endif()
|
||||
|
||||
add_test(NAME ObsidianDragonPhase4Tests COMMAND ObsidianDragonTests)
|
||||
endif()
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
**DragonX (DRGX) Lite Desktop Wallet — v1.1.1**
|
||||
|
||||
ObsidianDragonLite is the lightweight, node-free companion to ObsidianDragon — it connects to a DragonX lite-wallet server for near-instant startup, with the same native ImGui interface and shielded-first workflow. **v1.1.1 is a maintenance release** centered on an important wallet-encryption fix.
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Security fix — please read
|
||||
|
||||
**Creating or restoring a Lite wallet with a passphrase now actually encrypts it.**
|
||||
|
||||
In prior versions, the create / restore / open flow used by the Settings UI **silently discarded the passphrase you entered**:
|
||||
|
||||
- A **newly created or restored** wallet was saved to disk **unencrypted** — the seed and spending keys were stored in the clear even though you set a passphrase.
|
||||
- **Opening** an already-encrypted wallet did not unlock it with the supplied passphrase (it opened locked and re-prompted).
|
||||
|
||||
v1.1.1 fixes this: create/restore now **encrypt** the wallet with your passphrase (the backend encrypts and locks it), and open now **unlocks** it.
|
||||
|
||||
**What you should do:** if you created or restored a Lite wallet **with a passphrase** on an earlier version, treat that wallet's on-disk data as unencrypted. Open it in v1.1.1 and re-apply encryption from **Settings → encrypt** (and, if you're concerned the plaintext wallet file may have been exposed, move the funds to a freshly created encrypted wallet). Wallets you never set a passphrase on are unaffected.
|
||||
|
||||
---
|
||||
|
||||
## Also fixed
|
||||
|
||||
- **Chinese, Japanese, and Korean FAQ text renders correctly.** Several Help/FAQ titles showed `??` instead of characters (e.g. 什么, 门) after the previous translation back-fill; the bundled CJK font subset now includes those glyphs.
|
||||
|
||||
---
|
||||
|
||||
## Downloads
|
||||
|
||||
macOS builds are x86-64 (Intel; run on Apple Silicon via Rosetta 2).
|
||||
|
||||
| File | SHA-256 |
|
||||
|------|---------|
|
||||
| ObsidianDragonLite-1.1.1.AppImage | `323ff5f8658a5550aa12678ce4be37e32a2bfd3f3b0543074cc86a4267895dcf` |
|
||||
| ObsidianDragonLite-1.1.1-Linux-x64.zip | `e8604e479aed7e020946df45bf0d9373401354907e10565011694efa9978e631` |
|
||||
| ObsidianDragonLite-1.1.1.exe | `4cdcf291fe43c4fed04258aa55bb4e9b591f52780f9db5fb8f8bef522c43a351` |
|
||||
| ObsidianDragonLite-1.1.1-Windows-x64.zip | `c387bfcda508f32195c41fabec9f23d8b094d693a5dba41fbf8ec4abab6f2f6e` |
|
||||
| ObsidianDragonLite-1.1.1-macOS-x86_64.dmg | `bb0c491cb8f90ae55d162ec7c04c8f30988fa2f79c7911471220cddab44f99a5` |
|
||||
| ObsidianDragonLite-1.1.1-macOS-x86_64.app.zip | `a2a75fe40bf1f4db3abed8206dcc40d9fc74d8db2df7704afc9c834106b1c247` |
|
||||
|
||||
---
|
||||
|
||||
For the full feature set (node-free operation, wallet lifecycle, shielded + transparent send/shield/receive, HushChat, server browser with failover, 8-language UI), see the **lite-v1.0.0** release notes — v1.1.1 changes only the items listed above.
|
||||
@@ -0,0 +1,41 @@
|
||||
**DragonX (DRGX) Full-Node Desktop Wallet — v2.0.2**
|
||||
|
||||
ObsidianDragon is a native ImGui-based full-node wallet for the DragonX network, with an embedded `dragonxd` daemon and the integrated DRG-XMRig CPU miner. **v2.0.2 is a maintenance release** — a focused set of transaction-history, sync-safety, and correctness fixes on top of v2.0.x. No configuration changes are required; just replace the binary.
|
||||
|
||||
---
|
||||
|
||||
## What's Fixed in v2.0.2
|
||||
|
||||
### Transaction history
|
||||
|
||||
- **Outgoing shielded sends now appear in History.** Sends *from* a shielded (z-)address — both **z→z** (shielded-to-shielded) and **z→t** (deshield to a transparent address) — were missing from the History tab, because the daemon's `listtransactions` doesn't report them and `z_viewtransaction` doesn't expose the transparent leg. The wallet now enumerates them via `z_listsentbyaddress`, so **your History matches your balance.** (Your funds were never affected — only the display.)
|
||||
- **More accurate send rows** — a single transaction with two equal-value outputs to different recipients is no longer collapsed into one row, and a transparent self-send / change output is no longer double-counted as both sent and received.
|
||||
|
||||
### Sync & fund safety
|
||||
|
||||
- **A node that loses all its peers is no longer reported as "synced."** Previously, a wallet whose node dropped to **0 peers** could keep showing a stale balance as if it were current — and allow a Send against it. The wallet now treats a node with no peer-derived chain tip as *not synced*, and a new **"Connected, but no network peers — the node can't sync"** banner explains the stall instead of silently looking healthy.
|
||||
- **Migrate-to-seed hardening.** If a migrate-to-seed was interrupted (app or daemon restart) *after* the sweep transaction had broadcast but *before* it confirmed, the wallet could mistake the temporarily-zero balance for an empty wallet and adopt the new seed wallet — swapping `wallet.dat` — before the sweep was mined. It now records that a sweep is in flight and **will not swap your wallet until the sweep confirms**, so funds can't be stranded in the timestamped backup.
|
||||
- **Fixed a rare crash** caused by a data race on the daemon-status text (the daemon-monitor thread writing it while the UI thread read it).
|
||||
|
||||
### Display / internationalization
|
||||
|
||||
- **Chinese, Japanese, and Korean FAQ text renders correctly.** Several Help/FAQ titles showed `??` instead of characters (e.g. 什么, 门) after the previous translation back-fill; the bundled CJK font subset now includes those glyphs.
|
||||
|
||||
---
|
||||
|
||||
## Downloads
|
||||
|
||||
macOS builds are x86-64 (Intel; run on Apple Silicon via Rosetta 2).
|
||||
|
||||
| File | SHA-256 |
|
||||
|------|---------|
|
||||
| ObsidianDragon-2.0.2.AppImage | `9bf7328965c6348113ba7a30b0284f15787c635af17efae3dfa8a87e56e10c9b` |
|
||||
| ObsidianDragon-2.0.2-Linux-x64.zip | `1371880c4187a94d4cb56d535471142c5f1a8053ef73fffd99d59e7b78c75c42` |
|
||||
| ObsidianDragon-2.0.2.exe | `673347d2696fb6f5bd09dc25e415d5ae8e532abde75b8d48c3cd26545461de50` |
|
||||
| ObsidianDragon-2.0.2-Windows-x64.zip | `f5ea7c45e66c6c1b90a8127f96dee04235691acb913ddc86327f2060798cd28c` |
|
||||
| ObsidianDragon-2.0.2-macOS-x86_64.dmg | `7038580807a8e93e5443bdc04f2aede3ae1c30e5bc9f8776a207edb2e87771d1` |
|
||||
| ObsidianDragon-2.0.2-macOS-x86_64.app.zip | `78159512ca684b95528231fd46f2d07b5bcc6f2da4d815fc1ecb2ff52127f41c` |
|
||||
|
||||
---
|
||||
|
||||
For the full feature set (HushChat messaging, seed-phrase wallets & migrate-to-seed, in-app node/miner updaters, built-in mining, 8-language UI), see the **v2.0.0** release notes — v2.0.2 changes only the fixes listed above.
|
||||
Binary file not shown.
+33
-10
@@ -78,6 +78,7 @@
|
||||
"appx_creating_your_wallet": "Ihr Wallet wird erstellt…",
|
||||
"appx_daemon_error": "Daemon-Fehler",
|
||||
"appx_daemon_reinstall_in_progress": "Die Daemon-Neuinstallation läuft bereits.",
|
||||
"appx_daemon_reinstall_node_busy": "Der Node läuft noch, daher wurde der Daemon nicht neu installiert. Versuchen Sie es erneut, sobald er beendet ist.",
|
||||
"appx_disconnecting": "Verbindung wird getrennt...",
|
||||
"appx_done": "Fertig",
|
||||
"appx_dragonxd_output": "dragonxd-Ausgabe",
|
||||
@@ -99,7 +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_rebuilding_witness_cache": "Node baut seinen Witness-Cache neu auf",
|
||||
"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.",
|
||||
@@ -120,11 +122,7 @@
|
||||
"appx_simple_background_enabled": "Einfacher Hintergrund aktiviert",
|
||||
"appx_skip": "Überspringen",
|
||||
"appx_skip_anyway": "Trotzdem überspringen",
|
||||
"appx_still_status_prefix": "Weiterhin \"",
|
||||
"appx_still_status_suffix": "\" — ein erzwungenes Beenden jetzt kann die Chain-Daten beschädigen.",
|
||||
"appx_stop_anyway_and_quit": "Trotzdem stoppen & beenden",
|
||||
"appx_stopping_daemon_deleting_blockchain": "Daemon wird gestoppt und Blockchain-Daten werden gelöscht...",
|
||||
"appx_stopping_node_discards_rebuild": "Wenn Sie den Node jetzt stoppen, wird der laufende Neuaufbau verworfen und beim nächsten Öffnen des Wallets neu gestartet (mehrere Minuten). Sie können den Node stattdessen weiterlaufen lassen.",
|
||||
"appx_stopping_pool_miner": "Pool-Miner wird gestoppt...",
|
||||
"appx_syncing_pct_block_n_of_n": "Synchronisierung %.1f%% — Block %d / %d",
|
||||
"appx_tap_words_in_order": "Tippen Sie die Wörter in der richtigen Reihenfolge an, um zu bestätigen, dass Sie sie gespeichert haben.",
|
||||
@@ -867,11 +865,6 @@
|
||||
"file_save_location": "Datei wird gespeichert in: ~/.config/ObsidianDragon/",
|
||||
"filter": "Filtern...",
|
||||
"font_scale": "Schriftgröße",
|
||||
"force_quit": "Sofort beenden",
|
||||
"force_quit_confirm_msg": "Dies wird den Daemon sofort beenden ohne sauberes Herunterfahren.\nDies kann den Blockchain-Index beschädigen und eine Neusynchronisierung erfordern.",
|
||||
"force_quit_confirm_title": "Sofort beenden?",
|
||||
"force_quit_warning": "Dies wird den Daemon sofort beenden ohne sauberes Herunterfahren. Kann eine Neusynchronisierung der Blockchain erfordern.",
|
||||
"force_quit_yes": "Sofort beenden",
|
||||
"from": "Von",
|
||||
"from_upper": "VON",
|
||||
"full_details": "Alle Details",
|
||||
@@ -1358,7 +1351,19 @@
|
||||
"node_banner_offline_title": "Nicht mit dem DragonX-Node verbunden",
|
||||
"node_banner_reconnect": "Erneut verbinden",
|
||||
"node_banner_restart": "Node neu starten",
|
||||
"node_busy_rebuild_desc": "Läuft seit %s. Bei großen Wallets kann das etwa eine Stunde dauern, und der Node kann der Wallet erst danach antworten.",
|
||||
"node_busy_rebuild_extending": "Witnesses werden erweitert (der Node meldet für diesen Schritt keinen Fortschritt).",
|
||||
"node_busy_rebuild_reading": "Blöcke werden gelesen: %ld / %ld",
|
||||
"node_busy_rebuild_title": "Node beschäftigt: Note-Witnesses werden neu aufgebaut",
|
||||
"node_busy_replay_desc": "Läuft seit %s. Der Node wendet Blöcke erneut an, die er vor dem letzten Beenden empfangen hat.",
|
||||
"node_busy_replay_title": "Node beschäftigt: Blöcke werden nachgeholt",
|
||||
"node_busy_rpc_desc": "Der Node läuft, ist aber seit %s zu beschäftigt, um der Wallet zu antworten – meist wegen eines langen Vorgangs wie dem Neuaufbau der Witnesses.",
|
||||
"node_busy_rpc_title": "Node beschäftigt: antwortet noch nicht",
|
||||
"node_busy_running": "Läuft seit %s",
|
||||
"node_security": "KNOTEN & SICHERHEIT",
|
||||
"node_starting_busy_desc": "Der Node ist erreichbar, aber beschäftigt und antwortet noch nicht. Das ist nach einem Update oder beim ersten Start normal und kann einige Minuten dauern.",
|
||||
"node_starting_launch_desc": "dragonxd wird gestartet, warte auf Verbindung…",
|
||||
"node_starting_title": "DragonX-Node wird gestartet…",
|
||||
"noise": "Rauschen",
|
||||
"not_connected": "Nicht mit Daemon verbunden...",
|
||||
"not_connected_to_daemon": "Nicht mit Daemon verbunden",
|
||||
@@ -1587,6 +1592,7 @@
|
||||
"sb_net_khs": "Netz: %.2f KH/s",
|
||||
"sb_net_mhs": "Netz: %.2f MH/s",
|
||||
"sb_no_conf": "DRAGONX.conf nicht gefunden",
|
||||
"sb_node_busy": "Node beschäftigt",
|
||||
"sb_peers": "Peers: %zu",
|
||||
"sb_plaintext_remote_blocked": "RPC-Anmeldedaten werden nicht im Klartext an einen entfernten Host gesendet. Füge rpcallowplaintext=1 zu DRAGONX.conf hinzu, um dies zu erlauben, oder aktiviere TLS mit rpctls=1.",
|
||||
"sb_rescanning": "Neuscan",
|
||||
@@ -1850,6 +1856,18 @@
|
||||
"show_qr_code": "QR-Code anzeigen",
|
||||
"showing_transactions": "Zeige %d–%d von %d Transaktionen (gesamt: %zu)",
|
||||
"showing_x_of_y": "%d von %d Adressen angezeigt",
|
||||
"shutdown_kill_confirm_msg": "Der Node zeigt seit Langem keine CPU- oder Festplattenaktivität und hängt möglicherweise.\nWenn Sie ihn erzwungen beenden, geht alles Ungespeicherte verloren; sein nächster Start kann deutlich länger dauern.",
|
||||
"shutdown_kill_confirm_title": "Node erzwungen beenden?",
|
||||
"shutdown_kill_confirm_yes": "Erzwungen beenden",
|
||||
"shutdown_kill_node": "Node erzwungen beenden (unsicher)",
|
||||
"shutdown_kill_node_tip": "Beendet den Node sofort. Alles, was er noch nicht gespeichert hat, geht verloren, und beim nächsten Start spielt er die Blöcke seit dem letzten Speichern erneut ein.",
|
||||
"shutdown_leave_running": "Wallet schließen – Node weiterlaufen lassen",
|
||||
"shutdown_leave_running_tip": "Schließt die Wallet sofort. Der Node läuft allein weiter und beendet, was er gerade tut; nichts geht verloren.",
|
||||
"shutdown_stop_asking": "Node wird zum Beenden aufgefordert…",
|
||||
"shutdown_stop_cpu_suffix": " · CPU %d%%",
|
||||
"shutdown_stop_delivered": "Der Node fährt herunter und speichert seinen Zustand",
|
||||
"shutdown_stop_idle": "Seit %d Min. keine CPU- oder Festplattenaktivität – der Node hängt möglicherweise",
|
||||
"shutdown_stop_queued": "Der Node hat die Stopp-Anforderung erhalten, ist aber beschäftigt – er stoppt, sobald er kann",
|
||||
"simple_background": "Einfacher Hintergrund",
|
||||
"slider_off": "Aus",
|
||||
"sort_amount_high": "Größter Betrag",
|
||||
@@ -1860,6 +1878,11 @@
|
||||
"status": "Status",
|
||||
"stop_external": "Externen Daemon stoppen",
|
||||
"stop_mining": "Mining stoppen",
|
||||
"stopconfirm_busy_body": "Er ist seit %s zu beschäftigt, um der Wallet zu antworten – vermutlich wegen eines langen Vorgangs wie dem Neuaufbau der Witnesses. Wenn Sie ihn jetzt stoppen, muss der nächste Start diese Arbeit eventuell wiederholen.\n\nEmpfohlen: Wallet schließen und den Node weiterlaufen lassen. Er beendet die Arbeit selbstständig.",
|
||||
"stopconfirm_busy_title": "Der Node ist beschäftigt",
|
||||
"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",
|
||||
"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)",
|
||||
|
||||
+33
-10
@@ -78,6 +78,7 @@
|
||||
"appx_creating_your_wallet": "Creando tu cartera…",
|
||||
"appx_daemon_error": "Error del daemon",
|
||||
"appx_daemon_reinstall_in_progress": "La reinstalación del daemon ya está en curso.",
|
||||
"appx_daemon_reinstall_node_busy": "El nodo sigue en marcha, así que no se reinstaló el daemon. Vuelve a intentarlo cuando se haya detenido.",
|
||||
"appx_disconnecting": "Desconectando...",
|
||||
"appx_done": "Listo",
|
||||
"appx_dragonxd_output": "Salida de dragonxd",
|
||||
@@ -99,7 +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_rebuilding_witness_cache": "El nodo está reconstruyendo su caché de testigos",
|
||||
"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.",
|
||||
@@ -120,11 +122,7 @@
|
||||
"appx_simple_background_enabled": "Fondo simple activado",
|
||||
"appx_skip": "Omitir",
|
||||
"appx_skip_anyway": "Omitir de todos modos",
|
||||
"appx_still_status_prefix": "Aún \"",
|
||||
"appx_still_status_suffix": "\" — forzar el cierre ahora podría dañar los datos de la cadena.",
|
||||
"appx_stop_anyway_and_quit": "Detener de todos modos y salir",
|
||||
"appx_stopping_daemon_deleting_blockchain": "Deteniendo el daemon y eliminando los datos de la cadena de bloques...",
|
||||
"appx_stopping_node_discards_rebuild": "Detener el nodo ahora descarta la reconstrucción en curso y la reinicia (varios minutos) la próxima vez que abras la cartera. También puedes dejar el nodo en ejecución.",
|
||||
"appx_stopping_pool_miner": "Deteniendo el minero de pool...",
|
||||
"appx_syncing_pct_block_n_of_n": "Sincronizando %.1f%% — Bloque %d / %d",
|
||||
"appx_tap_words_in_order": "Toca las palabras en el orden correcto para confirmar que las guardaste.",
|
||||
@@ -867,11 +865,6 @@
|
||||
"file_save_location": "El archivo se guardará en: ~/.config/ObsidianDragon/",
|
||||
"filter": "Filtrar...",
|
||||
"font_scale": "Escala de fuente",
|
||||
"force_quit": "Forzar Salida",
|
||||
"force_quit_confirm_msg": "Esto matará inmediatamente el daemon sin un apagado limpio.\nEsto puede corromper el índice de la blockchain y requerir una resincronización.",
|
||||
"force_quit_confirm_title": "¿Forzar Salida?",
|
||||
"force_quit_warning": "Esto matará inmediatamente el daemon sin un apagado limpio. Puede requerir una resincronización de la blockchain.",
|
||||
"force_quit_yes": "Forzar Salida",
|
||||
"from": "Desde",
|
||||
"from_upper": "DESDE",
|
||||
"full_details": "Detalles Completos",
|
||||
@@ -1358,7 +1351,19 @@
|
||||
"node_banner_offline_title": "No conectado al nodo DragonX",
|
||||
"node_banner_reconnect": "Reconectar",
|
||||
"node_banner_restart": "Reiniciar nodo",
|
||||
"node_busy_rebuild_desc": "En curso desde hace %s. En carteras grandes puede tardar alrededor de una hora, y el nodo no puede responder a la cartera hasta que termine.",
|
||||
"node_busy_rebuild_extending": "Ampliando testigos (el nodo no informa del progreso de este paso).",
|
||||
"node_busy_rebuild_reading": "Leyendo bloques: %ld / %ld",
|
||||
"node_busy_rebuild_title": "Nodo ocupado: reconstruyendo testigos de notas",
|
||||
"node_busy_replay_desc": "En curso desde hace %s. El nodo está volviendo a aplicar bloques que recibió antes de detenerse por última vez.",
|
||||
"node_busy_replay_title": "Nodo ocupado: poniéndose al día con los bloques",
|
||||
"node_busy_rpc_desc": "El nodo está en marcha, pero lleva %s demasiado ocupado para responder a la cartera, normalmente por una operación larga como la reconstrucción de testigos.",
|
||||
"node_busy_rpc_title": "Nodo ocupado: aún no responde",
|
||||
"node_busy_running": "En curso desde hace %s",
|
||||
"node_security": "NODO Y SEGURIDAD",
|
||||
"node_starting_busy_desc": "El nodo está accesible pero ocupado y aún no responde. Es normal tras una actualización o en el primer inicio; puede tardar unos minutos.",
|
||||
"node_starting_launch_desc": "Iniciando dragonxd y esperando a que esté en línea…",
|
||||
"node_starting_title": "Iniciando el nodo DragonX…",
|
||||
"noise": "Ruido",
|
||||
"not_connected": "No conectado al daemon...",
|
||||
"not_connected_to_daemon": "No conectado al daemon",
|
||||
@@ -1587,6 +1592,7 @@
|
||||
"sb_net_khs": "Red: %.2f KH/s",
|
||||
"sb_net_mhs": "Red: %.2f MH/s",
|
||||
"sb_no_conf": "DRAGONX.conf no encontrado",
|
||||
"sb_node_busy": "Nodo ocupado",
|
||||
"sb_peers": "Pares: %zu",
|
||||
"sb_plaintext_remote_blocked": "Se rechaza enviar credenciales RPC en texto plano a un host remoto. Añade rpcallowplaintext=1 a DRAGONX.conf para permitirlo, o habilita TLS con rpctls=1.",
|
||||
"sb_rescanning": "Reescaneando",
|
||||
@@ -1850,6 +1856,18 @@
|
||||
"show_qr_code": "Mostrar Código QR",
|
||||
"showing_transactions": "Mostrando %d–%d de %d transacciones (total: %zu)",
|
||||
"showing_x_of_y": "Mostrando %d de %d direcciones",
|
||||
"shutdown_kill_confirm_msg": "El nodo lleva mucho tiempo sin actividad de CPU ni de disco y puede estar bloqueado.\nSi fuerzas su cierre se perderá todo lo que no haya guardado; su próximo inicio puede tardar mucho más.",
|
||||
"shutdown_kill_confirm_title": "¿Forzar el cierre del nodo?",
|
||||
"shutdown_kill_confirm_yes": "Forzar cierre",
|
||||
"shutdown_kill_node": "Forzar cierre del nodo (inseguro)",
|
||||
"shutdown_kill_node_tip": "Detiene el nodo de inmediato. Se pierde todo lo que no haya guardado, y en el próximo inicio volverá a procesar los bloques desde el último guardado.",
|
||||
"shutdown_leave_running": "Cerrar la cartera y dejar el nodo en marcha",
|
||||
"shutdown_leave_running_tip": "Cierra la cartera ahora. El nodo sigue funcionando por su cuenta y termina lo que está haciendo; no se pierde nada.",
|
||||
"shutdown_stop_asking": "Pidiendo al nodo que se detenga…",
|
||||
"shutdown_stop_cpu_suffix": " · CPU %d%%",
|
||||
"shutdown_stop_delivered": "El nodo se está cerrando y guardando su estado",
|
||||
"shutdown_stop_idle": "Sin actividad de CPU ni de disco desde hace %d min; puede que el nodo esté bloqueado",
|
||||
"shutdown_stop_queued": "El nodo recibió la orden de detenerse, pero está ocupado; se detendrá en cuanto pueda",
|
||||
"simple_background": "Fondo simple",
|
||||
"slider_off": "Apagado",
|
||||
"sort_amount_high": "Mayor importe",
|
||||
@@ -1860,6 +1878,11 @@
|
||||
"status": "Estado",
|
||||
"stop_external": "Detener daemon externo",
|
||||
"stop_mining": "Detener Minería",
|
||||
"stopconfirm_busy_body": "Lleva %s demasiado ocupado para responder a la cartera, probablemente por una operación larga como la reconstrucción de testigos. Detenerlo ahora puede hacer que el próximo inicio repita ese trabajo.\n\nRecomendado: cierra la cartera y deja el nodo en marcha. Terminará por su cuenta.",
|
||||
"stopconfirm_busy_title": "El nodo está ocupado",
|
||||
"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",
|
||||
"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)",
|
||||
|
||||
+33
-10
@@ -78,6 +78,7 @@
|
||||
"appx_creating_your_wallet": "Création de votre portefeuille…",
|
||||
"appx_daemon_error": "Erreur du démon",
|
||||
"appx_daemon_reinstall_in_progress": "La réinstallation du démon est déjà en cours.",
|
||||
"appx_daemon_reinstall_node_busy": "Le nœud est toujours actif, le démon n'a donc pas été réinstallé. Réessayez une fois qu'il sera arrêté.",
|
||||
"appx_disconnecting": "Déconnexion...",
|
||||
"appx_done": "Terminé",
|
||||
"appx_dragonxd_output": "Sortie de dragonxd",
|
||||
@@ -99,7 +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_rebuilding_witness_cache": "Le nœud reconstruit son cache de témoins",
|
||||
"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.",
|
||||
@@ -120,11 +122,7 @@
|
||||
"appx_simple_background_enabled": "Arrière-plan simple activé",
|
||||
"appx_skip": "Ignorer",
|
||||
"appx_skip_anyway": "Ignorer quand même",
|
||||
"appx_still_status_prefix": "Toujours « ",
|
||||
"appx_still_status_suffix": " » — forcer la fermeture maintenant peut corrompre les données de la chaîne.",
|
||||
"appx_stop_anyway_and_quit": "Arrêter quand même et quitter",
|
||||
"appx_stopping_daemon_deleting_blockchain": "Arrêt du démon et suppression des données de la blockchain...",
|
||||
"appx_stopping_node_discards_rebuild": "Arrêter le nœud maintenant abandonne la reconstruction en cours et la relance (plusieurs minutes) à la prochaine ouverture du portefeuille. Vous pouvez plutôt laisser le nœud en marche.",
|
||||
"appx_stopping_pool_miner": "Arrêt du mineur de pool...",
|
||||
"appx_syncing_pct_block_n_of_n": "Synchronisation %.1f%% — Bloc %d / %d",
|
||||
"appx_tap_words_in_order": "Touchez les mots dans le bon ordre pour confirmer que vous les avez enregistrés.",
|
||||
@@ -867,11 +865,6 @@
|
||||
"file_save_location": "Le fichier sera enregistré dans : ~/.config/ObsidianDragon/",
|
||||
"filter": "Filtrer...",
|
||||
"font_scale": "Taille de police",
|
||||
"force_quit": "Forcer la fermeture",
|
||||
"force_quit_confirm_msg": "Cela tuera immédiatement le daemon sans arrêt propre.\nCela peut corrompre l'index de la blockchain et nécessiter une resynchronisation.",
|
||||
"force_quit_confirm_title": "Forcer la fermeture ?",
|
||||
"force_quit_warning": "Cela tuera immédiatement le daemon sans arrêt propre. Peut nécessiter une resynchronisation de la blockchain.",
|
||||
"force_quit_yes": "Forcer la fermeture",
|
||||
"from": "De",
|
||||
"from_upper": "DE",
|
||||
"full_details": "Tous les détails",
|
||||
@@ -1358,7 +1351,19 @@
|
||||
"node_banner_offline_title": "Non connecté au nœud DragonX",
|
||||
"node_banner_reconnect": "Reconnecter",
|
||||
"node_banner_restart": "Redémarrer le nœud",
|
||||
"node_busy_rebuild_desc": "En cours depuis %s. Pour un gros portefeuille, cela peut prendre environ une heure, et le nœud ne peut pas répondre au portefeuille avant la fin.",
|
||||
"node_busy_rebuild_extending": "Extension des témoins (le nœud ne signale aucune progression pour cette étape).",
|
||||
"node_busy_rebuild_reading": "Lecture des blocs : %ld / %ld",
|
||||
"node_busy_rebuild_title": "Nœud occupé : reconstruction des témoins de notes",
|
||||
"node_busy_replay_desc": "En cours depuis %s. Le nœud réapplique les blocs reçus avant son dernier arrêt.",
|
||||
"node_busy_replay_title": "Nœud occupé : rattrapage des blocs",
|
||||
"node_busy_rpc_desc": "Le nœud fonctionne mais est trop occupé pour répondre au portefeuille depuis %s, généralement à cause d'une longue opération comme la reconstruction des témoins.",
|
||||
"node_busy_rpc_title": "Nœud occupé : ne répond pas encore",
|
||||
"node_busy_running": "En cours depuis %s",
|
||||
"node_security": "NŒUD & SÉCURITÉ",
|
||||
"node_starting_busy_desc": "Le nœud est joignable mais occupé et ne répond pas encore. C'est normal après une mise à jour ou au premier lancement ; cela peut prendre quelques minutes.",
|
||||
"node_starting_launch_desc": "Lancement de dragonxd, en attente de sa disponibilité…",
|
||||
"node_starting_title": "Démarrage du nœud DragonX…",
|
||||
"noise": "Bruit",
|
||||
"not_connected": "Non connecté au daemon...",
|
||||
"not_connected_to_daemon": "Non connecté au daemon",
|
||||
@@ -1587,6 +1592,7 @@
|
||||
"sb_net_khs": "Rés: %.2f KH/s",
|
||||
"sb_net_mhs": "Rés: %.2f MH/s",
|
||||
"sb_no_conf": "DRAGONX.conf introuvable",
|
||||
"sb_node_busy": "Nœud occupé",
|
||||
"sb_peers": "Pairs : %zu",
|
||||
"sb_plaintext_remote_blocked": "Refus d'envoyer les identifiants RPC en clair vers un hôte distant. Ajoutez rpcallowplaintext=1 à DRAGONX.conf pour l'autoriser, ou activez TLS avec rpctls=1.",
|
||||
"sb_rescanning": "Rescan",
|
||||
@@ -1850,6 +1856,18 @@
|
||||
"show_qr_code": "Afficher le code QR",
|
||||
"showing_transactions": "Affichage %d–%d sur %d transactions (total : %zu)",
|
||||
"showing_x_of_y": "Affichage de %d sur %d adresses",
|
||||
"shutdown_kill_confirm_msg": "Le nœud ne montre plus d'activité processeur ni disque depuis longtemps et est peut-être bloqué.\nLe tuer fait perdre tout ce qu'il n'a pas enregistré ; son prochain démarrage peut être beaucoup plus long.",
|
||||
"shutdown_kill_confirm_title": "Tuer le nœud ?",
|
||||
"shutdown_kill_confirm_yes": "Tuer le nœud",
|
||||
"shutdown_kill_node": "Tuer le nœud (risqué)",
|
||||
"shutdown_kill_node_tip": "Arrête le nœud immédiatement. Tout ce qu'il n'a pas enregistré est perdu, et au prochain démarrage il rejouera les blocs depuis son dernier enregistrement.",
|
||||
"shutdown_leave_running": "Fermer le portefeuille — laisser le nœud actif",
|
||||
"shutdown_leave_running_tip": "Ferme le portefeuille maintenant. Le nœud continue de fonctionner seul et termine ce qu'il fait ; rien n'est perdu.",
|
||||
"shutdown_stop_asking": "Demande d'arrêt envoyée au nœud…",
|
||||
"shutdown_stop_cpu_suffix": " · CPU %d%%",
|
||||
"shutdown_stop_delivered": "Le nœud s'arrête et enregistre son état",
|
||||
"shutdown_stop_idle": "Aucune activité processeur ni disque depuis %d min — le nœud est peut-être bloqué",
|
||||
"shutdown_stop_queued": "Le nœud a reçu la demande d'arrêt mais il est occupé — il s'arrêtera dès que possible",
|
||||
"simple_background": "Arrière-plan simple",
|
||||
"slider_off": "Désactivé",
|
||||
"sort_amount_high": "Montant le plus élevé",
|
||||
@@ -1860,6 +1878,11 @@
|
||||
"status": "Statut",
|
||||
"stop_external": "Arrêter le daemon externe",
|
||||
"stop_mining": "Arrêter le minage",
|
||||
"stopconfirm_busy_body": "Il est trop occupé pour répondre au portefeuille depuis %s, probablement à cause d'une longue opération comme la reconstruction des témoins. L'arrêter maintenant peut obliger son prochain démarrage à refaire ce travail.\n\nRecommandé : fermez le portefeuille et laissez le nœud actif. Il terminera seul.",
|
||||
"stopconfirm_busy_title": "Le nœud est occupé",
|
||||
"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",
|
||||
"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)",
|
||||
|
||||
+33
-10
@@ -78,6 +78,7 @@
|
||||
"appx_creating_your_wallet": "ウォレットを作成しています…",
|
||||
"appx_daemon_error": "デーモンエラー",
|
||||
"appx_daemon_reinstall_in_progress": "デーモンの再インストールがすでに進行中です。",
|
||||
"appx_daemon_reinstall_node_busy": "ノードがまだ実行中のため、デーモンは再インストールされませんでした。停止してからもう一度お試しください。",
|
||||
"appx_disconnecting": "切断しています...",
|
||||
"appx_done": "完了",
|
||||
"appx_dragonxd_output": "dragonxd の出力",
|
||||
@@ -99,7 +100,8 @@
|
||||
"appx_no_bundled_daemon_to_install": "このビルドにはインストールできる同梱デーモンがありません",
|
||||
"appx_no_embedded_daemon_to_install": "このビルドにはインストールできる組み込みデーモンがありません",
|
||||
"appx_node_busy_restarting": "ノードが再起動中でビジー状態です。少し待ってから再試行してください。",
|
||||
"appx_node_rebuilding_witness_cache": "ノードがウィットネスキャッシュを再構築しています",
|
||||
"appx_node_busy_try_later": "ノードは長時間の処理(ノートウィットネスの再構築など)を実行中です。完了してからもう一度お試しください。今停止すると、その処理を最初からやり直すことがあります。",
|
||||
"appx_node_not_stopped": "ノードが時間内に停止しなかったため、何も変更されませんでした。数分後にもう一度お試しください。",
|
||||
"appx_not_next_word": " — 次の単語ではありません",
|
||||
"appx_payment_request_loaded": "支払いリクエストを読み込みました",
|
||||
"appx_pool_miner_connected_and_hashing": "プールマイナーが接続し、ハッシュ計算中です。",
|
||||
@@ -120,11 +122,7 @@
|
||||
"appx_simple_background_enabled": "シンプル背景を有効にしました",
|
||||
"appx_skip": "スキップ",
|
||||
"appx_skip_anyway": "それでもスキップ",
|
||||
"appx_still_status_prefix": "まだ「",
|
||||
"appx_still_status_suffix": "」です — 今強制終了するとチェーンデータが破損する可能性があります。",
|
||||
"appx_stop_anyway_and_quit": "それでも停止して終了",
|
||||
"appx_stopping_daemon_deleting_blockchain": "デーモンを停止し、ブロックチェーンデータを削除しています...",
|
||||
"appx_stopping_node_discards_rebuild": "今ノードを停止すると、進行中の再構築が破棄され、次回ウォレットを開いたときに再度実行されます(数分かかります)。代わりにノードを起動したままにすることもできます。",
|
||||
"appx_stopping_pool_miner": "プールマイナーを停止しています...",
|
||||
"appx_syncing_pct_block_n_of_n": "同期中 %.1f%% — ブロック %d / %d",
|
||||
"appx_tap_words_in_order": "保存したことを確認するため、単語を正しい順番でタップしてください。",
|
||||
@@ -867,11 +865,6 @@
|
||||
"file_save_location": "ファイルの保存先:~/.config/ObsidianDragon/",
|
||||
"filter": "フィルター...",
|
||||
"font_scale": "フォントサイズ",
|
||||
"force_quit": "強制終了",
|
||||
"force_quit_confirm_msg": "クリーンシャットダウンなしでデーモンを即座に終了します。\nブロックチェーンインデックスが破損し、再同期が必要になる可能性があります。",
|
||||
"force_quit_confirm_title": "強制終了しますか?",
|
||||
"force_quit_warning": "クリーンシャットダウンなしでデーモンを即座に終了します。ブロックチェーンの再同期が必要になる場合があります。",
|
||||
"force_quit_yes": "強制終了",
|
||||
"from": "送信元",
|
||||
"from_upper": "送信元",
|
||||
"full_details": "詳細情報",
|
||||
@@ -1358,7 +1351,19 @@
|
||||
"node_banner_offline_title": "DragonX ノードに接続されていません",
|
||||
"node_banner_reconnect": "再接続",
|
||||
"node_banner_restart": "ノードを再起動",
|
||||
"node_busy_rebuild_desc": "経過時間 %s。大きなウォレットでは約 1 時間かかることがあり、完了するまでノードはウォレットに応答できません。",
|
||||
"node_busy_rebuild_extending": "ウィットネスを拡張しています(この段階ではノードは進捗を報告しません)。",
|
||||
"node_busy_rebuild_reading": "ブロックを読み込み中:%ld / %ld",
|
||||
"node_busy_rebuild_title": "ノード処理中:ノートウィットネスを再構築しています",
|
||||
"node_busy_replay_desc": "経過時間 %s。ノードは前回停止する前に受信したブロックを再適用しています。",
|
||||
"node_busy_replay_title": "ノード処理中:ブロックを追いかけています",
|
||||
"node_busy_rpc_desc": "ノードは実行中ですが、%s の間ウォレットに応答できないほど処理中です。通常はウィットネスの再構築などの長時間の処理が原因です。",
|
||||
"node_busy_rpc_title": "ノード処理中:まだ応答していません",
|
||||
"node_busy_running": "経過時間 %s",
|
||||
"node_security": "ノードとセキュリティ",
|
||||
"node_starting_busy_desc": "ノードには接続できますが処理中のため、まだ応答していません。更新後や初回起動時には正常な状態で、数分かかることがあります。",
|
||||
"node_starting_launch_desc": "dragonxd を起動し、オンラインになるのを待っています…",
|
||||
"node_starting_title": "DragonX ノードを起動しています…",
|
||||
"noise": "ノイズ",
|
||||
"not_connected": "デーモンに未接続...",
|
||||
"not_connected_to_daemon": "デーモンに未接続",
|
||||
@@ -1587,6 +1592,7 @@
|
||||
"sb_net_khs": "ネット: %.2f KH/s",
|
||||
"sb_net_mhs": "ネット: %.2f MH/s",
|
||||
"sb_no_conf": "DRAGONX.conf が見つかりません",
|
||||
"sb_node_busy": "ノード処理中",
|
||||
"sb_peers": "ピア: %zu",
|
||||
"sb_plaintext_remote_blocked": "リモートホストへRPC認証情報を平文で送信することを拒否しました。許可するにはDRAGONX.confにrpcallowplaintext=1を追加するか、rpctls=1でTLSを有効にしてください。",
|
||||
"sb_rescanning": "再スキャン",
|
||||
@@ -1850,6 +1856,18 @@
|
||||
"show_qr_code": "QRコードを表示",
|
||||
"showing_transactions": "%d–%d / %d 件の取引を表示中(合計:%zu)",
|
||||
"showing_x_of_y": "%d / %d アドレスを表示",
|
||||
"shutdown_kill_confirm_msg": "ノードは長時間 CPU やディスクの活動がなく、停止している可能性があります。\n強制終了すると保存されていない内容はすべて失われ、次回の起動に大幅に時間がかかることがあります。",
|
||||
"shutdown_kill_confirm_title": "ノードを強制終了しますか?",
|
||||
"shutdown_kill_confirm_yes": "強制終了",
|
||||
"shutdown_kill_node": "ノードを強制終了(危険)",
|
||||
"shutdown_kill_node_tip": "ノードを直ちに終了します。保存されていない内容はすべて失われ、次回の起動時に前回の保存以降のブロックを再処理します。",
|
||||
"shutdown_leave_running": "ウォレットを閉じる — ノードは実行したまま",
|
||||
"shutdown_leave_running_tip": "ウォレットをすぐに閉じます。ノードは単独で実行を続け、処理中の作業を完了します。何も失われません。",
|
||||
"shutdown_stop_asking": "ノードに停止を要求しています…",
|
||||
"shutdown_stop_cpu_suffix": " · CPU %d%%",
|
||||
"shutdown_stop_delivered": "ノードを終了し、状態を保存しています",
|
||||
"shutdown_stop_idle": "%d 分間 CPU やディスクの活動がありません — ノードが停止している可能性があります",
|
||||
"shutdown_stop_queued": "ノードは停止要求を受け取りましたが処理中です — 可能になり次第停止します",
|
||||
"simple_background": "シンプル背景",
|
||||
"slider_off": "オフ",
|
||||
"sort_amount_high": "金額が大きい順",
|
||||
@@ -1860,6 +1878,11 @@
|
||||
"status": "ステータス",
|
||||
"stop_external": "外部デーモンを停止",
|
||||
"stop_mining": "マイニング停止",
|
||||
"stopconfirm_busy_body": "%s の間、ウォレットに応答できないほど処理中です。ウィットネスの再構築などの長時間の処理が原因と考えられます。今停止すると、次回の起動時にその処理をやり直すことがあります。\n\n推奨:ウォレットを閉じ、ノードは実行したままにしてください。ノードは自動的に完了します。",
|
||||
"stopconfirm_busy_title": "ノードは処理中です",
|
||||
"stopconfirm_rebuild_body": "経過時間は %s で、大きなウォレットでは約 1 時間かかることがあります。今ノードを停止すると、再構築の完了まで待つか、次回の起動時に再構築とブロックの追いつきをやり直すことになります。\n\n推奨:ウォレットを閉じ、ノードは実行したままにしてください。ノードは自動的に完了します。",
|
||||
"stopconfirm_rebuild_title": "ノードはノートウィットネスを再構築しています",
|
||||
"stopconfirm_stop_wait": "ノードを停止して待つ",
|
||||
"stratum_allowip_hint": "マイナーの接続を許可するIPまたはCIDR(空欄=このコンピューターのみ)",
|
||||
"stratum_expose_warn": "許可したネットワークにマイニングポートを公開します — 信頼できるLANでのみ使用してください。",
|
||||
"stratum_host": "マイニングプールをホストする(stratum)",
|
||||
|
||||
+33
-10
@@ -78,6 +78,7 @@
|
||||
"appx_creating_your_wallet": "지갑을 생성하는 중…",
|
||||
"appx_daemon_error": "데몬 오류",
|
||||
"appx_daemon_reinstall_in_progress": "데몬 재설치가 이미 진행 중입니다.",
|
||||
"appx_daemon_reinstall_node_busy": "노드가 아직 실행 중이라 데몬을 다시 설치하지 않았습니다. 노드가 멈춘 뒤 다시 시도하세요.",
|
||||
"appx_disconnecting": "연결 해제 중...",
|
||||
"appx_done": "완료",
|
||||
"appx_dragonxd_output": "dragonxd 출력",
|
||||
@@ -99,7 +100,8 @@
|
||||
"appx_no_bundled_daemon_to_install": "이 빌드에는 설치할 번들 데몬이 없습니다",
|
||||
"appx_no_embedded_daemon_to_install": "이 빌드에는 설치할 내장 데몬이 없습니다",
|
||||
"appx_node_busy_restarting": "노드가 재시작 중입니다 — 잠시 후 다시 시도하세요.",
|
||||
"appx_node_rebuilding_witness_cache": "노드가 위트니스 캐시를 재구성하는 중입니다",
|
||||
"appx_node_busy_try_later": "노드가 긴 작업(노트 위트니스 재구성 등)을 마무리하는 중입니다. 끝난 뒤 다시 시도하세요. 지금 중지하면 그 작업을 처음부터 다시 할 수 있습니다.",
|
||||
"appx_node_not_stopped": "노드가 제때 중지되지 않아 아무것도 변경되지 않았습니다. 몇 분 후 다시 시도하세요.",
|
||||
"appx_not_next_word": " — 다음 단어가 아닙니다",
|
||||
"appx_payment_request_loaded": "결제 요청을 불러왔습니다",
|
||||
"appx_pool_miner_connected_and_hashing": "풀 채굴기가 연결되어 해싱 중입니다.",
|
||||
@@ -120,11 +122,7 @@
|
||||
"appx_simple_background_enabled": "단순 배경 활성화됨",
|
||||
"appx_skip": "건너뛰기",
|
||||
"appx_skip_anyway": "그래도 건너뛰기",
|
||||
"appx_still_status_prefix": "아직 \"",
|
||||
"appx_still_status_suffix": "\" 상태입니다 — 지금 강제 종료하면 체인 데이터가 손상될 수 있습니다.",
|
||||
"appx_stop_anyway_and_quit": "그래도 중지하고 종료",
|
||||
"appx_stopping_daemon_deleting_blockchain": "데몬을 중지하고 블록체인 데이터를 삭제하는 중...",
|
||||
"appx_stopping_node_discards_rebuild": "지금 노드를 중지하면 진행 중인 재구성이 취소되며, 다음에 지갑을 열 때 다시 시작됩니다(몇 분 소요). 대신 노드를 계속 실행할 수 있습니다.",
|
||||
"appx_stopping_pool_miner": "풀 채굴기를 중지하는 중...",
|
||||
"appx_syncing_pct_block_n_of_n": "동기화 중 %.1f%% — 블록 %d / %d",
|
||||
"appx_tap_words_in_order": "저장한 내용을 확인하도록 단어를 올바른 순서대로 탭하세요.",
|
||||
@@ -867,11 +865,6 @@
|
||||
"file_save_location": "파일 저장 위치: ~/.config/ObsidianDragon/",
|
||||
"filter": "필터...",
|
||||
"font_scale": "글꼴 크기",
|
||||
"force_quit": "강제 종료",
|
||||
"force_quit_confirm_msg": "정상 종료 없이 데몬을 즉시 종료합니다.\n블록체인 인덱스가 손상되어 재동기화가 필요할 수 있습니다.",
|
||||
"force_quit_confirm_title": "강제 종료하시겠습니까?",
|
||||
"force_quit_warning": "정상 종료 없이 데몬을 즉시 종료합니다. 블록체인 재동기화가 필요할 수 있습니다.",
|
||||
"force_quit_yes": "강제 종료",
|
||||
"from": "보낸 곳",
|
||||
"from_upper": "보낸 곳",
|
||||
"full_details": "전체 세부 정보",
|
||||
@@ -1358,7 +1351,19 @@
|
||||
"node_banner_offline_title": "DragonX 노드에 연결되지 않음",
|
||||
"node_banner_reconnect": "재연결",
|
||||
"node_banner_restart": "노드 재시작",
|
||||
"node_busy_rebuild_desc": "%s 동안 진행 중입니다. 큰 지갑은 약 1시간이 걸릴 수 있으며, 끝날 때까지 노드가 지갑에 응답할 수 없습니다.",
|
||||
"node_busy_rebuild_extending": "위트니스 확장 중(이 단계에서는 노드가 진행 상황을 보고하지 않습니다).",
|
||||
"node_busy_rebuild_reading": "블록 읽는 중: %ld / %ld",
|
||||
"node_busy_rebuild_title": "노드 작업 중: 노트 위트니스 재구성 중",
|
||||
"node_busy_replay_desc": "%s 동안 진행 중입니다. 노드가 마지막으로 멈추기 전에 받은 블록을 다시 적용하고 있습니다.",
|
||||
"node_busy_replay_title": "노드 작업 중: 블록 따라잡는 중",
|
||||
"node_busy_rpc_desc": "노드는 실행 중이지만 %s 동안 지갑에 응답할 수 없을 만큼 바쁩니다. 보통 위트니스 재구성 같은 긴 작업 때문입니다.",
|
||||
"node_busy_rpc_title": "노드 작업 중: 아직 응답하지 않음",
|
||||
"node_busy_running": "%s 동안 진행 중",
|
||||
"node_security": "노드 및 보안",
|
||||
"node_starting_busy_desc": "노드에 연결할 수 있지만 바빠서 아직 응답하지 않습니다. 업데이트 후나 처음 실행할 때는 정상이며, 몇 분 걸릴 수 있습니다.",
|
||||
"node_starting_launch_desc": "dragonxd를 시작하고 온라인 상태가 되기를 기다리는 중…",
|
||||
"node_starting_title": "DragonX 노드 시작 중…",
|
||||
"noise": "노이즈",
|
||||
"not_connected": "데몬에 연결되지 않음...",
|
||||
"not_connected_to_daemon": "데몬에 연결되지 않음",
|
||||
@@ -1587,6 +1592,7 @@
|
||||
"sb_net_khs": "네트: %.2f KH/s",
|
||||
"sb_net_mhs": "네트: %.2f MH/s",
|
||||
"sb_no_conf": "DRAGONX.conf를 찾을 수 없음",
|
||||
"sb_node_busy": "노드 작업 중",
|
||||
"sb_peers": "피어: %zu",
|
||||
"sb_plaintext_remote_blocked": "원격 호스트로 RPC 자격 증명을 평문으로 보내는 것을 거부했습니다. 허용하려면 DRAGONX.conf에 rpcallowplaintext=1을 추가하거나 rpctls=1로 TLS를 활성화하세요.",
|
||||
"sb_rescanning": "재스캔",
|
||||
@@ -1850,6 +1856,18 @@
|
||||
"show_qr_code": "QR 코드 표시",
|
||||
"showing_transactions": "%d–%d / %d건의 거래 표시 중 (총: %zu)",
|
||||
"showing_x_of_y": "%d / %d 주소 표시",
|
||||
"shutdown_kill_confirm_msg": "노드가 오랫동안 CPU나 디스크 활동이 없어 멈췄을 수 있습니다.\n강제 종료하면 저장되지 않은 내용이 모두 사라지고, 다음 시작이 훨씬 오래 걸릴 수 있습니다.",
|
||||
"shutdown_kill_confirm_title": "노드를 강제 종료할까요?",
|
||||
"shutdown_kill_confirm_yes": "강제 종료",
|
||||
"shutdown_kill_node": "노드 강제 종료(위험)",
|
||||
"shutdown_kill_node_tip": "노드를 즉시 종료합니다. 저장되지 않은 내용은 모두 사라지며, 다음 시작 시 마지막 저장 이후의 블록을 다시 처리합니다.",
|
||||
"shutdown_leave_running": "지갑 닫기 — 노드는 계속 실행",
|
||||
"shutdown_leave_running_tip": "지갑을 지금 닫습니다. 노드는 혼자 계속 실행되며 진행 중인 작업을 마칩니다. 잃는 것은 없습니다.",
|
||||
"shutdown_stop_asking": "노드에 중지를 요청하는 중…",
|
||||
"shutdown_stop_cpu_suffix": " · CPU %d%%",
|
||||
"shutdown_stop_delivered": "노드가 종료 중이며 상태를 저장하고 있습니다",
|
||||
"shutdown_stop_idle": "%d분 동안 CPU나 디스크 활동이 없습니다 — 노드가 멈췄을 수 있습니다",
|
||||
"shutdown_stop_queued": "노드가 중지 요청을 받았지만 작업 중입니다 — 가능해지면 중지됩니다",
|
||||
"simple_background": "단순 배경",
|
||||
"slider_off": "끔",
|
||||
"sort_amount_high": "금액 큰 순",
|
||||
@@ -1860,6 +1878,11 @@
|
||||
"status": "상태",
|
||||
"stop_external": "외부 데몬 중지",
|
||||
"stop_mining": "채굴 중지",
|
||||
"stopconfirm_busy_body": "%s 동안 지갑에 응답할 수 없을 만큼 바빴습니다. 위트니스 재구성 같은 긴 작업 때문일 가능성이 큽니다. 지금 중지하면 다음 시작 시 그 작업을 다시 해야 할 수 있습니다.\n\n권장: 지갑을 닫고 노드는 계속 실행하세요. 노드가 스스로 마칩니다.",
|
||||
"stopconfirm_busy_title": "노드가 작업 중입니다",
|
||||
"stopconfirm_rebuild_body": "%s 동안 진행 중이며, 큰 지갑은 약 1시간이 걸릴 수 있습니다. 지금 노드를 중지하면 재구성이 끝날 때까지 기다리거나, 다음 시작 시 재구성과 블록 따라잡기를 다시 해야 합니다.\n\n권장: 지갑을 닫고 노드는 계속 실행하세요. 노드가 스스로 마칩니다.",
|
||||
"stopconfirm_rebuild_title": "노드가 노트 위트니스를 재구성하고 있습니다",
|
||||
"stopconfirm_stop_wait": "노드 중지 후 대기",
|
||||
"stratum_allowip_hint": "채굴자를 허용할 IP 또는 CIDR (비워두면 이 컴퓨터만 허용)",
|
||||
"stratum_expose_warn": "허용한 네트워크로 채굴 포트를 엽니다 — 신뢰할 수 있는 LAN에서만 사용하세요.",
|
||||
"stratum_host": "마이닝 풀 호스팅 (stratum)",
|
||||
|
||||
+33
-10
@@ -78,6 +78,7 @@
|
||||
"appx_creating_your_wallet": "Criando sua carteira…",
|
||||
"appx_daemon_error": "Erro do Daemon",
|
||||
"appx_daemon_reinstall_in_progress": "A reinstalação do daemon já está em andamento.",
|
||||
"appx_daemon_reinstall_node_busy": "O nó ainda está em execução, por isso o daemon não foi reinstalado. Tente novamente quando ele tiver parado.",
|
||||
"appx_disconnecting": "Desconectando...",
|
||||
"appx_done": "Concluído",
|
||||
"appx_dragonxd_output": "saída do dragonxd",
|
||||
@@ -99,7 +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_rebuilding_witness_cache": "O nó está reconstruindo seu cache de testemunhas",
|
||||
"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.",
|
||||
@@ -120,11 +122,7 @@
|
||||
"appx_simple_background_enabled": "Fundo simples ativado",
|
||||
"appx_skip": "Pular",
|
||||
"appx_skip_anyway": "Pular mesmo assim",
|
||||
"appx_still_status_prefix": "Ainda \"",
|
||||
"appx_still_status_suffix": "\" — forçar o encerramento agora pode corromper os dados da chain.",
|
||||
"appx_stop_anyway_and_quit": "Parar mesmo assim e sair",
|
||||
"appx_stopping_daemon_deleting_blockchain": "Parando o daemon e excluindo os dados da blockchain...",
|
||||
"appx_stopping_node_discards_rebuild": "Parar o nó agora descarta a reconstrução em andamento e a reinicia (vários minutos) na próxima vez que você abrir a carteira. Em vez disso, você pode manter o nó em execução.",
|
||||
"appx_stopping_pool_miner": "Parando o minerador de pool...",
|
||||
"appx_syncing_pct_block_n_of_n": "Sincronizando %.1f%% — Bloco %d / %d",
|
||||
"appx_tap_words_in_order": "Toque nas palavras na ordem correta para confirmar que você as salvou.",
|
||||
@@ -867,11 +865,6 @@
|
||||
"file_save_location": "O arquivo será salvo em: ~/.config/ObsidianDragon/",
|
||||
"filter": "Filtrar...",
|
||||
"font_scale": "Escala da Fonte",
|
||||
"force_quit": "Forçar Saída",
|
||||
"force_quit_confirm_msg": "Isso matará imediatamente o daemon sem um desligamento limpo.\nIsso pode corromper o índice da blockchain e exigir uma ressincronização.",
|
||||
"force_quit_confirm_title": "Forçar Saída?",
|
||||
"force_quit_warning": "Isso matará imediatamente o daemon sem um desligamento limpo. Pode exigir uma ressincronização da blockchain.",
|
||||
"force_quit_yes": "Forçar Saída",
|
||||
"from": "De",
|
||||
"from_upper": "DE",
|
||||
"full_details": "Detalhes Completos",
|
||||
@@ -1358,7 +1351,19 @@
|
||||
"node_banner_offline_title": "Não conectado ao nó DragonX",
|
||||
"node_banner_reconnect": "Reconectar",
|
||||
"node_banner_restart": "Reiniciar nó",
|
||||
"node_busy_rebuild_desc": "Em andamento há %s. Em carteiras grandes pode levar cerca de uma hora, e o nó não consegue responder à carteira até terminar.",
|
||||
"node_busy_rebuild_extending": "Estendendo testemunhas (o nó não informa o progresso desta etapa).",
|
||||
"node_busy_rebuild_reading": "Lendo blocos: %ld / %ld",
|
||||
"node_busy_rebuild_title": "Nó ocupado: reconstruindo testemunhas de notas",
|
||||
"node_busy_replay_desc": "Em andamento há %s. O nó está reaplicando blocos que recebeu antes da última parada.",
|
||||
"node_busy_replay_title": "Nó ocupado: recuperando blocos",
|
||||
"node_busy_rpc_desc": "O nó está em execução, mas está ocupado demais para responder à carteira há %s, geralmente por causa de uma operação longa como a reconstrução de testemunhas.",
|
||||
"node_busy_rpc_title": "Nó ocupado: ainda não responde",
|
||||
"node_busy_running": "Em andamento há %s",
|
||||
"node_security": "NÓ & SEGURANÇA",
|
||||
"node_starting_busy_desc": "O nó está acessível, mas ocupado, e ainda não responde. Isso é normal após uma atualização ou na primeira execução; pode levar alguns minutos.",
|
||||
"node_starting_launch_desc": "Iniciando o dragonxd e aguardando ficar online…",
|
||||
"node_starting_title": "Iniciando o nó DragonX…",
|
||||
"noise": "Ruído",
|
||||
"not_connected": "Não conectado ao daemon...",
|
||||
"not_connected_to_daemon": "Não conectado ao daemon",
|
||||
@@ -1587,6 +1592,7 @@
|
||||
"sb_net_khs": "Rede: %.2f KH/s",
|
||||
"sb_net_mhs": "Rede: %.2f MH/s",
|
||||
"sb_no_conf": "DRAGONX.conf não encontrado",
|
||||
"sb_node_busy": "Nó ocupado",
|
||||
"sb_peers": "Pares: %zu",
|
||||
"sb_plaintext_remote_blocked": "Recusando enviar credenciais RPC em texto simples para um host remoto. Adicione rpcallowplaintext=1 ao DRAGONX.conf para permitir, ou habilite TLS com rpctls=1.",
|
||||
"sb_rescanning": "Reescaneando",
|
||||
@@ -1850,6 +1856,18 @@
|
||||
"show_qr_code": "Mostrar Código QR",
|
||||
"showing_transactions": "Mostrando %d–%d de %d transações (total: %zu)",
|
||||
"showing_x_of_y": "Mostrando %d de %d endereços",
|
||||
"shutdown_kill_confirm_msg": "O nó não mostra atividade de CPU nem de disco há muito tempo e pode estar travado.\nForçar o encerramento perde tudo o que não foi salvo; o próximo início pode demorar muito mais.",
|
||||
"shutdown_kill_confirm_title": "Forçar o encerramento do nó?",
|
||||
"shutdown_kill_confirm_yes": "Forçar encerramento",
|
||||
"shutdown_kill_node": "Forçar encerramento do nó (inseguro)",
|
||||
"shutdown_kill_node_tip": "Encerra o nó imediatamente. Tudo o que não foi salvo se perde, e no próximo início ele reprocessa os blocos desde o último salvamento.",
|
||||
"shutdown_leave_running": "Fechar a carteira e deixar o nó em execução",
|
||||
"shutdown_leave_running_tip": "Fecha a carteira agora. O nó continua em execução sozinho e termina o que está fazendo; nada é perdido.",
|
||||
"shutdown_stop_asking": "Pedindo ao nó para parar…",
|
||||
"shutdown_stop_cpu_suffix": " · CPU %d%%",
|
||||
"shutdown_stop_delivered": "O nó está encerrando e salvando seu estado",
|
||||
"shutdown_stop_idle": "Sem atividade de CPU ou disco há %d min; o nó pode estar travado",
|
||||
"shutdown_stop_queued": "O nó recebeu o pedido de parada, mas está ocupado; ele vai parar assim que puder",
|
||||
"simple_background": "Fundo simples",
|
||||
"slider_off": "Desligado",
|
||||
"sort_amount_high": "Maior valor",
|
||||
@@ -1860,6 +1878,11 @@
|
||||
"status": "Status",
|
||||
"stop_external": "Parar daemon externo",
|
||||
"stop_mining": "Parar Mineração",
|
||||
"stopconfirm_busy_body": "Está ocupado demais para responder à carteira há %s, provavelmente por causa de uma operação longa como a reconstrução de testemunhas. Pará-lo agora pode fazer o próximo início refazer esse trabalho.\n\nRecomendado: feche a carteira e deixe o nó em execução. Ele termina sozinho.",
|
||||
"stopconfirm_busy_title": "O nó está ocupado",
|
||||
"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",
|
||||
"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)",
|
||||
|
||||
+33
-10
@@ -78,6 +78,7 @@
|
||||
"appx_creating_your_wallet": "Создание кошелька…",
|
||||
"appx_daemon_error": "Ошибка демона",
|
||||
"appx_daemon_reinstall_in_progress": "Переустановка демона уже выполняется.",
|
||||
"appx_daemon_reinstall_node_busy": "Узел всё ещё работает, поэтому демон не был переустановлен. Повторите попытку, когда он остановится.",
|
||||
"appx_disconnecting": "Отключение...",
|
||||
"appx_done": "Готово",
|
||||
"appx_dragonxd_output": "вывод dragonxd",
|
||||
@@ -99,7 +100,8 @@
|
||||
"appx_no_bundled_daemon_to_install": "В этой сборке нет встроенного демона для установки",
|
||||
"appx_no_embedded_daemon_to_install": "В этой сборке нет встроенного демона для установки",
|
||||
"appx_node_busy_restarting": "Узел занят перезапуском — попробуйте ещё раз через мгновение.",
|
||||
"appx_node_rebuilding_witness_cache": "Узел перестраивает кэш свидетелей",
|
||||
"appx_node_busy_try_later": "Узел занят долгой задачей (например, перестройкой свидетелей заметок). Повторите попытку, когда она завершится; если остановить его сейчас, эту работу, возможно, придётся начать заново.",
|
||||
"appx_node_not_stopped": "Узел не остановился вовремя, поэтому ничего не изменено. Повторите попытку через несколько минут.",
|
||||
"appx_not_next_word": " — это не следующее слово",
|
||||
"appx_payment_request_loaded": "Запрос на оплату загружен",
|
||||
"appx_pool_miner_connected_and_hashing": "Пул-майнер подключён и вычисляет хеши.",
|
||||
@@ -120,11 +122,7 @@
|
||||
"appx_simple_background_enabled": "Простой фон включён",
|
||||
"appx_skip": "Пропустить",
|
||||
"appx_skip_anyway": "Всё равно пропустить",
|
||||
"appx_still_status_prefix": "Всё ещё \"",
|
||||
"appx_still_status_suffix": "\" — принудительный выход сейчас может повредить данные цепи.",
|
||||
"appx_stop_anyway_and_quit": "Всё равно остановить и выйти",
|
||||
"appx_stopping_daemon_deleting_blockchain": "Остановка демона и удаление данных блокчейна...",
|
||||
"appx_stopping_node_discards_rebuild": "Остановка узла сейчас отменит текущую перестройку и запустит её заново (несколько минут) при следующем открытии кошелька. Вместо этого можно оставить узел работать.",
|
||||
"appx_stopping_pool_miner": "Остановка пул-майнера...",
|
||||
"appx_syncing_pct_block_n_of_n": "Синхронизация %.1f%% — Блок %d / %d",
|
||||
"appx_tap_words_in_order": "Нажимайте слова в правильном порядке, чтобы подтвердить, что вы их сохранили.",
|
||||
@@ -867,11 +865,6 @@
|
||||
"file_save_location": "Файл будет сохранён в: ~/.config/ObsidianDragon/",
|
||||
"filter": "Фильтр...",
|
||||
"font_scale": "Масштаб шрифта",
|
||||
"force_quit": "Принудительный выход",
|
||||
"force_quit_confirm_msg": "Это немедленно завершит демон без корректного завершения.\nЭто может повредить индекс блокчейна и потребовать повторной синхронизации.",
|
||||
"force_quit_confirm_title": "Принудительный выход?",
|
||||
"force_quit_warning": "Это немедленно завершит демон без корректного завершения. Может потребоваться повторная синхронизация блокчейна.",
|
||||
"force_quit_yes": "Принудительный выход",
|
||||
"from": "От",
|
||||
"from_upper": "ОТ",
|
||||
"full_details": "Полные детали",
|
||||
@@ -1358,7 +1351,19 @@
|
||||
"node_banner_offline_title": "Нет подключения к узлу DragonX",
|
||||
"node_banner_reconnect": "Переподключить",
|
||||
"node_banner_restart": "Перезапустить узел",
|
||||
"node_busy_rebuild_desc": "Выполняется %s. Для больших кошельков это может занять около часа, и до окончания узел не может отвечать кошельку.",
|
||||
"node_busy_rebuild_extending": "Расширение свидетелей (узел не сообщает о ходе этого этапа).",
|
||||
"node_busy_rebuild_reading": "Чтение блоков: %ld / %ld",
|
||||
"node_busy_rebuild_title": "Узел занят: перестройка свидетелей заметок",
|
||||
"node_busy_replay_desc": "Выполняется %s. Узел повторно применяет блоки, полученные до последней остановки.",
|
||||
"node_busy_replay_title": "Узел занят: догоняет блоки",
|
||||
"node_busy_rpc_desc": "Узел работает, но уже %s слишком занят, чтобы отвечать кошельку, обычно из-за долгой операции, например перестройки свидетелей.",
|
||||
"node_busy_rpc_title": "Узел занят: пока не отвечает",
|
||||
"node_busy_running": "Выполняется %s",
|
||||
"node_security": "УЗЕЛ И БЕЗОПАСНОСТЬ",
|
||||
"node_starting_busy_desc": "Узел доступен, но занят и пока не отвечает. Это нормально после обновления или при первом запуске; может занять несколько минут.",
|
||||
"node_starting_launch_desc": "Запуск dragonxd и ожидание его готовности…",
|
||||
"node_starting_title": "Запуск узла DragonX…",
|
||||
"noise": "Шум",
|
||||
"not_connected": "Не подключено к daemon...",
|
||||
"not_connected_to_daemon": "Не подключено к daemon",
|
||||
@@ -1587,6 +1592,7 @@
|
||||
"sb_net_khs": "Сеть: %.2f KH/s",
|
||||
"sb_net_mhs": "Сеть: %.2f MH/s",
|
||||
"sb_no_conf": "DRAGONX.conf не найден",
|
||||
"sb_node_busy": "Узел занят",
|
||||
"sb_peers": "Пиры: %zu",
|
||||
"sb_plaintext_remote_blocked": "Отправка учётных данных RPC открытым текстом на удалённый узел запрещена. Добавьте rpcallowplaintext=1 в DRAGONX.conf, чтобы разрешить, или включите TLS с помощью rpctls=1.",
|
||||
"sb_rescanning": "Пересканирование",
|
||||
@@ -1850,6 +1856,18 @@
|
||||
"show_qr_code": "Показать QR-код",
|
||||
"showing_transactions": "Показано %d–%d из %d транзакций (всего: %zu)",
|
||||
"showing_x_of_y": "Показано %d из %d адресов",
|
||||
"shutdown_kill_confirm_msg": "Узел давно не проявляет активности процессора и диска и, возможно, завис.\nПри принудительном завершении всё несохранённое будет потеряно; следующий запуск может занять гораздо больше времени.",
|
||||
"shutdown_kill_confirm_title": "Принудительно завершить узел?",
|
||||
"shutdown_kill_confirm_yes": "Завершить узел",
|
||||
"shutdown_kill_node": "Принудительно завершить узел (небезопасно)",
|
||||
"shutdown_kill_node_tip": "Немедленно завершает узел. Всё несохранённое будет потеряно, а при следующем запуске он заново обработает блоки с момента последнего сохранения.",
|
||||
"shutdown_leave_running": "Закрыть кошелёк — оставить узел работать",
|
||||
"shutdown_leave_running_tip": "Закрывает кошелёк сейчас. Узел продолжит работать сам и завершит текущую операцию; ничего не теряется.",
|
||||
"shutdown_stop_asking": "Отправка узлу команды остановки…",
|
||||
"shutdown_stop_cpu_suffix": " · ЦП %d%%",
|
||||
"shutdown_stop_delivered": "Узел завершает работу и сохраняет своё состояние",
|
||||
"shutdown_stop_idle": "Нет активности процессора и диска уже %d мин — возможно, узел завис",
|
||||
"shutdown_stop_queued": "Узел получил команду остановки, но занят — он остановится, как только сможет",
|
||||
"simple_background": "Простой фон",
|
||||
"slider_off": "Выкл",
|
||||
"sort_amount_high": "Наибольшая сумма",
|
||||
@@ -1860,6 +1878,11 @@
|
||||
"status": "Статус",
|
||||
"stop_external": "Остановить внешний daemon",
|
||||
"stop_mining": "Остановить майнинг",
|
||||
"stopconfirm_busy_body": "Уже %s он слишком занят, чтобы отвечать кошельку, вероятно, из-за долгой операции, например перестройки свидетелей. Если остановить его сейчас, при следующем запуске эту работу, возможно, придётся выполнить заново.\n\nРекомендуется: закройте кошелёк и оставьте узел работать. Он завершит всё сам.",
|
||||
"stopconfirm_busy_title": "Узел занят",
|
||||
"stopconfirm_rebuild_body": "Она выполняется уже %s, а для больших кошельков может занять около часа. Если остановить узел сейчас, он либо дождётся окончания перестройки, либо при следующем запуске выполнит её заново вместе с догонянием блоков.\n\nРекомендуется: закройте кошелёк и оставьте узел работать. Он завершит всё сам.",
|
||||
"stopconfirm_rebuild_title": "Узел перестраивает свидетелей заметок",
|
||||
"stopconfirm_stop_wait": "Остановить узел и подождать",
|
||||
"stratum_allowip_hint": "Разрешить майнерам подключаться с IP или CIDR (пусто = только этот компьютер)",
|
||||
"stratum_expose_warn": "Открывает порт для майнинга в разрешённую вами сеть — используйте только в доверенной локальной сети.",
|
||||
"stratum_host": "Разместить майнинг-пул (stratum)",
|
||||
|
||||
+33
-10
@@ -78,6 +78,7 @@
|
||||
"appx_creating_your_wallet": "正在创建你的钱包…",
|
||||
"appx_daemon_error": "守护进程错误",
|
||||
"appx_daemon_reinstall_in_progress": "守护进程重新安装已在进行中。",
|
||||
"appx_daemon_reinstall_node_busy": "节点仍在运行,因此未重新安装守护进程。请在它停止后重试。",
|
||||
"appx_disconnecting": "正在断开连接…",
|
||||
"appx_done": "完成",
|
||||
"appx_dragonxd_output": "dragonxd 输出",
|
||||
@@ -99,7 +100,8 @@
|
||||
"appx_no_bundled_daemon_to_install": "此版本没有可安装的捆绑守护进程",
|
||||
"appx_no_embedded_daemon_to_install": "此版本没有可安装的内嵌守护进程",
|
||||
"appx_node_busy_restarting": "节点正忙于重启——请稍后再试。",
|
||||
"appx_node_rebuilding_witness_cache": "节点正在重建其见证缓存",
|
||||
"appx_node_busy_try_later": "节点正忙于完成一项耗时任务(例如重建票据见证)。请在完成后重试;现在停止它可能导致这项工作从头开始。",
|
||||
"appx_node_not_stopped": "节点未能及时停止,因此未做任何更改。请几分钟后重试。",
|
||||
"appx_not_next_word": " — 这不是下一个词",
|
||||
"appx_payment_request_loaded": "支付请求已加载",
|
||||
"appx_pool_miner_connected_and_hashing": "矿池矿工已连接并正在计算哈希。",
|
||||
@@ -120,11 +122,7 @@
|
||||
"appx_simple_background_enabled": "已启用简约背景",
|
||||
"appx_skip": "跳过",
|
||||
"appx_skip_anyway": "仍要跳过",
|
||||
"appx_still_status_prefix": "仍处于“",
|
||||
"appx_still_status_suffix": "”——现在强制退出可能损坏链数据。",
|
||||
"appx_stop_anyway_and_quit": "仍要停止并退出",
|
||||
"appx_stopping_daemon_deleting_blockchain": "正在停止守护进程并删除区块链数据…",
|
||||
"appx_stopping_node_discards_rebuild": "现在停止节点会丢弃正在进行的重建,下次打开钱包时将重新开始(需要几分钟)。你也可以让节点继续运行。",
|
||||
"appx_stopping_pool_miner": "正在停止矿池矿工…",
|
||||
"appx_syncing_pct_block_n_of_n": "正在同步 %.1f%% —— 区块 %d / %d",
|
||||
"appx_tap_words_in_order": "按正确顺序点击这些词,以确认你已保存。",
|
||||
@@ -867,11 +865,6 @@
|
||||
"file_save_location": "文件将保存至:~/.config/ObsidianDragon/",
|
||||
"filter": "筛选...",
|
||||
"font_scale": "字体大小",
|
||||
"force_quit": "强制退出",
|
||||
"force_quit_confirm_msg": "这将立即终止守护进程而不进行正常关闭。\n这可能会损坏区块链索引并需要重新同步。",
|
||||
"force_quit_confirm_title": "强制退出?",
|
||||
"force_quit_warning": "这将立即终止守护进程而不进行正常关闭。可能需要重新同步区块链。",
|
||||
"force_quit_yes": "强制退出",
|
||||
"from": "从",
|
||||
"from_upper": "从",
|
||||
"full_details": "完整详情",
|
||||
@@ -1358,7 +1351,19 @@
|
||||
"node_banner_offline_title": "未连接到 DragonX 节点",
|
||||
"node_banner_reconnect": "重新连接",
|
||||
"node_banner_restart": "重启节点",
|
||||
"node_busy_rebuild_desc": "已运行 %s。大型钱包可能需要约一小时,完成前节点无法响应钱包。",
|
||||
"node_busy_rebuild_extending": "正在扩展见证(此步骤节点不报告进度)。",
|
||||
"node_busy_rebuild_reading": "正在读取区块:%ld / %ld",
|
||||
"node_busy_rebuild_title": "节点繁忙:正在重建票据见证",
|
||||
"node_busy_replay_desc": "已运行 %s。节点正在重新应用上次停止前收到的区块。",
|
||||
"node_busy_replay_title": "节点繁忙:正在追赶区块",
|
||||
"node_busy_rpc_desc": "节点正在运行,但已有 %s 忙于其他任务而无法响应钱包,通常是因为见证重建等耗时操作。",
|
||||
"node_busy_rpc_title": "节点繁忙:暂未响应",
|
||||
"node_busy_running": "已运行 %s",
|
||||
"node_security": "节点与安全",
|
||||
"node_starting_busy_desc": "节点可以访问,但正忙,暂未响应。更新后或首次启动时属正常现象,可能需要几分钟。",
|
||||
"node_starting_launch_desc": "正在启动 dragonxd 并等待其上线…",
|
||||
"node_starting_title": "正在启动 DragonX 节点…",
|
||||
"noise": "噪点",
|
||||
"not_connected": "未连接到守护进程...",
|
||||
"not_connected_to_daemon": "未连接到守护进程",
|
||||
@@ -1587,6 +1592,7 @@
|
||||
"sb_net_khs": "网络: %.2f KH/s",
|
||||
"sb_net_mhs": "网络: %.2f MH/s",
|
||||
"sb_no_conf": "未找到 DRAGONX.conf",
|
||||
"sb_node_busy": "节点繁忙",
|
||||
"sb_peers": "节点: %zu",
|
||||
"sb_plaintext_remote_blocked": "拒绝以明文形式向远程主机发送 RPC 凭据。请在 DRAGONX.conf 中添加 rpcallowplaintext=1 以允许此操作,或启用 rpctls=1 使用 TLS。",
|
||||
"sb_rescanning": "重新扫描",
|
||||
@@ -1850,6 +1856,18 @@
|
||||
"show_qr_code": "显示二维码",
|
||||
"showing_transactions": "显示第 %d–%d 笔,共 %d 笔交易(总计:%zu)",
|
||||
"showing_x_of_y": "显示 %d / %d 个地址",
|
||||
"shutdown_kill_confirm_msg": "节点已长时间没有 CPU 或磁盘活动,可能已卡住。\n强制结束会丢失所有未保存的数据;下次启动可能需要更长时间。",
|
||||
"shutdown_kill_confirm_title": "强制结束节点?",
|
||||
"shutdown_kill_confirm_yes": "强制结束",
|
||||
"shutdown_kill_node": "强制结束节点(不安全)",
|
||||
"shutdown_kill_node_tip": "立即结束节点。所有尚未保存的数据都会丢失,下次启动时会重新处理自上次保存以来的区块。",
|
||||
"shutdown_leave_running": "关闭钱包——保持节点运行",
|
||||
"shutdown_leave_running_tip": "立即关闭钱包。节点会继续独立运行并完成当前操作,不会丢失任何数据。",
|
||||
"shutdown_stop_asking": "正在请求节点停止…",
|
||||
"shutdown_stop_cpu_suffix": " · CPU %d%%",
|
||||
"shutdown_stop_delivered": "节点正在关闭并保存其状态",
|
||||
"shutdown_stop_idle": "已 %d 分钟没有 CPU 或磁盘活动——节点可能已卡住",
|
||||
"shutdown_stop_queued": "节点已收到停止请求,但正忙——它会尽快停止",
|
||||
"simple_background": "简单背景",
|
||||
"slider_off": "关闭",
|
||||
"sort_amount_high": "金额最大",
|
||||
@@ -1860,6 +1878,11 @@
|
||||
"status": "状态",
|
||||
"stop_external": "停止外部守护进程",
|
||||
"stop_mining": "停止挖矿",
|
||||
"stopconfirm_busy_body": "节点已有 %s 忙于其他任务而无法响应钱包,可能是因为见证重建等耗时操作。现在停止它,下次启动时可能需要重做这些工作。\n\n建议:关闭钱包并保持节点运行,它会自行完成。",
|
||||
"stopconfirm_busy_title": "节点正忙",
|
||||
"stopconfirm_rebuild_body": "已运行 %s,大型钱包可能需要约一小时。现在停止节点,要么会等待重建完成,要么下次启动时需要重新进行重建以及区块追赶。\n\n建议:关闭钱包并保持节点运行,它会自行完成。",
|
||||
"stopconfirm_rebuild_title": "节点正在重建票据见证",
|
||||
"stopconfirm_stop_wait": "停止节点并等待",
|
||||
"stratum_allowip_hint": "允许来自指定 IP 或 CIDR 的矿工连接(留空 = 仅限本机)",
|
||||
"stratum_expose_warn": "会向您允许的网络开放挖矿端口——仅在受信任的局域网中使用。",
|
||||
"stratum_host": "托管矿池(stratum)",
|
||||
|
||||
@@ -25,6 +25,264 @@ 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",
|
||||
"fr": "Fermer le portefeuille — laisser le nœud actif", "pt": "Fechar a carteira e deixar o nó em execução",
|
||||
"ru": "Закрыть кошелёк — оставить узел работать", "zh": "关闭钱包——保持节点运行",
|
||||
"ja": "ウォレットを閉じる — ノードは実行したまま", "ko": "지갑 닫기 — 노드는 계속 실행"
|
||||
},
|
||||
"shutdown_leave_running_tip": {
|
||||
"de": "Schließt die Wallet sofort. Der Node läuft allein weiter und beendet, was er gerade tut; nichts geht verloren.",
|
||||
"es": "Cierra la cartera ahora. El nodo sigue funcionando por su cuenta y termina lo que está haciendo; no se pierde nada.",
|
||||
"fr": "Ferme le portefeuille maintenant. Le nœud continue de fonctionner seul et termine ce qu'il fait ; rien n'est perdu.",
|
||||
"pt": "Fecha a carteira agora. O nó continua em execução sozinho e termina o que está fazendo; nada é perdido.",
|
||||
"ru": "Закрывает кошелёк сейчас. Узел продолжит работать сам и завершит текущую операцию; ничего не теряется.",
|
||||
"zh": "立即关闭钱包。节点会继续独立运行并完成当前操作,不会丢失任何数据。",
|
||||
"ja": "ウォレットをすぐに閉じます。ノードは単独で実行を続け、処理中の作業を完了します。何も失われません。",
|
||||
"ko": "지갑을 지금 닫습니다. 노드는 혼자 계속 실행되며 진행 중인 작업을 마칩니다. 잃는 것은 없습니다."
|
||||
},
|
||||
"shutdown_kill_node": {
|
||||
"de": "Node erzwungen beenden (unsicher)", "es": "Forzar cierre del nodo (inseguro)",
|
||||
"fr": "Tuer le nœud (risqué)", "pt": "Forçar encerramento do nó (inseguro)",
|
||||
"ru": "Принудительно завершить узел (небезопасно)", "zh": "强制结束节点(不安全)",
|
||||
"ja": "ノードを強制終了(危険)", "ko": "노드 강제 종료(위험)"
|
||||
},
|
||||
"shutdown_kill_node_tip": {
|
||||
"de": "Beendet den Node sofort. Alles, was er noch nicht gespeichert hat, geht verloren, und beim nächsten Start spielt er die Blöcke seit dem letzten Speichern erneut ein.",
|
||||
"es": "Detiene el nodo de inmediato. Se pierde todo lo que no haya guardado, y en el próximo inicio volverá a procesar los bloques desde el último guardado.",
|
||||
"fr": "Arrête le nœud immédiatement. Tout ce qu'il n'a pas enregistré est perdu, et au prochain démarrage il rejouera les blocs depuis son dernier enregistrement.",
|
||||
"pt": "Encerra o nó imediatamente. Tudo o que não foi salvo se perde, e no próximo início ele reprocessa os blocos desde o último salvamento.",
|
||||
"ru": "Немедленно завершает узел. Всё несохранённое будет потеряно, а при следующем запуске он заново обработает блоки с момента последнего сохранения.",
|
||||
"zh": "立即结束节点。所有尚未保存的数据都会丢失,下次启动时会重新处理自上次保存以来的区块。",
|
||||
"ja": "ノードを直ちに終了します。保存されていない内容はすべて失われ、次回の起動時に前回の保存以降のブロックを再処理します。",
|
||||
"ko": "노드를 즉시 종료합니다. 저장되지 않은 내용은 모두 사라지며, 다음 시작 시 마지막 저장 이후의 블록을 다시 처리합니다."
|
||||
},
|
||||
"shutdown_kill_confirm_title": {
|
||||
"de": "Node erzwungen beenden?", "es": "¿Forzar el cierre del nodo?", "fr": "Tuer le nœud ?",
|
||||
"pt": "Forçar o encerramento do nó?", "ru": "Принудительно завершить узел?", "zh": "强制结束节点?",
|
||||
"ja": "ノードを強制終了しますか?", "ko": "노드를 강제 종료할까요?"
|
||||
},
|
||||
"shutdown_kill_confirm_msg": {
|
||||
"de": "Der Node zeigt seit Langem keine CPU- oder Festplattenaktivität und hängt möglicherweise.\nWenn Sie ihn erzwungen beenden, geht alles Ungespeicherte verloren; sein nächster Start kann deutlich länger dauern.",
|
||||
"es": "El nodo lleva mucho tiempo sin actividad de CPU ni de disco y puede estar bloqueado.\nSi fuerzas su cierre se perderá todo lo que no haya guardado; su próximo inicio puede tardar mucho más.",
|
||||
"fr": "Le nœud ne montre plus d'activité processeur ni disque depuis longtemps et est peut-être bloqué.\nLe tuer fait perdre tout ce qu'il n'a pas enregistré ; son prochain démarrage peut être beaucoup plus long.",
|
||||
"pt": "O nó não mostra atividade de CPU nem de disco há muito tempo e pode estar travado.\nForçar o encerramento perde tudo o que não foi salvo; o próximo início pode demorar muito mais.",
|
||||
"ru": "Узел давно не проявляет активности процессора и диска и, возможно, завис.\nПри принудительном завершении всё несохранённое будет потеряно; следующий запуск может занять гораздо больше времени.",
|
||||
"zh": "节点已长时间没有 CPU 或磁盘活动,可能已卡住。\n强制结束会丢失所有未保存的数据;下次启动可能需要更长时间。",
|
||||
"ja": "ノードは長時間 CPU やディスクの活動がなく、停止している可能性があります。\n強制終了すると保存されていない内容はすべて失われ、次回の起動に大幅に時間がかかることがあります。",
|
||||
"ko": "노드가 오랫동안 CPU나 디스크 활동이 없어 멈췄을 수 있습니다.\n강제 종료하면 저장되지 않은 내용이 모두 사라지고, 다음 시작이 훨씬 오래 걸릴 수 있습니다."
|
||||
},
|
||||
"shutdown_kill_confirm_yes": {
|
||||
"de": "Erzwungen beenden", "es": "Forzar cierre", "fr": "Tuer le nœud", "pt": "Forçar encerramento",
|
||||
"ru": "Завершить узел", "zh": "强制结束", "ja": "強制終了", "ko": "강제 종료"
|
||||
},
|
||||
"shutdown_stop_asking": {
|
||||
"de": "Node wird zum Beenden aufgefordert…", "es": "Pidiendo al nodo que se detenga…",
|
||||
"fr": "Demande d'arrêt envoyée au nœud…", "pt": "Pedindo ao nó para parar…",
|
||||
"ru": "Отправка узлу команды остановки…", "zh": "正在请求节点停止…",
|
||||
"ja": "ノードに停止を要求しています…", "ko": "노드에 중지를 요청하는 중…"
|
||||
},
|
||||
"shutdown_stop_queued": {
|
||||
"de": "Der Node hat die Stopp-Anforderung erhalten, ist aber beschäftigt – er stoppt, sobald er kann",
|
||||
"es": "El nodo recibió la orden de detenerse, pero está ocupado; se detendrá en cuanto pueda",
|
||||
"fr": "Le nœud a reçu la demande d'arrêt mais il est occupé — il s'arrêtera dès que possible",
|
||||
"pt": "O nó recebeu o pedido de parada, mas está ocupado; ele vai parar assim que puder",
|
||||
"ru": "Узел получил команду остановки, но занят — он остановится, как только сможет",
|
||||
"zh": "节点已收到停止请求,但正忙——它会尽快停止",
|
||||
"ja": "ノードは停止要求を受け取りましたが処理中です — 可能になり次第停止します",
|
||||
"ko": "노드가 중지 요청을 받았지만 작업 중입니다 — 가능해지면 중지됩니다"
|
||||
},
|
||||
"shutdown_stop_delivered": {
|
||||
"de": "Der Node fährt herunter und speichert seinen Zustand", "es": "El nodo se está cerrando y guardando su estado",
|
||||
"fr": "Le nœud s'arrête et enregistre son état", "pt": "O nó está encerrando e salvando seu estado",
|
||||
"ru": "Узел завершает работу и сохраняет своё состояние", "zh": "节点正在关闭并保存其状态",
|
||||
"ja": "ノードを終了し、状態を保存しています", "ko": "노드가 종료 중이며 상태를 저장하고 있습니다"
|
||||
},
|
||||
"shutdown_stop_cpu_suffix": {
|
||||
"de": " · CPU %d%%", "es": " · CPU %d%%", "fr": " · CPU %d%%", "pt": " · CPU %d%%",
|
||||
"ru": " · ЦП %d%%", "zh": " · CPU %d%%", "ja": " · CPU %d%%", "ko": " · CPU %d%%"
|
||||
},
|
||||
"shutdown_stop_idle": {
|
||||
"de": "Seit %d Min. keine CPU- oder Festplattenaktivität – der Node hängt möglicherweise",
|
||||
"es": "Sin actividad de CPU ni de disco desde hace %d min; puede que el nodo esté bloqueado",
|
||||
"fr": "Aucune activité processeur ni disque depuis %d min — le nœud est peut-être bloqué",
|
||||
"pt": "Sem atividade de CPU ou disco há %d min; o nó pode estar travado",
|
||||
"ru": "Нет активности процессора и диска уже %d мин — возможно, узел завис",
|
||||
"zh": "已 %d 分钟没有 CPU 或磁盘活动——节点可能已卡住",
|
||||
"ja": "%d 分間 CPU やディスクの活動がありません — ノードが停止している可能性があります",
|
||||
"ko": "%d분 동안 CPU나 디스크 활동이 없습니다 — 노드가 멈췄을 수 있습니다"
|
||||
},
|
||||
# --- The node is busy: overlay, banner, status bar ---
|
||||
"node_busy_rebuild_title": {
|
||||
"de": "Node beschäftigt: Note-Witnesses werden neu aufgebaut",
|
||||
"es": "Nodo ocupado: reconstruyendo testigos de notas",
|
||||
"fr": "Nœud occupé : reconstruction des témoins de notes",
|
||||
"pt": "Nó ocupado: reconstruindo testemunhas de notas",
|
||||
"ru": "Узел занят: перестройка свидетелей заметок",
|
||||
"zh": "节点繁忙:正在重建票据见证",
|
||||
"ja": "ノード処理中:ノートウィットネスを再構築しています",
|
||||
"ko": "노드 작업 중: 노트 위트니스 재구성 중",
|
||||
},
|
||||
"node_busy_running": {
|
||||
"de": "Läuft seit %s",
|
||||
"es": "En curso desde hace %s",
|
||||
"fr": "En cours depuis %s",
|
||||
"pt": "Em andamento há %s",
|
||||
"ru": "Выполняется %s",
|
||||
"zh": "已运行 %s",
|
||||
"ja": "経過時間 %s",
|
||||
"ko": "%s 동안 진행 중",
|
||||
},
|
||||
"node_busy_rebuild_desc": {
|
||||
"de": "Läuft seit %s. Bei großen Wallets kann das etwa eine Stunde dauern, und der Node kann der Wallet erst danach antworten.",
|
||||
"es": "En curso desde hace %s. En carteras grandes puede tardar alrededor de una hora, y el nodo no puede responder a la cartera hasta que termine.",
|
||||
"fr": "En cours depuis %s. Pour un gros portefeuille, cela peut prendre environ une heure, et le nœud ne peut pas répondre au portefeuille avant la fin.",
|
||||
"pt": "Em andamento há %s. Em carteiras grandes pode levar cerca de uma hora, e o nó não consegue responder à carteira até terminar.",
|
||||
"ru": "Выполняется %s. Для больших кошельков это может занять около часа, и до окончания узел не может отвечать кошельку.",
|
||||
"zh": "已运行 %s。大型钱包可能需要约一小时,完成前节点无法响应钱包。",
|
||||
"ja": "経過時間 %s。大きなウォレットでは約 1 時間かかることがあり、完了するまでノードはウォレットに応答できません。",
|
||||
"ko": "%s 동안 진행 중입니다. 큰 지갑은 약 1시간이 걸릴 수 있으며, 끝날 때까지 노드가 지갑에 응답할 수 없습니다."
|
||||
},
|
||||
"node_busy_rebuild_reading": {
|
||||
"de": "Blöcke werden gelesen: %ld / %ld", "es": "Leyendo bloques: %ld / %ld", "fr": "Lecture des blocs : %ld / %ld",
|
||||
"pt": "Lendo blocos: %ld / %ld", "ru": "Чтение блоков: %ld / %ld", "zh": "正在读取区块:%ld / %ld",
|
||||
"ja": "ブロックを読み込み中:%ld / %ld", "ko": "블록 읽는 중: %ld / %ld"
|
||||
},
|
||||
"node_busy_rebuild_extending": {
|
||||
"de": "Witnesses werden erweitert (der Node meldet für diesen Schritt keinen Fortschritt).",
|
||||
"es": "Ampliando testigos (el nodo no informa del progreso de este paso).",
|
||||
"fr": "Extension des témoins (le nœud ne signale aucune progression pour cette étape).",
|
||||
"pt": "Estendendo testemunhas (o nó não informa o progresso desta etapa).",
|
||||
"ru": "Расширение свидетелей (узел не сообщает о ходе этого этапа).",
|
||||
"zh": "正在扩展见证(此步骤节点不报告进度)。",
|
||||
"ja": "ウィットネスを拡張しています(この段階ではノードは進捗を報告しません)。",
|
||||
"ko": "위트니스 확장 중(이 단계에서는 노드가 진행 상황을 보고하지 않습니다)."
|
||||
},
|
||||
"node_busy_replay_title": {
|
||||
"de": "Node beschäftigt: Blöcke werden nachgeholt", "es": "Nodo ocupado: poniéndose al día con los bloques",
|
||||
"fr": "Nœud occupé : rattrapage des blocs", "pt": "Nó ocupado: recuperando blocos",
|
||||
"ru": "Узел занят: догоняет блоки", "zh": "节点繁忙:正在追赶区块",
|
||||
"ja": "ノード処理中:ブロックを追いかけています", "ko": "노드 작업 중: 블록 따라잡는 중"
|
||||
},
|
||||
"node_busy_replay_desc": {
|
||||
"de": "Läuft seit %s. Der Node wendet Blöcke erneut an, die er vor dem letzten Beenden empfangen hat.",
|
||||
"es": "En curso desde hace %s. El nodo está volviendo a aplicar bloques que recibió antes de detenerse por última vez.",
|
||||
"fr": "En cours depuis %s. Le nœud réapplique les blocs reçus avant son dernier arrêt.",
|
||||
"pt": "Em andamento há %s. O nó está reaplicando blocos que recebeu antes da última parada.",
|
||||
"ru": "Выполняется %s. Узел повторно применяет блоки, полученные до последней остановки.",
|
||||
"zh": "已运行 %s。节点正在重新应用上次停止前收到的区块。",
|
||||
"ja": "経過時間 %s。ノードは前回停止する前に受信したブロックを再適用しています。",
|
||||
"ko": "%s 동안 진행 중입니다. 노드가 마지막으로 멈추기 전에 받은 블록을 다시 적용하고 있습니다."
|
||||
},
|
||||
"node_busy_rpc_title": {
|
||||
"de": "Node beschäftigt: antwortet noch nicht", "es": "Nodo ocupado: aún no responde",
|
||||
"fr": "Nœud occupé : ne répond pas encore", "pt": "Nó ocupado: ainda não responde",
|
||||
"ru": "Узел занят: пока не отвечает", "zh": "节点繁忙:暂未响应",
|
||||
"ja": "ノード処理中:まだ応答していません", "ko": "노드 작업 중: 아직 응답하지 않음"
|
||||
},
|
||||
"node_busy_rpc_desc": {
|
||||
"de": "Der Node läuft, ist aber seit %s zu beschäftigt, um der Wallet zu antworten – meist wegen eines langen Vorgangs wie dem Neuaufbau der Witnesses.",
|
||||
"es": "El nodo está en marcha, pero lleva %s demasiado ocupado para responder a la cartera, normalmente por una operación larga como la reconstrucción de testigos.",
|
||||
"fr": "Le nœud fonctionne mais est trop occupé pour répondre au portefeuille depuis %s, généralement à cause d'une longue opération comme la reconstruction des témoins.",
|
||||
"pt": "O nó está em execução, mas está ocupado demais para responder à carteira há %s, geralmente por causa de uma operação longa como a reconstrução de testemunhas.",
|
||||
"ru": "Узел работает, но уже %s слишком занят, чтобы отвечать кошельку, обычно из-за долгой операции, например перестройки свидетелей.",
|
||||
"zh": "节点正在运行,但已有 %s 忙于其他任务而无法响应钱包,通常是因为见证重建等耗时操作。",
|
||||
"ja": "ノードは実行中ですが、%s の間ウォレットに応答できないほど処理中です。通常はウィットネスの再構築などの長時間の処理が原因です。",
|
||||
"ko": "노드는 실행 중이지만 %s 동안 지갑에 응답할 수 없을 만큼 바쁩니다. 보통 위트니스 재구성 같은 긴 작업 때문입니다."
|
||||
},
|
||||
"node_starting_title": {
|
||||
"de": "DragonX-Node wird gestartet…", "es": "Iniciando el nodo DragonX…", "fr": "Démarrage du nœud DragonX…",
|
||||
"pt": "Iniciando o nó DragonX…", "ru": "Запуск узла DragonX…", "zh": "正在启动 DragonX 节点…",
|
||||
"ja": "DragonX ノードを起動しています…", "ko": "DragonX 노드 시작 중…"
|
||||
},
|
||||
"node_starting_busy_desc": {
|
||||
"de": "Der Node ist erreichbar, aber beschäftigt und antwortet noch nicht. Das ist nach einem Update oder beim ersten Start normal und kann einige Minuten dauern.",
|
||||
"es": "El nodo está accesible pero ocupado y aún no responde. Es normal tras una actualización o en el primer inicio; puede tardar unos minutos.",
|
||||
"fr": "Le nœud est joignable mais occupé et ne répond pas encore. C'est normal après une mise à jour ou au premier lancement ; cela peut prendre quelques minutes.",
|
||||
"pt": "O nó está acessível, mas ocupado, e ainda não responde. Isso é normal após uma atualização ou na primeira execução; pode levar alguns minutos.",
|
||||
"ru": "Узел доступен, но занят и пока не отвечает. Это нормально после обновления или при первом запуске; может занять несколько минут.",
|
||||
"zh": "节点可以访问,但正忙,暂未响应。更新后或首次启动时属正常现象,可能需要几分钟。",
|
||||
"ja": "ノードには接続できますが処理中のため、まだ応答していません。更新後や初回起動時には正常な状態で、数分かかることがあります。",
|
||||
"ko": "노드에 연결할 수 있지만 바빠서 아직 응답하지 않습니다. 업데이트 후나 처음 실행할 때는 정상이며, 몇 분 걸릴 수 있습니다."
|
||||
},
|
||||
"node_starting_launch_desc": {
|
||||
"de": "dragonxd wird gestartet, warte auf Verbindung…", "es": "Iniciando dragonxd y esperando a que esté en línea…",
|
||||
"fr": "Lancement de dragonxd, en attente de sa disponibilité…", "pt": "Iniciando o dragonxd e aguardando ficar online…",
|
||||
"ru": "Запуск dragonxd и ожидание его готовности…", "zh": "正在启动 dragonxd 并等待其上线…",
|
||||
"ja": "dragonxd を起動し、オンラインになるのを待っています…", "ko": "dragonxd를 시작하고 온라인 상태가 되기를 기다리는 중…"
|
||||
},
|
||||
"sb_node_busy": {
|
||||
"de": "Node beschäftigt", "es": "Nodo ocupado", "fr": "Nœud occupé", "pt": "Nó ocupado",
|
||||
"ru": "Узел занят", "zh": "节点繁忙", "ja": "ノード処理中", "ko": "노드 작업 중"
|
||||
},
|
||||
# --- Quitting while the node is busy ---
|
||||
"stopconfirm_rebuild_title": {
|
||||
"de": "Der Node baut die Note-Witnesses neu auf", "es": "El nodo está reconstruyendo los testigos de notas",
|
||||
"fr": "Le nœud reconstruit les témoins de notes", "pt": "O nó está reconstruindo as testemunhas de notas",
|
||||
"ru": "Узел перестраивает свидетелей заметок", "zh": "节点正在重建票据见证",
|
||||
"ja": "ノードはノートウィットネスを再構築しています", "ko": "노드가 노트 위트니스를 재구성하고 있습니다"
|
||||
},
|
||||
"stopconfirm_rebuild_body": {
|
||||
"de": "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.",
|
||||
"es": "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.",
|
||||
"fr": "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.",
|
||||
"pt": "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.",
|
||||
"ru": "Она выполняется уже %s, а для больших кошельков может занять около часа. Если остановить узел сейчас, он либо дождётся окончания перестройки, либо при следующем запуске выполнит её заново вместе с догонянием блоков.\n\nРекомендуется: закройте кошелёк и оставьте узел работать. Он завершит всё сам.",
|
||||
"zh": "已运行 %s,大型钱包可能需要约一小时。现在停止节点,要么会等待重建完成,要么下次启动时需要重新进行重建以及区块追赶。\n\n建议:关闭钱包并保持节点运行,它会自行完成。",
|
||||
"ja": "経過時間は %s で、大きなウォレットでは約 1 時間かかることがあります。今ノードを停止すると、再構築の完了まで待つか、次回の起動時に再構築とブロックの追いつきをやり直すことになります。\n\n推奨:ウォレットを閉じ、ノードは実行したままにしてください。ノードは自動的に完了します。",
|
||||
"ko": "%s 동안 진행 중이며, 큰 지갑은 약 1시간이 걸릴 수 있습니다. 지금 노드를 중지하면 재구성이 끝날 때까지 기다리거나, 다음 시작 시 재구성과 블록 따라잡기를 다시 해야 합니다.\n\n권장: 지갑을 닫고 노드는 계속 실행하세요. 노드가 스스로 마칩니다."
|
||||
},
|
||||
"stopconfirm_busy_title": {
|
||||
"de": "Der Node ist beschäftigt", "es": "El nodo está ocupado", "fr": "Le nœud est occupé",
|
||||
"pt": "O nó está ocupado", "ru": "Узел занят", "zh": "节点正忙", "ja": "ノードは処理中です",
|
||||
"ko": "노드가 작업 중입니다"
|
||||
},
|
||||
"stopconfirm_busy_body": {
|
||||
"de": "Er ist seit %s zu beschäftigt, um der Wallet zu antworten – vermutlich wegen eines langen Vorgangs wie dem Neuaufbau der Witnesses. Wenn Sie ihn jetzt stoppen, muss der nächste Start diese Arbeit eventuell wiederholen.\n\nEmpfohlen: Wallet schließen und den Node weiterlaufen lassen. Er beendet die Arbeit selbstständig.",
|
||||
"es": "Lleva %s demasiado ocupado para responder a la cartera, probablemente por una operación larga como la reconstrucción de testigos. Detenerlo ahora puede hacer que el próximo inicio repita ese trabajo.\n\nRecomendado: cierra la cartera y deja el nodo en marcha. Terminará por su cuenta.",
|
||||
"fr": "Il est trop occupé pour répondre au portefeuille depuis %s, probablement à cause d'une longue opération comme la reconstruction des témoins. L'arrêter maintenant peut obliger son prochain démarrage à refaire ce travail.\n\nRecommandé : fermez le portefeuille et laissez le nœud actif. Il terminera seul.",
|
||||
"pt": "Está ocupado demais para responder à carteira há %s, provavelmente por causa de uma operação longa como a reconstrução de testemunhas. Pará-lo agora pode fazer o próximo início refazer esse trabalho.\n\nRecomendado: feche a carteira e deixe o nó em execução. Ele termina sozinho.",
|
||||
"ru": "Уже %s он слишком занят, чтобы отвечать кошельку, вероятно, из-за долгой операции, например перестройки свидетелей. Если остановить его сейчас, при следующем запуске эту работу, возможно, придётся выполнить заново.\n\nРекомендуется: закройте кошелёк и оставьте узел работать. Он завершит всё сам.",
|
||||
"zh": "节点已有 %s 忙于其他任务而无法响应钱包,可能是因为见证重建等耗时操作。现在停止它,下次启动时可能需要重做这些工作。\n\n建议:关闭钱包并保持节点运行,它会自行完成。",
|
||||
"ja": "%s の間、ウォレットに応答できないほど処理中です。ウィットネスの再構築などの長時間の処理が原因と考えられます。今停止すると、次回の起動時にその処理をやり直すことがあります。\n\n推奨:ウォレットを閉じ、ノードは実行したままにしてください。ノードは自動的に完了します。",
|
||||
"ko": "%s 동안 지갑에 응답할 수 없을 만큼 바빴습니다. 위트니스 재구성 같은 긴 작업 때문일 가능성이 큽니다. 지금 중지하면 다음 시작 시 그 작업을 다시 해야 할 수 있습니다.\n\n권장: 지갑을 닫고 노드는 계속 실행하세요. 노드가 스스로 마칩니다."
|
||||
},
|
||||
"stopconfirm_stop_wait": {
|
||||
"de": "Node stoppen & warten", "es": "Detener el nodo y esperar", "fr": "Arrêter le nœud et attendre",
|
||||
"pt": "Parar o nó e aguardar", "ru": "Остановить узел и подождать", "zh": "停止节点并等待",
|
||||
"ja": "ノードを停止して待つ", "ko": "노드 중지 후 대기"
|
||||
},
|
||||
"appx_daemon_reinstall_node_busy": {
|
||||
"de": "Der Node läuft noch, daher wurde der Daemon nicht neu installiert. Versuchen Sie es erneut, sobald er beendet ist.",
|
||||
"es": "El nodo sigue en marcha, así que no se reinstaló el daemon. Vuelve a intentarlo cuando se haya detenido.",
|
||||
"fr": "Le nœud est toujours actif, le démon n'a donc pas été réinstallé. Réessayez une fois qu'il sera arrêté.",
|
||||
"pt": "O nó ainda está em execução, por isso o daemon não foi reinstalado. Tente novamente quando ele tiver parado.",
|
||||
"ru": "Узел всё ещё работает, поэтому демон не был переустановлен. Повторите попытку, когда он остановится.",
|
||||
"zh": "节点仍在运行,因此未重新安装守护进程。请在它停止后重试。",
|
||||
"ja": "ノードがまだ実行中のため、デーモンは再インストールされませんでした。停止してからもう一度お試しください。",
|
||||
"ko": "노드가 아직 실행 중이라 데몬을 다시 설치하지 않았습니다. 노드가 멈춘 뒤 다시 시도하세요."
|
||||
},
|
||||
# --- Balance tab ---
|
||||
"hide_zero_balances": {
|
||||
"es": "Ocultar saldos 0", "de": "Nullsalden ausblenden", "fr": "Masquer les soldes à 0",
|
||||
|
||||
Executable
+79
@@ -0,0 +1,79 @@
|
||||
#!/usr/bin/env bash
|
||||
# Cross-compile the test suite (ObsidianDragonTests.exe) and the fake daemon (tools/fake_dragonxd)
|
||||
# for Windows with mingw-w64, then run the tests: directly under WSL (Windows interop), else with
|
||||
# wine if installed, else just build.
|
||||
#
|
||||
# scripts/test-windows.sh [--no-run] [--jobs N]
|
||||
#
|
||||
# Uses its own build/windows-tests tree (full-node config), so build/windows is left alone. Needs
|
||||
# the mingw toolchain file that ./build.sh --win-release writes to build/windows/. Dependency sources
|
||||
# already fetched into build/windows/_deps are reused, so nothing is downloaded twice.
|
||||
set -euo pipefail
|
||||
|
||||
repo="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
bd="$repo/build/windows-tests"
|
||||
toolchain="$repo/build/windows/mingw-toolchain.cmake"
|
||||
deps="$repo/build/windows/_deps"
|
||||
run=true
|
||||
jobs=8 # each test TU is large; several parallel mingw builds can exhaust RAM
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--no-run) run=false; shift ;;
|
||||
--jobs) jobs="$2"; shift 2 ;;
|
||||
-h|--help) sed -n '2,10p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
|
||||
*) echo "unknown option: $1" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ ! -f "$toolchain" ]]; then
|
||||
echo "missing $toolchain; run ./build.sh --win-release once to create the mingw toolchain" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
src_args=()
|
||||
for dep in sdl3 json tomlplusplus sqlite3 curl libwebp; do
|
||||
if [[ -d "$deps/$dep-src" ]]; then
|
||||
src_args+=("-DFETCHCONTENT_SOURCE_DIR_${dep^^}=$deps/$dep-src")
|
||||
fi
|
||||
done
|
||||
|
||||
cmake -S "$repo" -B "$bd" \
|
||||
-DCMAKE_TOOLCHAIN_FILE="$toolchain" \
|
||||
-DCMAKE_BUILD_TYPE=Release \
|
||||
-DDRAGONX_USE_SYSTEM_SDL3=OFF \
|
||||
-DDRAGONX_BUILD_LITE=OFF \
|
||||
-DDRAGONX_ENABLE_LITE_BACKEND=OFF \
|
||||
-DBUILD_TESTING=ON \
|
||||
-DDRAGONX_BUILD_FAKE_DAEMON=ON \
|
||||
"${src_args[@]}"
|
||||
cmake --build "$bd" --target ObsidianDragonTests fake_dragonxd -j "$jobs"
|
||||
|
||||
exe="$bd/bin/ObsidianDragonTests.exe"
|
||||
echo "built: $exe"
|
||||
echo "built: $bd/fake_dragonxd/dragonxd.exe"
|
||||
$run || exit 0
|
||||
|
||||
# The fixture path compiled into the binary is a Linux path; hand the Windows process its own.
|
||||
# APPDATA/LOCALAPPDATA point at a sandbox so anything resolving them from the environment stays out
|
||||
# of the real profile. (SHGetFolderPath-based paths ignore the environment; the tests that would
|
||||
# write through those are compiled out on Windows.)
|
||||
export DRAGONX_TEST_FIXTURE_DIR="$repo/tests/fixtures"
|
||||
sandbox="$bd/test-appdata"
|
||||
mkdir -p "$sandbox"
|
||||
export APPDATA="$sandbox" LOCALAPPDATA="$sandbox"
|
||||
# The fake dragonxd.exe plays a "foreign" node (isolated datadir/port) for the never-kill-by-name test.
|
||||
export DRAGONX_TEST_FAKE_DAEMON="$bd/fake_dragonxd/dragonxd.exe"
|
||||
if grep -qi microsoft /proc/version 2>/dev/null && [[ -e /proc/sys/fs/binfmt_misc/WSLInterop ]]; then
|
||||
# /p: translate each to a Windows path for the Windows process
|
||||
export WSLENV="${WSLENV:+$WSLENV:}DRAGONX_TEST_FIXTURE_DIR/p:APPDATA/p:LOCALAPPDATA/p:DRAGONX_TEST_FAKE_DAEMON/p"
|
||||
cd "$bd/bin"
|
||||
exec "$exe"
|
||||
elif command -v wine >/dev/null 2>&1; then
|
||||
export DRAGONX_TEST_FIXTURE_DIR="$(winepath -w "$DRAGONX_TEST_FIXTURE_DIR")"
|
||||
export DRAGONX_TEST_FAKE_DAEMON="$(winepath -w "$DRAGONX_TEST_FAKE_DAEMON")"
|
||||
cd "$bd/bin"
|
||||
exec wine "$exe"
|
||||
else
|
||||
echo "not on WSL and no wine: copy $exe (and tests/fixtures) to Windows to run it" >&2
|
||||
fi
|
||||
+738
-294
File diff suppressed because it is too large
Load Diff
@@ -21,6 +21,9 @@
|
||||
#include "data/wallet_index.h"
|
||||
#include "data/wallet_state.h"
|
||||
#include "rpc/connection.h"
|
||||
#include "daemon/stop_delivery.h"
|
||||
#include "daemon/daemon_busy_detect.h"
|
||||
#include "rpc/rpc_busy_gate.h"
|
||||
#include "services/network_refresh_service.h"
|
||||
#include "services/wallet_security_controller.h"
|
||||
#include "services/wallet_security_workflow.h"
|
||||
@@ -190,11 +193,29 @@ public:
|
||||
*/
|
||||
std::vector<std::string> tailDaemonDebugLog(int maxLines) const;
|
||||
|
||||
// True when the daemon's debug.log shows an in-progress Sapling witness-cache rebuild (best-effort
|
||||
// heuristic). Stopping the daemon during one discards it and forces a multi-minute redo next launch.
|
||||
bool daemonWitnessRebuildActive() const;
|
||||
// Whether beginShutdown() should pause and confirm before stopping the daemon (rebuild in progress).
|
||||
// Whether beginShutdown() should pause and confirm before stopping the daemon: this quit would stop it,
|
||||
// and it is rebuilding witnesses (its own log says so) or has been alive but too busy to answer RPC for
|
||||
// a while. Stopping mid-rebuild throws the rebuild away, and a node killed then replays and redoes it.
|
||||
bool shouldConfirmDaemonStop() const;
|
||||
// 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;
|
||||
// May quitting (or a default-policy stop) stop a node this session didn't launch? With stop_external_daemon
|
||||
// on, or when it is the node an earlier session of ours launched and left running (its ownership record
|
||||
// matches a live process). That one is stopped gracefully and waited for, never killed.
|
||||
bool mayStopExternalNode() 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);
|
||||
// Called by main.cpp: brings the window back (restore + raise) when a quit is deferred for the confirm
|
||||
// modal while minimized or behind other windows — the modal only renders in a visible frame.
|
||||
void setRaiseWindowCallback(std::function<void()> cb) { raise_window_ = std::move(cb); }
|
||||
// The "node is rebuilding — stop anyway / keep running / cancel" modal, rendered from render().
|
||||
void renderDaemonStopConfirm();
|
||||
|
||||
@@ -267,7 +288,7 @@ public:
|
||||
bool isConnected() const { return state_.connected; }
|
||||
int getBlockHeight() const { return state_.sync.blocks; }
|
||||
const std::string& getConnectionStatus() const { return connection_status_; }
|
||||
const std::string& getDaemonStatus() const { return daemon_status_; }
|
||||
std::string getDaemonStatus() const; // thread-safe copy (daemon_status_ is written off-thread)
|
||||
|
||||
// Balance info (convenience wrappers)
|
||||
double getShieldedBalance() const { return state_.shielded_balance; }
|
||||
@@ -480,12 +501,22 @@ public:
|
||||
|
||||
// Embedded daemon control
|
||||
bool startEmbeddedDaemon();
|
||||
void stopEmbeddedDaemon();
|
||||
// Stop the node specifically for a wallet switch, which MUST free the RPC port to relaunch on
|
||||
// -wallet=<name>. 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).
|
||||
// How long stopEmbeddedDaemon() may wait, and who decides. It never kills the node on its own.
|
||||
enum class StopMode {
|
||||
AppShutdown, // quitting: no time limit; the shutdown screen offers "Leave running" (and "Kill" when stuck)
|
||||
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
|
||||
};
|
||||
// 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=<name>. 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_; }
|
||||
@@ -632,6 +663,8 @@ public:
|
||||
|
||||
private:
|
||||
friend class AppDaemonLifecycleRuntime;
|
||||
friend class AppStopRuntime;
|
||||
friend class AppStopTaskContext;
|
||||
friend class AppDaemonLifecycleTaskContext;
|
||||
|
||||
// Global keyboard-shortcut handling, dispatched once per frame from update().
|
||||
@@ -751,6 +784,7 @@ private:
|
||||
chat::ChatDatabase chat_db_; // persistent backing (seed-derived encryption at rest)
|
||||
bool chat_identity_provisioned_ = false; // identity set on the service this session
|
||||
bool chat_identity_fetch_in_flight_ = false; // a z_exportmnemonic worker job is pending
|
||||
std::chrono::steady_clock::time_point chat_identity_retry_at_{}; // after a transient failure
|
||||
// Bumped by resetChatSession() on every wallet change; an in-flight identity fetch captures the
|
||||
// value at post time and its completion callback discards its (previous-wallet) secret if the id
|
||||
// no longer matches — so wallet A's seed can't be provisioned under wallet B via a stale job.
|
||||
@@ -884,6 +918,7 @@ private:
|
||||
std::function<void(bool, const std::string&)> makeSweepCompletionCallback(bool resumed);
|
||||
void beginSweepToSeedWallet(); // z_mergetoaddress ["ANY_TADDR","ANY_ZADDR"] -> dest
|
||||
void pollSweepStatus(); // Confirming step: poll sweep confirmations + legacy balance
|
||||
void locateSweepTxid(); // recover a lost sweep txid (send to dest) -> Confirming gate
|
||||
void beginAdoptSeedWallet(); // stop daemon -> swap wallet.dat -> restart with -rescan
|
||||
void provisionChatIdentityFromSecret(std::string secret);
|
||||
std::string chatReplyZaddr(); // a stable (persisted) wallet z-addr — chat IDENTITY (reply-to)
|
||||
@@ -963,9 +998,19 @@ private:
|
||||
bool shutdown_confirmed_ = false; // user chose to proceed — bypass the guard on re-entry
|
||||
bool shutdown_keep_daemon_override_ = false; // user chose "keep node running" for this shutdown only
|
||||
float shutdown_timer_ = 0.0f;
|
||||
bool force_quit_confirm_ = false;
|
||||
std::chrono::steady_clock::time_point shutdown_start_time_;
|
||||
|
||||
// One stop session at a time (stopEmbeddedDaemon), and the shutdown screen's view of it.
|
||||
std::mutex stop_mutex_;
|
||||
std::atomic<bool> stop_session_active_{false};
|
||||
std::atomic<int> stop_user_choice_{0}; // daemon::StopUserChoice, set by the shutdown screen
|
||||
std::atomic<bool> stop_kill_offered_{false}; // the node looks stuck: the screen may offer "Kill"
|
||||
std::atomic<int> stop_idle_minutes_{0};
|
||||
std::mutex stop_client_mutex_;
|
||||
rpc::RPCClient* active_stop_client_ = nullptr; // aborted on "Leave running" so a pending attempt ends at once
|
||||
bool stop_kill_confirm_ = false;
|
||||
void chooseStopAction(daemon::StopUserChoice choice);
|
||||
|
||||
// Daemon restart (e.g. after changing debug log categories)
|
||||
std::atomic<bool> daemon_restarting_{false};
|
||||
|
||||
@@ -1046,6 +1091,11 @@ private:
|
||||
// Set by the deleteBlockchainData worker (item count); the main loop surfaces a completion toast
|
||||
// and resets it to -1. Atomic because the worker thread writes it and the UI thread reads/clears it.
|
||||
std::atomic<int> pending_delete_result_{-1};
|
||||
std::atomic<bool> 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<int> pending_stop_failed_{0};
|
||||
|
||||
// Encryption state check timeout
|
||||
float encryption_check_timer_ = 0.0f;
|
||||
@@ -1069,6 +1119,7 @@ private:
|
||||
bool seed_backup_loading_ = false;
|
||||
bool seed_backup_no_mnemonic_ = false;
|
||||
bool seed_backup_reminder_in_flight_ = false; // guards the one-time backup nudge probe
|
||||
std::chrono::steady_clock::time_point seed_backup_reminder_retry_at_{}; // after an inconclusive probe
|
||||
bool large_wallet_checked_ = false; // gate: stat wallet.dat for the bloat nudge once per launch
|
||||
|
||||
// Cached mnemonic status of the current wallet, driving the Migrate-to-seed button glow. Probed
|
||||
@@ -1123,9 +1174,58 @@ private:
|
||||
|
||||
// Embedded daemon state
|
||||
bool use_embedded_daemon_ = wallet::supportsEmbeddedDaemon(wallet::currentWalletCapabilities());
|
||||
std::string daemon_status_;
|
||||
// Written by the DaemonController state callback (which fires on EmbeddedDaemon's monitor thread)
|
||||
// AND read on the UI thread — so all access goes through set/getDaemonStatus() under this mutex.
|
||||
// getDaemonStatus() returns a COPY: a returned reference could dangle if the monitor thread
|
||||
// reassigned the string mid-read (data race / use-after-free).
|
||||
mutable std::mutex daemon_status_mutex_;
|
||||
std::string daemon_status_; // guarded by daemon_status_mutex_ — use set/getDaemonStatus()
|
||||
void setDaemonStatus(std::string s);
|
||||
mutable std::string daemon_mem_diag_; // diagnostic info for daemon memory detection
|
||||
size_t daemon_output_offset_ = 0; // for incremental output parsing (rescan detection)
|
||||
// What the node's own output says it is doing (witness rebuild / startup replay). Fed on the UI thread
|
||||
// from the captured output; node_rebuilding_ is its snapshot for the shutdown thread's stop loop.
|
||||
daemon::DaemonBusyTracker daemon_busy_;
|
||||
std::atomic<bool> node_rebuilding_{false};
|
||||
std::atomic<bool> daemon_busy_reset_pending_{false}; // set from any thread; applied by feedDaemonBusy()
|
||||
std::chrono::steady_clock::time_point rpc_busy_since_{}; // epoch = not busy
|
||||
std::function<void()> raise_window_;
|
||||
// S6: once the node is too busy to answer (a reply timeout or a full queue), the polling clients
|
||||
// (rpc_, fast_rpc_) hold back everything that needs cs_main, and one canary request on its own
|
||||
// client waits for the node to answer again. rpc_busy_gate_ is shared with those clients.
|
||||
std::shared_ptr<rpc::RpcBusyGate> rpc_busy_gate_ = std::make_shared<rpc::RpcBusyGate>();
|
||||
rpc::ConnectRetryBackoff connect_backoff_; // S8: per-failure-kind delay before the next connect attempt
|
||||
std::thread canary_thread_;
|
||||
std::atomic<int> canary_state_{0}; // CanaryState
|
||||
std::mutex canary_mutex_;
|
||||
rpc::RPCClient* canary_client_ = nullptr; // aborted on shutdown
|
||||
rpc::ConnectRetryBackoff canary_backoff_; // a canary refused as queue-full waits before the next
|
||||
std::chrono::steady_clock::time_point canary_retry_at_{};
|
||||
void pumpRpcBusyCanary();
|
||||
// A node this session didn't launch (left running by an earlier session, or found running): feed the busy
|
||||
// tracker from its <datadir>/debug.log, while that log is tied to the live process (see
|
||||
// daemon::externalLogFeedOffset). Our own running node's output feeds the tracker directly. UI thread.
|
||||
void pumpExternalDaemonLog();
|
||||
long externalNodePid() const; // that node: our ownership record, or (POSIX) its hushd.pid; 0 if none
|
||||
bool busyTrackerIsLive() const; // the tracker describes a node that is running now
|
||||
bool ext_log_tied_ = false;
|
||||
long ext_log_pid_ = 0;
|
||||
std::uintmax_t ext_log_offset_ = 0;
|
||||
double ext_log_next_at_ = 0.0;
|
||||
void abortRpcBusyCanary();
|
||||
void feedDaemonBusy(const std::string& chunk);
|
||||
void resetDaemonBusy();
|
||||
std::chrono::steady_clock::time_point busy_first_seen_{}; // when the tracker last turned busy (UI thread)
|
||||
// What to tell the user while the node is busy (overlay, banner, quit prompt). busy=false: it isn't.
|
||||
struct NodeBusyText {
|
||||
bool busy = false;
|
||||
bool rebuilding = false;
|
||||
std::string title; // e.g. "Node busy: rebuilding note witnesses — do not close"
|
||||
std::string detail; // running time, what it's doing, progress when the node reports any
|
||||
std::string elapsed; // "12:03"
|
||||
std::string compact; // one line for the banner: "Running for 12:03 · Reading blocks: 45438 / 62583"
|
||||
};
|
||||
NodeBusyText nodeBusyText() const;
|
||||
|
||||
// Export/Import state
|
||||
char export_result_[256] = {0}; // SECRET exported key — fixed buffer so it can be sodium_memzero'd
|
||||
@@ -1514,6 +1614,17 @@ private:
|
||||
// `reachableButBusy` is true when the probe connected but got no RPC reply (a timeout),
|
||||
// false when the daemon is merely launching (not bound yet). Returns the status title.
|
||||
std::string applyDaemonInitStatus(bool reachableButBusy);
|
||||
// The overlay text applyDaemonInitStatus last produced. It runs only on a connect failure, which a busy
|
||||
// node backs off to every 30s, so update() re-applies it once a second (the elapsed time and the
|
||||
// node's own progress tick) for as long as nothing else has replaced the status.
|
||||
std::string init_status_title_;
|
||||
bool init_status_busy_ = false;
|
||||
double status_refresh_at_ = 0.0;
|
||||
// The warm-up overlay text, from the node's last -28 message — unless its own log says it is rebuilding
|
||||
// witnesses: a startup rebuild runs under "Activating best chain...", which hides the step users must
|
||||
// not interrupt. update() re-applies it once a second too (see init_status_title_).
|
||||
void applyWarmupText(const std::string& rpcMessage);
|
||||
std::string warmup_rpc_message_;
|
||||
// Tear down a connection that died mid-session (daemon crash / restart / dropped
|
||||
// socket) so update()'s reconnect branch re-enters tryConnect(). Unlike onDisconnected
|
||||
// alone, this also rpc_->disconnect()s so rpc_->isConnected() actually flips to false.
|
||||
|
||||
+243
-49
@@ -264,6 +264,79 @@ void App::detectWalletDegraded()
|
||||
VERBOSE_LOGF("[recovery] Daemon opened wallet in DEGRADED mode — new-key derivation disabled\n");
|
||||
}
|
||||
|
||||
// One canary per busy period: a getinfo on its own client with NO client timeout, so it is the one request
|
||||
// we let wait in the node's queue instead of re-sending (each re-send would take another slot). Its answer
|
||||
// means the node serves cs_main requests again.
|
||||
enum CanaryState { kCanaryIdle = 0, kCanaryRunning, kCanaryAnswered, kCanaryRefused, kCanaryGone };
|
||||
|
||||
void App::abortRpcBusyCanary()
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(canary_mutex_);
|
||||
if (canary_client_) canary_client_->requestAbort();
|
||||
}
|
||||
|
||||
void App::pumpRpcBusyCanary()
|
||||
{
|
||||
const int st = canary_state_.load();
|
||||
if (st >= kCanaryAnswered) {
|
||||
if (canary_thread_.joinable()) canary_thread_.join();
|
||||
canary_state_ = kCanaryIdle;
|
||||
if (st == kCanaryAnswered) {
|
||||
DEBUG_LOGF("[busy] the node answered again after %.0fs; resuming normal polling\n",
|
||||
rpc_busy_gate_->busySeconds());
|
||||
rpc_busy_gate_->clear();
|
||||
canary_backoff_.reset();
|
||||
connect_backoff_.reset();
|
||||
network_refresh_.markImmediateRefresh(); // also makes a pending connect attempt due at once
|
||||
} else if (st == kCanaryRefused) {
|
||||
const float wait = canary_backoff_.next(rpc::RpcFailureKind::QueueFull);
|
||||
canary_retry_at_ = std::chrono::steady_clock::now() +
|
||||
std::chrono::milliseconds(static_cast<int>(wait * 1000.0f));
|
||||
DEBUG_LOGF("[busy] canary refused (queue full); next one in %.0fs\n", wait);
|
||||
} else {
|
||||
// Nothing listening any more (or our own abort): there's no busy node to wait for. Normal
|
||||
// connect / lost-connection handling takes over.
|
||||
DEBUG_LOGF("[busy] canary got no answer; leaving busy mode\n");
|
||||
rpc_busy_gate_->clear();
|
||||
canary_backoff_.reset();
|
||||
}
|
||||
}
|
||||
if (!rpc_busy_gate_->busy() || canary_state_.load() != kCanaryIdle || shutting_down_) return;
|
||||
if (std::chrono::steady_clock::now() < canary_retry_at_) return;
|
||||
|
||||
const rpc::ConnectionConfig cfg = (!saved_config_.rpcuser.empty() || !saved_config_.rpcpassword.empty())
|
||||
? saved_config_ : rpc::Connection::autoDetectConfig();
|
||||
DEBUG_LOGF("[busy] the node is too busy to answer (%d request(s) given up on): holding back polls; "
|
||||
"one canary getinfo waits for it\n", rpc_busy_gate_->abandoned());
|
||||
canary_state_ = kCanaryRunning;
|
||||
canary_thread_ = std::thread([this, cfg]() {
|
||||
rpc::RPCClient client; // deliberately ungated: this is the one request we allow
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(canary_mutex_);
|
||||
canary_client_ = &client;
|
||||
}
|
||||
int result = kCanaryGone;
|
||||
if (!shutting_down_ && client.open(cfg.host, cfg.port, cfg.rpcuser, cfg.rpcpassword, cfg.use_tls)) {
|
||||
try {
|
||||
rpc::RPCClient::TraceScope trace("Busy node / Canary");
|
||||
client.call("getinfo", json::array(), 0L); // 0 = no client timeout
|
||||
result = kCanaryAnswered;
|
||||
} catch (const std::exception& e) {
|
||||
const rpc::RpcFailureKind kind = rpc::rpcFailureKindOf(e);
|
||||
if (kind == rpc::RpcFailureKind::AppError || kind == rpc::RpcFailureKind::Warmup)
|
||||
result = kCanaryAnswered; // a worker ran it: cs_main is free again
|
||||
else if (kind == rpc::RpcFailureKind::QueueFull)
|
||||
result = kCanaryRefused;
|
||||
}
|
||||
}
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(canary_mutex_);
|
||||
canary_client_ = nullptr;
|
||||
}
|
||||
canary_state_ = result;
|
||||
});
|
||||
}
|
||||
|
||||
void App::tryConnect()
|
||||
{
|
||||
// Lite builds have no full node / RPC daemon, so never run the RPC connection state machine
|
||||
@@ -324,8 +397,7 @@ void App::tryConnect()
|
||||
if (++daemon_wait_attempts_ == kDaemonWaitWarnAttempts) {
|
||||
ui::Notifications::instance().warning(TR("daemon_port_busy_warn"), 20.0f);
|
||||
}
|
||||
network_refresh_.setTimer(services::NetworkRefreshService::Timer::Core,
|
||||
services::RefreshScheduler::kCoreDefault - 1.0f);
|
||||
network_refresh_.deferFor(services::NetworkRefreshService::Timer::Core, 4.0f);
|
||||
return;
|
||||
}
|
||||
daemon_wait_attempts_ = 0; // port is free — clear the bounded-wait counter
|
||||
@@ -338,8 +410,7 @@ void App::tryConnect()
|
||||
if (startEmbeddedDaemon()) {
|
||||
// Will retry connection after daemon starts
|
||||
VERBOSE_LOGF("[connect #%d] Embedded daemon starting, will retry connection...\n", connect_attempt);
|
||||
network_refresh_.setTimer(services::NetworkRefreshService::Timer::Core,
|
||||
services::RefreshScheduler::kCoreDefault - 1.0f);
|
||||
network_refresh_.deferFor(services::NetworkRefreshService::Timer::Core, 1.0f);
|
||||
} else {
|
||||
// The daemon couldn't be started (binary not found, Sapling params missing, spawn
|
||||
// failure, …). Surface the actual reason instead of leaving the status stuck on
|
||||
@@ -356,8 +427,7 @@ void App::tryConnect()
|
||||
detail.empty() ? std::string(TR("sb_daemon_start_failed")) : detail, 30.0f);
|
||||
}
|
||||
// Keep retrying: a missing binary/params can be fixed without a restart.
|
||||
network_refresh_.setTimer(services::NetworkRefreshService::Timer::Core,
|
||||
services::RefreshScheduler::kCoreDefault - 1.0f);
|
||||
network_refresh_.deferFor(services::NetworkRefreshService::Timer::Core, 5.0f);
|
||||
}
|
||||
} else if (!isUsingEmbeddedDaemon()) {
|
||||
VERBOSE_LOGF("[connect #%d] Embedded daemon disabled (using external). No config found at %s\n",
|
||||
@@ -444,10 +514,14 @@ void App::tryConnect()
|
||||
worker_->post([this, config, daemonStarting, externalDetected, attempt]() -> rpc::RPCWorker::MainCb {
|
||||
bool connected = rpc_->connect(config.host, config.port, config.rpcuser, config.rpcpassword, config.use_tls);
|
||||
std::string connectErr = rpc_->getLastConnectError();
|
||||
const rpc::RpcFailureKind connectKind = rpc_->getLastConnectFailureKind();
|
||||
bool warmingUp = rpc_->isWarmingUp();
|
||||
std::string warmupStatus = rpc_->getWarmupStatus();
|
||||
|
||||
return [this, config, connected, warmingUp, warmupStatus, daemonStarting, externalDetected, attempt, connectErr]() {
|
||||
return [this, config, connected, warmingUp, warmupStatus, daemonStarting, externalDetected, attempt, connectErr,
|
||||
connectKind]() {
|
||||
noteDaemonRpcResult(connected, connectKind); // a busy node (timeouts / queue full) feeds the quit guard
|
||||
if (connected) connect_backoff_.reset();
|
||||
if (connected) {
|
||||
VERBOSE_LOGF("[connect #%d] Connected successfully%s\n", attempt,
|
||||
warmingUp ? " (daemon warming up)" : "");
|
||||
@@ -459,22 +533,13 @@ void App::tryConnect()
|
||||
// the UI shows status instead of a blocking overlay.
|
||||
state_.warming_up = true;
|
||||
if (connect_stall_since_ <= 0.0) connect_stall_since_ = ImGui::GetTime(); // start the "taking too long" clock
|
||||
auto wt = translateWarmup(warmupStatus);
|
||||
state_.warmup_status = wt.title;
|
||||
state_.warmup_description = wt.description;
|
||||
// Append current block height from daemon output
|
||||
if (daemon_controller_) {
|
||||
int h = daemon_controller_->lastBlockHeight();
|
||||
if (h > 0)
|
||||
state_.warmup_status += " (Block " + std::to_string(h) + ")";
|
||||
}
|
||||
connection_status_ = state_.warmup_status;
|
||||
applyWarmupText(warmupStatus);
|
||||
}
|
||||
} else {
|
||||
// HTTP 401 = authentication failure. The daemon is running
|
||||
// but our rpcuser/rpcpassword don't match. Don't retry
|
||||
// endlessly — tell the user what's wrong.
|
||||
bool authFailure = (connectErr.find("401") != std::string::npos);
|
||||
bool authFailure = (connectKind == rpc::RpcFailureKind::Auth);
|
||||
if (authFailure) {
|
||||
rpc::ConnectionConfig cookieConfig;
|
||||
if (rpc::Connection::buildCookieAuthConfig(config, cookieConfig)) {
|
||||
@@ -520,18 +585,20 @@ void App::tryConnect()
|
||||
// a connect refusal means it hasn't bound the RPC port yet. Either way, show a
|
||||
// clear "node initializing" overlay (status + phase + block height from the
|
||||
// daemon's own console output) instead of a bare technical error.
|
||||
const bool reachableButBusy = connectErr.find("Timeout") != std::string::npos;
|
||||
// Reached the node but no answer (reply timeout / full queue / held back): up but busy.
|
||||
// A refusal or connect timeout means it hasn't bound (or has already unbound) RPC.
|
||||
const bool reachableButBusy = rpc::rpcKindMeansBusy(connectKind);
|
||||
applyDaemonInitStatus(reachableButBusy);
|
||||
VERBOSE_LOGF("[connect #%d] RPC connection failed (%s) — daemon still starting, will retry...\n",
|
||||
attempt, connectErr.c_str());
|
||||
network_refresh_.setTimer(services::NetworkRefreshService::Timer::Core,
|
||||
services::RefreshScheduler::kCoreDefault - 1.0f);
|
||||
const float retryIn = connect_backoff_.next(connectKind);
|
||||
VERBOSE_LOGF("[connect #%d] RPC connection failed (%s) — daemon still starting, retry in %.0fs\n",
|
||||
attempt, connectErr.c_str(), retryIn);
|
||||
network_refresh_.deferFor(services::NetworkRefreshService::Timer::Core, retryIn);
|
||||
} else if (externalDetected) {
|
||||
state_.connected = false;
|
||||
// An external daemon is on the RPC port but not answering. A timeout means it's
|
||||
// up and busy initializing; surface that as the init overlay (we can't read its
|
||||
// console since we didn't launch it, so no phase line — just a clear message).
|
||||
if (connectErr.find("Timeout") != std::string::npos) {
|
||||
if (rpc::rpcKindMeansBusy(connectKind)) {
|
||||
applyDaemonInitStatus(/*reachableButBusy=*/true);
|
||||
} else if (!connectErr.empty()) {
|
||||
char buf[256]; snprintf(buf, sizeof(buf), TR("sb_connecting_err"), connectErr.c_str());
|
||||
@@ -539,10 +606,10 @@ void App::tryConnect()
|
||||
} else {
|
||||
connection_status_ = TR("sb_connecting_external");
|
||||
}
|
||||
VERBOSE_LOGF("[connect #%d] External daemon detected but RPC failed (%s), will retry...\n",
|
||||
attempt, connectErr.c_str());
|
||||
network_refresh_.setTimer(services::NetworkRefreshService::Timer::Core,
|
||||
services::RefreshScheduler::kCoreDefault - 1.0f);
|
||||
const float retryIn = connect_backoff_.next(connectKind);
|
||||
VERBOSE_LOGF("[connect #%d] External daemon detected but RPC failed (%s), retry in %.0fs\n",
|
||||
attempt, connectErr.c_str(), retryIn);
|
||||
network_refresh_.deferFor(services::NetworkRefreshService::Timer::Core, retryIn);
|
||||
} else {
|
||||
onDisconnected("Connection failed");
|
||||
VERBOSE_LOGF("[connect #%d] RPC connection failed — no daemon starting, no external detected\n", attempt);
|
||||
@@ -593,7 +660,11 @@ void App::tryConnect()
|
||||
if (startEmbeddedDaemon()) {
|
||||
VERBOSE_LOGF("[connect #%d] Embedded daemon starting, will retry connection...\n", attempt);
|
||||
} else if (daemon_controller_ && daemon_controller_->externalDaemonDetected()) {
|
||||
connection_status_ = TR("sb_connecting_generic");
|
||||
// A node already running (left running, or started by hand). If our probe reached
|
||||
// it but got no answer, say what it is doing (its log may show a rebuild) rather
|
||||
// than a bare "Connecting…" until the next attempt, up to 30s away.
|
||||
if (rpc::rpcKindMeansBusy(connectKind)) applyDaemonInitStatus(/*reachableButBusy=*/true);
|
||||
else connection_status_ = TR("sb_connecting_generic");
|
||||
VERBOSE_LOGF("[connect #%d] External daemon detected, will connect via RPC...\n", attempt);
|
||||
} else {
|
||||
VERBOSE_LOGF("[connect #%d] Failed to start embedded daemon — lastError: %s\n",
|
||||
@@ -625,6 +696,7 @@ void App::onConnected()
|
||||
wallet_switch_phase_.store(static_cast<int>(WalletSwitchPhase::None));
|
||||
}
|
||||
state_.daemon_initializing = false; // RPC is answering now; clear the "initializing" overlay
|
||||
init_status_title_.clear();
|
||||
daemon_wait_attempts_ = 0; // re-arm the port-busy / start-failure notifications
|
||||
connect_stall_since_ = 0.0; // connected — clear the "taking too long" clock
|
||||
// A repair's rescan finished and connected — retire the recovery session so its crash-toast/error-card
|
||||
@@ -689,6 +761,7 @@ void App::onConnected()
|
||||
// behind the main refresh batch.
|
||||
if (!fast_rpc_) {
|
||||
fast_rpc_ = std::make_unique<rpc::RPCClient>();
|
||||
fast_rpc_->setBusyGate(rpc_busy_gate_);
|
||||
}
|
||||
if (!fast_worker_) {
|
||||
fast_worker_ = std::make_unique<rpc::RPCWorker>();
|
||||
@@ -795,19 +868,33 @@ std::string App::applyDaemonInitStatus(bool reachableButBusy)
|
||||
}
|
||||
}
|
||||
|
||||
// Most specific first: the node's own log says it is rebuilding witnesses or replaying blocks (then
|
||||
// "Launching dragonxd…" over a node that has been busy for an hour invited closing it), else an init
|
||||
// phase line, else "reachable but busy", else not bound yet.
|
||||
const auto kind = daemon::chooseDaemonStatus(!phaseLine.empty(), reachableButBusy, daemon_busy_);
|
||||
std::string busyTitle, busyDetail;
|
||||
WarmupText wt;
|
||||
if (!phaseLine.empty()) {
|
||||
switch (kind) {
|
||||
case daemon::DaemonStatusKind::Rebuilding:
|
||||
case daemon::DaemonStatusKind::Replaying: {
|
||||
const NodeBusyText busy = nodeBusyText();
|
||||
busyTitle = busy.title;
|
||||
busyDetail = busy.detail;
|
||||
wt = {busyTitle.c_str(), busyDetail.c_str()};
|
||||
break;
|
||||
}
|
||||
case daemon::DaemonStatusKind::InitPhase:
|
||||
wt = translateWarmup(phaseLine);
|
||||
} else if (reachableButBusy) {
|
||||
// The probe connected but got no RPC reply within the timeout: the node is up but busy
|
||||
// initializing (it isn't printing a recognizable phase, or we didn't launch it).
|
||||
wt = {"Starting DragonX node…",
|
||||
"The node is reachable but still initializing and isn't answering yet. "
|
||||
"This is normal after an update or on first launch — it can take a few minutes."};
|
||||
} else {
|
||||
break;
|
||||
case daemon::DaemonStatusKind::Busy:
|
||||
// The probe reached the node but got no answer (a reply timeout or a full queue): it is up but
|
||||
// too busy to answer (it isn't printing a recognizable phase, or we didn't launch it).
|
||||
wt = {TR("node_starting_title"), TR("node_starting_busy_desc")};
|
||||
break;
|
||||
case daemon::DaemonStatusKind::Launching:
|
||||
// The daemon is launching but hasn't bound its RPC port yet.
|
||||
wt = {"Starting DragonX node…",
|
||||
"Launching dragonxd and waiting for it to come online…"};
|
||||
wt = {TR("node_starting_title"), TR("node_starting_launch_desc")};
|
||||
break;
|
||||
}
|
||||
|
||||
std::string title = wt.title;
|
||||
@@ -817,9 +904,29 @@ std::string App::applyDaemonInitStatus(bool reachableButBusy)
|
||||
state_.warmup_status = title;
|
||||
state_.warmup_description = wt.description ? wt.description : "";
|
||||
connection_status_ = title;
|
||||
init_status_title_ = title;
|
||||
init_status_busy_ = reachableButBusy;
|
||||
return title;
|
||||
}
|
||||
|
||||
void App::applyWarmupText(const std::string& rpcMessage)
|
||||
{
|
||||
warmup_rpc_message_ = rpcMessage;
|
||||
if (daemon_busy_.rebuilding()) {
|
||||
const NodeBusyText busy = nodeBusyText();
|
||||
state_.warmup_status = busy.title;
|
||||
state_.warmup_description = busy.detail;
|
||||
} else {
|
||||
const WarmupText wt = translateWarmup(rpcMessage);
|
||||
state_.warmup_status = wt.title;
|
||||
state_.warmup_description = wt.description;
|
||||
}
|
||||
// Append current block height from daemon output
|
||||
const int h = daemon_controller_ ? daemon_controller_->lastBlockHeight() : -1;
|
||||
if (h > 0) state_.warmup_status += " (Block " + std::to_string(h) + ")";
|
||||
connection_status_ = state_.warmup_status;
|
||||
}
|
||||
|
||||
void App::handleLostConnection(const std::string& reason)
|
||||
{
|
||||
DEBUG_LOGF("[Connection] %s — tearing down for reconnect\n", reason.c_str());
|
||||
@@ -1288,6 +1395,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=<name>, 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.
|
||||
@@ -1724,15 +1832,7 @@ void App::refreshCoreData()
|
||||
refreshData();
|
||||
} else {
|
||||
// Still warming up — update status
|
||||
auto wt = translateWarmup(result.errorMessage);
|
||||
state_.warmup_status = wt.title;
|
||||
state_.warmup_description = wt.description;
|
||||
if (daemon_controller_) {
|
||||
int h = daemon_controller_->lastBlockHeight();
|
||||
if (h > 0)
|
||||
state_.warmup_status += " (Block " + std::to_string(h) + ")";
|
||||
}
|
||||
connection_status_ = state_.warmup_status;
|
||||
applyWarmupText(result.errorMessage);
|
||||
VERBOSE_LOGF("[warmup] Still warming up: %s\n", result.errorMessage.c_str());
|
||||
}
|
||||
};
|
||||
@@ -1792,10 +1892,16 @@ void App::refreshCoreData()
|
||||
// RPCs failing together means the daemon connection is dead (a busy daemon
|
||||
// fails them individually, not both at once). Warmup is excluded — both fail
|
||||
// with -28 there legitimately, and counting it would cause a reconnect loop.
|
||||
noteDaemonRpcResult(result.blockchainOk || result.balanceOk,
|
||||
result.blockchainOk ? result.balanceErrKind : result.blockchainErrKind);
|
||||
constexpr int kCoreFailuresBeforeDisconnect = 3;
|
||||
if (!state_.warming_up) {
|
||||
if (!result.balanceOk && !result.blockchainOk) {
|
||||
if (++consecutive_core_failures_ >= kCoreFailuresBeforeDisconnect &&
|
||||
// Only failures that put the connection itself in doubt count. A node that is merely
|
||||
// busy (reply timeouts, a full queue, calls held back) is still there: tearing down
|
||||
// then wiped the wallet state and restarted the connect probes that fill its queue.
|
||||
if (rpc::rpcKindMeansLost(result.blockchainErrKind) &&
|
||||
++consecutive_core_failures_ >= kCoreFailuresBeforeDisconnect &&
|
||||
state_.connected) {
|
||||
consecutive_core_failures_ = 0;
|
||||
handleLostConnection("Lost connection to daemon");
|
||||
@@ -3131,6 +3237,9 @@ void App::maybeProvisionChatIdentity()
|
||||
|
||||
if (chat_service_.hasIdentity() || chat_identity_provisioned_ ||
|
||||
chat_identity_fetch_in_flight_ || chat_identity_unavailable_) return;
|
||||
// After a transient failure (connection blip, a busy node) wait before asking again: retrying on the
|
||||
// next frame re-sent the request continuously.
|
||||
if (std::chrono::steady_clock::now() < chat_identity_retry_at_) return;
|
||||
|
||||
if (supportsLiteBackend()) {
|
||||
// Lite: the backend's `seed` command returns the 24-word mnemonic synchronously.
|
||||
@@ -3201,6 +3310,7 @@ void App::maybeProvisionChatIdentity()
|
||||
}
|
||||
chat_identity_fetch_in_flight_ = false;
|
||||
if (unavailable) chat_identity_unavailable_ = true;
|
||||
if (transientFail) chat_identity_retry_at_ = std::chrono::steady_clock::now() + std::chrono::seconds(10);
|
||||
// Provision only on success AND while still unlocked: a lock (e.g. auto-lock) can
|
||||
// land during the blocking fetch, and provisioning then would unlock the chat DB +
|
||||
// load decrypted history while the wallet is locked. Leaving the flag cleared here
|
||||
@@ -4446,6 +4556,9 @@ void App::maybeRemindSeedBackup()
|
||||
if (!state_.connected || !state_.encryption_state_known) return;
|
||||
if (state_.isLocked()) return; // wait until unlocked to read it
|
||||
if (seed_backup_reminder_in_flight_) return;
|
||||
// An inconclusive answer (an error other than "not derived from a mnemonic" — e.g. a daemon without
|
||||
// the RPC, or a busy one) used to re-fire this probe on the very next frame, hammering the daemon.
|
||||
if (std::chrono::steady_clock::now() < seed_backup_reminder_retry_at_) return;
|
||||
seed_backup_reminder_in_flight_ = true;
|
||||
exportSeedPhrase([this](bool ok, bool noMnemonic, const std::string& /*phrase*/,
|
||||
const std::string& /*error*/) {
|
||||
@@ -4455,6 +4568,8 @@ void App::maybeRemindSeedBackup()
|
||||
if (ok || noMnemonic) {
|
||||
settings_->setSeedBackupReminded(true);
|
||||
settings_->save();
|
||||
} else {
|
||||
seed_backup_reminder_retry_at_ = std::chrono::steady_clock::now() + std::chrono::minutes(5);
|
||||
}
|
||||
if (ok)
|
||||
ui::Notifications::instance().info(TR("seed_backup_reminder"), 12.0f);
|
||||
@@ -4701,6 +4816,9 @@ void App::showSeedMigrationDialog()
|
||||
seed_migration_balance_loaded_ = false;
|
||||
seed_migration_nofunds_confirmed_ = false;
|
||||
refreshSeedMigrationBalance();
|
||||
// If a sweep was already broadcast (the marker survives a lost opid), try to locate its tx so
|
||||
// we resume at the confirmation gate instead of risking a premature "no funds -> adopt".
|
||||
if (settings_ && settings_->getSeedMigrationSweepSubmitted()) locateSweepTxid();
|
||||
break;
|
||||
case MigrationResume::Intro:
|
||||
default:
|
||||
@@ -4768,6 +4886,12 @@ void App::beginSweepToSeedWallet()
|
||||
}
|
||||
seed_migration_step_ = SeedMigrationStep::Sweeping;
|
||||
seed_migration_status_ = "Building the sweep transaction…";
|
||||
// Mark the sweep as submitted BEFORE broadcasting (persisted synchronously). If the app or daemon
|
||||
// then dies after the tx reaches the mempool but before its opid/txid is persisted, a resumed
|
||||
// migration still knows a sweep is in flight and won't mistake the resulting ~0 legacy balance for
|
||||
// an empty wallet and adopt (swap wallet.dat) prematurely — funds in an unconfirmed sweep can still
|
||||
// revert on a drop/reorg. Cleared below only if the submit definitively fails (nothing broadcast).
|
||||
if (settings_) { settings_->setSeedMigrationSweepSubmitted(true); settings_->save(); }
|
||||
const std::string dest = seed_migration_dest_;
|
||||
worker_->post([this, dest]() -> rpc::RPCWorker::MainCb {
|
||||
std::string opid, err;
|
||||
@@ -4780,6 +4904,8 @@ void App::beginSweepToSeedWallet()
|
||||
} catch (const std::exception& e) { err = e.what(); }
|
||||
return [this, opid, err]() {
|
||||
if (!err.empty() || opid.empty()) {
|
||||
// Submit failed — nothing was broadcast, so drop the "sweep in flight" marker.
|
||||
if (settings_) { settings_->setSeedMigrationSweepSubmitted(false); settings_->save(); }
|
||||
seed_migration_status_ = err.empty() ? "The sweep failed to start." : err;
|
||||
seed_migration_step_ = SeedMigrationStep::Error;
|
||||
return;
|
||||
@@ -4882,11 +5008,75 @@ void App::pollSweepStatus()
|
||||
});
|
||||
}
|
||||
|
||||
// Best-effort recovery when a sweep was broadcast but its opid was lost (e.g. a daemon restart wiped
|
||||
// the in-memory op queue) so we have no txid to confirm against. The sweep is an outgoing send to
|
||||
// seed_migration_dest_; find it among the wallet's own sends (z_listsentbyaddress) and, if present,
|
||||
// adopt its txid and resume at the confirmation gate (pollSweepStatus / Confirming). Read-only, and
|
||||
// backstopped by that gate (confs >= 1 && legacy balance ~0), so a wrong match cannot lose funds —
|
||||
// it only avoids the premature "no funds -> adopt" short-circuit while a sweep is unconfirmed.
|
||||
void App::locateSweepTxid()
|
||||
{
|
||||
if (capture_mode_) return;
|
||||
if (!rpc_ || !worker_ || !state_.connected) return;
|
||||
if (seed_migration_confirm_in_flight_ || !seed_migration_sweep_txid_.empty()) return;
|
||||
if (seed_migration_dest_.empty()) return;
|
||||
seed_migration_confirm_in_flight_ = true;
|
||||
const std::string dest = seed_migration_dest_;
|
||||
worker_->post([this, dest]() -> rpc::RPCWorker::MainCb {
|
||||
std::string found;
|
||||
rpc::RPCClient::TraceScope trace("Migrate / locate sweep");
|
||||
try {
|
||||
// z_listsentbyaddress ignores its address argument and returns ALL wallet sends
|
||||
// (oldest -> newest); the sweep is the latest send whose outputs include the destination.
|
||||
auto sent = rpc_->call("z_listsentbyaddress", {dest, 0});
|
||||
if (sent.is_array()) {
|
||||
for (const auto& e : sent) {
|
||||
if (!e.is_object() || !e.contains("sends") || !e["sends"].is_object()) continue;
|
||||
const auto& s = e["sends"];
|
||||
bool toDest = false;
|
||||
for (const char* leg : {"saplingSends", "transparentSends"}) {
|
||||
auto it = s.find(leg);
|
||||
if (it == s.end() || !it->is_array()) continue;
|
||||
for (const auto& o : *it) {
|
||||
if (o.is_object() && o.value("address", std::string()) == dest) {
|
||||
toDest = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (toDest) break;
|
||||
}
|
||||
if (toDest) {
|
||||
auto t = e.value("txid", std::string());
|
||||
if (!t.empty()) found = t; // keep scanning; entries are oldest->newest
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (...) {}
|
||||
return [this, found]() {
|
||||
seed_migration_confirm_in_flight_ = false;
|
||||
if (found.empty() || !seed_migration_sweep_txid_.empty()) return;
|
||||
// Adopt the recovered txid and hand off to the confirmation gate (which alone permits adopt).
|
||||
seed_migration_sweep_txid_ = found;
|
||||
if (settings_) {
|
||||
settings_->setSeedMigrationSweepTxid(found);
|
||||
settings_->setSeedMigrationSweepOpid("");
|
||||
settings_->save();
|
||||
}
|
||||
seed_migration_sweep_confs_ = 0;
|
||||
seed_migration_legacy_remaining_ = -1.0;
|
||||
seed_migration_poll_timer_ = 0.0f;
|
||||
seed_migration_status_.clear();
|
||||
seed_migration_step_ = SeedMigrationStep::Confirming;
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Phase 2 step 2: adopt the new seed wallet as the primary wallet. Stop the daemon, move the
|
||||
// legacy wallet.dat aside to a timestamped backup (NEVER delete it), copy the new wallet in, and
|
||||
// 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<std::mutex> lk(seed_migration_mutex_); seed_migration_adopt_done_ = false; }
|
||||
@@ -5002,6 +5192,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.
|
||||
@@ -5169,6 +5360,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; }
|
||||
|
||||
@@ -5308,6 +5500,7 @@ void App::pumpSeedMigration()
|
||||
settings_->setSeedMigrationTempDir("");
|
||||
settings_->setSeedMigrationSweepTxid("");
|
||||
settings_->setSeedMigrationSweepOpid(""); // W3-3
|
||||
settings_->setSeedMigrationSweepSubmitted(false);
|
||||
settings_->save();
|
||||
}
|
||||
seed_migration_status_ = err; // a non-empty warning here (e.g. restart hiccup) is shown on Done
|
||||
@@ -5355,6 +5548,7 @@ void App::pumpSeedMigration()
|
||||
// txid/opid (the resume block reads these whenever the migration is pending).
|
||||
settings_->setSeedMigrationSweepTxid("");
|
||||
settings_->setSeedMigrationSweepOpid("");
|
||||
settings_->setSeedMigrationSweepSubmitted(false);
|
||||
settings_->save();
|
||||
}
|
||||
} else {
|
||||
|
||||
+24
-13
@@ -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,
|
||||
|
||||
+9
-5
@@ -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<util::Bootstrap>();
|
||||
@@ -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<util::Bootstrap>();
|
||||
@@ -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<util::Bootstrap>();
|
||||
std::string dataDir = util::Platform::getDragonXDataDir();
|
||||
|
||||
@@ -267,6 +267,7 @@ bool Settings::load(const std::string& path)
|
||||
loadScalar(j, "seed_migration_temp_dir", seed_migration_temp_dir_);
|
||||
loadScalar(j, "seed_migration_sweep_txid", seed_migration_sweep_txid_);
|
||||
loadScalar(j, "seed_migration_sweep_opid", seed_migration_sweep_opid_);
|
||||
loadScalar(j, "seed_migration_sweep_submitted", seed_migration_sweep_submitted_);
|
||||
loadScalar(j, "auto_lock_timeout", auto_lock_timeout_);
|
||||
loadScalar(j, "unlock_duration", unlock_duration_);
|
||||
loadScalar(j, "pin_enabled", pin_enabled_);
|
||||
@@ -559,6 +560,7 @@ bool Settings::save(const std::string& path)
|
||||
j["seed_migration_temp_dir"] = seed_migration_temp_dir_;
|
||||
j["seed_migration_sweep_txid"] = seed_migration_sweep_txid_;
|
||||
j["seed_migration_sweep_opid"] = seed_migration_sweep_opid_;
|
||||
j["seed_migration_sweep_submitted"] = seed_migration_sweep_submitted_;
|
||||
j["auto_lock_timeout"] = auto_lock_timeout_;
|
||||
j["unlock_duration"] = unlock_duration_;
|
||||
j["pin_enabled"] = pin_enabled_;
|
||||
|
||||
@@ -396,6 +396,12 @@ public:
|
||||
// in the same write that persists the txid, so the txid always outranks it (see [[decideSeedMigrationResume]]).
|
||||
std::string getSeedMigrationSweepOpid() const { return seed_migration_sweep_opid_; }
|
||||
void setSeedMigrationSweepOpid(const std::string& v) { seed_migration_sweep_opid_ = v; }
|
||||
// Set TRUE synchronously when a sweep is broadcast; survives the stale-opid resume fallback so the
|
||||
// Sweep step knows a ~0 legacy balance may be an UNCONFIRMED in-flight sweep (funds not yet safe)
|
||||
// and must NOT short-circuit to adopt/swap wallet.dat. Cleared on confirmed-adopt / reset / a
|
||||
// failed submit. See [[decideSeedMigrationResume]] and App::renderSeedMigrationDialog (fund-safety).
|
||||
bool getSeedMigrationSweepSubmitted() const { return seed_migration_sweep_submitted_; }
|
||||
void setSeedMigrationSweepSubmitted(bool v) { seed_migration_sweep_submitted_ = v; }
|
||||
|
||||
// Security — auto-lock timeout (seconds; 0 = disabled)
|
||||
int getAutoLockTimeout() const { return auto_lock_timeout_; }
|
||||
@@ -640,6 +646,7 @@ private:
|
||||
std::string seed_migration_temp_dir_;
|
||||
std::string seed_migration_sweep_txid_;
|
||||
std::string seed_migration_sweep_opid_;
|
||||
bool seed_migration_sweep_submitted_ = false;
|
||||
int auto_lock_timeout_ = 900; // 15 minutes
|
||||
int unlock_duration_ = 600; // 10 minutes
|
||||
bool pin_enabled_ = false;
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
#pragma once
|
||||
|
||||
// Is the node busy with something that stopping it would throw away? Tracks dragonxd's own log markers,
|
||||
// in order, for two independent states:
|
||||
// - rebuilding: a witness-cache rebuild (BuildWitnessCache). It holds cs_main for up to an hour on a
|
||||
// big wallet, its second phase logs nothing and ignores shutdown, and a node killed mid-rebuild redoes
|
||||
// it (after replaying everything since its last flush) on the next start;
|
||||
// - replaying: the startup replay ("Activating best chain..."). Stopping there is safe — the daemon
|
||||
// checks for shutdown between steps and flushes — it just takes a moment.
|
||||
// A rebuild can start in the middle of a replay, so its end must not clear `replaying`, and the state is
|
||||
// order-based: it never compares timestamps (POSIX nodes log to stdout without them). Pure and
|
||||
// header-only, so the test target can feed it lines.
|
||||
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <string>
|
||||
|
||||
namespace dragonx {
|
||||
namespace daemon {
|
||||
|
||||
/**
|
||||
* "YYYY-MM-DD HH:MM:SS ..." (dragonxd's debug.log prefix, which is UTC) -> seconds since the epoch, or 0
|
||||
* when the line has no such prefix. Days-from-civil arithmetic, not mktime(): mktime reads local time.
|
||||
*/
|
||||
inline std::int64_t parseDaemonLogUtc(const std::string& line)
|
||||
{
|
||||
int y = 0, mo = 0, d = 0, h = 0, mi = 0, s = 0;
|
||||
if (line.size() < 19 || std::sscanf(line.c_str(), "%4d-%2d-%2d %2d:%2d:%2d", &y, &mo, &d, &h, &mi, &s) != 6)
|
||||
return 0;
|
||||
if (mo < 1 || mo > 12 || d < 1 || d > 31 || h > 23 || mi > 59 || s > 60) return 0;
|
||||
// Howard Hinnant's days_from_civil.
|
||||
const int yy = y - (mo <= 2 ? 1 : 0);
|
||||
const int era = (yy >= 0 ? yy : yy - 399) / 400;
|
||||
const unsigned yoe = static_cast<unsigned>(yy - era * 400);
|
||||
const unsigned doy = (153u * static_cast<unsigned>(mo + (mo > 2 ? -3 : 9)) + 2u) / 5u + static_cast<unsigned>(d) - 1u;
|
||||
const unsigned doe = yoe * 365u + yoe / 4u - yoe / 100u + doy;
|
||||
const std::int64_t days = static_cast<std::int64_t>(era) * 146097 + static_cast<std::int64_t>(doe) - 719468;
|
||||
return days * 86400 + h * 3600 + mi * 60 + s;
|
||||
}
|
||||
|
||||
class DaemonBusyTracker {
|
||||
public:
|
||||
// Feed raw output: any chunking, a line split across two chunks is reassembled.
|
||||
void feed(const std::string& chunk)
|
||||
{
|
||||
for (char c : chunk) {
|
||||
if (c == '\n') {
|
||||
feedLine(carry_);
|
||||
carry_.clear();
|
||||
} else if (c != '\r') {
|
||||
carry_.push_back(c);
|
||||
if (carry_.size() > 64 * 1024) carry_.clear(); // not a log line; don't grow without bound
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void feedLine(const std::string& line)
|
||||
{
|
||||
// A new daemon process, or the end of the last one: forget everything.
|
||||
if (contains(line, "DragonX version ") || contains(line, "Hush version ") || contains(line, "Shutdown: done")) {
|
||||
reset();
|
||||
return;
|
||||
}
|
||||
if (contains(line, "init message: Activating best chain")) {
|
||||
replaying_ = true;
|
||||
replayStartUtc_ = parseDaemonLogUtc(line);
|
||||
lastMarker_ = line;
|
||||
return;
|
||||
}
|
||||
if (contains(line, "init message: Full Node Done Loading") || contains(line, "init message: Done loading")) {
|
||||
replaying_ = false;
|
||||
lastMarker_ = line;
|
||||
return;
|
||||
}
|
||||
const size_t read = line.find("Reading blocks for witness rebuild: ");
|
||||
if (read != std::string::npos || contains(line, "Building Witnesses for block ")) {
|
||||
if (!rebuilding_) rebuildStartUtc_ = parseDaemonLogUtc(line);
|
||||
rebuilding_ = true;
|
||||
if (read != std::string::npos) {
|
||||
long done = 0, total = 0;
|
||||
if (std::sscanf(line.c_str() + read, "Reading blocks for witness rebuild: %ld / %ld", &done, &total) == 2) {
|
||||
readDone_ = done;
|
||||
readTotal_ = total;
|
||||
}
|
||||
}
|
||||
lastMarker_ = line;
|
||||
return;
|
||||
}
|
||||
// The rebuild's end. UpdateTip is logged under cs_main, so one after a rebuild marker means the
|
||||
// rebuild has released it (exceptions and non-bulk paths print no end line).
|
||||
if (rebuilding_ && (contains(line, "note witness cache(s) to height") ||
|
||||
contains(line, "aborting witness rebuild") ||
|
||||
contains(line, "aborted during witness rebuild") ||
|
||||
contains(line, "aborting building witnesses") ||
|
||||
contains(line, "UpdateTip: "))) {
|
||||
rebuilding_ = false;
|
||||
readDone_ = readTotal_ = 0;
|
||||
rebuildStartUtc_ = 0;
|
||||
lastMarker_ = line;
|
||||
}
|
||||
}
|
||||
|
||||
void reset()
|
||||
{
|
||||
rebuilding_ = replaying_ = false;
|
||||
readDone_ = readTotal_ = 0;
|
||||
rebuildStartUtc_ = replayStartUtc_ = 0;
|
||||
lastMarker_.clear();
|
||||
}
|
||||
|
||||
bool rebuilding() const { return rebuilding_; }
|
||||
bool replaying() const { return replaying_; }
|
||||
bool busy() const { return rebuilding_ || replaying_; }
|
||||
long readDone() const { return readDone_; } // the last Phase-1 "reading blocks" progress, 0 if none
|
||||
long readTotal() const { return readTotal_; }
|
||||
std::int64_t rebuildStartUtc() const { return rebuildStartUtc_; } // 0: unknown (no timestamps)
|
||||
std::int64_t replayStartUtc() const { return replayStartUtc_; }
|
||||
const std::string& lastMarker() const { return lastMarker_; }
|
||||
|
||||
private:
|
||||
static bool contains(const std::string& s, const char* needle) { return s.find(needle) != std::string::npos; }
|
||||
|
||||
std::string carry_;
|
||||
bool rebuilding_ = false;
|
||||
bool replaying_ = false;
|
||||
long readDone_ = 0;
|
||||
long readTotal_ = 0;
|
||||
std::int64_t rebuildStartUtc_ = 0;
|
||||
std::int64_t replayStartUtc_ = 0;
|
||||
std::string lastMarker_;
|
||||
};
|
||||
|
||||
/**
|
||||
* The "keep the node running?" guard on quit. Prompt only when this quit would stop the node and it is
|
||||
* rebuilding witnesses, or has been alive but too busy to answer RPC for a while (a rebuild that logged
|
||||
* no marker). A replay alone never prompts: stopping it is safe. No daemon version suppresses the prompt
|
||||
* (v1.3.0 does not checkpoint a witness rebuild; nothing survives a kill).
|
||||
*/
|
||||
inline bool shouldConfirmStop(bool stopsDaemon, const DaemonBusyTracker& tracker, bool rpcBusyWhileAlive)
|
||||
{
|
||||
return stopsDaemon && (tracker.rebuilding() || rpcBusyWhileAlive);
|
||||
}
|
||||
|
||||
/**
|
||||
* Where to start feeding a DaemonBusyTracker from the last `window` of the debug.log of a node someone else
|
||||
* started at `processStartUtc` (UTC seconds). Only lines that process wrote count: a node started with
|
||||
* -printtoconsole leaves an old debug.log behind, whose last run may have died mid-rebuild. Returns the
|
||||
* offset of that process's startup banner in the window; 0 when its banner is older than the window
|
||||
* (`windowStartsMidFile`) and the window's first stamped line postdates the process start; npos when the
|
||||
* log isn't that process's.
|
||||
*/
|
||||
inline size_t externalLogFeedOffset(const std::string& window, bool windowStartsMidFile, std::int64_t processStartUtc)
|
||||
{
|
||||
size_t lastBanner = std::string::npos;
|
||||
std::int64_t lastBannerAt = 0, firstStamp = 0;
|
||||
size_t pos = 0;
|
||||
while (pos < window.size()) {
|
||||
size_t end = window.find('\n', pos);
|
||||
if (end == std::string::npos) end = window.size();
|
||||
const std::string line = window.substr(pos, end - pos);
|
||||
const std::int64_t at = parseDaemonLogUtc(line);
|
||||
if (at > 0 && firstStamp == 0) firstStamp = at;
|
||||
if (line.find("DragonX version ") != std::string::npos || line.find("Hush version ") != std::string::npos) {
|
||||
lastBanner = pos;
|
||||
lastBannerAt = at;
|
||||
}
|
||||
pos = end + 1;
|
||||
}
|
||||
if (processStartUtc <= 0) return std::string::npos;
|
||||
const std::int64_t since = processStartUtc - 5; // the log's whole seconds vs the process's start time
|
||||
if (lastBanner != std::string::npos) return (lastBannerAt > 0 && lastBannerAt >= since) ? lastBanner : std::string::npos;
|
||||
if (windowStartsMidFile && firstStamp > 0 && firstStamp >= since) return 0;
|
||||
return std::string::npos;
|
||||
}
|
||||
|
||||
// What the "node is starting / not answering" overlay should say, most specific first.
|
||||
enum class DaemonStatusKind {
|
||||
Rebuilding, // its own log says it's rebuilding note witnesses
|
||||
Replaying, // its own log says it's catching up on blocks it saved before (startup replay)
|
||||
InitPhase, // a recognisable init phase line (loading the block index, verifying, ...)
|
||||
Busy, // it answers the connection but not the request (reply timeout / queue full)
|
||||
Launching, // nothing bound to the RPC port yet
|
||||
};
|
||||
|
||||
inline DaemonStatusKind chooseDaemonStatus(bool hasInitPhaseLine, bool reachableButBusy,
|
||||
const DaemonBusyTracker& tracker)
|
||||
{
|
||||
if (tracker.rebuilding()) return DaemonStatusKind::Rebuilding;
|
||||
if (tracker.replaying()) return DaemonStatusKind::Replaying;
|
||||
if (hasInitPhaseLine) return DaemonStatusKind::InitPhase;
|
||||
if (reachableButBusy) return DaemonStatusKind::Busy;
|
||||
return DaemonStatusKind::Launching;
|
||||
}
|
||||
|
||||
} // namespace daemon
|
||||
} // namespace dragonx
|
||||
@@ -52,6 +52,11 @@ void DaemonController::stop(int waitMs)
|
||||
daemon_->stop(waitMs);
|
||||
}
|
||||
|
||||
void DaemonController::markStopRequested()
|
||||
{
|
||||
daemon_->markStopRequested();
|
||||
}
|
||||
|
||||
bool DaemonController::isRunning() const
|
||||
{
|
||||
return daemon_->isRunning();
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
#pragma once
|
||||
|
||||
#include "embedded_daemon.h"
|
||||
#include "stop_delivery.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstddef>
|
||||
#include <deque>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
@@ -61,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;
|
||||
};
|
||||
@@ -90,6 +96,7 @@ public:
|
||||
|
||||
bool start(const config::Settings* settings);
|
||||
void stop(int waitMs);
|
||||
void markStopRequested();
|
||||
|
||||
bool isRunning() const;
|
||||
bool externalDaemonDetected() const;
|
||||
@@ -191,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;
|
||||
}
|
||||
@@ -233,6 +242,223 @@ public:
|
||||
ShutdownDecision shutdownDecision(bool keepDaemonRunning,
|
||||
bool stopExternalDaemon) const;
|
||||
|
||||
// ------------------------------------------------------------------------------------------------
|
||||
// Stopping the node: deliver "stop" until it lands, then wait for the process to exit. Never kill it
|
||||
// on our own: a node that is busy (a witness rebuild can hold it for an hour) or flushing would lose
|
||||
// everything since its last save and replay it on the next start. Only an explicit user "Kill",
|
||||
// offered once the node has gone idle, ends it by force; "Leave running" lets the wallet close while
|
||||
// the node finishes on its own.
|
||||
// ------------------------------------------------------------------------------------------------
|
||||
|
||||
using StopUserChoice = daemon::StopUserChoice;
|
||||
using StopOutcome = daemon::StopOutcome;
|
||||
|
||||
struct StopPolicy {
|
||||
bool owned = true; // we launched it: watch the process, signal it, offer Kill
|
||||
bool gracefulSignal = false; // POSIX: a group SIGTERM is a stop channel (dragonxd treats it as stop)
|
||||
double maxWaitSec = 0.0; // stop waiting (never kill) after this long; 0 = no limit
|
||||
bool userChoices = false; // the shutdown screen offers "Leave running" / "Kill"
|
||||
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 {
|
||||
bool delivered = false; // stop accepted (or the node is shutting down, or was signalled)
|
||||
bool heard = false; // the node has the request (possibly still queued)
|
||||
bool signalled = false; // a graceful signal (POSIX SIGTERM) went out
|
||||
double elapsedSec = 0.0;
|
||||
double cpuPercent = -1.0; // < 0: unknown
|
||||
double idleSec = 0.0;
|
||||
bool killOffered = false;
|
||||
bool rebuildActive = false;
|
||||
StopSendResult lastSend = StopSendResult::Other;
|
||||
int attempts = 0;
|
||||
};
|
||||
|
||||
class StopRuntime {
|
||||
public:
|
||||
virtual ~StopRuntime() = default;
|
||||
virtual double nowSec() = 0;
|
||||
virtual bool processAlive() = 0;
|
||||
virtual StopSendResult trySendStop() = 0;
|
||||
virtual bool nextCredentials() = 0; // switch to the next stop credentials; false when none left
|
||||
virtual bool sendGracefulSignal() = 0; // POSIX group SIGTERM; false where there is none (Windows)
|
||||
virtual double cpuSeconds() = 0; // cumulative CPU of the node; < 0 if unknown
|
||||
virtual double ioBytes() = 0; // cumulative I/O bytes of the node; < 0 if unknown
|
||||
virtual bool rebuildActive() = 0; // a witness rebuild is running: never offer Kill
|
||||
virtual StopUserChoice userChoice() = 0;
|
||||
virtual void offerForceKill(bool offered) = 0;
|
||||
virtual void forceKill() = 0;
|
||||
virtual void setStatus(const StopStatus& status) = 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* "Making progress" = >= 0.05 cores of CPU or >= 1 MB of I/O over the last >= 60s. A flush that
|
||||
* fsyncs a big wallet is mostly I/O at low CPU; a rebuild is mostly CPU. Idle time counts from the
|
||||
* first sample. With no readings at all (unknown), it never reports idle, so Kill is never offered.
|
||||
*/
|
||||
class StopProgressTracker {
|
||||
public:
|
||||
static constexpr double kWindowSec = 60.0;
|
||||
static constexpr double kMinCores = 0.05;
|
||||
static constexpr double kMinIoBytes = 1024.0 * 1024.0;
|
||||
|
||||
void add(double t, double cpuSec, double ioBytes)
|
||||
{
|
||||
if (cpuSec >= 0 || ioBytes >= 0) measurable_ = true;
|
||||
if (samples_.empty()) lastProgressAt_ = t;
|
||||
samples_.push_back({t, cpuSec, ioBytes});
|
||||
// Keep the newest sample that is at least a full window old as the comparison base.
|
||||
while (samples_.size() > 2 && samples_[1].t <= t - kWindowSec) samples_.pop_front();
|
||||
const Sample& base = samples_.front();
|
||||
const double span = t - base.t;
|
||||
if (span >= kWindowSec) {
|
||||
const bool cpuMoved = cpuSec >= 0 && base.cpu >= 0 && (cpuSec - base.cpu) / span >= kMinCores;
|
||||
const bool ioMoved = ioBytes >= 0 && base.io >= 0 && (ioBytes - base.io) >= kMinIoBytes;
|
||||
if (cpuMoved || ioMoved) lastProgressAt_ = t;
|
||||
}
|
||||
}
|
||||
double idleSec(double t) const { return measurable_ ? std::max(0.0, t - lastProgressAt_) : 0.0; }
|
||||
// CPU over the most recent sample interval (100 = one core), for display; < 0 unknown.
|
||||
double cpuPercent() const
|
||||
{
|
||||
if (samples_.size() < 2) return -1.0;
|
||||
const Sample& a = samples_[samples_.size() - 2];
|
||||
const Sample& b = samples_.back();
|
||||
if (a.cpu < 0 || b.cpu < 0 || b.t <= a.t) return -1.0;
|
||||
return std::max(0.0, (b.cpu - a.cpu) / (b.t - a.t) * 100.0);
|
||||
}
|
||||
|
||||
private:
|
||||
struct Sample { double t, cpu, io; };
|
||||
std::deque<Sample> samples_;
|
||||
double lastProgressAt_ = 0.0;
|
||||
bool measurable_ = false;
|
||||
};
|
||||
|
||||
enum class StopWaitDecision { Done, Wait, GiveUp, Detach, OfferForceKill, ForceKill };
|
||||
|
||||
struct StopWaitSample {
|
||||
bool processAlive = true;
|
||||
bool delivered = false;
|
||||
double elapsedSec = 0.0;
|
||||
double idleSec = 0.0;
|
||||
bool rebuildActive = false;
|
||||
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)
|
||||
{
|
||||
if (!s.processAlive) return StopWaitDecision::Done;
|
||||
if (p.userChoices && s.choice == StopUserChoice::Detach) return StopWaitDecision::Detach;
|
||||
if (p.userChoices && p.owned && s.choice == StopUserChoice::Kill && s.killOffered)
|
||||
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;
|
||||
}
|
||||
|
||||
static constexpr int kStopTickMs = 250;
|
||||
|
||||
static inline StopOutcome executeDaemonStop(const StopPolicy& policy, StopRuntime& rt,
|
||||
LifecycleTaskContext& task)
|
||||
{
|
||||
if (!rt.processAlive()) return StopOutcome::Exited; // already gone: not a single RPC call
|
||||
|
||||
const double t0 = rt.nowSec();
|
||||
StopDeliveryPolicy delivery(policy.owned);
|
||||
StopProgressTracker progress;
|
||||
bool signalled = false;
|
||||
bool offered = false;
|
||||
auto withdrawOffer = [&] {
|
||||
if (offered) { offered = false; rt.offerForceKill(false); }
|
||||
};
|
||||
|
||||
for (;;) {
|
||||
double now = rt.nowSec() - t0;
|
||||
if (delivery.shouldSend(now)) {
|
||||
const StopSendResult r = delivery.record(rt.trySendStop(), now);
|
||||
if (r == StopSendResult::Auth && policy.owned) {
|
||||
// Our own node refusing our credentials means they're stale (e.g. a regenerated
|
||||
// .cookie): try the next source; out of sources, the graceful signal below remains.
|
||||
if (rt.nextCredentials()) delivery.retryNow(rt.nowSec() - t0);
|
||||
else delivery.giveUp();
|
||||
}
|
||||
// POSIX: a group SIGTERM bypasses the RPC queue altogether (dragonxd's handler sets the same
|
||||
// flag as stop). Send it as soon as an attempt shows stop didn't get through.
|
||||
if (policy.gracefulSignal && !signalled && !delivery.delivered() &&
|
||||
r != StopSendResult::Queued && rt.sendGracefulSignal()) {
|
||||
signalled = true;
|
||||
delivery.markDelivered();
|
||||
}
|
||||
}
|
||||
if (!rt.processAlive()) {
|
||||
withdrawOffer();
|
||||
return StopOutcome::Exited;
|
||||
}
|
||||
|
||||
now = rt.nowSec() - t0;
|
||||
progress.add(now, rt.cpuSeconds(), rt.ioBytes());
|
||||
StopWaitSample s;
|
||||
s.delivered = delivery.delivered();
|
||||
s.elapsedSec = now;
|
||||
s.idleSec = progress.idleSec(now);
|
||||
s.rebuildActive = rt.rebuildActive();
|
||||
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:
|
||||
withdrawOffer();
|
||||
return StopOutcome::Exited;
|
||||
case StopWaitDecision::Detach:
|
||||
withdrawOffer();
|
||||
return StopOutcome::Detached;
|
||||
case StopWaitDecision::ForceKill:
|
||||
withdrawOffer();
|
||||
rt.forceKill();
|
||||
return StopOutcome::Killed;
|
||||
case StopWaitDecision::GiveUp:
|
||||
withdrawOffer();
|
||||
return StopOutcome::StillRunning;
|
||||
case StopWaitDecision::OfferForceKill:
|
||||
if (!offered) { offered = true; rt.offerForceKill(true); }
|
||||
break;
|
||||
case StopWaitDecision::Wait:
|
||||
withdrawOffer(); // progress resumed (or a rebuild started): the node isn't stuck after all
|
||||
break;
|
||||
}
|
||||
|
||||
StopStatus st;
|
||||
st.delivered = delivery.delivered();
|
||||
st.heard = delivery.heard();
|
||||
st.signalled = signalled;
|
||||
st.elapsedSec = now;
|
||||
st.cpuPercent = progress.cpuPercent();
|
||||
st.idleSec = s.idleSec;
|
||||
st.killOffered = offered;
|
||||
st.rebuildActive = s.rebuildActive;
|
||||
st.lastSend = delivery.last();
|
||||
st.attempts = delivery.attempts();
|
||||
rt.setStatus(st);
|
||||
task.sleepForMs(kStopTickMs);
|
||||
}
|
||||
}
|
||||
|
||||
private:
|
||||
std::unique_ptr<EmbeddedDaemon> daemon_;
|
||||
};
|
||||
|
||||
+574
-130
@@ -6,16 +6,22 @@
|
||||
// binary discovery, process spawn, stdout/stderr monitoring, crash recovery.
|
||||
|
||||
#include "embedded_daemon.h"
|
||||
#include "daemon_busy_detect.h"
|
||||
#include "../config/version.h"
|
||||
#include "../resources/embedded_resources.h"
|
||||
#include "../util/platform.h"
|
||||
|
||||
#include <cctype>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <filesystem>
|
||||
#include <chrono>
|
||||
#include <cstring>
|
||||
#include <ctime>
|
||||
#include <algorithm>
|
||||
#include <fstream>
|
||||
#include <iterator>
|
||||
#include <limits>
|
||||
#include <vector>
|
||||
|
||||
#include "../util/logger.h"
|
||||
@@ -33,11 +39,13 @@
|
||||
#include <signal.h>
|
||||
#include <sys/wait.h>
|
||||
#include <fcntl.h>
|
||||
#include <sys/stat.h>
|
||||
#include <errno.h>
|
||||
#include <sys/socket.h>
|
||||
#include <netinet/in.h>
|
||||
#include <arpa/inet.h>
|
||||
#ifdef __APPLE__
|
||||
#include <libproc.h>
|
||||
#include <sys/sysctl.h>
|
||||
#endif
|
||||
#endif
|
||||
@@ -51,7 +59,30 @@ EmbeddedDaemon::EmbeddedDaemon() = default;
|
||||
|
||||
EmbeddedDaemon::~EmbeddedDaemon()
|
||||
{
|
||||
stop(3000); // Wait up to 3 seconds for clean shutdown
|
||||
// Never stop() from here: a destructor must not kill a node. On Windows stop() also used to fall
|
||||
// back to killing ANY dragonxd.exe by name, so destroying an object that had already stopped its own
|
||||
// process (seed migration's isolated node) could take down the user's main node. Owners call stop()
|
||||
// explicitly (the app's own daemon is never destroyed: main() ends in _Exit). Just stop watching and
|
||||
// release what we hold; a process still running is left to finish on its own.
|
||||
should_stop_ = true;
|
||||
if (monitor_thread_.joinable()) {
|
||||
monitor_thread_.join();
|
||||
}
|
||||
#ifdef _WIN32
|
||||
if (process_handle_ != nullptr) {
|
||||
CloseHandle(process_handle_);
|
||||
process_handle_ = nullptr;
|
||||
}
|
||||
if (stdout_read_ != nullptr) {
|
||||
CloseHandle(stdout_read_);
|
||||
stdout_read_ = nullptr;
|
||||
}
|
||||
#else
|
||||
if (stdout_fd_ >= 0) {
|
||||
close(stdout_fd_);
|
||||
stdout_fd_ = -1;
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
std::string EmbeddedDaemon::findDaemonBinary()
|
||||
@@ -193,11 +224,11 @@ std::vector<std::string> EmbeddedDaemon::getChainParams()
|
||||
DEBUG_LOGF("[INFO] Using %s\n", dbcache_arg.c_str());
|
||||
}
|
||||
|
||||
// No -printtoconsole on any platform: the node writes its timestamped <datadir>/debug.log, which we
|
||||
// tail (and which keeps going if the node is left running after we quit); its stdout/stderr stay piped
|
||||
// on POSIX for crash diagnostics.
|
||||
return {
|
||||
"-tls=only",
|
||||
#ifndef _WIN32
|
||||
"-printtoconsole",
|
||||
#endif
|
||||
"-clientname=ObsidianDragon",
|
||||
"-ac_name=DRAGONX",
|
||||
"-ac_algo=randomx",
|
||||
@@ -244,6 +275,7 @@ void EmbeddedDaemon::appendOutput(const char* data, size_t len)
|
||||
{
|
||||
// Caller must hold output_mutex_
|
||||
process_output_.append(data, len);
|
||||
output_total_ += len;
|
||||
if (process_output_.size() > MAX_OUTPUT_BYTES + MAX_OUTPUT_BYTES / 4) {
|
||||
// Trim to MAX_OUTPUT_BYTES, cutting at a newline boundary
|
||||
size_t trim_to = process_output_.size() - MAX_OUTPUT_BYTES;
|
||||
@@ -545,6 +577,19 @@ bool EmbeddedDaemon::start(const std::string& binary_path)
|
||||
args.push_back("-maxconnections=" + std::to_string(max_connections_));
|
||||
}
|
||||
|
||||
// More RPC workers for a node we launch: dragonxd's default 8 workers + 16-deep queue fill up with
|
||||
// requests waiting behind a long cs_main holder (a witness rebuild), after which even "stop" is refused.
|
||||
// The GUI itself no longer floods it; this is headroom for dragonx-cli and other clients — a delay,
|
||||
// not a fix. Not for an isolated start, and DRAGONX.conf wins (a flag would override the user's own).
|
||||
if (override_datadir_.empty()) {
|
||||
std::string conf;
|
||||
{
|
||||
std::ifstream in((fs::path(util::Platform::getDragonXDataDir()) / "DRAGONX.conf").string(), std::ios::binary);
|
||||
if (in) conf.assign(std::istreambuf_iterator<char>(in), std::istreambuf_iterator<char>());
|
||||
}
|
||||
if (!confSetsKey(conf, "rpcthreads")) args.push_back("-rpcthreads=16");
|
||||
}
|
||||
|
||||
// Host a RandomX stratum pool from this node (-stratum). Only v1.3.0+ daemons implement it; older
|
||||
// ones ignore the unknown flag (no fatal arg check), and the Settings toggle is gated on daemon
|
||||
// version, so this is only enabled against a daemon that supports it. Without -stratumallowip the
|
||||
@@ -599,9 +644,22 @@ bool EmbeddedDaemon::start(const std::string& binary_path)
|
||||
args.push_back("-datadir=" + override_datadir_);
|
||||
}
|
||||
for (const auto& a : override_extra_args_) args.push_back(a);
|
||||
const bool isolated = !override_datadir_.empty();
|
||||
// Tail this node's debug.log — in the isolated datadir for an isolated start — from its current end.
|
||||
{
|
||||
debug_log_path_ = (fs::path(isolated ? override_datadir_ : util::Platform::getDragonXDataDir()) /
|
||||
"debug.log").string();
|
||||
std::error_code ec;
|
||||
const auto size = fs::file_size(debug_log_path_, ec);
|
||||
debug_log_offset_ = ec ? 0 : static_cast<size_t>(size);
|
||||
launch_utc_ = static_cast<std::int64_t>(std::time(nullptr));
|
||||
DEBUG_LOGF("[INFO] Will tail daemon output from: %s (offset %zu)\n", debug_log_path_.c_str(),
|
||||
debug_log_offset_);
|
||||
}
|
||||
override_datadir_.clear();
|
||||
override_extra_args_.clear();
|
||||
|
||||
stop_requested_ = false; // a new process: its exits are crashes until a stop is asked for
|
||||
if (!startProcess(daemon_path, args)) {
|
||||
// startProcess() sets a precise last_error_ (e.g. "dragonxd could not be executed:
|
||||
// ... not executable or wrong architecture"). Surface THAT via setState — which also
|
||||
@@ -616,6 +674,20 @@ bool EmbeddedDaemon::start(const std::string& binary_path)
|
||||
|
||||
setState(State::Running, "dragonxd started");
|
||||
DEBUG_LOGF("[INFO] dragonxd process started successfully\\n");
|
||||
|
||||
// Record it as ours, so a later session recognises it if it is left running (main datadir only).
|
||||
if (!isolated) {
|
||||
#ifdef _WIN32
|
||||
const long pid = process_handle_ ? static_cast<long>(GetProcessId(process_handle_)) : 0;
|
||||
#else
|
||||
const long pid = static_cast<long>(process_pid_);
|
||||
#endif
|
||||
OwnershipRecord rec;
|
||||
rec.pid = pid;
|
||||
rec.startToken = processStartToken(pid);
|
||||
rec.exePath = daemon_path;
|
||||
if (pid > 0 && !rec.startToken.empty()) writeOwnershipRecord(ownershipRecordPath(), rec);
|
||||
}
|
||||
|
||||
// Start monitor thread (if not already running)
|
||||
should_stop_ = false;
|
||||
@@ -677,29 +749,6 @@ bool EmbeddedDaemon::startProcess(const std::string& binary_path, const std::vec
|
||||
}
|
||||
}
|
||||
|
||||
// Determine debug.log path for output tailing.
|
||||
// The daemon writes to %APPDATA%\Hush\DRAGONX\debug.log when -printtoconsole is NOT used.
|
||||
{
|
||||
char appdata[MAX_PATH];
|
||||
if (SUCCEEDED(SHGetFolderPathA(NULL, CSIDL_APPDATA, NULL, 0, appdata))) {
|
||||
debug_log_path_ = std::string(appdata) + "\\Hush\\DRAGONX\\debug.log";
|
||||
} else {
|
||||
const char* home = getenv("APPDATA");
|
||||
if (home) {
|
||||
debug_log_path_ = std::string(home) + "\\Hush\\DRAGONX\\debug.log";
|
||||
}
|
||||
}
|
||||
|
||||
// Record current file size so we only read NEW output from this launch
|
||||
if (!debug_log_path_.empty() && fs::exists(debug_log_path_)) {
|
||||
debug_log_offset_ = static_cast<size_t>(fs::file_size(debug_log_path_));
|
||||
} else {
|
||||
debug_log_offset_ = 0;
|
||||
}
|
||||
DEBUG_LOGF("[INFO] Will tail daemon output from: %s (offset %zu)\n",
|
||||
debug_log_path_.c_str(), debug_log_offset_);
|
||||
}
|
||||
|
||||
// Launch daemon windowless. Use CREATE_NO_WINDOW (NOT CREATE_NEW_CONSOLE): CREATE_NEW_CONSOLE
|
||||
// allocates a console window that briefly flashes on screen before SW_HIDE can hide it, which is
|
||||
// visible as a console-window flash on wallet launch. CREATE_NO_WINDOW gives the console child no
|
||||
@@ -824,7 +873,13 @@ void EmbeddedDaemon::drainOutput()
|
||||
// Witness …" lines the warmup progress bar parses only exist in debug.log on Windows. On a shrink,
|
||||
// restart from the new beginning; the parser is monotonic and converges as it reaches current output.
|
||||
if (currentSize < debug_log_offset_) {
|
||||
debug_log_offset_ = 0;
|
||||
std::string content(currentSize, '\0');
|
||||
DWORD got = 0;
|
||||
LARGE_INTEGER zero{};
|
||||
SetFilePointerEx(hFile, zero, NULL, FILE_BEGIN);
|
||||
if (currentSize > 0 && ReadFile(hFile, &content[0], static_cast<DWORD>(currentSize), &got, NULL))
|
||||
content.resize(got);
|
||||
debug_log_offset_ = shrinkResumeOffset(content, launch_utc_);
|
||||
}
|
||||
if (currentSize <= debug_log_offset_) {
|
||||
CloseHandle(hFile);
|
||||
@@ -855,8 +910,9 @@ void EmbeddedDaemon::drainOutput()
|
||||
CloseHandle(hFile);
|
||||
}
|
||||
|
||||
void EmbeddedDaemon::stop(int wait_ms)
|
||||
bool EmbeddedDaemon::stop(int wait_ms)
|
||||
{
|
||||
stop_requested_ = true;
|
||||
should_stop_ = true;
|
||||
setState(State::Stopping, "Stopping dragonxd...");
|
||||
|
||||
@@ -882,6 +938,7 @@ void EmbeddedDaemon::stop(int wait_ms)
|
||||
}
|
||||
};
|
||||
|
||||
bool exitedOnItsOwn = true;
|
||||
if (tracked_alive) {
|
||||
// Our tracked process (dragonxd.exe launched directly) is still alive.
|
||||
// The RPC "stop" was already sent by the caller — wait for it to exit.
|
||||
@@ -890,40 +947,21 @@ void EmbeddedDaemon::stop(int wait_ms)
|
||||
DEBUG_LOGF("Timeout — forcing daemon termination...\n");
|
||||
TerminateProcess(process_handle_, 1);
|
||||
WaitForSingleObject(process_handle_, 2000);
|
||||
exitedOnItsOwn = false;
|
||||
}
|
||||
drainOutput();
|
||||
CloseHandle(process_handle_);
|
||||
process_handle_ = nullptr;
|
||||
} else {
|
||||
// Tracked handle is dead (batch file case: cmd.exe already exited).
|
||||
// The real dragonxd.exe may still be running as an orphan.
|
||||
// No live process of OUR OWN to stop. Never go looking for one by name: findProcessByName returns
|
||||
// the first dragonxd.exe of any kind (an old node still shutting down, a node on another datadir,
|
||||
// an isolated seed-migration node), and killing it is how an in-progress shutdown got
|
||||
// TerminateProcess'd. Nodes we didn't launch only ever get an RPC stop from the caller.
|
||||
if (process_handle_ != nullptr) {
|
||||
CloseHandle(process_handle_);
|
||||
process_handle_ = nullptr;
|
||||
}
|
||||
|
||||
// Find the real dragonxd.exe process by name
|
||||
DWORD dragonxd_pid = findProcessByName("dragonxd.exe");
|
||||
if (dragonxd_pid != 0) {
|
||||
DEBUG_LOGF("Found orphaned dragonxd.exe (PID %lu) — waiting for RPC stop to take effect...\n", dragonxd_pid);
|
||||
HANDLE hProc = OpenProcess(PROCESS_TERMINATE | SYNCHRONIZE, FALSE, dragonxd_pid);
|
||||
if (hProc) {
|
||||
// RPC stop was already sent — wait for graceful exit
|
||||
if (!pollWait(hProc, wait_ms)) {
|
||||
DEBUG_LOGF("Timeout — forcing dragonxd.exe (PID %lu) termination...\n", dragonxd_pid);
|
||||
TerminateProcess(hProc, 1);
|
||||
WaitForSingleObject(hProc, 2000);
|
||||
}
|
||||
drainOutput();
|
||||
CloseHandle(hProc);
|
||||
DEBUG_LOGF("dragonxd.exe stopped\n");
|
||||
} else {
|
||||
DEBUG_LOGF("Could not open dragonxd.exe process (PID %lu), error %lu\n",
|
||||
dragonxd_pid, GetLastError());
|
||||
}
|
||||
} else {
|
||||
DEBUG_LOGF("No dragonxd.exe process found — daemon may have already exited\n");
|
||||
}
|
||||
DEBUG_LOGF("No tracked daemon process to stop (any other dragonxd.exe is not ours and is left alone)\n");
|
||||
}
|
||||
|
||||
if (stdout_read_ != nullptr) {
|
||||
@@ -938,6 +976,79 @@ void EmbeddedDaemon::stop(int wait_ms)
|
||||
}
|
||||
|
||||
setState(State::Stopped, "dragonxd stopped");
|
||||
return exitedOnItsOwn;
|
||||
}
|
||||
|
||||
bool EmbeddedDaemon::beginStop()
|
||||
{
|
||||
stop_requested_ = true;
|
||||
should_stop_ = true;
|
||||
if (monitor_thread_.joinable()) {
|
||||
monitor_thread_.join();
|
||||
}
|
||||
if (!isRunning()) return false; // handle-based: our own process only
|
||||
setState(State::Stopping, "Stopping dragonxd...");
|
||||
return true;
|
||||
}
|
||||
|
||||
bool EmbeddedDaemon::pollOwnedProcess()
|
||||
{
|
||||
drainOutput();
|
||||
return isRunning();
|
||||
}
|
||||
|
||||
bool EmbeddedDaemon::signalGracefulStop()
|
||||
{
|
||||
return false; // no graceful channel on Windows: the GUI launches dragonxd without a console
|
||||
}
|
||||
|
||||
void EmbeddedDaemon::forceKillOwnedProcess()
|
||||
{
|
||||
if (process_handle_ == nullptr || !isRunning()) return;
|
||||
DEBUG_LOGF("Killing dragonxd at the user's request (TerminateProcess on our own handle)\n");
|
||||
TerminateProcess(process_handle_, 1);
|
||||
WaitForSingleObject(process_handle_, 5000);
|
||||
drainOutput();
|
||||
}
|
||||
|
||||
EmbeddedDaemon::ResourceUsage EmbeddedDaemon::ownedResourceUsage() const
|
||||
{
|
||||
ResourceUsage usage;
|
||||
if (process_handle_ == nullptr) return usage;
|
||||
FILETIME created, exited, kernel, user;
|
||||
if (GetProcessTimes(process_handle_, &created, &exited, &kernel, &user)) {
|
||||
auto ticks = [](const FILETIME& ft) {
|
||||
return (static_cast<unsigned long long>(ft.dwHighDateTime) << 32) | ft.dwLowDateTime;
|
||||
};
|
||||
usage.cpuSeconds = static_cast<double>(ticks(kernel) + ticks(user)) / 1e7; // 100ns units
|
||||
}
|
||||
IO_COUNTERS io;
|
||||
if (GetProcessIoCounters(process_handle_, &io))
|
||||
usage.ioBytes = static_cast<double>(io.ReadTransferCount + io.WriteTransferCount);
|
||||
return usage;
|
||||
}
|
||||
|
||||
void EmbeddedDaemon::endStop(bool keepWatching)
|
||||
{
|
||||
if (keepWatching && isRunning()) {
|
||||
// Still running and still ours: watch it again. Its exit is booked as a clean stop.
|
||||
should_stop_ = false;
|
||||
monitor_thread_ = std::thread(&EmbeddedDaemon::monitorProcess, this);
|
||||
return;
|
||||
}
|
||||
const bool leftRunning = isRunning();
|
||||
if (leftRunning)
|
||||
DEBUG_LOGF("Leaving dragonxd running; it will finish shutting down on its own\n");
|
||||
if (process_handle_ != nullptr) {
|
||||
CloseHandle(process_handle_); // only our handle: the process carries on
|
||||
process_handle_ = nullptr;
|
||||
}
|
||||
if (stdout_read_ != nullptr) {
|
||||
CloseHandle(stdout_read_);
|
||||
stdout_read_ = nullptr;
|
||||
}
|
||||
drainOutput();
|
||||
setState(State::Stopped, leftRunning ? "dragonxd left running" : "dragonxd stopped");
|
||||
}
|
||||
|
||||
// Translate Windows NTSTATUS / exit codes to human-readable descriptions
|
||||
@@ -971,6 +1082,13 @@ void EmbeddedDaemon::monitorProcess()
|
||||
// Read any remaining output after process exits
|
||||
drainOutput();
|
||||
|
||||
// An exit after markStopRequested() is the stop we asked for (it can land before stop() runs).
|
||||
if (!should_stop_ && !isRunning() && stop_requested_) {
|
||||
DEBUG_LOGF("dragonxd exited after a requested stop\n");
|
||||
setState(State::Stopped, "dragonxd stopped");
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if process exited unexpectedly
|
||||
if (!should_stop_ && !isRunning()) {
|
||||
// Get exit code for diagnostics
|
||||
@@ -1292,12 +1410,24 @@ bool EmbeddedDaemon::isRunning() const
|
||||
return (s == State::Running || s == State::Stopping);
|
||||
}
|
||||
|
||||
void EmbeddedDaemon::drainOutput()
|
||||
void EmbeddedDaemon::tailDebugLog()
|
||||
{
|
||||
if (stdout_fd_ < 0) return;
|
||||
if (debug_log_path_.empty()) return;
|
||||
const int fd = open(debug_log_path_.c_str(), O_RDONLY | O_CLOEXEC);
|
||||
if (fd < 0) return; // not created yet
|
||||
struct stat st{};
|
||||
if (fstat(fd, &st) != 0) { close(fd); return; }
|
||||
const size_t size = static_cast<size_t>(st.st_size);
|
||||
if (size < debug_log_offset_) { // shrunk at startup: resume at this run's banner (see shrinkResumeOffset)
|
||||
std::string content(size, '\0');
|
||||
const ssize_t got = size ? pread(fd, &content[0], size, 0) : 0;
|
||||
content.resize(got > 0 ? static_cast<size_t>(got) : 0);
|
||||
debug_log_offset_ = shrinkResumeOffset(content, launch_utc_);
|
||||
}
|
||||
char buffer[4096];
|
||||
for (;;) {
|
||||
ssize_t n = read(stdout_fd_, buffer, sizeof(buffer) - 1);
|
||||
while (debug_log_offset_ < size) {
|
||||
const size_t want = std::min(sizeof(buffer) - 1, size - debug_log_offset_);
|
||||
const ssize_t n = pread(fd, buffer, want, static_cast<off_t>(debug_log_offset_));
|
||||
if (n <= 0) break;
|
||||
buffer[n] = '\0';
|
||||
{
|
||||
@@ -1305,82 +1435,223 @@ void EmbeddedDaemon::drainOutput()
|
||||
appendOutput(buffer, static_cast<size_t>(n));
|
||||
}
|
||||
VERBOSE_LOGF("[dragonxd] %s", buffer);
|
||||
debug_log_offset_ += static_cast<size_t>(n);
|
||||
}
|
||||
close(fd);
|
||||
}
|
||||
|
||||
void EmbeddedDaemon::drainOutput()
|
||||
{
|
||||
if (stdout_fd_ >= 0) {
|
||||
char buffer[4096];
|
||||
for (;;) {
|
||||
ssize_t n = read(stdout_fd_, buffer, sizeof(buffer) - 1);
|
||||
if (n <= 0) break;
|
||||
buffer[n] = '\0';
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(output_mutex_);
|
||||
appendOutput(buffer, static_cast<size_t>(n));
|
||||
}
|
||||
VERBOSE_LOGF("[dragonxd] %s", buffer);
|
||||
}
|
||||
}
|
||||
tailDebugLog();
|
||||
}
|
||||
|
||||
static std::string describeExitStatus(int status)
|
||||
{
|
||||
if (WIFEXITED(status)) return "exit code: " + std::to_string(WEXITSTATUS(status));
|
||||
if (WIFSIGNALED(status)) return "killed by signal " + std::to_string(WTERMSIG(status));
|
||||
return "unknown exit status";
|
||||
}
|
||||
|
||||
bool EmbeddedDaemon::reapWithin(pid_t pid, int timeout_ms, int& status)
|
||||
{
|
||||
const auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(timeout_ms);
|
||||
for (;;) {
|
||||
drainOutput();
|
||||
const pid_t r = waitpid(pid, &status, WNOHANG);
|
||||
if (r == pid) return true;
|
||||
if (r < 0 && errno != EINTR) return true; // ECHILD: nothing left to reap
|
||||
if (std::chrono::steady_clock::now() >= deadline) return false;
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(100));
|
||||
}
|
||||
}
|
||||
|
||||
void EmbeddedDaemon::stop(int wait_ms)
|
||||
bool EmbeddedDaemon::stop(int wait_ms)
|
||||
{
|
||||
stop_requested_ = true;
|
||||
should_stop_ = true;
|
||||
|
||||
if (process_pid_ > 0) {
|
||||
bool exitedOnItsOwn = true;
|
||||
|
||||
// Join the monitor FIRST (it sees should_stop_ within one 100ms tick) so that from here on this is
|
||||
// the only reaper. isRunning() is state-based and stays true until we set Stopped below, so it can't
|
||||
// tell us the child exited: poll waitpid() on a cached pid instead. If the child exited inside that
|
||||
// tick, the monitor already reaped it and, seeing stop_requested_, recorded a clean stop (pid now 0).
|
||||
if (monitor_thread_.joinable()) {
|
||||
monitor_thread_.join();
|
||||
}
|
||||
|
||||
const pid_t pid = process_pid_;
|
||||
if (pid > 0) {
|
||||
setState(State::Stopping, "Stopping dragonxd...");
|
||||
|
||||
int status = 0;
|
||||
|
||||
// Phase 1: Wait for the daemon to exit naturally.
|
||||
// The caller (stopEmbeddedDaemon) already sent an RPC "stop" which
|
||||
// tells the daemon to flush LevelDB, close sockets, and exit cleanly.
|
||||
// On macOS/APFS the LevelDB flush can take several seconds — we must
|
||||
// NOT send SIGTERM until the daemon has had enough time to finish.
|
||||
DEBUG_LOGF("Waiting up to %d ms for daemon to exit after RPC stop...\n", wait_ms);
|
||||
auto start = std::chrono::steady_clock::now();
|
||||
while (isRunning()) {
|
||||
drainOutput();
|
||||
|
||||
auto elapsed = std::chrono::duration_cast<std::chrono::milliseconds>(
|
||||
std::chrono::steady_clock::now() - start).count();
|
||||
|
||||
if (elapsed >= wait_ms) {
|
||||
break;
|
||||
}
|
||||
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(100));
|
||||
}
|
||||
|
||||
// Phase 2: If still running, send SIGTERM and wait a further 10s.
|
||||
if (isRunning()) {
|
||||
DEBUG_LOGF("Daemon did not exit gracefully — sending SIGTERM to process group -%d\n", process_pid_);
|
||||
kill(-process_pid_, SIGTERM);
|
||||
|
||||
auto sigterm_start = std::chrono::steady_clock::now();
|
||||
while (isRunning()) {
|
||||
drainOutput();
|
||||
auto elapsed = std::chrono::duration_cast<std::chrono::milliseconds>(
|
||||
std::chrono::steady_clock::now() - sigterm_start).count();
|
||||
if (elapsed >= 10000) {
|
||||
// Phase 3: Force kill
|
||||
DEBUG_LOGF("Forcing dragonxd termination with SIGKILL (group -%d)...\n", process_pid_);
|
||||
kill(-process_pid_, SIGKILL);
|
||||
break;
|
||||
}
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(100));
|
||||
}
|
||||
if (reapWithin(pid, wait_ms, status)) {
|
||||
DEBUG_LOGF("Daemon exited cleanly after RPC stop (%s)\n", describeExitStatus(status).c_str());
|
||||
} else {
|
||||
DEBUG_LOGF("Daemon exited cleanly after RPC stop\n");
|
||||
// Phase 2: still running — SIGTERM (dragonxd treats it like RPC stop) and a further 10s.
|
||||
DEBUG_LOGF("Daemon did not exit gracefully — sending SIGTERM to process group -%d\n", pid);
|
||||
kill(-pid, SIGTERM);
|
||||
if (!reapWithin(pid, 10000, status)) {
|
||||
// Phase 3: Force kill
|
||||
DEBUG_LOGF("Forcing dragonxd termination with SIGKILL (group -%d)...\n", pid);
|
||||
kill(-pid, SIGKILL);
|
||||
while (waitpid(pid, &status, 0) < 0 && errno == EINTR) {}
|
||||
exitedOnItsOwn = false;
|
||||
}
|
||||
DEBUG_LOGF("dragonxd exited (%s)\n", describeExitStatus(status).c_str());
|
||||
}
|
||||
|
||||
drainOutput(); // read any remaining output
|
||||
|
||||
// Reap the child process
|
||||
int status;
|
||||
waitpid(process_pid_, &status, 0);
|
||||
process_pid_ = 0;
|
||||
}
|
||||
|
||||
|
||||
if (stdout_fd_ >= 0) {
|
||||
close(stdout_fd_);
|
||||
stdout_fd_ = -1;
|
||||
}
|
||||
|
||||
|
||||
setState(State::Stopped, "dragonxd stopped");
|
||||
return exitedOnItsOwn;
|
||||
}
|
||||
|
||||
bool EmbeddedDaemon::beginStop()
|
||||
{
|
||||
stop_requested_ = true;
|
||||
should_stop_ = true;
|
||||
// The monitor sees should_stop_ within one 100ms tick. If the child exited in that window the
|
||||
// monitor reaped it (as a clean stop: stop_requested_ is set) and the pid is now 0.
|
||||
if (monitor_thread_.joinable()) {
|
||||
monitor_thread_.join();
|
||||
}
|
||||
|
||||
setState(State::Stopped, "dragonxd stopped");
|
||||
if (process_pid_ <= 0) return false;
|
||||
setState(State::Stopping, "Stopping dragonxd...");
|
||||
return true;
|
||||
}
|
||||
|
||||
bool EmbeddedDaemon::pollOwnedProcess()
|
||||
{
|
||||
const pid_t pid = process_pid_;
|
||||
if (pid <= 0) return false;
|
||||
drainOutput();
|
||||
int status = 0;
|
||||
const pid_t r = waitpid(pid, &status, WNOHANG);
|
||||
if (r == 0 || (r < 0 && errno == EINTR)) return true;
|
||||
if (r == pid) DEBUG_LOGF("dragonxd exited (%s)\n", describeExitStatus(status).c_str());
|
||||
drainOutput();
|
||||
process_pid_ = 0;
|
||||
return false;
|
||||
}
|
||||
|
||||
bool EmbeddedDaemon::signalGracefulStop()
|
||||
{
|
||||
const pid_t pid = process_pid_;
|
||||
if (pid <= 0) return false;
|
||||
// The whole group: with a bash wrapper (embedded_daemon.cpp startProcess) a SIGTERM to the wrapper
|
||||
// alone would orphan dragonxd.
|
||||
DEBUG_LOGF("Sending SIGTERM to dragonxd's process group -%d (graceful stop)\n", pid);
|
||||
return kill(-pid, SIGTERM) == 0;
|
||||
}
|
||||
|
||||
void EmbeddedDaemon::forceKillOwnedProcess()
|
||||
{
|
||||
const pid_t pid = process_pid_;
|
||||
if (pid <= 0) return;
|
||||
DEBUG_LOGF("Killing dragonxd (group -%d) at the user's request\n", pid);
|
||||
kill(-pid, SIGKILL);
|
||||
int status = 0;
|
||||
while (waitpid(pid, &status, 0) < 0 && errno == EINTR) {}
|
||||
drainOutput();
|
||||
process_pid_ = 0;
|
||||
}
|
||||
|
||||
EmbeddedDaemon::ResourceUsage EmbeddedDaemon::ownedResourceUsage() const
|
||||
{
|
||||
ResourceUsage usage;
|
||||
#ifdef __APPLE__
|
||||
return usage; // no sampler yet (would need proc_pid_rusage): never "idle", so Kill is never offered
|
||||
#else
|
||||
const pid_t pgid = process_pid_;
|
||||
if (pgid <= 0) return usage;
|
||||
// The tracked pid may be a bash wrapper that itself uses ~0 CPU: sum the whole process group.
|
||||
unsigned long long ticks = 0;
|
||||
double io = 0.0;
|
||||
bool anyCpu = false, anyIo = false;
|
||||
std::error_code ec;
|
||||
fs::directory_iterator it("/proc", ec), end;
|
||||
for (; !ec && it != end; it.increment(ec)) {
|
||||
const std::string name = it->path().filename().string();
|
||||
if (name.empty() || name[0] < '0' || name[0] > '9') continue;
|
||||
std::ifstream statFile((it->path() / "stat").string());
|
||||
std::string line;
|
||||
long pgrp = 0;
|
||||
unsigned long long t = 0;
|
||||
if (!statFile || !std::getline(statFile, line) || !parseProcStatLine(line, pgrp, t) || pgrp != pgid)
|
||||
continue;
|
||||
ticks += t;
|
||||
anyCpu = true;
|
||||
std::ifstream ioFile((it->path() / "io").string());
|
||||
std::string key;
|
||||
unsigned long long value = 0;
|
||||
while (ioFile >> key >> value) {
|
||||
if (key == "read_bytes:" || key == "write_bytes:") {
|
||||
io += static_cast<double>(value);
|
||||
anyIo = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
const long hz = sysconf(_SC_CLK_TCK);
|
||||
if (anyCpu && hz > 0) usage.cpuSeconds = static_cast<double>(ticks) / static_cast<double>(hz);
|
||||
if (anyIo) usage.ioBytes = io;
|
||||
return usage;
|
||||
#endif
|
||||
}
|
||||
|
||||
void EmbeddedDaemon::endStop(bool keepWatching)
|
||||
{
|
||||
const pid_t pid = process_pid_;
|
||||
if (pid > 0 && keepWatching) {
|
||||
// Still running and still ours: watch it again. Its exit is booked as a clean stop.
|
||||
should_stop_ = false;
|
||||
monitor_thread_ = std::thread(&EmbeddedDaemon::monitorProcess, this);
|
||||
return;
|
||||
}
|
||||
if (pid > 0) {
|
||||
// Leave it running. Close our end of its output pipe: its further writes fail with EPIPE, which
|
||||
// dragonxd ignores (it ignores SIGPIPE), instead of blocking once a pipe nobody drains is full.
|
||||
// A detached waiter reaps it when it exits, if this process is still around then.
|
||||
DEBUG_LOGF("Leaving dragonxd (pid %d) running; it will finish shutting down on its own\n", pid);
|
||||
std::thread([pid] {
|
||||
int st = 0;
|
||||
while (waitpid(pid, &st, 0) < 0 && errno == EINTR) {}
|
||||
}).detach();
|
||||
process_pid_ = 0;
|
||||
}
|
||||
if (stdout_fd_ >= 0) {
|
||||
close(stdout_fd_);
|
||||
stdout_fd_ = -1;
|
||||
}
|
||||
setState(State::Stopped, pid > 0 ? "dragonxd left running" : "dragonxd stopped");
|
||||
}
|
||||
|
||||
void EmbeddedDaemon::monitorProcess()
|
||||
{
|
||||
char buffer[4096];
|
||||
|
||||
while (!should_stop_) {
|
||||
// Check if process exited (non-blocking waitpid captures exit status)
|
||||
if (process_pid_ > 0) {
|
||||
@@ -1390,38 +1661,196 @@ void EmbeddedDaemon::monitorProcess()
|
||||
// Process exited — drain remaining output
|
||||
drainOutput();
|
||||
|
||||
crash_count_++;
|
||||
std::string exitInfo;
|
||||
if (WIFEXITED(status)) {
|
||||
exitInfo = "exit code: " + std::to_string(WEXITSTATUS(status));
|
||||
} else if (WIFSIGNALED(status)) {
|
||||
exitInfo = "killed by signal " + std::to_string(WTERMSIG(status));
|
||||
} else {
|
||||
exitInfo = "unknown exit status";
|
||||
}
|
||||
const std::string exitInfo = describeExitStatus(status);
|
||||
process_pid_ = 0;
|
||||
if (stop_requested_) {
|
||||
// The stop we asked for (markStopRequested() / stop()) landed before stop() could
|
||||
// reap it: a clean stop, not a crash. A crash recorded earlier keeps its count.
|
||||
DEBUG_LOGF("dragonxd exited after a requested stop (%s)\n", exitInfo.c_str());
|
||||
setState(State::Stopped, "dragonxd stopped");
|
||||
return;
|
||||
}
|
||||
crash_count_++;
|
||||
setState(State::Error, "dragonxd exited unexpectedly (" + exitInfo + ")");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Read process output (non-blocking)
|
||||
ssize_t bytes_read = read(stdout_fd_, buffer, sizeof(buffer) - 1);
|
||||
if (bytes_read > 0) {
|
||||
buffer[bytes_read] = '\0';
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(output_mutex_);
|
||||
appendOutput(buffer, static_cast<size_t>(bytes_read));
|
||||
}
|
||||
VERBOSE_LOGF("[dragonxd] %s", buffer);
|
||||
}
|
||||
|
||||
// Read process output (non-blocking): the stdout/stderr pipe and the node's debug.log
|
||||
drainOutput();
|
||||
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(100));
|
||||
}
|
||||
}
|
||||
|
||||
#endif // _WIN32
|
||||
|
||||
std::string EmbeddedDaemon::processStartToken(long pid)
|
||||
{
|
||||
if (pid <= 0) return {};
|
||||
#ifdef _WIN32
|
||||
HANDLE h = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, static_cast<DWORD>(pid));
|
||||
if (!h) return {};
|
||||
std::string token;
|
||||
DWORD code = 0;
|
||||
FILETIME created{}, exited{}, kernel{}, user{};
|
||||
if (GetExitCodeProcess(h, &code) && code == STILL_ACTIVE &&
|
||||
GetProcessTimes(h, &created, &exited, &kernel, &user)) {
|
||||
token = std::to_string((static_cast<unsigned long long>(created.dwHighDateTime) << 32) |
|
||||
created.dwLowDateTime);
|
||||
}
|
||||
CloseHandle(h);
|
||||
return token;
|
||||
#elif defined(__linux__)
|
||||
std::ifstream f("/proc/" + std::to_string(pid) + "/stat");
|
||||
std::string line;
|
||||
if (!f || !std::getline(f, line)) return {};
|
||||
char state = 0;
|
||||
unsigned long long startTicks = 0;
|
||||
if (!parseProcStatStart(line, state, startTicks) || state == 'Z' || state == 'X') return {};
|
||||
std::string bootId; // start ticks count from boot: tie them to this boot
|
||||
std::ifstream b("/proc/sys/kernel/random/boot_id");
|
||||
if (b) std::getline(b, bootId);
|
||||
return bootId + "/" + std::to_string(startTicks);
|
||||
#elif defined(__APPLE__)
|
||||
struct proc_bsdinfo info{};
|
||||
if (proc_pidinfo(static_cast<int>(pid), PROC_PIDTBSDINFO, 0, &info, sizeof(info)) != sizeof(info)) return {};
|
||||
if (info.pbi_status == 5) return {}; // SZOMB
|
||||
return std::to_string(info.pbi_start_tvsec) + "." + std::to_string(info.pbi_start_tvusec);
|
||||
#else
|
||||
return {};
|
||||
#endif
|
||||
}
|
||||
|
||||
std::int64_t EmbeddedDaemon::processStartUtc(long pid)
|
||||
{
|
||||
if (pid <= 0) return 0;
|
||||
#ifdef _WIN32
|
||||
HANDLE h = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, static_cast<DWORD>(pid));
|
||||
if (!h) return 0;
|
||||
std::int64_t utc = 0;
|
||||
FILETIME created{}, exited{}, kernel{}, user{};
|
||||
if (GetProcessTimes(h, &created, &exited, &kernel, &user)) {
|
||||
const unsigned long long t = (static_cast<unsigned long long>(created.dwHighDateTime) << 32) |
|
||||
created.dwLowDateTime; // 100ns since 1601
|
||||
if (t > 116444736000000000ULL) utc = static_cast<std::int64_t>((t - 116444736000000000ULL) / 10000000ULL);
|
||||
}
|
||||
CloseHandle(h);
|
||||
return utc;
|
||||
#elif defined(__linux__)
|
||||
std::ifstream f("/proc/" + std::to_string(pid) + "/stat");
|
||||
std::string line;
|
||||
char state = 0;
|
||||
unsigned long long startTicks = 0;
|
||||
if (!f || !std::getline(f, line) || !parseProcStatStart(line, state, startTicks)) return 0;
|
||||
std::ifstream s("/proc/stat"); // "btime <boot time, UTC seconds>"
|
||||
std::string key;
|
||||
long long btime = 0;
|
||||
while (s >> key) {
|
||||
if (key == "btime") { s >> btime; break; }
|
||||
s.ignore(std::numeric_limits<std::streamsize>::max(), '\n');
|
||||
}
|
||||
const long hz = sysconf(_SC_CLK_TCK);
|
||||
if (btime <= 0 || hz <= 0) return 0;
|
||||
return static_cast<std::int64_t>(btime + static_cast<long long>(startTicks / static_cast<unsigned long long>(hz)));
|
||||
#elif defined(__APPLE__)
|
||||
struct proc_bsdinfo info{};
|
||||
if (proc_pidinfo(static_cast<int>(pid), PROC_PIDTBSDINFO, 0, &info, sizeof(info)) != sizeof(info)) return 0;
|
||||
return static_cast<std::int64_t>(info.pbi_start_tvsec);
|
||||
#else
|
||||
return 0;
|
||||
#endif
|
||||
}
|
||||
|
||||
bool EmbeddedDaemon::processIsDaemon(long pid)
|
||||
{
|
||||
if (pid <= 0) return false;
|
||||
#ifdef _WIN32
|
||||
HANDLE h = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, static_cast<DWORD>(pid));
|
||||
if (!h) return false;
|
||||
char path[MAX_PATH];
|
||||
bool is = false;
|
||||
// A device path (\Device\HarddiskVolume3\...\dragonxd.exe); only its file name matters.
|
||||
const DWORD len = GetProcessImageFileNameA(h, path, MAX_PATH);
|
||||
if (len > 0) {
|
||||
std::string name(path, len);
|
||||
const size_t slash = name.find_last_of("\\/");
|
||||
if (slash != std::string::npos) name.erase(0, slash + 1);
|
||||
std::transform(name.begin(), name.end(), name.begin(), [](unsigned char c) { return std::tolower(c); });
|
||||
is = name == "dragonxd.exe";
|
||||
}
|
||||
CloseHandle(h);
|
||||
return is;
|
||||
#elif defined(__linux__)
|
||||
const fs::path proc = fs::path("/proc") / std::to_string(pid);
|
||||
std::ifstream f((proc / "comm").string());
|
||||
std::string comm;
|
||||
if (f) std::getline(f, comm);
|
||||
std::error_code ec;
|
||||
return isDaemonProcessIdentity(comm, fs::read_symlink(proc / "exe", ec).filename().string());
|
||||
#elif defined(__APPLE__)
|
||||
char path[PROC_PIDPATHINFO_MAXSIZE];
|
||||
if (proc_pidpath(static_cast<int>(pid), path, sizeof(path)) <= 0) return false;
|
||||
return isDaemonProcessIdentity("", fs::path(path).filename().string());
|
||||
#else
|
||||
return false;
|
||||
#endif
|
||||
}
|
||||
|
||||
std::string EmbeddedDaemon::ownershipRecordPath()
|
||||
{
|
||||
return (fs::path(util::Platform::getDragonXDataDir()) / "obsidiandragon-dragonxd.pid").string();
|
||||
}
|
||||
|
||||
size_t EmbeddedDaemon::shrinkResumeOffset(const std::string& content, std::int64_t launchUtc)
|
||||
{
|
||||
size_t pos = 0;
|
||||
while (pos < content.size()) {
|
||||
size_t end = content.find('\n', pos);
|
||||
if (end == std::string::npos) end = content.size();
|
||||
const std::string line = content.substr(pos, end - pos);
|
||||
if ((line.find("DragonX version ") != std::string::npos || line.find("Hush version ") != std::string::npos)) {
|
||||
const std::int64_t at = parseDaemonLogUtc(line);
|
||||
if (at > 0 && at >= launchUtc - 5) return pos; // 5s: the log's second vs our clock read
|
||||
}
|
||||
pos = end + 1;
|
||||
}
|
||||
return content.size();
|
||||
}
|
||||
|
||||
void EmbeddedDaemon::writeOwnershipRecord(const std::string& path, const OwnershipRecord& r)
|
||||
{
|
||||
const std::string tmp = path + ".tmp";
|
||||
{
|
||||
std::ofstream out(tmp, std::ios::binary | std::ios::trunc);
|
||||
if (!out) return;
|
||||
out << formatOwnershipRecord(r);
|
||||
if (!out) return;
|
||||
}
|
||||
std::error_code ec;
|
||||
fs::rename(tmp, path, ec);
|
||||
if (ec) fs::remove(tmp, ec);
|
||||
else DEBUG_LOGF("[INFO] Recorded our dragonxd (pid %ld) in %s\n", r.pid, path.c_str());
|
||||
}
|
||||
|
||||
bool EmbeddedDaemon::liveOwnershipRecord(const std::string& path, OwnershipRecord* out)
|
||||
{
|
||||
std::string text;
|
||||
{
|
||||
std::ifstream in(path, std::ios::binary);
|
||||
if (!in) return false;
|
||||
text.assign(std::istreambuf_iterator<char>(in), std::istreambuf_iterator<char>());
|
||||
}
|
||||
OwnershipRecord r;
|
||||
if (parseOwnershipRecord(text, r) && processStartToken(r.pid) == r.startToken) {
|
||||
if (out) *out = r;
|
||||
return true;
|
||||
}
|
||||
std::error_code ec; // that process is gone (or the pid now belongs to another): the record is stale
|
||||
if (fs::remove(path, ec)) DEBUG_LOGF("[INFO] Removed stale dragonxd record (pid %ld)\n", r.pid);
|
||||
return false;
|
||||
}
|
||||
|
||||
bool EmbeddedDaemon::isRpcPortInUse()
|
||||
{
|
||||
int port = std::atoi(DRAGONX_DEFAULT_RPC_PORT);
|
||||
@@ -1438,8 +1867,8 @@ bool EmbeddedDaemon::isDaemonProcessRunning()
|
||||
#ifdef _WIN32
|
||||
return findProcessByName("dragonxd.exe") != 0;
|
||||
#elif defined(__linux__)
|
||||
// Scan /proc for a process whose comm is exactly "dragonxd". Iterate with an error_code so a proc
|
||||
// entry vanishing mid-scan (a process exiting) can't throw.
|
||||
// Scan /proc for a dragonxd process (by comm or by executable: see isDaemonProcessIdentity). Iterate
|
||||
// with an error_code so a proc entry vanishing mid-scan (a process exiting) can't throw.
|
||||
std::error_code ec;
|
||||
fs::directory_iterator it("/proc", ec), end;
|
||||
for (; !ec && it != end; it.increment(ec)) {
|
||||
@@ -1447,11 +1876,26 @@ bool EmbeddedDaemon::isDaemonProcessRunning()
|
||||
if (pid.empty() || pid[0] < '0' || pid[0] > '9') continue; // numeric pid dirs only
|
||||
std::ifstream f((it->path() / "comm").string());
|
||||
std::string comm;
|
||||
if (f && std::getline(f, comm) && comm == "dragonxd") return true;
|
||||
if (f) std::getline(f, comm);
|
||||
std::error_code linkEc; // another user's process: EACCES, leaving the name empty
|
||||
const std::string exe = fs::read_symlink(it->path() / "exe", linkEc).filename().string();
|
||||
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<pid_t> pids(static_cast<size_t>(count) + 64); // room for processes started meanwhile
|
||||
const int n = proc_listallpids(pids.data(), static_cast<int>(pids.size() * sizeof(pid_t)));
|
||||
char path[PROC_PIDPATHINFO_MAXSIZE];
|
||||
for (int i = 0; i < n && i < static_cast<int>(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
|
||||
}
|
||||
|
||||
+190
-12
@@ -4,6 +4,9 @@
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <string>
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
@@ -55,11 +58,63 @@ public:
|
||||
bool start(const std::string& binary_path = "");
|
||||
|
||||
/**
|
||||
* @brief Stop the running daemon
|
||||
* @brief Stop the daemon process this object launched (never any other dragonxd)
|
||||
* @param wait_ms Maximum milliseconds to wait for graceful shutdown
|
||||
* @return true if it exited on its own (or there was nothing to stop), false if it had to be
|
||||
* force-killed — then its wallet/chain files may not have been closed cleanly
|
||||
*/
|
||||
void stop(int wait_ms = 5000);
|
||||
|
||||
bool stop(int wait_ms = 5000);
|
||||
|
||||
/**
|
||||
* @brief Record that we are about to ask the daemon to stop (e.g. over RPC)
|
||||
*
|
||||
* Call it before sending the stop. If the daemon then exits before stop() runs, the monitor
|
||||
* records a clean stop instead of a crash (crash_count_ drives auto-restart). stop() sets it
|
||||
* too; start() clears it.
|
||||
*/
|
||||
void markStopRequested() { stop_requested_ = true; }
|
||||
|
||||
// --- A stop session, driven by DaemonController::executeDaemonStop through the app's StopRuntime ---
|
||||
|
||||
/** Marks the stop as requested and stops the monitor, so the session is the only reaper.
|
||||
* @return true if a process this object launched is still alive; then finish with endStop(). */
|
||||
bool beginStop();
|
||||
/** Polls our own process (reaping it on POSIX) and drains its output; false once it has exited. */
|
||||
bool pollOwnedProcess();
|
||||
/** POSIX: SIGTERM our process group (dragonxd treats it like RPC stop). Windows has no such channel. */
|
||||
bool signalGracefulStop();
|
||||
/** Ends our own process by force (SIGKILL / TerminateProcess) and reaps it. Explicit user choice only. */
|
||||
void forceKillOwnedProcess();
|
||||
struct ResourceUsage {
|
||||
double cpuSeconds = -1.0; // cumulative; < 0 if unknown
|
||||
double ioBytes = -1.0; // cumulative storage I/O; < 0 if unknown
|
||||
};
|
||||
/** CPU and I/O used so far by our process (its whole process group on Linux). */
|
||||
ResourceUsage ownedResourceUsage() const;
|
||||
/** Finishes a stop session. The process is gone: release everything. Still running: keepWatching
|
||||
* resumes the monitor (its exit is still seen, and booked as a clean stop); otherwise let it go —
|
||||
* the user chose to leave it running. */
|
||||
void endStop(bool keepWatching);
|
||||
|
||||
/**
|
||||
* @brief Parse a /proc/<pid>/stat line: process group (field 5) and utime+stime ticks (fields 14+15)
|
||||
*
|
||||
* comm (field 2) is parenthesised and may itself contain spaces and ')', so fields are read from
|
||||
* after the LAST ')'.
|
||||
*/
|
||||
static bool parseProcStatLine(const std::string& line, long& pgrp, unsigned long long& cpuTicks)
|
||||
{
|
||||
const size_t close = line.rfind(')');
|
||||
if (close == std::string::npos) return false;
|
||||
unsigned long long utime = 0, stime = 0;
|
||||
// state ppid pgrp session tty_nr tpgid flags minflt cminflt majflt cmajflt utime stime
|
||||
if (std::sscanf(line.c_str() + close + 1, " %*c %*d %ld %*d %*d %*d %*u %*u %*u %*u %*u %llu %llu",
|
||||
&pgrp, &utime, &stime) != 3)
|
||||
return false;
|
||||
cpuTicks = utime + stime;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Check if daemon process is running
|
||||
*/
|
||||
@@ -93,16 +148,26 @@ public:
|
||||
|
||||
/**
|
||||
* @brief Get new output since a given offset (thread-safe).
|
||||
* Returns the new text and updates offset to the current size.
|
||||
* Returns the new text and advances offset. Offsets are absolute positions in everything ever
|
||||
* appended (start from 0), so trimming old output never shifts them: a reader that falls behind a
|
||||
* trim only loses the trimmed part, not output that is still buffered.
|
||||
*/
|
||||
std::string getOutputSince(size_t& offset) const {
|
||||
std::lock_guard<std::mutex> lk(output_mutex_);
|
||||
if (offset >= process_output_.size()) {
|
||||
offset = process_output_.size();
|
||||
return outputSince(process_output_, output_total_, offset);
|
||||
}
|
||||
|
||||
/** getOutputSince() on a buffer holding the LAST buffer.size() of `total` bytes ever appended. */
|
||||
static std::string outputSince(const std::string& buffer, size_t total, size_t& offset)
|
||||
{
|
||||
const size_t base = total - buffer.size(); // absolute position of buffer[0]
|
||||
if (offset < base) offset = base; // trimmed before it was read: resume at what's left
|
||||
if (offset >= total) {
|
||||
offset = total;
|
||||
return {};
|
||||
}
|
||||
std::string result = process_output_.substr(offset);
|
||||
offset = process_output_.size();
|
||||
std::string result = buffer.substr(offset - base);
|
||||
offset = total;
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -245,6 +310,109 @@ public:
|
||||
*/
|
||||
static bool isDaemonProcessRunning();
|
||||
|
||||
/**
|
||||
* @brief Does a /proc entry (its comm + its exe link's basename) belong to a dragonxd? (Linux)
|
||||
*
|
||||
* comm alone misses a node in its final shutdown phase: Shutdown() renames the main thread to
|
||||
* "hush-shutoff" while the process still holds the datadir lock. The exe link keeps the binary
|
||||
* name, with " (deleted)" appended once the binary was replaced underneath it (reinstall/update).
|
||||
*/
|
||||
static bool isDaemonProcessIdentity(const std::string& comm, std::string exeName)
|
||||
{
|
||||
static const std::string kDeleted = " (deleted)";
|
||||
if (exeName.size() > kDeleted.size() &&
|
||||
exeName.compare(exeName.size() - kDeleted.size(), kDeleted.size(), kDeleted) == 0)
|
||||
exeName.erase(exeName.size() - kDeleted.size());
|
||||
return comm == "dragonxd" || exeName == "dragonxd";
|
||||
}
|
||||
|
||||
// ---- Ownership record --------------------------------------------------------------------------
|
||||
// <datadir>/obsidiandragon-dragonxd.pid names the node this wallet launched, so a later session that
|
||||
// finds it still running (after "Leave running") knows it is ours: it may stop it gracefully and wait
|
||||
// for it, never kill it. The start token (when that process started) defeats PID reuse and makes a
|
||||
// record from before a reboot stale.
|
||||
struct OwnershipRecord {
|
||||
long pid = 0;
|
||||
std::string startToken;
|
||||
std::string exePath;
|
||||
};
|
||||
static std::string formatOwnershipRecord(const OwnershipRecord& r)
|
||||
{
|
||||
return "pid=" + std::to_string(r.pid) + "\nstart=" + r.startToken + "\nexe=" + r.exePath + "\n";
|
||||
}
|
||||
static bool parseOwnershipRecord(const std::string& text, OwnershipRecord& out)
|
||||
{
|
||||
OwnershipRecord r;
|
||||
size_t pos = 0;
|
||||
while (pos < text.size()) {
|
||||
size_t end = text.find('\n', pos);
|
||||
if (end == std::string::npos) end = text.size();
|
||||
std::string line = text.substr(pos, end - pos);
|
||||
pos = end + 1;
|
||||
if (!line.empty() && line.back() == '\r') line.pop_back();
|
||||
if (line.rfind("pid=", 0) == 0) r.pid = std::strtol(line.c_str() + 4, nullptr, 10);
|
||||
else if (line.rfind("start=", 0) == 0) r.startToken = line.substr(6);
|
||||
else if (line.rfind("exe=", 0) == 0) r.exePath = line.substr(4);
|
||||
}
|
||||
if (r.pid <= 0 || r.startToken.empty()) return false;
|
||||
out = r;
|
||||
return true;
|
||||
}
|
||||
/** Linux /proc/<pid>/stat: the process state and its start time (clock ticks after boot, field 22). */
|
||||
static bool parseProcStatStart(const std::string& line, char& state, unsigned long long& startTicks)
|
||||
{
|
||||
const size_t close = line.rfind(')');
|
||||
if (close == std::string::npos) return false;
|
||||
// state ppid pgrp session tty_nr tpgid flags minflt cminflt majflt cmajflt utime stime cutime cstime
|
||||
// priority nice num_threads itrealvalue starttime
|
||||
return std::sscanf(line.c_str() + close + 1,
|
||||
" %c %*d %*d %*d %*d %*d %*u %*u %*u %*u %*u %*u %*u %*d %*d %*d %*d %*d %*d %llu",
|
||||
&state, &startTicks) == 2;
|
||||
}
|
||||
/**
|
||||
* dragonxd shrinks a large debug.log at startup (it keeps the tail), so the file can become smaller
|
||||
* than where we were reading. Resume at THIS run's startup banner — the first banner line stamped at or
|
||||
* after `launchUtc` (UTC) — not at 0, which would replay the previous run's lines (old rebuild markers,
|
||||
* old corruption errors) as if they were new. No such banner yet: skip to the end (it follows).
|
||||
*/
|
||||
static size_t shrinkResumeOffset(const std::string& content, std::int64_t launchUtc);
|
||||
|
||||
/** When process `pid` started, as an opaque token; "" if it isn't running (or is a zombie). */
|
||||
static std::string processStartToken(long pid);
|
||||
/** When process `pid` started, in UTC seconds; 0 if unknown. */
|
||||
static std::int64_t processStartUtc(long pid);
|
||||
/** Is process `pid` a dragonxd (by name / executable)? */
|
||||
static bool processIsDaemon(long pid);
|
||||
static std::string ownershipRecordPath(); // <datadir>/obsidiandragon-dragonxd.pid
|
||||
static void writeOwnershipRecord(const std::string& path, const OwnershipRecord& r);
|
||||
/** The record at `path`, if its process is still running; a stale or unreadable record is deleted. */
|
||||
static bool liveOwnershipRecord(const std::string& path, OwnershipRecord* out = nullptr);
|
||||
|
||||
/**
|
||||
* Does this DRAGONX.conf text set `key` on an active line ("key=value", spaces allowed around '=';
|
||||
* '#' starts a comment)? Used before adding a flag of our own: a command-line flag overrides the conf,
|
||||
* so the user's own setting must win.
|
||||
*/
|
||||
static bool confSetsKey(const std::string& confText, const std::string& key)
|
||||
{
|
||||
size_t pos = 0;
|
||||
while (pos < confText.size()) {
|
||||
size_t end = confText.find('\n', pos);
|
||||
if (end == std::string::npos) end = confText.size();
|
||||
std::string line = confText.substr(pos, end - pos);
|
||||
pos = end + 1;
|
||||
const size_t hash = line.find('#');
|
||||
if (hash != std::string::npos) line.erase(hash);
|
||||
const size_t eq = line.find('=');
|
||||
if (eq == std::string::npos) continue;
|
||||
size_t b = line.find_first_not_of(" \t\r");
|
||||
size_t e = line.find_last_not_of(" \t\r", eq == 0 ? 0 : eq - 1);
|
||||
if (b == std::string::npos || b >= eq || e == std::string::npos || e < b) continue;
|
||||
if (line.compare(b, e - b + 1, key) == 0) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Decision returned by evaluateDatadirLockGate(): whether start() may spawn now. */
|
||||
struct StartLockGateDecision {
|
||||
bool proceed = true; // false => bail before spawning
|
||||
@@ -292,24 +460,34 @@ private:
|
||||
mutable std::mutex error_mutex_; // protects last_error_ (written by main + monitor threads)
|
||||
mutable std::mutex output_mutex_; // protects process_output_
|
||||
std::string process_output_;
|
||||
size_t output_total_ = 0; // bytes ever appended (the absolute end of process_output_)
|
||||
StateCallback state_callback_;
|
||||
|
||||
// Process handle
|
||||
#ifdef _WIN32
|
||||
HANDLE process_handle_ = nullptr;
|
||||
HANDLE stdout_read_ = nullptr; // pipe handle (Linux-style pipe, unused when tailing debug.log)
|
||||
std::string debug_log_path_; // path to daemon's debug.log for output tailing
|
||||
size_t debug_log_offset_ = 0; // read offset into debug.log
|
||||
std::string launch_cmd_; // full command line used to launch daemon (for diagnostics)
|
||||
std::string launch_binary_; // binary path used (for diagnostics)
|
||||
std::string launch_workdir_; // working directory used (for diagnostics)
|
||||
#else
|
||||
pid_t process_pid_ = 0;
|
||||
int stdout_fd_ = -1;
|
||||
int stdout_fd_ = -1; // the child's stdout+stderr (crash diagnostics; the log itself is debug.log)
|
||||
void tailDebugLog();
|
||||
|
||||
// Waits up to timeout_ms for `pid` to exit, draining its output; true once reaped. Only valid
|
||||
// after monitor_thread_ is joined, so that stop() is the sole reaper.
|
||||
bool reapWithin(pid_t pid, int timeout_ms, int& status);
|
||||
#endif
|
||||
|
||||
|
||||
// The node's output is its <datadir>/debug.log (timestamped; it outlives us if the node is left running).
|
||||
std::string debug_log_path_;
|
||||
size_t debug_log_offset_ = 0; // read offset into debug.log
|
||||
std::int64_t launch_utc_ = 0; // when we launched it (to find this run's banner after a log shrink)
|
||||
|
||||
std::thread monitor_thread_;
|
||||
std::atomic<bool> should_stop_{false};
|
||||
std::atomic<bool> stop_requested_{false}; // a stop was asked for: the next exit is not a crash
|
||||
std::set<std::string> debug_categories_;
|
||||
int max_connections_ = 0; // 0 = daemon default
|
||||
bool stratum_enabled_ = false; // -stratum: host a RandomX pool (v1.3.0+; older daemons ignore it)
|
||||
|
||||
@@ -17,6 +17,21 @@ namespace daemon {
|
||||
|
||||
namespace {
|
||||
|
||||
// Asks the isolated node to stop over its own RPC (port + throwaway credentials, never the main node's)
|
||||
// and waits for it to exit. Without the RPC stop it was only ever force-killed, holding the wallet.dat
|
||||
// it had just created. Returns false if it still had to be force-killed.
|
||||
bool stopIsolatedNode(EmbeddedDaemon& node, rpc::RPCClient& cli, int port, const std::string& user,
|
||||
const std::string& pass, int waitMs)
|
||||
{
|
||||
node.markStopRequested();
|
||||
// connect() also succeeds against a node still in warmup, which accepts stop too.
|
||||
if (cli.isConnected() || cli.connect("127.0.0.1", std::to_string(port), user, pass, /*useTls=*/false)) {
|
||||
try { cli.call("stop", nlohmann::json::array(), 10L); } catch (const std::exception&) {}
|
||||
}
|
||||
cli.disconnect();
|
||||
return node.stop(waitMs);
|
||||
}
|
||||
|
||||
// Random alphanumeric token for the isolated node's throwaway RPC credentials (libsodium CSPRNG;
|
||||
// sodium_init() has already run at app startup for the chat crypto).
|
||||
std::string randomToken(int n)
|
||||
@@ -113,7 +128,7 @@ SeedWalletResult SeedWalletCreator::create(bool keepDatadir,
|
||||
}
|
||||
if (!ready) {
|
||||
r.error = "The isolated node did not become ready in time.";
|
||||
temp.stop(20000);
|
||||
stopIsolatedNode(temp, cli, port, user, pass, 20000); // datadir is thrown away: a forced stop is harmless
|
||||
fs::remove_all(base, ec);
|
||||
return r;
|
||||
}
|
||||
@@ -146,18 +161,24 @@ SeedWalletResult SeedWalletCreator::create(bool keepDatadir,
|
||||
}
|
||||
}
|
||||
|
||||
// 7. Stop the isolated node (graceful; it flushes its tiny empty chain quickly). Adopt later copies
|
||||
// this wallet.dat, so it must have been closed cleanly: a node that had to be force-killed
|
||||
// fails the creation (nothing has been swept into it yet, so nothing is lost by retrying).
|
||||
if (!stopIsolatedNode(temp, cli, port, user, pass, 60000) && r.ok) {
|
||||
r.ok = false;
|
||||
r.error = "The isolated node had to be force-stopped, so its new wallet can't be trusted. "
|
||||
"Please try again.";
|
||||
}
|
||||
|
||||
// W1-2: never hand back a live seed on a failure path. If the mnemonic was exported but a
|
||||
// later step failed (empty address, or z_getnewaddress threw), the caller discards this
|
||||
// result without wiping it, which would leave the seed resident. Success keeps it deliberately.
|
||||
// later step failed (empty address, z_getnewaddress threw, or an unclean stop), the caller
|
||||
// discards this result without wiping it, which would leave the seed resident. Success keeps
|
||||
// it deliberately.
|
||||
if (!r.ok && !r.seedPhrase.empty()) {
|
||||
sodium_memzero(&r.seedPhrase[0], r.seedPhrase.size());
|
||||
r.seedPhrase.clear();
|
||||
}
|
||||
|
||||
// 7. Stop the isolated node (graceful; it flushes its tiny empty chain quickly).
|
||||
cli.disconnect();
|
||||
temp.stop(20000);
|
||||
|
||||
// 8. Keep the temp wallet for a later sweep/adopt step, or scrub it. tempDatadir is the
|
||||
// migration root `base`; the new wallet.dat lives in <base>/DRAGONX.
|
||||
r.tempDatadir = base;
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
#pragma once
|
||||
|
||||
// Delivering "stop" to dragonxd: what one attempt achieved, and when to send another. Pure and
|
||||
// header-only so the test target can drive it without the RPC client.
|
||||
//
|
||||
// Why retrying matters: dragonxd's HTTP server has 8 workers and a 16-deep queue. While a long
|
||||
// operation holds cs_main, the GUI's own abandoned polls can fill every slot, and each new request is
|
||||
// refused at once with "Work queue depth exceeded" until a slot frees. The old code made one or two
|
||||
// attempts, gated on a getinfo probe that needs cs_main, so stop was never delivered and the node was
|
||||
// then killed.
|
||||
|
||||
#include "rpc/rpc_failure.h"
|
||||
|
||||
namespace dragonx {
|
||||
namespace daemon {
|
||||
|
||||
// What the user picked on the shutdown screen while the node was stopping.
|
||||
enum class StopUserChoice { None, Detach, Kill };
|
||||
|
||||
// How a stop session ended. The wallet never kills the node unless the user chose Kill.
|
||||
enum class StopOutcome {
|
||||
Exited, // the process is gone
|
||||
StillRunning, // we stopped waiting (time budget, app shutdown, cancellation); never killed
|
||||
Detached, // the user chose to leave it running
|
||||
Killed, // the user chose Kill
|
||||
};
|
||||
|
||||
inline const char* stopOutcomeName(StopOutcome o)
|
||||
{
|
||||
switch (o) {
|
||||
case StopOutcome::Exited: return "Exited";
|
||||
case StopOutcome::StillRunning: return "StillRunning";
|
||||
case StopOutcome::Detached: return "Detached";
|
||||
case StopOutcome::Killed: return "Killed";
|
||||
}
|
||||
return "Exited";
|
||||
}
|
||||
|
||||
enum class StopSendResult {
|
||||
Accepted, // HTTP 200: the daemon ran stop
|
||||
Queued, // reply timeout: it reached the daemon and waits for a worker; it will run
|
||||
QueueFull, // "Work queue depth exceeded": refused before queueing (holds no slot)
|
||||
NotListening, // refused / connect timeout: RPC isn't bound (not up yet, or already unbound)
|
||||
ShuttingDown, // 503 / "Unhandled request": the daemon is already shutting down
|
||||
Auth, // 401: our credentials are wrong or stale
|
||||
Other,
|
||||
};
|
||||
|
||||
inline const char* stopSendResultName(StopSendResult r)
|
||||
{
|
||||
switch (r) {
|
||||
case StopSendResult::Accepted: return "Accepted";
|
||||
case StopSendResult::Queued: return "Queued";
|
||||
case StopSendResult::QueueFull: return "QueueFull";
|
||||
case StopSendResult::NotListening: return "NotListening";
|
||||
case StopSendResult::ShuttingDown: return "ShuttingDown";
|
||||
case StopSendResult::Auth: return "Auth";
|
||||
case StopSendResult::Other: return "Other";
|
||||
}
|
||||
return "Other";
|
||||
}
|
||||
|
||||
// What a stop call's failure kind (RpcFailureKind::None = HTTP 200) means for delivery.
|
||||
inline StopSendResult stopSendResultFromKind(rpc::RpcFailureKind kind)
|
||||
{
|
||||
using K = rpc::RpcFailureKind;
|
||||
switch (kind) {
|
||||
case K::None: return StopSendResult::Accepted;
|
||||
case K::ReplyTimeout: return StopSendResult::Queued;
|
||||
case K::QueueFull: return StopSendResult::QueueFull;
|
||||
case K::NotListening:
|
||||
case K::ConnectTimeout: return StopSendResult::NotListening;
|
||||
case K::ShuttingDown:
|
||||
case K::Unhandled: return StopSendResult::ShuttingDown;
|
||||
case K::Auth: return StopSendResult::Auth;
|
||||
default: return StopSendResult::Other;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* When to (re)send stop. Times are seconds on the caller's clock.
|
||||
*
|
||||
* - Accepted / ShuttingDown: delivered, never resend.
|
||||
* - Queued: the daemon has it. Resend at most every 30s: each resend takes another queue slot, and
|
||||
* concurrent stops race on the miner threads (stop's GenerateBitcoins(false)).
|
||||
* - QueueFull / NotListening / Other: not delivered, cheap to retry: again after 1.5s.
|
||||
* - NotListening AFTER the daemon had heard us means it unbound its listener to shut down: delivered.
|
||||
* - Auth: give up for a node we don't own. For our own node the caller may switch credentials and
|
||||
* retry at once (retryNow), or give up (giveUp).
|
||||
*/
|
||||
class StopDeliveryPolicy {
|
||||
public:
|
||||
static constexpr double kRetrySec = 1.5;
|
||||
static constexpr double kQueuedResendSec = 30.0;
|
||||
|
||||
explicit StopDeliveryPolicy(bool owned) : owned_(owned) {}
|
||||
|
||||
bool shouldSend(double now) const { return !finished_ && now >= nextSendAt_; }
|
||||
|
||||
// Records one attempt; returns it as interpreted (a refusal after we were heard reads as ShuttingDown).
|
||||
StopSendResult record(StopSendResult r, double now)
|
||||
{
|
||||
++attempts_;
|
||||
if (r == StopSendResult::NotListening && heard_) r = StopSendResult::ShuttingDown;
|
||||
switch (r) {
|
||||
case StopSendResult::Accepted:
|
||||
heard_ = true;
|
||||
markDelivered();
|
||||
break;
|
||||
case StopSendResult::ShuttingDown:
|
||||
markDelivered();
|
||||
break;
|
||||
case StopSendResult::Queued:
|
||||
heard_ = true;
|
||||
nextSendAt_ = now + kQueuedResendSec;
|
||||
break;
|
||||
case StopSendResult::Auth:
|
||||
authRejected_ = true;
|
||||
if (!owned_) finished_ = true;
|
||||
nextSendAt_ = now + kRetrySec;
|
||||
break;
|
||||
case StopSendResult::QueueFull:
|
||||
case StopSendResult::NotListening:
|
||||
case StopSendResult::Other:
|
||||
nextSendAt_ = now + kRetrySec;
|
||||
break;
|
||||
}
|
||||
last_ = r;
|
||||
return r;
|
||||
}
|
||||
|
||||
void markDelivered() { delivered_ = finished_ = true; } // also for a graceful signal (POSIX SIGTERM)
|
||||
void retryNow(double now) { nextSendAt_ = now; }
|
||||
void giveUp() { finished_ = true; }
|
||||
|
||||
bool delivered() const { return delivered_; } // the daemon is stopping (or was signalled)
|
||||
bool heard() const { return heard_; } // it has (or ran) our request
|
||||
bool finished() const { return finished_; } // no more attempts
|
||||
bool authRejected() const { return authRejected_; }
|
||||
int attempts() const { return attempts_; }
|
||||
StopSendResult last() const { return last_; }
|
||||
|
||||
private:
|
||||
bool owned_ = true;
|
||||
bool delivered_ = false;
|
||||
bool heard_ = false;
|
||||
bool finished_ = false;
|
||||
bool authRejected_ = false;
|
||||
int attempts_ = 0;
|
||||
double nextSendAt_ = 0.0;
|
||||
StopSendResult last_ = StopSendResult::Other;
|
||||
};
|
||||
|
||||
} // namespace daemon
|
||||
} // namespace dragonx
|
||||
@@ -34,4 +34,12 @@ inline MigrationResume decideSeedMigrationResume(bool pending,
|
||||
return MigrationResume::SweepGate;
|
||||
}
|
||||
|
||||
// At the Sweep step with a ~0 legacy balance, may we offer the direct "no funds -> adopt" short-cut
|
||||
// (swap wallet.dat immediately)? Only when NO sweep has been submitted — a genuinely empty wallet.
|
||||
// If a sweep WAS submitted, a ~0 balance is ambiguous: the sweep tx may be unconfirmed in the mempool
|
||||
// and could revert on a drop/reorg, so adopting now can strand the funds in the moved-aside backup.
|
||||
// In that case adopt must go through the confirmation-gated path (>=1 conf) instead — never swap
|
||||
// wallet.dat on an unconfirmed sweep. (fund-safety: audit)
|
||||
inline bool sweepGateMayAdoptEmptyWallet(bool sweepSubmitted) { return !sweepSubmitted; }
|
||||
|
||||
} // namespace dragonx
|
||||
|
||||
@@ -149,7 +149,13 @@ struct SyncInfo {
|
||||
float witness_progress = 0.0f; // 0.0 - 1.0, within the current sub-phase
|
||||
int witness_remaining = 0; // blocks left in the cache walk (0 if unknown / phase 1)
|
||||
|
||||
bool isSynced() const { return !syncing && blocks > 0 && blocks >= headers - 2; }
|
||||
// True only when the daemon reports a positive peer-derived tip (longestchain > 0). A node with
|
||||
// no peers reports longestchain == 0, so `blocks` is then only the LOCAL best height and cannot be
|
||||
// trusted as "at the network tip". isSynced() gates on this so a peerless / cold-started node is
|
||||
// never reported synced (which would enable Send against a stale balance). (audit: peerless-synced)
|
||||
bool tip_known = false;
|
||||
|
||||
bool isSynced() const { return tip_known && !syncing && blocks > 0 && blocks >= headers - 2; }
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
+8
-4
@@ -1319,6 +1319,11 @@ int main(int argc, char* argv[])
|
||||
|
||||
// Create application instance
|
||||
dragonx::App app;
|
||||
// A quit deferred for the "keep the node running?" prompt brings the window back so it can be seen.
|
||||
app.setRaiseWindowCallback([window]() {
|
||||
SDL_RestoreWindow(window);
|
||||
SDL_RaiseWindow(window);
|
||||
});
|
||||
if (!app.init()) {
|
||||
fprintf(stderr, "Failed to initialize application!\n");
|
||||
#ifdef _WIN32
|
||||
@@ -2147,10 +2152,9 @@ int main(int argc, char* argv[])
|
||||
|
||||
// Watchdog: if cleanup takes too long the process lingers without a
|
||||
// window, showing up as a "Background Service" in Task Manager.
|
||||
// Force-exit after timeout — all critical state (settings, daemon
|
||||
// stop) was handled in beginShutdown().
|
||||
// Allow enough time for the daemon to shut down gracefully (up to
|
||||
// 10s in stop()) plus RPC disconnect overhead.
|
||||
// Force-exit after timeout. The daemon stop is already over by the time the render loop exits: the
|
||||
// node has exited, or the user chose to leave it running (it carries on as its own process), so this
|
||||
// only bounds the remaining thread joins and RPC disconnects — it never cuts a node's shutdown short.
|
||||
std::thread([]() {
|
||||
std::this_thread::sleep_for(std::chrono::seconds(15));
|
||||
fflush(stdout);
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
#pragma once
|
||||
|
||||
// Keeping the GUI from flooding a busy node (S6). dragonxd's HTTP server has 8 workers and a 16-deep
|
||||
// queue. While a long operation holds cs_main (a witness rebuild can take an hour), every request that
|
||||
// needs cs_main waits, and a client that gives up does NOT free its slot. The GUI's polls used to time
|
||||
// out and be re-sent until all 24 slots were its own abandoned requests, after which even "stop" was
|
||||
// refused. The gate stops that: after the first sign the node is busy (a reply timeout, or a queue-full
|
||||
// refusal), the GUI's clients send nothing that needs cs_main until a single "canary" request (which
|
||||
// waits as long as it takes) gets through. Pure and header-only; shared by the GUI's RPC clients.
|
||||
|
||||
#include "rpc_failure.h"
|
||||
|
||||
#include <chrono>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
|
||||
namespace dragonx {
|
||||
namespace rpc {
|
||||
|
||||
// Kinds that mean "reached a node that is too busy to answer", as opposed to "no node there".
|
||||
inline bool rpcKindMeansBusy(RpcFailureKind kind)
|
||||
{
|
||||
return kind == RpcFailureKind::ReplyTimeout || kind == RpcFailureKind::QueueFull ||
|
||||
kind == RpcFailureKind::Deferred;
|
||||
}
|
||||
|
||||
// Kinds that mean the connection itself is in doubt (counted toward a reconnect). Busy kinds, a
|
||||
// warmup reply, an application error and our own abort are not: the node is there.
|
||||
inline bool rpcKindMeansLost(RpcFailureKind kind)
|
||||
{
|
||||
switch (kind) {
|
||||
case RpcFailureKind::None:
|
||||
case RpcFailureKind::ReplyTimeout:
|
||||
case RpcFailureKind::QueueFull:
|
||||
case RpcFailureKind::Deferred:
|
||||
case RpcFailureKind::Warmup:
|
||||
case RpcFailureKind::AppError:
|
||||
case RpcFailureKind::Aborted:
|
||||
return false;
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
class RpcBusyGate {
|
||||
public:
|
||||
// Methods dragonxd runs without cs_main: always sent, and their results say nothing about cs_main.
|
||||
static bool isLockFree(const std::string& method)
|
||||
{
|
||||
return method == "stop" || method == "help" || method == "getrescaninfo";
|
||||
}
|
||||
|
||||
// May this request be sent now? Lock-free methods always; the rest only while the node isn't busy.
|
||||
bool admit(const std::string& method) const
|
||||
{
|
||||
if (isLockFree(method)) return true;
|
||||
std::lock_guard<std::mutex> lk(mx_);
|
||||
return !busy_;
|
||||
}
|
||||
|
||||
// How a sent request went. Any real answer (even an error) means the node is serving cs_main
|
||||
// requests again; a reply timeout or a queue-full refusal means it isn't. Other failures (nothing
|
||||
// listening, auth, our own abort) say nothing about busyness and leave the state alone.
|
||||
void record(const std::string& method, RpcFailureKind kind)
|
||||
{
|
||||
if (isLockFree(method)) return;
|
||||
std::lock_guard<std::mutex> lk(mx_);
|
||||
if (kind == RpcFailureKind::None || kind == RpcFailureKind::AppError || kind == RpcFailureKind::Warmup) {
|
||||
clearLocked();
|
||||
} else if (kind == RpcFailureKind::ReplyTimeout || kind == RpcFailureKind::QueueFull) {
|
||||
if (!busy_) {
|
||||
busy_ = true;
|
||||
since_ = std::chrono::steady_clock::now();
|
||||
++busyPeriods_;
|
||||
}
|
||||
if (kind == RpcFailureKind::ReplyTimeout) ++abandoned_; // it still holds a daemon slot
|
||||
}
|
||||
}
|
||||
|
||||
// The canary got through (or the node is gone): poll normally again.
|
||||
void clear()
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(mx_);
|
||||
clearLocked();
|
||||
}
|
||||
|
||||
bool busy() const
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(mx_);
|
||||
return busy_;
|
||||
}
|
||||
// Requests we gave up on that the node may still be holding, since the gate last cleared.
|
||||
int abandoned() const
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(mx_);
|
||||
return abandoned_;
|
||||
}
|
||||
// Increments each time the gate turns busy: lets the owner arm exactly one canary per busy period.
|
||||
int busyPeriods() const
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(mx_);
|
||||
return busyPeriods_;
|
||||
}
|
||||
double busySeconds() const
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(mx_);
|
||||
if (!busy_) return 0.0;
|
||||
return std::chrono::duration<double>(std::chrono::steady_clock::now() - since_).count();
|
||||
}
|
||||
|
||||
private:
|
||||
void clearLocked()
|
||||
{
|
||||
busy_ = false;
|
||||
abandoned_ = 0;
|
||||
}
|
||||
|
||||
mutable std::mutex mx_;
|
||||
bool busy_ = false;
|
||||
int abandoned_ = 0;
|
||||
int busyPeriods_ = 0;
|
||||
std::chrono::steady_clock::time_point since_{};
|
||||
};
|
||||
|
||||
/**
|
||||
* How long to wait before the next connection attempt after a failure of this kind. Each repeat of the
|
||||
* same kind waits longer, up to its cap; a success or a different kind starts over.
|
||||
* nothing listening / connect timeout: 1, 2, 5s (the node is starting, or gone)
|
||||
* queue full: 2, 4, 8, 16, 30s (the queue is someone's; hammering it only adds to it)
|
||||
* busy (reply timeout / deferred): 30s; the canary brings the connection back as soon as it can
|
||||
* anything else: 4s
|
||||
*/
|
||||
class ConnectRetryBackoff {
|
||||
public:
|
||||
float next(RpcFailureKind kind)
|
||||
{
|
||||
streak_ = (kind == last_) ? streak_ + 1 : 0;
|
||||
last_ = kind;
|
||||
switch (kind) {
|
||||
case RpcFailureKind::NotListening:
|
||||
case RpcFailureKind::ConnectTimeout: {
|
||||
static const float steps[] = {1.0f, 2.0f, 5.0f};
|
||||
return steps[streak_ < 2 ? streak_ : 2];
|
||||
}
|
||||
case RpcFailureKind::QueueFull: {
|
||||
static const float steps[] = {2.0f, 4.0f, 8.0f, 16.0f, 30.0f};
|
||||
return steps[streak_ < 4 ? streak_ : 4];
|
||||
}
|
||||
case RpcFailureKind::ReplyTimeout:
|
||||
case RpcFailureKind::Deferred:
|
||||
return 30.0f;
|
||||
default:
|
||||
return 4.0f;
|
||||
}
|
||||
}
|
||||
void reset()
|
||||
{
|
||||
streak_ = 0;
|
||||
last_ = RpcFailureKind::None;
|
||||
}
|
||||
|
||||
private:
|
||||
RpcFailureKind last_ = RpcFailureKind::None;
|
||||
int streak_ = 0;
|
||||
};
|
||||
|
||||
} // namespace rpc
|
||||
} // namespace dragonx
|
||||
+109
-67
@@ -163,6 +163,17 @@ bool RPCClient::connect(const std::string& host, const std::string& port,
|
||||
bool useTls)
|
||||
{
|
||||
std::lock_guard<std::recursive_mutex> lk(curl_mutex_);
|
||||
if (!open(host, port, user, password, useTls)) return false;
|
||||
return probeConnection(host);
|
||||
}
|
||||
|
||||
bool RPCClient::open(const std::string& host, const std::string& port,
|
||||
const std::string& user, const std::string& password,
|
||||
bool useTls)
|
||||
{
|
||||
std::lock_guard<std::recursive_mutex> lk(curl_mutex_);
|
||||
connected_ = false; // only a successful probe (connect()) sets these
|
||||
warming_up_ = false;
|
||||
host_ = host;
|
||||
port_ = port;
|
||||
last_connect_info_ = json();
|
||||
@@ -218,13 +229,18 @@ bool RPCClient::connect(const std::string& host, const std::string& port,
|
||||
// build defaults. Harmless on the localhost http:// case; essential for a remote https daemon.
|
||||
curl_easy_setopt(impl_->curl, CURLOPT_SSL_VERIFYPEER, 1L);
|
||||
curl_easy_setopt(impl_->curl, CURLOPT_SSL_VERIFYHOST, 2L);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool RPCClient::probeConnection(const std::string& host)
|
||||
{
|
||||
// Test connection with getinfo. Use a SHORT timeout for the probe on localhost: a healthy
|
||||
// local daemon answers in milliseconds and a warming one returns -28 just as fast, so a long
|
||||
// hang means a wedged/loading occupant — no point blocking the full 30s before we retry and
|
||||
// update the UI. (call(timeoutSec) restores the persistent 30s afterwards, so normal RPC calls
|
||||
// that legitimately take longer are unaffected.) Remote/TLS daemons keep the full budget.
|
||||
const long probeTimeout = Connection::isLocalHost(host) ? 8L : 30L;
|
||||
last_connect_kind_ = RpcFailureKind::Other; // until the probe says otherwise
|
||||
try {
|
||||
json result = call("getinfo", json::array(), probeTimeout);
|
||||
if (result.contains("version")) {
|
||||
@@ -232,36 +248,32 @@ bool RPCClient::connect(const std::string& host, const std::string& port,
|
||||
warming_up_ = false;
|
||||
warmup_status_.clear();
|
||||
last_connect_error_.clear();
|
||||
last_connect_kind_ = RpcFailureKind::None;
|
||||
last_connect_info_ = result;
|
||||
if (connect_error_repeats_ > 0)
|
||||
DEBUG_LOGF("Connection failed: the last error repeated %u more time(s)\n", connect_error_repeats_);
|
||||
logged_connect_error_.clear();
|
||||
connect_error_repeats_ = 0;
|
||||
DEBUG_LOGF("Connected to dragonxd v%d\n", result["version"].get<int>());
|
||||
return true;
|
||||
}
|
||||
} catch (const std::exception& e) {
|
||||
last_connect_error_ = e.what();
|
||||
// Daemon warmup messages (Loading block index, Verifying blocks, etc.)
|
||||
// are normal startup progress — the daemon is reachable and auth works,
|
||||
// it just hasn't finished initializing yet. Mark as connected+warmup
|
||||
// so the wallet can show the UI instead of a blocking overlay.
|
||||
last_connect_kind_ = rpcFailureKindOf(e);
|
||||
// Daemon warmup (Loading block index, Verifying blocks, etc.) is normal startup progress — the
|
||||
// daemon is reachable and auth works, it just hasn't finished initializing yet. Mark as
|
||||
// connected+warmup so the wallet can show the UI instead of a blocking overlay. Only the
|
||||
// JSON-RPC code (-28, RPC_IN_WARMUP) counts: matching the message text would also match a
|
||||
// plain-text HTTP body that happens to say "Loading".
|
||||
std::string msg = e.what();
|
||||
// Warmup is JSON-RPC error code -28 (RPC_IN_WARMUP) — the robust signal. Fall back
|
||||
// to message substrings for any path that didn't carry the numeric code.
|
||||
int code = 0;
|
||||
if (const auto* re = dynamic_cast<const RpcError*>(&e)) code = re->code;
|
||||
bool isWarmup = (code == -28) ||
|
||||
(msg.find("Loading") != std::string::npos ||
|
||||
msg.find("Verifying") != std::string::npos ||
|
||||
msg.find("Activating") != std::string::npos ||
|
||||
msg.find("Rewinding") != std::string::npos ||
|
||||
msg.find("Rescanning") != std::string::npos ||
|
||||
msg.find("Pruning") != std::string::npos);
|
||||
if (isWarmup) {
|
||||
if (last_connect_kind_ == RpcFailureKind::Warmup) {
|
||||
connected_ = true;
|
||||
warming_up_ = true;
|
||||
warmup_status_ = msg;
|
||||
DEBUG_LOGF("Daemon warming up: %s\n", msg.c_str());
|
||||
return true;
|
||||
} else {
|
||||
DEBUG_LOGF("Connection failed: %s\n", msg.c_str());
|
||||
logConnectFailure(msg);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -272,6 +284,34 @@ bool RPCClient::connect(const std::string& host, const std::string& port,
|
||||
return false;
|
||||
}
|
||||
|
||||
// A busy or absent daemon makes every connect() fail the same way, and logging each one flooded the
|
||||
// log (60k lines in one incident). Log a new error at once; a repeat only every 100th time.
|
||||
void RPCClient::logConnectFailure(const std::string& msg)
|
||||
{
|
||||
const std::string line = std::string("[") + rpcFailureKindName(last_connect_kind_) + "] " + msg;
|
||||
if (line != logged_connect_error_) {
|
||||
if (connect_error_repeats_ > 0)
|
||||
DEBUG_LOGF("Connection failed: the last error repeated %u more time(s)\n", connect_error_repeats_);
|
||||
logged_connect_error_ = line;
|
||||
connect_error_repeats_ = 0;
|
||||
DEBUG_LOGF("Connection failed %s\n", line.c_str());
|
||||
return;
|
||||
}
|
||||
if (++connect_error_repeats_ % 100 == 0)
|
||||
DEBUG_LOGF("Connection failed %s (repeated %u times)\n", line.c_str(), connect_error_repeats_);
|
||||
}
|
||||
|
||||
RpcFailureKind RPCClient::trySendStop(long timeoutSec)
|
||||
{
|
||||
try {
|
||||
TraceScope trace("Daemon lifecycle / Stop command");
|
||||
call("stop", json::array(), timeoutSec);
|
||||
return RpcFailureKind::None;
|
||||
} catch (const std::exception& e) {
|
||||
return rpcFailureKindOf(e);
|
||||
}
|
||||
}
|
||||
|
||||
json RPCClient::getLastConnectInfo() const
|
||||
{
|
||||
std::lock_guard<std::recursive_mutex> lk(curl_mutex_);
|
||||
@@ -320,6 +360,8 @@ json RPCClient::makePayload(const std::string& method, const json& params)
|
||||
|
||||
std::string RPCClient::performCall(const std::string& method, const json& params, long& httpCodeOut)
|
||||
{
|
||||
if (busy_gate_ && !busy_gate_->admit(method))
|
||||
throw RpcTransportError(RpcFailureKind::Deferred, "RPC deferred: the node is busy", CURLE_OK, 0, false);
|
||||
emitRpcTrace(method);
|
||||
|
||||
json payload = makePayload(method, params);
|
||||
@@ -335,38 +377,60 @@ std::string RPCClient::performCall(const std::string& method, const json& params
|
||||
CURLcode res = curl_easy_perform(impl_->curl);
|
||||
|
||||
if (res != CURLE_OK) {
|
||||
throw std::runtime_error("RPC request failed: " + std::string(curl_easy_strerror(res)));
|
||||
// libcurl reports connect- and reply-phase timeouts with the same code. The request size
|
||||
// (reset for every transfer) tells them apart: 0 means it never reached the daemon.
|
||||
long requestSize = 0;
|
||||
curl_easy_getinfo(impl_->curl, CURLINFO_REQUEST_SIZE, &requestSize);
|
||||
const bool sent = requestSize > 0;
|
||||
const RpcFailureKind kind = classifyRpcFailure(res, sent, 0, 0, std::string());
|
||||
if (busy_gate_) busy_gate_->record(method, kind);
|
||||
throw RpcTransportError(kind, "RPC request failed: " + std::string(curl_easy_strerror(res)), res, 0, sent);
|
||||
}
|
||||
|
||||
// Check HTTP response code
|
||||
httpCodeOut = 0;
|
||||
curl_easy_getinfo(impl_->curl, CURLINFO_RESPONSE_CODE, &httpCodeOut);
|
||||
if (busy_gate_) {
|
||||
// Any HTTP answer means a daemon worker ran our request, except the queue-full refusal.
|
||||
const bool queueFull = httpCodeOut == 500 &&
|
||||
response_data.find("Work queue depth exceeded") != std::string::npos;
|
||||
busy_gate_->record(method, queueFull ? RpcFailureKind::QueueFull : RpcFailureKind::None);
|
||||
}
|
||||
|
||||
return response_data;
|
||||
}
|
||||
|
||||
void RPCClient::throwHttpFailure(long httpCode, const std::string& body, bool hideBody)
|
||||
{
|
||||
// Bitcoin/Hush RPC returns HTTP 500 for application-level errors (insufficient funds, bad
|
||||
// params, etc.), and 400/404 for a bad request / unknown method, with a JSON body carrying the
|
||||
// real message. Parse the body first to extract it.
|
||||
bool isJson = false;
|
||||
try {
|
||||
json response = json::parse(body);
|
||||
isJson = true;
|
||||
if (response.contains("error") && response["error"].is_object()) {
|
||||
int errCode = 0;
|
||||
if (response["error"].contains("code") && response["error"]["code"].is_number_integer())
|
||||
errCode = response["error"]["code"].get<int>();
|
||||
if (response["error"].contains("message") && response["error"]["message"].is_string())
|
||||
throw RpcError(errCode, response["error"]["message"].get<std::string>(), httpCode);
|
||||
// message missing/non-string — keep the detail instead of a bare HTTP code
|
||||
throw RpcError(errCode, "RPC error: " + response["error"].dump(), httpCode);
|
||||
}
|
||||
} catch (const json::exception&) {
|
||||
// Not JSON: the daemon's HTTP layer refused the request itself ("Work queue depth exceeded",
|
||||
// "Unhandled request") or sent a 401/503 page.
|
||||
}
|
||||
const std::string plain = isJson ? std::string() : body;
|
||||
throw RpcTransportError(classifyRpcFailure(CURLE_OK, true, httpCode, 0, plain),
|
||||
formatHttpError(httpCode, hideBody ? std::string() : plain),
|
||||
CURLE_OK, httpCode, true);
|
||||
}
|
||||
|
||||
json RPCClient::parseRpcResult(long httpCode, const std::string& body, bool scrubSource)
|
||||
{
|
||||
// Bitcoin/Hush RPC returns HTTP 500 for application-level errors
|
||||
// (insufficient funds, bad params, etc.) with a valid JSON body.
|
||||
// Parse the body first to extract the real error message.
|
||||
if (httpCode != 200) {
|
||||
int errCode = 0;
|
||||
try {
|
||||
json response = json::parse(body);
|
||||
if (response.contains("error") && response["error"].is_object()) {
|
||||
if (response["error"].contains("code") && response["error"]["code"].is_number_integer())
|
||||
errCode = response["error"]["code"].get<int>();
|
||||
if (response["error"].contains("message") && response["error"]["message"].is_string())
|
||||
throw RpcError(errCode, response["error"]["message"].get<std::string>());
|
||||
// message missing/non-string — keep the detail instead of a bare HTTP code
|
||||
throw RpcError(errCode, "RPC error: " + response["error"].dump());
|
||||
}
|
||||
} catch (const json::exception&) {
|
||||
// Body wasn't valid JSON — fall through to generic HTTP error
|
||||
}
|
||||
throw RpcError(errCode, "RPC error: HTTP " + std::to_string(httpCode));
|
||||
}
|
||||
if (httpCode != 200) throwHttpFailure(httpCode, body, /*hideBody=*/scrubSource);
|
||||
|
||||
json response = json::parse(body);
|
||||
|
||||
@@ -380,7 +444,7 @@ json RPCClient::parseRpcResult(long httpCode, const std::string& body, bool scru
|
||||
err_msg = response["error"]["message"].get<std::string>();
|
||||
}
|
||||
if (err_msg.empty()) err_msg = response["error"].dump();
|
||||
throw RpcError(errCode, "RPC error: " + err_msg);
|
||||
throw RpcError(errCode, "RPC error: " + err_msg, httpCode);
|
||||
}
|
||||
|
||||
json result = response["result"]; // a COPY of the subobject (operator[] yields an lvalue ref)
|
||||
@@ -481,34 +545,9 @@ std::string RPCClient::callRaw(const std::string& method, const json& params)
|
||||
throw std::runtime_error("Not connected");
|
||||
}
|
||||
|
||||
emitRpcTrace(method);
|
||||
|
||||
json payload = makePayload(method, params);
|
||||
std::string body = payload.dump();
|
||||
std::string response_data;
|
||||
|
||||
curl_easy_setopt(impl_->curl, CURLOPT_POSTFIELDS, body.c_str());
|
||||
curl_easy_setopt(impl_->curl, CURLOPT_POSTFIELDSIZE, (long)body.size());
|
||||
curl_easy_setopt(impl_->curl, CURLOPT_WRITEDATA, &response_data);
|
||||
|
||||
CURLcode res = curl_easy_perform(impl_->curl);
|
||||
if (res != CURLE_OK) {
|
||||
throw std::runtime_error("RPC request failed: " + std::string(curl_easy_strerror(res)));
|
||||
}
|
||||
|
||||
long http_code = 0;
|
||||
curl_easy_getinfo(impl_->curl, CURLINFO_RESPONSE_CODE, &http_code);
|
||||
|
||||
if (http_code != 200) {
|
||||
try {
|
||||
json response = json::parse(response_data);
|
||||
if (response.contains("error") && !response["error"].is_null()) {
|
||||
std::string err_msg = response["error"]["message"].get<std::string>();
|
||||
throw std::runtime_error(err_msg);
|
||||
}
|
||||
} catch (const json::exception&) {}
|
||||
throw std::runtime_error("RPC error: HTTP " + std::to_string(http_code));
|
||||
}
|
||||
std::string response_data = performCall(method, params, http_code);
|
||||
if (http_code != 200) throwHttpFailure(http_code, response_data, /*hideBody=*/false);
|
||||
|
||||
// Parse with ordered_json to preserve the daemon's original key order
|
||||
nlohmann::ordered_json oj = nlohmann::ordered_json::parse(response_data);
|
||||
@@ -517,11 +556,14 @@ std::string RPCClient::callRaw(const std::string& method, const json& params)
|
||||
// error (missing/non-string message) must yield a clean RPC error, not a json type-exception.
|
||||
const auto& err = oj["error"];
|
||||
std::string err_msg;
|
||||
int errCode = 0;
|
||||
if (err.is_object() && err.contains("code") && err["code"].is_number_integer())
|
||||
errCode = err["code"].get<int>();
|
||||
if (err.is_object() && err.contains("message") && err["message"].is_string())
|
||||
err_msg = err["message"].get<std::string>();
|
||||
else
|
||||
err_msg = err.dump();
|
||||
throw std::runtime_error("RPC error: " + err_msg);
|
||||
throw RpcError(errCode, "RPC error: " + err_msg, http_code);
|
||||
}
|
||||
|
||||
auto& result = oj["result"];
|
||||
|
||||
+38
-15
@@ -5,6 +5,8 @@
|
||||
#pragma once
|
||||
|
||||
#include "types.h"
|
||||
#include "rpc_failure.h" // RpcError, RpcTransportError, RpcFailureKind
|
||||
#include "rpc_busy_gate.h"
|
||||
#include <atomic>
|
||||
#include <string>
|
||||
#include <functional>
|
||||
@@ -20,21 +22,6 @@ using json = nlohmann::json;
|
||||
using Callback = std::function<void(const json&)>;
|
||||
using ErrorCallback = std::function<void(const std::string&)>;
|
||||
|
||||
/**
|
||||
* @brief A JSON-RPC error carrying the daemon's numeric error code.
|
||||
*
|
||||
* what() preserves the exact human-readable message (so existing string matching
|
||||
* still works); `code` exposes the JSON-RPC error code — notably -28 (RPC_IN_WARMUP)
|
||||
* for a daemon still starting up. Derives from std::runtime_error, so every existing
|
||||
* `catch (const std::exception&)` continues to handle it unchanged.
|
||||
*/
|
||||
class RpcError : public std::runtime_error {
|
||||
public:
|
||||
RpcError(int errorCode, const std::string& message)
|
||||
: std::runtime_error(message), code(errorCode) {}
|
||||
int code = 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief JSON-RPC client for dragonxd
|
||||
*
|
||||
@@ -78,6 +65,31 @@ public:
|
||||
const std::string& user, const std::string& password,
|
||||
bool useTls);
|
||||
|
||||
/**
|
||||
* @brief Set up the connection without probing it (connect() = open() + a getinfo probe)
|
||||
*
|
||||
* For sending "stop": the getinfo probe needs the daemon's cs_main, so against a busy node it
|
||||
* times out (holding one of the daemon's few RPC slots) or is refused, and stop never went out.
|
||||
* isConnected() stays false; call() works once this returns true.
|
||||
*/
|
||||
bool open(const std::string& host, const std::string& port,
|
||||
const std::string& user, const std::string& password, bool useTls);
|
||||
|
||||
/**
|
||||
* @brief Send "stop" once, with its own timeout; returns how it went (None = the daemon ran it)
|
||||
*
|
||||
* A ReplyTimeout here is NOT a failure: the request reached the daemon and waits for a worker.
|
||||
*/
|
||||
RpcFailureKind trySendStop(long timeoutSec = 2);
|
||||
|
||||
/**
|
||||
* @brief Share a busy gate with the app's other polling clients (nullptr: none)
|
||||
*
|
||||
* While the gate is busy, calls that need the daemon's cs_main fail at once with a Deferred
|
||||
* RpcTransportError instead of being sent; every sent call reports how it went to the gate.
|
||||
*/
|
||||
void setBusyGate(std::shared_ptr<RpcBusyGate> gate) { busy_gate_ = std::move(gate); }
|
||||
|
||||
/**
|
||||
* @brief Disconnect from dragonxd
|
||||
*/
|
||||
@@ -117,6 +129,8 @@ public:
|
||||
* @brief Get the error message from the last failed connect() attempt.
|
||||
*/
|
||||
const std::string& getLastConnectError() const { return last_connect_error_; }
|
||||
/** @brief How the last connect() probe went: None on success, Warmup for a warming-up node, else the failure. */
|
||||
RpcFailureKind getLastConnectFailureKind() const { return last_connect_kind_; }
|
||||
json getLastConnectInfo() const;
|
||||
|
||||
static void setTraceCallback(TraceCallback callback);
|
||||
@@ -274,6 +288,11 @@ private:
|
||||
// parse tree's string values are zeroed before it is discarded — parseRpcResult returns a COPY of
|
||||
// the result node, so its own tree would otherwise free the secret un-wiped (B7).
|
||||
static json parseRpcResult(long httpCode, const std::string& body, bool scrubSource = false);
|
||||
// A non-200 reply: RpcError when the body carries a JSON-RPC error, else RpcTransportError classified
|
||||
// from the status and plain-text body. hideBody keeps the body out of the message (secret calls).
|
||||
[[noreturn]] static void throwHttpFailure(long httpCode, const std::string& body, bool hideBody);
|
||||
bool probeConnection(const std::string& host); // connect()'s getinfo probe, after open()
|
||||
void logConnectFailure(const std::string& msg);
|
||||
|
||||
// Splits a UnifiedCallback into the (Callback, ErrorCallback) pair used by doRPC.
|
||||
static std::pair<Callback, ErrorCallback> splitUnified(UnifiedCallback cb);
|
||||
@@ -286,6 +305,10 @@ private:
|
||||
bool warming_up_ = false;
|
||||
std::string warmup_status_;
|
||||
std::string last_connect_error_;
|
||||
RpcFailureKind last_connect_kind_ = RpcFailureKind::None;
|
||||
std::shared_ptr<RpcBusyGate> busy_gate_;
|
||||
std::string logged_connect_error_; // last "Connection failed" text logged (rate limit)
|
||||
unsigned connect_error_repeats_ = 0; // identical failures since it was logged
|
||||
json last_connect_info_;
|
||||
mutable std::recursive_mutex curl_mutex_; // serializes all curl handle access
|
||||
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
// DragonX Wallet - ImGui Edition
|
||||
// Copyright 2024-2026 The Hush Developers
|
||||
// Released under the GPLv3
|
||||
|
||||
#pragma once
|
||||
|
||||
// Classifying a failed RPC call from structured data (curl code, request-sent bit, HTTP status,
|
||||
// JSON-RPC error code, non-JSON body) instead of substrings of what(). Header-only and pure, so the
|
||||
// test target can use it without compiling rpc_client.cpp.
|
||||
|
||||
#include <curl/curl.h>
|
||||
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
|
||||
namespace dragonx {
|
||||
namespace rpc {
|
||||
|
||||
enum class RpcFailureKind {
|
||||
None, // the call succeeded
|
||||
NotListening, // connection refused: nothing bound to the RPC port (not started, or already unbound)
|
||||
ConnectTimeout, // TCP connect didn't complete in time (Windows after the daemon unbinds; filtered port)
|
||||
ReplyTimeout, // request sent, no reply in time: the daemon holds it (busy, e.g. blocked on cs_main)
|
||||
QueueFull, // HTTP 500 "Work queue depth exceeded": rejected before any handler ran; holds no slot
|
||||
ShuttingDown, // HTTP 503: the daemon's HTTP server was interrupted for shutdown
|
||||
Unhandled, // HTTP 500 "Unhandled request": queued, then dropped by a shutting-down daemon
|
||||
Auth, // HTTP 401: wrong rpcuser/rpcpassword (or a stale .cookie)
|
||||
Warmup, // JSON-RPC -28 (RPC_IN_WARMUP): the node is still loading
|
||||
AppError, // the daemon ran the method and answered with a JSON-RPC error
|
||||
Aborted, // our own requestAbort() cut the transfer
|
||||
Deferred, // not sent: the node is busy (RpcBusyGate), so we held back rather than queue more
|
||||
Other,
|
||||
};
|
||||
|
||||
inline const char* rpcFailureKindName(RpcFailureKind kind)
|
||||
{
|
||||
switch (kind) {
|
||||
case RpcFailureKind::None: return "None";
|
||||
case RpcFailureKind::NotListening: return "NotListening";
|
||||
case RpcFailureKind::ConnectTimeout: return "ConnectTimeout";
|
||||
case RpcFailureKind::ReplyTimeout: return "ReplyTimeout";
|
||||
case RpcFailureKind::QueueFull: return "QueueFull";
|
||||
case RpcFailureKind::ShuttingDown: return "ShuttingDown";
|
||||
case RpcFailureKind::Unhandled: return "Unhandled";
|
||||
case RpcFailureKind::Auth: return "Auth";
|
||||
case RpcFailureKind::Warmup: return "Warmup";
|
||||
case RpcFailureKind::AppError: return "AppError";
|
||||
case RpcFailureKind::Aborted: return "Aborted";
|
||||
case RpcFailureKind::Deferred: return "Deferred";
|
||||
case RpcFailureKind::Other: return "Other";
|
||||
}
|
||||
return "Other";
|
||||
}
|
||||
|
||||
// True for the two curl timeouts: what the old `what().find("Timeout")` checks meant.
|
||||
inline bool isRpcTimeout(RpcFailureKind kind)
|
||||
{
|
||||
return kind == RpcFailureKind::ConnectTimeout || kind == RpcFailureKind::ReplyTimeout;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param curlCode CURLcode of the transfer (CURLE_OK when a response arrived)
|
||||
* @param requestSent the request reached the daemon (CURLINFO_REQUEST_SIZE > 0). libcurl reports
|
||||
* connect- and reply-phase timeouts with the same code; this tells them apart.
|
||||
* @param httpStatus HTTP status, 0 when there was no response
|
||||
* @param rpcCode JSON-RPC error.code from a JSON error body, 0 when there was none
|
||||
* @param plainBody the response body when it was NOT JSON (dragonxd's HTTP-layer replies), else ""
|
||||
*/
|
||||
inline RpcFailureKind classifyRpcFailure(int curlCode, bool requestSent, long httpStatus, int rpcCode,
|
||||
const std::string& plainBody)
|
||||
{
|
||||
if (curlCode != CURLE_OK) {
|
||||
switch (curlCode) {
|
||||
case CURLE_ABORTED_BY_CALLBACK: return RpcFailureKind::Aborted;
|
||||
case CURLE_COULDNT_CONNECT: return RpcFailureKind::NotListening;
|
||||
case CURLE_OPERATION_TIMEDOUT:
|
||||
return requestSent ? RpcFailureKind::ReplyTimeout : RpcFailureKind::ConnectTimeout;
|
||||
default: return RpcFailureKind::Other;
|
||||
}
|
||||
}
|
||||
if (rpcCode == -28) return RpcFailureKind::Warmup; // takes precedence over the HTTP status
|
||||
if (rpcCode != 0) return RpcFailureKind::AppError; // 500, or 400/404 (invalid request / no method)
|
||||
if (httpStatus == 200) return RpcFailureKind::None;
|
||||
if (httpStatus == 401) return RpcFailureKind::Auth;
|
||||
if (httpStatus == 503) return RpcFailureKind::ShuttingDown;
|
||||
if (httpStatus == 500) {
|
||||
if (plainBody.find("Work queue depth exceeded") != std::string::npos) return RpcFailureKind::QueueFull;
|
||||
if (plainBody.find("Unhandled request") != std::string::npos) return RpcFailureKind::Unhandled;
|
||||
}
|
||||
return RpcFailureKind::Other;
|
||||
}
|
||||
|
||||
/**
|
||||
* "RPC error: HTTP <code>", plus the body when the daemon sent a plain-text one (e.g. ": Work queue
|
||||
* depth exceeded"), trimmed and limited to 120 printable ASCII characters so an HTML page or binary
|
||||
* junk can't flood a log line. Exactly "RPC error: HTTP <code>" when nothing printable remains.
|
||||
*/
|
||||
inline std::string formatHttpError(long httpStatus, const std::string& plainBody)
|
||||
{
|
||||
std::string text;
|
||||
bool pendingSpace = false;
|
||||
for (unsigned char c : plainBody) {
|
||||
if (text.size() >= 120) break;
|
||||
if (c == ' ' || c == '\t' || c == '\r' || c == '\n') {
|
||||
pendingSpace = !text.empty();
|
||||
} else if (c >= 0x21 && c <= 0x7e) {
|
||||
if (pendingSpace && text.size() < 119) text.push_back(' ');
|
||||
pendingSpace = false;
|
||||
text.push_back(static_cast<char>(c));
|
||||
}
|
||||
}
|
||||
std::string out = "RPC error: HTTP " + std::to_string(httpStatus);
|
||||
if (!text.empty()) out += ": " + text;
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* The daemon ran the method and answered with a JSON-RPC error. what() is the daemon's message;
|
||||
* `code` is its JSON-RPC error code, notably -28 (RPC_IN_WARMUP). Callers catch this type to mean
|
||||
* "the daemon said no" (e.g. no mnemonic -> fall back), as opposed to a transport failure.
|
||||
*/
|
||||
class RpcError : public std::runtime_error {
|
||||
public:
|
||||
RpcError(int errorCode, const std::string& message, long status = 0)
|
||||
: std::runtime_error(message), code(errorCode), httpStatus(status) {}
|
||||
RpcFailureKind kind() const { return code == -28 ? RpcFailureKind::Warmup : RpcFailureKind::AppError; }
|
||||
int code = 0;
|
||||
long httpStatus = 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* The call produced no JSON-RPC answer: curl failed (refused, timed out, aborted), or the daemon's
|
||||
* HTTP layer turned the request away (401, 503, queue full, unhandled). what() keeps the historic
|
||||
* text ("RPC request failed: <curl error>" / "RPC error: HTTP <code>[: body]").
|
||||
*/
|
||||
class RpcTransportError : public std::runtime_error {
|
||||
public:
|
||||
RpcTransportError(RpcFailureKind failureKind, const std::string& message, int curl = CURLE_OK,
|
||||
long status = 0, bool sent = false)
|
||||
: std::runtime_error(message), kind(failureKind), curlCode(curl), httpStatus(status), requestSent(sent) {}
|
||||
RpcFailureKind kind = RpcFailureKind::Other;
|
||||
int curlCode = CURLE_OK;
|
||||
long httpStatus = 0;
|
||||
bool requestSent = false;
|
||||
};
|
||||
|
||||
// The kind of any exception an RPC call threw (Other for anything that isn't an RPC error).
|
||||
inline RpcFailureKind rpcFailureKindOf(const std::exception& e)
|
||||
{
|
||||
if (const auto* t = dynamic_cast<const RpcTransportError*>(&e)) return t->kind;
|
||||
if (const auto* r = dynamic_cast<const RpcError*>(&e)) return r->kind();
|
||||
return RpcFailureKind::Other;
|
||||
}
|
||||
|
||||
} // namespace rpc
|
||||
} // namespace dragonx
|
||||
@@ -1,4 +1,5 @@
|
||||
#include "network_refresh_service.h"
|
||||
#include "rpc/rpc_busy_gate.h"
|
||||
#include "../util/logger.h"
|
||||
|
||||
#include <algorithm>
|
||||
@@ -296,6 +297,8 @@ NetworkRefreshService::CoreRefreshResult NetworkRefreshService::collectCoreRefre
|
||||
bool balanceOk = false;
|
||||
bool blockOk = false;
|
||||
double balanceScanMs = 0.0;
|
||||
rpc::RpcFailureKind blockErrKind = rpc::RpcFailureKind::None;
|
||||
rpc::RpcFailureKind balanceErrKind = rpc::RpcFailureKind::None;
|
||||
|
||||
// getblockchaininfo FIRST — it's cheap, and the sync state it returns gates everything else
|
||||
// (the balance/address/tx throttles and kSyncProfile). Running it BEFORE the O(mapWallet) balance
|
||||
@@ -306,6 +309,7 @@ NetworkRefreshService::CoreRefreshResult NetworkRefreshService::collectCoreRefre
|
||||
blockInfo = rpc.call("getblockchaininfo", json::array());
|
||||
blockOk = true;
|
||||
} catch (const std::exception& e) {
|
||||
blockErrKind = rpc::rpcFailureKindOf(e);
|
||||
DEBUG_LOGF("BlockchainInfo error: %s\n", e.what());
|
||||
}
|
||||
|
||||
@@ -321,7 +325,9 @@ NetworkRefreshService::CoreRefreshResult NetworkRefreshService::collectCoreRefre
|
||||
if (lc > 0 && b < lc - 2) behind = true;
|
||||
}
|
||||
|
||||
if (includeBalance && !behind) {
|
||||
// Don't follow a chain-info call that found the node busy (a reply timeout or a full queue) with two
|
||||
// balance scans: they need the same cs_main, and each would only take another of its few RPC slots.
|
||||
if (includeBalance && !behind && !rpc::rpcKindMeansBusy(blockErrKind)) {
|
||||
// z_gettotalbalance is O(mapWallet) and holds the daemon's cs_main for its whole duration —
|
||||
// seconds on a large shielded wallet. Time it so the caller can throttle how often it polls
|
||||
// (balanceRefreshDue()), keeping balance scans from starving block connection.
|
||||
@@ -330,6 +336,7 @@ NetworkRefreshService::CoreRefreshResult NetworkRefreshService::collectCoreRefre
|
||||
totalBalance = rpc.call("z_gettotalbalance", json::array({0}));
|
||||
balanceOk = true;
|
||||
} catch (const std::exception& e) {
|
||||
balanceErrKind = rpc::rpcFailureKindOf(e);
|
||||
DEBUG_LOGF("Balance error: %s\n", e.what());
|
||||
}
|
||||
try { // SPENDABLE total: minconf=1 (confirmed). If absent, spendable degrades to display in apply.
|
||||
@@ -341,6 +348,8 @@ NetworkRefreshService::CoreRefreshResult NetworkRefreshService::collectCoreRefre
|
||||
|
||||
auto result = parseCoreRefreshResult(totalBalance, spendableBalance, balanceOk, blockInfo, blockOk);
|
||||
result.balanceScanMs = balanceScanMs;
|
||||
result.blockchainErrKind = blockErrKind;
|
||||
result.balanceErrKind = balanceErrKind;
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -853,8 +862,11 @@ void NetworkRefreshService::appendViewTransactionOutputs(std::vector<Transaction
|
||||
for (const auto& out : entry.outgoing_outputs) {
|
||||
bool alreadyTracked = false;
|
||||
for (const auto& existing : transactions) {
|
||||
// Key on address as well as amount: two outgoing outputs of EQUAL value to DIFFERENT
|
||||
// recipients in one tx are distinct rows — an amount-only match would drop the second.
|
||||
if (existing.txid == txid && existing.type == "send" &&
|
||||
std::abs(existing.amount + out.value) < 0.00000001) {
|
||||
std::abs(existing.amount + out.value) < 0.00000001 &&
|
||||
existing.address == out.address) {
|
||||
alreadyTracked = true;
|
||||
break;
|
||||
}
|
||||
@@ -885,6 +897,55 @@ void NetworkRefreshService::appendViewTransactionOutputs(std::vector<Transaction
|
||||
}
|
||||
}
|
||||
|
||||
void NetworkRefreshService::appendSentTransparentOutputs(std::vector<TransactionInfo>& transactions,
|
||||
const std::string& txid,
|
||||
const nlohmann::json& sentEntry)
|
||||
{
|
||||
if (!sentEntry.is_object() || !sentEntry.contains("sends") || !sentEntry["sends"].is_object())
|
||||
return;
|
||||
const auto& sends = sentEntry["sends"];
|
||||
auto it = sends.find("transparentSends");
|
||||
if (it == sends.end() || !it->is_array()) return;
|
||||
|
||||
// Per-tx metadata for the synthesized rows.
|
||||
int confirmations = 0;
|
||||
std::int64_t timestamp = 0;
|
||||
if (auto v = readOptional<int>(sentEntry, "confirmations")) confirmations = *v;
|
||||
if (auto v = readOptional<std::int64_t>(sentEntry, "blocktime")) timestamp = *v;
|
||||
if (timestamp == 0) {
|
||||
if (auto v = readOptional<std::int64_t>(sentEntry, "time")) timestamp = *v;
|
||||
}
|
||||
|
||||
for (const auto& out : *it) {
|
||||
auto amount = readOptional<double>(out, "amount");
|
||||
if (!amount) continue;
|
||||
const double debit = -std::abs(*amount); // the daemon reports -nValue; a send is a debit
|
||||
std::string address;
|
||||
if (auto a = readOptional<std::string>(out, "address")) address = *a;
|
||||
|
||||
// Dedup against rows already present — notably listtransactions-derived t->t sends, which
|
||||
// this whole-wallet payload ALSO reports. Same txid+address+amount == the same output.
|
||||
bool exists = false;
|
||||
for (const auto& e : transactions) {
|
||||
if (e.txid == txid && e.type == "send" && e.address == address &&
|
||||
std::abs(e.amount - debit) < 0.00000001) {
|
||||
exists = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (exists) continue;
|
||||
|
||||
TransactionInfo info;
|
||||
info.txid = txid;
|
||||
info.type = "send";
|
||||
info.address = address;
|
||||
info.amount = debit;
|
||||
info.confirmations = confirmations;
|
||||
info.timestamp = timestamp;
|
||||
transactions.push_back(std::move(info));
|
||||
}
|
||||
}
|
||||
|
||||
void NetworkRefreshService::sortTransactionsNewestFirst(std::vector<TransactionInfo>& transactions)
|
||||
{
|
||||
std::sort(transactions.begin(), transactions.end(),
|
||||
@@ -985,6 +1046,45 @@ NetworkRefreshService::TransactionRefreshResult NetworkRefreshService::collectTr
|
||||
result.shieldedScanComplete = !pendingShieldedIndex.has_value();
|
||||
result.nextShieldedScanStartIndex = pendingShieldedIndex.value_or(0);
|
||||
|
||||
// Discover outgoing shielded (z->z) sends. A pure z->z send moves no value in or out of the
|
||||
// transparent pool (valueBalance == 0), so the daemon's `listtransactions` never reports it,
|
||||
// and — being a send rather than a receive — it never shows up in `z_listreceivedbyaddress`
|
||||
// either. Its txid therefore never reaches `knownTxids`, so the z_viewtransaction enrichment
|
||||
// below is never run for it and the send is invisible in History (the reported bug).
|
||||
//
|
||||
// `z_listsentbyaddress` scans the whole wallet for sends — the address argument is NOT used to
|
||||
// filter (the daemon iterates every wallet tx against "*"), so a single call yields all send
|
||||
// txids. Two legs need handling:
|
||||
// * SHIELDED (z->z) outgoing legs are surfaced by z_viewtransaction, so we fold the txid into
|
||||
// `knownTxids` and let the z_viewtransaction / appendViewTransactionOutputs path build them.
|
||||
// * TRANSPARENT outgoing legs (z->t deshields) are NOT surfaced by z_viewtransaction (it
|
||||
// enumerates only Sapling outputs), and for a z-originated send listtransactions also omits
|
||||
// the external t-vout (GetAmounts' isFromMyTaddr is false when inputs are shielded). So we
|
||||
// build those rows directly from the payload's `sends.transparentSends[]` here. They are
|
||||
// deduped (by txid+address+amount) against listtransactions-derived t->t sends above, so a
|
||||
// purely-transparent (t->t) send — which the payload ALSO reports — is not double-counted.
|
||||
//
|
||||
// Gated on shieldedScanComplete so it adds no cost during the multi-cycle initial received
|
||||
// scan: it is one more O(mapWallet) pass holding cs_main (comparable to the listtransactions
|
||||
// pass), and the caller's adaptive throttle (txRefreshDue) already accounts for it via scanMs.
|
||||
if (result.shieldedScanComplete && !snapshot.shieldedAddresses.empty()) {
|
||||
try {
|
||||
json sent = rpc.call("z_listsentbyaddress",
|
||||
json::array({snapshot.shieldedAddresses.front(), 0}));
|
||||
if (sent.is_array()) {
|
||||
for (const auto& sentTx : sent) {
|
||||
auto txid = readOptional<std::string>(sentTx, "txid");
|
||||
if (!txid || txid->empty()) continue;
|
||||
appendSentTransparentOutputs(result.transactions, *txid, sentTx);
|
||||
knownTxids.insert(*txid); // shielded (z->z) legs enriched via z_viewtransaction
|
||||
}
|
||||
}
|
||||
} catch (const std::exception& e) {
|
||||
transactionRpcError = true;
|
||||
DEBUG_LOGF("z_listsentbyaddress error: %s\n", e.what());
|
||||
}
|
||||
}
|
||||
|
||||
for (const auto& txid : snapshot.sendTxids) {
|
||||
knownTxids.insert(txid);
|
||||
}
|
||||
@@ -1305,6 +1405,10 @@ void NetworkRefreshService::applyCoreRefreshResult(WalletState& state,
|
||||
if (result.headers) state.sync.headers = *result.headers;
|
||||
if (result.bestBlockHash) state.sync.best_blockhash = *result.bestBlockHash;
|
||||
if (result.verificationProgress) state.sync.verification_progress = *result.verificationProgress;
|
||||
// A peerless daemon reports longestchain == 0 (no peer heights). Track that as "tip unknown" from
|
||||
// THIS refresh's raw value (not the retained state, which keeps the last positive value) so a
|
||||
// peerless node isn't reported synced against its own stale local height. (audit: peerless-synced)
|
||||
state.sync.tip_known = (result.longestChain.has_value() && *result.longestChain > 0);
|
||||
if (result.longestChain && *result.longestChain > 0) state.longestchain = *result.longestChain;
|
||||
if (state.longestchain > 0 && state.sync.blocks > state.longestchain) state.longestchain = state.sync.blocks;
|
||||
if (state.longestchain > 0)
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
#include "data/wallet_state.h"
|
||||
#include "refresh_scheduler.h"
|
||||
#include "rpc/rpc_worker.h"
|
||||
#include "rpc/rpc_failure.h"
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
@@ -112,6 +113,10 @@ public:
|
||||
std::optional<int> longestChain;
|
||||
std::optional<int> notarized;
|
||||
double balanceScanMs = 0.0; // wall-clock spent in z_gettotalbalance this refresh (0 if balance skipped)
|
||||
// How those calls failed (None when they succeeded or weren't made), so a caller can tell a busy
|
||||
// node (ReplyTimeout / QueueFull) from a gone one (NotListening) without reading what().
|
||||
rpc::RpcFailureKind blockchainErrKind = rpc::RpcFailureKind::None;
|
||||
rpc::RpcFailureKind balanceErrKind = rpc::RpcFailureKind::None;
|
||||
};
|
||||
|
||||
struct MiningRefreshResult {
|
||||
@@ -310,6 +315,12 @@ public:
|
||||
static void appendViewTransactionOutputs(std::vector<TransactionInfo>& transactions,
|
||||
const std::string& txid,
|
||||
const TransactionViewCacheEntry& entry);
|
||||
// Build "send" rows from a z_listsentbyaddress entry's sends.transparentSends[] — the transparent
|
||||
// (z->t deshield) outgoing outputs that z_viewtransaction does not surface. Deduped by
|
||||
// txid+address+amount against rows already present (e.g. listtransactions-derived t->t sends).
|
||||
static void appendSentTransparentOutputs(std::vector<TransactionInfo>& transactions,
|
||||
const std::string& txid,
|
||||
const nlohmann::json& sentEntry);
|
||||
static void sortTransactionsNewestFirst(std::vector<TransactionInfo>& transactions);
|
||||
static TransactionRefreshResult collectTransactionRefreshResult(RefreshRpcGateway& rpc,
|
||||
const TransactionRefreshSnapshot& snapshot,
|
||||
@@ -375,6 +386,7 @@ public:
|
||||
void reset(Timer timer) { scheduler_.reset(timer); }
|
||||
void markDue(Timer timer) { scheduler_.markDue(timer); }
|
||||
void setTimer(Timer timer, float seconds) { scheduler_.setTimer(timer, seconds); }
|
||||
void deferFor(Timer timer, float seconds) { scheduler_.deferFor(timer, seconds); }
|
||||
float timer(Timer timer) const { return scheduler_.timer(timer); }
|
||||
float interval(Timer timer) const { return scheduler_.interval(timer); }
|
||||
void markImmediateRefresh() { scheduler_.markImmediateRefresh(); }
|
||||
|
||||
@@ -47,10 +47,16 @@ void RefreshScheduler::tick(float deltaSeconds)
|
||||
timers_.fast += delta;
|
||||
timers_.txAge += delta;
|
||||
timers_.opid += delta;
|
||||
for (float& h : holds_) {
|
||||
if (h > 0.0f) h = std::max(0.0f, h - delta);
|
||||
}
|
||||
}
|
||||
|
||||
bool RefreshScheduler::isDue(Timer timer) const
|
||||
{
|
||||
const float h = hold(timer);
|
||||
if (h > 0.0f) return false; // held by deferFor()
|
||||
if (h == 0.0f) return true; // the hold ran out: due now, whatever the interval
|
||||
float timerInterval = interval(timer);
|
||||
return timerInterval > 0.0f && timerRef(timer) >= timerInterval;
|
||||
}
|
||||
@@ -65,19 +71,27 @@ bool RefreshScheduler::consumeDue(Timer timer)
|
||||
void RefreshScheduler::reset(Timer timer)
|
||||
{
|
||||
timerRef(timer) = 0.0f;
|
||||
holdRef(timer) = -1.0f;
|
||||
}
|
||||
|
||||
void RefreshScheduler::markDue(Timer timer)
|
||||
{
|
||||
holdRef(timer) = -1.0f; // an explicit "now" (user action) overrides a backoff
|
||||
float timerInterval = interval(timer);
|
||||
timerRef(timer) = timerInterval > 0.0f ? timerInterval : 0.0f;
|
||||
}
|
||||
|
||||
void RefreshScheduler::setTimer(Timer timer, float seconds)
|
||||
{
|
||||
holdRef(timer) = -1.0f;
|
||||
timerRef(timer) = std::max(0.0f, seconds);
|
||||
}
|
||||
|
||||
void RefreshScheduler::deferFor(Timer timer, float seconds)
|
||||
{
|
||||
holdRef(timer) = std::max(0.0f, seconds);
|
||||
}
|
||||
|
||||
float RefreshScheduler::timer(Timer timer) const
|
||||
{
|
||||
return timerRef(timer);
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
#include "ui/sidebar.h"
|
||||
|
||||
#include <array>
|
||||
|
||||
namespace dragonx {
|
||||
namespace services {
|
||||
|
||||
@@ -55,6 +57,11 @@ public:
|
||||
void reset(Timer timer);
|
||||
void markDue(Timer timer);
|
||||
void setTimer(Timer timer, float seconds);
|
||||
// Hold a timer for exactly `seconds`, whatever the page's interval (which only measures elapsed
|
||||
// time): not due until then, due as soon as it runs out. consumeDue/reset/markDue/setTimer end it.
|
||||
// For retry backoff: the old "setTimer(interval - delay)" collapsed to an immediate retry wherever
|
||||
// the page interval was shorter than the delay (every frame on Overview/Send).
|
||||
void deferFor(Timer timer, float seconds);
|
||||
float timer(Timer timer) const;
|
||||
float interval(Timer timer) const;
|
||||
|
||||
@@ -79,9 +86,14 @@ private:
|
||||
|
||||
float& timerRef(Timer timer);
|
||||
const float& timerRef(Timer timer) const;
|
||||
static constexpr int kTimerCount = 8;
|
||||
float& holdRef(Timer timer) { return holds_[static_cast<int>(timer)]; }
|
||||
float hold(Timer timer) const { return holds_[static_cast<int>(timer)]; }
|
||||
|
||||
Intervals intervals_{kCoreDefault, kTransactionDefault, kAddressDefault, kPeerDefault};
|
||||
Timers timers_;
|
||||
// deferFor() holds: > 0 counting down (not due), 0 ran out (due), < 0 none.
|
||||
std::array<float, kTimerCount> holds_{{-1.0f, -1.0f, -1.0f, -1.0f, -1.0f, -1.0f, -1.0f, -1.0f}};
|
||||
};
|
||||
|
||||
} // namespace services
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -31,6 +31,8 @@ enum class NodeBannerReason {
|
||||
FullNodeOffline, // a reachable node was lost, or never came up; reconnect offered
|
||||
DaemonCrashed, // the embedded daemon crashed repeatedly and auto-restart stopped
|
||||
LiteOpenFailed, // lite build: the wallet failed to open
|
||||
NoPeers, // connected to the daemon but no peer-derived tip (0 peers) — stalled, stale
|
||||
NodeBusy, // the node is up but busy (rebuilding witnesses / catching up / not answering)
|
||||
};
|
||||
|
||||
struct NodeBannerState {
|
||||
@@ -50,12 +52,25 @@ struct NodeBannerInputs {
|
||||
bool daemon_initializing = false; // daemon launching / block index loading
|
||||
bool connection_in_progress = false; // a connect attempt is actively running
|
||||
|
||||
// Peer connectivity: a full node connected to the daemon but with NO peer-derived tip
|
||||
// (longestchain == 0 -> tip_known false) has no peers, so it silently stalls at a stale height
|
||||
// while balance/sync look "done". `blocks` gates the check so we don't flag the brief window
|
||||
// before the first chain-info refresh. (audit: no-peer-banner)
|
||||
bool tip_known = true; // sync.tip_known — a peer-derived network tip is known
|
||||
int blocks = 0; // sync.blocks — >0 once chain info has been fetched
|
||||
|
||||
// Full-node embedded-daemon crash signal.
|
||||
bool using_embedded_daemon = false;
|
||||
bool has_daemon_controller = false;
|
||||
bool daemon_running = false;
|
||||
int daemon_crash_count = 0;
|
||||
|
||||
// The node is running but busy with something it can't be interrupted out of (a witness rebuild, the
|
||||
// startup replay) or too busy to answer RPC. Wallet data may be stale; a reconnect would only add
|
||||
// requests, so the banner offers no action. node_busy_detail is the live, translated description.
|
||||
bool node_busy = false;
|
||||
std::string node_busy_detail;
|
||||
|
||||
std::string connection_status; // human-readable status line (already translated)
|
||||
std::string daemon_last_error; // DaemonController::lastError() (may be empty)
|
||||
std::string lite_open_error; // lite: last wallet-open failure reason
|
||||
@@ -81,13 +96,38 @@ inline NodeBannerState evaluateNodeStatusBanner(const NodeBannerInputs& in) {
|
||||
return s;
|
||||
}
|
||||
|
||||
// Full node. Connected, or in an expected startup phase → the loading/warmup overlay owns
|
||||
// the screen, so no banner. An active connect attempt likewise shows progress, not an
|
||||
// error — don't flicker a banner over it.
|
||||
if (in.connected) return s;
|
||||
// Full node, busy. Before the startup-phase returns below: the overlay sets daemon_initializing while
|
||||
// the node is busy too, and the banner must still say why the wallet isn't updating. No action: a
|
||||
// "Reconnect" would only queue more requests behind the busy node.
|
||||
if (in.node_busy) {
|
||||
s.show = true;
|
||||
s.severity = NodeBannerSeverity::Warning;
|
||||
s.reason = NodeBannerReason::NodeBusy;
|
||||
s.action = NodeBannerAction::None;
|
||||
s.detail = in.node_busy_detail;
|
||||
return s;
|
||||
}
|
||||
|
||||
// Full node. In an expected startup phase → the loading/warmup overlay owns the screen, so no
|
||||
// banner. An active connect attempt likewise shows progress, not an error — don't flicker a
|
||||
// banner over it. (These return even if `connected` has flipped early, matching prior behavior.)
|
||||
if (in.warming_up || in.daemon_initializing) return s;
|
||||
if (in.connection_in_progress) return s;
|
||||
|
||||
// Connected to the daemon (RPC works) but with no peer-derived tip → the node has no peers and
|
||||
// cannot learn about new blocks; it silently stalls at a stale height while everything looks
|
||||
// synced. Surface it as a recoverable warning (reconnect won't help, so no action button).
|
||||
if (in.connected) {
|
||||
if (!in.tip_known && in.blocks > 0) {
|
||||
s.show = true;
|
||||
s.severity = NodeBannerSeverity::Warning;
|
||||
s.reason = NodeBannerReason::NoPeers;
|
||||
s.action = NodeBannerAction::None;
|
||||
s.detail = in.connection_status;
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
// Genuinely offline. Distinguish "the embedded daemon crashed and we stopped retrying" (a
|
||||
// hard fault needing a manual restart) from an ordinary lost/failed connection (retryable).
|
||||
if (in.using_embedded_daemon && in.has_daemon_controller && !in.daemon_running &&
|
||||
|
||||
@@ -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;
|
||||
|
||||
+40
-10
@@ -425,6 +425,9 @@ void I18n::loadBuiltinEnglish()
|
||||
// Sweep step: balance still loading / no funds to move
|
||||
strings_["mig_checking_balance"] = "Checking your balance";
|
||||
strings_["mig_no_funds"] = "Your wallet has no funds to migrate. You can adopt the new seed wallet directly — it replaces your current (empty) wallet with the seed-backed one. Your old wallet is still moved aside to a timestamped backup, just in case.";
|
||||
strings_["mig_sweep_inflight"] = "A sweep was already started and hasn't confirmed yet. Your balance reads zero because the funds are in the pending sweep transaction — they are not lost. Wait for it to confirm before finishing; the wallet will not be swapped until the sweep is mined.";
|
||||
strings_["mig_checking_sweep"] = "Looking for the sweep transaction";
|
||||
strings_["mig_check_sweep"] = "Check the sweep";
|
||||
strings_["mig_adopt_now"] = "Adopt seed wallet";
|
||||
strings_["mig_nofunds_confirm"] = "I understand this replaces my current wallet with the new seed wallet";
|
||||
// Adopting step note (shown under the spinner while the node restarts + rescans)
|
||||
@@ -579,11 +582,42 @@ void I18n::loadBuiltinEnglish()
|
||||
strings_["incorrect_passphrase"] = "Incorrect passphrase";
|
||||
strings_["pin_not_set"] = "PIN not set. Use passphrase to unlock.";
|
||||
strings_["restarting_after_encryption"] = "Restarting daemon after encryption...";
|
||||
strings_["force_quit"] = "Force Quit";
|
||||
strings_["force_quit_warning"] = "This will immediately kill the daemon without a clean shutdown. May require a blockchain resync.";
|
||||
strings_["force_quit_confirm_title"] = "Force Quit?";
|
||||
strings_["force_quit_confirm_msg"] = "This will immediately kill the daemon without a clean shutdown.\nThis may corrupt the blockchain index and require a resync.";
|
||||
strings_["force_quit_yes"] = "Force Quit";
|
||||
// Stopping the node on quit: deliver stop, wait, never kill unasked
|
||||
strings_["shutdown_leave_running"] = "Close wallet — leave node running";
|
||||
strings_["shutdown_leave_running_tip"] = "Closes the wallet now. The node keeps running on its own and finishes what it is doing; nothing is lost.";
|
||||
strings_["shutdown_kill_node"] = "Kill node (unsafe)";
|
||||
strings_["shutdown_kill_node_tip"] = "Ends the node at once. Anything it has not saved is lost, and its next start replays the blocks since its last save.";
|
||||
strings_["shutdown_kill_confirm_title"] = "Kill the node?";
|
||||
strings_["shutdown_kill_confirm_msg"] = "The node has shown no CPU or disk activity for a long time and may be stuck.\nKilling it loses everything it has not saved; its next start can take much longer.";
|
||||
strings_["shutdown_kill_confirm_yes"] = "Kill node";
|
||||
strings_["shutdown_stop_asking"] = "Asking the node to stop…";
|
||||
strings_["shutdown_stop_queued"] = "The node has the stop request but is busy — it will stop when it can";
|
||||
strings_["shutdown_stop_delivered"] = "The node is shutting down and saving its state";
|
||||
strings_["shutdown_stop_cpu_suffix"] = " · CPU %d%%";
|
||||
strings_["shutdown_stop_idle"] = "No CPU or disk activity for %d min — the node may be stuck";
|
||||
// The node is busy (S9): overlay, banner, status bar
|
||||
strings_["node_busy_rebuild_title"] = "Node busy: rebuilding note witnesses";
|
||||
strings_["node_busy_running"] = "Running for %s";
|
||||
strings_["node_busy_rebuild_desc"] = "Running for %s. Large wallets can take about an hour, and the node can't answer the wallet until it finishes.";
|
||||
strings_["node_busy_rebuild_reading"] = "Reading blocks: %ld / %ld";
|
||||
strings_["node_busy_rebuild_extending"] = "Extending witnesses (the node reports no progress for this step).";
|
||||
strings_["node_busy_replay_title"] = "Node busy: catching up on blocks";
|
||||
strings_["node_busy_replay_desc"] = "Running for %s. The node is re-applying blocks it received before it last stopped.";
|
||||
strings_["node_busy_rpc_title"] = "Node busy: not answering yet";
|
||||
strings_["node_busy_rpc_desc"] = "The node is running but has been too busy to answer the wallet for %s, usually because of a long operation such as a witness rebuild.";
|
||||
strings_["node_starting_title"] = "Starting DragonX node…";
|
||||
strings_["node_starting_busy_desc"] = "The node is reachable but busy and isn't answering yet. This is normal after an update or on first launch; it can take a few minutes.";
|
||||
strings_["node_starting_launch_desc"] = "Launching dragonxd and waiting for it to come online…";
|
||||
strings_["sb_node_busy"] = "Node busy";
|
||||
// Quit while the node is busy (S10)
|
||||
strings_["stopconfirm_rebuild_title"] = "The node is rebuilding note witnesses";
|
||||
strings_["stopconfirm_rebuild_body"] = "It has been running for %s, and large wallets can take about an hour. Stopping the node now either waits for the rebuild to finish or makes its next start redo the rebuild and any catch-up.\n\nRecommended: close the wallet and leave the node running. It finishes on its own.";
|
||||
strings_["stopconfirm_busy_title"] = "The node is busy";
|
||||
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";
|
||||
@@ -1459,6 +1493,7 @@ void I18n::loadBuiltinEnglish()
|
||||
strings_["sb_wallet_needs_recovery"] = "Wallet repair available";
|
||||
// Persistent node-status banner (App::renderNodeStatusBanner).
|
||||
strings_["node_banner_offline_title"] = "Not connected to the DragonX node";
|
||||
strings_["node_banner_no_peers_title"] = "Connected, but no network peers — the node can't sync";
|
||||
strings_["node_banner_crashed_title"] = "The node stopped unexpectedly";
|
||||
strings_["node_banner_lite_open_failed"] = "Couldn't open your wallet";
|
||||
strings_["node_banner_reconnect"] = "Reconnect";
|
||||
@@ -2621,10 +2656,7 @@ void I18n::loadBuiltinEnglish()
|
||||
strings_["appx_restoring_your_wallet"] = "Restoring your wallet…";
|
||||
strings_["appx_recovery_phrase_word_count"] = "Recovery phrase should be 24 words — you have %d.";
|
||||
strings_["appx_could_not_start_restore"] = "Could not start restore";
|
||||
strings_["appx_node_rebuilding_witness_cache"] = "Node is rebuilding its witness cache";
|
||||
strings_["appx_stopping_node_discards_rebuild"] = "Stopping the node now discards the in-progress rebuild and restarts it (several minutes) the next time you open the wallet. You can keep the node running instead.";
|
||||
strings_["appx_keep_node_running_and_quit"] = "Keep node running & quit";
|
||||
strings_["appx_stop_anyway_and_quit"] = "Stop anyway & quit";
|
||||
strings_["appx_cancel"] = "Cancel";
|
||||
strings_["appx_last_used_wallet_not_found_prefix"] = "Your last-used wallet file (";
|
||||
strings_["appx_last_used_wallet_not_found_suffix"] = ") was not found — opened the default wallet instead. If you moved it, restore it and switch back from the wallet list.";
|
||||
@@ -2654,8 +2686,6 @@ void I18n::loadBuiltinEnglish()
|
||||
strings_["appx_shutdown_complete"] = "Shutdown complete";
|
||||
strings_["appx_n_seconds"] = "%d seconds";
|
||||
strings_["appx_n_min_n_sec"] = "%d min %d sec";
|
||||
strings_["appx_still_status_prefix"] = "Still \"";
|
||||
strings_["appx_still_status_suffix"] = "\" — force quitting now may corrupt chain data.";
|
||||
strings_["appx_rebuilding_witness_cache_blocks_left"] = "Rebuilding witness cache %.0f%% — %d blocks left";
|
||||
strings_["appx_rebuilding_witness_cache_pct"] = "Rebuilding witness cache %.0f%%";
|
||||
strings_["appx_setting_initial_sapling_witnesses"] = "Setting initial Sapling witnesses %.0f%%";
|
||||
|
||||
@@ -450,31 +450,47 @@ void LiteWalletController::pumpAsyncOpen()
|
||||
bool LiteWalletController::beginCreateWalletAsync(LiteWalletCreateRequest request)
|
||||
{
|
||||
auto req = std::make_shared<LiteWalletCreateRequest>(std::move(request));
|
||||
return beginLifecycleRequestAsync(
|
||||
// Encrypt the brand-new wallet with the user's passphrase once it opens. The async worker
|
||||
// can't (walletOpen_ isn't set until pumpLifecycleResult runs), so stash it and apply on the
|
||||
// main-thread finalize — otherwise the passphrase is discarded and the seed stored in
|
||||
// plaintext (audit C1). Mirrors the synchronous createWallet() wrapper.
|
||||
stashPendingLifecycleSecurity(req->passphrase, PendingSecurityOp::Encrypt);
|
||||
const bool started = beginLifecycleRequestAsync(
|
||||
"Create",
|
||||
[req](LiteWalletLifecycleService& svc, const std::string& url) {
|
||||
req->serverUrl = url;
|
||||
return svc.createWallet(*req);
|
||||
},
|
||||
[req]() { secureWipeLiteSecret(req->passphrase); });
|
||||
if (!started) clearPendingLifecycleSecurity();
|
||||
return started;
|
||||
}
|
||||
|
||||
bool LiteWalletController::beginOpenWalletAsync(LiteWalletOpenRequest request)
|
||||
{
|
||||
auto req = std::make_shared<LiteWalletOpenRequest>(std::move(request));
|
||||
return beginLifecycleRequestAsync(
|
||||
// An existing wallet may be encrypted+locked — unlock it with the supplied passphrase on the
|
||||
// main-thread finalize (the async worker can't; walletOpen_ isn't set yet). Without this an
|
||||
// encrypted wallet opens but stays locked and the passphrase is never verified (audit M1).
|
||||
stashPendingLifecycleSecurity(req->passphrase, PendingSecurityOp::Unlock);
|
||||
const bool started = beginLifecycleRequestAsync(
|
||||
"Open",
|
||||
[req](LiteWalletLifecycleService& svc, const std::string& url) {
|
||||
req->serverUrl = url;
|
||||
return svc.openWallet(*req);
|
||||
},
|
||||
[req]() { secureWipeLiteSecret(req->passphrase); });
|
||||
if (!started) clearPendingLifecycleSecurity();
|
||||
return started;
|
||||
}
|
||||
|
||||
bool LiteWalletController::beginRestoreWalletAsync(LiteWalletRestoreRequest request)
|
||||
{
|
||||
auto req = std::make_shared<LiteWalletRestoreRequest>(std::move(request));
|
||||
return beginLifecycleRequestAsync(
|
||||
// Encrypt the restored wallet with the user's passphrase once it opens (same rationale and
|
||||
// main-thread finalize as create — audit C1).
|
||||
stashPendingLifecycleSecurity(req->passphrase, PendingSecurityOp::Encrypt);
|
||||
const bool started = beginLifecycleRequestAsync(
|
||||
"Restore",
|
||||
[req](LiteWalletLifecycleService& svc, const std::string& url) {
|
||||
req->serverUrl = url;
|
||||
@@ -484,6 +500,8 @@ bool LiteWalletController::beginRestoreWalletAsync(LiteWalletRestoreRequest requ
|
||||
secureWipeLiteSecret(req->seedPhrase);
|
||||
secureWipeLiteSecret(req->passphrase);
|
||||
});
|
||||
if (!started) clearPendingLifecycleSecurity();
|
||||
return started;
|
||||
}
|
||||
|
||||
bool LiteWalletController::beginLifecycleRequestAsync(
|
||||
@@ -585,15 +603,54 @@ void LiteWalletController::pumpLifecycleResult()
|
||||
// log the failure otherwise (shared with the synchronous lifecycle path).
|
||||
onLifecycleResult(out);
|
||||
if (out.walletReady) {
|
||||
// Wallet is now open (walletOpen_ set by onLifecycleResult) — apply the stashed passphrase:
|
||||
// encrypt a freshly created/restored wallet, or unlock an existing encrypted one (C1/M1).
|
||||
applyPendingLifecycleSecurity();
|
||||
lastOpenError_.clear();
|
||||
lastOpenWarming_ = false;
|
||||
} else {
|
||||
clearPendingLifecycleSecurity(); // wallet didn't open — drop the stashed passphrase
|
||||
lastOpenError_ = out.error.empty() ? out.status.message : out.error;
|
||||
lastOpenWarming_ = liteOpenErrorIsWarmup(lastOpenError_);
|
||||
}
|
||||
lastLifecycleResult_ = std::move(out);
|
||||
}
|
||||
|
||||
void LiteWalletController::stashPendingLifecycleSecurity(const std::string& passphrase,
|
||||
PendingSecurityOp op)
|
||||
{
|
||||
clearPendingLifecycleSecurity(); // wipe any stale prior stash first
|
||||
pendingLifecyclePassphrase_ = passphrase; // independent copy; wiped in apply/clear
|
||||
pendingLifecycleSecurityOp_ = op;
|
||||
}
|
||||
|
||||
void LiteWalletController::applyPendingLifecycleSecurity()
|
||||
{
|
||||
const PendingSecurityOp op = pendingLifecycleSecurityOp_;
|
||||
if (op == PendingSecurityOp::None || pendingLifecyclePassphrase_.empty() ||
|
||||
!walletOpen_.load() || !bridge_) {
|
||||
clearPendingLifecycleSecurity();
|
||||
return;
|
||||
}
|
||||
if (op == PendingSecurityOp::Encrypt) {
|
||||
// encryptWallet() takes its own copy and wipes it; the backend encrypts + locks + saves.
|
||||
const auto enc = encryptWallet(pendingLifecyclePassphrase_);
|
||||
if (!enc.ok)
|
||||
liteLog("wallet created/restored but ENCRYPTION FAILED — wallet is NOT encrypted: " +
|
||||
enc.error);
|
||||
} else if (op == PendingSecurityOp::Unlock) {
|
||||
if (!unlockWallet(pendingLifecyclePassphrase_))
|
||||
liteLog("wallet opened but unlock failed (wrong passphrase?)");
|
||||
}
|
||||
clearPendingLifecycleSecurity();
|
||||
}
|
||||
|
||||
void LiteWalletController::clearPendingLifecycleSecurity()
|
||||
{
|
||||
secureWipeLiteSecret(pendingLifecyclePassphrase_); // no-op if already empty
|
||||
pendingLifecycleSecurityOp_ = PendingSecurityOp::None;
|
||||
}
|
||||
|
||||
void LiteWalletController::startSync()
|
||||
{
|
||||
if (syncLaunched_.exchange(true)) return;
|
||||
|
||||
@@ -388,6 +388,20 @@ private:
|
||||
std::make_shared<std::optional<LiteWalletLifecycleResult>>();
|
||||
LiteWalletLifecycleResult lastLifecycleResult_; // main-thread only; last finalized request
|
||||
|
||||
// Passphrase to apply on the main thread once an async create/restore/open finalizes. The
|
||||
// backend wallet is not controller-"open" (walletOpen_) until pumpLifecycleResult() runs
|
||||
// onLifecycleResult(), and encryptWallet()/unlockWallet() gate on walletOpen_ — so the async
|
||||
// worker thread cannot do it. The passphrase is stashed here (main-thread only) and applied in
|
||||
// pumpLifecycleResult(), mirroring the synchronous createWallet()/restoreWallet()/openWallet()
|
||||
// wrappers. Without this the UI's async path silently discarded the passphrase, storing the
|
||||
// seed UNENCRYPTED (create/restore) or never unlocking an encrypted wallet (open) — audit C1/M1.
|
||||
enum class PendingSecurityOp { None, Encrypt, Unlock };
|
||||
std::string pendingLifecyclePassphrase_; // main-thread only; wiped after use
|
||||
PendingSecurityOp pendingLifecycleSecurityOp_ = PendingSecurityOp::None;
|
||||
void stashPendingLifecycleSecurity(const std::string& passphrase, PendingSecurityOp op);
|
||||
void applyPendingLifecycleSecurity(); // encrypt (create/restore) or unlock (open), then wipe
|
||||
void clearPendingLifecycleSecurity(); // wipe + reset (rejected request / failed open)
|
||||
|
||||
// Joinable background refresh worker (fast iterations: syncstatus, plus data once synced).
|
||||
std::thread worker_;
|
||||
std::atomic<bool> running_{false};
|
||||
|
||||
+1805
-31
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,138 @@
|
||||
# fake_dragonxd
|
||||
|
||||
A deliberately slow, scriptable stand-in for `dragonxd`, used to reproduce and regression-test how
|
||||
ObsidianDragon stops (or kills) a busy node. It is test tooling: off by default, never packaged.
|
||||
|
||||
The incident it models (2026-09):
|
||||
1. A 13.6k-note wallet spent ~1 h in a witness rebuild that held `cs_main`.
|
||||
2. The GUI's own abandoned polls filled the node's 24 RPC slots, so `stop` was never delivered.
|
||||
3. On close, the GUI hard-killed the node mid-rebuild, so every restart replayed ~62k blocks and redid the rebuild.
|
||||
|
||||
What it copies from the real daemon (DragonX v1.3.0):
|
||||
|
||||
- **HTTP-RPC queue.** 8 workers plus a 16-deep queue (or `-rpcthreads` / `-rpcworkqueue` from the
|
||||
command line or `DRAGONX.conf`). When the queue is full the request gets an immediate plain-text
|
||||
`HTTP 500 "Work queue depth exceeded"`. A request whose client timed out **keeps its slot** until
|
||||
a worker reaches it.
|
||||
- **cs_main.** One global lock. Every method except `stop`, `help` and `getrescaninfo` (lock-free in dragonxd too) takes it. A "witness rebuild"
|
||||
holds it for `FAKE_REBUILD_SECS` and logs the real markers:
|
||||
- `Reading blocks for witness rebuild: <off> / <total>` (Phase 1, abortable);
|
||||
- nothing during Phase 2, which ignores shutdown;
|
||||
- `BuildWitnessCache: rebuilt N note witness cache(s) to height H in Tms using 8 thread(s)` at the end.
|
||||
- **Warmup.** `-28` errors carrying the current init message, using the daemon's warmup allow-list.
|
||||
Optionally an `Activating best chain...` replay, which logs `UpdateTip` lines and stops between steps on shutdown.
|
||||
- **Shutdown.** Triggered by `stop`, or by SIGTERM/SIGINT on POSIX (Windows has no signal channel,
|
||||
as in v1.3.0). In order, the stub:
|
||||
1. unbinds the listener at once;
|
||||
2. answers new requests on open connections with `503`;
|
||||
3. waits for the rebuild to finish;
|
||||
4. logs `Shutdown: In progress...` and renames its main thread to `hush-shutoff` (Linux `comm`);
|
||||
5. answers never-run queued requests with `500 Unhandled request`;
|
||||
6. flushes for `FAKE_FLUSH_SECS`, logs `Shutdown: done`, and exits 0.
|
||||
- **Logging.** With `-printtoconsole`, untimestamped lines go to stdout (this is what the GUI uses on
|
||||
Linux and macOS). Otherwise, timestamped UTC lines go to `<datadir>/debug.log` (the Windows path the GUI tails).
|
||||
- **Datadir.** It takes `.lock`, writes `hushd.pid` (POSIX) and `.cookie` (when there is no
|
||||
`rpcpassword`), and uses the same default datadir as dragonxd (`~/.hush/DRAGONX`, `%APPDATA%\Hush\DRAGONX`).
|
||||
|
||||
Its replies are canned: `getinfo` reports version `1030050`, the balance is fixed, and there are two
|
||||
peers on TEST-NET addresses. Unknown methods get the real `-32601 Method not found` (HTTP 404).
|
||||
|
||||
## Events file
|
||||
|
||||
Everything that matters for a test is appended to `<datadir>/fake_dragonxd.events`, one
|
||||
`<name> t=<unix ms> [k=v ...]` per line:
|
||||
|
||||
| Event | Meaning |
|
||||
|---|---|
|
||||
| `start`, `listening`, `warmup-end` | startup; `start` lists every knob |
|
||||
| `rebuild-start`, `rebuild-end`, `rebuild-aborted` | the cs_main hold |
|
||||
| `queue-full` | the first `Work queue depth exceeded` |
|
||||
| `max-abandoned n=` | a new high for client-abandoned requests still holding a slot |
|
||||
| `stop-received n=` | a `stop` RPC ran (every one is logged, so resends are visible) |
|
||||
| `stop-rejected status=` | a `stop` never reached a worker (500 queue-full, or 503 after interrupt) |
|
||||
| `sigterm` | SIGTERM/SIGINT received (POSIX) |
|
||||
| `http-interrupted`, `shutdown-begin` | shutdown progress |
|
||||
| `stats ...` | counters, every 30 s when they change |
|
||||
| `methods ...` | the 10 most-requested JSON-RPC methods with counts (with `stats`, and at exit): what the GUI is polling |
|
||||
| `clean-exit ...` | **graceful exit**, with final counters. If this is missing, the process was killed. |
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
cmake -S . -B build/linux -DDRAGONX_BUILD_FAKE_DAEMON=ON
|
||||
cmake --build build/linux --target fake_dragonxd # -> build/linux/fake_dragonxd/dragonxd
|
||||
|
||||
# Windows (mingw cross, from Linux/WSL): builds it with the Windows test suite in build/windows-tests
|
||||
scripts/test-windows.sh --no-run # -> build/windows-tests/fake_dragonxd/dragonxd.exe
|
||||
```
|
||||
|
||||
The output goes to its own `fake_dragonxd/` directory so it never replaces the real `bin/dragonxd`.
|
||||
|
||||
## Run the GUI against it (Linux)
|
||||
|
||||
```bash
|
||||
FAKE_REBUILD_SECS=300 tools/fake_dragonxd/run-gui-with-fake.sh [--build-dir build/linux] [--scratch DIR]
|
||||
```
|
||||
|
||||
The script builds a throwaway `HOME` with:
|
||||
|
||||
- copies of the GUI and the stub (as `dragonxd`, next to the GUI, where `findDaemonBinary()` looks first);
|
||||
- a `DRAGONX.conf` and an empty `blocks/`, so the first-run wizard is skipped;
|
||||
- `settings.json` with `verbose_logging`.
|
||||
|
||||
It then starts the GUI and prints the events file when the GUI exits. It refuses to run if
|
||||
something already listens on 21769, because the GUI would adopt that process as an external node.
|
||||
It also refuses a Lite-configured `--build-dir`: Lite has no embedded daemon, and any
|
||||
`bin/ObsidianDragon` in such a tree is stale.
|
||||
To "close the window" from a script, send SIGTERM to the GUI (`kill -TERM <gui pid>`); SDL turns it
|
||||
into a normal quit. For scripted runs nobody needs to watch, add `SDL_VIDEODRIVER=offscreen`: the GUI
|
||||
runs with no window at all (OpenGL still loads, and connecting and shutting down work as usual).
|
||||
|
||||
To run the stub alone: `FAKE_... build/linux/fake_dragonxd/dragonxd -datadir=/some/dir [-printtoconsole]`
|
||||
with a `DRAGONX.conf` (`rpcuser`/`rpcpassword`/`rpcport`) in that dir.
|
||||
|
||||
## Windows
|
||||
|
||||
Use a **separate Windows user or a VM**:
|
||||
|
||||
- The datadir is `%APPDATA%\Hush\DRAGONX` and can't be redirected for the GUI.
|
||||
- Until S3 lands, the GUI `TerminateProcess`es **any** `dragonxd.exe` of the same user, including a real node.
|
||||
|
||||
Put `dragonxd.exe` (the stub) next to `ObsidianDragon.exe`. Set the `FAKE_*` variables in that
|
||||
user's environment, or start the stub by hand from a console for case E. Each new GUI-launched stub
|
||||
inherits the GUI's environment.
|
||||
|
||||
## Knobs (environment)
|
||||
|
||||
| Variable | Default | Meaning |
|
||||
|---|---|---|
|
||||
| `FAKE_WARMUP_SECS` | 3 | `-28 Loading block index...` phase |
|
||||
| `FAKE_REPLAY_SECS` / `FAKE_REPLAY_BPS` | 0 / 12 | startup replay (`Activating best chain...`, UpdateTip lines) |
|
||||
| `FAKE_REBUILD_DELAY_SECS` | 20 | wait after `Done Loading` before the rebuild, so the GUI connects first |
|
||||
| `FAKE_REBUILD_SECS` | 300 | how long the rebuild holds cs_main; `0` = healthy node, no rebuild |
|
||||
| `FAKE_PHASE1_SECS` | 2 | the logged, abortable part of the rebuild |
|
||||
| `FAKE_POST_REBUILD_LOCK_SECS` | 0 | cs_main held after the end line (models the 18–36 s flush + wallet `SetBestChain`) |
|
||||
| `FAKE_FLUSH_SECS` | 3 | `Shutdown: In progress...` → `Shutdown: done` |
|
||||
| `FAKE_UPDATETIP_SECS` | 36 | block interval once loaded |
|
||||
| `FAKE_NOISE_SECS` | 20 | an unrelated log line this often (defeats "newest line" heuristics) |
|
||||
| `FAKE_SERVER_TIMEOUT_SECS` | 30 | idle keep-alive connections are closed (`-rpcservertimeout`) |
|
||||
| `FAKE_PREFILL` | 0 | inject N abandoned requests when the rebuild starts (`24` = saturated) |
|
||||
| `FAKE_MINING` | 0 | internal miner on: `stop` waits for cs_main first (`GenerateBitcoins` join) |
|
||||
| `FAKE_REBUILD_SILENT` | 0 | hold cs_main like a rebuild but log no rebuild markers (a busy node whose output says nothing) |
|
||||
| `FAKE_STUCK_SHUTDOWN` | 0 | the Sep 29 orphan: hold the datadir lock, no listener, ignore SIGTERM, never exit |
|
||||
| `FAKE_HEIGHT`, `FAKE_REBUILD_TOTAL`, `FAKE_NOTES`, `FAKE_VERSION` | 3327145, 62583, 13632, 1030050 | cosmetic numbers |
|
||||
|
||||
## Test cases
|
||||
|
||||
"Today" is the behaviour of v2.0.1 / `master` @ `46189da`. "Fixed" is the target behaviour of the
|
||||
daemon-stop hardening work.
|
||||
|
||||
| Case | Setup | Today (bug) | Fixed |
|
||||
|---|---|---|---|
|
||||
| A | defaults; leave the GUI open ~5 min into the rebuild | ~24 abandoned, then queue-full 500s; overlay "Launching dragonxd…" | busy overlay; `max-abandoned` ≤ 7 |
|
||||
| B | close the GUI during the rebuild | Linux: `sigterm` at +20 s, killed at +30 s, no `clean-exit`. Windows: killed at +20 s, no `stop-received` | modal; `stop-received`/`sigterm` within 2 s; waits; `clean-exit` |
|
||||
| C | close so the rebuild ends ~8 s later | killed | `stop` retried, `clean-exit` |
|
||||
| D | `FAKE_PREFILL=24`, close | `stop` never delivered | POSIX via SIGTERM; Windows `stop` retried until it lands |
|
||||
| E | Windows: start the stub by hand with `FAKE_STUCK_SHUTDOWN=1`, launch the GUI, close it | "Found orphaned dragonxd.exe … forcing termination" | no kill |
|
||||
| F | Detach on the shutdown screen | n/a | GUI exits at once; stub runs on to `clean-exit` |
|
||||
| G | Overview page during the 500 phase; count `[connect #N]` per minute in the GUI log | ~1,800–3,200/min | ≤ ~2–4/min |
|
||||
File diff suppressed because it is too large
Load Diff
Executable
+91
@@ -0,0 +1,91 @@
|
||||
#!/usr/bin/env bash
|
||||
# Run ObsidianDragon against tools/fake_dragonxd in a throwaway, fully isolated HOME (Linux).
|
||||
#
|
||||
# tools/fake_dragonxd/run-gui-with-fake.sh [--build-dir DIR] [--scratch DIR] [-- GUI args...]
|
||||
#
|
||||
# FAKE_* variables in the environment pass through to the stub (see README.md). It never touches
|
||||
# the real ~/.hush or ~/.config: HOME, XDG_CONFIG_HOME and TMPDIR all point into the scratch dir.
|
||||
# Re-using a --scratch dir keeps its settings, conf and GUI log; the stub's events file is appended.
|
||||
set -euo pipefail
|
||||
|
||||
repo="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
build_dir="$repo/build/linux"
|
||||
scratch=""
|
||||
port=21769 # the GUI's external-node / port checks are hard-wired to the default RPC port
|
||||
|
||||
usage() { sed -n '2,8p' "$0" | sed 's/^# \{0,1\}//'; }
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--build-dir) build_dir="$(cd "$2" && pwd)"; shift 2 ;;
|
||||
--scratch) scratch="$2"; shift 2 ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
--) shift; break ;;
|
||||
*) echo "unknown option: $1" >&2; usage >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
gui="$build_dir/bin/ObsidianDragon"
|
||||
stub="$build_dir/fake_dragonxd/dragonxd"
|
||||
# A Lite tree has no embedded daemon and builds ObsidianDragonLite; any bin/ObsidianDragon in it is stale.
|
||||
if grep -q '^DRAGONX_BUILD_LITE:BOOL=ON' "$build_dir/CMakeCache.txt" 2>/dev/null; then
|
||||
echo "$build_dir is configured as the Lite variant; pass --build-dir of a full-node tree" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ ! -x "$gui" ]]; then
|
||||
echo "missing $gui; build the app first (./build.sh)" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ ! -x "$stub" ]]; then
|
||||
echo "missing $stub; build it with:" >&2
|
||||
echo " cmake -S . -B ${build_dir#"$repo"/} -DDRAGONX_BUILD_FAKE_DAEMON=ON && cmake --build ${build_dir#"$repo"/} --target fake_dragonxd" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ss -ltn 2>/dev/null | grep -qE "[:.]$port( |$)"; then
|
||||
echo "port $port is already in use (a real dragonxd?). The GUI would adopt it as an external node; stop it first." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
scratch="${scratch:-$(mktemp -d "${TMPDIR:-/tmp}/fake-dragonxd.XXXXXX")}"
|
||||
mkdir -p "$scratch"
|
||||
scratch="$(cd "$scratch" && pwd)"
|
||||
app="$scratch/app"
|
||||
home="$scratch/home"
|
||||
datadir="$home/.hush/DRAGONX"
|
||||
config_dir="$home/.config/ObsidianDragon"
|
||||
mkdir -p "$app" "$datadir/blocks" "$config_dir" "$scratch/tmp"
|
||||
|
||||
# Copies, not symlinks: the GUI looks for dragonxd next to /proc/self/exe, which resolves links.
|
||||
cp -f "$gui" "$app/ObsidianDragon"
|
||||
cp -f "$stub" "$app/dragonxd"
|
||||
ln -sfn "$build_dir/bin/res" "$app/res"
|
||||
for f in sapling-spend.params sapling-output.params asmap.dat; do
|
||||
if [[ -e "$build_dir/bin/$f" ]]; then ln -sf "$build_dir/bin/$f" "$app/$f"; fi
|
||||
done
|
||||
# The GUI also keeps a dragonxd copy in its daemon dir; keep that one the stub too.
|
||||
if [[ -e "$config_dir/dragonx/dragonxd" ]]; then cp -f "$stub" "$config_dir/dragonx/dragonxd"; fi
|
||||
|
||||
# A pre-made conf + blocks/ dir skips the first-run wizard (App::isFirstRun).
|
||||
if [[ ! -e "$datadir/DRAGONX.conf" ]]; then
|
||||
printf 'rpcuser=fake\nrpcpassword=fake-%s\nrpcport=%s\n' "$RANDOM$RANDOM" "$port" > "$datadir/DRAGONX.conf"
|
||||
fi
|
||||
if [[ ! -e "$config_dir/settings.json" ]]; then
|
||||
printf '{\n "wizard_completed": true,\n "verbose_logging": true\n}\n' > "$config_dir/settings.json"
|
||||
fi
|
||||
|
||||
cat <<EOF
|
||||
scratch : $scratch
|
||||
stub events : $datadir/fake_dragonxd.events
|
||||
GUI log : $config_dir/dragonx-debug.log
|
||||
FAKE_* : $(env | grep -E '^FAKE_' | tr '\n' ' ')
|
||||
EOF
|
||||
|
||||
rc=0
|
||||
HOME="$home" XDG_CONFIG_HOME="$home/.config" TMPDIR="$scratch/tmp" "$app/ObsidianDragon" "$@" || rc=$?
|
||||
|
||||
echo "--- GUI exited ($rc). Stub events:"
|
||||
cat "$datadir/fake_dragonxd.events" 2>/dev/null || echo "(none)"
|
||||
if pgrep -f "^$app/dragonxd" >/dev/null; then
|
||||
echo "--- the stub is still running (pid $(pgrep -f "^$app/dragonxd" | tr '\n' ' ')); it exits after its rebuild + flush."
|
||||
fi
|
||||
exit "$rc"
|
||||
Reference in New Issue
Block a user