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
+120
View File
@@ -75,6 +75,7 @@
#include "util/background_sampler.h"
#include "util/lite_server_probe.h"
#include "util/wallet_file_probe.h"
#include "util/wallet_folder_scan.h"
#include "wallet/lite_connection_service.h"
#include "wallet/lite_diagnostics.h"
#include "wallet/lite_owned_string.h"
@@ -3340,6 +3341,123 @@ void testVerifySaplingParams()
fsn::remove_all(dir2, rmec);
}
// Wallets dialog: the pure folder walk that moved out of WalletsDialog::scan() (now run on a worker).
void testWalletFolderScan()
{
using namespace dragonx::util;
namespace fsn = std::filesystem;
const fsn::path root = makeTempDir() / "wallet_folder_scan";
const fsn::path dd = root / "datadir", ext = root / "ext", ext2 = root / "ext2";
auto touch = [](const fsn::path& p, const std::string& body = "x") {
std::error_code ec;
fsn::create_directories(p.parent_path(), ec);
std::ofstream(p.string(), std::ios::binary) << body;
};
auto names = [](const std::vector<ScannedWallet>& v) {
std::set<std::string> s;
for (const auto& w : v) s.insert(w.fileName);
return s;
};
// Datadir: top level only, wallet-prefixed, in-place links and node files hidden.
for (const char* n : { "wallet.dat", "wallet-x.dat", "wallet-ip-1a.dat", "peers.dat", "other.dat" }) touch(dd / n);
touch(dd / "blocks" / "blk00000.dat");
{
std::unordered_set<std::string> seen;
const auto rows = scanDatadirWallets(dd.string(), seen);
EXPECT_EQ(rows.size(), static_cast<std::size_t>(2));
EXPECT_TRUE((names(rows) == std::set<std::string>{ "wallet.dat", "wallet-x.dat" }));
for (const auto& w : rows) EXPECT_TRUE(w.inDatadir);
EXPECT_EQ(seen.size(), static_cast<std::size_t>(2));
std::unordered_set<std::string> seen2;
EXPECT_TRUE(scanDatadirWallets((root / "missing").string(), seen2).empty());
}
// External: any .dat except node junk, recursive within the depth cap, never into node subtrees.
touch(ext / "a.dat");
touch(ext / "peers.dat");
touch(ext / "blk1.dat");
touch(ext / "blocks" / "b.dat");
touch(ext / "sub" / "w.dat");
touch(ext / "d1" / "d2" / "d3" / "deep.dat");
touch(ext / "notes.txt");
{
WalletScanLimits lim;
lim.maxDepth = 2;
std::unordered_set<std::string> seen;
const auto rows = scanExternalWalletFolders({ ext.string() }, lim, seen, 0);
EXPECT_TRUE((names(rows) == std::set<std::string>{ "a.dat", "w.dat" }));
for (const auto& w : rows) {
EXPECT_TRUE(!w.inDatadir);
if (w.fileName == "w.dat") EXPECT_TRUE(fsn::path(w.dir) == ext / "sub"); // the real parent
if (w.fileName == "a.dat") EXPECT_TRUE(fsn::path(w.dir) == ext);
}
}
{
std::unordered_set<std::string> seen;
const auto rows = scanExternalWalletFolders({ ext.string() }, WalletScanLimits{}, seen, 0);
EXPECT_TRUE((names(rows) == std::set<std::string>{ "a.dat", "w.dat", "deep.dat" }));
}
// The hit cap holds across folders.
touch(ext2 / "e.dat");
{
WalletScanLimits lim;
lim.maxHits = 1;
std::unordered_set<std::string> seen;
EXPECT_EQ(scanExternalWalletFolders({ ext.string(), ext2.string() }, lim, seen, 0).size(),
static_cast<std::size_t>(1));
}
// The visit cap stops the walk early.
{
WalletScanLimits lim;
lim.maxVisited = 1;
std::unordered_set<std::string> seen;
EXPECT_TRUE(scanExternalWalletFolders({ ext.string() }, lim, seen, 0).size() <= 1);
}
// Rows already listed (the datadir pass) count toward the hit cap.
{
std::unordered_set<std::string> seen;
EXPECT_TRUE(scanExternalWalletFolders({ ext.string() }, WalletScanLimits{}, seen, 200).size() <= 1);
}
// A cancelled walk returns nothing.
{
std::atomic<bool> cancel{true};
std::unordered_set<std::string> seen;
EXPECT_TRUE(scanExternalWalletFolders({ ext.string() }, WalletScanLimits{}, seen, 0, &cancel).empty());
}
#ifndef _WIN32
// A symlink to a datadir wallet inside an added folder is the same physical wallet: listed once.
{
const fsn::path lnDir = root / "ext_link";
std::error_code ec;
fsn::create_directories(lnDir, ec);
fsn::create_symlink(dd / "wallet.dat", lnDir / "linked.dat", ec);
EXPECT_TRUE(!ec);
touch(lnDir / "own.dat");
std::unordered_set<std::string> seen;
const auto dataRows = scanDatadirWallets(dd.string(), seen);
const auto rows = scanExternalWalletFolders({ lnDir.string() }, WalletScanLimits{}, seen, dataRows.size());
EXPECT_TRUE((names(rows) == std::set<std::string>{ "own.dat" }));
}
#endif
removeTree(root.parent_path());
}
void testExternalScanCache()
{
using dragonx::util::externalScanKey;
using dragonx::util::externalScanCacheUsable;
EXPECT_TRUE(externalScanKey({ "a", "b" }) != externalScanKey({ "ab" }));
EXPECT_TRUE(externalScanKey({}).empty());
const std::string k = externalScanKey({ "/home/u/wallets" });
EXPECT_TRUE(externalScanCacheUsable(true, k, k, 10.0, 60.0, false));
EXPECT_TRUE(!externalScanCacheUsable(true, k, k, 10.0, 60.0, /*force=*/true));
EXPECT_TRUE(!externalScanCacheUsable(true, k, externalScanKey({ "/other" }), 10.0, 60.0, false));
EXPECT_TRUE(!externalScanCacheUsable(true, k, k, 61.0, 60.0, false));
EXPECT_TRUE(!externalScanCacheUsable(false, k, k, 10.0, 60.0, false));
EXPECT_TRUE(!externalScanCacheUsable(true, k, k, -1.0, 60.0, false));
}
void testPlatformEnsureDirectory()
{
using dragonx::util::Platform;
@@ -13570,6 +13688,8 @@ int main()
#endif
testPlatformEnsureDirectory();
testVerifySaplingParams();
testWalletFolderScan();
testExternalScanCache();
testConnectHasStalled();
testIsLocalHost();
testAllowsPlaintextRemote();