diff --git a/res/lang/de.json b/res/lang/de.json index d52b8e3..594c194 100644 --- a/res/lang/de.json +++ b/res/lang/de.json @@ -1926,6 +1926,8 @@ "sort_date_newest": "Neueste zuerst", "sort_date_oldest": "Älteste zuerst", "start_mining": "Mining starten", + "startup_files_preparing": "Wallet-Dateien werden vorbereitet... %d%%", + "startup_files_verifying": "Wallet-Dateien werden überprüft...", "status": "Status", "stop_external": "Externen Daemon stoppen", "stop_mining": "Mining stoppen", diff --git a/res/lang/es.json b/res/lang/es.json index fc35386..0166240 100644 --- a/res/lang/es.json +++ b/res/lang/es.json @@ -1926,6 +1926,8 @@ "sort_date_newest": "Más recientes primero", "sort_date_oldest": "Más antiguas primero", "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", "stop_external": "Detener daemon externo", "stop_mining": "Detener Minería", diff --git a/res/lang/fr.json b/res/lang/fr.json index d669d37..0bfc071 100644 --- a/res/lang/fr.json +++ b/res/lang/fr.json @@ -1926,6 +1926,8 @@ "sort_date_newest": "Plus récent d'abord", "sort_date_oldest": "Plus ancien d'abord", "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", "stop_external": "Arrêter le daemon externe", "stop_mining": "Arrêter le minage", diff --git a/res/lang/ja.json b/res/lang/ja.json index 824f581..e6429dd 100644 --- a/res/lang/ja.json +++ b/res/lang/ja.json @@ -1926,6 +1926,8 @@ "sort_date_newest": "新しい順", "sort_date_oldest": "古い順", "start_mining": "マイニング開始", + "startup_files_preparing": "ウォレットファイルを準備しています... %d%%", + "startup_files_verifying": "ウォレットファイルを検証しています...", "status": "ステータス", "stop_external": "外部デーモンを停止", "stop_mining": "マイニング停止", diff --git a/res/lang/ko.json b/res/lang/ko.json index 5e484ba..4e46102 100644 --- a/res/lang/ko.json +++ b/res/lang/ko.json @@ -1926,6 +1926,8 @@ "sort_date_newest": "최신순", "sort_date_oldest": "오래된 순", "start_mining": "채굴 시작", + "startup_files_preparing": "지갑 파일 준비 중... %d%%", + "startup_files_verifying": "지갑 파일 확인 중...", "status": "상태", "stop_external": "외부 데몬 중지", "stop_mining": "채굴 중지", diff --git a/res/lang/pt.json b/res/lang/pt.json index fe602bf..d89d386 100644 --- a/res/lang/pt.json +++ b/res/lang/pt.json @@ -1926,6 +1926,8 @@ "sort_date_newest": "Mais recentes primeiro", "sort_date_oldest": "Mais antigas primeiro", "start_mining": "Iniciar Mineração", + "startup_files_preparing": "Preparando os arquivos da carteira... %d%%", + "startup_files_verifying": "Verificando os arquivos da carteira...", "status": "Status", "stop_external": "Parar daemon externo", "stop_mining": "Parar Mineração", diff --git a/res/lang/ru.json b/res/lang/ru.json index b638885..4622296 100644 --- a/res/lang/ru.json +++ b/res/lang/ru.json @@ -1926,6 +1926,8 @@ "sort_date_newest": "Сначала новые", "sort_date_oldest": "Сначала старые", "start_mining": "Начать майнинг", + "startup_files_preparing": "Подготовка файлов кошелька... %d%%", + "startup_files_verifying": "Проверка файлов кошелька...", "status": "Статус", "stop_external": "Остановить внешний daemon", "stop_mining": "Остановить майнинг", diff --git a/res/lang/zh.json b/res/lang/zh.json index f5fb7be..11cf095 100644 --- a/res/lang/zh.json +++ b/res/lang/zh.json @@ -1926,6 +1926,8 @@ "sort_date_newest": "最新优先", "sort_date_oldest": "最早优先", "start_mining": "开始挖矿", + "startup_files_preparing": "正在准备钱包文件... %d%%", + "startup_files_verifying": "正在验证钱包文件...", "status": "状态", "stop_external": "停止外部守护进程", "stop_mining": "停止挖矿", diff --git a/res/themes/ui.toml b/res/themes/ui.toml index 6d49764..55f3f4d 100644 --- a/res/themes/ui.toml +++ b/res/themes/ui.toml @@ -1203,6 +1203,16 @@ connection-dot = { opacity = 1.0 } height = 30 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] width = 350.0 height = 60.0 diff --git a/src/app.cpp b/src/app.cpp index a4ae24d..436d9e3 100644 --- a/src/app.cpp +++ b/src/app.cpp @@ -274,7 +274,10 @@ void App::applyPoolAutoBalance(const util::PoolStatsService::Snapshot& snap) 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(); } @@ -348,11 +351,11 @@ bool App::init() { DEBUG_LOGF("Initializing ObsidianDragon...\n"); - // Extract embedded resources (Sapling params, asmap.dat) on first run - if (resources::hasEmbeddedResources() && resources::needsParamsExtraction()) { - DEBUG_LOGF("First run - extracting bundled resources...\n"); - resources::extractEmbeddedResources(); - } + ui_thread_id_ = std::this_thread::get_id(); + // Write the node files (Sapling params, asmap, node, miner) on first run / after an upgrade OFF the UI + // thread: the first frame no longer waits for ~88 MB of disk writes. startEmbeddedDaemon() defers (UI + // thread) or waits (workers) until the job is done, so the node never starts over half-written files. + startStartupFilesProvision(); // Initialize settings settings_ = std::make_unique(); @@ -525,7 +528,10 @@ bool App::init() // 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. 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 } @@ -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. 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). processWalletSwitchRevert(); @@ -1845,6 +1854,10 @@ void App::render() // (Pins current_page_ too, before the sidebar reads it further down.) 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. ensureLogoTexture(); @@ -4294,6 +4307,160 @@ void App::renderBackupDialog() 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(); + + 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(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() { if (!supportsFullNodeLifecycleActions()) return; // lite / no embedded-daemon build @@ -5550,6 +5717,31 @@ bool App::startEmbeddedDaemon() 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 if (!rpc::Connection::verifySaplingParams()) { 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"); } else { - // Fallback: check for params bundled alongside the executable - // (zip distributions bundle sapling-*.params next to the binary) - namespace fs = std::filesystem; - std::string exe_dir = util::Platform::getExecutableDirectory(); - std::string daemon_dir = resources::getDaemonDirectory(); - const char* paramFiles[] = { "sapling-spend.params", "sapling-output.params", "asmap.dat" }; - bool copied = false; - if (!exe_dir.empty()) { + // Fallback: params bundled alongside the executable (zip / AppImage / .app). The startup job + // normally did this already; this repairs a failed or corrupt copy. replaceExisting: we only get + // here because verification FAILED, so an existing daemon-dir param is suspect (the old + // `if (exists) continue` kept a truncated param forever). + const std::string daemon_dir = resources::getDaemonDirectory(); + if (!util::Platform::getExecutableDirectory().empty()) { std::string dirErr; if (!util::Platform::ensureDirectory(daemon_dir, &dirErr)) { setDaemonStatus(dirErr); DEBUG_LOGF("[ERROR] %s\n", dirErr.c_str()); return false; } - std::error_code ec; - - // On macOS .app bundles, params are in Contents/Resources/ - // while the executable is in Contents/MacOS/ - std::vector 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"); } else { setDaemonStatus(TR("sb_sapling_not_found")); @@ -5625,49 +5797,22 @@ bool App::startEmbeddedDaemon() } } - // Ensure asmap.dat and daemon binaries are copied from the bundle - // to the daemon directory even if sapling params already existed. + // Ensure asmap.dat and the daemon binaries are copied from the bundle to the daemon directory even if the + // 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; - std::string exe_dir = util::Platform::getExecutableDirectory(); - std::string daemon_dir = resources::getDaemonDirectory(); - if (!exe_dir.empty()) { + const std::string daemon_dir = resources::getDaemonDirectory(); + if (!util::Platform::getExecutableDirectory().empty()) { std::string dirErr; if (!util::Platform::ensureDirectory(daemon_dir, &dirErr)) { setDaemonStatus(dirErr); DEBUG_LOGF("[ERROR] %s\n", dirErr.c_str()); return false; } - std::error_code ec; - - std::vector 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 - 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) { + static const std::vector kExtraFiles = { "asmap.dat", "dragonxd", "dragonx-cli", "dragonx-tx" }; + const auto rep = resources::copyBundledFiles(resources::bundleSearchDirs(), daemon_dir, kExtraFiles, + /*replaceExisting=*/false); + if (!rep.ok) { char buf[512]; snprintf(buf, sizeof(buf), TR("sb_daemon_files_failed"), daemon_dir.c_str()); setDaemonStatus(buf); @@ -6118,42 +6263,58 @@ void App::reinstallBundledDaemon() ui::Notifications::instance().warning(TR("appx_daemon_reinstall_in_progress")); 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; DEBUG_LOGF("[App] Reinstalling bundled daemon binary — stopping daemon first\n"); ui::Notifications::instance().info(TR("appx_installing_bundled_daemon")); - async_tasks_.submit("reinstall-daemon", [this](const util::AsyncTaskManager::Token& token) { - AppDaemonLifecycleRuntime runtime(*this); - daemon::AsyncLifecycleTaskContext ctx(token, shutting_down_); - // Stop the running node so its binary is free: ours, or the external/leftover dragonxd we're attached - // to (it would keep the RPC port and the fresh daemon couldn't start). Bounded, never killed. - stopEmbeddedDaemon(StopMode::Background, StopScope::Connected); - // Wait (bounded, ~120s) for the old node's PROCESS to exit, not just its RPC port: dragonxd unbinds - // RPC first and can take a minute or more to flush and close its wallet. Until it exits its exe is - // locked on Windows (the overwrite would fail) and it holds the datadir lock (a restart would fail). - auto stillUp = [] { - return daemon::EmbeddedDaemon::isRpcPortInUse() || daemon::EmbeddedDaemon::isDaemonProcessRunning(); - }; - for (int i = 0; i < 1200 && stillUp() && !ctx.cancelled() && !ctx.shuttingDown(); ++i) { - ctx.sleepForMs(100); - } - if (ctx.cancelled() || ctx.shuttingDown()) return; - if (stillUp()) { - DEBUG_LOGF("[App] Reinstall bundled daemon: aborted — a dragonxd process is still running; " - "binaries left untouched and no new node started\n"); - pending_reinstall_node_busy_.store(true, std::memory_order_relaxed); // told on the UI thread - return; - } - // 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(); - } - }); + // This task owns the node for its whole run: it starts the node itself once the new files are in place, so + // drop a start deferred behind the startup files job and gate tryConnect() + the pollStartupFiles() replay + // (either could otherwise launch the OLD binary while the new one is being written). + daemon_start_after_files_ = false; + daemon_restarting_ = true; + try { + async_tasks_.submit("reinstall-daemon", [this](const util::AsyncTaskManager::Token& token) { + struct Rearm { std::atomic& f; ~Rearm() { f = false; } } rearm{daemon_restarting_}; // every exit + AppDaemonLifecycleRuntime runtime(*this); + daemon::AsyncLifecycleTaskContext ctx(token, shutting_down_); + // Stop the running node so its binary is free: ours, or the external/leftover dragonxd we're attached + // to (it would keep the RPC port and the fresh daemon couldn't start). Bounded, never killed. + stopEmbeddedDaemon(StopMode::Background, StopScope::Connected); + // Wait (bounded, ~120s) for the old node's PROCESS to exit, not just its RPC port: dragonxd unbinds + // RPC first and can take a minute or more to flush and close its wallet. Until it exits its exe is + // locked on Windows (the overwrite would fail) and it holds the datadir lock (a restart would fail). + auto stillUp = [] { + return daemon::EmbeddedDaemon::isRpcPortInUse() || daemon::EmbeddedDaemon::isDaemonProcessRunning(); + }; + for (int i = 0; i < 1200 && stillUp() && !ctx.cancelled() && !ctx.shuttingDown(); ++i) { + ctx.sleepForMs(100); + } + if (ctx.cancelled() || ctx.shuttingDown()) return; + if (stillUp()) { + DEBUG_LOGF("[App] Reinstall bundled daemon: aborted — a dragonxd process is still running; " + "binaries left untouched and no new node started\n"); + pending_reinstall_node_busy_.store(true, std::memory_order_relaxed); // told on the UI thread + return; + } + // 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() @@ -6199,6 +6360,9 @@ void App::deleteBlockchainData() 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()) { return false; } @@ -7548,7 +7712,8 @@ bool App::hasPendingBackgroundResults() const { return (worker_ && worker_->hasPendingResults()) || (fast_worker_ && fast_worker_->hasPendingResults()) || (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 diff --git a/src/app.h b/src/app.h index e9c8fb6..88de215 100644 --- a/src/app.h +++ b/src/app.h @@ -38,6 +38,7 @@ #include "services/wallet_security_controller.h" #include "services/wallet_security_workflow.h" #include "util/async_task_manager.h" +#include "resources/provision_state.h" #include "util/background_sampler.h" #include "util/coalescing_writer.h" #include "util/fetch_backoff.h" @@ -768,9 +769,10 @@ public: void verifyDebugCredential(const std::string& secret, std::function cb); /// @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 - /// (hasImmediateWork) and renders a frame when it is true, so the result shows without input, even while the - /// window is hidden. A new lane whose results are applied in update() adds its term HERE. + /// (the RPC worker lanes, the HTTP lane, lite wallet ops, the startup node-file job). main.cpp's idle loop + /// polls this every <= 200 ms (hasImmediateWork) and renders a frame when it is true, so the result shows + /// 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 hasTransactionSendProgress() const { return send_progress_active_ || send_submissions_in_flight_ > 0 || !pending_opids_.empty(); } std::string transactionSendProgressText() const; @@ -1191,6 +1193,15 @@ private: std::chrono::steady_clock::time_point fetchedAt{}; }; std::unordered_map 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 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_; bool pending_antivirus_dialog_ = false; // Show Windows Defender help dialog diff --git a/src/app_network.cpp b/src/app_network.cpp index 67a45b0..651d19a 100644 --- a/src/app_network.cpp +++ b/src/app_network.cpp @@ -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 // wallet opens cleanly; a bad/corrupt wallet exits during init and we revert. 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 // moment; if not, the wallet is bad. (A valid launch keeps the process alive while it syncs.) if (ok && !shutting_down_) { @@ -3511,7 +3512,8 @@ void App::postPoolMinerJob(int threads, bool retargetOnly) daemon::XmrigManager::Config cfgCopy = cfg; const int target = cfg.threads; 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 // 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. @@ -3520,6 +3522,19 @@ void App::postPoolMinerJob(int threads, bool retargetOnly) if (xmrig_manager_->getRequestedThreads() == target) return; } 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 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 std::string err = ok ? std::string() : xmrig_manager_->getLastError(); if (!ok) clearPoolThreadsTarget(target); diff --git a/src/resources/embedded_resources.cpp b/src/resources/embedded_resources.cpp index 3742f1c..eb4f157 100644 --- a/src/resources/embedded_resources.cpp +++ b/src/resources/embedded_resources.cpp @@ -1,10 +1,15 @@ #include "embedded_resources.h" #include "../util/platform.h" #include "../util/logger.h" +#include +#include #include #include +#include +#include #include #include +#include #include #include @@ -12,6 +17,7 @@ #include #include #else +#include #include #include #include @@ -205,272 +211,461 @@ std::string getParamsDirectory() #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; } - - // Check daemon directory (dragonx/) — the only extraction target - std::string daemonDir = getDaemonDirectory(); + return true; +} + +// 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 ".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 - const char pathSep = '\\'; + h_ = CreateFileA(path.c_str(), GENERIC_WRITE, 0, nullptr, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr); #else - const char pathSep = '/'; -#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; + fd_ = ::open(path.c_str(), O_WRONLY | O_CREAT | O_TRUNC | O_CLOEXEC, 0644); #endif + } + ~TempSink() { (void)close(); } + TempSink(const TempSink&) = delete; + TempSink& operator=(const TempSink&) = delete; -#ifdef HAS_EMBEDDED_XMRIG - const auto* xmrigRes = getEmbeddedResource(RESOURCE_XMRIG); - std::string xmrigPath = daemonDir + pathSep + RESOURCE_XMRIG; - if (xmrigRes && resourceNeedsUpdate(xmrigRes, xmrigPath)) return true; + bool ok() const + { +#ifdef _WIN32 + return h_ != INVALID_HANDLE_VALUE; +#else + return fd_ >= 0; #endif + } + bool write(const void* data, std::size_t n) + { + const char* p = static_cast(data); + while (n > 0) { +#ifdef _WIN32 + const DWORD chunk = static_cast(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(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; +#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(diskSize) != embeddedSize; } // 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. -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 (!std::filesystem::exists(destPath)) return true; - 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(res->size); + return resourceNeedsWrite(res->size, /*refreshOnSizeMismatch=*/true, destPath); } -static bool extractResource(const EmbeddedResource* res, const std::string& destPath) +bool needsParamsExtraction() { - if (!res || !res->data || res->size == 0) { - return false; + if (!hasEmbeddedResources()) 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; } - - // Create parent directories - std::filesystem::path path(destPath); - if (path.has_parent_path()) { - std::error_code ec; - std::filesystem::create_directories(path.parent_path(), ec); - if (ec) { - DEBUG_LOGF("[ERROR] Failed to create directory %s: %s\n", - path.parent_path().string().c_str(), ec.message().c_str()); - return false; - } - } - - // Write file - std::ofstream file(destPath, std::ios::binary); - if (!file) { - // The destination may be locked because the previous daemon is still using the binary: - // Windows locks a running .exe against truncation, Linux returns ETXTBSY. Both platforms - // DO allow renaming/moving such a file — the running process keeps the moved copy — so move - // the stale binary aside and write a fresh one at the original path. - 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()); + return false; +} + +bool writeFileAtomic(const std::string& destPath, const std::uint8_t* data, std::size_t size, bool executable, + ProvisionProgress* progress, const CancelFn& cancelled) +{ + if (!data || size == 0) return false; + if (!prepareParentDir(destPath)) return false; + const std::string tmp = destPath + kPartialFileSuffix; + std::error_code ec; + bool good = true; + { + TempSink f(tmp); + if (!f.ok()) { DEBUG_LOGF("[ERROR] Failed to open %s for writing\n", tmp.c_str()); return false; } + for (std::size_t off = 0; off < size; off += kCopyChunk) { + if (cancelled && cancelled()) { good = false; break; } + const std::size_t n = std::min(kCopyChunk, size - off); + if (!f.write(data + off, n)) { + DEBUG_LOGF("[ERROR] Failed to write %zu bytes to %s\n", n, tmp.c_str()); + good = false; break; } + if (progress) progress->bytesDone.fetch_add(n, std::memory_order_relaxed); } - if (!file) { - DEBUG_LOGF("[ERROR] Failed to open %s for writing\n", destPath.c_str()); - return false; - } - } - - file.write(reinterpret_cast(res->data), res->size); - if (!file) { - DEBUG_LOGF("[ERROR] Failed to write %zu bytes to %s\n", res->size, destPath.c_str()); - return false; - } - - DEBUG_LOGF("[INFO] Extracted %s (%zu bytes)\n", destPath.c_str(), res->size); + // Flush on the same handle BEFORE the rename: a power cut can't leave a truncated file under the final name. + if (good && !f.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) { std::filesystem::remove(tmp, ec); return false; } +#ifndef _WIN32 + if (executable) ::chmod(tmp.c_str(), 0755); // before it becomes visible under the final name +#else + (void)executable; +#endif + if (!replaceFromTemp(tmp, destPath)) return false; + DEBUG_LOGF("[INFO] Extracted %s (%zu bytes)\n", destPath.c_str(), size); 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 buf(kCopyChunk); + for (;;) { + if (cancelled && cancelled()) { good = false; break; } + in.read(buf.data(), static_cast(buf.size())); + const std::streamsize got = in.gcount(); + if (got > 0) { + if (!out.write(buf.data(), static_cast(got))) { + DEBUG_LOGF("[ERROR] Failed to write %s\n", tmp.c_str()); + good = false; break; + } + if (progress) progress->bytesDone.fetch_add(static_cast(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()) { DEBUG_LOGF("[ERROR] No embedded resources available\n"); return false; } - - bool success = true; - -#ifdef _WIN32 - const char pathSep = '\\'; -#else - const char pathSep = '/'; -#endif - - // All files go to /dragonx/ - std::string daemonDir = getDaemonDirectory(); + std::lock_guard lock(provisionMutex()); - // Extract Sapling params to daemon directory alongside dragonxd - const EmbeddedResource* spendRes = getEmbeddedResource(RESOURCE_SAPLING_SPEND); - if (spendRes) { - 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 + // All files go to /dragonx/ — NOT the data directory (running dragonxd.exe from inside + // the data directory, where it writes chain data / debug.log / lock files, crashes on some Windows machines). + const std::string daemonDir = getDaemonDirectory(); DEBUG_LOGF("[INFO] Daemon extraction directory: %s\n", daemonDir.c_str()); - - // Daemon binaries are placed ONLY when missing — never auto-overwritten on a size mismatch - // (the user may run a specific dragonxd; replacing it is an explicit Settings action). - const EmbeddedResource* daemonRes = getEmbeddedResource(RESOURCE_DRAGONXD); - if (daemonRes) { - std::string dest = daemonDir + pathSep + RESOURCE_DRAGONXD; - if (!std::filesystem::exists(dest)) { - DEBUG_LOGF("[INFO] Extracting dragonxd (%zu MB)...\n", daemonRes->size / (1024*1024)); - if (!extractResource(daemonRes, dest)) { - success = false; - } -#ifndef _WIN32 - else { chmod(dest.c_str(), 0755); } -#endif + std::error_code ec; + std::filesystem::create_directories(daemonDir, ec); + ec.clear(); + + // Temp files left by an interrupted run (killed process, power cut). Only OUR suffix. + for (const auto& e : kProvisionEntries) { + std::filesystem::remove(joinDir(daemonDir, e.name) + kPartialFileSuffix, ec); + ec.clear(); + } + + struct Todo { const ProvisionEntry* entry; const EmbeddedResource* res; std::string dest; }; + std::vector todo; + std::uint64_t total = 0; + 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); - if (cliRes) { - std::string dest = daemonDir + pathSep + RESOURCE_DRAGONX_CLI; - if (!std::filesystem::exists(dest)) { - DEBUG_LOGF("[INFO] Extracting dragonx-cli (%zu MB)...\n", cliRes->size / (1024*1024)); - if (!extractResource(cliRes, dest)) { - success = false; - } -#ifndef _WIN32 - else { chmod(dest.c_str(), 0755); } -#endif - } + // Best-effort cleanup of ".old" copies left by an in-use replacement. Once the old process has exited the + // file is no longer locked and removes cleanly; if it still runs, the remove fails and we retry next run. + for (const auto& e : kProvisionEntries) { + std::filesystem::remove(joinDir(daemonDir, e.name) + std::string(".old"), ec); + ec.clear(); } - - 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; } +std::vector bundleSearchDirs() +{ + std::vector 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& saplingParamFileNames() +{ + static const std::vector v = { "sapling-spend.params", "sapling-output.params" }; + return v; +} + +const std::vector& nodeBundleFileNames() +{ + static const std::vector v = { "sapling-spend.params", "sapling-output.params", "asmap.dat", + "dragonxd", "dragonx-cli", "dragonx-tx" }; + return v; +} + +std::vector planBundleCopy(const std::vector& searchDirs, const std::string& destDir, + const std::vector& names, bool replaceExisting) +{ + namespace fs = std::filesystem; + std::vector 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& searchDirs, const std::string& destDir, + const std::vector& 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 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() { #ifdef HAS_EMBEDDED_WALLET_REBUILD @@ -484,12 +679,11 @@ std::string ensureWalletRebuildHelperExtracted() const std::string dir = getDaemonDirectory(); const std::string dest = dir + sep + RESOURCE_DRAGONX_WALLET_REBUILD; 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 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); - if (!extractResource(res, dest)) return {}; -#ifndef _WIN32 - chmod(dest.c_str(), 0755); -#endif + if (!extractResourceLocked(res, dest, /*executable=*/true)) return {}; DEBUG_LOGF("[INFO] Extracted wallet-rebuild helper on demand: %s\n", dest.c_str()); return dest; #else @@ -623,14 +817,11 @@ bool forceExtractXmrig() } DEBUG_LOGF("[INFO] forceExtractXmrig: extracting xmrig (%zu MB) to %s\n", xmrigRes->size / (1024*1024), dest.c_str()); - if (!extractResource(xmrigRes, dest)) { + std::lock_guard lock(provisionMutex()); + if (!extractResourceLocked(xmrigRes, dest, /*executable=*/true)) { DEBUG_LOGF("[ERROR] forceExtractXmrig: extraction failed\n"); return false; } -#ifndef _WIN32 - // Set executable permission on Linux - chmod(dest.c_str(), 0755); -#endif return true; #else return false; @@ -722,6 +913,7 @@ BundledDaemonInfo getBundledDaemonInfo() bool reextractBundledDaemon() { #ifdef HAS_EMBEDDED_DAEMON + std::lock_guard lock(provisionMutex()); std::string daemonDir = getDaemonDirectory(); #ifdef _WIN32 const char pathSep = '\\'; @@ -736,15 +928,12 @@ bool reextractBundledDaemon() if (!res) continue; std::string dest = daemonDir + pathSep + name; 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); ok = false; continue; } wroteAny = true; -#ifndef _WIN32 - chmod(dest.c_str(), 0755); -#endif } return ok && wroteAny; #else @@ -770,8 +959,13 @@ std::string getXmrigPath() std::string embeddedPath = daemonDir + pathSep + xmrigName; DEBUG_LOGF("[DEBUG] getXmrigPath: checking embedded path %s\n", embeddedPath.c_str()); if (!std::filesystem::exists(embeddedPath)) { - DEBUG_LOGF("[DEBUG] getXmrigPath: not found, force re-extracting xmrig\n"); - forceExtractXmrig(); + // Serialise with the startup job: if it just wrote xmrig, the re-check finds it complete. + std::lock_guard 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)) { DEBUG_LOGF("[INFO] getXmrigPath: found at %s\n", embeddedPath.c_str()); diff --git a/src/resources/embedded_resources.h b/src/resources/embedded_resources.h index 38c5a7e..4a7da16 100644 --- a/src/resources/embedded_resources.h +++ b/src/resources/embedded_resources.h @@ -1,7 +1,12 @@ #pragma once -#include +#include #include +#include +#include +#include + +#include "provision_state.h" namespace dragonx { namespace resources { @@ -20,9 +25,47 @@ bool hasEmbeddedResources(); // Returns nullptr if not found or not embedded const EmbeddedResource* getEmbeddedResource(const std::string& name); -// Extract all embedded resources to appropriate locations -// Returns true if all resources extracted successfully -bool extractEmbeddedResources(); +using CancelFn = std::function; // polled between 4 MB chunks; true = stop now + +// Suffix of our own temp files in the daemon dir (".partial"). NOT ".tmp": the in-app miner/node +// updaters stage ".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 bundleSearchDirs(); // macOS: .app Contents/Resources first; then exe dir +const std::vector& saplingParamFileNames(); // sapling-spend.params, sapling-output.params +const std::vector& 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 planBundleCopy(const std::vector& searchDirs, const std::string& destDir, + const std::vector& 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& searchDirs, const std::string& destDir, + const std::vector& names, bool replaceExisting, + ProvisionProgress* progress = nullptr, const CancelFn& cancelled = {}); // Check if params need to be extracted (not already present) bool needsParamsExtraction(); diff --git a/src/resources/provision_state.h b/src/resources/provision_state.h new file mode 100644 index 0000000..62313b1 --- /dev/null +++ b/src/resources/provision_state.h @@ -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 +#include +#include +#include +#include + +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 bytesDone{0}; + std::atomic bytesTotal{0}; + std::atomic phase{static_cast(ProvisionPhase::Idle)}; + void setPhase(ProvisionPhase p) { phase.store(static_cast(p), std::memory_order_relaxed); } + ProvisionPhase getPhase() const { return static_cast(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((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 paramRepairTried{false}; + + void begin() { + std::lock_guard 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 lk(m_); state_ = State::Reaped; } + void finish(const Result& r) { + { std::lock_guard lk(m_); result_ = r; state_ = State::Finished; } + cv_.notify_all(); + } + bool isRunning() const { std::lock_guard 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 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 lk(m_); return state_ == State::Finished; } + // UI thread: true exactly once, after the job finished. + bool takeCompletion(Result* out) { + std::lock_guard 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 lk(m_); + cv_.wait_for(lk, std::chrono::milliseconds(timeoutMs), [&] { return state_ != State::Running; }); + return state_ != State::Running; + } + double elapsedSeconds() const { + std::lock_guard lk(m_); + if (state_ != State::Running) return 0.0; + return std::chrono::duration(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 diff --git a/src/rpc/connection.cpp b/src/rpc/connection.cpp index 9c8bfc1..5b0ecc4 100644 --- a/src/rpc/connection.cpp +++ b/src/rpc/connection.cpp @@ -241,6 +241,30 @@ bool Connection::verifySaplingParams() return verifySaplingParamsIn(getSaplingParamsDir(), digests); } +Connection::SaplingParamState Connection::saplingParamStateIn( + const std::string& dir, + const std::vector>& digests) +{ + if (dir.empty() || digests.empty()) return SaplingParamState::Missing; + std::vector 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> 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 config; diff --git a/src/rpc/connection.h b/src/rpc/connection.h index fc0d5f3..dc1064e 100644 --- a/src/rpc/connection.h +++ b/src/rpc/connection.h @@ -83,6 +83,14 @@ public: const std::string& dir, const std::vector>& 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>& digests); + /** * @brief Get the Sapling params directory */ diff --git a/src/util/i18n.cpp b/src/util/i18n.cpp index b40246f..b88d0b6 100644 --- a/src/util/i18n.cpp +++ b/src/util/i18n.cpp @@ -1513,6 +1513,8 @@ void I18n::loadBuiltinEnglish() "Close the program using it (or free the port), then restart — the wallet can't start " "its own node while the port is taken."; 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_not_found"] = "Sapling parameters not found."; strings_["sb_daemon_extract_failed"] = "Failed to write daemon files — check free disk space and permissions."; diff --git a/tests/test_phase4.cpp b/tests/test_phase4.cpp index 0bf4bb7..30e8ad7 100644 --- a/tests/test_phase4.cpp +++ b/tests/test_phase4.cpp @@ -30,6 +30,7 @@ #include "rpc/rpc_failure.h" #include "rpc/rpc_busy_gate.h" #include "resources/embedded_resources.h" +#include "resources/provision_state.h" #include "services/history_scan_policy.h" #include "services/wallet_scan_governor.h" #include "services/scan_policy.h" @@ -3341,6 +3342,278 @@ void testVerifySaplingParams() 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(f)), std::istreambuf_iterator()); +} + +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 buf(9u * 1024u * 1024u); // > two 4 MB chunks + for (std::size_t i = 0; i < buf.size(); ++i) buf[i] = static_cast((i * 31u + (i >> 13)) & 0xFFu); + const std::string expect(reinterpret_cast(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(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((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(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(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 dirs = { A.string(), B.string() }; + const std::vector 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(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> 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). void testWalletFolderScan() { @@ -13688,6 +13961,12 @@ int main() #endif testPlatformEnsureDirectory(); testVerifySaplingParams(); + testStartupProvisionState(); + testResourceNeedsWrite(); + testWriteFileAtomic(); + testCopyFileAtomic(); + testBundleCopy(); + testSaplingParamState(); testWalletFolderScan(); testExternalScanCache(); testConnectHasStalled();