perf(settings): write settings.json and wallets.json off the UI thread

Settings::save() and WalletIndex::save() ran the atomic write (temp
file, fsync, rename, directory fsync) on the UI thread: on every
preference change, and for wallets.json after every address-scan pass
whose wallet metadata changed, i.e. repeatedly during sync, when the
disk is busiest. Each took milliseconds to tens of milliseconds, and
seconds under heavy disk load.

The JSON is still built on the caller; the write is queued on an
App-owned CoalescingWriter, latest wins per file, one write at a time.

- After a fund- or security-critical setter (seed-migration stage,
  encryption pending, PIN enabled, active wallet file, wizard completed)
  the next save still waits until the file is on disk, so e.g. the
  sweep-submitted marker is durable before the sweep is broadcast. A
  failed durable save keeps the next one durable.
- Saves before the writer is attached (the upgrade re-save at startup),
  and in tests and tools, stay synchronous.
- main() flushes the queue (at most 3 s) before and after
  App::shutdown(), before the single-instance lock is released, so a
  relaunch never reads a stale settings.json.

A kill or power loss right after an ordinary save can now lose that
last preference change; the previous file stays intact. WalletIndex
saves report "queued" rather than "written" (no caller reads it). On a
wedged disk, exit can wait up to 2 x 3 s longer, inside the 15 s
watchdog.

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 72054fe17f
commit 7649b1f8a3
8 changed files with 126 additions and 17 deletions
+7 -2
View File
@@ -363,9 +363,12 @@ bool App::init()
// On upgrade (version mismatch), re-save to persist new defaults + current version
if (settings_->needsUpgradeSave()) {
DEBUG_LOGF("[INFO] Wallet upgraded — re-saving settings with new defaults\n");
settings_->save();
settings_->save(); // no writer attached yet: synchronous
settings_->clearUpgradeSave();
}
// From here on settings_->save() is written by file_writer_ off the UI thread (flushed by main()
// at exit); a save after a fund/security-critical setter still waits for the disk (Settings::save).
settings_->setFileWriter(&file_writer_);
// Lite builds with a linked SDXL backend own a lite wallet controller that drives
// real create/open/restore through the bridge. Full-node and unlinked-lite builds
@@ -575,7 +578,9 @@ bool App::init()
address_book_.load();
// Load the wallet-files metadata index (wallets.json); populated after each wallet load. Local
// only, missing file is fine.
// only, missing file is fine. Its saves are queued on file_writer_ (they follow every scan pass
// whose metadata changed, so they shouldn't fsync on the UI thread).
wallet_index_.setFileWriter(&file_writer_);
wallet_index_.load();
// If this wallet build bundles a newer daemon than the one already installed, offer to update it