Skip to content

fix(dash-spv): promote finished header segments from the tick, not only on a message - #960

Open
romchornyi wants to merge 1 commit into
devfrom
fix/header-drain-on-tick
Open

fix(dash-spv): promote finished header segments from the tick, not only on a message#960
romchornyi wants to merge 1 commit into
devfrom
fix/header-drain-on-tick

Conversation

@romchornyi

@romchornyi romchornyi commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

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 — this is not a disconnect path.

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 caller is 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 takes, refills and stores in the same order handle_headers_pipeline uses, then finalizes if that was the last of the work.

The ordering is deliberate and follows #950: draining can expose a segment at the end of the active window, and the refill should pick it up in the same pass. That is also why store_ready_batches takes the batches rather than draining them itself — moving take_ready_to_store inside the helper would have forced the refill after the storage writes on both paths.

store_ready_batches and finalize_sync_if_complete are lifted out of handle_headers_pipeline, which still calls both in place. No behaviour change on the message-driven path.

Relationship to #950

ACTIVE_SEGMENT_WINDOW narrows the blast radius — at most eight segments' worth of headers strand instead of a million — but adds no trigger for promotion, so the stall survives it. I rebased onto dev after #950 landed and confirmed the gap is still there: tick's Syncing arm is byte-for-byte handle_timeouts + send_pending + return Ok(vec![]).

What this does not explain

Why the first promotion opportunity — the message that completed segment 25 — stored nothing. No error is logged anywhere near it, and no early return in the current code fits the evidence. This lets the pipeline 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 the trigger, since the same missed promotion is unrecoverable today whatever causes it.

Test

test_tick_promotes_buffered_headers_with_no_further_messages covers both halves: a tick promotes buffered headers when no further message will arrive, and a tick over a complete pipeline announces the sync rather than leaving the manager in Syncing. It fails without the change, at the promotion assertion.

cargo test -p dash-spv --lib                          # 547 passed
cargo test -p dash-spv --test header_dispatch_order   # passed (the suite #950 added)
cargo clippy -p dash-spv --all-targets                # clean
cargo fmt --check                                     # clean

Relationship to #955

Independent — different subsystem, different files, no shared commits, both branch from dev. #955 fixes an assert reachable through the filters manager; this fixes a header-pipeline stall. They were briefly one PR and were split so neither waits on the other.

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.

Summary by CodeRabbit

  • Bug Fixes
    • Improved block-header synchronization completion when no additional header message arrives.
    • Buffered header batches are now advanced during periodic processing.
    • Sync completion events are emitted reliably after all available headers are stored.
    • Added regression coverage for storage advancement and completion during periodic processing.

…ly on a message

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%)
    Blocks:  WaitForEvents last_relevant: 1472978

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

`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.

`take_ready_to_store` is the only thing that promotes a finished segment
into storage, and its single caller was `handle_headers_pipeline` —
reached only when a `Headers` message arrives. All 47 checkpoint
segments had finished downloading by 22:23:12, so no further `Headers`
would ever come and the promotion had nothing left to trigger it. 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 what made the stall visible at 22:27:20.

`tick` runs every 100ms and called only `handle_timeouts` and
`send_pending`. It now takes, refills and stores in the same order
`handle_headers_pipeline` uses — #950 established that ordering so a
segment exposed by draining gets requested in the same pass — and
finalizes if that was the last of the work.

`store_ready_batches` and `finalize_sync_if_complete` are lifted out of
`handle_headers_pipeline`, which still calls both in place. The drain
itself stays at the call sites rather than moving into the helper,
precisely so the take → refill → store order is preserved on both paths.
No behaviour change on the message-driven path.

**Not established:** why the first promotion opportunity — the message
that completed segment 25 — stored nothing. No error is logged anywhere
near it and no early return in the current code fits the evidence. This
lets the pipeline recover from that miss; it does not explain the miss.
Worth a `RUST_LOG=dash_spv::sync::block_headers=trace` reproduction.

#950's `ACTIVE_SEGMENT_WINDOW` narrows the blast radius — at most eight
segments' worth of headers strand instead of a million — but adds no
trigger for promotion, so the stall survives it.

The regression test covers both halves: a tick promotes buffered headers
with no further message, and a tick over a complete pipeline announces
the sync. It fails without the change at the promotion assertion.

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

coderabbitai Bot commented Aug 13, 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: cb757672-c1c7-4b6f-a115-09bbeaa889aa

📥 Commits

Reviewing files that changed from the base of the PR and between 173ffac and d8012b7.

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

📝 Walkthrough

Walkthrough

The header sync manager now stores buffered header batches and finalizes completed initial syncs during tick. The change emits storage and completion events without requiring another headers message.

Changes

Header sync completion

Layer / File(s) Summary
Pipeline storage and finalization
dash-spv/src/sync/block_headers/manager.rs
handle_headers_pipeline delegates batch storage and sync finalization to new helpers. The helpers store ready batches, request announced headers, and emit BlockHeaderSyncComplete when the pipeline completes.
Tick integration and regression coverage
dash-spv/src/sync/block_headers/sync_manager.rs, dash-spv/src/sync/block_headers/manager.rs
tick stores ready batches and returns generated events. Regression coverage verifies storage advancement and completion through tick.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Mergeability Score: ⚪ Minimal · up to d8012

This localized change promotes completed header work during periodic processing and includes focused and broader validation; no actionable merge-blocking risk remains beyond normal checks and review.

Sequence Diagram(s)

sequenceDiagram
  participant SyncManagerTick
  participant BlockHeadersManager
  participant RequestSender
  SyncManagerTick->>BlockHeadersManager: drain and store ready batches
  SyncManagerTick->>RequestSender: send pending header requests
  SyncManagerTick->>BlockHeadersManager: finalize sync if complete
  BlockHeadersManager-->>SyncManagerTick: storage and completion events
Loading

Possibly related PRs

Suggested labels: ready-for-review

Suggested reviewers: xdustinface, pastapastapasta

🚥 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 fix: promoting completed header segments during tick processing.
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/header-drain-on-tick

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

@codecov

codecov Bot commented Aug 13, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 92.75362% with 5 lines in your changes missing coverage. Please review.
✅ Project coverage is 76.48%. Comparing base (173ffac) to head (d8012b7).

Files with missing lines Patch % Lines
dash-spv/src/sync/block_headers/manager.rs 92.75% 5 Missing ⚠️
Additional details and impacted files
@@           Coverage Diff           @@
##              dev     #960   +/-   ##
=======================================
  Coverage   76.47%   76.48%           
=======================================
  Files         329      329           
  Lines       80353    80403   +50     
=======================================
+ Hits        61453    61495   +42     
- Misses      18900    18908    +8     
Flag Coverage Δ
core 78.25% <ø> (ø)
ffi 52.18% <ø> (-0.01%) ⬇️
rpc 20.00% <ø> (ø)
spv 91.84% <92.75%> (-0.02%) ⬇️
wallet 77.57% <ø> (ø)
Files with missing lines Coverage Δ
dash-spv/src/sync/block_headers/sync_manager.rs 87.50% <ø> (ø)
dash-spv/src/sync/block_headers/manager.rs 93.07% <92.75%> (+0.12%) ⬆️

... and 5 files with indirect coverage changes

@github-actions github-actions Bot added the ready-for-review CodeRabbit has approved this PR label Aug 13, 2026

@ZocoLini ZocoLini left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Are you able to create an integration test that recreates the same issue??

@romchornyi

Copy link
Copy Markdown
Contributor Author

I tried, and I want to give you a straight answer rather than a hopeful one: not one that reproduces the original stall, no — and the reason is worth stating because it also bounds what this PR claims.

What blocks a tests/-level test mechanically

I drafted one and hit the crate boundary. BlockHeadersManager is exported, but everything the test needs around it is not: tip() is private, DiskStorageManager::with_temp_dir is #[cfg(test)], testnet_checkpoints is not re-exported, and SyncEvent is not in dash_spv::types. That is all solvable — widen the existing test-utils feature to cover the storage helper and add a couple of accessors — but it means growing the public surface for a test, inside a bugfix PR. I did not want to do that unilaterally. If you would rather have the tests/ version and are fine with that trade, say so and I will do it.

What blocks it in principle, which matters more

I do not know what skipped the first promotion. The PR says so, and it is not modesty — I chased it twice today.

The promotion is due on the same pass that receives a Headers message, so the natural way for one to go missing is an early return before it. There is exactly one: let matched = self.pipeline.receive_headers(headers)?. That looked promising, since the failing run logged 36 × "no segment matched" alongside segment timeouts with several peers connected — a duplicate or stale response from a second peer is exactly the shape that would hit it.

So I wrote a test for it. It fails: a duplicate is answered Ok(None) with a warning, not Err, so the promotion still runs. The hypothesis is wrong and I deleted the test rather than keep one that proves something other than what its name claims.

Which leaves: I can reproduce the state (segments downloaded, promotion outstanding, no further message coming) but not the transition into it. Any test I write — unit or integration — has to put the pipeline in that state by hand. Dressing that up as an integration test would make it look like a reproduction without being one.

What the current test does establish

That the tick is a sufficient recovery path: it promotes what is buffered, and when that was the last of it, finishes the sync. It runs against real on-disk storage (create_test_manager uses a temp-dir DiskStorageManager), and it fails without the change — at the promotion assertion, not on an abort.

That is the honest scope of this PR: it makes a missed promotion recoverable, it does not explain the miss. I would rather ship that than leave a client that wedges permanently while the root cause is still open, since today the same miss is unrecoverable whatever causes it.

What would actually reproduce it

A RUST_LOG=dash_spv::sync::block_headers=trace run against testnet with headers2 peers, which would show whether handle_headers_pipeline returns early for the segment-completing message and why. I have the wallet and the network to do that — it needs a stall to recur, which is a matter of waiting rather than of writing code. Happy to hold the PR for it if you would prefer the cause before the mitigation; my preference is to land the recovery path first, precisely because a stall today is silent and permanent.

@ZocoLini

Copy link
Copy Markdown
Collaborator

@xdustinface can you take a look into this pls??

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

Labels

ready-for-review CodeRabbit has approved this PR

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants