Skip to content

fix(dash-spv): resume on the invariant start_download asserts - #955

Open
romchornyi wants to merge 2 commits into
devfrom
fix/filters-resume-guard-matches-idle
Open

fix(dash-spv): resume on the invariant start_download asserts#955
romchornyi wants to merge 2 commits into
devfrom
fix/filters-resume-guard-matches-idle

Conversation

@romchornyi

@romchornyi romchornyi commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

The bug

FiltersManager::start_sync decides whether in-flight work survived a disconnect by asking one question:

if !self.active_batches.is_empty() {
    // resume
}

start_download, which the other branch falls into, asserts a stricter one:

debug_assert!(self.is_idle(), "manager should have no in-flight state on start");

and is_idle() is four conditions — active_batches, tracker, pending_batches and filter_pipeline all empty. The guard checked one of them.

They come apart routinely:

  • on_disconnect deliberately preserves the first three (its own doc says so) and calls requeue_in_flight(), which moves the pipeline's in-flight slots to pending — making the pipeline non-idle rather than restoring it.
  • A batch that finished downloading leaves active_batches while its verified output waits in pending_batches and its blocks wait in the tracker.

Any of those outlasting the last active batch sends a reconnect through the guard and straight into the assert.

Why it matters

In a build with debug assertions on this aborts the host process. debug_assert! is not a no-op there, and the iOS profile pairs it with panic = "abort", so there is no unwinding either.

It has already happened in the field. A TestFlight build of the Dash iOS wallet (9.0.0/25, iPhone17,2, iOS 26.6) died with SIGABRT in exactly this frame:

Thread 9 Crashed:
  abort ← rust_panic ← core::panicking::panic_fmt
  ← FiltersManager::start_download::{{closure}}            (manager.rs:182)
  ← filters::sync_manager::…::start_sync                   (sync_manager.rs:89)
  ← SyncManager::handle_network_event                      (sync_manager.rs:193)
  ← SyncManager::run                                       (sync_manager.rs:293)

The app had been running 33 minutes with Role: Non UI — backgrounded, where iOS tearing down and restoring networking makes the disconnect/reconnect cycle routine rather than exotic.

The fix

The guard now asks is_idle() — the same predicate the assert states. is_idle becomes pub(super) so the decision and the assertion cannot drift apart again.

Resuming is safe for all four cases, not just the one the guard covered: the resume path sets Syncing, and tick in that state runs send_pending, store_and_match_batches and try_process_batch unconditionally. send_pending returns Ok(0) when the pipeline has nothing queued, so a manager whose only surviving state is a pending batch or a tracked block still gets drained by the ticker instead of being wedged.

Deliberately not reset_for_rescan() before start_download: that would also clear the state on_disconnect documents itself as preserving, throwing away verified batches and forcing a re-download of work already done.

Test

test_start_sync_resumes_when_only_pending_batches_survive puts a verified batch in pending_batches with active_batches empty, asserts the two predicates disagree at that moment, and drives start_sync.

It fails without the guard change, panicking at the assert — the production crash reproduced as a unit test.

cargo test -p dash-spv --lib                 # 538 passed
cargo clippy -p dash-spv --all-targets       # clean
cargo fmt --check                            # clean

Note for reviewers

tick has the same narrow predicate in let has_pending_work = !self.active_batches.is_empty();, used to decide whether to tick while Synced. It cannot abort — the worst case is that a surviving pending batch waits for a state change instead of being drained — so I left it alone rather than widen the diff. Worth a look by someone who knows whether that path can strand work.

Also: #921 and #902 both change start_download's signature (requestsnetwork). Neither touches this guard, but whichever lands first will make the other a trivial conflict here.

Summary by CodeRabbit

  • Bug Fixes
    • Improved blockchain header synchronization so buffered headers are processed and stored during scheduled updates.
    • Synchronization can now complete without requiring an additional headers message.
    • Improved recovery after disconnections by resuming pending filter downloads instead of starting duplicate downloads.
    • Preserved pending verified filter batches and requeued requests during synchronization recovery.
  • Tests
    • Added regression coverage for buffered header processing and resumed filter synchronization.

`FiltersManager::start_sync` decided whether in-flight work survived a
disconnect by asking `!self.active_batches.is_empty()`. `start_download`,
which the other branch falls into, asserts something stricter:

    debug_assert!(self.is_idle(), "manager should have no in-flight state on start");

and `is_idle()` is four conditions — `active_batches`, `tracker`,
`pending_batches` and `filter_pipeline` all empty. The guard checked one
of them.

They come apart routinely. `on_disconnect` deliberately preserves the
first three and calls `requeue_in_flight()`, which moves the pipeline's
in-flight slots to *pending* — making the pipeline non-idle rather than
restoring it. And a batch that finished downloading leaves
`active_batches` while its verified output waits in `pending_batches` and
its blocks wait in the tracker. Any of those outlasting the last active
batch sent a reconnect through the guard and into the assert.

In a build with debug assertions on — which `dev-ios` and every other
`dev`-profile build is — that aborts the host process. It has: a
TestFlight build of the Dash iOS wallet (9.0.0/25, iOS 26.6) died with
SIGABRT in exactly this frame after 33 minutes in the background, where
network teardown and restore make the disconnect/reconnect cycle routine.

The guard now asks `is_idle()`. Resuming is safe for all four cases: the
path sets `Syncing`, and `tick` in that state runs `send_pending`,
`store_and_match_batches` and `try_process_batch` unconditionally, with
`send_pending` a no-op when the pipeline has nothing queued.

`is_idle` becomes `pub(super)` so both the decision and the assertion
read the same predicate.

The regression test fails without the guard change, panicking at the
assert — the production crash reproduced in a unit test.

cargo test -p dash-spv --lib   # 538 passed
cargo clippy --all-targets + cargo fmt --check   # clean
@coderabbitai

coderabbitai Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Header synchronization now drains buffered segments and completes during ticks. Filter synchronization now resumes any preserved in-flight work after reconnects, including pending verified batches, without starting duplicate downloads.

Changes

Synchronization pipeline updates

Layer / File(s) Summary
Header tick completion
dash-spv/src/sync/block_headers/manager.rs, dash-spv/src/sync/block_headers/sync_manager.rs
Ready header segments are drained, completion is finalized separately, and tick returns storage or completion events. A regression test covers buffered headers without a later headers message.
Filter synchronization resume handling
dash-spv/src/sync/filters/manager.rs, dash-spv/src/sync/filters/sync_manager.rs
start_sync uses is_idle() to resume preserved requests, batches, and tracked blocks. The reconnect test verifies that pending batches remain intact and no duplicate SyncStart occurs.

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

Sequence Diagram(s)

sequenceDiagram
  participant SyncManager
  participant BlockHeadersManager
  participant RequestSender
  participant Storage
  SyncManager->>BlockHeadersManager: tick drains ready segments
  BlockHeadersManager->>Storage: store promoted headers
  BlockHeadersManager-->>SyncManager: return storage events
  SyncManager->>BlockHeadersManager: finalize synchronization
  BlockHeadersManager->>RequestSender: re-request tip segment if announcements remain
  BlockHeadersManager-->>SyncManager: return completion event
Loading
sequenceDiagram
  participant SyncManager
  participant FiltersManager
  participant RequestSender
  SyncManager->>FiltersManager: start_sync checks is_idle
  FiltersManager-->>SyncManager: preserved work remains active
  SyncManager->>RequestSender: send pending requests
  SyncManager-->>FiltersManager: enter Syncing without fresh initialization
Loading

Possibly related PRs

Suggested labels: ready-for-review

Suggested reviewers: xdustinface, zocolini

🚥 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 identifies the dash-spv resume fix and the start_download invariant addressed by the primary change.
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 docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/filters-resume-guard-matches-idle

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

@codecov

codecov Bot commented Aug 11, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 93.24324% with 5 lines in your changes missing coverage. Please review.
✅ Project coverage is 76.05%. Comparing base (37b1a36) to head (a20f371).
⚠️ Report is 1 commits behind head on dev.

Files with missing lines Patch % Lines
dash-spv/src/sync/block_headers/manager.rs 91.07% 5 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##              dev     #955      +/-   ##
==========================================
+ Coverage   75.62%   76.05%   +0.43%     
==========================================
  Files         329      329              
  Lines       79258    79312      +54     
==========================================
+ Hits        59939    60322     +383     
+ Misses      19319    18990     -329     
Flag Coverage Δ
core 77.29% <ø> (ø)
ffi 52.19% <ø> (+3.15%) ⬆️
rpc 20.00% <ø> (ø)
spv 91.65% <93.24%> (+0.02%) ⬆️
wallet 77.57% <ø> (ø)
Files with missing lines Coverage Δ
dash-spv/src/sync/block_headers/sync_manager.rs 86.66% <ø> (ø)
dash-spv/src/sync/filters/manager.rs 97.96% <100.00%> (-0.08%) ⬇️
dash-spv/src/sync/filters/sync_manager.rs 100.00% <ø> (ø)
dash-spv/src/sync/block_headers/manager.rs 93.02% <91.07%> (+2.25%) ⬆️

... and 19 files with indirect coverage changes

… on a message

A wallet restore froze with the whole testnet chain downloaded and none
of the tail of it stored:

    Headers: Syncing 2520289/2520288 (100.0%) processed: 1274000, buffered: 1046289
    Filter Headers: Syncing 1474000/2520288 (58.5%)
    Blocks:  WaitForEvents last_relevant: 1472978

Peers stayed connected and chain locks kept arriving for the sixteen
minutes the app was left running after that. Nothing advanced again.

`processed`/`buffered` decode as: 1,474,000 headers in storage, and
1,046,289 more downloaded, validated and sitting in memory. The
top-line percentage reads 100% because `current_height()` is
`tip + buffered` — it counts what was downloaded, not what was kept.

The only thing that promotes a finished segment into storage is
`take_ready_to_store`, and its single production caller was
`handle_headers_pipeline` — reached only when a `Headers` message
arrives. All 47 checkpoint segments had finished downloading by
22:23:12 (the last being segment 25), so no further `Headers` would
ever arrive, and the promotion had nothing left to trigger it. Filter
headers, filters and blocks then coasted to a stop over the following
four minutes as they consumed the backlog they had been racing ahead
on, which is what made the stall visible at 22:27:20.

`tick` runs every 100ms and called only `handle_timeouts` and
`send_pending`. It now also drains and, if that was the last of the
work, finalizes — so a promotion that gets missed self-heals on the next
tick rather than wedging the client until it is restarted.

`drain_ready_segments` and `finalize_sync_if_complete` are lifted
verbatim out of `handle_headers_pipeline`, which now calls both. No
behaviour change on the message-driven path.

What is NOT established: why the first drain opportunity — the message
that completed segment 25 — did not promote anything. No error is logged
anywhere near it and no early return in the current code fits the
evidence. This makes the pipeline able to recover from that miss; it
does not explain the miss. Worth a `RUST_LOG=dash_spv::sync::block_headers=trace`
reproduction.

The regression test fails without the change, at the assertion that the
tip advanced.

cargo test -p dash-spv --lib   # 539 passed
cargo clippy --all-targets + cargo fmt --check   # clean
@romchornyi

Copy link
Copy Markdown
Contributor Author

Pushed a second fix onto this branch (a20f371). Same class of bug — a sync pipeline with no path to recover in-flight state — but in the header phase rather than filters, and found from a field stall rather than a crash.

The stall

A testnet wallet restore froze with the whole chain downloaded and none of the tail of it stored:

Headers:        Syncing 2520289/2520288 (100.0%) processed: 1274000, buffered: 1046289
Filter Headers: Syncing 1474000/2520288 (58.5%)
Filters:        Syncing 1474000/2520288 (58.5%)
Blocks:         WaitForEvents last_relevant: 1472978

Peers stayed connected and ChainLockReceived kept arriving for the sixteen minutes the app was left running afterwards. PeersUpdated never reported connected=0, so this is not a disconnect path — and not the one the first commit on this branch fixes.

processed/buffered decode as 1,474,000 headers in storage and 1,046,289 more downloaded, validated and held in memory. The top line reads 100% because current_height() is tip + buffered: it counts what was downloaded, not what was kept.

Why it cannot recover

take_ready_to_store is the only thing that promotes a finished segment into storage, and its single production caller was handle_headers_pipeline — reached only when a Headers message arrives.

All 47 checkpoint segments finished downloading by 22:23:12 (segment 25 last). From that instant no further Headers would ever arrive, so the promotion had nothing left to trigger it. tick, which runs every 100ms, called only handle_timeouts and send_pending.

Filter headers, filters and blocks then coasted to a stop over the next four minutes as they consumed the backlog they had been racing ahead on — which is why the symptom looks like it starts at 22:27:20 rather than 22:23:12.

The change

tick now also drains, and finalizes if that was the last of the work. drain_ready_segments and finalize_sync_if_complete are lifted verbatim out of handle_headers_pipeline, which calls both — no behaviour change on the message-driven path.

What this does not explain

Why the first drain opportunity — the message that completed segment 25 — promoted nothing. No error is logged anywhere near it, and no early return in the current code fits the evidence. This makes the pipeline able to recover from that miss; it does not explain the miss. A RUST_LOG=dash_spv::sync::block_headers=trace reproduction would settle it, and I would rather ship the self-healing path than block on finding the trigger, since the same missed promotion is unrecoverable today whatever causes it.

Test

test_tick_promotes_buffered_headers_with_no_further_messages — headers land in the pipeline, no further message arrives, the tick must promote them. It fails without the change, at the assertion that the tip advanced.

cargo test -p dash-spv --lib                 # 539 passed
cargo clippy -p dash-spv --all-targets       # clean
cargo fmt --check                            # clean

Note for reviewers

Two subsystems in one PR is not ideal and I would normally split them. They are here because they are the same defect shape — state that only a network event can advance, with no periodic path to retry it — and because the second was found while validating the first in a real restore. Happy to split if you would rather review them separately.

Also worth someone's eye, found while tracing this and not addressed here: headers2_state is a single CompressionState shared across all peer connections (network/manager.rs:512) rather than one per peer, and the run logged 36 × Received 8000 headers with prev_hash … but no segment matched. The segment-25 batch passed its checkpoint hash check so it was genuine data, but interleaved Headers2 streams from multiple peers look like a real decompression hazard. #950 touches this area.

@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: 2

🤖 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/block_headers/manager.rs`:
- Around line 376-385: Extend the test around manager.tick to assert that the
manager reaches SyncState::Synced and that the returned events include
BlockHeaderSyncComplete, in addition to the existing BlockHeadersStored
assertion. Use the existing manager state accessor and SyncEvent variants,
preserving the current promotion checks.

In `@dash-spv/src/sync/filters/sync_manager.rs`:
- Around line 62-77: Update the reconnect guard in handle_new_filter_headers to
use the complete !self.is_idle() predicate instead of checking only
active_batches before calling start_download. Add a regression test covering
WaitForEvents with only a pending batch, verifying the route does not invoke
start_download and avoids the idle assertion.
🪄 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: 2b845b2e-8fbb-49e9-9d19-268d9c4ad7ca

📥 Commits

Reviewing files that changed from the base of the PR and between 37b1a36 and a20f371.

📒 Files selected for processing (4)
  • dash-spv/src/sync/block_headers/manager.rs
  • dash-spv/src/sync/block_headers/sync_manager.rs
  • dash-spv/src/sync/filters/manager.rs
  • dash-spv/src/sync/filters/sync_manager.rs

Comment on lines +376 to +385
let events = manager.tick(&sender).await.unwrap();

assert!(
manager.tip().await.unwrap().height() > start_height,
"tick must promote buffered headers into storage"
);
assert!(
events.iter().any(|e| matches!(e, SyncEvent::BlockHeadersStored { .. })),
"the promotion must be reported, not done silently"
);

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 | 🟡 Minor | ⚡ Quick win

Assert tick-driven synchronization completion.

Line 376 invokes tick, which now calls finalize_sync_if_complete. The test only verifies BlockHeadersStored. Assert SyncState::Synced and BlockHeaderSyncComplete so a regression that stores the last segment but leaves the manager in Syncing fails.

As per coding guidelines, "Write unit tests for new functionality".

Proposed test additions
         assert!(
             events.iter().any(|e| matches!(e, SyncEvent::BlockHeadersStored { .. })),
             "the promotion must be reported, not done silently"
         );
+        assert_eq!(manager.state(), SyncState::Synced);
+        assert!(
+            events
+                .iter()
+                .any(|e| matches!(e, SyncEvent::BlockHeaderSyncComplete { .. })),
+            "tick must report completion after storing the final segment"
+        );
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
let events = manager.tick(&sender).await.unwrap();
assert!(
manager.tip().await.unwrap().height() > start_height,
"tick must promote buffered headers into storage"
);
assert!(
events.iter().any(|e| matches!(e, SyncEvent::BlockHeadersStored { .. })),
"the promotion must be reported, not done silently"
);
let events = manager.tick(&sender).await.unwrap();
assert!(
manager.tip().await.unwrap().height() > start_height,
"tick must promote buffered headers into storage"
);
assert!(
events.iter().any(|e| matches!(e, SyncEvent::BlockHeadersStored { .. })),
"the promotion must be reported, not done silently"
);
assert_eq!(manager.state(), SyncState::Synced);
assert!(
events
.iter()
.any(|e| matches!(e, SyncEvent::BlockHeaderSyncComplete { .. })),
"tick must report completion after storing the final segment"
);
🤖 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/block_headers/manager.rs` around lines 376 - 385, Extend
the test around manager.tick to assert that the manager reaches
SyncState::Synced and that the returned events include BlockHeaderSyncComplete,
in addition to the existing BlockHeadersStored assertion. Use the existing
manager state accessor and SyncEvent variants, preserving the current promotion
checks.

Source: Coding guidelines

Comment on lines +62 to +77
//
// The condition is `is_idle()` — the whole invariant `start_download`
// asserts — and not `active_batches` alone. They are not the same
// question: `on_disconnect` calls `requeue_in_flight`, which moves the
// pipeline's in-flight slots to *pending*, and a batch that finished
// downloading leaves `active_batches` while its verified output waits
// in `pending_batches` and its blocks wait in the tracker. Any of those
// three outlasting the last active batch used to fall through to
// `start_download` and trip its `debug_assert!(is_idle())` — an abort
// in any build with debug assertions on.
//
// Resuming is safe for all of them: this path sets `Syncing`, and
// `tick` in that state runs `send_pending`, `store_and_match_batches`
// and `try_process_batch` unconditionally. `send_pending` is a no-op
// when the pipeline has nothing queued.
if !self.is_idle() {

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.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Use the complete idle predicate in the other reconnect route.

dash-spv/src/sync/filters/manager.rs Lines 1078-1087 still tests only active_batches. If pending_batches, tracker state, or filter-pipeline state survives without an active batch, that route calls start_download. start_download then fails debug_assert!(is_idle()).

Replace that guard with !self.is_idle(). Add a regression test that calls handle_new_filter_headers from WaitForEvents with only a pending batch.

Proposed fix
-                if !self.active_batches.is_empty() {
+                if !self.is_idle() {
                     self.filter_pipeline.extend_target(tip_height);
                     return Ok(vec![]);
                 }

As per coding guidelines, “Write unit tests for new functionality.”

🤖 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/filters/sync_manager.rs` around lines 62 - 77, Update the
reconnect guard in handle_new_filter_headers to use the complete !self.is_idle()
predicate instead of checking only active_batches before calling start_download.
Add a regression test covering WaitForEvents with only a pending batch,
verifying the route does not invoke start_download and avoids the idle
assertion.

Source: Coding guidelines

@github-actions

Copy link
Copy Markdown
Contributor

This PR has merge conflicts with the base branch. Please rebase or merge the base branch into your branch to resolve them.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

merge-conflict The PR conflicts with the target branch.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants