Skip to content

fix(zakura): keep Zakura reactors alive when the stall watchdog falls back to legacy sync#481

Closed
p0mvn wants to merge 13 commits into
zakura/newblock-best-chain-gatefrom
zakura/fallback-keeps-zakura-alive
Closed

fix(zakura): keep Zakura reactors alive when the stall watchdog falls back to legacy sync#481
p0mvn wants to merge 13 commits into
zakura/newblock-best-chain-gatefrom
zakura/fallback-keeps-zakura-alive

Conversation

@p0mvn

@p0mvn p0mvn commented Jul 6, 2026

Copy link
Copy Markdown

Stacked on #478 (which stacks on #476); merge in order. Rebased onto ironwood-main @ da1923411.

This PR grew during live fleet validation into four related fixes that together make the dual-stack fallback survivable and the fork-recovery pipeline crash-safe. Each was driven by a logged production incident on the zakura testnet fleet (details in the PR comments).

1. Fallback keeps the Zakura reactors alive (the bridge)

Problem: when the stall watchdog fell back to legacy ChainSync, stop_zakura_sync killed the Zakura header- and block-sync reactors. The fallback node is usually the only peer with working canonical ingest at that moment; killing its Zakura layer stopped its advertising/serving/forwarding, pinned every Zakura peer's frontier at the common stuck height (no higher advertiser → no header requests → no fork-recovery evidence), cascaded the watchdog fleet-wide, and starved zakura-only peers until manual restarts. Observed twice in one night (a min-difficulty burst at ~15 s/block outruns the gossip-only ingest that Zakura mode leaves as the sole canonical intake).

Fix: the fallback records the mode switch (sync.zakura.legacy_fallback.engaged) and resumes legacy ChainSync as the body-sync driver while the reactors stay alive: they follow local commits through the chain-tip mirror and quiesce into a serving/advertising bridge. One bridge node's robust batch-sync ingest then feeds the whole fleet over Zakura.

2. Commit barrier without the kill (the apply gate)

Problem: the old hard stop also served as a commit barrier — two engines submitting bulk commits concurrently race in the applying queue (a previously-observed failure class). Removing the kill alone reopened that race in the handoff window.

Fix: ZakuraApplyGate — on fallback, no new Zakura bulk applies start and the watchdog drains in-flight applies (bounded backstop; each apply has its own 30 s driver timeout) before legacy takes the pipeline. Permits are threaded through the apply futures so the barrier is exact. Serving, statuses, header commits, and NewBlock accepts (the same class as gossip commits, which already coexist with ChainSync) continue.

3. Metrics code must not panic the write task

Problem: repeated fork-recovery invalidations exposed a latent panic: update_metrics_bars .expects recent_fork_length() after checking recent_fork_height(), but invalidating a chain suffix can leave last_fork_height above the truncated tip — where recent_fork_length() deliberately returns None. The panic killed the block write task → chain tip sender was dropped → process exit → crash loop on a zakura-only node. Also reachable via the invalidateblock RPC.

Fix: the metrics caller handles None.

4. Reconcile a body suffix stranded across a crash (ReadRequest::ZakuraHeaderHash)

Problem: if the node exits between a durable header reorg and its body invalidation (exactly what the panic above caused), later header commits find the rows already replaced, report reorged_at: None, and the event-based invalidation never fires again — headers honest, bodies permanently stranded, walk-back cycling uselessly.

Fix: on the recurring BodyGaps signal, the driver compares the best body chain against the Zakura header store (new ReadRequest::ZakuraHeaderHash, which reads the header store directly — the serving reads prefer the best body chain and would mask the divergence), walks down to the fork point, and invalidates the first stranded body block (sync.header.fork_recovery.body_suffix_reconciled). Two reads when the chains agree; zero writes.

Plus ingest observability: gossip download/verify failures at info with counters (gossip.ingest.{failed,duplicate}.count), queue/lookahead drops promoted to info — the previous silence made tip-ingest freezes unattributable from fleet logs.

Tests

  • Fallback contract tests rewritten and extended: fallback yields + drains the gate, blocks new applies, waits for in-flight applies, cancels nothing (fallback_drains_the_apply_gate_without_cancelling_zakura et al.).
  • stranded_body_suffix_is_reconciled_by_invalidation: mock-state agree/stranded cases, including the fork-point walk-down.
  • Full suites: zebra-network (897), zebrad zakura (53) + components::sync (50) + inbound (22), zebra-state (301); fmt + clippy clean.
  • Fleet evidence (all on the live zakura testnet, logged in PR comments): first watchdog fallback ever survived hands-off (4/5 nodes in bridge mode, body == header throughout, zero reactor exits); nodes closing 46-block gaps over pure Zakura served by bridge nodes; the crash-loop reproduced and eliminated; the stranded suffix reconciled live (node unstranded ~470 blocks in seconds); and with the full stack: snapshot→tip onboard of 12,529 blocks in 152 s (~7,400 blocks/min) ending hash-equal with the fleet.

Follow-up Work

AI Disclosure

  • AI tools were used: Claude Code (Fable 5) for diagnosis (fleet trace forensics), implementation, tests, fleet validation, and this PR description, directed and reviewed by the author.

PR Checklist

  • The PR title follows conventional commits format: type(scope): description
  • The PR follows the contribution guidelines.
  • This change was discussed in an issue or with the team beforehand.
  • The solution is tested.
  • The documentation and changelogs are up to date.

@p0mvn p0mvn added the C-bug Bug fix label Jul 6, 2026
@v12-auditor

v12-auditor Bot commented Jul 6, 2026

Copy link
Copy Markdown

Note

Complete: Audit complete. V12 found two issues worth reviewing.

Open the full results here.

FindingSeverityDetails
F-96416 🟡 Medium
Gossip queue drops flood logs

A connected peer can turn fresh block advertisements into default-level log output after its gossip-download allowance is full. The network connection maps a one-block inv message directly into Request::AdvertiseBlock, and the inbound service calls download_and_verify() while discarding whether the hash was admitted or dropped. Once the peer has reached the per-source cap, every distinct attacker-chosen hash bypasses the duplicate branch and executes the info! per-source drop branch, increments metrics, and returns FullQueue without consuming another download slot. Zebra’s start command defaults to info logging, and configured log files are opened append-only behind a bounded non-blocking writer that drops only after accepting events into the tracing path. The global and per-source queue caps bound concurrent downloads, but they do not bound rejected-advertisement log volume over time.

F-96417 🟡 Medium
Malformed gossip floods logs

For admitted gossiped hashes, a malicious peer can advertise the hash of a fabricated block and then serve that exact block back to Zebra. The download path checks that the returned block hash matches, then performs local coinbase-height and lookahead-window checks before sending the block to the consensus verifier. The new info! logs on the no-height, too-far-ahead, and too-old branches fire before verifier submission and return generic boxed errors. poll_next() then logs every non-duplicate download or verify failure again at info!, so common malformed or out-of-window bodies produce repeated default-level log events. These pre-verifier failures do not satisfy the VerifyBlockError downcast used for peer scoring, so the source can repeat the attack as slots free.

Analyzed three files, diff 7fe1a93...e601642.

@p0mvn

p0mvn commented Jul 6, 2026

Copy link
Copy Markdown
Author

Fleet validation: another watchdog-fallback event hit ~30 min after deploy (burst aftermath) — the first one ever survived without human intervention.

  • 4 of 5 dual-stack nodes engaged legacy fallback (sync.zakura.legacy_fallback.engaged=1) — and on every one of them the zakura header frontier kept tracking the body tip exactly (body == header == 4145882), with zero sync_header_reactor_exited. Before this fix, the same event froze header gauges at the kill height while bodies raced 70–128 blocks ahead.
  • The bridge role works end-to-end: testnet-eu (still in zakura mode, no fallback) had its header frontier fed to 4145882 over zakura by the fallback nodes and its body sync catching up through them; the zakura-only node tracked the fleet at the same height instead of starving at a frozen tip — previously it sat stranded for ~20 min per event until fleet restarts.
  • The new ingest instrumentation is live and immediately useful: gossip.ingest.failed.count = 61–206 per node within ~15 min around the burst, quantifying the gossip-path loss rate that motivates the follow-up throughput work (the failures were previously consumed silently).

No restarts, no operator action, no stranded nodes. Soak continues.

@p0mvn

p0mvn commented Jul 6, 2026

Copy link
Copy Markdown
Author

Soak update 2: eu (the last zakura-mode dual-stack node) hit its own watchdog and joined bridge mode — all 5 dual-stack nodes now have body == header tracking exactly while in fallback, zero reactor exits, no operator action.

One real bug shaken out on the zakura-only node: repeated fork-recovery invalidations (#476 path, firing correctly on min-diff mini-forks) exposed a latent panic in the progress-bar metrics code — update_metrics_bars .expects recent_fork_length() after checking recent_fork_height(), but invalidating a chain suffix can leave last_fork_height above the truncated tip, where recent_fork_length() deliberately returns None (its own comment says ignoring invalid data is fine because it's metrics-only). The panic killed the block write task → chain tip sender was dropped → process exit → crash loop on the zakura-only node (3 restarts). Fixed in 792c4fe on this branch: the metrics caller now handles None; display code must never panic the write task. Note the same panic is reachable via the invalidateblock RPC path independently of this PR stack.

@p0mvn
p0mvn marked this pull request as draft July 6, 2026 05:58
@p0mvn

p0mvn commented Jul 6, 2026

Copy link
Copy Markdown
Author

Second gap found and fixed while validating on the zakura-only node (commit 2b6d1eb): if the node exits between a durable header reorg and its body invalidation (which is exactly what the metrics panic caused), later header commits find the rows already replaced, report reorged_at: None, and the event-based invalidation never fires again — headers honest, bodies permanently stranded, walk-back cycling uselessly. The driver now reconciles instead of relying on the event: committed ranges overlapping the body tip are compared against the best body chain and the first stranded body block is invalidated (sync.header.fork_recovery.body_suffix_reconciled). Zero extra reads in the common case (range above body tip). Unit-tested with mock state (stranded_body_suffix_is_reconciled_by_invalidation).

Meanwhile the bridge design keeps proving itself: after the latest rolling restart, three nodes closed 46-block gaps via pure zakura served by the two bridge-mode nodes, no fallback needed on their side.

@p0mvn
p0mvn force-pushed the zakura/fallback-keeps-zakura-alive branch from 2b6d1eb to cce28d4 Compare July 6, 2026 06:38
p0mvn added 4 commits July 6, 2026 00:59
… back to legacy sync

Killing the reactors turned every fallback into a fleet-wide Zakura outage:
the fallback node is often the only peer with working block ingest, and with
its reactors dead it stops advertising, serving, and forwarding, pinning
every Zakura peer's frontier. Legacy ChainSync now resumes as the body-sync
driver while Zakura quiesces into a serving/advertising bridge. Also makes
inbound block-gossip ingest failures and queue drops visible at info with
counters, so tip-ingest freezes are attributable from logs.
…truncated chain tip

Invalidating a chain suffix (Zakura fork recovery) can leave last_fork_height
above the new tip; recent_fork_length deliberately returns None for that, but
the progress-bar metrics caller expected Some and panicked the block write
task, crash-looping the node. Metrics display code must never panic.
…ecovery

If the node exits between a durable header reorg and its body invalidation,
later header commits find the rows already replaced and report no conflict,
so the event-based invalidation never fires again and the body chain stays
stranded on the abandoned branch forever. Committed ranges overlapping the
body tip are now compared against the best body chain, and the first
stranded body block is invalidated (one extra state read per commit only
when the range overlaps committed bodies).
…rives commits

Two sync engines submitting bulk commits concurrently race in the applying
queue, so the fallback is now a commit barrier again — without killing the
reactors: an apply gate stops new Zakura block applies once fallback engages
and the watchdog waits for in-flight applies to finish (bounded; each apply
has its own driver timeout) before legacy ChainSync takes the pipeline.
Serving, statuses, header commits, and NewBlock accepts continue unaffected.
@p0mvn
p0mvn force-pushed the zakura/newblock-best-chain-gate branch from 7fe1a93 to 1aa8ad6 Compare July 6, 2026 07:04
@p0mvn
p0mvn force-pushed the zakura/fallback-keeps-zakura-alive branch from 021af96 to 4d77165 Compare July 6, 2026 07:04
p0mvn added 2 commits July 6, 2026 01:51
…h-up

Fallback was permanent per process lifetime: bootstrap_genesis_then_pause
exited into the legacy sync loop and nothing ever handed the driver role
back, so every min-difficulty burst demoted one more node's Zakura sync to
a serve-only bridge until restart. The watchdog is now a cycle: on fallback
it yields+drains the apply gate, legacy drives until caught up and stable
(three consecutive exhausted sync rounds advancing the tip by at most two
blocks), then the gate is returned to Zakura and the watchdog resumes.
Hand-back happens between legacy rounds, so no legacy bulk work is in
flight when Zakura resumes driving.
…dlines

When the download floor passed a request (other peers delivered its heights),
the GC removed it but only disarmed the liveness deadline for peers whose
last delivery was newer than their last request. A healthy peer with an older
delivery kept a deadline it could no longer answer, was disconnected for 'no
accepted block progress' with zero outstanding, and parked in the 180s
no-progress cooldown — at tip this serially exiled peers until the block-sync
peer set collapsed (reproduced live 5→0 after a fleet-wide restart, wedging
body sync at the next gap). Heights satisfied below the floor now always
clear an idle peer's deadline; unresponsive peers are still caught by request
timeouts, which deliberately keep the deadline armed.
@p0mvn

p0mvn commented Jul 6, 2026

Copy link
Copy Markdown
Author

Another live-reproduced fix (commit a433abe), found by the deliberate mass-restart soak: stale block-sync liveness deadlines were serially exiling healthy peers at tip. When the download floor passed a request because other peers delivered its heights, gc_committed_outstanding removed it but used the after-progress disarm, whose block_at >= request_at predicate fails for a peer whose delivery predates its newest request — the stale deadline then fired no accepted block progress with outstanding: 0 (the victim had delivered a block 32s earlier), disconnecting and parking the peer for the 180s no-progress cooldown. Watched it live: t4's block-sync peer set collapsed 5 → 0 in ~25 min after a fleet-wide restart, freezing body sync at 4146600 when the next gap formed. This also retro-explains the earlier peers: 0 wedge attributed to the duplicate-connection race. Fix: heights satisfied below the floor always clear an idle peer's deadline (unresponsive peers are still caught by request timeouts, which deliberately keep the deadline armed). Regression test floor_gc_clears_stale_liveness_deadline reproduces the exact sequence. Fleet validation next: post-deploy, peer sets must hold at 5 through tip-idle and a repeat mass restart.

The mirror maxed a TipAction::Reset tip against the latest_chain_tip watch,
which may not have observed the reset yet; the stale higher tip turned the
downstream VerifiedReset into a no-op, pinning the block-sync sequencer one
block above the real tip after a fork-recovery invalidation — the missing
parent was never requested and every body commit timed out until restart.
On Reset the action's tip is authoritative; anti-regression maxing now
applies only to Grow.
@p0mvn

p0mvn commented Jul 6, 2026

Copy link
Copy Markdown
Author

Seventh fix on this branch (600a67e), again live-reproduced within the hour: the chain-tip mirror silently discarded tip resets. On TipAction::Reset (fork-recovery invalidation, or invalidateblock) the mirror maxed the reset tip against the latest_chain_tip watch — which may not have observed the reset yet — republished the stale higher tip, and turned the downstream VerifiedReset into a no-op. The block-sync sequencer then pinned exactly one block above the real tip (verified=4146695 while RPC said 4146694), never requested the missing parent, and every body commit timed out (applying=60, committed/sec=0) until restart. This race also explains the earlier post-crash zombie session and why some recoveries needed a restart to complete: the whole reorg pipeline was only as reliable as this race. Fix: on Reset the action's tip is published verbatim; anti-regression maxing applies only to Grow. Regression test chain_tip_mirror_reset_tip_is_authoritative.

Running tally of live-validated behavior on the full stack tonight: snapshot→tip 12.5k blocks in 152 s hash-equal; 12/12 kill-soak cycles at ~20 s recovery; three hands-off fork recoveries; first hands-off fallback survival; and two at-tip stability bugs (stale liveness deadline cascade, reset-swallowing mirror) found by deliberate mass-restart + tip-idle soaks, both with deterministic regression tests.

@p0mvn

p0mvn commented Jul 6, 2026

Copy link
Copy Markdown
Author

Re-promotion validated live on its first natural trigger. testnet-as hit gossip-intake starvation at tip (~52-block freeze — its zakura header store lags at tip by design, so state-driven needed-blocks was empty, and its legacy gossip intake stalled). The full cycle ran hands-off on d7fb56ed: watchdog fallback engaged (apply-gate drain → sync.zakura.legacy_fallback.engaged=1) → legacy ChainSync caught up 4146823 → 4146886 → three consecutive stable rounds → sync.zakura.repromoted=1 → zakura resumed as body-sync driver, reactors never stopped serving. What used to be a permanent per-process demotion (or, before the bridge fix, a fleet-wide outage) is now a ~5-minute self-healing blip.

Also in progress on the same build: the liveness-fix validation — t4 at 8+ minutes of tip-idle with all 5 block-sync peers held and zero liveness disconnects (the old binary's cascade had already started exiling peers by this point). Mass-restart round 2 follows the 20-minute pass.

@p0mvn

p0mvn commented Jul 6, 2026

Copy link
Copy Markdown
Author

Mass-restart round 2 (all 6 nodes in the same second, concurrent with a live min-difficulty burst — the harshest combination yet):

Validated: no liveness cascade — peer sets were not serially exiled (round 1 collapsed 5→0 in 25 min; round 2 saw only genuine storm-time disconnects). The 20-min tip-idle test beforehand held 5 peers / 0 disconnects / 0 lag. The mirror fix held — no parent-gap zombies. The bridge + re-promotion cycle ran hands-off 2–3 times per dual-stack node through the storm (sync.zakura.repromoted = 3/2/2 on t1/t3/as) and the whole fleet converged. The crossing-dial duplicate race did not reproduce in either round.

Two new findings for the next iteration (documented, not yet fixed):

  1. A ~45-minute uniform commit stall on two nodes: every block commit timed out while — per a full gdb thread dump — the state write task idled at its queue, all runtime workers were parked, and the service's parent-waiting queue was empty. The stall lives in the consensus-verifier layer between driver and state, and it self-released. Root cause open; the next instrumentation is a commit-timeout stage dump (on driver timeout, log which verifier await is pending).
  2. The no-progress liveness disconnect cannot distinguish a dead peer from a stalled local commit pipeline: during (1) it exiled healthy peers for a local condition. It should be suppressed while local commits are not landing.

DoD scorecard on the current stack: snapshot→tip with reorg handling ✅ (12.5k blocks / 152 s / hash-equal, across 3 historical fork points); repeated soak ✅ (12/12 kill cycles + 2 mass restarts, no cascades, no manual interventions); sustained zakura p2p ✅ with the caveat of finding (1) — the layer and driver role now survive and recover from everything thrown at them, but one bounded-stall mechanism remains unidentified.

@p0mvn

p0mvn commented Jul 6, 2026

Copy link
Copy Markdown
Author

One last post-storm observation, cleanly isolating a known design gap: t4 is in lockstep at tip but its block-sync peer set is holding at 2 — the storm-parked peers never re-admit because nothing re-escalates an ordered service stream on a live connection, and the #472 keepalives (correctly) removed the accidental refill path (idle-reap → redial → re-escalate). This is the "re-escalation" half of the original §12 Step-1 item, now separated from the winner-agreement half (#458's registration-id work). Next-loop list, in priority order: (1) commit-timeout stage-dump instrumentation to identify the ~45-min verifier-layer stall, (2) suppress no-progress liveness disconnects while local commits aren't landing, (3) service-stream re-escalation on live connections.

p0mvn added 3 commits July 6, 2026 03:48
A ~45-minute uniform commit-stall (every Zakura block-sync commit timing
out while the state write task, runtime workers, and parent-wait queue were
all idle) could not be attributed from logs: the driver's timeout says only
that the verifier did not answer. Log the two stages that can silently hold
a block — the transaction-verification drain (UTXO awaits on out-of-order
parents serialize here, bounded by the 6-minute UTXO_LOOKUP_TIMEOUT) and
the state commit await (parent-waiting queue / write-task backlog) — at
warn when they exceed 20s, and log timed-out and slow (>20s) transparent
UTXO lookups with their outpoint.
…t stalls

'No accepted block progress' cannot be blamed on a peer while the node's own
apply pipeline is not landing commits: during a ~45-minute verifier-layer
commit stall the liveness deadline exiled healthy peers one by one into the
180s no-progress cooldown (observed live: parks +4 on t2, t4 down to 2
peers). When the sequencer view shows bodies in the applying queue, extend
the deadline instead of disconnecting; a genuinely dead peer is caught as
soon as the pipeline drains, and its timed-out requests were already
requeued by expire_due_timeouts.
@p0mvn

p0mvn commented Jul 6, 2026

Copy link
Copy Markdown
Author

Backlog item 2 shipped (c3734e5): liveness disconnects are now deferred during local commit stalls. When the sequencer view shows bodies in the applying queue, an expired block-sync liveness deadline extends instead of disconnecting — "no accepted block progress" cannot be blamed on a peer while the node's own commits are not landing (during the ~45-min stall this exiled healthy peers into the 180s cooldown; t4 dropped to 2 peers). A genuinely dead peer is still caught the moment the pipeline drains, and its timed-out requests were already requeued by expire_due_timeouts. Counted in sync.block.liveness.deferred_local_stall; regression test liveness_disconnect_is_deferred_while_local_applies_are_stalled covers both the deferral and the post-drain disconnect. Remaining backlog: stall-stage instrumentation is already fleet-deployed and armed; service-stream re-escalation on live conns is the last item.

@p0mvn

p0mvn commented Jul 6, 2026

Copy link
Copy Markdown
Author

Design note closing out the investigation backlog — service-stream re-escalation on live connections (the peer-set refill gap) is documented but deliberately not implemented on this branch. Escalation currently happens exactly once per connection in register_and_serve (demand-narrowed ordered_streams_for_escalation, both sides proactive); a session that dies later is never re-opened while the connection lives, and the #472 keepalives removed the accidental reap→redial→refill path. The fix needs (a) per-(peer, service) session-absence detection, (b) a re-escalation trigger plumbed into the serve loop that owns the Connection, and (c) collision-safety for simultaneous re-escalations from both sides — which is the same add_escalated_peer collision surface as the duplicate-connection race that #458's registration-id work addresses. Sequencing recommendation: land or extract #458 first, then build re-escalation on top of its generation guards; doing it before that risks re-escalation storms reintroducing the exact wedge this campaign closed out. Until then the exposure is bounded: refill only lags after mid-session session loss, and any restart/fallback cycle refreshes the peer set.

p0mvn added 2 commits July 6, 2026 08:05
…lures

InvalidPeerRange rejections score the peer, but the CommitHeaderRangeError
variant behind them was only visible at debug level: a stranded-context
wedge (every peer's response for the same range rejected identically for
hours, observed live at height 4148005) was indistinguishable from real
peer misbehavior at default log levels.
…of scoring peers

A fork-recovery re-commit can leave stale rows in the stored difficulty
window; every extension range is then rejected with
InvalidDifficultyThreshold, each rejection scored and disconnected the
honest serving peer, and the wedge survived restarts (on-disk rows).
No link failure ever fires in this state, so the existing walk-back
trigger never engages. Classify ValidateContextError commit failures as
ContextMismatch: never score the peer, and count them as stale-anchor
evidence — the quorum (3 failures, 2 distinct peers) starts the same
exponential walk-back, and each deepening re-commits a longer range that
rewrites the poisoned window.
@p0mvn

p0mvn commented Jul 6, 2026

Copy link
Copy Markdown
Author

New wedge class found, diagnosed to the exact consensus error, and fixed (9348039): stale difficulty-window rows after a fork-recovery re-commit. t4 wedged at 4148004 for ~1h: every extension range from every peer rejected, each rejection scoring and disconnecting the honest serving peer (59 cycles), surviving restarts — the poison is on disk. The promoted warn log (c9480b5) captured the exact error: ValidateContextError(InvalidDifficultyThreshold { threshold: 0x2000a370, expected: 0x2000a397 }) — canonical headers vs a locally-computed expectation from a DAA window containing stale reorg rows. No link failure ever fires in this state, so the existing walk-back trigger could never engage.

Fix: ValidateContextError commit failures are now classified ContextMismatch — never scored (the peers are honest; the rows are ours), and counted as stale-anchor evidence: the existing quorum (3 failures / 2 distinct peers) starts the exponential walk-back, and each deepening re-commits a longer range that rewrites the poisoned window. Regression test context_mismatch_commit_failures_walk_back_without_scoring_peers.

Honest validation note: t4 healed via a naturally-triggered deep walk-back (a real fork during the burst) minutes before the fix binary landed — which live-validates the repair mechanism (deep re-commit rewrites the window) but not the new trigger, which is covered by the unit test and exists precisely for wedges that get no natural rescue. Still open on the diagnosis trail: the write-side hole that left stale rows despite the reorg batch's delete loop (first_conflicting_height..=best_header_tip) — captured traces + the warn logs will pin it on next occurrence. Fleet is on d2b7e004 (14 commits).

…Mismatch

UnknownAnchor cannot be the peer's fault: the anchor is this node's own
request bookkeeping, and the error fires when the reactor's in-memory
frontier references a hash the header store does not have (observed live
at 4148376 after the frontier advanced over diverged store rows). It was
classified InvalidPeerRange, so honest peers were scored and disconnected
and no recovery trigger ever fired. As ContextMismatch it feeds the
stale-anchor quorum; the walk-back re-anchors from the store, which is
exactly the self-correction this state needs.
@p0mvn

p0mvn commented Jul 6, 2026

Copy link
Copy Markdown
Author

Follow-up on the same class (6f8b4ad): a second local-coherence failure surfaced within the hour — UnknownAnchor at 4148376, where the reactor's mirror-advanced in-memory frontier referenced a hash the header store did not have; classified InvalidPeerRange, it scored and disconnected honest peers with no recovery trigger. A peer cannot cause UnknownAnchor (the anchor is the node's own request bookkeeping), so it is now ContextMismatch too: no scoring, and the stale-anchor quorum walks back — re-anchoring from the store, which is precisely the needed self-correction. This instance healed on restart alone (in-memory divergence, unlike the on-disk DAA poisoning), so the trigger is validated by the unit test; it exists for the persistent case. Fleet is on 2b67b90b (15 commits). The write-side question — why store rows diverge from the actual chain above a reorg re-commit (root cause behind BOTH wedge flavors tonight) — remains the open item, now with two captured signatures and fleet-wide warn logging to pin it.

@p0mvn p0mvn closed this Jul 7, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

C-bug Bug fix

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant