perf(startup): prepare first-run node files in the background

On a first run or after an upgrade, Windows release builds wrote the
bundled Sapling params, asmap, node and miner (about 88 MB) in
App::init, before the first frame: 0.5-3 s of frozen blank window, 5-30 s
on a slow disk or under antivirus. Zip, AppImage and .app builds copied
the bundled files into the daemon dir and hashed the 51.5 MB of params on
the UI thread at the first node start, a 0.3-1.5 s freeze in the wizard
or the connect loop.

A stat-only check at startup decides whether anything is missing, stale
or unverified; a normal launch starts no thread and shows nothing.
Otherwise one background job extracts (Windows) or copies (Linux, macOS)
the files, then verifies the params once:

- The first frame paints at once. If the job takes longer than 0.4 s, a
  chip at the bottom shows "Preparing wallet files... N%", then
  "Verifying wallet files...".
- A node start on the UI thread while the job runs is deferred and
  replayed when it ends (never into a bootstrap download, a restart or a
  live connection); worker threads wait, and give up on quit. After an
  upgrade that ships a new miner, the node starts after the miner is
  written instead of before. Pool-miner starts and the miner version
  check wait too, so the old miner is never run or reported while it is
  being replaced.
- Each file goes to "<name>.partial", is flushed and renamed into place
  under one lock shared with the daemon start, the bundled-node
  reinstall and the miner extraction, and the job stops between 4 MB
  chunks at shutdown. An interrupted write used to leave a truncated
  binary that was trusted forever; a leftover .partial is now removed.

Other behaviour changes:

- Linux and macOS full-node builds copy the bundled node files (about
  77 MB) into the daemon dir at first launch, even if the wallet ends up
  on an external node; before, this happened at the first embedded start.
- Lite no longer writes the 53 MB of params and asmap it never reads.
- A disk-full or permission failure that leaves the node files unusable
  shows a warning; a miner blocked by antivirus alone does not.
- A corrupt or truncated param in a zip, AppImage or .app install is
  replaced from the bundle and verified again instead of trusted forever.
- Reinstalling the bundled node holds off the connect loop and a deferred
  start for its whole run (either could launch the old binary while the
  new one was written), and is refused while another restart is running.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-09 21:09:48 -05:00
co-authored by Claude Opus 5.5
parent 47180ff840
commit 971f2abae5
19 changed files with 1245 additions and 360 deletions
+2
View File
@@ -1926,6 +1926,8 @@
"sort_date_newest": "Neueste zuerst", "sort_date_newest": "Neueste zuerst",
"sort_date_oldest": "Älteste zuerst", "sort_date_oldest": "Älteste zuerst",
"start_mining": "Mining starten", "start_mining": "Mining starten",
"startup_files_preparing": "Wallet-Dateien werden vorbereitet... %d%%",
"startup_files_verifying": "Wallet-Dateien werden überprüft...",
"status": "Status", "status": "Status",
"stop_external": "Externen Daemon stoppen", "stop_external": "Externen Daemon stoppen",
"stop_mining": "Mining stoppen", "stop_mining": "Mining stoppen",
+2
View File
@@ -1926,6 +1926,8 @@
"sort_date_newest": "Más recientes primero", "sort_date_newest": "Más recientes primero",
"sort_date_oldest": "Más antiguas primero", "sort_date_oldest": "Más antiguas primero",
"start_mining": "Iniciar Minería", "start_mining": "Iniciar Minería",
"startup_files_preparing": "Preparando los archivos de la cartera... %d%%",
"startup_files_verifying": "Verificando los archivos de la cartera...",
"status": "Estado", "status": "Estado",
"stop_external": "Detener daemon externo", "stop_external": "Detener daemon externo",
"stop_mining": "Detener Minería", "stop_mining": "Detener Minería",
+2
View File
@@ -1926,6 +1926,8 @@
"sort_date_newest": "Plus récent d'abord", "sort_date_newest": "Plus récent d'abord",
"sort_date_oldest": "Plus ancien d'abord", "sort_date_oldest": "Plus ancien d'abord",
"start_mining": "Démarrer le minage", "start_mining": "Démarrer le minage",
"startup_files_preparing": "Préparation des fichiers du portefeuille... %d%%",
"startup_files_verifying": "Vérification des fichiers du portefeuille...",
"status": "Statut", "status": "Statut",
"stop_external": "Arrêter le daemon externe", "stop_external": "Arrêter le daemon externe",
"stop_mining": "Arrêter le minage", "stop_mining": "Arrêter le minage",
+2
View File
@@ -1926,6 +1926,8 @@
"sort_date_newest": "新しい順", "sort_date_newest": "新しい順",
"sort_date_oldest": "古い順", "sort_date_oldest": "古い順",
"start_mining": "マイニング開始", "start_mining": "マイニング開始",
"startup_files_preparing": "ウォレットファイルを準備しています... %d%%",
"startup_files_verifying": "ウォレットファイルを検証しています...",
"status": "ステータス", "status": "ステータス",
"stop_external": "外部デーモンを停止", "stop_external": "外部デーモンを停止",
"stop_mining": "マイニング停止", "stop_mining": "マイニング停止",
+2
View File
@@ -1926,6 +1926,8 @@
"sort_date_newest": "최신순", "sort_date_newest": "최신순",
"sort_date_oldest": "오래된 순", "sort_date_oldest": "오래된 순",
"start_mining": "채굴 시작", "start_mining": "채굴 시작",
"startup_files_preparing": "지갑 파일 준비 중... %d%%",
"startup_files_verifying": "지갑 파일 확인 중...",
"status": "상태", "status": "상태",
"stop_external": "외부 데몬 중지", "stop_external": "외부 데몬 중지",
"stop_mining": "채굴 중지", "stop_mining": "채굴 중지",
+2
View File
@@ -1926,6 +1926,8 @@
"sort_date_newest": "Mais recentes primeiro", "sort_date_newest": "Mais recentes primeiro",
"sort_date_oldest": "Mais antigas primeiro", "sort_date_oldest": "Mais antigas primeiro",
"start_mining": "Iniciar Mineração", "start_mining": "Iniciar Mineração",
"startup_files_preparing": "Preparando os arquivos da carteira... %d%%",
"startup_files_verifying": "Verificando os arquivos da carteira...",
"status": "Status", "status": "Status",
"stop_external": "Parar daemon externo", "stop_external": "Parar daemon externo",
"stop_mining": "Parar Mineração", "stop_mining": "Parar Mineração",
+2
View File
@@ -1926,6 +1926,8 @@
"sort_date_newest": "Сначала новые", "sort_date_newest": "Сначала новые",
"sort_date_oldest": "Сначала старые", "sort_date_oldest": "Сначала старые",
"start_mining": "Начать майнинг", "start_mining": "Начать майнинг",
"startup_files_preparing": "Подготовка файлов кошелька... %d%%",
"startup_files_verifying": "Проверка файлов кошелька...",
"status": "Статус", "status": "Статус",
"stop_external": "Остановить внешний daemon", "stop_external": "Остановить внешний daemon",
"stop_mining": "Остановить майнинг", "stop_mining": "Остановить майнинг",
+2
View File
@@ -1926,6 +1926,8 @@
"sort_date_newest": "最新优先", "sort_date_newest": "最新优先",
"sort_date_oldest": "最早优先", "sort_date_oldest": "最早优先",
"start_mining": "开始挖矿", "start_mining": "开始挖矿",
"startup_files_preparing": "正在准备钱包文件... %d%%",
"startup_files_verifying": "正在验证钱包文件...",
"status": "状态", "status": "状态",
"stop_external": "停止外部守护进程", "stop_external": "停止外部守护进程",
"stop_mining": "停止挖矿", "stop_mining": "停止挖矿",
+10
View File
@@ -1203,6 +1203,16 @@ connection-dot = { opacity = 1.0 }
height = 30 height = 30
padding = [8, 0] padding = [8, 0]
[components.startup-files-chip]
width = { size = 340.0 }
height = { size = 46.0 }
bottom-margin = { size = 38.0 }
rounding = { size = 10.0 }
padding-x = { size = 14.0 }
bar-height = { size = 3.0 }
bar-bottom = { size = 10.0 }
show-delay-seconds = { size = 0.4 }
[components.notifications] [components.notifications]
width = 350.0 width = 350.0
height = 60.0 height = 60.0
+275 -110
View File
@@ -274,7 +274,10 @@ void App::applyPoolAutoBalance(const util::PoolStatsService::Snapshot& snap)
std::string App::poolMiningInstalledVersion() std::string App::poolMiningInstalledVersion()
{ {
daemon::XmrigManager::startVersionDetection(); // idempotent one-shot // Not while the startup job may still be replacing the miner (`xmrig --version` would cache the old
// binary's version); pollStartupFiles() starts the detection when the job ends.
if (!startup_files_ || !startup_files_->isRunning())
daemon::XmrigManager::startVersionDetection(); // idempotent one-shot
return daemon::XmrigManager::installedVersion(); return daemon::XmrigManager::installedVersion();
} }
@@ -348,11 +351,11 @@ bool App::init()
{ {
DEBUG_LOGF("Initializing ObsidianDragon...\n"); DEBUG_LOGF("Initializing ObsidianDragon...\n");
// Extract embedded resources (Sapling params, asmap.dat) on first run ui_thread_id_ = std::this_thread::get_id();
if (resources::hasEmbeddedResources() && resources::needsParamsExtraction()) { // Write the node files (Sapling params, asmap, node, miner) on first run / after an upgrade OFF the UI
DEBUG_LOGF("First run - extracting bundled resources...\n"); // thread: the first frame no longer waits for ~88 MB of disk writes. startEmbeddedDaemon() defers (UI
resources::extractEmbeddedResources(); // thread) or waits (workers) until the job is done, so the node never starts over half-written files.
} startStartupFilesProvision();
// Initialize settings // Initialize settings
settings_ = std::make_unique<config::Settings>(); settings_ = std::make_unique<config::Settings>();
@@ -525,7 +528,10 @@ bool App::init()
// hashrates and the installed miner version are shown as soon as the tab is opened, // hashrates and the installed miner version are shown as soon as the tab is opened,
// instead of only after the user clicks a mine / auto-balance button. // instead of only after the user clicks a mine / auto-balance button.
if (supportsPoolMining()) { if (supportsPoolMining()) {
daemon::XmrigManager::startVersionDetection(); // idempotent one-shot `xmrig --version` // Needs the final miner on disk: while the startup job may still be writing it, pollStartupFiles()
// starts this instead.
if (!startup_files_->isRunning())
daemon::XmrigManager::startVersionDetection(); // idempotent one-shot `xmrig --version`
pool_stats_service_.refresh(util::knownPools()); // snapshot lands on a later frame pool_stats_service_.refresh(util::knownPools()); // snapshot lands on a later frame
} }
@@ -842,6 +848,9 @@ void App::update()
// capture_mode_ is only set for the offline full sweep, so the live tab-only sweep is unaffected. // capture_mode_ is only set for the offline full sweep, so the live tab-only sweep is unaffected.
if (capture_mode_) return; if (capture_mode_) return;
// Reap the startup node-file job (failure toast, deferred daemon start, miner version probe).
pollStartupFiles();
// If a wallet switch's daemon failed to start, revert to the previous wallet (main-thread-safe). // If a wallet switch's daemon failed to start, revert to the previous wallet (main-thread-safe).
processWalletSwitchRevert(); processWalletSwitchRevert();
@@ -1845,6 +1854,10 @@ void App::render()
// (Pins current_page_ too, before the sidebar reads it further down.) // (Pins current_page_ too, before the sidebar reads it further down.)
updateScreenshotSweep(); updateScreenshotSweep();
// First-run node-file progress. Foreground draw list, so it shows over the wizard, the lock screen and
// the main UI alike (all of which early-return below).
renderStartupFilesChip();
// DragonX logo — load/recolor before the wizard/lock early-returns so every screen shows it. // DragonX logo — load/recolor before the wizard/lock early-returns so every screen shows it.
ensureLogoTexture(); ensureLogoTexture();
@@ -4294,6 +4307,160 @@ void App::renderBackupDialog()
m::EndOverlayDialog(); m::EndOverlayDialog();
} }
// ---------------------------------------------------------------------------------------------------
// Startup node files (C14). Windows release exes carry the Sapling params, asmap, the node and the miner
// inside the .exe and write them out on first run / after an upgrade (~88 MB); zip/AppImage/.app builds copy
// them from next to the executable; full-node builds then SHA-256 the params once (cached by marker). All of
// it used to run on the UI thread. It now runs on the "extract-node-files" async task.
// ---------------------------------------------------------------------------------------------------
void App::startStartupFilesProvision()
{
startup_files_ = std::make_shared<resources::StartupProvision>();
const bool embedded = resources::hasEmbeddedResources();
const bool fullNode = supportsEmbeddedDaemon(); // compile-time; Lite provisions only the miner
// Stat-only pre-check (a handful of stats + the .sapling_verified marker): a steady-state launch starts no
// thread and shows nothing.
bool needed = embedded && resources::needsParamsExtraction();
if (!needed && !embedded && fullNode) {
needed = !resources::planBundleCopy(resources::bundleSearchDirs(), resources::getDaemonDirectory(),
resources::nodeBundleFileNames(), /*replaceExisting=*/false).empty();
}
if (!needed && fullNode)
needed = rpc::Connection::saplingParamState() == rpc::Connection::SaplingParamState::NeedsHash;
if (!needed) { startup_files_->markNothingToDo(); return; }
DEBUG_LOGF("Startup: preparing node files in the background...\n");
startup_files_->begin();
auto st = startup_files_; // the job owns a reference and captures NO App state
auto job = [st, embedded, fullNode](const util::AsyncTaskManager::Token& token) {
// Every shutdown path calls async_tasks_.cancelAll() first, so the token is the only cancel source.
auto cancelled = [&token]() { return token.cancelled(); };
resources::StartupProvision::Result r;
try {
if (embedded) {
r.extractOk = resources::extractEmbeddedResources(&st->progress, cancelled);
} else if (fullNode) {
r.extractOk = resources::copyBundledFiles(resources::bundleSearchDirs(),
resources::getDaemonDirectory(),
resources::nodeBundleFileNames(),
/*replaceExisting=*/false, &st->progress, cancelled).ok;
}
// Verify even if some file failed (an antivirus-blocked miner must not skip the param check).
if (fullNode && !cancelled()) {
st->progress.setPhase(resources::ProvisionPhase::Verifying);
r.verifyOk = rpc::Connection::verifySaplingParams(); // hashes once, then writes the marker
if (!r.verifyOk && !embedded && !cancelled()) {
// A copied distribution whose daemon-dir param exists but is corrupt/truncated: the old code
// skipped existing files forever. Replace the params from the bundle and verify again.
st->paramRepairTried.store(true, std::memory_order_relaxed);
const auto rep = resources::copyBundledFiles(resources::bundleSearchDirs(),
resources::getDaemonDirectory(),
resources::saplingParamFileNames(),
/*replaceExisting=*/true, &st->progress, cancelled);
if (rep.copied > 0 && !cancelled()) {
st->progress.setPhase(resources::ProvisionPhase::Verifying);
r.verifyOk = rpc::Connection::verifySaplingParams();
}
}
}
} catch (const std::exception& e) {
DEBUG_LOGF("[ERROR] Startup file preparation threw: %s\n", e.what());
r.extractOk = false;
} catch (...) {
DEBUG_LOGF("[ERROR] Startup file preparation threw an unknown exception\n");
r.extractOk = false;
}
r.cancelled = token.cancelled();
st->finish(r); // ALWAYS: a job stuck in Running would defer every daemon start until shutdown
};
try {
async_tasks_.submit("extract-node-files", job);
} catch (const std::exception& e) {
// No thread could be created: do it in line, exactly as before this change (blocks once).
DEBUG_LOGF("[WARN] Startup: no background thread for file preparation (%s) - running it in line\n", e.what());
job(util::AsyncTaskManager::Token{});
}
}
void App::pollStartupFiles()
{
resources::StartupProvision::Result r;
if (!startup_files_ || !startup_files_->takeCompletion(&r)) return;
if (r.cancelled || shutting_down_) return;
DEBUG_LOGF("Startup: node files done (write=%d verify=%d)\n", r.extractOk ? 1 : 0, r.verifyOk ? 1 : 0);
// Deliberate improvement: the old code swallowed a write failure until the node refused to start. Only
// when the node files are unusable — a miner blocked by antivirus (common) must not nag every launch.
if (supportsEmbeddedDaemon() && !r.extractOk && !r.verifyOk)
ui::Notifications::instance().warning(TR("sb_daemon_extract_failed"), 20.0f);
// The miner version probe waits for the final miner on disk (deferred from init()).
if (supportsPoolMining()) daemon::XmrigManager::startVersionDetection();
// A start was requested while the files were being written (wizard pre-start / Done, first tryConnect, the
// connect MainCb): replay it — but never into a bootstrap download (which stops the node on purpose), an
// orchestrated restart, or an already-connected session. Every UI-thread requester also calls tryConnect().
if (daemon_start_after_files_) {
daemon_start_after_files_ = false;
const bool bootstrapping = bootstrap_downloading_ || wizard_phase_ == WizardPhase::BootstrapInProgress;
if (!bootstrapping && !daemon_restarting_ && !state_.connected) {
if (isUsingEmbeddedDaemon() && !isEmbeddedDaemonRunning()) startEmbeddedDaemon();
if (!connection_in_progress_) tryConnect();
} else if (getDaemonStatus() == TR("sb_waiting_daemon")) {
setDaemonStatus(std::string()); // the deferral's status, unless another flow replaced it since
}
}
}
void App::renderStartupFilesChip()
{
if (!startup_files_ || !startup_files_->isRunning()) return;
ui::material::RequestProgressFrame(); // keeps the percentage moving (~10 fps) while the job runs
const auto& S = ui::schema::UI();
auto val = [&S](const char* key, float fallback) {
return S.drawElement("components.startup-files-chip", key).sizeOr(fallback);
};
if (!resources::provisionChipVisible(true, startup_files_->elapsedSeconds(), val("show-delay-seconds", 0.4f)))
return; // a job that ends within the grace period never flashes a chip
namespace m = ui::material;
const auto& pr = startup_files_->progress;
const bool verifying = pr.getPhase() == resources::ProvisionPhase::Verifying;
const int pct = resources::provisionPercent(pr.bytesDone.load(std::memory_order_relaxed),
pr.bytesTotal.load(std::memory_order_relaxed));
char buf[160];
if (verifying) snprintf(buf, sizeof(buf), "%s", TR("startup_files_verifying"));
else snprintf(buf, sizeof(buf), TR("startup_files_preparing"), pct);
// Hand-drawn geometry: ui.toml values are logical px -> multiply by dpiScale(); font metrics already are.
const float dp = ui::Layout::dpiScale();
ImFont* f = m::Type().caption();
const float padX = val("padding-x", 14.0f) * dp, barH = val("bar-height", 3.0f) * dp;
const float barBottom = val("bar-bottom", 10.0f) * dp;
const float textW = f->CalcTextSizeA(f->LegacySize, FLT_MAX, 0.0f, buf).x;
ImGuiViewport* vp = ImGui::GetMainViewport();
const float w = std::min(std::max(val("width", 340.0f) * dp, textW + 2.0f * padX), vp->Size.x - 2.0f * padX);
const float h = val("height", 46.0f) * dp;
const float margin = val("bottom-margin", 38.0f) * dp, rounding = val("rounding", 10.0f) * dp;
const ImVec2 mn(vp->Pos.x + (vp->Size.x - w) * 0.5f, vp->Pos.y + vp->Size.y - h - margin);
const ImVec2 mx(mn.x + w, mn.y + h);
ImDrawList* dl = ImGui::GetForegroundDrawList();
dl->AddRectFilled(mn, mx, m::SurfaceAt(m::Elevation::Dp6), rounding); // opaque (Surface() is translucent)
dl->AddRect(mn, mx, m::WithAlpha(m::OnSurface(), 40), rounding, 0, 1.0f * dp);
const float textY = mn.y + (h - barBottom - barH - f->LegacySize) * 0.5f;
dl->AddText(f, f->LegacySize, ImVec2(mn.x + padX, textY), m::OnSurface(), buf);
const float bx0 = mn.x + padX, bx1 = mx.x - padX, by1 = mx.y - barBottom, by0 = by1 - barH;
dl->AddRectFilled(ImVec2(bx0, by0), ImVec2(bx1, by1), m::WithAlpha(m::OnSurface(), 30), barH * 0.5f);
float f0 = 0.0f, f1 = pct / 100.0f;
if (verifying) { // indeterminate: a sliding segment
const float t = std::fmod(static_cast<float>(ImGui::GetTime()) * 0.8f, 1.0f);
f0 = std::max(0.0f, t - 0.15f);
f1 = std::min(1.0f, t + 0.15f);
}
if (f1 > f0)
dl->AddRectFilled(ImVec2(bx0 + (bx1 - bx0) * f0, by0), ImVec2(bx0 + (bx1 - bx0) * f1, by1),
m::Primary(), barH * 0.5f);
}
void App::maybeOfferDaemonUpdate() void App::maybeOfferDaemonUpdate()
{ {
if (!supportsFullNodeLifecycleActions()) return; // lite / no embedded-daemon build if (!supportsFullNodeLifecycleActions()) return; // lite / no embedded-daemon build
@@ -5550,6 +5717,31 @@ bool App::startEmbeddedDaemon()
return false; return false;
} }
// The startup job may still be writing/copying/verifying the node files. Never launch the node over
// half-written files and never block the UI thread on the job: on the UI thread remember the request
// (pollStartupFiles() replays it) and report true ("starting") so every caller keeps its retry path
// instead of raising a failure — NOTE: true here does NOT mean a process was launched; on a worker,
// wait for the job (ends at shutdown).
if (startup_files_) {
switch (resources::daemonStartGate(startup_files_->isRunning(),
std::this_thread::get_id() == ui_thread_id_)) {
case resources::DaemonStartGate::Defer:
setDaemonStatus(TR("sb_waiting_daemon")); // neutral: the job may be a copy / verify / miner refresh
daemon_start_after_files_ = true;
return true;
case resources::DaemonStartGate::Wait:
setDaemonStatus(TR("sb_waiting_daemon"));
while (!startup_files_->waitUntilDone(100)) {
if (shutting_down_) return false;
}
// The job ends BECAUSE of shutdown (cancelAll): don't fall into the uncancellable extraction/launch.
if (shutting_down_ || startup_files_->endedCancelled()) return false;
break;
case resources::DaemonStartGate::Proceed:
break;
}
}
// Check if Sapling params exist - try extracting if embedded // Check if Sapling params exist - try extracting if embedded
if (!rpc::Connection::verifySaplingParams()) { if (!rpc::Connection::verifySaplingParams()) {
DEBUG_LOGF("Sapling params not found, checking for embedded resources...\n"); DEBUG_LOGF("Sapling params not found, checking for embedded resources...\n");
@@ -5573,48 +5765,28 @@ bool App::startEmbeddedDaemon()
} }
DEBUG_LOGF("Sapling params extracted successfully\n"); DEBUG_LOGF("Sapling params extracted successfully\n");
} else { } else {
// Fallback: check for params bundled alongside the executable // Fallback: params bundled alongside the executable (zip / AppImage / .app). The startup job
// (zip distributions bundle sapling-*.params next to the binary) // normally did this already; this repairs a failed or corrupt copy. replaceExisting: we only get
namespace fs = std::filesystem; // here because verification FAILED, so an existing daemon-dir param is suspect (the old
std::string exe_dir = util::Platform::getExecutableDirectory(); // `if (exists) continue` kept a truncated param forever).
std::string daemon_dir = resources::getDaemonDirectory(); const std::string daemon_dir = resources::getDaemonDirectory();
const char* paramFiles[] = { "sapling-spend.params", "sapling-output.params", "asmap.dat" }; if (!util::Platform::getExecutableDirectory().empty()) {
bool copied = false;
if (!exe_dir.empty()) {
std::string dirErr; std::string dirErr;
if (!util::Platform::ensureDirectory(daemon_dir, &dirErr)) { if (!util::Platform::ensureDirectory(daemon_dir, &dirErr)) {
setDaemonStatus(dirErr); setDaemonStatus(dirErr);
DEBUG_LOGF("[ERROR] %s\n", dirErr.c_str()); DEBUG_LOGF("[ERROR] %s\n", dirErr.c_str());
return false; return false;
} }
std::error_code ec;
// On macOS .app bundles, params are in Contents/Resources/
// while the executable is in Contents/MacOS/
std::vector<std::string> searchDirs = { exe_dir };
#ifdef __APPLE__
{
fs::path resourcesDir = fs::path(exe_dir).parent_path() / "Resources";
if (fs::is_directory(resourcesDir))
searchDirs.insert(searchDirs.begin(), resourcesDir.string());
}
#endif
for (const char* name : paramFiles) {
fs::path dst = fs::path(daemon_dir) / name;
if (fs::exists(dst)) continue;
for (const auto& dir : searchDirs) {
fs::path src = fs::path(dir) / name;
if (fs::exists(src)) {
DEBUG_LOGF("Copying bundled %s from %s to %s\n", name, dir.c_str(), daemon_dir.c_str());
fs::copy_file(src, dst, ec);
if (!ec) copied = true;
break;
}
}
}
} }
if (copied && rpc::Connection::verifySaplingParams()) { // At most once per session (the startup job or an earlier start may have tried it): a still-failing
// verify must not repeat this ~51.5 MB copy on every 5 s connect retry on the UI thread.
bool repaired = false;
if (!(startup_files_ && startup_files_->paramRepairTried.exchange(true))) {
const auto rep = resources::copyBundledFiles(resources::bundleSearchDirs(), daemon_dir,
resources::saplingParamFileNames(), /*replaceExisting=*/true);
repaired = rep.copied > 0 && rpc::Connection::verifySaplingParams();
}
if (repaired) {
DEBUG_LOGF("Sapling params copied from exe directory successfully\n"); DEBUG_LOGF("Sapling params copied from exe directory successfully\n");
} else { } else {
setDaemonStatus(TR("sb_sapling_not_found")); setDaemonStatus(TR("sb_sapling_not_found"));
@@ -5625,49 +5797,22 @@ bool App::startEmbeddedDaemon()
} }
} }
// Ensure asmap.dat and daemon binaries are copied from the bundle // Ensure asmap.dat and the daemon binaries are copied from the bundle to the daemon directory even if the
// to the daemon directory even if sapling params already existed. // Sapling params already existed (stat-only no-op when present; the startup job normally did it). An absent
// source is optional; only a real copy error counts.
{ {
namespace fs = std::filesystem; const std::string daemon_dir = resources::getDaemonDirectory();
std::string exe_dir = util::Platform::getExecutableDirectory(); if (!util::Platform::getExecutableDirectory().empty()) {
std::string daemon_dir = resources::getDaemonDirectory();
if (!exe_dir.empty()) {
std::string dirErr; std::string dirErr;
if (!util::Platform::ensureDirectory(daemon_dir, &dirErr)) { if (!util::Platform::ensureDirectory(daemon_dir, &dirErr)) {
setDaemonStatus(dirErr); setDaemonStatus(dirErr);
DEBUG_LOGF("[ERROR] %s\n", dirErr.c_str()); DEBUG_LOGF("[ERROR] %s\n", dirErr.c_str());
return false; return false;
} }
std::error_code ec; static const std::vector<std::string> kExtraFiles = { "asmap.dat", "dragonxd", "dragonx-cli", "dragonx-tx" };
const auto rep = resources::copyBundledFiles(resources::bundleSearchDirs(), daemon_dir, kExtraFiles,
std::vector<std::string> searchDirs = { exe_dir }; /*replaceExisting=*/false);
#ifdef __APPLE__ if (!rep.ok) {
{
fs::path resourcesDir = fs::path(exe_dir).parent_path() / "Resources";
if (fs::is_directory(resourcesDir))
searchDirs.insert(searchDirs.begin(), resourcesDir.string());
}
#endif
const char* extraFiles[] = { "asmap.dat", "dragonxd", "dragonx-cli", "dragonx-tx" };
bool copyFailed = false;
for (const char* name : extraFiles) {
fs::path dst = fs::path(daemon_dir) / name;
if (fs::exists(dst)) continue;
for (const auto& dir : searchDirs) {
fs::path src = fs::path(dir) / name;
if (fs::exists(src)) { // an absent source is optional; only a real copy error counts
DEBUG_LOGF("Copying bundled %s from %s to %s\n", name, dir.c_str(), daemon_dir.c_str());
fs::copy_file(src, dst, ec);
if (ec) {
DEBUG_LOGF("[ERROR] Failed to copy %s: %s\n", name, ec.message().c_str());
copyFailed = true;
ec.clear();
}
break;
}
}
}
if (copyFailed) {
char buf[512]; char buf[512];
snprintf(buf, sizeof(buf), TR("sb_daemon_files_failed"), daemon_dir.c_str()); snprintf(buf, sizeof(buf), TR("sb_daemon_files_failed"), daemon_dir.c_str());
setDaemonStatus(buf); setDaemonStatus(buf);
@@ -6118,42 +6263,58 @@ void App::reinstallBundledDaemon()
ui::Notifications::instance().warning(TR("appx_daemon_reinstall_in_progress")); ui::Notifications::instance().warning(TR("appx_daemon_reinstall_in_progress"));
return; return;
} }
// Don't race a wallet switch / seed-adopt / encryption restart, which drive their own daemon stop/start.
if (daemon_restarting_) {
ui::Notifications::instance().warning(TR("appx_node_busy_restarting"));
return;
}
if (refuseIfNodeBusy()) return; if (refuseIfNodeBusy()) return;
DEBUG_LOGF("[App] Reinstalling bundled daemon binary — stopping daemon first\n"); DEBUG_LOGF("[App] Reinstalling bundled daemon binary — stopping daemon first\n");
ui::Notifications::instance().info(TR("appx_installing_bundled_daemon")); ui::Notifications::instance().info(TR("appx_installing_bundled_daemon"));
async_tasks_.submit("reinstall-daemon", [this](const util::AsyncTaskManager::Token& token) { // This task owns the node for its whole run: it starts the node itself once the new files are in place, so
AppDaemonLifecycleRuntime runtime(*this); // drop a start deferred behind the startup files job and gate tryConnect() + the pollStartupFiles() replay
daemon::AsyncLifecycleTaskContext ctx(token, shutting_down_); // (either could otherwise launch the OLD binary while the new one is being written).
// Stop the running node so its binary is free: ours, or the external/leftover dragonxd we're attached daemon_start_after_files_ = false;
// to (it would keep the RPC port and the fresh daemon couldn't start). Bounded, never killed. daemon_restarting_ = true;
stopEmbeddedDaemon(StopMode::Background, StopScope::Connected); try {
// Wait (bounded, ~120s) for the old node's PROCESS to exit, not just its RPC port: dragonxd unbinds async_tasks_.submit("reinstall-daemon", [this](const util::AsyncTaskManager::Token& token) {
// RPC first and can take a minute or more to flush and close its wallet. Until it exits its exe is struct Rearm { std::atomic<bool>& f; ~Rearm() { f = false; } } rearm{daemon_restarting_}; // every exit
// locked on Windows (the overwrite would fail) and it holds the datadir lock (a restart would fail). AppDaemonLifecycleRuntime runtime(*this);
auto stillUp = [] { daemon::AsyncLifecycleTaskContext ctx(token, shutting_down_);
return daemon::EmbeddedDaemon::isRpcPortInUse() || daemon::EmbeddedDaemon::isDaemonProcessRunning(); // Stop the running node so its binary is free: ours, or the external/leftover dragonxd we're attached
}; // to (it would keep the RPC port and the fresh daemon couldn't start). Bounded, never killed.
for (int i = 0; i < 1200 && stillUp() && !ctx.cancelled() && !ctx.shuttingDown(); ++i) { stopEmbeddedDaemon(StopMode::Background, StopScope::Connected);
ctx.sleepForMs(100); // Wait (bounded, ~120s) for the old node's PROCESS to exit, not just its RPC port: dragonxd unbinds
} // RPC first and can take a minute or more to flush and close its wallet. Until it exits its exe is
if (ctx.cancelled() || ctx.shuttingDown()) return; // locked on Windows (the overwrite would fail) and it holds the datadir lock (a restart would fail).
if (stillUp()) { auto stillUp = [] {
DEBUG_LOGF("[App] Reinstall bundled daemon: aborted — a dragonxd process is still running; " return daemon::EmbeddedDaemon::isRpcPortInUse() || daemon::EmbeddedDaemon::isDaemonProcessRunning();
"binaries left untouched and no new node started\n"); };
pending_reinstall_node_busy_.store(true, std::memory_order_relaxed); // told on the UI thread for (int i = 0; i < 1200 && stillUp() && !ctx.cancelled() && !ctx.shuttingDown(); ++i) {
return; ctx.sleepForMs(100);
} }
// Overwrite the installed dragonx binaries (dragonxd/cli/tx) with the bundled ones. if (ctx.cancelled() || ctx.shuttingDown()) return;
const bool ok = resources::reextractBundledDaemon(); if (stillUp()) {
DEBUG_LOGF("[App] Reinstall bundled daemon: %s\n", ok ? "ok" : "FAILED"); DEBUG_LOGF("[App] Reinstall bundled daemon: aborted — a dragonxd process is still running; "
// Restart on the freshly-installed binary. "binaries left untouched and no new node started\n");
runtime.resetOutputOffset(); pending_reinstall_node_busy_.store(true, std::memory_order_relaxed); // told on the UI thread
if (!ctx.cancelled() && !ctx.shuttingDown()) { return;
runtime.startDaemon(); }
} // Overwrite the installed dragonx binaries (dragonxd/cli/tx) with the bundled ones.
}); const bool ok = resources::reextractBundledDaemon();
DEBUG_LOGF("[App] Reinstall bundled daemon: %s\n", ok ? "ok" : "FAILED");
// Restart on the freshly-installed binary.
runtime.resetOutputOffset();
if (!ctx.cancelled() && !ctx.shuttingDown()) {
runtime.startDaemon();
}
});
} catch (...) {
daemon_restarting_ = false; // no task ran: re-arm the reconnect gate
throw;
}
} }
void App::deleteBlockchainData() void App::deleteBlockchainData()
@@ -6199,6 +6360,9 @@ void App::deleteBlockchainData()
bool App::stopDaemonForBootstrap() bool App::stopDaemonForBootstrap()
{ {
// A bootstrap download stops the node on purpose: drop a start deferred while the startup files were
// being prepared, or pollStartupFiles() would launch the node into the half-extracted chain data.
daemon_start_after_files_ = false;
if (!supportsFullNodeLifecycleActions()) { if (!supportsFullNodeLifecycleActions()) {
return false; return false;
} }
@@ -7548,7 +7712,8 @@ bool App::hasPendingBackgroundResults() const {
return (worker_ && worker_->hasPendingResults()) return (worker_ && worker_->hasPendingResults())
|| (fast_worker_ && fast_worker_->hasPendingResults()) || (fast_worker_ && fast_worker_->hasPendingResults())
|| (http_worker_ && http_worker_->hasPendingResults()) // g3: market-data GETs || (http_worker_ && http_worker_->hasPendingResults()) // g3: market-data GETs
|| (lite_wallet_ && lite_wallet_->hasWalletOpCompletions()); // g1: lite wallet-op lane || (lite_wallet_ && lite_wallet_->hasWalletOpCompletions()) // g1: lite wallet-op lane
|| (startup_files_ && startup_files_->hasCompletion()); // g6: startup node files
} }
std::string App::transactionSendProgressText() const std::string App::transactionSendProgressText() const
+14 -3
View File
@@ -38,6 +38,7 @@
#include "services/wallet_security_controller.h" #include "services/wallet_security_controller.h"
#include "services/wallet_security_workflow.h" #include "services/wallet_security_workflow.h"
#include "util/async_task_manager.h" #include "util/async_task_manager.h"
#include "resources/provision_state.h"
#include "util/background_sampler.h" #include "util/background_sampler.h"
#include "util/coalescing_writer.h" #include "util/coalescing_writer.h"
#include "util/fetch_backoff.h" #include "util/fetch_backoff.h"
@@ -768,9 +769,10 @@ public:
void verifyDebugCredential(const std::string& secret, std::function<void(bool)> cb); void verifyDebugCredential(const std::string& secret, std::function<void(bool)> cb);
/// @brief Something finished on a background thread and waits for update() to apply it on the main thread /// @brief Something finished on a background thread and waits for update() to apply it on the main thread
/// (the RPC worker lanes, the HTTP lane, lite wallet ops). main.cpp's idle loop polls this every <= 200 ms /// (the RPC worker lanes, the HTTP lane, lite wallet ops, the startup node-file job). main.cpp's idle loop
/// (hasImmediateWork) and renders a frame when it is true, so the result shows without input, even while the /// polls this every <= 200 ms (hasImmediateWork) and renders a frame when it is true, so the result shows
/// window is hidden. A new lane whose results are applied in update() adds its term HERE. /// without input, even while the window is hidden. A new lane whose results are applied in update() adds
/// its term HERE.
bool hasPendingBackgroundResults() const; bool hasPendingBackgroundResults() const;
bool hasTransactionSendProgress() const { return send_progress_active_ || send_submissions_in_flight_ > 0 || !pending_opids_.empty(); } bool hasTransactionSendProgress() const { return send_progress_active_ || send_submissions_in_flight_ > 0 || !pending_opids_.empty(); }
std::string transactionSendProgressText() const; std::string transactionSendProgressText() const;
@@ -1191,6 +1193,15 @@ private:
std::chrono::steady_clock::time_point fetchedAt{}; std::chrono::steady_clock::time_point fetchedAt{};
}; };
std::unordered_map<std::string, ExchangeChartCache> exchange_chart_cache_; std::unordered_map<std::string, ExchangeChartCache> exchange_chart_cache_;
// Startup node files (Windows: extract the bundled params/node/miner; Linux/macOS: copy them from the exe
// dir; full node: then verify the Sapling params) on the "extract-node-files" async task. Declared BEFORE
// async_tasks_ so it outlives the task threads if the App is destroyed without shutdown().
std::shared_ptr<resources::StartupProvision> startup_files_;
std::thread::id ui_thread_id_{}; // set first thing in init(); startEmbeddedDaemon() never blocks it
bool daemon_start_after_files_ = false; // UI thread only: a daemon start was deferred while the job ran
void startStartupFilesProvision();
void pollStartupFiles(); // UI thread, every update()
void renderStartupFilesChip(); // UI thread, top of render()
util::AsyncTaskManager async_tasks_; util::AsyncTaskManager async_tasks_;
bool pending_antivirus_dialog_ = false; // Show Windows Defender help dialog bool pending_antivirus_dialog_ = false; // Show Windows Defender help dialog
+16 -1
View File
@@ -1647,6 +1647,7 @@ void App::switchToWallet(const std::string& walletFile, bool stopDaemonConfirmed
// stopDaemonForWalletSwitch() wait already ensured the old node's process is gone, so a valid // stopDaemonForWalletSwitch() wait already ensured the old node's process is gone, so a valid
// wallet opens cleanly; a bad/corrupt wallet exits during init and we revert. // wallet opens cleanly; a bad/corrupt wallet exits during init and we revert.
ok = shutting_down_ ? true : startEmbeddedDaemon(); ok = shutting_down_ ? true : startEmbeddedDaemon();
if (shutting_down_) ok = true; // a start aborted BY shutdown is not a failed switch (no revert)
// A missing/corrupt wallet makes dragonxd exit during init — confirm the process survived a // A missing/corrupt wallet makes dragonxd exit during init — confirm the process survived a
// moment; if not, the wallet is bad. (A valid launch keeps the process alive while it syncs.) // moment; if not, the wallet is bad. (A valid launch keeps the process alive while it syncs.)
if (ok && !shutting_down_) { if (ok && !shutting_down_) {
@@ -3511,7 +3512,8 @@ void App::postPoolMinerJob(int threads, bool retargetOnly)
daemon::XmrigManager::Config cfgCopy = cfg; daemon::XmrigManager::Config cfgCopy = cfg;
const int target = cfg.threads; const int target = cfg.threads;
pool_threads_target_.store(target, std::memory_order_relaxed); pool_threads_target_.store(target, std::memory_order_relaxed);
postMiningControl([this, cfgCopy, target, retargetOnly]() { auto files = startup_files_; // shared_ptr copy (set once in init()): the startup node-files job
postMiningControl([this, cfgCopy, target, retargetOnly, files]() {
// A retarget is decided HERE, after every earlier start/stop ran: nothing happens unless a miner is // A retarget is decided HERE, after every earlier start/stop ran: nothing happens unless a miner is
// running now and at another count. So it never starts a miner the user stopped, and a restart's // running now and at another count. So it never starts a miner the user stopped, and a restart's
// stop window (isRunning() false on the UI thread) can't hide the running one. // stop window (isRunning() false on the UI thread) can't hide the running one.
@@ -3520,6 +3522,19 @@ void App::postPoolMinerJob(int threads, bool retargetOnly)
if (xmrig_manager_->getRequestedThreads() == target) return; if (xmrig_manager_->getRequestedThreads() == target) return;
} }
if (xmrig_manager_->isRunning()) xmrig_manager_->stop(3000); if (xmrig_manager_->isRunning()) xmrig_manager_->stop(3000);
// The startup job may still be replacing the miner binary: never launch the old one under it. Waiting
// here (the mining-control thread) never blocks the UI; shutdown, or the control thread being stopped
// (shutdown()'s fallback path joins it before cancelling the job), ends the wait.
if (files) {
auto stopping = [this]() {
if (shutting_down_) return true;
std::lock_guard<std::mutex> lk(mining_ctl_mutex_);
return mining_ctl_stop_;
};
bool abort = false;
while (!abort && !files->waitUntilDone(100)) abort = stopping();
if (abort || shutting_down_ || files->endedCancelled()) { clearPoolThreadsTarget(target); return; }
}
const bool ok = xmrig_manager_->start(cfgCopy); const bool ok = xmrig_manager_->start(cfgCopy);
const std::string err = ok ? std::string() : xmrig_manager_->getLastError(); const std::string err = ok ? std::string() : xmrig_manager_->getLastError();
if (!ok) clearPoolThreadsTarget(target); if (!ok) clearPoolThreadsTarget(target);
+436 -242
View File
@@ -1,10 +1,15 @@
#include "embedded_resources.h" #include "embedded_resources.h"
#include "../util/platform.h" #include "../util/platform.h"
#include "../util/logger.h" #include "../util/logger.h"
#include <algorithm>
#include <cerrno>
#include <fstream> #include <fstream>
#include <filesystem> #include <filesystem>
#include <functional>
#include <mutex>
#include <vector> #include <vector>
#include <cstdio> #include <cstdio>
#include <cstring>
#include <cctype> #include <cctype>
#include <chrono> #include <chrono>
@@ -12,6 +17,7 @@
#include <windows.h> #include <windows.h>
#include <shlobj.h> #include <shlobj.h>
#else #else
#include <fcntl.h>
#include <unistd.h> #include <unistd.h>
#include <pwd.h> #include <pwd.h>
#include <sys/types.h> #include <sys/types.h>
@@ -205,272 +211,461 @@ std::string getParamsDirectory()
#endif #endif
} }
// Forward declaration — defined below extractResource() // ---------------------------------------------------------------------------------------------------
static bool resourceNeedsUpdate(const EmbeddedResource* res, const std::string& destPath); // Writing into the daemon directory: one lock, atomic (temp + flush + rename) files.
// ---------------------------------------------------------------------------------------------------
namespace {
bool needsParamsExtraction() // Every writer of the daemon directory (startup job, getDaemonPath(), getXmrigPath(), forceExtractXmrig(),
// reextractBundledDaemon(), ensureWalletRebuildHelperExtracted(), copyBundledFiles()) holds this for its whole
// write; whoever comes second finds the files complete and does nothing. Never taken re-entrantly. Lock order:
// provisionMutex -> Logger mutex (DEBUG_LOGF) only. The in-app updaters do not take it (see residual risks).
std::mutex& provisionMutex() { static std::mutex m; return m; }
struct ProvisionEntry {
const char* name;
bool refreshOnSizeMismatch; // re-extract when the bundled size differs (params, asmap, xmrig)
bool executable;
bool fullNodeOnly; // not written by Lite builds (the lite backend bakes in its own params)
bool startupTrigger; // makes needsParamsExtraction() true
};
// The same files and rules as the old hand-written extraction: params/asmap/xmrig follow the bundled size;
// the node binaries and the rebuild helper are placed only when MISSING (the user may run a specific dragonxd;
// replacing it is the explicit Settings action). asmap/cli/tx/rebuild are written along, never a trigger.
const ProvisionEntry kProvisionEntries[] = {
{ RESOURCE_SAPLING_SPEND, true, false, true, true },
{ RESOURCE_SAPLING_OUTPUT, true, false, true, true },
{ RESOURCE_ASMAP, true, false, true, false },
#ifdef HAS_EMBEDDED_DAEMON
{ RESOURCE_DRAGONXD, false, true, true, true },
{ RESOURCE_DRAGONX_CLI, false, true, true, false },
{ RESOURCE_DRAGONX_TX, false, true, true, false },
#endif
#ifdef HAS_EMBEDDED_XMRIG
{ RESOURCE_XMRIG, true, true, false, true },
#endif
#ifdef HAS_EMBEDDED_WALLET_REBUILD
{ RESOURCE_DRAGONX_WALLET_REBUILD, false, true, true, false },
#endif
};
bool entryActive(const ProvisionEntry& e)
{ {
if (!hasEmbeddedResources()) { #if defined(DRAGONX_LITE_BUILD) && DRAGONX_LITE_BUILD
return !e.fullNodeOnly;
#else
(void)e;
return true;
#endif
}
std::string joinDir(const std::string& dir, const char* name)
{
#ifdef _WIN32
return dir + "\\" + name;
#else
return dir + "/" + name;
#endif
}
constexpr std::size_t kCopyChunk = 4u * 1024u * 1024u;
bool prepareParentDir(const std::string& destPath)
{
const std::filesystem::path dest(destPath);
if (!dest.has_parent_path()) return true;
std::error_code ec;
std::filesystem::create_directories(dest.parent_path(), ec);
if (ec) {
DEBUG_LOGF("[ERROR] Failed to create directory %s: %s\n", dest.parent_path().string().c_str(),
ec.message().c_str());
return false; return false;
} }
return true;
// Check daemon directory (dragonx/) — the only extraction target }
std::string daemonDir = getDaemonDirectory();
// Temp-file sink for writeFileAtomic / copyFileAtomic. Windows: ONE CreateFileA handle with no SECURITY_ATTRIBUTES,
// i.e. NOT inheritable: std::ofstream's CRT handle is, and a child created with bInheritHandles=TRUE meanwhile
// (xmrig, runHiddenCapture) would hold "<name>.partial" without FILE_SHARE_DELETE, so the MoveFileEx in
// replaceFromTemp fails while it runs (same fix as Platform::writeFileAtomically). commit() flushes on the same
// handle (FlushFileBuffers / fsync) before close, so the bytes are on disk before the rename. POSIX: O_CLOEXEC.
class TempSink {
public:
explicit TempSink(const std::string& path)
{
#ifdef _WIN32 #ifdef _WIN32
const char pathSep = '\\'; h_ = CreateFileA(path.c_str(), GENERIC_WRITE, 0, nullptr, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr);
#else #else
const char pathSep = '/'; fd_ = ::open(path.c_str(), O_WRONLY | O_CREAT | O_TRUNC | O_CLOEXEC, 0644);
#endif
std::string spendPath = daemonDir + pathSep + RESOURCE_SAPLING_SPEND;
std::string outputPath = daemonDir + pathSep + RESOURCE_SAPLING_OUTPUT;
// Check if params are missing or stale (size mismatch → updated in newer build)
const auto* spendRes = getEmbeddedResource(RESOURCE_SAPLING_SPEND);
const auto* outputRes = getEmbeddedResource(RESOURCE_SAPLING_OUTPUT);
if (spendRes && resourceNeedsUpdate(spendRes, spendPath)) return true;
if (outputRes && resourceNeedsUpdate(outputRes, outputPath)) return true;
// Daemon binaries are only auto-placed when MISSING (never auto-overwritten on a size
// mismatch) — the user may be running a specific dragonxd. Replacing the bundled daemon is
// an explicit action via Settings → daemon binary. So only trigger extraction if it's absent.
#ifdef HAS_EMBEDDED_DAEMON
const auto* daemonRes = getEmbeddedResource(RESOURCE_DRAGONXD);
std::string daemonPath = daemonDir + pathSep + RESOURCE_DRAGONXD;
if (daemonRes && !std::filesystem::exists(daemonPath)) return true;
#endif #endif
}
~TempSink() { (void)close(); }
TempSink(const TempSink&) = delete;
TempSink& operator=(const TempSink&) = delete;
#ifdef HAS_EMBEDDED_XMRIG bool ok() const
const auto* xmrigRes = getEmbeddedResource(RESOURCE_XMRIG); {
std::string xmrigPath = daemonDir + pathSep + RESOURCE_XMRIG; #ifdef _WIN32
if (xmrigRes && resourceNeedsUpdate(xmrigRes, xmrigPath)) return true; return h_ != INVALID_HANDLE_VALUE;
#else
return fd_ >= 0;
#endif #endif
}
bool write(const void* data, std::size_t n)
{
const char* p = static_cast<const char*>(data);
while (n > 0) {
#ifdef _WIN32
const DWORD chunk = static_cast<DWORD>(n > (1u << 30) ? (1u << 30) : n);
DWORD w = 0;
if (!WriteFile(h_, p, chunk, &w, nullptr) || w == 0) return false;
#else
const ssize_t w = ::write(fd_, p, n);
if (w < 0) { if (errno == EINTR) continue; return false; }
if (w == 0) return false;
#endif
p += w;
n -= static_cast<std::size_t>(w);
}
return true;
}
// Flush to stable storage, then close. False on any error.
bool commit()
{
bool good = ok();
#ifdef _WIN32
if (good && !FlushFileBuffers(h_)) good = false;
#else
if (good && ::fsync(fd_) != 0) good = false;
#endif
if (!close()) good = false;
return good;
}
private:
bool close()
{
#ifdef _WIN32
if (h_ == INVALID_HANDLE_VALUE) return true;
const bool r = CloseHandle(h_) != 0;
h_ = INVALID_HANDLE_VALUE;
return r;
#else
if (fd_ < 0) return true;
const bool r = ::close(fd_) == 0;
fd_ = -1;
return r;
#endif
}
#ifdef _WIN32
HANDLE h_ = INVALID_HANDLE_VALUE;
#else
int fd_ = -1;
#endif
};
// Move `tmp` over `dest`. POSIX: rename() replaces atomically (even a running binary) + best-effort dir fsync.
// Windows (same primitive as Platform::writeFileAtomically): MoveFileEx REPLACE_EXISTING|WRITE_THROUGH. A
// running .exe cannot be replaced but can be renamed, so it is moved aside to ".old" once (the running process
// keeps it; cleaned on a later run). A just-closed file is often held for a moment by antivirus (sharing
// violation), so retry for up to ~2 s, but stop at once if the temp vanished (quarantined). On failure the
// temp is removed and a moved-aside original is put back.
bool replaceFromTemp(const std::string& tmp, const std::string& dest)
{
#ifdef _WIN32
const std::string aside = dest + ".old";
bool movedAside = false;
DWORD err = 0;
for (int attempt = 0; attempt < 20; ++attempt) {
if (MoveFileExA(tmp.c_str(), dest.c_str(), MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH))
return true;
err = GetLastError();
if (GetFileAttributesA(tmp.c_str()) == INVALID_FILE_ATTRIBUTES) break; // temp gone (AV quarantine)
if (!movedAside && GetFileAttributesA(dest.c_str()) != INVALID_FILE_ATTRIBUTES) {
DeleteFileA(aside.c_str());
if (MoveFileExA(dest.c_str(), aside.c_str(), MOVEFILE_REPLACE_EXISTING)) {
movedAside = true;
DEBUG_LOGF("[INFO] %s is in use - moved the old copy to .old\n", dest.c_str());
continue;
}
}
Sleep(100);
}
DEBUG_LOGF("[ERROR] Could not move %s into place (Win32 error %lu)\n", dest.c_str(), (unsigned long)err);
DeleteFileA(tmp.c_str());
if (movedAside) MoveFileExA(aside.c_str(), dest.c_str(), MOVEFILE_REPLACE_EXISTING);
return false; return false;
#else
if (::rename(tmp.c_str(), dest.c_str()) != 0) {
DEBUG_LOGF("[ERROR] Could not move %s into place: %s\n", dest.c_str(), std::strerror(errno));
::remove(tmp.c_str());
return false;
}
const std::string dir = std::filesystem::path(dest).parent_path().string();
const int dfd = ::open(dir.empty() ? "." : dir.c_str(), O_RDONLY | O_CLOEXEC);
if (dfd >= 0) { ::fsync(dfd); ::close(dfd); }
return true;
#endif
}
} // namespace
bool resourceNeedsWrite(std::uint64_t embeddedSize, bool refreshOnSizeMismatch, const std::string& destPath)
{
if (embeddedSize == 0) return false;
std::error_code ec;
if (!std::filesystem::exists(destPath, ec)) return true;
if (!refreshOnSizeMismatch) return false;
const auto diskSize = std::filesystem::file_size(destPath, ec);
if (ec) return true; // can't stat -> rewrite
return static_cast<std::uint64_t>(diskSize) != embeddedSize;
} }
// Check if an on-disk file is missing or differs in size from the embedded resource. // Check if an on-disk file is missing or differs in size from the embedded resource.
// A size mismatch means the binary was updated in a newer wallet build. // A size mismatch means the binary was updated in a newer wallet build.
static bool resourceNeedsUpdate(const EmbeddedResource* res, const std::string& destPath) [[maybe_unused]] static bool resourceNeedsUpdate(const EmbeddedResource* res, const std::string& destPath)
{ {
if (!res || !res->data || res->size == 0) return false; if (!res || !res->data || res->size == 0) return false;
if (!std::filesystem::exists(destPath)) return true; return resourceNeedsWrite(res->size, /*refreshOnSizeMismatch=*/true, destPath);
std::error_code ec;
auto diskSize = std::filesystem::file_size(destPath, ec);
if (ec) return true; // can't stat → re-extract
return diskSize != static_cast<std::uintmax_t>(res->size);
} }
static bool extractResource(const EmbeddedResource* res, const std::string& destPath) bool needsParamsExtraction()
{ {
if (!res || !res->data || res->size == 0) { if (!hasEmbeddedResources()) return false;
return false; const std::string dir = getDaemonDirectory(); // the only extraction target
for (const auto& e : kProvisionEntries) {
if (!entryActive(e) || !e.startupTrigger) continue;
const EmbeddedResource* res = getEmbeddedResource(e.name);
if (res && resourceNeedsWrite(res->size, e.refreshOnSizeMismatch, joinDir(dir, e.name))) return true;
} }
return false;
// Create parent directories }
std::filesystem::path path(destPath);
if (path.has_parent_path()) { bool writeFileAtomic(const std::string& destPath, const std::uint8_t* data, std::size_t size, bool executable,
std::error_code ec; ProvisionProgress* progress, const CancelFn& cancelled)
std::filesystem::create_directories(path.parent_path(), ec); {
if (ec) { if (!data || size == 0) return false;
DEBUG_LOGF("[ERROR] Failed to create directory %s: %s\n", if (!prepareParentDir(destPath)) return false;
path.parent_path().string().c_str(), ec.message().c_str()); const std::string tmp = destPath + kPartialFileSuffix;
return false; std::error_code ec;
} bool good = true;
} {
TempSink f(tmp);
// Write file if (!f.ok()) { DEBUG_LOGF("[ERROR] Failed to open %s for writing\n", tmp.c_str()); return false; }
std::ofstream file(destPath, std::ios::binary); for (std::size_t off = 0; off < size; off += kCopyChunk) {
if (!file) { if (cancelled && cancelled()) { good = false; break; }
// The destination may be locked because the previous daemon is still using the binary: const std::size_t n = std::min(kCopyChunk, size - off);
// Windows locks a running .exe against truncation, Linux returns ETXTBSY. Both platforms if (!f.write(data + off, n)) {
// DO allow renaming/moving such a file — the running process keeps the moved copy — so move DEBUG_LOGF("[ERROR] Failed to write %zu bytes to %s\n", n, tmp.c_str());
// the stale binary aside and write a fresh one at the original path. good = false; break;
std::error_code ec;
if (std::filesystem::exists(destPath)) {
std::string sidelined = destPath + ".old";
std::filesystem::remove(sidelined, ec); // clear any leftover from a prior swap
ec.clear();
std::filesystem::rename(destPath, sidelined, ec);
if (!ec) {
file.clear();
file.open(destPath, std::ios::binary);
if (file)
DEBUG_LOGF("[INFO] Replaced in-use %s (old copy moved to .old)\n", destPath.c_str());
} else {
DEBUG_LOGF("[WARN] Could not move stale %s aside: %s\n",
destPath.c_str(), ec.message().c_str());
} }
if (progress) progress->bytesDone.fetch_add(n, std::memory_order_relaxed);
} }
if (!file) { // Flush on the same handle BEFORE the rename: a power cut can't leave a truncated file under the final name.
DEBUG_LOGF("[ERROR] Failed to open %s for writing\n", destPath.c_str()); if (good && !f.commit()) { DEBUG_LOGF("[ERROR] Failed to finish writing %s\n", tmp.c_str()); good = false; }
return false; } // closed here: Windows can't delete a file that is still open without FILE_SHARE_DELETE
} if (!good) { std::filesystem::remove(tmp, ec); return false; }
} #ifndef _WIN32
if (executable) ::chmod(tmp.c_str(), 0755); // before it becomes visible under the final name
file.write(reinterpret_cast<const char*>(res->data), res->size); #else
if (!file) { (void)executable;
DEBUG_LOGF("[ERROR] Failed to write %zu bytes to %s\n", res->size, destPath.c_str()); #endif
return false; if (!replaceFromTemp(tmp, destPath)) return false;
} DEBUG_LOGF("[INFO] Extracted %s (%zu bytes)\n", destPath.c_str(), size);
DEBUG_LOGF("[INFO] Extracted %s (%zu bytes)\n", destPath.c_str(), res->size);
return true; return true;
} }
bool extractEmbeddedResources() bool copyFileAtomic(const std::string& srcPath, const std::string& destPath,
ProvisionProgress* progress, const CancelFn& cancelled)
{
namespace fs = std::filesystem;
if (!prepareParentDir(destPath)) return false;
const std::string tmp = destPath + kPartialFileSuffix;
std::error_code ec;
bool good = true;
{
std::ifstream in(srcPath, std::ios::binary); // a read handle on the source is harmless if inherited
if (!in) { DEBUG_LOGF("[ERROR] Failed to open %s\n", srcPath.c_str()); return false; }
TempSink out(tmp);
if (!out.ok()) { DEBUG_LOGF("[ERROR] Failed to open %s for writing\n", tmp.c_str()); return false; }
std::vector<char> buf(kCopyChunk);
for (;;) {
if (cancelled && cancelled()) { good = false; break; }
in.read(buf.data(), static_cast<std::streamsize>(buf.size()));
const std::streamsize got = in.gcount();
if (got > 0) {
if (!out.write(buf.data(), static_cast<std::size_t>(got))) {
DEBUG_LOGF("[ERROR] Failed to write %s\n", tmp.c_str());
good = false; break;
}
if (progress) progress->bytesDone.fetch_add(static_cast<std::uint64_t>(got), std::memory_order_relaxed);
}
if (!in) break; // EOF (eof|fail) or a read error (bad, checked below)
}
if (good && in.bad()) {
DEBUG_LOGF("[ERROR] Failed to read %s\n", srcPath.c_str());
good = false;
}
// Flush on the same handle BEFORE the rename: a power cut can't leave a truncated file under the final name.
if (good && !out.commit()) { DEBUG_LOGF("[ERROR] Failed to finish writing %s\n", tmp.c_str()); good = false; }
} // closed here: Windows can't delete a file that is still open without FILE_SHARE_DELETE
if (!good) { fs::remove(tmp, ec); return false; }
const auto st = fs::status(srcPath, ec);
if (!ec) fs::permissions(tmp, st.permissions(), ec); // keeps dragonxd/cli/tx executable (as copy_file did)
return replaceFromTemp(tmp, destPath);
}
// Requires provisionMutex() held.
static bool extractResourceLocked(const EmbeddedResource* res, const std::string& destPath, bool executable,
ProvisionProgress* progress = nullptr, const CancelFn& cancelled = {})
{
if (!res || !res->data || res->size == 0) return false;
return writeFileAtomic(destPath, res->data, res->size, executable, progress, cancelled);
}
bool extractEmbeddedResources(ProvisionProgress* progress, const CancelFn& cancelled)
{ {
if (!hasEmbeddedResources()) { if (!hasEmbeddedResources()) {
DEBUG_LOGF("[ERROR] No embedded resources available\n"); DEBUG_LOGF("[ERROR] No embedded resources available\n");
return false; return false;
} }
std::lock_guard<std::mutex> lock(provisionMutex());
bool success = true;
#ifdef _WIN32
const char pathSep = '\\';
#else
const char pathSep = '/';
#endif
// All files go to <ObsidianDragonDir>/dragonx/
std::string daemonDir = getDaemonDirectory();
// Extract Sapling params to daemon directory alongside dragonxd // All files go to <ObsidianDragonDir>/dragonx/ — NOT the data directory (running dragonxd.exe from inside
const EmbeddedResource* spendRes = getEmbeddedResource(RESOURCE_SAPLING_SPEND); // the data directory, where it writes chain data / debug.log / lock files, crashes on some Windows machines).
if (spendRes) { const std::string daemonDir = getDaemonDirectory();
std::string dest = daemonDir + pathSep + RESOURCE_SAPLING_SPEND;
if (resourceNeedsUpdate(spendRes, dest)) {
DEBUG_LOGF("[INFO] Extracting sapling-spend.params (%zu MB)...\n", spendRes->size / (1024*1024));
if (!extractResource(spendRes, dest)) {
success = false;
}
}
}
const EmbeddedResource* outputRes = getEmbeddedResource(RESOURCE_SAPLING_OUTPUT);
if (outputRes) {
std::string dest = daemonDir + pathSep + RESOURCE_SAPLING_OUTPUT;
if (resourceNeedsUpdate(outputRes, dest)) {
DEBUG_LOGF("[INFO] Extracting sapling-output.params (%zu MB)...\n", outputRes->size / (1024*1024));
if (!extractResource(outputRes, dest)) {
success = false;
}
}
}
// Extract asmap.dat to daemon directory
const EmbeddedResource* asmapRes = getEmbeddedResource(RESOURCE_ASMAP);
if (asmapRes) {
std::string dest = daemonDir + pathSep + RESOURCE_ASMAP;
if (resourceNeedsUpdate(asmapRes, dest)) {
DEBUG_LOGF("[INFO] Extracting asmap.dat...\n");
if (!extractResource(asmapRes, dest)) {
success = false;
}
}
}
// Extract daemon binaries — NOT the data directory.
// Running dragonxd.exe from inside the data directory (where it writes blockchain
// data, debug.log, lock files, etc.) causes crashes on some Windows machines.
#ifdef HAS_EMBEDDED_DAEMON
DEBUG_LOGF("[INFO] Daemon extraction directory: %s\n", daemonDir.c_str()); DEBUG_LOGF("[INFO] Daemon extraction directory: %s\n", daemonDir.c_str());
std::error_code ec;
// Daemon binaries are placed ONLY when missing — never auto-overwritten on a size mismatch std::filesystem::create_directories(daemonDir, ec);
// (the user may run a specific dragonxd; replacing it is an explicit Settings action). ec.clear();
const EmbeddedResource* daemonRes = getEmbeddedResource(RESOURCE_DRAGONXD);
if (daemonRes) { // Temp files left by an interrupted run (killed process, power cut). Only OUR suffix.
std::string dest = daemonDir + pathSep + RESOURCE_DRAGONXD; for (const auto& e : kProvisionEntries) {
if (!std::filesystem::exists(dest)) { std::filesystem::remove(joinDir(daemonDir, e.name) + kPartialFileSuffix, ec);
DEBUG_LOGF("[INFO] Extracting dragonxd (%zu MB)...\n", daemonRes->size / (1024*1024)); ec.clear();
if (!extractResource(daemonRes, dest)) { }
success = false;
} struct Todo { const ProvisionEntry* entry; const EmbeddedResource* res; std::string dest; };
#ifndef _WIN32 std::vector<Todo> todo;
else { chmod(dest.c_str(), 0755); } std::uint64_t total = 0;
#endif for (const auto& e : kProvisionEntries) {
if (!entryActive(e)) continue;
const EmbeddedResource* res = getEmbeddedResource(e.name);
if (!res) continue;
std::string dest = joinDir(daemonDir, e.name);
if (!resourceNeedsWrite(res->size, e.refreshOnSizeMismatch, dest)) continue;
total += res->size;
todo.push_back({ &e, res, std::move(dest) });
}
if (progress) {
progress->bytesDone.store(0, std::memory_order_relaxed);
progress->bytesTotal.store(total, std::memory_order_relaxed);
progress->setPhase(ProvisionPhase::Extracting);
}
bool success = true;
for (const auto& t : todo) {
if (cancelled && cancelled()) return false;
DEBUG_LOGF("[INFO] Extracting %s (%zu MB)...\n", t.entry->name, t.res->size / (1024 * 1024));
if (!extractResourceLocked(t.res, t.dest, t.entry->executable, progress, cancelled)) {
if (cancelled && cancelled()) return false;
success = false;
} }
} }
const EmbeddedResource* cliRes = getEmbeddedResource(RESOURCE_DRAGONX_CLI); // Best-effort cleanup of ".old" copies left by an in-use replacement. Once the old process has exited the
if (cliRes) { // file is no longer locked and removes cleanly; if it still runs, the remove fails and we retry next run.
std::string dest = daemonDir + pathSep + RESOURCE_DRAGONX_CLI; for (const auto& e : kProvisionEntries) {
if (!std::filesystem::exists(dest)) { std::filesystem::remove(joinDir(daemonDir, e.name) + std::string(".old"), ec);
DEBUG_LOGF("[INFO] Extracting dragonx-cli (%zu MB)...\n", cliRes->size / (1024*1024)); ec.clear();
if (!extractResource(cliRes, dest)) {
success = false;
}
#ifndef _WIN32
else { chmod(dest.c_str(), 0755); }
#endif
}
} }
const EmbeddedResource* txRes = getEmbeddedResource(RESOURCE_DRAGONX_TX);
if (txRes) {
std::string dest = daemonDir + pathSep + RESOURCE_DRAGONX_TX;
if (!std::filesystem::exists(dest)) {
DEBUG_LOGF("[INFO] Extracting dragonx-tx (%zu MB)...\n", txRes->size / (1024*1024));
if (!extractResource(txRes, dest)) {
success = false;
}
#ifndef _WIN32
else { chmod(dest.c_str(), 0755); }
#endif
}
}
#endif
#ifdef HAS_EMBEDDED_XMRIG
const EmbeddedResource* xmrigRes = getEmbeddedResource(RESOURCE_XMRIG);
if (xmrigRes) {
std::string dest = daemonDir + pathSep + RESOURCE_XMRIG;
if (resourceNeedsUpdate(xmrigRes, dest)) {
if (std::filesystem::exists(dest))
DEBUG_LOGF("[INFO] Updating stale xmrig (size mismatch)...\n");
DEBUG_LOGF("[INFO] Extracting xmrig (%zu MB)...\n", xmrigRes->size / (1024*1024));
if (!extractResource(xmrigRes, dest)) {
success = false;
}
#ifndef _WIN32
else { chmod(dest.c_str(), 0755); }
#endif
}
}
#endif
#ifdef HAS_EMBEDDED_WALLET_REBUILD
// Offline wallet-rebuild recovery helper — extracted next to the daemon so a bare, self-extracting
// ObsidianDragon.exe still offers "Repair automatically" (findWalletRebuildHelper() checks this dir).
const EmbeddedResource* rebuildRes = getEmbeddedResource(RESOURCE_DRAGONX_WALLET_REBUILD);
if (rebuildRes) {
std::string dest = daemonDir + pathSep + RESOURCE_DRAGONX_WALLET_REBUILD;
if (!std::filesystem::exists(dest)) {
DEBUG_LOGF("[INFO] Extracting dragonx-wallet-rebuild (%zu MB)...\n", rebuildRes->size / (1024*1024));
if (!extractResource(rebuildRes, dest)) {
success = false;
}
#ifndef _WIN32
else { chmod(dest.c_str(), 0755); }
#endif
}
}
#endif
// Best-effort cleanup of any ".old" binaries left behind by a previous in-use replacement.
// Once the old daemon/xmrig process has exited, the file is no longer locked and removes cleanly;
// if it's still running, the remove fails harmlessly and we retry on the next startup.
{
std::error_code ec;
for (const char* name : { RESOURCE_DRAGONXD, RESOURCE_XMRIG }) {
std::filesystem::remove(daemonDir + pathSep + name + std::string(".old"), ec);
ec.clear();
}
}
return success; return success;
} }
std::vector<std::string> bundleSearchDirs()
{
std::vector<std::string> dirs;
const std::string exeDir = util::Platform::getExecutableDirectory();
if (exeDir.empty()) return dirs;
#ifdef __APPLE__
// Inside a .app the params live in Contents/Resources while the executable is in Contents/MacOS.
std::error_code ec;
const std::filesystem::path res = std::filesystem::path(exeDir).parent_path() / "Resources";
if (std::filesystem::is_directory(res, ec)) dirs.push_back(res.string());
#endif
dirs.push_back(exeDir);
return dirs;
}
const std::vector<std::string>& saplingParamFileNames()
{
static const std::vector<std::string> v = { "sapling-spend.params", "sapling-output.params" };
return v;
}
const std::vector<std::string>& nodeBundleFileNames()
{
static const std::vector<std::string> v = { "sapling-spend.params", "sapling-output.params", "asmap.dat",
"dragonxd", "dragonx-cli", "dragonx-tx" };
return v;
}
std::vector<BundleCopyItem> planBundleCopy(const std::vector<std::string>& searchDirs, const std::string& destDir,
const std::vector<std::string>& names, bool replaceExisting)
{
namespace fs = std::filesystem;
std::vector<BundleCopyItem> plan;
std::error_code ec;
for (const auto& name : names) {
const fs::path dst = fs::path(destDir) / name;
if (!replaceExisting && fs::exists(dst, ec)) continue;
for (const auto& dir : searchDirs) {
const fs::path src = fs::path(dir) / name;
if (fs::exists(src, ec)) { plan.push_back({ name, src.string(), dst.string() }); break; }
}
}
return plan;
}
BundleCopyResult copyBundledFiles(const std::vector<std::string>& searchDirs, const std::string& destDir,
const std::vector<std::string>& names, bool replaceExisting,
ProvisionProgress* progress, const CancelFn& cancelled)
{
BundleCopyResult r;
// Stat-only check first: the steady-state call (every daemon start) never touches the lock.
if (planBundleCopy(searchDirs, destDir, names, replaceExisting).empty()) return r;
std::lock_guard<std::mutex> lock(provisionMutex());
const auto plan = planBundleCopy(searchDirs, destDir, names, replaceExisting); // re-check under the lock
std::uint64_t total = 0;
std::error_code ec;
for (const auto& it : plan) {
std::filesystem::remove(it.dst + kPartialFileSuffix, ec); // stale temp from an interrupted copy
ec.clear();
const auto sz = std::filesystem::file_size(it.src, ec);
if (!ec) total += sz;
ec.clear();
}
if (progress) {
progress->bytesDone.store(0, std::memory_order_relaxed);
progress->bytesTotal.store(total, std::memory_order_relaxed);
progress->setPhase(ProvisionPhase::Copying);
}
for (const auto& it : plan) {
if (cancelled && cancelled()) { r.ok = false; return r; }
DEBUG_LOGF("Copying bundled %s to %s\n", it.name.c_str(), destDir.c_str());
if (copyFileAtomic(it.src, it.dst, progress, cancelled)) {
++r.copied;
} else {
if (cancelled && cancelled()) { r.ok = false; return r; }
DEBUG_LOGF("[ERROR] Failed to copy %s\n", it.name.c_str());
r.ok = false;
if (r.firstFailure.empty()) r.firstFailure = it.name;
}
}
return r;
}
std::string ensureWalletRebuildHelperExtracted() std::string ensureWalletRebuildHelperExtracted()
{ {
#ifdef HAS_EMBEDDED_WALLET_REBUILD #ifdef HAS_EMBEDDED_WALLET_REBUILD
@@ -484,12 +679,11 @@ std::string ensureWalletRebuildHelperExtracted()
const std::string dir = getDaemonDirectory(); const std::string dir = getDaemonDirectory();
const std::string dest = dir + sep + RESOURCE_DRAGONX_WALLET_REBUILD; const std::string dest = dir + sep + RESOURCE_DRAGONX_WALLET_REBUILD;
std::error_code ec; std::error_code ec;
if (std::filesystem::exists(dest, ec)) return dest; // already extracted if (std::filesystem::exists(dest, ec)) return dest; // already extracted (atomic write: exists == complete)
std::lock_guard<std::mutex> lock(provisionMutex()); // serialise with the startup job / other writers
if (std::filesystem::exists(dest, ec)) return dest; // written meanwhile by the startup job
std::filesystem::create_directories(dir, ec); std::filesystem::create_directories(dir, ec);
if (!extractResource(res, dest)) return {}; if (!extractResourceLocked(res, dest, /*executable=*/true)) return {};
#ifndef _WIN32
chmod(dest.c_str(), 0755);
#endif
DEBUG_LOGF("[INFO] Extracted wallet-rebuild helper on demand: %s\n", dest.c_str()); DEBUG_LOGF("[INFO] Extracted wallet-rebuild helper on demand: %s\n", dest.c_str());
return dest; return dest;
#else #else
@@ -623,14 +817,11 @@ bool forceExtractXmrig()
} }
DEBUG_LOGF("[INFO] forceExtractXmrig: extracting xmrig (%zu MB) to %s\n", DEBUG_LOGF("[INFO] forceExtractXmrig: extracting xmrig (%zu MB) to %s\n",
xmrigRes->size / (1024*1024), dest.c_str()); xmrigRes->size / (1024*1024), dest.c_str());
if (!extractResource(xmrigRes, dest)) { std::lock_guard<std::mutex> lock(provisionMutex());
if (!extractResourceLocked(xmrigRes, dest, /*executable=*/true)) {
DEBUG_LOGF("[ERROR] forceExtractXmrig: extraction failed\n"); DEBUG_LOGF("[ERROR] forceExtractXmrig: extraction failed\n");
return false; return false;
} }
#ifndef _WIN32
// Set executable permission on Linux
chmod(dest.c_str(), 0755);
#endif
return true; return true;
#else #else
return false; return false;
@@ -722,6 +913,7 @@ BundledDaemonInfo getBundledDaemonInfo()
bool reextractBundledDaemon() bool reextractBundledDaemon()
{ {
#ifdef HAS_EMBEDDED_DAEMON #ifdef HAS_EMBEDDED_DAEMON
std::lock_guard<std::mutex> lock(provisionMutex());
std::string daemonDir = getDaemonDirectory(); std::string daemonDir = getDaemonDirectory();
#ifdef _WIN32 #ifdef _WIN32
const char pathSep = '\\'; const char pathSep = '\\';
@@ -736,15 +928,12 @@ bool reextractBundledDaemon()
if (!res) continue; if (!res) continue;
std::string dest = daemonDir + pathSep + name; std::string dest = daemonDir + pathSep + name;
DEBUG_LOGF("[INFO] reextractBundledDaemon: writing %s (%zu MB)\n", name, res->size / (1024*1024)); DEBUG_LOGF("[INFO] reextractBundledDaemon: writing %s (%zu MB)\n", name, res->size / (1024*1024));
if (!extractResource(res, dest)) { if (!extractResourceLocked(res, dest, /*executable=*/true)) {
DEBUG_LOGF("[ERROR] reextractBundledDaemon: failed to write %s\n", name); DEBUG_LOGF("[ERROR] reextractBundledDaemon: failed to write %s\n", name);
ok = false; ok = false;
continue; continue;
} }
wroteAny = true; wroteAny = true;
#ifndef _WIN32
chmod(dest.c_str(), 0755);
#endif
} }
return ok && wroteAny; return ok && wroteAny;
#else #else
@@ -770,8 +959,13 @@ std::string getXmrigPath()
std::string embeddedPath = daemonDir + pathSep + xmrigName; std::string embeddedPath = daemonDir + pathSep + xmrigName;
DEBUG_LOGF("[DEBUG] getXmrigPath: checking embedded path %s\n", embeddedPath.c_str()); DEBUG_LOGF("[DEBUG] getXmrigPath: checking embedded path %s\n", embeddedPath.c_str());
if (!std::filesystem::exists(embeddedPath)) { if (!std::filesystem::exists(embeddedPath)) {
DEBUG_LOGF("[DEBUG] getXmrigPath: not found, force re-extracting xmrig\n"); // Serialise with the startup job: if it just wrote xmrig, the re-check finds it complete.
forceExtractXmrig(); std::lock_guard<std::mutex> lock(provisionMutex());
if (!std::filesystem::exists(embeddedPath)) {
DEBUG_LOGF("[DEBUG] getXmrigPath: not found, force re-extracting xmrig\n");
const EmbeddedResource* xr = getEmbeddedResource(RESOURCE_XMRIG);
if (xr) extractResourceLocked(xr, embeddedPath, /*executable=*/true);
}
} }
if (std::filesystem::exists(embeddedPath)) { if (std::filesystem::exists(embeddedPath)) {
DEBUG_LOGF("[INFO] getXmrigPath: found at %s\n", embeddedPath.c_str()); DEBUG_LOGF("[INFO] getXmrigPath: found at %s\n", embeddedPath.c_str());
+47 -4
View File
@@ -1,7 +1,12 @@
#pragma once #pragma once
#include <string> #include <cstddef>
#include <cstdint> #include <cstdint>
#include <functional>
#include <string>
#include <vector>
#include "provision_state.h"
namespace dragonx { namespace dragonx {
namespace resources { namespace resources {
@@ -20,9 +25,47 @@ bool hasEmbeddedResources();
// Returns nullptr if not found or not embedded // Returns nullptr if not found or not embedded
const EmbeddedResource* getEmbeddedResource(const std::string& name); const EmbeddedResource* getEmbeddedResource(const std::string& name);
// Extract all embedded resources to appropriate locations using CancelFn = std::function<bool()>; // polled between 4 MB chunks; true = stop now
// Returns true if all resources extracted successfully
bool extractEmbeddedResources(); // Suffix of our own temp files in the daemon dir ("<name>.partial"). NOT ".tmp": the in-app miner/node
// updaters stage "<name>.tmp" in the same directory and must never have it deleted under them.
inline constexpr const char* kPartialFileSuffix = ".partial";
// Extract all embedded resources to the daemon directory. Serialised with every other writer of that
// directory by an internal mutex; atomic per file (temp + flush + rename), so a destination is either the old
// complete file or the new one. `progress` (optional) receives bytes written; `cancelled` (optional) aborts
// between chunks leaving no partial destination. Returns true if every needed file was written (false also on
// cancel). Safe on any thread.
bool extractEmbeddedResources(ProvisionProgress* progress = nullptr, const CancelFn& cancelled = {});
// Does `destPath` need (re)writing? Missing -> yes. Present: only when `refreshOnSizeMismatch` and the size
// differs. embeddedSize == 0 -> never. (Pure apart from the stat; exposed for tests.)
bool resourceNeedsWrite(std::uint64_t embeddedSize, bool refreshOnSizeMismatch, const std::string& destPath);
// Write `data` to `destPath` via `destPath + kPartialFileSuffix`, flush it to disk, then move it into place
// (POSIX rename; Windows MoveFileEx with retry and move-aside of a running .exe to ".old"). POSIX: chmod 0755
// first when `executable`. Returns false (temp removed, original kept) on any failure or on cancel.
// Takes no lock itself (extractEmbeddedResources / copyBundledFiles / the on-demand extractors do).
bool writeFileAtomic(const std::string& destPath, const std::uint8_t* data, std::size_t size, bool executable,
ProvisionProgress* progress = nullptr, const CancelFn& cancelled = {});
// Same contract, streaming from a source file in 4 MB chunks; keeps the source's permissions.
bool copyFileAtomic(const std::string& srcPath, const std::string& destPath,
ProvisionProgress* progress = nullptr, const CancelFn& cancelled = {});
// Node files shipped NEXT TO the executable (zip / AppImage / .app; no embedded_data.h).
struct BundleCopyItem { std::string name, src, dst; };
std::vector<std::string> bundleSearchDirs(); // macOS: .app Contents/Resources first; then exe dir
const std::vector<std::string>& saplingParamFileNames(); // sapling-spend.params, sapling-output.params
const std::vector<std::string>& nodeBundleFileNames(); // params + asmap.dat + dragonxd + dragonx-cli + dragonx-tx
// First search dir holding a name wins; names with no source are skipped (optional); names whose destination
// exists are skipped unless replaceExisting. Stat-only.
std::vector<BundleCopyItem> planBundleCopy(const std::vector<std::string>& searchDirs, const std::string& destDir,
const std::vector<std::string>& names, bool replaceExisting);
struct BundleCopyResult { bool ok = true; int copied = 0; std::string firstFailure; };
// Executes planBundleCopy under the provisioning lock (checks the plan lock-free first: the common
// "nothing to copy" call never touches the lock).
BundleCopyResult copyBundledFiles(const std::vector<std::string>& searchDirs, const std::string& destDir,
const std::vector<std::string>& names, bool replaceExisting,
ProvisionProgress* progress = nullptr, const CancelFn& cancelled = {});
// Check if params need to be extracted (not already present) // Check if params need to be extracted (not already present)
bool needsParamsExtraction(); bool needsParamsExtraction();
+118
View File
@@ -0,0 +1,118 @@
// DragonX Wallet - ImGui Edition
// Copyright 2024-2026 The Hush Developers
// Released under the GPLv3
//
// provision_state.h - state of the startup "node files" job (App::startStartupFilesProvision): on Windows it
// extracts the bundled Sapling params / asmap / node / miner from the .exe, on Linux/macOS it copies them
// from the executable's directory, and on full-node builds it then verifies the Sapling params. The job runs
// on a worker thread; the UI thread only polls this. No app dependencies, so tests include it directly.
#pragma once
#include <atomic>
#include <chrono>
#include <condition_variable>
#include <cstdint>
#include <mutex>
namespace dragonx {
namespace resources {
enum class ProvisionPhase : int { Idle = 0, Extracting, Copying, Verifying };
// Lock-free progress: written by the worker, read by the UI every frame.
struct ProvisionProgress {
std::atomic<std::uint64_t> bytesDone{0};
std::atomic<std::uint64_t> bytesTotal{0};
std::atomic<int> phase{static_cast<int>(ProvisionPhase::Idle)};
void setPhase(ProvisionPhase p) { phase.store(static_cast<int>(p), std::memory_order_relaxed); }
ProvisionPhase getPhase() const { return static_cast<ProvisionPhase>(phase.load(std::memory_order_relaxed)); }
};
// 0..100; 0 while the total is unknown.
inline int provisionPercent(std::uint64_t done, std::uint64_t total)
{
if (total == 0) return 0;
if (done >= total) return 100;
return static_cast<int>((done * 100u) / total);
}
// The chip stays hidden for a short grace period so a job that ends in a few ms never flashes.
inline bool provisionChipVisible(bool running, double elapsedSeconds, double delaySeconds)
{
return running && elapsedSeconds >= delaySeconds;
}
// What App::startEmbeddedDaemon() does while the job may still be writing the node files: never launch the
// node over half-written files, never block the UI thread (defer + replay), workers simply wait.
enum class DaemonStartGate { Proceed, Defer, Wait };
inline DaemonStartGate daemonStartGate(bool jobRunning, bool onUiThread)
{
if (!jobRunning) return DaemonStartGate::Proceed;
return onUiThread ? DaemonStartGate::Defer : DaemonStartGate::Wait;
}
class StartupProvision {
public:
struct Result {
bool extractOk = true; // every needed file written/copied (false: disk full / permissions / AV)
bool verifyOk = true; // Sapling SHA-256 check (full node only)
bool cancelled = false; // shutdown stopped it; nothing is wrong
};
enum class State : int { Idle, Running, Finished, Reaped };
ProvisionProgress progress;
// The Sapling param repair copy (replace the daemon-dir params from the bundle, ~51.5 MB) ran this session:
// set by the job and by App::startEmbeddedDaemon(), so a still-failing verify never repeats it on every
// connect retry (the UI thread).
std::atomic<bool> paramRepairTried{false};
void begin() {
std::lock_guard<std::mutex> lk(m_);
state_ = State::Running;
startedAt_ = std::chrono::steady_clock::now();
}
// Nothing needed doing (the steady-state launch): no thread was started.
void markNothingToDo() { std::lock_guard<std::mutex> lk(m_); state_ = State::Reaped; }
void finish(const Result& r) {
{ std::lock_guard<std::mutex> lk(m_); result_ = r; state_ = State::Finished; }
cv_.notify_all();
}
bool isRunning() const { std::lock_guard<std::mutex> lk(m_); return state_ == State::Running; }
// Non-reaping peek: the job has ended and shutdown cancelled it (synchronized with finish() through m_).
bool endedCancelled() const {
std::lock_guard<std::mutex> lk(m_);
return state_ != State::Running && result_.cancelled;
}
// App::hasPendingBackgroundResults(): finished and not yet reaped (the reap needs one update() tick).
// NOT while Running: nothing is reapable then, and the chip already requests progress frames when visible.
bool hasCompletion() const { std::lock_guard<std::mutex> lk(m_); return state_ == State::Finished; }
// UI thread: true exactly once, after the job finished.
bool takeCompletion(Result* out) {
std::lock_guard<std::mutex> lk(m_);
if (state_ != State::Finished) return false;
if (out) *out = result_;
state_ = State::Reaped;
return true;
}
// Worker threads: true when the job is no longer running.
bool waitUntilDone(int timeoutMs) {
std::unique_lock<std::mutex> lk(m_);
cv_.wait_for(lk, std::chrono::milliseconds(timeoutMs), [&] { return state_ != State::Running; });
return state_ != State::Running;
}
double elapsedSeconds() const {
std::lock_guard<std::mutex> lk(m_);
if (state_ != State::Running) return 0.0;
return std::chrono::duration<double>(std::chrono::steady_clock::now() - startedAt_).count();
}
private:
mutable std::mutex m_;
std::condition_variable cv_;
State state_ = State::Idle;
Result result_;
std::chrono::steady_clock::time_point startedAt_{};
};
} // namespace resources
} // namespace dragonx
+24
View File
@@ -241,6 +241,30 @@ bool Connection::verifySaplingParams()
return verifySaplingParamsIn(getSaplingParamsDir(), digests); return verifySaplingParamsIn(getSaplingParamsDir(), digests);
} }
Connection::SaplingParamState Connection::saplingParamStateIn(
const std::string& dir,
const std::vector<std::pair<std::string, std::string>>& digests)
{
if (dir.empty() || digests.empty()) return SaplingParamState::Missing;
std::vector<std::string> current;
current.reserve(digests.size());
for (const auto& d : digests) {
const std::string p = joinParamPath(dir, d.first);
std::error_code ec;
if (!fs::exists(p, ec)) return SaplingParamState::Missing;
current.push_back(paramStatLine(p));
}
return saplingMarkerMatches(joinParamPath(dir, ".sapling_verified"), current)
? SaplingParamState::Verified : SaplingParamState::NeedsHash;
}
Connection::SaplingParamState Connection::saplingParamState()
{
std::vector<std::pair<std::string, std::string>> digests;
for (const auto& d : kSaplingParamDigests) digests.emplace_back(d.first, d.second);
return saplingParamStateIn(getSaplingParamsDir(), digests);
}
ConnectionConfig Connection::parseConfFile(const std::string& path) ConnectionConfig Connection::parseConfFile(const std::string& path)
{ {
ConnectionConfig config; ConnectionConfig config;
+8
View File
@@ -83,6 +83,14 @@ public:
const std::string& dir, const std::string& dir,
const std::vector<std::pair<std::string, std::string>>& digests); const std::vector<std::pair<std::string, std::string>>& digests);
// Stat-only (never reads or hashes the params): Missing = a param is absent, Verified = the
// .sapling_verified marker matches every param's size:mtime, NeedsHash = present but unverified.
enum class SaplingParamState { Missing, NeedsHash, Verified };
static SaplingParamState saplingParamState();
static SaplingParamState saplingParamStateIn(
const std::string& dir,
const std::vector<std::pair<std::string, std::string>>& digests);
/** /**
* @brief Get the Sapling params directory * @brief Get the Sapling params directory
*/ */
+2
View File
@@ -1513,6 +1513,8 @@ void I18n::loadBuiltinEnglish()
"Close the program using it (or free the port), then restart — the wallet can't start " "Close the program using it (or free the port), then restart — the wallet can't start "
"its own node while the port is taken."; "its own node while the port is taken.";
strings_["sb_extracting_sapling"] = "Extracting Sapling parameters..."; strings_["sb_extracting_sapling"] = "Extracting Sapling parameters...";
strings_["startup_files_preparing"] = "Preparing wallet files... %d%%";
strings_["startup_files_verifying"] = "Verifying wallet files...";
strings_["sb_sapling_failed"] = "Failed to extract Sapling parameters."; strings_["sb_sapling_failed"] = "Failed to extract Sapling parameters.";
strings_["sb_sapling_not_found"] = "Sapling parameters not found."; strings_["sb_sapling_not_found"] = "Sapling parameters not found.";
strings_["sb_daemon_extract_failed"] = "Failed to write daemon files — check free disk space and permissions."; strings_["sb_daemon_extract_failed"] = "Failed to write daemon files — check free disk space and permissions.";
+279
View File
@@ -30,6 +30,7 @@
#include "rpc/rpc_failure.h" #include "rpc/rpc_failure.h"
#include "rpc/rpc_busy_gate.h" #include "rpc/rpc_busy_gate.h"
#include "resources/embedded_resources.h" #include "resources/embedded_resources.h"
#include "resources/provision_state.h"
#include "services/history_scan_policy.h" #include "services/history_scan_policy.h"
#include "services/wallet_scan_governor.h" #include "services/wallet_scan_governor.h"
#include "services/scan_policy.h" #include "services/scan_policy.h"
@@ -3341,6 +3342,278 @@ void testVerifySaplingParams()
fsn::remove_all(dir2, rmec); fsn::remove_all(dir2, rmec);
} }
// ── Startup node files (resources/provision_state.h, embedded_resources atomic writers, Sapling param state) ──
static std::string readProvisionFile(const fs::path& p)
{
std::ifstream f(p.string(), std::ios::binary);
return std::string((std::istreambuf_iterator<char>(f)), std::istreambuf_iterator<char>());
}
void testStartupProvisionState()
{
using namespace dragonx::resources;
EXPECT_EQ(provisionPercent(0, 0), 0);
EXPECT_EQ(provisionPercent(50, 100), 50);
EXPECT_EQ(provisionPercent(100, 100), 100);
EXPECT_EQ(provisionPercent(150, 100), 100);
EXPECT_EQ(provisionPercent(1, 3), 33);
EXPECT_TRUE(!provisionChipVisible(true, 0.1, 0.4));
EXPECT_TRUE(provisionChipVisible(true, 0.5, 0.4));
EXPECT_TRUE(!provisionChipVisible(false, 5.0, 0.4));
EXPECT_TRUE(daemonStartGate(false, true) == DaemonStartGate::Proceed);
EXPECT_TRUE(daemonStartGate(false, false) == DaemonStartGate::Proceed);
EXPECT_TRUE(daemonStartGate(true, true) == DaemonStartGate::Defer);
EXPECT_TRUE(daemonStartGate(true, false) == DaemonStartGate::Wait);
ProvisionProgress progress;
EXPECT_TRUE(progress.getPhase() == ProvisionPhase::Idle);
progress.setPhase(ProvisionPhase::Verifying);
EXPECT_TRUE(progress.getPhase() == ProvisionPhase::Verifying);
StartupProvision sp;
StartupProvision::Result r;
EXPECT_TRUE(!sp.isRunning());
EXPECT_TRUE(!sp.hasCompletion());
EXPECT_TRUE(!sp.takeCompletion(&r));
EXPECT_TRUE(sp.waitUntilDone(0));
sp.begin();
EXPECT_TRUE(sp.isRunning());
EXPECT_TRUE(!sp.hasCompletion()); // nothing to reap while it runs (no idle-loop wake for the whole job)
EXPECT_TRUE(!sp.takeCompletion(&r));
EXPECT_TRUE(!sp.waitUntilDone(10));
EXPECT_TRUE(sp.elapsedSeconds() >= 0.0);
std::thread worker([&sp] { sp.finish({ true, false, false }); });
EXPECT_TRUE(sp.waitUntilDone(5000));
worker.join();
EXPECT_TRUE(!sp.isRunning());
EXPECT_TRUE(sp.hasCompletion());
EXPECT_TRUE(sp.takeCompletion(&r));
EXPECT_TRUE(r.extractOk && !r.verifyOk && !r.cancelled);
EXPECT_TRUE(!sp.takeCompletion(&r)); // exactly once
EXPECT_TRUE(!sp.hasCompletion());
EXPECT_NEAR(sp.elapsedSeconds(), 0.0, 1e-9);
EXPECT_TRUE(!sp.endedCancelled());
// A job that shutdown cancelled: a waiting daemon start / miner start must abort, not fall through.
StartupProvision cancelledJob;
cancelledJob.begin();
EXPECT_TRUE(!cancelledJob.endedCancelled()); // still running
cancelledJob.finish({ false, true, true });
EXPECT_TRUE(cancelledJob.waitUntilDone(0));
EXPECT_TRUE(cancelledJob.endedCancelled());
EXPECT_TRUE(cancelledJob.takeCompletion(&r) && r.cancelled);
EXPECT_TRUE(cancelledJob.endedCancelled()); // a non-reaping peek: still true once reaped
StartupProvision idle;
idle.markNothingToDo();
EXPECT_TRUE(!idle.hasCompletion() && !idle.isRunning());
EXPECT_TRUE(!idle.takeCompletion(nullptr));
EXPECT_TRUE(idle.waitUntilDone(0));
EXPECT_TRUE(!idle.endedCancelled());
}
void testResourceNeedsWrite()
{
using dragonx::resources::resourceNeedsWrite;
const fs::path dir = makeTempDir();
const std::string f = (dir / "res.bin").string();
EXPECT_TRUE(resourceNeedsWrite(10, false, f)); // missing
EXPECT_TRUE(resourceNeedsWrite(10, true, f));
EXPECT_TRUE(!resourceNeedsWrite(0, true, f)); // nothing embedded
{ std::ofstream(f, std::ios::binary) << "12345"; }
EXPECT_TRUE(!resourceNeedsWrite(10, false, f)); // present, size not tracked
EXPECT_TRUE(!resourceNeedsWrite(5, true, f)); // same size
EXPECT_TRUE(resourceNeedsWrite(10, true, f)); // the bundled size differs
EXPECT_TRUE(!resourceNeedsWrite(0, true, f));
removeTree(dir);
}
void testWriteFileAtomic()
{
using namespace dragonx::resources;
const fs::path dir = makeTempDir();
std::vector<std::uint8_t> buf(9u * 1024u * 1024u); // > two 4 MB chunks
for (std::size_t i = 0; i < buf.size(); ++i) buf[i] = static_cast<std::uint8_t>((i * 31u + (i >> 13)) & 0xFFu);
const std::string expect(reinterpret_cast<const char*>(buf.data()), buf.size());
const fs::path dest = dir / "sub" / "out.bin"; // parent created on demand
const std::string partial = dest.string() + kPartialFileSuffix;
// (a) a new file: byte-exact, no temp left, progress counts every byte
{
ProvisionProgress p;
EXPECT_TRUE(writeFileAtomic(dest.string(), buf.data(), buf.size(), false, &p));
EXPECT_TRUE(readProvisionFile(dest) == expect);
EXPECT_TRUE(!fs::exists(partial));
EXPECT_EQ(p.bytesDone.load(), static_cast<std::uint64_t>(buf.size()));
}
// (b) an existing file is replaced
{
{ std::ofstream(dest.string(), std::ios::binary | std::ios::trunc) << "old contents"; }
EXPECT_TRUE(writeFileAtomic(dest.string(), buf.data(), buf.size(), false));
EXPECT_TRUE(readProvisionFile(dest) == expect);
EXPECT_TRUE(!fs::exists(partial));
}
// (c) cancelled on the 2nd poll: false, the destination is absent / unchanged, no temp left
{
int calls = 0;
const CancelFn cancel = [&calls]() { return ++calls >= 2; };
const fs::path fresh = dir / "fresh.bin";
EXPECT_TRUE(!writeFileAtomic(fresh.string(), buf.data(), buf.size(), false, nullptr, cancel));
EXPECT_TRUE(!fs::exists(fresh));
EXPECT_TRUE(!fs::exists(fresh.string() + kPartialFileSuffix));
calls = 0;
{ std::ofstream(dest.string(), std::ios::binary | std::ios::trunc) << "keep me"; }
EXPECT_TRUE(!writeFileAtomic(dest.string(), buf.data(), buf.size(), false, nullptr, cancel));
EXPECT_TRUE(readProvisionFile(dest) == "keep me");
EXPECT_TRUE(!fs::exists(partial));
}
// (d) nothing to write
{
EXPECT_TRUE(!writeFileAtomic((dir / "null.bin").string(), nullptr, 10, false));
EXPECT_TRUE(!writeFileAtomic((dir / "zero.bin").string(), buf.data(), 0, false));
EXPECT_TRUE(!fs::exists(dir / "null.bin"));
EXPECT_TRUE(!fs::exists(dir / "zero.bin"));
}
#ifndef _WIN32
// (e) executable: +x is set before the file appears under its final name
{
const fs::path exe = dir / "tool";
EXPECT_TRUE(writeFileAtomic(exe.string(), buf.data(), 1024, true));
EXPECT_TRUE((fs::status(exe).permissions() & fs::perms::owner_exec) != fs::perms::none);
}
#endif
removeTree(dir);
}
void testCopyFileAtomic()
{
using namespace dragonx::resources;
const fs::path dir = makeTempDir();
std::string body(5u * 1024u * 1024u, '\0');
for (std::size_t i = 0; i < body.size(); ++i) body[i] = static_cast<char>((i * 7u + 3u) & 0xFFu);
const fs::path src = dir / "src.bin";
{ std::ofstream out(src.string(), std::ios::binary); out.write(body.data(), static_cast<std::streamsize>(body.size())); }
#ifndef _WIN32
fs::permissions(src, fs::perms::owner_all | fs::perms::group_read | fs::perms::group_exec |
fs::perms::others_read | fs::perms::others_exec, fs::perm_options::replace); // 0755
#endif
const fs::path dst = dir / "d" / "dst.bin";
{
ProvisionProgress p;
EXPECT_TRUE(copyFileAtomic(src.string(), dst.string(), &p));
EXPECT_TRUE(readProvisionFile(dst) == body);
EXPECT_EQ(p.bytesDone.load(), static_cast<std::uint64_t>(body.size()));
EXPECT_TRUE(!fs::exists(dst.string() + kPartialFileSuffix));
#ifndef _WIN32
EXPECT_TRUE((fs::status(dst).permissions() & fs::perms::owner_exec) != fs::perms::none);
#endif
}
// Cancelled on the first poll: false, no destination, no temp.
{
const fs::path d2 = dir / "d" / "cancelled.bin";
const CancelFn cancelNow = []() { return true; };
EXPECT_TRUE(!copyFileAtomic(src.string(), d2.string(), nullptr, cancelNow));
EXPECT_TRUE(!fs::exists(d2));
EXPECT_TRUE(!fs::exists(d2.string() + kPartialFileSuffix));
}
// A missing source: false, nothing written.
{
const fs::path d3 = dir / "d" / "missing.bin";
EXPECT_TRUE(!copyFileAtomic((dir / "nope.bin").string(), d3.string()));
EXPECT_TRUE(!fs::exists(d3));
EXPECT_TRUE(!fs::exists(d3.string() + kPartialFileSuffix));
}
removeTree(dir);
}
void testBundleCopy()
{
using namespace dragonx::resources;
const fs::path root = makeTempDir();
const fs::path A = root / "A", B = root / "B", D = root / "D";
fs::create_directories(A);
fs::create_directories(B);
auto put = [](const fs::path& p, const std::string& c) {
std::ofstream(p.string(), std::ios::binary | std::ios::trunc) << c;
};
put(A / "one", "A-one");
put(B / "one", "B-one");
put(B / "two", "B-two");
const std::vector<std::string> dirs = { A.string(), B.string() };
const std::vector<std::string> names = { "one", "two", "three" }; // "three" has no source: optional
const std::string partialOne = (D / "one").string() + kPartialFileSuffix;
// The plan: the first search dir holding a name wins; names with no source are skipped.
const auto plan = planBundleCopy(dirs, D.string(), names, false);
EXPECT_EQ(plan.size(), static_cast<std::size_t>(2));
bool sawOne = false;
for (const auto& it : plan) {
if (it.name != "one") continue;
sawOne = true;
EXPECT_TRUE(fs::path(it.src) == A / "one");
EXPECT_TRUE(fs::path(it.dst) == D / "one");
}
EXPECT_TRUE(sawOne);
// A stale temp from an interrupted copy is removed by the run that copies that name.
fs::create_directories(D);
put(fs::path(partialOne), "stale");
auto r = copyBundledFiles(dirs, D.string(), names, false);
EXPECT_TRUE(r.ok);
EXPECT_EQ(r.copied, 2);
EXPECT_TRUE(r.firstFailure.empty());
EXPECT_TRUE(readProvisionFile(D / "one") == "A-one");
EXPECT_TRUE(readProvisionFile(D / "two") == "B-two");
EXPECT_TRUE(!fs::exists(D / "three"));
EXPECT_TRUE(!fs::exists(partialOne));
// Everything present: the stat-only early return copies nothing.
r = copyBundledFiles(dirs, D.string(), names, false);
EXPECT_TRUE(r.ok);
EXPECT_EQ(r.copied, 0);
EXPECT_TRUE(planBundleCopy(dirs, D.string(), names, false).empty());
// An existing destination is kept without replaceExisting and overwritten with it.
put(D / "one", "local");
r = copyBundledFiles(dirs, D.string(), { "one" }, false);
EXPECT_EQ(r.copied, 0);
EXPECT_TRUE(readProvisionFile(D / "one") == "local");
r = copyBundledFiles(dirs, D.string(), { "one" }, true);
EXPECT_TRUE(r.ok);
EXPECT_EQ(r.copied, 1);
EXPECT_TRUE(readProvisionFile(D / "one") == "A-one");
removeTree(root);
}
void testSaplingParamState()
{
using dragonx::rpc::Connection;
using State = Connection::SaplingParamState;
const fs::path dir = makeTempDir() / "sapling_state";
fs::create_directories(dir);
auto writeFile = [](const fs::path& p, const std::string& c) {
std::ofstream(p.string(), std::ios::binary | std::ios::trunc) << c;
};
const std::string spend = "fake-spend-params-contents";
const std::string output = "fake-output-params-contents";
const std::vector<std::pair<std::string, std::string>> digests = {
{ "sapling-spend.params", dragonx::util::sha256Hex(spend.data(), spend.size()) },
{ "sapling-output.params", dragonx::util::sha256Hex(output.data(), output.size()) },
};
writeFile(dir / "sapling-spend.params", spend);
EXPECT_TRUE(Connection::saplingParamStateIn(dir.string(), digests) == State::Missing); // one absent
writeFile(dir / "sapling-output.params", output);
EXPECT_TRUE(Connection::saplingParamStateIn(dir.string(), digests) == State::NeedsHash); // no marker yet
EXPECT_TRUE(Connection::verifySaplingParamsIn(dir.string(), digests));
EXPECT_TRUE(Connection::saplingParamStateIn(dir.string(), digests) == State::Verified);
writeFile(dir / "sapling-spend.params", spend + "-longer"); // size changed
EXPECT_TRUE(Connection::saplingParamStateIn(dir.string(), digests) == State::NeedsHash);
EXPECT_TRUE(Connection::saplingParamStateIn("", digests) == State::Missing);
removeTree(dir.parent_path());
}
// Wallets dialog: the pure folder walk that moved out of WalletsDialog::scan() (now run on a worker). // Wallets dialog: the pure folder walk that moved out of WalletsDialog::scan() (now run on a worker).
void testWalletFolderScan() void testWalletFolderScan()
{ {
@@ -13688,6 +13961,12 @@ int main()
#endif #endif
testPlatformEnsureDirectory(); testPlatformEnsureDirectory();
testVerifySaplingParams(); testVerifySaplingParams();
testStartupProvisionState();
testResourceNeedsWrite();
testWriteFileAtomic();
testCopyFileAtomic();
testBundleCopy();
testSaplingParamState();
testWalletFolderScan(); testWalletFolderScan();
testExternalScanCache(); testExternalScanCache();
testConnectHasStalled(); testConnectHasStalled();