fix(dash-spv): recover masternode sync from a rejected QRInfo - #947
fix(dash-spv): recover masternode sync from a rejected QRInfo#947bfoss765 wants to merge 3 commits into
Conversation
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.
…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>
|
Warning Review limit reached
Next review available in: 7 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (3)
Comment |
Supersedes #936 — same change, recreated on a
dashpay/rust-dashcorebranch per repo policy (no more personal-fork PRs). Commits and authorship unchanged; full review history on #936.Summary
One rejected QRInfo response permanently strands masternode sync.
qrinfo_received()clearedqrinfo_in_flightbefore the falliblefeed_qr_info, so a validation failure returnedErrand left the manager inSyncingwithqrinfo_in_flight = Noneand an empty diff pipeline. TheQRINFO_TIMEOUT_SCHEDULE_SECS = [10, 30, 60]retry ladder is armed solely byqrinfo_in_flight, sotick()fell through forever. The error surfaced only as aSyncEvent::ManagerError, which has no consumer — the run loop logs it and continues.No watchdog existed. The comment at
masternodes/manager.rs:544already warned about stranding "Syncingwithqrinfo_in_flight = None, whichtickcannot recover", but only guarded the not-yet-Syncingentrance.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_hashreturnsNone), 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):then frozen for ~40 minutes until restart:
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:
Timeout waiting for QRInfoMasternode task exitingLaggedMainnet, Galaxy S22, 2026-08-09, restored wallet — reproduced twice in one day, in two separate processes:
Frozen 34+ minutes. DAPI logged
total: 0calls over 33.73 minutes andmnlist=0while 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: 1is the direct signature of the bug: the counter is bumped bysend_qrinfo_for_tip, so a stuck1means 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 beforequeue_requests, afterfeed_qrinfo_heights_to_engine,feed_qr_infoandbuild_mnlistdiff_request_pairshave all succeeded. It still has to run before thehas_pending_requests()check that follows, which reads it.On the
feed_qr_infoerror branch the slot stays armed and the attempt is flaggedrejected.ticktreats 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 throughsend_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_tipcontinues to be set only on success, so the retry's response is not dropped as a duplicate byshould_process_qrinfo.2. Stall watchdog in
tick(sync_manager.rs)Syncing+ no QRInfo in flight + empty diff pipeline for longer thanQRINFO_STALL_WATCHDOG(60s) re-dispatches a QRInfo. This is the backstop for the routes the in-flight flag cannot cover — notably asend_qrinfo_for_tipthat fails after its caller already cleared the slot, which is exactly whattick's own retry path does.Timed off a new
MasternodeSyncState::last_qrinfo_dispatch, stamped insidestart_waiting_for_qrinfoso it can never drift from the actual dispatch. Deliberately notprogress.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_disconnectrequeues instead of clearing (sync_manager.rs,manager.rs,pipeline.rs)clear_pending()→requeue_in_flight(), mirroringBlocksManager(sync/blocks/sync_manager.rs:61-63) andFiltersManager. In-flightGetMnListDiffs move back to the front of the pending queue with theirbase_hashesmapping intact; the QRInfo slot stays armed so the ladder re-dispatches it. The oldclear_pending()also discarded theqr_info_resultcarried onpipeline_mode, forcing a full QRInfo re-run after every disconnect. Theqrinfo_retry_countandlast_processed_qrinfo_tipresets 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:
ticknow 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 othersend_pendingcall site hangs off a response handler, which cannot run while nothing is outstanding. It is a no-op for every pre-existing path, sincequeue_requests,handle_timeoutsandrequeueare all already followed by asend_pending.Tests
Four new tests in
sync/masternodes/sync_manager.rs, on a realMasternodesManageroverDiskStorageManagerwith a boundRequestSenderreceiver:test_rejected_qrinfo_retries_against_another_peer_then_gives_up— drives a rejected QRInfo throughhandle_message+tickfor the whole budget. Asserts the slot is not released and is flaggedrejected, that the handler does not consume budget itself, that eachtickdispatches exactly one retryGetQRInfoand re-arms a clean attempt, and that the run terminates at exactlyMAX_RETRY_ATTEMPTSdispatches without parking the manager inSyncing. 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— assertstickactually puts a requeuedGetMnListDiffback on the wire.Out of scope
Noted while root-causing, intentionally not touched here, for a separate ticket:
sync/sync_manager.rs:281-285and:308-312treat a recoverableRecvError::Lagged(n)on the broadcast receivers as fatal andbreakout of the manager's run loop.Summary by CodeRabbit