commit 1d21300512bdd7ad9dffa9f3572cb302efc1ac79
parent 330bdfe7f263cc7931931e3bc99a2fec55a3ce4b
Author: Joris Hartog <jorishartog@hotmail.com>
Date: Wed, 19 Aug 2026 20:52:33 +0200
Harden persistence crash consistency
Diffstat:
5 files changed, 333 insertions(+), 57 deletions(-)
diff --git a/PLAN.md b/PLAN.md
@@ -127,6 +127,10 @@ Acceptance:
## 6. Crash Consistency
+Status: started with atomic JSON writes for config and wallet files, stale
+temp-file regression coverage, and rollback tests that keep the last committed
+chain snapshot/UI projection after failed persistence work.
+
Goal: prove local persistence survives process death at bad moments.
Scenarios:
diff --git a/src/adapters/chain_store.rs b/src/adapters/chain_store.rs
@@ -170,3 +170,40 @@ fn unix_ms() -> u64 {
.unwrap_or_default()
.as_millis() as u64
}
+
+#[cfg(test)]
+mod tests {
+ use std::collections::BTreeMap;
+
+ use tempfile::tempdir;
+
+ use crate::domain::{ChainSnapshot, GenesisBurn, Ledger, Wallet};
+
+ use super::SqliteChainStore;
+
+ fn test_snapshot(seed: &str) -> ChainSnapshot {
+ let wallet = Wallet::from_seed(seed);
+ let mut allocations = BTreeMap::new();
+ allocations.insert(wallet.address().to_string(), 1);
+ Ledger::new_with_genesis_burns(allocations, vec![GenesisBurn::new(wallet.address(), 1)], 1)
+ .unwrap()
+ .snapshot()
+ }
+
+ #[test]
+ fn failed_snapshot_save_keeps_last_committed_chain() {
+ let dir = tempdir().unwrap();
+ let store = SqliteChainStore::open(dir.path().join("chain.sqlite3")).unwrap();
+ let snapshot = test_snapshot("chain-store-rollback");
+ let tip = snapshot.blocks.last().unwrap().hash.clone();
+ store.save(&snapshot).unwrap();
+
+ let mut invalid = snapshot.clone();
+ invalid.blocks.clear();
+ let error = store.save(&invalid).unwrap_err();
+
+ assert!(error.to_string().contains("cannot persist empty chain"));
+ let restored = store.load().unwrap().unwrap();
+ assert_eq!(restored.blocks.last().unwrap().hash, tip);
+ }
+}
diff --git a/src/adapters/config_store.rs b/src/adapters/config_store.rs
@@ -4,7 +4,8 @@ use std::{
collections::BTreeMap,
fs::{self, File, OpenOptions},
io::Write,
- path::Path,
+ path::{Path, PathBuf},
+ time::{SystemTime, UNIX_EPOCH},
};
use anyhow::{Context, Result, bail};
@@ -141,13 +142,10 @@ pub fn save(path: &Path, config: &UiConfig) -> Result<()> {
peers: config.peers.clone(),
address_book: config.address_book.clone(),
};
- let bytes = serde_json::to_vec_pretty(&stored).context("failed to serialize config file")?;
- let mut file = create_config_file(path)?;
- file.write_all(&bytes)
- .with_context(|| format!("failed to write config file {}", path.display()))?;
- file.write_all(b"\n")
- .with_context(|| format!("failed to write config file {}", path.display()))?;
- Ok(())
+ let mut bytes =
+ serde_json::to_vec_pretty(&stored).context("failed to serialize config file")?;
+ bytes.push(b'\n');
+ atomic_write_config_file(path, &bytes)
}
fn load(path: &Path) -> Result<UiConfig> {
@@ -219,9 +217,52 @@ fn default_stratum_bind_port() -> u16 {
DEFAULT_STRATUM_BIND_PORT
}
+fn atomic_write_config_file(path: &Path, bytes: &[u8]) -> Result<()> {
+ if let Some(parent) = path.parent() {
+ fs::create_dir_all(parent)
+ .with_context(|| format!("failed to create config directory {}", parent.display()))?;
+ }
+
+ for attempt in 0..16 {
+ let temp_path = temp_file_path(path, attempt);
+ match create_config_file(&temp_path) {
+ Ok(mut file) => {
+ if let Err(error) = write_and_sync(&mut file, bytes) {
+ let _ = fs::remove_file(&temp_path);
+ return Err(error).with_context(|| {
+ format!("failed to write config file {}", path.display())
+ });
+ }
+ drop(file);
+ if let Err(error) = fs::rename(&temp_path, path) {
+ let _ = fs::remove_file(&temp_path);
+ return Err(error).with_context(|| {
+ format!("failed to replace config file {}", path.display())
+ });
+ }
+ sync_parent_dir(path);
+ return Ok(());
+ }
+ Err(error)
+ if error
+ .downcast_ref::<std::io::Error>()
+ .is_some_and(|error| error.kind() == std::io::ErrorKind::AlreadyExists) =>
+ {
+ continue;
+ }
+ Err(error) => return Err(error),
+ }
+ }
+
+ bail!(
+ "failed to create temporary config file for {}",
+ path.display()
+ )
+}
+
fn create_config_file(path: &Path) -> Result<File> {
let mut options = OpenOptions::new();
- options.write(true).create(true).truncate(true);
+ options.write(true).create_new(true);
#[cfg(unix)]
options.mode(0o600);
options
@@ -229,6 +270,36 @@ fn create_config_file(path: &Path) -> Result<File> {
.with_context(|| format!("failed to create config file {}", path.display()))
}
+fn write_and_sync(file: &mut File, bytes: &[u8]) -> Result<()> {
+ file.write_all(bytes)?;
+ file.sync_all()?;
+ Ok(())
+}
+
+fn temp_file_path(path: &Path, attempt: u64) -> PathBuf {
+ let file_name = path
+ .file_name()
+ .and_then(|name| name.to_str())
+ .unwrap_or("config");
+ let nanos = SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .unwrap_or_default()
+ .as_nanos();
+ path.with_file_name(format!(
+ ".{file_name}.{}.{}.tmp",
+ std::process::id(),
+ nanos.saturating_add(u128::from(attempt))
+ ))
+}
+
+fn sync_parent_dir(path: &Path) {
+ if let Some(parent) = path.parent() {
+ if let Ok(dir) = File::open(parent) {
+ let _ = dir.sync_all();
+ }
+ }
+}
+
#[cfg(test)]
mod tests {
use std::fs;
@@ -322,6 +393,32 @@ mod tests {
}
#[test]
+ fn stale_atomic_temp_file_does_not_replace_saved_config() {
+ let dir = tempdir().unwrap();
+ let path = dir.path().join("config.json");
+ let stale_temp = dir.path().join(".config.json.crash.tmp");
+
+ save(
+ &path,
+ &UiConfig {
+ setup_complete: true,
+ mining_enabled: true,
+ peers: vec!["127.0.0.1:9444".to_string()],
+ ..UiConfig::default()
+ },
+ )
+ .unwrap();
+ fs::write(&stale_temp, b"{\"version\": 1,").unwrap();
+
+ let config = load_or_create(&path).unwrap();
+
+ assert!(config.setup_complete);
+ assert!(config.mining_enabled);
+ assert_eq!(config.peers, vec!["127.0.0.1:9444"]);
+ assert!(stale_temp.exists());
+ }
+
+ #[test]
fn ui_config_json_does_not_expose_password_hash() {
let json = serde_json::to_string(&UiConfig {
auth_password_hash: Some("secret-password-hash".to_string()),
diff --git a/src/adapters/ui_data_store.rs b/src/adapters/ui_data_store.rs
@@ -1341,3 +1341,45 @@ fn unix_ms() -> u64 {
.unwrap_or_default()
.as_millis() as u64
}
+
+#[cfg(test)]
+mod tests {
+ use std::collections::BTreeMap;
+
+ use tempfile::tempdir;
+
+ use crate::domain::{ChainSnapshot, GenesisBurn, Ledger, Wallet};
+
+ use super::SqliteUiDataStore;
+
+ fn test_snapshot(seed: &str) -> ChainSnapshot {
+ let wallet = Wallet::from_seed(seed);
+ let mut allocations = BTreeMap::new();
+ allocations.insert(wallet.address().to_string(), 1);
+ Ledger::new_with_genesis_burns(allocations, vec![GenesisBurn::new(wallet.address(), 1)], 1)
+ .unwrap()
+ .snapshot()
+ }
+
+ #[test]
+ fn failed_ui_projection_keeps_last_committed_projection() {
+ let dir = tempdir().unwrap();
+ let store = SqliteUiDataStore::open(dir.path().join("ui_data.sqlite3")).unwrap();
+ let snapshot = test_snapshot("ui-data-rollback");
+ let tip = snapshot.blocks.last().unwrap().hash.clone();
+ store.project_snapshot(&snapshot, true).unwrap();
+ assert!(store.is_projected_to(&tip).unwrap());
+ assert!(!store.load_metrics().unwrap().is_empty());
+
+ let mut invalid = snapshot.clone();
+ invalid.blocks.clear();
+ let error = store.project_snapshot(&invalid, true).unwrap_err();
+
+ assert!(
+ format!("{error:#}").contains("chain snapshot is empty"),
+ "{error:#}"
+ );
+ assert!(store.is_projected_to(&tip).unwrap());
+ assert!(!store.load_metrics().unwrap().is_empty());
+ }
+}
diff --git a/src/adapters/wallet_store.rs b/src/adapters/wallet_store.rs
@@ -1,7 +1,8 @@
use std::{
fs::{self, File, OpenOptions},
io::Write,
- path::Path,
+ path::{Path, PathBuf},
+ time::{SystemTime, UNIX_EPOCH},
};
use anyhow::{Context, Result, anyhow, bail};
@@ -62,9 +63,7 @@ pub fn load_or_create(path: &Path) -> Result<Wallet> {
let seed = generate_seed_phrase()?;
let wallet = Wallet::from_seed(&seed);
- let mut file = create_wallet_file(path)?;
- write_wallet_file(&mut file, seed, wallet.address())
- .with_context(|| format!("failed to write wallet file {}", path.display()))?;
+ write_wallet_file(path, seed, wallet.address(), WalletFileMode::CreateNew)?;
Ok(wallet)
}
@@ -160,9 +159,14 @@ pub fn encrypt_existing_with_password(path: &Path, password: &str) -> Result<()>
wallet.address()
);
}
- let mut file = open_wallet_file(path, WalletFileMode::Replace)?;
- write_encrypted_wallet_data_file(&mut file, WalletData { seed }, wallet.address(), password)
- .with_context(|| format!("failed to encrypt wallet file {}", path.display()))
+ write_encrypted_wallet_data_file(
+ path,
+ WalletData { seed },
+ wallet.address(),
+ password,
+ WalletFileMode::Replace,
+ )
+ .with_context(|| format!("failed to encrypt wallet file {}", path.display()))
}
pub fn reencrypt_with_password(
@@ -182,12 +186,12 @@ pub fn reencrypt_with_password(
wallet.address()
);
}
- let mut file = open_wallet_file(path, WalletFileMode::Replace)?;
write_encrypted_wallet_data_file(
- &mut file,
+ path,
WalletData { seed },
wallet.address(),
new_password,
+ WalletFileMode::Replace,
)
.with_context(|| format!("failed to re-encrypt wallet file {}", path.display()))?;
Ok(wallet)
@@ -201,15 +205,10 @@ fn load_encrypted_or_plaintext(path: &Path, password: Option<&str>) -> Result<Wa
let stored = read_wallet_file(path)?;
let wallet = wallet_from_stored(&stored, password)?;
if stored.version == 1 {
- let mut file = OpenOptions::new()
- .write(true)
- .truncate(true)
- .open(path)
- .with_context(|| format!("failed to migrate wallet file {}", path.display()))?;
let seed = stored
.seed
.context("legacy wallet file does not contain a seed")?;
- write_wallet_file(&mut file, seed, wallet.address())
+ write_wallet_file(path, seed, wallet.address(), WalletFileMode::Replace)
.with_context(|| format!("failed to migrate wallet file {}", path.display()))?;
return Ok(wallet);
}
@@ -262,9 +261,7 @@ enum WalletFileMode {
fn write_wallet(path: &Path, seed: String, mode: WalletFileMode) -> Result<Wallet> {
let wallet = Wallet::from_seed(&seed);
- let mut file = open_wallet_file(path, mode)?;
- write_wallet_file(&mut file, seed, wallet.address())
- .with_context(|| format!("failed to write wallet file {}", path.display()))?;
+ write_wallet_file(path, seed, wallet.address(), mode)?;
Ok(wallet)
}
@@ -275,43 +272,48 @@ fn write_wallet_encrypted(
mode: WalletFileMode,
) -> Result<Wallet> {
let wallet = Wallet::from_seed(&seed);
- let mut file = open_wallet_file(path, mode)?;
- write_encrypted_wallet_file(&mut file, seed, wallet.address(), password)
- .with_context(|| format!("failed to write wallet file {}", path.display()))?;
+ write_encrypted_wallet_file(path, seed, wallet.address(), password, mode)?;
Ok(wallet)
}
-fn write_wallet_file(file: &mut File, seed: String, address: &str) -> Result<()> {
- write_wallet_data_file(file, WalletData { seed }, address)
+fn write_wallet_file(path: &Path, seed: String, address: &str, mode: WalletFileMode) -> Result<()> {
+ write_wallet_data_file(path, WalletData { seed }, address, mode)
}
-fn write_wallet_data_file(file: &mut File, data: WalletData, address: &str) -> Result<()> {
+fn write_wallet_data_file(
+ path: &Path,
+ data: WalletData,
+ address: &str,
+ mode: WalletFileMode,
+) -> Result<()> {
let stored = WalletFile {
version: PLAINTEXT_WALLET_FILE_VERSION,
seed: Some(data.seed),
address: address.to_string(),
encryption: None,
};
- let bytes = serde_json::to_vec_pretty(&stored).context("failed to serialize wallet file")?;
- file.write_all(&bytes)?;
- file.write_all(b"\n")?;
- Ok(())
+ let mut bytes =
+ serde_json::to_vec_pretty(&stored).context("failed to serialize wallet file")?;
+ bytes.push(b'\n');
+ atomic_write_wallet_file(path, &bytes, mode)
}
fn write_encrypted_wallet_file(
- file: &mut File,
+ path: &Path,
seed: String,
address: &str,
password: &str,
+ mode: WalletFileMode,
) -> Result<()> {
- write_encrypted_wallet_data_file(file, WalletData { seed }, address, password)
+ write_encrypted_wallet_data_file(path, WalletData { seed }, address, password, mode)
}
fn write_encrypted_wallet_data_file(
- file: &mut File,
+ path: &Path,
data: WalletData,
address: &str,
password: &str,
+ mode: WalletFileMode,
) -> Result<()> {
let encryption = encrypt_wallet_data(&data, address, password)?;
let stored = WalletFile {
@@ -320,10 +322,10 @@ fn write_encrypted_wallet_data_file(
address: address.to_string(),
encryption: Some(encryption),
};
- let bytes = serde_json::to_vec_pretty(&stored).context("failed to serialize wallet file")?;
- file.write_all(&bytes)?;
- file.write_all(b"\n")?;
- Ok(())
+ let mut bytes =
+ serde_json::to_vec_pretty(&stored).context("failed to serialize wallet file")?;
+ bytes.push(b'\n');
+ atomic_write_wallet_file(path, &bytes, mode)
}
fn wallet_data(stored: &WalletFile, password: Option<&str>) -> Result<WalletData> {
@@ -482,27 +484,66 @@ fn normalize_seed_phrase(seed_phrase: &str) -> Result<String> {
Ok(mnemonic.to_string())
}
-fn create_wallet_file(path: &Path) -> Result<File> {
- open_wallet_file(path, WalletFileMode::CreateNew)
-}
-
-fn open_wallet_file(path: &Path, mode: WalletFileMode) -> Result<File> {
+fn atomic_write_wallet_file(path: &Path, bytes: &[u8], mode: WalletFileMode) -> Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("failed to create wallet directory {}", parent.display()))?;
}
- let mut options = OpenOptions::new();
- options.write(true);
- match mode {
- WalletFileMode::CreateNew => {
- options.create_new(true);
- }
- WalletFileMode::Replace => {
- options.create(true).truncate(true);
+ for attempt in 0..16 {
+ let temp_path = temp_file_path(path, attempt);
+ match open_wallet_temp_file(&temp_path) {
+ Ok(mut file) => {
+ if let Err(error) = write_and_sync(&mut file, bytes) {
+ let _ = fs::remove_file(&temp_path);
+ return Err(error).with_context(|| {
+ format!("failed to write wallet file {}", path.display())
+ });
+ }
+ drop(file);
+ match mode {
+ WalletFileMode::CreateNew => {
+ if let Err(error) = fs::hard_link(&temp_path, path) {
+ let _ = fs::remove_file(&temp_path);
+ return Err(error).with_context(|| {
+ format!("failed to create wallet file {}", path.display())
+ });
+ }
+ let _ = fs::remove_file(&temp_path);
+ }
+ WalletFileMode::Replace => {
+ if let Err(error) = fs::rename(&temp_path, path) {
+ let _ = fs::remove_file(&temp_path);
+ return Err(error).with_context(|| {
+ format!("failed to replace wallet file {}", path.display())
+ });
+ }
+ }
+ }
+ sync_parent_dir(path);
+ return Ok(());
+ }
+ Err(error)
+ if error
+ .downcast_ref::<std::io::Error>()
+ .is_some_and(|error| error.kind() == std::io::ErrorKind::AlreadyExists) =>
+ {
+ continue;
+ }
+ Err(error) => return Err(error),
}
}
+ bail!(
+ "failed to create temporary wallet file for {}",
+ path.display()
+ )
+}
+
+fn open_wallet_temp_file(path: &Path) -> Result<File> {
+ let mut options = OpenOptions::new();
+ options.write(true).create_new(true);
+
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
@@ -513,3 +554,58 @@ fn open_wallet_file(path: &Path, mode: WalletFileMode) -> Result<File> {
.open(path)
.with_context(|| format!("failed to create wallet file {}", path.display()))
}
+
+fn write_and_sync(file: &mut File, bytes: &[u8]) -> Result<()> {
+ file.write_all(bytes)?;
+ file.sync_all()?;
+ Ok(())
+}
+
+fn temp_file_path(path: &Path, attempt: u64) -> PathBuf {
+ let file_name = path
+ .file_name()
+ .and_then(|name| name.to_str())
+ .unwrap_or("wallet");
+ let nanos = SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .unwrap_or_default()
+ .as_nanos();
+ path.with_file_name(format!(
+ ".{file_name}.{}.{}.tmp",
+ std::process::id(),
+ nanos.saturating_add(u128::from(attempt))
+ ))
+}
+
+fn sync_parent_dir(path: &Path) {
+ if let Some(parent) = path.parent() {
+ if let Ok(dir) = File::open(parent) {
+ let _ = dir.sync_all();
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use std::fs;
+
+ use tempfile::tempdir;
+
+ use super::{load_or_create, replace_with_imported_seed_phrase};
+
+ const TEST_SEED: &str = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon art";
+
+ #[test]
+ fn stale_atomic_temp_file_does_not_replace_saved_wallet() {
+ let dir = tempdir().unwrap();
+ let path = dir.path().join("wallet.json");
+ let stale_temp = dir.path().join(".wallet.json.crash.tmp");
+ let wallet = replace_with_imported_seed_phrase(&path, TEST_SEED).unwrap();
+ fs::write(&stale_temp, b"{\"version\": 2,").unwrap();
+
+ let loaded = load_or_create(&path).unwrap();
+
+ assert_eq!(loaded.address(), wallet.address());
+ assert!(stale_temp.exists());
+ }
+}