Skip to content

fix(dash-spv): recover masternode sync from a rejected QRInfo - #936

Closed
bfoss765 wants to merge 3 commits into
dashpay:devfrom
bfoss765:fix/spv-qrinfo-stall-recovery
Closed

fix(dash-spv): recover masternode sync from a rejected QRInfo#936
bfoss765 wants to merge 3 commits into
dashpay:devfrom
bfoss765:fix/spv-qrinfo-stall-recovery

Conversation

@bfoss765

@bfoss765 bfoss765 commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Summary

One rejected QRInfo response permanently strands masternode sync. qrinfo_received() cleared qrinfo_in_flight before the fallible feed_qr_info, so a validation failure returned Err and left the manager in Syncing with qrinfo_in_flight = None and an empty diff pipeline. The QRINFO_TIMEOUT_SCHEDULE_SECS = [10, 30, 60] retry ladder is armed solely by qrinfo_in_flight, so tick() fell through forever. The error surfaced only as a SyncEvent::ManagerError, which has no consumer — the run loop logs it and continues.

No watchdog existed. The comment at masternodes/manager.rs:544 already warned about stranding "Syncing with qrinfo_in_flight = None, which tick cannot recover", but only guarded the not-yet-Syncing entrance.

Masternode sync gates overall SYNCED (sync/progress.rs:79-97), so while stalled every InstantLock fails verification, DAPI has no masternode list, and platform features are dead until the process restarts. It is intermittent because a fresh wallet chains diffs from genesis (compute_qrinfo_anchor_hash returns None), a much wider validation surface than an incremental catch-up.

On-device evidence

Testnet, Galaxy S21, 2026-08-06, fresh wallet (dash_spv/run.log):

ERROR dash_spv::sync::masternodes::sync_manager: QRInfo feed into engine failed: All commitment aggregated signature not valid: invalid signature

then frozen for ~40 minutes until restart:

Masternodes: Syncing 0/1529065 | diffs_processed: 0, qr_infos_requested: 1

Discriminating greps over the same log — these are what prove the timeout branch never ran, rather than the task dying or the log being lossy:

grep hits
Timeout waiting for QRInfo 0
Masternode task exiting 0
Lagged 0

Mainnet, Galaxy S22, 2026-08-09, restored wallet — reproduced twice in one day, in two separate processes:

2026-08-09T18:32:23.668969Z ERROR dash_spv::sync::masternodes::sync_manager: QRInfo feed into engine failed: All commitment aggregated signature not valid: invalid signature
Masternodes: Syncing 0/2519119 | diffs_processed: 0, qr_infos_requested: 1

Frozen 34+ minutes. DAPI logged total: 0 calls over 33.73 minutes and mnlist=0 while the chain sat at tip. User-visible symptoms: "unable to connect", and an invitation-creation confirm loop (asset-lock InstantSend verification is impossible with no quorums).

The frozen qr_infos_requested: 1 is the direct signature of the bug: the counter is bumped by send_qrinfo_for_tip, so a stuck 1 means no retry was ever dispatched.

Changes

1. Release the request slot only after the last fallible step (sync_manager.rs)

qrinfo_received() moves from handler entry to just before queue_requests, after feed_qrinfo_heights_to_engine, feed_qr_info and build_mnlistdiff_request_pairs have all succeeded. It still has to run before the has_pending_requests() check that follows, which reads it.

On the feed_qr_info error branch the slot stays armed and the attempt is flagged rejected. tick treats that flag as an elapsed timeout, so the retry goes out on the next tick rather than waiting out a timeout the peer has already answered. Each dispatch goes through send_distributed, whose round-robin (network/manager.rs:1194-1201) lands it on a different peer than the one that served the bad response.

last_processed_qrinfo_tip continues to be set only on success, so the retry's response is not dropped as a duplicate by should_process_qrinfo.

One deviation from the agreed design, called out deliberately. The design said to increment qrinfo_retry_count on the error branch. Doing that double-counts: the tick timeout branch increments as well, so each rejection would burn two budget slots and the run would terminate after 2 dispatches, not the intended 3. Flagging the attempt and letting tick own the counter keeps the increment at exactly one per attempt, gives the full MAX_RETRY_ATTEMPTS = 3 dispatches, and reuses the tick branch verbatim instead of duplicating its dispatch and give-up logic.

2. Stall watchdog in tick (sync_manager.rs)

Syncing + no QRInfo in flight + empty diff pipeline for longer than QRINFO_STALL_WATCHDOG (60s) re-dispatches a QRInfo. This is the backstop for the routes the in-flight flag cannot cover — notably a send_qrinfo_for_tip that fails after its caller already cleared the slot, which is exactly what tick's own retry path does.

Timed off a new MasternodeSyncState::last_qrinfo_dispatch, stamped inside start_waiting_for_qrinfo so it can never drift from the actual dispatch. Deliberately not progress.last_activity(), which unrelated block-header events keep bumping (masternodes/progress.rs) and which would therefore reset the watchdog while masternode sync itself made no progress at all.

The watchdog re-stamps before dispatching, so a dispatch that fails (no peers, empty header storage) re-arms it for another full interval instead of becoming a 10 Hz retry loop. It cannot race the retry ladder: the ladder only runs while the slot is occupied, the watchdog only while it is empty.

3. on_disconnect requeues instead of clearing (sync_manager.rs, manager.rs, pipeline.rs)

clear_pending()requeue_in_flight(), mirroring BlocksManager (sync/blocks/sync_manager.rs:61-63) and FiltersManager. In-flight GetMnListDiffs move back to the front of the pending queue with their base_hashes mapping intact; the QRInfo slot stays armed so the ladder re-dispatches it. The old clear_pending() also discarded the qr_info_result carried on pipeline_mode, forcing a full QRInfo re-run after every disconnect. The qrinfo_retry_count and last_processed_qrinfo_tip resets are kept — a fresh peer set earns a fresh budget, and the dedup guard must not reject the reconnect's response.

This required one supporting change to make the requeue actionable: tick now flushes the pending queue whenever the pipeline is non-empty, not only when something is in flight. That is the only path that can reissue requeued requests — every other send_pending call site hangs off a response handler, which cannot run while nothing is outstanding. It is a no-op for every pre-existing path, since queue_requests, handle_timeouts and requeue are all already followed by a send_pending.

Tests

Four new tests in sync/masternodes/sync_manager.rs, on a real MasternodesManager over DiskStorageManager with a bound RequestSender receiver:

  • test_rejected_qrinfo_retries_against_another_peer_then_gives_up — drives a rejected QRInfo through handle_message + tick for the whole budget. Asserts the slot is not released and is flagged rejected, that the handler does not consume budget itself, that each tick dispatches exactly one retry GetQRInfo and re-arms a clean attempt, and that the run terminates at exactly MAX_RETRY_ATTEMPTS dispatches without parking the manager in Syncing. Verified to fail against the pre-fix ordering (panics on "a rejected response must NOT release the request slot").
  • test_tick_watchdog_redispatches_after_stall — asserts the watchdog stays quiet inside its interval and re-dispatches with a fresh budget once it elapses.
  • test_on_disconnect_requeues_instead_of_clearing — asserts the dead peer's slot is released but the request survives in pending, and the QRInfo slot stays armed at the right tip.
  • test_tick_reissues_requeued_mnlistdiffs — asserts tick actually puts a requeued GetMnListDiff back on the wire.
cargo test -p dash-spv (SKIP_DASHD_TESTS=1)   588 passed, 0 failed
cargo clippy -p dash-spv --all-targets -- -D warnings   clean
cargo fmt -p dash-spv   clean

Out of scope

Noted while root-causing, intentionally not touched here, for a separate ticket: sync/sync_manager.rs:281-285 and :308-312 treat a recoverable RecvError::Lagged(n) on the broadcast receivers as fatal and break out of the manager's run loop.

Summary by CodeRabbit

  • Bug Fixes
    • Improved masternode synchronization reliability when peers reject requests or disconnect.
    • Automatically retries rejected synchronization requests.
    • Preserves pending work and retries it with another peer instead of discarding it.
    • Added recovery for stalled synchronization, including automatic redispatch after extended inactivity.
    • Improved request completion handling to prevent work from being released before processing finishes.
  • Tests
    • Added coverage for rejection retries, disconnect recovery, stalled-sync recovery, and request redispatching.

One QRInfo response the engine rejects permanently strands masternode
sync. `qrinfo_received()` cleared `qrinfo_in_flight` before the fallible
`feed_qr_info`, so a validation failure returned `Err` leaving the
manager in `Syncing` with nothing in flight and an empty diff pipeline.
The [10, 30, 60] retry ladder is armed solely by `qrinfo_in_flight`, so
`tick` fell through forever - a frozen `qr_infos_requested: 1` for the
life of the process, with the error surfacing only as a
`SyncEvent::ManagerError` that has no consumer.

Masternode sync gates overall SYNCED, so while stalled every InstantLock
fails verification, DAPI has no masternode list, and platform features
are dead until a restart.

Three changes:

1. Release the request slot only after the last fallible step, just
   before `queue_requests`. On the `feed_qr_info` error branch keep the
   slot armed and flag the attempt `rejected`, which `tick` treats as an
   elapsed timeout: the retry rotates to the next peer via
   `send_distributed`'s round-robin, on the existing budget, so a
   deterministically-bad response still terminates after
   MAX_RETRY_ATTEMPTS dispatches. `last_processed_qrinfo_tip` continues
   to be set only on success, so the retry is not dropped as a duplicate.

2. Add a stall watchdog to `tick`: `Syncing` with no QRInfo in flight and
   an empty diff pipeline for longer than 60s re-dispatches a QRInfo.
   This covers the routes the in-flight flag cannot, notably a
   `send_qrinfo_for_tip` that fails after its caller already cleared the
   slot. Timed off a new `last_qrinfo_dispatch` rather than
   `progress.last_activity()`, which unrelated block events keep bumping.

3. `on_disconnect` requeues in-flight `GetMnListDiff`s instead of
   clearing, mirroring `BlocksManager` and `FiltersManager`, and leaves
   the QRInfo slot armed. `tick` now flushes the pending queue whenever
   the pipeline is non-empty, which is what actually reissues requeued
   requests - every other `send_pending` call site hangs off a response
   handler that cannot run while nothing is in flight.
@coderabbitai

coderabbitai Bot commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: c5305177-2675-4893-aa81-1f29dd055651

📥 Commits

Reviewing files that changed from the base of the PR and between 3859e61 and f4570c0.

📒 Files selected for processing (1)
  • dash-spv/src/sync/masternodes/sync_manager.rs
🚧 Files skipped from review as they are similar to previous changes (1)
  • dash-spv/src/sync/masternodes/sync_manager.rs

📝 Walkthrough

Walkthrough

The masternode sync flow tracks QRInfo rejection and dispatch time, preserves work across disconnects, retries rejected requests, redispatches pending MnListDiff requests, and recovers stalled synchronization through a watchdog.

Changes

Masternode sync reliability

Layer / File(s) Summary
Sync state and in-flight requeueing
dash-spv/src/sync/masternodes/manager.rs, dash-spv/src/sync/masternodes/pipeline.rs, dash-spv/src/sync/masternodes/sync_manager.rs
Sync state tracks QRInfo rejection and dispatch time. Disconnect handling requeues in-flight MnListDiff requests while retaining base-hash mappings and retry state.
QRInfo rejection and completion handling
dash-spv/src/sync/masternodes/manager.rs, dash-spv/src/sync/masternodes/sync_manager.rs
QRInfo dispatch initializes rejection state and a shared timestamp. Failed processing keeps the request in flight. Successful processing releases the request slot after fallible processing completes.
Retry scheduling and integration validation
dash-spv/src/sync/masternodes/sync_manager.rs
tick retries rejected QRInfo requests, flushes pending MnListDiff requests, and redispatches QRInfo after a 60-second stall. Tests cover retry exhaustion, watchdog recovery, disconnect requeueing, and tick-driven redispatch.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant SyncManager
  participant Peer
  participant MnListDiffPipeline
  SyncManager->>SyncManager: tick()
  alt QRInfo is rejected or timed out
    SyncManager->>Peer: retry QRInfo
  else MnListDiff work is pending
    SyncManager->>MnListDiffPipeline: send_pending()
    MnListDiffPipeline->>Peer: dispatch MnListDiff
  else sync is stalled for 60 seconds
    SyncManager->>Peer: redispatch QRInfo
  end
Loading

Possibly related PRs

Suggested labels: ready-for-review

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: recovering masternode synchronization after rejected QRInfo responses.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@dash-spv/src/sync/masternodes/sync_manager.rs`:
- Around line 32-43: Make QRINFO_STALL_WATCHDOG configurable by sourcing its
duration from the existing synchronization or client configuration instead of
defining a fixed Duration::from_secs(60). Update the SyncManager initialization
and tick logic to use the configured value while preserving the watchdog’s
empty-slot Syncing behavior.
- Around line 1047-1293: Add an external integration test under dash-spv/tests
that exercises the public synchronization API through a recovery scenario, such
as a rejected QRInfo or peer disconnect, and verifies that synchronization
re-dispatches or resumes successfully. Keep the existing in-module tests
unchanged, and drive the scenario through publicly accessible setup, message,
and synchronization-flow interfaces rather than inspecting private manager
state.
- Around line 347-350: Move the `last_processed_qrinfo_tip` assignment from
before `build_mnlistdiff_request_pairs(...).await?` to immediately before
`self.sync_state.qrinfo_received()`, after all fallible QRInfo processing
succeeds. Add a regression test covering request-pair construction failure
followed by a retry for the same tip, verifying the retry is processed
successfully.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: fe140a20-2aa5-4e54-9463-4f5a67c4a89b

📥 Commits

Reviewing files that changed from the base of the PR and between b056d07 and 8baa456.

📒 Files selected for processing (3)
  • dash-spv/src/sync/masternodes/manager.rs
  • dash-spv/src/sync/masternodes/pipeline.rs
  • dash-spv/src/sync/masternodes/sync_manager.rs

Comment on lines +32 to +43
/// How long the manager may sit in `Syncing` with no QRInfo in flight and an empty
/// MnListDiff pipeline before `tick` re-dispatches a QRInfo.
///
/// That combination is a dead state: every retry path in `tick` is armed solely by
/// `qrinfo_in_flight`, so once the slot is empty while `Syncing`, nothing fires
/// again and masternode sync is stranded for the life of the process - which also
/// strands InstantSend verification and every other masternode-list consumer.
///
/// Chosen shorter than the full retry ladder (`sum(QRINFO_TIMEOUT_SCHEDULE_SECS)
/// = 100s`) because the two can never overlap: the ladder only runs while the slot
/// is occupied, the watchdog only while it is empty.
const QRINFO_STALL_WATCHDOG: Duration = Duration::from_secs(60);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | 🏗️ Heavy lift

Make the watchdog interval configurable.

Duration::from_secs(60) hardcodes a network recovery parameter. Source this value from synchronization or client configuration.

As per coding guidelines, **/*.rs: “Never hardcode network parameters, addresses, or keys”.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@dash-spv/src/sync/masternodes/sync_manager.rs` around lines 32 - 43, Make
QRINFO_STALL_WATCHDOG configurable by sourcing its duration from the existing
synchronization or client configuration instead of defining a fixed
Duration::from_secs(60). Update the SyncManager initialization and tick logic to
use the configured value while preserving the watchdog’s empty-slot Syncing
behavior.

Source: Coding guidelines

Comment thread dash-spv/src/sync/masternodes/sync_manager.rs
Comment on lines +1047 to +1293
/// Same filler `QRInfo` as [`qrinfo_with_tip`], but carrying an explicit tip
/// hash so it can be aimed at whatever tip a live manager actually requested.
fn qrinfo_with_tip_hash(tip: BlockHash) -> QRInfo {
let mut qr_info = qrinfo_with_tip(0x00);
qr_info.mn_list_diff_tip.block_hash = tip;
qr_info
}

type TestMasternodesManager = MasternodesManager<PersistentBlockHeaderStorage>;

/// Build a regtest manager whose header storage holds dummy headers up to
/// `tip`, then fire the initial QRInfo so it sits in `Syncing` with a real
/// in-flight request, exactly as a fresh wallet does once headers catch up.
///
/// Returns the manager, the `RequestSender`, the matching receiver (the caller
/// must bind it - the channel closes when it drops), and the tip hash the
/// in-flight request was made for. A response has to echo that hash to get
/// past `should_process_qrinfo`. The initial `GetQRInfo` is drained from the
/// receiver so callers only see what they trigger themselves.
async fn syncing_manager_awaiting_qrinfo(
tip: u32,
) -> (TestMasternodesManager, RequestSender, mpsc::UnboundedReceiver<NetworkRequest>, BlockHash)
{
let storage = DiskStorageManager::with_temp_dir().await.unwrap();
let block_headers = storage.block_headers();
block_headers
.write()
.await
.store_headers(
&Header::dummy_batch(0..tip + 1)
.iter()
.map(HashedBlockHeader::from)
.collect::<Vec<_>>(),
)
.await
.unwrap();
let engine = MasternodeListEngine::default_for_network(Network::Regtest);
let mut manager =
MasternodesManager::new(block_headers, Arc::new(RwLock::new(engine)), Network::Regtest)
.await;
manager.progress.update_block_header_tip_height(tip);

let (tx, mut rx) = mpsc::unbounded_channel();
let requests = RequestSender::new(tx);
manager.send_qrinfo_for_tip(&requests).await.expect("initial QRInfo dispatch succeeds");
assert_eq!(manager.state(), SyncState::Syncing);
let tip_hash = manager.sync_state.qrinfo_in_flight.expect("QRInfo in flight").tip;
rx.try_recv().expect("initial GetQRInfo is queued");
(manager, requests, rx, tip_hash)
}

/// A QRInfo the engine rejects must not release the request slot.
///
/// `qrinfo_in_flight` is the only thing that arms `tick`'s retry ladder, so
/// the pre-fix ordering - release the slot, *then* run the fallible engine
/// feed - left the manager `Syncing` with nothing in flight and an empty diff
/// pipeline on any validation failure. `tick` has no branch for that state, so
/// masternode sync never resumed: observed once on testnet and twice on
/// mainnet as a frozen `Masternodes: Syncing 0/N | diffs_processed: 0,
/// qr_infos_requested: 1` for as long as the process lived, with InstantSend
/// verification and every masternode-list consumer dead behind it.
///
/// With the slot kept armed and the attempt flagged, `tick` retries on the
/// existing budget. Each dispatch goes out through `send_distributed`, whose
/// round-robin lands it on a different peer than the one that served the bad
/// response, and a deterministically-bad response still terminates - after
/// `MAX_RETRY_ATTEMPTS` dispatches, not silently and not never.
#[tokio::test]
async fn test_rejected_qrinfo_retries_against_another_peer_then_gives_up() {
let (mut manager, requests, mut rx, tip_hash) = syncing_manager_awaiting_qrinfo(200).await;
let peer = "127.0.0.1:19999".parse().unwrap();
let bad_response =
|| Message::new(peer, NetworkMessage::QRInfo(qrinfo_with_tip_hash(tip_hash)));

for attempt in 0..MAX_RETRY_ATTEMPTS - 1 {
let err = manager
.handle_message(bad_response(), &requests)
.await
.expect_err("the engine must reject this filler QRInfo");
assert!(
matches!(err, SyncError::MasternodeSyncFailed(_)),
"expected MasternodeSyncFailed, got {:?}",
err
);

let in_flight = manager
.sync_state
.qrinfo_in_flight
.expect("a rejected response must NOT release the request slot");
assert!(in_flight.rejected, "the spent attempt must be flagged for retry");
assert_eq!(
manager.sync_state.qrinfo_retry_count, attempt,
"the handler must not consume budget itself; `tick` owns the counter"
);

manager.tick(&requests).await.expect("tick retries the rejected attempt");
assert_eq!(manager.sync_state.qrinfo_retry_count, attempt + 1);
assert!(
matches!(
rx.try_recv().expect("tick must dispatch a retry GetQRInfo"),
NetworkRequest::SendMessage(NetworkMessage::GetQRInfo(_))
),
"the retry must be a GetQRInfo"
);
assert!(
!manager.sync_state.qrinfo_in_flight.expect("slot re-armed").rejected,
"the retry must start as a clean attempt"
);
}

// Budget spent. The last rejection must terminate rather than dispatch
// again, and must not park the manager in `Syncing` forever.
manager
.handle_message(bad_response(), &requests)
.await
.expect_err("the engine must reject this filler QRInfo");
let _ = manager.tick(&requests).await;
assert!(
rx.try_recv().is_err(),
"the retry budget must stop dispatching after MAX_RETRY_ATTEMPTS"
);
assert_eq!(
manager.progress.qr_infos_requested(),
MAX_RETRY_ATTEMPTS as u32,
"exactly MAX_RETRY_ATTEMPTS dispatches: the initial one plus its retries"
);
assert_ne!(
manager.state(),
SyncState::Syncing,
"giving up must resolve the state, not leave it silently Syncing"
);
}

/// The stall watchdog is the backstop for every route into the stranded state
/// that keeping the in-flight flag cannot cover - notably a
/// `send_qrinfo_for_tip` that fails *after* its caller already cleared the
/// slot, which is exactly what `tick`'s own retry path does. `Syncing` with
/// nothing in flight and an empty diff pipeline has no other exit.
#[tokio::test]
async fn test_tick_watchdog_redispatches_after_stall() {
let (mut manager, requests, mut rx, _) = syncing_manager_awaiting_qrinfo(200).await;

// Strand the manager the way a dispatch failure would.
manager.sync_state.clear_pending();
assert_eq!(manager.state(), SyncState::Syncing);
assert!(manager.sync_state.qrinfo_in_flight.is_none());
assert!(manager.sync_state.mnlistdiff_pipeline.is_complete());

manager.tick(&requests).await.expect("tick succeeds");
assert!(
rx.try_recv().is_err(),
"the watchdog must stay quiet until its interval has elapsed"
);

manager.sync_state.last_qrinfo_dispatch = Some(
Instant::now()
.checked_sub(QRINFO_STALL_WATCHDOG + Duration::from_secs(1))
.expect("test host uptime must exceed the watchdog interval"),
);

manager.tick(&requests).await.expect("the watchdog re-dispatch succeeds");
assert!(
matches!(
rx.try_recv().expect("the watchdog must re-dispatch a GetQRInfo"),
NetworkRequest::SendMessage(NetworkMessage::GetQRInfo(_))
),
"the watchdog must re-dispatch a GetQRInfo"
);
assert!(
manager.sync_state.qrinfo_in_flight.is_some(),
"the re-dispatch must re-arm the request slot"
);
assert_eq!(manager.sync_state.qrinfo_retry_count, 0, "recovery starts a fresh budget");
}

/// A disconnect must not throw masternode work away. `BlocksManager` and
/// `FiltersManager` both requeue their in-flight network slots; the masternode
/// manager used to call `clear_pending()`, which dropped the queued
/// `GetMnListDiff`s, the QRInfo slot, and any `qr_info_result` carried on
/// `pipeline_mode`, forcing a full QRInfo re-run on every reconnect.
#[tokio::test]
async fn test_on_disconnect_requeues_instead_of_clearing() {
let (mut manager, requests, _rx, tip_hash) = syncing_manager_awaiting_qrinfo(200).await;

let base = BlockHash::from_slice(&[0x11; 32]).unwrap();
let target = BlockHash::from_slice(&[0x22; 32]).unwrap();
manager.sync_state.mnlistdiff_pipeline.queue_requests(vec![(base, target)]);
manager.sync_state.mnlistdiff_pipeline.send_pending(&requests).expect("send succeeds");
assert_eq!(manager.sync_state.mnlistdiff_pipeline.active_count(), 1);

manager.on_disconnect();

assert_eq!(
manager.sync_state.mnlistdiff_pipeline.active_count(),
0,
"the dead peer's network slot must be released"
);
assert!(
!manager.sync_state.mnlistdiff_pipeline.is_complete(),
"but the request itself must survive, back in the pending queue"
);
assert_eq!(
manager.sync_state.qrinfo_in_flight.map(|in_flight| in_flight.tip),
Some(tip_hash),
"the QRInfo slot must stay armed so tick's ladder re-dispatches it"
);
assert_eq!(
manager.sync_state.qrinfo_retry_count, 0,
"a fresh peer set earns a fresh retry budget"
);
assert_eq!(
manager.sync_state.last_processed_qrinfo_tip, None,
"the dedup guard must not reject the reconnect's response"
);
}

/// `tick` must reissue MnListDiff requests that a disconnect moved back to
/// pending. Nothing else can: every other `send_pending` call site hangs off a
/// response handler, and after a disconnect no response is coming.
#[tokio::test]
async fn test_tick_reissues_requeued_mnlistdiffs() {
let (mut manager, requests, mut rx, _) = syncing_manager_awaiting_qrinfo(200).await;
manager.sync_state.qrinfo_received();

let base = BlockHash::from_slice(&[0x11; 32]).unwrap();
let target = BlockHash::from_slice(&[0x22; 32]).unwrap();
manager.sync_state.mnlistdiff_pipeline.queue_requests(vec![(base, target)]);
manager.sync_state.mnlistdiff_pipeline.send_pending(&requests).expect("send succeeds");
while rx.try_recv().is_ok() {}

manager.on_disconnect();
assert_eq!(manager.sync_state.mnlistdiff_pipeline.active_count(), 0);

manager.tick(&requests).await.expect("tick reissues the requeued request");
assert!(
matches!(
rx.try_recv().expect("tick must reissue the GetMnListDiff"),
NetworkRequest::SendMessage(NetworkMessage::GetMnListD(_))
),
"the reissued request must be a GetMnListDiff"
);
assert_eq!(
manager.sync_state.mnlistdiff_pipeline.active_count(),
1,
"the reissued request must be tracked in flight again"
);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | 🏗️ Heavy lift

Add an external integration test for the recovery flow.

These in-module tests validate internal state transitions. Add at least one recovery scenario under dash-spv/tests/ that drives the public synchronization flow across a rejected QRInfo or peer disconnect.

As per coding guidelines, **/*.rs: “Write unit tests for new functionality”. As per path instructions, dash-spv/**/{src,tests}/**/*.rs: “Implement comprehensive unit tests in-module for individual components using #[cfg(test)] and integration tests in the tests/ directory”.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@dash-spv/src/sync/masternodes/sync_manager.rs` around lines 1047 - 1293, Add
an external integration test under dash-spv/tests that exercises the public
synchronization API through a recovery scenario, such as a rejected QRInfo or
peer disconnect, and verifies that synchronization re-dispatches or resumes
successfully. Keep the existing in-module tests unchanged, and drive the
scenario through publicly accessible setup, message, and synchronization-flow
interfaces rather than inspecting private manager state.

Sources: Coding guidelines, Path instructions

@codecov

codecov Bot commented Aug 9, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 97.43590% with 4 lines in your changes missing coverage. Please review.
✅ Project coverage is 75.53%. Comparing base (b056d07) to head (f4570c0).
⚠️ Report is 2 commits behind head on dev.

Files with missing lines Patch % Lines
dash-spv/src/sync/masternodes/sync_manager.rs 97.16% 4 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##              dev     #936      +/-   ##
==========================================
+ Coverage   75.18%   75.53%   +0.34%     
==========================================
  Files         328      328              
  Lines       78194    79283    +1089     
==========================================
+ Hits        58792    59884    +1092     
+ Misses      19402    19399       -3     
Flag Coverage Δ
core 77.29% <ø> (ø)
ffi 48.75% <ø> (+0.16%) ⬆️
rpc 20.00% <ø> (ø)
spv 91.53% <97.43%> (+0.16%) ⬆️
wallet 77.80% <ø> (+0.91%) ⬆️
Files with missing lines Coverage Δ
dash-spv/src/sync/masternodes/manager.rs 94.41% <100.00%> (+0.90%) ⬆️
dash-spv/src/sync/masternodes/pipeline.rs 98.07% <100.00%> (+0.02%) ⬆️
dash-spv/src/sync/masternodes/sync_manager.rs 88.43% <97.16%> (+6.37%) ⬆️

... and 15 files with indirect coverage changes

@bfoss765

Copy link
Copy Markdown
Contributor Author

Additional field evidence, this time from a production device (v11.9.x wallet, dashj 22.0.5, mainnet, 5 days of continuous logs): the same rejection occurred organically 26 times across two days —

MasternodeListDiffException: The mnlistdiff does not connect to this list. height: -1 -> 2515960

plus a TimeoutException on GetQuorumRotationInfo in the same window, with the masternode list briefly unusable (216 consecutive tipHeight ... vs -1 mismatches). dashj recovered every single time because it keeps re-requesting (requesting next qrinfo 2517417 -> 2517408); by later in the session qrinfo processed normally with no user-visible impact.

So the reject-then-retry scenario is not an edge case — it happens repeatedly in the field under normal mainnet conditions, and retrying is the established, proven recovery behavior in the reference client. This PR gives the SPV client that same behavior; without it the first such rejection permanently strands masternode sync (no retry is ever armed).

bfoss765 and others added 2 commits August 10, 2026 10:07
…llible step

Recording last_processed_qrinfo_tip before build_mnlistdiff_request_pairs
turned the dedup gate against the retry ladder: a failure in that step left
the request slot armed, but every retried response for the same tip was
rejected at the handler entry by should_process_qrinfo, so the retry budget
burned down with no way to succeed - reintroducing the permanent stall this
branch exists to fix, through a different fallible step.

Defer the record to the success path, next to qrinfo_received(). A straggler
can only arrive after the handler returns, so the dedup gate loses nothing.
Re-feeding the engine on such a retry is already accepted by design - the
on_disconnect path clears the recorded tip for the same reason.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@bfoss765

Copy link
Copy Markdown
Contributor Author

Closed in favor of the in-repo recreation: #947 (same commits and authorship; no more personal-fork PRs).

@bfoss765 bfoss765 closed this Aug 10, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant