Skip to content
18 changes: 15 additions & 3 deletions dash-spv/src/test_utils/context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ use std::net::SocketAddr;
use tempfile::TempDir;
use tracing::info;

use super::fs_helpers::{copy_dir, retain_test_dir};
use super::fs_helpers::{copy_dir, retain_test_dir, RetainOnPanic};
use super::node::TestChain;
use super::{DashCoreConfig, DashCoreNode, WalletFile};

Expand Down Expand Up @@ -53,6 +53,8 @@ impl DashdTestContext {
async fn create(mut config: DashCoreConfig) -> Self {
let datadir = TempDir::new().expect("failed to create temp dir");
copy_dir(&config.datadir, datadir.path()).expect("failed to copy datadir");
// Stale fixture locks are cleared in DashCoreNode::start (covers all
// callers, including masternode harnesses).
config.datadir = datadir.path().to_path_buf();
config.wallet = "wallet".to_string();

Expand All @@ -62,14 +64,22 @@ impl DashdTestContext {
wallet.wallet_name, wallet.transaction_count, wallet.utxo_count, wallet.balance
);

// retain_guard is declared before node so reverse-declaration drop order
// shuts dashd down (DashCoreNode::drop / stop_and_wait) before
// RetainOnPanic copies the datadir on post-start panics.
// start() failures retain via fail_startup instead, so the guard is
// installed only after start returns.
let retain_guard;
let mut node = DashCoreNode::with_config(config);
let addr = node.start().await;
info!("DashCoreNode started at {}", addr);
retain_guard = RetainOnPanic::new(datadir.path(), "dashd-startup");

// Load a separate wallet for mining so coinbase rewards don't pollute
// the test wallet's address space (the "wallet" wallet and SPV wallet
// share the same mnemonic).
node.ensure_wallet("default");
// share the same mnemonic). The fixture already ships this wallet on
// disk — load only; never create.
node.load_wallet("default");
info!("Mining wallet 'default' ready");

let initial_height = node.get_block_count();
Expand All @@ -80,6 +90,7 @@ impl DashdTestContext {
info!("RPC miner not available (tests requiring block generation will be skipped)");
}

retain_guard.defuse();
DashdTestContext {
node,
addr,
Expand All @@ -94,6 +105,7 @@ impl DashdTestContext {
impl Drop for DashdTestContext {
fn drop(&mut self) {
let label = format!("dashd-{}", self.addr.port());
self.node.stop_and_wait();
retain_test_dir(self.datadir.path(), &label);
}
}
132 changes: 132 additions & 0 deletions dash-spv/src/test_utils/fs_helpers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,58 @@ pub(super) fn copy_dir(src: &Path, dst: &Path) -> io::Result<()> {
Ok(())
}

/// Remove runtime lock files that must not survive a datadir copy.
///
/// The regtest fixtures are snapshots of a previously running node, so they
/// may contain `regtest/.lock` and per-wallet `.walletlock` files. A live
/// dashd refuses to start (or fails wallet load) when those are present.
pub(super) fn clear_stale_runtime_locks(datadir: &Path) -> io::Result<()> {
let regtest = datadir.join("regtest");
remove_if_exists(&regtest.join(".lock"))?;
// Legacy single-wallet layout stores the lock at regtest/.walletlock.
remove_if_exists(&regtest.join(".walletlock"))?;

// Named wallet directories may sit under regtest/<name>/ or regtest/wallets/<name>/.
let wallets_root = regtest.join("wallets");
for wallet_root in [&regtest, &wallets_root] {
let entries = match fs::read_dir(wallet_root) {
Ok(entries) => entries,
Err(e) if e.kind() == io::ErrorKind::NotFound => continue,
Err(e) => {
return Err(io::Error::new(
e.kind(),
format!("failed to read wallet root {}: {}", wallet_root.display(), e),
));
}
};
for entry in entries {
let entry = entry.map_err(|e| {
io::Error::new(
e.kind(),
format!("failed to read entry in {}: {}", wallet_root.display(), e),
)
})?;
let path = entry.path();
if entry.file_type()?.is_dir() {
remove_if_exists(&path.join(".walletlock"))?;
}
}
}

Ok(())
}

fn remove_if_exists(path: &Path) -> io::Result<()> {
match fs::remove_file(path) {
Ok(()) => Ok(()),
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(io::Error::new(
e.kind(),
format!("failed to remove stale lock {}: {}", path.display(), e),
)),
}
}

/// When `DASHD_TEST_RETAIN_DIR` is set, copy `src` to a test-named
/// subdirectory for post-mortem inspection.
///
Expand All @@ -33,6 +85,14 @@ pub fn retain_test_dir(src: &Path, label: &str) {
return;
}

retain_test_dir_now(src, label);
}

/// Unconditionally retain `src` when `DASHD_TEST_RETAIN_DIR` is set.
///
/// Use this before panicking during setup that has not yet constructed a type
/// whose `Drop` impl calls [`retain_test_dir`].
pub(super) fn retain_test_dir_now(src: &Path, label: &str) {
let Ok(retain_dir) = std::env::var("DASHD_TEST_RETAIN_DIR") else {
return;
};
Expand All @@ -48,3 +108,75 @@ pub fn retain_test_dir(src: &Path, label: &str) {
eprintln!("Test data retained at: {}", dest.display());
}
}

/// Retains `path` on panic drop when `DASHD_TEST_RETAIN_DIR` is set.
///
/// Used while constructing [`super::DashdTestContext`] so startup failures
/// still leave dashd logs for CI artifacts.
pub(super) struct RetainOnPanic {
path: PathBuf,
label: String,
}

impl RetainOnPanic {
pub(super) fn new(path: impl Into<PathBuf>, label: impl Into<String>) -> Self {
Self {
path: path.into(),
label: label.into(),
}
}

pub(super) fn defuse(self) {
std::mem::forget(self);
}
}

impl Drop for RetainOnPanic {
fn drop(&mut self) {
if std::thread::panicking() {
// Already know we are panicking; skip retain_test_dir's re-check.
retain_test_dir_now(&self.path, &self.label);
}
}
}

#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;

#[test]
fn remove_if_exists_treats_missing_file_as_success() {
let tmp = TempDir::new().unwrap();
remove_if_exists(&tmp.path().join("missing.lock")).unwrap();
}

#[test]
fn remove_if_exists_propagates_removal_failures() {
let tmp = TempDir::new().unwrap();
let lock_path = tmp.path().join(".lock");
fs::create_dir(&lock_path).unwrap();

let err = remove_if_exists(&lock_path).unwrap_err();

assert_ne!(err.kind(), io::ErrorKind::NotFound);
}

#[test]
fn clear_stale_runtime_locks_treats_missing_roots_as_success() {
let tmp = TempDir::new().unwrap();
clear_stale_runtime_locks(tmp.path()).unwrap();
}

#[test]
fn clear_stale_runtime_locks_propagates_directory_read_failures() {
let tmp = TempDir::new().unwrap();
let regtest = tmp.path().join("regtest");
fs::create_dir(&regtest).unwrap();
fs::write(regtest.join("wallets"), b"not a directory").unwrap();

let err = clear_stale_runtime_locks(tmp.path()).unwrap_err();

assert_ne!(err.kind(), io::ErrorKind::NotFound);
}
}
Loading
Loading