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
+8 -2
View File
@@ -15,6 +15,7 @@
#include <algorithm>
#include <type_traits>
#include "../util/coalescing_writer.h"
#include "../util/logger.h"
#include "../util/platform.h"
@@ -657,8 +658,13 @@ bool Settings::save(const std::string& path)
// Atomic + durable: write to a temp file, fsync, then rename over the real file.
// A crash mid-write can no longer truncate settings.json (which would silently
// reset every preference on the next launch). Owner-only (0600) — it carries the
// lite-server list and address metadata.
return util::Platform::writeFileAtomically(path, j.dump(4), /*restrictPermissions=*/true);
// lite-server list and address metadata. With a writer attached the same atomic write
// runs on its background thread (queued, latest-wins); after a critical setter
// (durable_next_save_) this waits until the file is on disk, as the old save did.
const bool ok = util::persistFileAtomically(writer_, durable_next_save_, path, j.dump(4),
/*restrictPermissions=*/true);
if (ok) durable_next_save_ = false;
return ok;
} catch (const std::exception& e) {
DEBUG_LOGF("Failed to save settings: %s\n", e.what());
return false;
+24 -11
View File
@@ -12,6 +12,7 @@
#include <vector>
namespace dragonx {
namespace util { class CoalescingWriter; }
namespace config {
/**
@@ -39,10 +40,16 @@ public:
/**
* @brief Save settings to default location
* @return true if saved successfully
* With a file writer attached (the App's), the JSON snapshot is taken here, on the caller's thread,
* and the atomic write runs on the writer's thread: returns true once queued. After a fund/security-
* critical setter (see durable_next_save_) it waits until the file is on disk, like the old save().
* @return true if saved (or queued) successfully
*/
bool save();
/** @brief Route saves through a background writer (not owned; must outlive this). Null = synchronous. */
void setFileWriter(util::CoalescingWriter* writer) { writer_ = writer; }
/**
* @brief Save settings to specific path
* @param path Path to settings file
@@ -344,7 +351,7 @@ public:
// First-run wizard
bool getWizardCompleted() const { return wizard_completed_; }
void setWizardCompleted(bool v) { wizard_completed_ = v; }
void setWizardCompleted(bool v) { wizard_completed_ = v; durable_next_save_ = true; }
// Whether the one-time "back up your seed phrase" reminder has already been shown.
bool getSeedBackupReminded() const { return seed_backup_reminded_; }
@@ -366,7 +373,7 @@ public:
// observed to be actually encrypted. Lets a quit/crash/failed-connect before it applies be detected
// and surfaced (W2-2). NEVER stores the passphrase — only the fact that encryption was requested.
bool getEncryptionPending() const { return encryption_pending_; }
void setEncryptionPending(bool v) { encryption_pending_ = v; }
void setEncryptionPending(bool v) { encryption_pending_ = v; durable_next_save_ = true; }
// Bundled-daemon size we last prompted to install (see App::renderDaemonUpdatePrompt). Lets the
// "a newer node is bundled — update?" prompt fire once per wallet version, never re-nagging.
@@ -377,31 +384,31 @@ public:
// the datadir; defaults to the daemon's own default. Used at launch to know which wallet we're
// on before connect + to scope per-wallet data.
std::string getActiveWalletFile() const { return active_wallet_file_; }
void setActiveWalletFile(const std::string& v) { active_wallet_file_ = v; }
void setActiveWalletFile(const std::string& v) { active_wallet_file_ = v; durable_next_save_ = true; }
// Pending "migrate to a seed wallet" state (Phase 1 created the wallet; a later sweep/adopt
// step consumes it). dest = the new wallet's sweep-target z-address; tempDir = its datadir.
bool getSeedMigrationPending() const { return seed_migration_pending_; }
void setSeedMigrationPending(bool v) { seed_migration_pending_ = v; }
void setSeedMigrationPending(bool v) { seed_migration_pending_ = v; durable_next_save_ = true; }
std::string getSeedMigrationDest() const { return seed_migration_dest_; }
void setSeedMigrationDest(const std::string& v) { seed_migration_dest_ = v; }
void setSeedMigrationDest(const std::string& v) { seed_migration_dest_ = v; durable_next_save_ = true; }
std::string getSeedMigrationTempDir() const { return seed_migration_temp_dir_; }
void setSeedMigrationTempDir(const std::string& v) { seed_migration_temp_dir_ = v; }
void setSeedMigrationTempDir(const std::string& v) { seed_migration_temp_dir_ = v; durable_next_save_ = true; }
// The sweep transaction id, persisted once the sweep is submitted — non-empty means the
// migration is past the sweep, so a resume goes to the confirm/adopt stage (not sweep again).
std::string getSeedMigrationSweepTxid() const { return seed_migration_sweep_txid_; }
void setSeedMigrationSweepTxid(const std::string& v) { seed_migration_sweep_txid_ = v; }
void setSeedMigrationSweepTxid(const std::string& v) { seed_migration_sweep_txid_ = v; durable_next_save_ = true; }
// W3-3: the async sweep operation id, persisted while the sweep is in flight (before it resolves
// to a txid). Lets a resume re-poll a mid-sweep interruption instead of dropping the txid. Cleared
// in the same write that persists the txid, so the txid always outranks it (see [[decideSeedMigrationResume]]).
std::string getSeedMigrationSweepOpid() const { return seed_migration_sweep_opid_; }
void setSeedMigrationSweepOpid(const std::string& v) { seed_migration_sweep_opid_ = v; }
void setSeedMigrationSweepOpid(const std::string& v) { seed_migration_sweep_opid_ = v; durable_next_save_ = true; }
// Set TRUE synchronously when a sweep is broadcast; survives the stale-opid resume fallback so the
// Sweep step knows a ~0 legacy balance may be an UNCONFIRMED in-flight sweep (funds not yet safe)
// and must NOT short-circuit to adopt/swap wallet.dat. Cleared on confirmed-adopt / reset / a
// failed submit. See [[decideSeedMigrationResume]] and App::renderSeedMigrationDialog (fund-safety).
bool getSeedMigrationSweepSubmitted() const { return seed_migration_sweep_submitted_; }
void setSeedMigrationSweepSubmitted(bool v) { seed_migration_sweep_submitted_ = v; }
void setSeedMigrationSweepSubmitted(bool v) { seed_migration_sweep_submitted_ = v; durable_next_save_ = true; }
// Security — auto-lock timeout (seconds; 0 = disabled)
int getAutoLockTimeout() const { return auto_lock_timeout_; }
@@ -413,7 +420,7 @@ public:
// Security — PIN unlock enabled
bool getPinEnabled() const { return pin_enabled_; }
void setPinEnabled(bool v) { pin_enabled_ = v; }
void setPinEnabled(bool v) { pin_enabled_ = v; durable_next_save_ = true; }
// Daemon — keep running in background when closing the app
bool getKeepDaemonRunning() const { return keep_daemon_running_; }
@@ -581,6 +588,12 @@ public:
private:
std::string settings_path_;
util::CoalescingWriter* writer_ = nullptr; // null -> synchronous saves (tests, tools, early startup)
// Armed by the fund/security-critical setters (seed-migration stage, encryption_pending, pin_enabled,
// active_wallet_file, wizard_completed): the next save() waits until the file is on disk before it
// returns, so e.g. the sweep-submitted marker is durable before the sweep is broadcast. Cleared by a
// successful save. A NEW setter for crash-critical state must arm it too.
bool durable_next_save_ = false;
// Settings values
std::string theme_ = "dragonx";