Skip to content

Commit 77824fe

Browse files
authored
fix(dash-spv): preserve in-progress sync state on peer disconnect (#746)
* fix(dash-spv): preserve in-progress sync state on peer disconnect `BlocksManager` and `FiltersManager` wiped their pipelines on every disconnect, which raced with the previous cycle's `BlockProcessed` events still in flight and left `pending_blocks` stuck above zero so `SyncComplete` never fired. Replace the trait's `clear_in_flight_state` with a single `on_disconnect` hook. Each manager only invalidates state tied to the dead peer. `BlocksManager` and `FiltersManager` requeue their in-flight `getdata` / `getcfilters` slots so the next `send_pending` reissues them on the new peer, and `start_sync` resumes the preserved batches. The wallet-rescan path in `FiltersManager::tick` calls a manager-internal `reset_for_rescan` for the case that genuinely wants a full wipe. * fix(dash-spv): cancel pending entry when late `cfilter` completes a requeued batch After `requeue_in_flight()` moves a batch's `start_height` from `coordinator.in_flight` back to `coordinator.pending`, a buffered `CFilter` from the disconnected peer can still reach `receive_with_data` and complete the batch. The tracker is removed but `coordinator.receive` returns `false` (the key is in `pending`, not `in_flight`), leaving the key orphaned in `pending`. The next `send_pending` pops it, finds no tracker, and aborts sync with `SyncError::InvalidState`. Add `DownloadCoordinator::cancel_pending` and call it from `receive_with_data` when `receive` reports the key was not in flight. Addresses CodeRabbit review comment on PR #746 #746 (comment)
1 parent 5313086 commit 77824fe

15 files changed

Lines changed: 381 additions & 34 deletions

File tree

dash-spv/src/sync/block_headers/sync_manager.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ impl<H: BlockHeaderStorage, M: MetadataStorage> SyncManager for BlockHeadersMana
3636
&[MessageType::Headers, MessageType::Inv]
3737
}
3838

39-
fn clear_in_flight_state(&mut self) {
39+
fn on_disconnect(&mut self) {
4040
// Drop only per-peer in-flight bookkeeping. Segment topology and
4141
// validated chain state per segment (current_tip_hash, current_height,
4242
// buffered_headers, complete) are preserved so a reconnect can resume

dash-spv/src/sync/blocks/manager.rs

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -327,4 +327,41 @@ mod tests {
327327
"wallet_out was not in the routed set, must not be processed"
328328
);
329329
}
330+
331+
/// `on_disconnect` for `BlocksManager` keeps the downloaded buffer, the
332+
/// per-block wallet routing, and the `filters_sync_complete` flag, and
333+
/// moves any in-flight `getdata`s back to pending so the next
334+
/// `send_pending` reissues them. Without this preservation, blocks waiting
335+
/// in `downloaded` for height ordering would be dropped, leaving
336+
/// `FiltersManager.tracker` entries that never get decremented.
337+
#[tokio::test]
338+
async fn test_on_disconnect_preserves_pipeline_work() {
339+
use dashcore::block::Header;
340+
use dashcore::{Block, TxMerkleNode};
341+
use dashcore_hashes::Hash;
342+
343+
let mut manager = create_test_manager().await;
344+
manager.filters_sync_complete = true;
345+
346+
let header = Header {
347+
version: dashcore::blockdata::block::Version::from_consensus(1),
348+
prev_blockhash: dashcore::BlockHash::all_zeros(),
349+
merkle_root: TxMerkleNode::all_zeros(),
350+
time: 0,
351+
bits: dashcore::CompactTarget::from_consensus(0),
352+
nonce: 0,
353+
};
354+
let block = Block {
355+
header,
356+
txdata: vec![],
357+
};
358+
359+
// Already-downloaded block sitting in the pipeline.
360+
manager.pipeline.add_from_storage(block.clone(), 200, BTreeSet::from([MOCK_WALLET_ID]));
361+
362+
manager.on_disconnect();
363+
364+
assert!(!manager.pipeline.is_complete(), "downloaded buffer must survive on_disconnect");
365+
assert!(manager.filters_sync_complete);
366+
}
330367
}

dash-spv/src/sync/blocks/pipeline.rs

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,15 @@ impl BlocksPipeline {
191191
pub(super) fn handle_timeouts(&mut self) {
192192
self.coordinator.check_and_retry_timeouts();
193193
}
194+
195+
/// Move in-flight `getdata` requests back to pending after a peer
196+
/// disconnect so the next `send_pending` reissues them to the new peer.
197+
/// `pending_heights`, `downloaded`, `hash_to_height`, and `hash_to_wallets`
198+
/// are preserved so already-received blocks are not re-fetched and the
199+
/// per-block wallet routing stays intact.
200+
pub(super) fn requeue_in_flight(&mut self) {
201+
self.coordinator.requeue_in_flight();
202+
}
194203
}
195204

196205
#[cfg(test)]
@@ -308,6 +317,34 @@ mod tests {
308317
assert!(!pipeline.has_pending_requests());
309318
}
310319

320+
#[test]
321+
fn test_requeue_in_flight_preserves_downloaded_and_pending_heights() {
322+
let mut pipeline = BlocksPipeline::new();
323+
let block_a = make_test_block(1);
324+
let block_b = make_test_block(2);
325+
let hash_a = block_a.block_hash();
326+
let hash_b = block_b.block_hash();
327+
328+
// A: queued and sent — will be requeued.
329+
pipeline.queue([(FilterMatchKey::new(100, hash_a), BTreeSet::from([[1u8; 32]]))]);
330+
let sent = pipeline.coordinator.take_pending(1);
331+
pipeline.coordinator.mark_sent(&sent);
332+
assert_eq!(pipeline.coordinator.active_count(), 1);
333+
334+
// B: already received, sitting in `downloaded` — must survive requeue.
335+
pipeline.add_from_storage(block_b.clone(), 200, BTreeSet::from([[2u8; 32]]));
336+
337+
pipeline.requeue_in_flight();
338+
339+
assert_eq!(pipeline.coordinator.active_count(), 0);
340+
assert_eq!(pipeline.coordinator.pending_count(), 1);
341+
assert!(pipeline.pending_heights.contains(&100));
342+
assert_eq!(pipeline.hash_to_height.get(&hash_a), Some(&100));
343+
assert!(pipeline.hash_to_wallets.contains_key(&hash_a));
344+
assert!(pipeline.downloaded.contains_key(&200));
345+
assert!(pipeline.hash_to_wallets.contains_key(&hash_b));
346+
}
347+
311348
#[test]
312349
fn test_timeout_requeues() {
313350
// Create pipeline with very short timeout for testing

dash-spv/src/sync/blocks/sync_manager.rs

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
use crate::error::SyncResult;
22
use crate::network::{Message, MessageType, RequestSender};
33
use crate::storage::{BlockHeaderStorage, BlockStorage};
4-
use crate::sync::blocks::pipeline::BlocksPipeline;
54
use crate::sync::sync_manager::ensure_not_started;
65
use crate::sync::{
76
BlocksManager, ManagerIdentifier, SyncEvent, SyncManager, SyncManagerProgress, SyncState,
@@ -42,14 +41,25 @@ impl<H: BlockHeaderStorage, B: BlockStorage, W: WalletInterface + 'static> SyncM
4241
return Ok(vec![]);
4342
}
4443

45-
// Otherwise wait for BlocksNeeded or FiltersSyncComplete events
46-
self.set_state(SyncState::WaitForEvents);
44+
// If in-progress block work survived a disconnect, resume in Syncing so
45+
// `process_buffered_blocks` can transition to Synced once the pipeline
46+
// drains. Otherwise wait for BlocksNeeded / FiltersSyncComplete events.
47+
if !self.pipeline.is_complete() {
48+
self.set_state(SyncState::Syncing);
49+
} else {
50+
self.set_state(SyncState::WaitForEvents);
51+
}
4752
Ok(vec![])
4853
}
4954

50-
fn clear_in_flight_state(&mut self) {
51-
self.pipeline = BlocksPipeline::new();
52-
self.filters_sync_complete = false;
55+
/// Keep the entire pipeline (downloaded blocks, pending queue, per-block
56+
/// wallet routing) and the `filters_sync_complete` flag, and move in-flight
57+
/// `getdata`s back to the front of `pending` so the next `send_pending`
58+
/// reissues them to the new peer immediately. Without this preservation,
59+
/// `FiltersManager`'s tracker would re-track the same block hashes after a
60+
/// re-scan and leak `pending_blocks` counters that never reach zero.
61+
fn on_disconnect(&mut self) {
62+
self.pipeline.requeue_in_flight();
5363
}
5464

5565
async fn handle_message(

dash-spv/src/sync/chainlock/sync_manager.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ impl<H: BlockHeaderStorage, M: MetadataStorage> SyncManager for ChainLockManager
2626
&[MessageType::CLSig, MessageType::Inv]
2727
}
2828

29-
fn clear_in_flight_state(&mut self) {
29+
fn on_disconnect(&mut self) {
3030
self.requested_chainlocks.clear();
3131
self.masternode_ready = false;
3232
}

dash-spv/src/sync/download_coordinator.rs

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,23 @@ impl<K: Hash + Eq + Clone> DownloadCoordinator<K> {
9292
self.last_progress = Instant::now();
9393
}
9494

95+
/// Move all in-flight items back to the front of the pending queue.
96+
///
97+
/// Used on peer disconnect: the requests went to a now-dead peer, but the
98+
/// items themselves are still wanted. Retry counts are preserved so a peer
99+
/// that consistently fails to deliver an item still trips the normal retry
100+
/// budget. Without this hook, items would only be retried once their
101+
/// timeout elapsed.
102+
pub(crate) fn requeue_in_flight(&mut self) {
103+
let items: Vec<K> = self.in_flight.drain().map(|(k, _)| k).collect();
104+
if items.is_empty() {
105+
return;
106+
}
107+
for item in items.into_iter().rev() {
108+
self.pending.push_front(item);
109+
}
110+
}
111+
95112
/// Queue items for download.
96113
pub(crate) fn enqueue(&mut self, items: impl IntoIterator<Item = K>) {
97114
for item in items {
@@ -148,6 +165,18 @@ impl<K: Hash + Eq + Clone> DownloadCoordinator<K> {
148165
}
149166
}
150167

168+
/// Drop a key from the pending queue without touching in-flight state.
169+
///
170+
/// Used when a pending item is satisfied through a side channel: a late
171+
/// response from a disconnected peer can complete a batch that
172+
/// `requeue_in_flight` just moved from in-flight back to pending. Without
173+
/// this hook, the key would stay in `pending` with no tracker, and the
174+
/// next `take_pending` would resurrect a finished batch.
175+
pub(crate) fn cancel_pending(&mut self, key: &K) {
176+
self.pending.retain(|k| k != key);
177+
self.retry_counts.remove(key);
178+
}
179+
151180
/// Check if an item is currently in-flight.
152181
pub(crate) fn is_in_flight(&self, key: &K) -> bool {
153182
self.in_flight.contains_key(key)
@@ -315,6 +344,80 @@ mod tests {
315344
assert!(coord.in_flight.is_empty());
316345
}
317346

347+
#[test]
348+
fn test_requeue_in_flight_moves_items_to_pending_front() {
349+
let mut coord: DownloadCoordinator<u32> = DownloadCoordinator::default();
350+
coord.enqueue([10, 11]);
351+
coord.mark_sent(&[1, 2, 3]);
352+
353+
coord.requeue_in_flight();
354+
355+
assert_eq!(coord.active_count(), 0);
356+
// Requeued items go to the front, original pending follows.
357+
let items = coord.take_pending(5);
358+
assert_eq!(items.len(), 5);
359+
assert_eq!(&items[3..], &[10, 11]);
360+
let mut requeued = items[..3].to_vec();
361+
requeued.sort();
362+
assert_eq!(requeued, vec![1, 2, 3]);
363+
}
364+
365+
#[test]
366+
fn test_requeue_in_flight_preserves_retry_counts() {
367+
let mut coord: DownloadCoordinator<u32> = DownloadCoordinator::default();
368+
coord.enqueue_retry(7);
369+
let items = coord.take_pending(1);
370+
coord.mark_sent(&items);
371+
assert_eq!(coord.retry_counts.get(&7), Some(&1));
372+
373+
coord.requeue_in_flight();
374+
375+
assert_eq!(coord.retry_counts.get(&7), Some(&1));
376+
assert!(!coord.is_in_flight(&7));
377+
assert_eq!(coord.pending_count(), 1);
378+
}
379+
380+
#[test]
381+
fn test_requeue_in_flight_no_op_when_empty() {
382+
let mut coord: DownloadCoordinator<u32> = DownloadCoordinator::default();
383+
coord.enqueue([1, 2]);
384+
385+
coord.requeue_in_flight();
386+
387+
assert_eq!(coord.pending_count(), 2);
388+
assert_eq!(coord.active_count(), 0);
389+
}
390+
391+
#[test]
392+
fn test_cancel_pending_removes_from_pending_only() {
393+
let mut coord: DownloadCoordinator<u32> = DownloadCoordinator::default();
394+
coord.enqueue([1, 2, 3]);
395+
coord.mark_sent(&[10]);
396+
coord.enqueue_retry(2);
397+
assert_eq!(coord.retry_counts.get(&2), Some(&1));
398+
399+
coord.cancel_pending(&2);
400+
401+
assert_eq!(coord.pending_count(), 2);
402+
assert!(coord.is_in_flight(&10));
403+
assert_eq!(coord.retry_counts.get(&2), None);
404+
405+
let items = coord.take_pending(2);
406+
assert_eq!(items, vec![1, 3]);
407+
}
408+
409+
#[test]
410+
fn test_cancel_pending_unknown_key_is_noop() {
411+
let mut coord: DownloadCoordinator<u32> = DownloadCoordinator::default();
412+
coord.enqueue([1, 2]);
413+
coord.mark_sent(&[5]);
414+
415+
coord.cancel_pending(&99);
416+
417+
assert_eq!(coord.pending_count(), 2);
418+
assert_eq!(coord.active_count(), 1);
419+
}
420+
318421
#[test]
319422
fn test_clear() {
320423
let mut coord: DownloadCoordinator<u32> = DownloadCoordinator::default();

dash-spv/src/sync/filter_headers/manager.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -380,14 +380,14 @@ mod tests {
380380
}
381381

382382
#[tokio::test]
383-
async fn test_clear_in_flight_state() {
383+
async fn test_on_disconnect() {
384384
let mut manager = create_test_manager().await;
385385

386-
// Set all fields that clear_in_flight_state resets
386+
// Set all fields that on_disconnect resets
387387
manager.block_headers_synced = true;
388388
manager.checkpoint_start_height = Some(500);
389389

390-
manager.clear_in_flight_state();
390+
manager.on_disconnect();
391391

392392
assert!(!manager.block_headers_synced);
393393
assert!(manager.checkpoint_start_height.is_none());

dash-spv/src/sync/filter_headers/sync_manager.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ impl<H: BlockHeaderStorage, FH: FilterHeaderStorage> SyncManager for FilterHeade
3131
&[MessageType::CFHeaders]
3232
}
3333

34-
fn clear_in_flight_state(&mut self) {
34+
fn on_disconnect(&mut self) {
3535
self.pipeline = FilterHeadersPipeline::default();
3636
self.checkpoint_start_height = None;
3737
self.block_headers_synced = false;

0 commit comments

Comments
 (0)