#11172·lnd

[bug]: historical sync stalls after reply_channel_range exceeds 100k SCID cap

Author: bufo24Created Sep 4, 2026Updated Sep 9, 2026
Labelsbugneeds triage

Pre-Submission Checklist

  • I have searched the existing issues and believe this is a new bug.
  • I am not asking a question about how to use lnd, but reporting a bug (otherwise open a discussion).

LND Version

v0.21.3

LND Configuration

lnd.conf: lnd.backend=neutrino

Backend Version

Bitcoin core v31.1

Backend Configuration

OS/Distribution

Bug Details & Steps to Reproduce

Since 2bcb757 ("discovery: bound channel range reply buffering", v0.21.2-beta) a GossipSyncer aborts a query_channel_range once the aggregate reply exceeds maxChanRangeReplySCIDs = 100_000. The bound is fine, but the error path leaves the node without any active historical sync and it never recovers on its own:

  • channelGraphSyncer (discovery/syncer.go, waitingQueryRangeReply case) logs Unable to process chan range query: … and does a bare return. The goroutine exits but the GossipSyncer stays registered as initialHistoricalSyncer and the peer stays connected.
  • SyncManager only replaces the initial historical syncer on peer disconnect (staleSyncer path). No disconnect happens here, so nothing is replaced.
  • While IsGraphSynced() is false, every newly connected peer is created as a passive syncer and parked in inactiveSyncers "until the initial historical sync completes", which can no longer happen.
  • The only remaining trigger is HistoricalSyncTicker (default 1h), which picks a random syncer and can re-pick the same oversized peer.

Result: one peer with a bloated graph view (zombie channels, see #10033) leaves a freshly restarted node at synced_to_graph: false for an hour or more. A restart was needed to recover.

Environment

lnd v0.21.3-beta, mainnet, numgraphsyncpeers default (3), gossip.ban-threshold default. The same node on v0.21.1-beta synced fine against the same peers.

Steps to reproduce

  1. Run lnd ≥ v0.21.2 and make sure the initial historical sync is performed against a peer whose reply_channel_range for first_block=0, num_blocks=<tip> carries more than 100,000 SCIDs. Easiest is nobootstrap=1 plus lncli connect <peer> so it is the only candidate, or gossip.pinned-syncers=<pubkey>. The peer that triggered it here is a well-connected public mainnet node (pubkey available on request).
  2. Restart lnd so a fresh initial historical sync runs. In this case nothing was forced: the first pick was banned mid-sync and SyncManager failed over to this peer on its own.
  3. Observe [ERR] DISC: Unable to process chan range query: channel range reply exceeds maximum number of short channel IDs: max=100000, then no further GossipSyncer activity, getinfo reports synced_to_graph: false, and only a trickle of channel_updates arrives via passive syncers.

Expected behaviour

A processChanRangeReply error should be handled like a disconnect of the initial historical syncer: notify SyncManager, run forceHistoricalSync() against a different peer immediately, and exclude the failing peer from that pick. Secondary: make the SCID cap configurable or derive it from the local graph size, since peers serving zombie-heavy graphs exceed 100k today while the public graph is ~30k channels.

Actual behaviour

Syncer goroutine exits, no failover, node unsynced for ~60 minutes until restarted. After the restart a different peer served 31,215 SCIDs and the sync completed in 18 seconds.

Logs (UTC, 2026-09-04)

Peers are labelled PEER_APEER_E. PEER_C is the one whose reply exceeds the cap.

First historical sync, killed by a ban mid-query. lnd correctly fails over here because the peer disconnected:

12:11:08.258 [INF] LTND: Chain backend is fully synced! end_height=965474
12:11:11.573 [INF] DISC: Creating new GossipSyncer for peer=PEER_A
12:11:11.574 [INF] DISC: GossipSyncer(PEER_A): requesting new chans from height=0 and 965474 blocks after
12:11:16.864 [INF] DISC: Creating new GossipSyncer for peer=PEER_B
12:11:17.162 [INF] DISC: Creating new GossipSyncer for peer=PEER_D
12:11:17.214 [INF] DISC: Creating new GossipSyncer for peer=PEER_C
12:11:19.368 [INF] DISC: Creating new GossipSyncer for peer=PEER_E
12:11:29.758 [INF] DISC: GossipSyncer(PEER_A): buffering chan range reply of size=4000
   … 7 more replies of ~4000 …
12:11:44.183 [INF] DISC: GossipSyncer(PEER_A): buffering chan range reply of size=539
12:11:44.183 [INF] DISC: GossipSyncer(PEER_A): filtering through 29159 chans
12:11:45.149 [INF] DISC: GossipSyncer(PEER_A): starting query for 565 new chans
12:11:45.149 [INF] DISC: GossipSyncer(PEER_A): querying for 500 new channels
12:11:52.070 [INF] PEER: Peer(PEER_A): disconnecting PEER_A@<addr>, reason: peer has bypassed ban threshold - banning
12:11:52.172 [INF] DISC: Removing GossipSyncer for peer=PEER_A

Failover to the next peer, which trips the cap. This is where the syncer dies:

12:11:52.181 [INF] DISC: GossipSyncer(PEER_C): requesting new chans from height=0 and 965474 blocks after
12:11:57.262 [INF] DISC: GossipSyncer(PEER_C): buffering chan range reply of size=0
12:11:57.627 [INF] DISC: GossipSyncer(PEER_C): buffering chan range reply of size=4000
12:11:57.805 [INF] DISC: GossipSyncer(PEER_C): buffering chan range reply of size=4000
   … 22 more replies of 3988–4000 within 1.4s …
12:11:58.706 [INF] DISC: GossipSyncer(PEER_C): buffering chan range reply of size=3996
12:11:58.706 [ERR] DISC: Unable to process chan range query: channel range reply exceeds maximum number o00

Nothing further from the sync manager for the next hour. The only GossipSyncer line in 60 minutes is a new peer being parked as passive:

13:01:40.160 [INF] DISC: GossipSyncer(PEER_B): applying new remote update horizon: start=2026-09-04 13:01 19:29:55 +0000 UTC, has_updates=true

Graph activity in that window: ~10 GRPH: Received ChannelEdgePolicy lines between 12:24 and 13:01. A healthy node logs GRPH: Processed channels=… updates=… every minute.

Manual restart, successful sync from a different peer with the same query shape:

13:12:01.996 [INF] LTND: Version Info version=0.21.3-beta
13:14:08.131 [INF] LTND: Chain backend is fully synced! end_height=965480
13:15:58.472 [INF] DISC: GossipSyncer(PEER_E): buffering chan range reply of size=3999
   … 6 more …
13:16:05.279 [INF] DISC: GossipSyncer(PEER_E): buffering chan range reply of size=1772
13:16:05.279 [INF] DISC: GossipSyncer(PEER_E): filtering through 31215 chans
13:16:07.430 [INF] DISC: GossipSyncer(PEER_E): starting query for 492 new chans
13:16:16.254 [INF] DISC: GossipSyncer(PEER_E): no more chans to query
13:16:16.254 [INF] DISC: GossipSyncer(PEER_E): applying gossipFilter(start=2026-09-04 13:16:16 …)

294 GRPH: Received lines in the following 2.5 minutes.

For comparison, the same PEER_C against a second node still on v0.21.1-beta earlier that day:

05:52:11.735 [INF] DISC: GossipSyncer(PEER_C): requesting new chans from height=0 and 965432 blocks after
05:52:20.371 [INF] DISC: GossipSyncer(PEER_C): filtering through 29631 chans

Same peer, ~29.6k SCIDs at 05:52 vs >100k at 12:11. Whether that is the peer's state or a difference in h8af890e lnwire+discovery: handle zero-block ranges is the only other syncer.go change between theversions) I can't tell from this side. The failure mode after the cap is the bug being reported regardless.

Expected Behavior

Perhaps some mitigations:

  • Try a new peer if the first one gets banned, up to 5 peers.
  • Reduce the default of 1 hour

Debug Information

No response

Environment

No response