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
+47 -4
View File
@@ -1,7 +1,12 @@
#pragma once
#include <string>
#include <cstddef>
#include <cstdint>
#include <functional>
#include <string>
#include <vector>
#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<bool()>; // polled between 4 MB chunks; true = stop now
// 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)
bool needsParamsExtraction();