perf(wallets): walk added wallet folders off the UI thread

Every open of the Wallets dialog, Refresh, folder add or remove, and Open
on a stale row walked each user-added folder recursively on the UI thread
before the dialog drew: up to 40,000 entries per folder, roughly
50-150 ms warm on Linux and seconds on a cold disk, a network share or
Windows.

The datadir listing (a few top-level files) stays synchronous, so a
wallet just created shows at once. The recursive walk runs on a detached
worker with the same depth, hit and entry limits; its rows are merged at
the start of a later frame and only the new rows are probed.

- The dialog opens at once with the datadir rows. The external rows
  arrive when the walk ends, with "Scanning folders..." beside the
  scanned-folders caption, and the card grows once.
- Reopening within 60 s with the same folders reuses the last walk
  without touching the disk, so its rows and sizes can be up to a minute
  stale: a wallet copied into a folder in that minute needs Refresh.
  Refresh and Open on a stale row always walk again.
- While a walk or a wallet probe runs (up to 30 s) the dialog keeps
  frames coming, so rows and badges appear without moving the mouse.
- The 200-row cap now holds across all folders; each extra folder could
  add one row past it.

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 1658950386
commit a700d2c7ee
12 changed files with 455 additions and 144 deletions
+1
View File
@@ -383,6 +383,7 @@ void I18n::loadBuiltinEnglish()
strings_["wallets_external_tt"] = "Outside your data directory \xE2\x80\x94 Open links it in place (no copy).";
strings_["wallets_scan_folder"] = "Scan another folder for wallets\xE2\x80\xA6";
strings_["wallets_scanned_folders"] = "Scanned folders:";
strings_["wallets_scanning"] = "Scanning folders\xE2\x80\xA6";
strings_["wallets_remove_folder"] = "Stop scanning this folder";
strings_["wallets_empty_hint"] = "Scan a folder to find more wallets";
strings_["wallets_folder_invalid"] = "That folder doesn't exist.";
+144
View File
@@ -0,0 +1,144 @@
// DragonX Wallet - ImGui Edition
// Copyright 2024-2026 The Hush Developers
// Released under the GPLv3
//
// wallet_folder_scan.h - enumerate wallet .dat files in the datadir (top level) and in user-added folders
// (bounded recursive walk). Pure filesystem logic (no UI, no App), moved verbatim out of
// WalletsDialog::scan() so the external walk can run on a worker thread and be unit-tested.
#pragma once
#include <atomic>
#include <cstddef>
#include <filesystem>
#include <string>
#include <system_error>
#include <unordered_set>
#include <vector>
#include "wallet_file_probe.h" // isInPlaceLinkName
namespace dragonx {
namespace util {
struct ScannedWallet {
std::string fileName;
std::string dir; // the file's ACTUAL parent (may be a subdir) — Open reads dir + "/" + fileName
std::string canonPath; // symlink-resolved, absolute: the STABLE identity of the physical file (two paths
// reaching one wallet share it); used for the in-place link name + row de-dup
bool inDatadir = false;
long long sizeBytes = 0;
};
struct WalletScanLimits {
int maxDepth = 8; // don't descend deeper than this below a chosen folder
std::size_t maxHits = 200; // cap on total rows listed (datadir + external)
int maxVisited = 40000; // cap on entries walked per external folder
};
// Node/daemon .dat files that are NOT wallets (a user can point the picker at a folder that IS or CONTAINS a
// datadir, so these are filtered everywhere or the list fills with blk*/rev* block files, peers.dat, …).
inline bool isWalletNodeArtifact(const std::string& n) {
static const char* kExact[] = { "peers.dat", "banlist.dat", "fee_estimates.dat",
"mempool.dat", "asmap.dat", "zindex.dat" };
for (const char* e : kExact) if (n == e) return true;
return n.rfind("blk", 0) == 0 || n.rfind("rev", 0) == 0; // blk00000.dat / rev00000.dat
}
// Directory names a recursive external scan never descends into (no user wallets; would burn the budget).
inline bool isWalletNodeSubtree(const std::string& n) {
return n == "blocks" || n == "chainstate" || n == "database" || n == "indexes" || n == "index";
}
inline std::string canonicalWalletPath(const std::filesystem::path& p) {
namespace fs = std::filesystem;
std::error_code ec;
fs::path c = fs::canonical(p, ec);
if (ec) c = fs::weakly_canonical(p, ec);
return c.empty() ? p.string() : c.string();
}
// String checks FIRST (no syscalls; most entries in a big tree are neither .dat nor wallets), then stat /
// canonical only for plausible candidates. Datadir: wallet-prefixed, in-place links hidden. External: any
// .dat except node junk. One row per physical wallet via seenCanon.
inline bool considerWalletFile(const std::filesystem::path& p, bool inDatadir,
std::unordered_set<std::string>& seenCanon, ScannedWallet& out) {
namespace fs = std::filesystem;
const std::string name = p.filename().string();
if (name.size() <= 4 || name.substr(name.size() - 4) != ".dat") return false;
if (inDatadir) { if (name.rfind("wallet", 0) != 0 || isInPlaceLinkName(name)) return false; }
else { if (isWalletNodeArtifact(name)) return false; }
std::error_code ec;
if (!fs::is_regular_file(p, ec)) return false;
ScannedWallet r;
r.fileName = name;
r.dir = p.parent_path().string();
r.canonPath = canonicalWalletPath(p);
if (!seenCanon.insert(r.canonPath).second) return false; // same physical wallet already listed
r.inDatadir = inDatadir;
r.sizeBytes = static_cast<long long>(fs::file_size(p, ec));
out = std::move(r);
return true;
}
// Top-level wallet*.dat files of the datadir (never descends into blocks/ chainstate/ …). Cheap: UI thread.
inline std::vector<ScannedWallet> scanDatadirWallets(const std::string& datadir,
std::unordered_set<std::string>& seenCanon) {
namespace fs = std::filesystem;
std::vector<ScannedWallet> out;
std::error_code ec;
if (datadir.empty() || !fs::is_directory(datadir, ec)) return out;
fs::directory_iterator it(datadir, ec), end;
for (; !ec && it != end; it.increment(ec)) {
ScannedWallet w;
if (considerWalletFile(it->path(), true, seenCanon, w)) out.push_back(std::move(w));
}
return out;
}
// Bounded recursive walk of the user-added folders. Safe off the UI thread: touches only its arguments.
// `cancel` is polled per entry. Directory symlinks are not followed (std default) so cycles can't loop;
// permission-denied subtrees are skipped. `alreadyListed` (the caller's datadir rows) counts toward maxHits.
inline std::vector<ScannedWallet> scanExternalWalletFolders(const std::vector<std::string>& folders,
const WalletScanLimits& lim,
std::unordered_set<std::string>& seenCanon,
std::size_t alreadyListed,
const std::atomic<bool>* cancel = nullptr) {
namespace fs = std::filesystem;
std::vector<ScannedWallet> out;
auto cancelled = [&]() { return cancel && cancel->load(std::memory_order_relaxed); };
for (const auto& dir : folders) {
if (cancelled()) break;
std::error_code ec;
if (dir.empty() || !fs::is_directory(dir, ec)) continue;
fs::recursive_directory_iterator it(dir, fs::directory_options::skip_permission_denied, ec), end;
int visited = 0;
for (; !ec && it != end; it.increment(ec)) {
if (cancelled()) return out;
if (++visited > lim.maxVisited) break;
std::error_code dec;
if (it->is_directory(dec)) {
if (it.depth() >= lim.maxDepth || isWalletNodeSubtree(it->path().filename().string()))
it.disable_recursion_pending();
continue; // directories aren't wallet files
}
ScannedWallet w;
if (considerWalletFile(it->path(), false, seenCanon, w)) out.push_back(std::move(w));
if (alreadyListed + out.size() >= lim.maxHits) return out;
}
}
return out;
}
// External-result cache policy: reuse only when not forced, the folder list is unchanged and the result is
// younger than the TTL. Refresh and a stale-row Open force; add/remove folder change the key.
inline std::string externalScanKey(const std::vector<std::string>& folders) {
std::string k;
for (const auto& f : folders) { k += f; k += '\n'; }
return k;
}
inline bool externalScanCacheUsable(bool valid, const std::string& cachedKey, const std::string& key,
double ageSeconds, double ttlSeconds, bool force) {
return valid && !force && cachedKey == key && ageSeconds >= 0.0 && ageSeconds < ttlSeconds;
}
} // namespace util
} // namespace dragonx