Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
9375a66
fix(spv): exit manager task loop on network errors and signal shutdow…
lklimek Feb 16, 2026
d8bc066
fix(dash-spv): shutdown token not checked when waiting for peer conne…
lklimek Feb 16, 2026
cdccab6
fix(spv): address review findings for network error handling
lklimek Feb 16, 2026
0a6dff1
refactor(spv): replace signal_shutdown with full shutdown in stop()
lklimek Feb 16, 2026
9f3970c
fix(spv): resolve deadlock in PeerNetworkManager shutdown
lklimek Feb 16, 2026
86ebbab
doc: document logging
lklimek Feb 16, 2026
85cf2bd
feat(spv): surface FatalNetwork errors to API consumers
lklimek Feb 16, 2026
8541836
fix(spv): self-recover on FatalNetwork instead of exiting manager loop
lklimek Feb 16, 2026
cb84ccc
fix(spv): remove take() in PeerNetworkManager::shutdown to close race
lklimek Feb 16, 2026
f4a52dd
fix(spv): add missing shutdown checks in maintenance loop
lklimek Feb 16, 2026
7356e2b
fix(spv): address PR #440 audit findings
lklimek Feb 16, 2026
f48a3a5
fmt
lklimek Feb 16, 2026
04a87a7
Merge remote-tracking branch 'origin/v0.42-dev' into fix/block-header…
lklimek Feb 17, 2026
8e22175
refactor(spv): extract network error cooldown into named constant
lklimek Feb 17, 2026
8872062
fmt
lklimek Feb 17, 2026
c3fa668
fix(spv): restore shutdown checks dropped during upstream refactor
lklimek Feb 17, 2026
ddc539f
fmt
lklimek Feb 17, 2026
531eb11
revert: CLAUDE.md logging info
lklimek Feb 17, 2026
6affdaa
revert logging changes
lklimek Feb 17, 2026
ebdfbe3
Update dash-spv/src/network/manager.rs
lklimek Feb 17, 2026
e6c01e8
chore: fmt
lklimek Feb 17, 2026
d3d0305
fix: don't set SyncState::WaitingForConnections) on network error
lklimek Feb 17, 2026
b239c4e
refactor: remove cooldown
lklimek Feb 17, 2026
9501aed
Merge branch 'v0.42-dev' into fix/block-header-error-retry-loop
lklimek Feb 18, 2026
8f7eacc
refactor: strip non-shutdown changes from PR 440
lklimek Feb 18, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions dash-spv/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,9 @@ Use domain-specific error types:
- **Built on**: `dashcore` library with Dash-specific features enabled
- **Async runtime**: Tokio with full feature set

### Logging Convention
Comment thread
xdustinface marked this conversation as resolved.
Outdated
The project uses the `tracing` framework for structured logging. New code should use `tracing::` macros (`tracing::info!`, `tracing::debug!`, `tracing::warn!`, `tracing::error!`).

## Key Implementation Details

### Storage Architecture
Expand Down
7 changes: 7 additions & 0 deletions dash-spv/src/client/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,13 @@ impl<W: WalletInterface, N: NetworkManager, S: StorageManager> DashSpvClient<W,
}

/// Subscribe to sync events from the sync coordinator.
///
/// **Important:** Consumers MUST monitor for [`SyncEvent::ManagerExited`] events.
/// When a manager exits due to a fatal network error, it will not restart
/// automatically. The consumer should restart the SPV client to recover.
///
/// The overall sync state (via [`Self::subscribe_progress`] / [`Self::progress`])
/// will transition to [`SyncState::Error`] when any manager exits fatally.
pub fn subscribe_sync_events(&self) -> broadcast::Receiver<SyncEvent> {
self.sync_coordinator.subscribe_events()
}
Expand Down
6 changes: 6 additions & 0 deletions dash-spv/src/client/lifecycle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,12 @@ impl<W: WalletInterface, N: NetworkManager, S: StorageManager> DashSpvClient<W,
}
}

// Shut down sync coordinator: signals cancellation and waits for manager
// tasks to drain before we tear down the network and storage layers.
if let Err(e) = self.sync_coordinator.shutdown().await {
tracing::warn!("Error shutting down sync coordinator: {}", e);
}

// Disconnect from network
self.network.disconnect().await?;

Expand Down
20 changes: 18 additions & 2 deletions dash-spv/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -210,10 +210,26 @@ pub enum SyncError {
#[error("Timeout error: {0}")]
Timeout(String),

/// Network-related errors (e.g., connection failures, protocol errors)
/// Network-related errors (e.g., connection failures, protocol errors).
///
/// For transient network issues, use this variant or [`SyncError::Timeout`].
/// For fatal, unrecoverable conditions (e.g., request channel closed),
/// use [`SyncError::FatalNetwork`] instead.
#[error("Network error: {0}")]
Network(String),

/// Fatal network condition that causes the manager task loop to exit immediately.
///
/// Unlike [`SyncError::Network`], which is logged and allows the loop to continue,
/// this variant signals an unrecoverable failure (e.g., the request channel is closed)
/// and causes the manager to stop permanently.
///
/// **Consumer responsibility:** When this error occurs, [`SyncEvent::ManagerExited`]
/// is emitted and [`SyncState::Error`] is set on the affected manager's progress.
/// The consumer must restart the SPV client to recover the affected sync phase.
#[error("Fatal network error (manager exiting): {0}")]
FatalNetwork(String),

/// Validation errors for data received during sync (e.g., invalid headers, invalid proofs)
/// Use this for data validation errors, not state errors
#[error("Validation error: {0}")]
Expand All @@ -240,7 +256,7 @@ impl SyncError {
SyncError::Timeout(_) => "timeout",
SyncError::Validation(_) => "validation",
SyncError::MissingDependency(_) => "dependency",
SyncError::Network(_) => "network",
SyncError::Network(_) | SyncError::FatalNetwork(_) => "network",
SyncError::Storage(_) => "storage",
SyncError::Headers2DecompressionFailed(_) => "headers2",
SyncError::MasternodeSyncFailed(_) => "masternode",
Expand Down
42 changes: 32 additions & 10 deletions dash-spv/src/network/manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -240,31 +240,46 @@ impl PeerNetworkManager {
let message_dispatcher = self.message_dispatcher.clone();
let network_event_sender = self.network_event_sender.clone();

// Spawn connection task
let mut tasks = self.tasks.lock().await;
// Spawn connection task — use select to avoid blocking on the lock during shutdown
let mut tasks = tokio::select! {
guard = self.tasks.lock() => guard,
_ = self.shutdown_token.cancelled() => {
self.pool.remove_peer(&addr).await;
return;
}
};
tasks.spawn(async move {
log::debug!("Attempting to connect to {}", addr);
tracing::debug!("Attempting to connect to {}", addr);

match Peer::connect(addr, CONNECTION_TIMEOUT.as_secs(), network).await {
let connect_result = tokio::select! {
result = Peer::connect(addr, CONNECTION_TIMEOUT.as_secs(), network) => result,
_ = shutdown_token.cancelled() => {
tracing::debug!("Connection to {} cancelled by shutdown", addr);
pool.remove_peer(&addr).await;
return;
}
};

match connect_result {
Ok(mut peer) => {
// Perform handshake
let mut handshake_manager =
HandshakeManager::new(network, mempool_strategy, user_agent);
match handshake_manager.perform_handshake(&mut peer).await {
Ok(_) => {
log::info!("Successfully connected to {}", addr);
tracing::info!("Successfully connected to {}", addr);

// Request addresses from the peer for discovery
if let Err(e) = peer.send_message(NetworkMessage::GetAddr).await {
log::warn!("Failed to send GetAddr to {}: {}", addr, e);
tracing::warn!("Failed to send GetAddr to {}: {}", addr, e);
}

// Record successful connection
reputation_manager.record_successful_connection(addr).await;

// Add to pool
if let Err(e) = pool.add_peer(addr, peer).await {
log::error!("Failed to add peer to pool: {}", e);
tracing::error!("Failed to add peer to pool: {}", e);
return;
}

Expand Down Expand Up @@ -784,6 +799,7 @@ impl PeerNetworkManager {
if !this.pool.is_connected(addr).await
&& !this.pool.is_connecting(addr).await
{
if this.shutdown_token.is_cancelled() { break; }
log::info!("Reconnecting to exclusive peer: {}", addr);
this.connect_to_peer(*addr).await;
}
Expand All @@ -803,6 +819,7 @@ impl PeerNetworkManager {
if !this.pool.is_connected(&addr).await
&& !this.pool.is_connecting(&addr).await
{
if this.shutdown_token.is_cancelled() { break; }
this.connect_to_peer(addr).await;
attempted += 1;
if attempted >= needed {
Expand Down Expand Up @@ -1232,11 +1249,16 @@ impl PeerNetworkManager {
log::warn!("Failed to save reputation data on shutdown: {}", e);
}

// Wait for tasks to complete
let mut tasks = self.tasks.lock().await;
// Take tasks out of the mutex so we don't hold the lock while draining.
// This prevents a deadlock where a task (e.g. maintenance loop) tries to
// acquire self.tasks via connect_to_peer() while we hold the lock here.
let mut tasks = {
let mut guard = self.tasks.lock().await;
std::mem::take(&mut *guard)
};
while let Some(result) = tasks.join_next().await {
if let Err(e) = result {
log::error!("Task join error: {}", e);
tracing::error!("Task join error: {}", e);
}
}

Expand Down
25 changes: 24 additions & 1 deletion dash-spv/src/sync/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,9 @@ pub enum SyncEvent {
height: u32,
},

/// A manager encountered a recoverable error.
/// A manager encountered a non-fatal error (logged, loop continues).
///
/// For fatal errors that cause the manager to exit, see [`SyncEvent::ManagerExited`].
///
/// Emitted by: Any manager
/// Consumed by: Coordinator (for logging/monitoring)
Expand All @@ -120,6 +122,20 @@ pub enum SyncEvent {
error: String,
},

/// A manager task has exited due to a fatal error and will not restart.
///
/// This is a **critical event** that consumers MUST handle. The affected sync
/// phase has stopped permanently. The client should be restarted to recover.
///
/// Emitted by: Any manager (on `SyncError::FatalNetwork`)
/// Consumer action required: Restart the SPV client or take corrective action.
ManagerExited {
/// Which manager exited
manager: ManagerIdentifier,
/// Reason for exit
error: String,
},

/// ChainLock received and processed.
///
/// Emitted by: `ChainLockManager`
Expand Down Expand Up @@ -218,6 +234,13 @@ impl SyncEvent {
} => {
format!("ManagerError({}, {})", manager, error)
}
SyncEvent::ManagerExited {
manager,
error,
..
} => {
format!("ManagerExited({}, {})", manager, error)
}
SyncEvent::ChainLockReceived {
chain_lock,
validated,
Expand Down
Loading
Loading