Skip to content

netsync: recover after losing sync peer near tip - #2607

Open
Roasbeef wants to merge 2 commits into
masterfrom
fix/netsync-sync-peer-stall-2606
Open

netsync: recover after losing sync peer near tip#2607
Roasbeef wants to merge 2 commits into
masterfrom
fix/netsync-sync-peer-stall-2606

Conversation

@Roasbeef

Copy link
Copy Markdown
Member

In this PR, we clear ibdMode when updateSyncPeer removes the active sync peer. Near the chain tip, startSync can find no strictly higher replacement and return with syncPeer nil. Leaving IBD enabled in that state causes handleInvMsg to discard every new block announcement, so the node remains stalled until a newly connected peer advertises a higher height.

Resetting both pieces of sync state together lets the remaining peers announce and request blocks normally, while a replacement peer still re-enters IBD through startSync. The regression test covers the near-tip disconnect and verifies that the next block announcement is requested.

Fixes #2606

Tests: go test ./...

The repository-wide make lint target still reports 13 existing findings outside this diff, including pre-existing unchecked calls in netsync.

In this commit, we clear ibdMode when updateSyncPeer removes the active sync peer. Near the chain tip, startSync can find no strictly higher replacement and return with syncPeer nil. Leaving IBD enabled in that state causes handleInvMsg to discard every new block announcement, so the node cannot recover until a new higher peer connects.

The regression test covers the near-tip disconnect and verifies that a remaining peer announcement is requested after the sync state is reset.
@coveralls

coveralls commented Sep 11, 2026

Copy link
Copy Markdown

Coverage Report for CI Build 34637539728

Coverage increased (+0.1%) to 54.16%

Details

  • Coverage increased (+0.1%) from the base build.
  • Patch coverage: 4 of 4 lines across 1 file are fully covered (100%).
  • 771 coverage regressions across 8 files.

Uncovered Changes

No uncovered changes found.

Coverage Regressions

771 previously-covered lines in 8 files lost coverage.

File Lines Losing Coverage Coverage
rpcserver.go 680 6.51%
txscript/standard.go 27 79.0%
rpcclient/cookiefile.go 22 0.0%
btcec/v2/ellswift/ellswift.go 15 79.5%
psbt/finalizer.go 13 75.43%
psbt/utils.go 8 85.19%
database/ffldb/blockio.go 4 88.81%
netsync/manager.go 2 45.86%

Coverage Stats

Coverage Status
Relevant Lines: 70297
Covered Lines: 38073
Line Coverage: 54.16%
Coverage Strength: 369139.87 hits per line

💛 - Coveralls

@saubyk
saubyk requested a review from kcalvinalvin September 11, 2026 18:54
@saubyk

saubyk commented Sep 11, 2026

Copy link
Copy Markdown
Collaborator

cc: @kcalvinalvin for review

@Roasbeef

Copy link
Copy Markdown
Member Author

Two property-based tests added on top of the regression test (rapid is already a go.mod dependency, used in internal/inbound):

TestPropertyLostSyncPeerRecovers generalizes the near-tip regression across arbitrary chain heights and remaining-peer height combinations. For any generated scenario (chain height 1–31, 1–4 remaining peers at tip or tip+1), losing the sync peer with IBD enabled must end in one of exactly two live states: a strictly higher peer was promoted and the header-first path recovers (verified end-to-end: header delivered, block requested), or the sync state was fully reset and a new-block announcement from any remaining peer is requested. The dead state (syncPeer=nil, ibdMode=true) — the state in which handleInvMsg line ~1165 drops every announcement — is asserted unreachable.

TestPropertySyncStateTransition is a stateful property over the whole sync state machine: a generated schedule of events (peer connects at arbitrary heights, header/block delivery from the sync peer, stall detection, sync-peer loss) is replayed against the manager with the same invariant asserted after every event.

One supporting change was needed and is worth flagging for reviewers: generateTestBlocks previously anchored timestamps to the regtest genesis (2011), so a generated chain was never IsCurrent() and the near-tip code paths the bug lives in were unreachable at generated heights. Timestamps now start from the current time, which keeps those paths reachable — this is what lets the properties actually reproduce the bug: both fail on the parent commit and pass with the fix (verified by reverting manager.go locally and re-running).

With the fix reverted, the properties fail with exactly the dead-state message; with the fix, 100 generated cases pass per property (-count=3 also clean, full go test ./... green).

On issue coverage: the issue describes a node 1–2 blocks behind tip whose sync peer departs while no other peer is strictly higher — that exact scenario is now generated by both properties. The one behavioral gap the issue mentions that this PR does not address is the "at minimum it should log that it is stuck" ask — the fix removes the stuck state rather than logging it, which resolves the incident class outright.

@Roasbeef
Roasbeef force-pushed the fix/netsync-sync-peer-stall-2606 branch 3 times, most recently from 3d7b5a0 to 1ecc502 Compare September 11, 2026 19:11
In this commit, we add two rapid-based property tests that generalize the regression test for the lost-sync-peer stall.

TestPropertyLostSyncPeerRecovers covers the near-tip disconnect across arbitrary chain heights and remaining-peer height combinations: for any generated scenario, losing the sync peer with IBD enabled must either promote a strictly higher replacement peer (re-entering IBD through the header-first path, which the test drives to completion) or fully reset the sync state so that a new-block announcement from any remaining peer is requested.

TestPropertySyncStateTransition is a stateful property over the whole header-first sync state machine. A generated schedule of events (new peers at arbitrary heights, header and block delivery from the sync peer, stalls, and peer loss) is replayed against the manager, asserting after every event that the dead state (nil sync peer with IBD enabled) in which handleInvMsg discards announcements never holds.

generateTestBlocks now anchors timestamps to the current time rather than the 2011 regtest genesis timestamp. A chain built from the genesis timestamp is never IsCurrent, which keeps the near-tip paths where the stall occurs unreachable; starting from now keeps them reachable at any generated height, which is what lets the properties reproduce the bug. Both properties fail on the parent commit and pass with the fix.

Tests: go test ./netsync/ ./blockchain/ ./peer/ ./mempool/; go test ./...
@Roasbeef
Roasbeef force-pushed the fix/netsync-sync-peer-stall-2606 branch from 1ecc502 to 8ad3174 Compare September 11, 2026 19:12

@saubyk saubyk 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.

Thanks for the quick turnaround on #2606. I had the change checked out and exercised locally: build, vet, and the netsync tests (including the new ones, with -race) all pass on this head.

From a product/risk angle the fix does what the issue asks, but it also opens a couple of side doors that I'd like a second look at before this ships. Details are inline; two more that don't sit on changed lines:

  • handleInvMsg (~line 1165) still trusts the ibdMode flag alone. This PR fixes the flag at one write site. If any future code path clears the sync peer without clearing the flag, we're back to #2606 with no test catching it. handleHeadersMsg already double-checks syncPeer != nil locally. Is there a reason not to do the same here? It should be behavior-neutral today.
  • Full UTXO cache flush when the sync peer is lost mid-IBD (~line 834). With ibdMode now cleared even when the node is far from the tip, the first block from a remaining peer can hit the periodic-flush branch and write the whole cache to disk, which IBD then refills. One-time cost, not a correctness issue, but it's a new user-visible slowdown that didn't exist before. Gating that flush on chain currency instead of the flag would avoid it.

Small one: the doc comment on updateSyncPeer still mentions resetting header prefetch state, which was removed a while back, and doesn't mention that the function now leaves IBD mode. Worth folding the inline note into the doc comment.

Comment thread netsync/manager.go
// IBD requires an active sync peer. Clear it with the peer so a lost
// sync peer cannot leave the manager in a state where announcements are
// ignored indefinitely.
sm.ibdMode = false

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.

This clears IBD mode unconditionally, including when the node is far from the tip and no higher-height peer is available to take over. In that window syncPeer is nil and ibdMode is false, so a headers message from any peer now falls through to fetchHeaderBlocks (~line 1014) with no check that the sender is the sync peer or even a sync candidate.

As I understand it, that can reserve up to 50k block hashes in requestedBlocks from a peer that never serves them, and nothing clears those entries while there's no sync peer. A later honest sync peer then skips every reserved hash and stalls out. Pre-PR the ibdMode guard in handleHeadersMsg blocked this. Can you confirm whether that's reachable, and if so gate the fall-through on the peer being the sync peer or a sync candidate?

Comment thread netsync/manager_test.go
blockHash := block2.Hash()
inv := wire.NewMsgInvSizeHint(1)
err = inv.AddInvVect(wire.NewInvVect(
wire.InvTypeWitnessBlock, blockHash,

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.

The announcement here uses InvTypeWitnessBlock, but real peers announce with InvTypeBlock. When I swapped this to InvTypeBlock the requestedBlocks assertion fails even with the fix present, because the mock peer from newSyncCandidate isn't witness-enabled and handleInvMsg drops the inv. So this test currently proves recovery through the direct ibdMode field check, not through the announcement path users actually hit.

The two property tests below (lines 1366 and 1594) announce the same way. Could the test peer get the witness service flag so the tests can use InvTypeBlock? Ideally the announced block also goes through handleBlockMsg so we see it accepted end to end.

Comment thread netsync/manager_test.go
"ibdMode should not be activated when chain is already current")
}

// TestLostSyncPeerNearTip verifies that losing the sync peer near the tip

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.

Coverage gap worth flagging against the PR description ("remaining peers announce and request blocks normally"): all three new tests start with headers and blocks at the same height. The state from the issue report is headers ahead of blocks. In that state, if the sync peer is lost and no higher peer exists, the already-validated headers are never re-requested and recovery waits for the next block to be mined. That's pre-existing rather than introduced here, but the description reads as if it's covered. Could we either add a case that processes a header ahead of the tip before the disconnect, or narrow the description to what's actually fixed?

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.

[bug]: node stops fetching blocks after losing its sync peer near tip; recovers only when a new peer connects

3 participants