Skip to content

fix: prevent sync deadlock when no peer can serve the requested header range - #2

Draft
nonsense wants to merge 3 commits into
op-rs:optimismfrom
nonsense:fix/backfill-headers-deadlock
Draft

fix: prevent sync deadlock when no peer can serve the requested header range#2
nonsense wants to merge 3 commits into
op-rs:optimismfrom
nonsense:fix/backfill-headers-deadlock

Conversation

@nonsense

@nonsense nonsense commented Aug 6, 2026

Copy link
Copy Markdown

When a fork choice target is >32 blocks ahead and no connected peer can serve the header
range — e.g. a fleet of equally-behind replicas resuming after a sequencer stall — the node
bans its entire honest peer set (4 empty responses × BadMessage = 12h ban), the Headers
stage pends forever with no error or log line, and the network stays marked syncing until
an operator restart. Three production incidents on 1s-blocktime OP Stack chains hit this
exact shape; on 12s chains the same path is reachable after ~6.4 min of drift.

  • don't report EmptyResponse as a bad message — it is the protocol-correct answer of a
    peer that lacks the data, and the fetcher already deprioritizes such peers for the next
    request (sibling fix to fix(net): recover partial header responses paradigmxyz/reth#26482)
  • fail the Headers stage with a recoverable StageError::Stalled when the download makes
    no progress for stages.headers.stall_timeout (default 30s), keeping ETL/downloader
    state so a same-gap retry resumes; end the pipeline run with ControlFlow::NoProgress
    once readiness failures exceed a budget (default 5m), so the engine re-targets from the
    latest fork choice state instead of retrying a stale target forever
  • return the network to SyncState::Idle unconditionally on BackfillSyncFinished
    (reverts the fix: small networking fixes paradigmxyz/reth#16742 gate — sticky "syncing" after an unproductive backfill suppresses tx
    gossip indefinitely; the debug flag keeps its startup semantics)

nonsense and others added 3 commits August 6, 2026 15:50
An empty response to a headers or bodies request is the
protocol-correct answer of a peer that does not have the requested
data, but the downloaders reported it as a bad message
(ReputationChangeKind::BadMessage, -16384): four honest answers cross
the ban threshold and disconnect the peer for 12 hours. When no peer
can serve the requested range - e.g. a fork choice target ahead of
every currently connected peer - the node bans its entire honest peer
set within seconds, guaranteeing the download it is waiting on can
never complete. This wedged OP Stack nodes in production (Ink Mainnet
2026-07-16, unichain 2026-08-05), where a >32 block gap after a
sequencer stall sent all replicas into backfill with no peer able to
serve the range.

Empty responses are no longer reported; the failed request is
resubmitted as before, and the fetcher already deprioritizes peers
whose last response was unsatisfactory when picking a peer for the
next request. Malformed responses (wrong start block, non-contiguous
headers, invalid seals, too many bodies) keep the full penalty. The
engine's single-block and block-range fetch paths already treat empty
responses as non-reportable.

Also promotes the "Penalizing peer" log from trace to debug so
reputation destruction is visible closer to default verbosity.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The Headers stage propagated the downloader's Pending forever: a
fork choice target that no connected peer can serve - e.g. after a
sequencer stall leaves every replica equally behind - parked the
pipeline at stage 1 indefinitely, with no retry, no error and no log
line. Combined with fork choice updates only being tracked during
backfill, this wedged OP Stack nodes in production until an operator
restart (Ink Mainnet 2026-07-16, unichain 2026-08-05).

Two bounds, both progress-aware so a slow but healthy sync is never
interrupted:

- `HeaderStage` now arms a stall deadline (default 30s, configurable
  via `stages.headers.stall_timeout`) while the downloader is pending
  and resets it whenever headers arrive. On expiry the stage returns a
  new recoverable `StageError::Stalled` instead of pending forever.
  The ETL and downloader state are kept so a retry with an unchanged
  sync gap resumes the download; the ETL collectors are instead
  cleared whenever the sync gap changes, which also fixes stale
  collector entries leaking into a later run with a different target.

  A timeout wrapped around the `execute_ready` future itself would not
  work here: the headers stage legitimately downloads the entire gap -
  potentially for hours during initial sync - before it resolves.

- The pipeline now gives a stage a readiness budget (default 5m,
  `PipelineBuilder::with_execute_ready_timeout`) armed on the first
  `execute_ready` failure and cleared once the stage becomes ready.
  When a stage keeps failing readiness beyond the budget, the pipeline
  emits `PipelineEvent::TimedOut` and gives up on the stage for the
  current run with `ControlFlow::NoProgress`, so control returns to
  the caller (the engine re-evaluates the backfill target) instead of
  retrying a stale target forever.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A finished backfill only returned the network to `SyncState::Idle`
when the debug-only `--debug.startup-sync-state-idle` flag was set
(the unconditional transition was removed in paradigmxyz#16742 and re-added
behind the flag in paradigmxyz#19429). A backfill that ends without reaching its
target - now reachable via the stage stall handling - therefore left
the node marked syncing indefinitely, suppressing transaction gossip,
until live sync happened to advance. This contributed to wedged OP
Stack nodes in production (Ink Mainnet 2026-07-16, unichain
2026-08-05).

Make the idle transition on `BackfillSyncFinished` unconditional
again. If the node is still behind, `on_backfill_sync_finished`
re-checks the distance against the latest tracked fork choice state
and re-triggers backfill (emitting `BackfillSyncStarted`, which marks
the network as syncing again) or issues the remaining downloads, so
the idle window between consecutive runs is brief. This also means
the initial-sync latch flips after the first backfill run instead of
at the first live-sync block, matching pre-paradigmxyz#16742 behavior; a brief
syncing/idle flap between runs is preferable to a node that stays
"syncing" forever while wedged. The flag keeps its startup semantics.

Also warn when the connected-peer count reaches zero while the node
is syncing - a sync that has no peers cannot make progress, and this
state was previously invisible at default log levels.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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.

1 participant