Skip to content

fix(l1): release the syncer lock when waiting for a forkchoice head (sync wedge)#6924

Open
ilitteri wants to merge 2 commits into
mainfrom
fix/sync-resume-lock-wedge
Open

fix(l1): release the syncer lock when waiting for a forkchoice head (sync wedge)#6924
ilitteri wants to merge 2 commits into
mainfrom
fix/sync-resume-lock-wedge

Conversation

@ilitteri

Copy link
Copy Markdown
Collaborator

Motivation

On glamsterdam-devnet-6, a node that fell behind (buildoor-prysm-ethrex-1) sat wedged ~15h with zero sync logs while its consensus client drove it every slot — it only recovered after a manual removedb + resync. This is the recovery half of that incident (the trigger — peers not serving headers by hash — is #6921).

Root cause

SyncManager::start_sync spawns a task that try_lock()s the syncer mutex and holds it for the whole task. When the forkchoice head is H256::zero() (e.g. right after a restart, before the first forkchoiceUpdated), the task entered an infinite log + sleep(5s) + continue loop, never releasing the lock. Since is_active() is self.syncer.try_lock().is_err() and sync_to_head() only spawns when !is_active(), a task parked in that spin pinned is_active() == true and turned every later sync_to_head() — from both forkchoiceUpdated and newPayload — into a silent no-op. Compounded by set_head() dropping the fcu-head on try_lock contention (leaving the head at zero), the node could never re-arm: a permanent wedge with no sync logs.

Fix

In the zero-head branch, return and release the syncer lock instead of spinning while holding it. The next forkchoiceUpdated/newPayload calls sync_to_head(), finds the syncer idle, and re-spawns start_sync() with the head populated. The non-zero-head path and the snap multi-cycle loop (continue while get_header_download_checkpoint() is Some) are unchanged. The restart-resume state is still surfaced at info level (now one-shot). Removed the now-unused Duration/sleep/debug imports.

Correctness / review

Adversarially reviewed (concurrency + regression lenses) — ship-it, no blockers:

  • The try_lock guard is dropped synchronously on return (no .await between the zero-check and the return), so is_active() correctly flips back to false.
  • There is a residual, transient TOCTOU: if a concurrent first FCU's is_active() check observes the boot task still holding the lock during the few instructions before its return, that FCU's spawn is skipped — recovered on the next FCU (CLs resend every slot). This is a strict improvement over the baseline, which held the lock forever. It cannot leave a node permanently un-synced as long as the CL keeps sending forkchoice updates (if it doesn't, no sync design could proceed).

Testing

cargo check/cargo clippy -p ethrex-p2p clean. This is a concurrency/lifetime fix (hard to unit-test deterministically); validated by reasoning + review. Empirically, the wedged node recovered after a manual reset, consistent with the diagnosis.

Scope / follow-up

This is Q1 of the recovery work. Q2 — a node with no retained state pausing with "Full sync cannot resume … run ethrex removedb" — is left as a separate change: the obvious "re-enable snap from the resume guard" loops on < MIN_FULL_BLOCKS devnets (where full sync is force-enabled), so the real fix is an automatic reset-and-full-resync, which deserves its own design.

@ilitteri ilitteri requested a review from a team as a code owner June 26, 2026 18:42
@github-actions github-actions Bot added the L1 Ethereum client label Jun 26, 2026
@github-actions

Copy link
Copy Markdown

🤖 Kimi Code Review

This is a correct and important fix for a liveness issue. The change eliminates a potential deadlock scenario where the sync manager could wedge itself indefinitely.

Key improvements:

  1. Fixes liveness bug (lines 212-224): The old code held the syncer lock while sleeping in a 5-second loop, causing is_active() to remain true. This meant incoming forkchoiceUpdate calls would see an "active" syncer and become silent no-ops, even though the task was just sleeping. If the FCU update arrived during the sleep window, the node would never resume syncing.

  2. Removes lock-holding across await points: Holding a tokio::sync::Mutex guard across a 5-second sleep (line 224 in old code) blocks other tasks from acquiring the lock and can cause executor starvation. The early return properly releases the lock.

  3. Simplifies control flow: Removing the internal loop and waiting_for_fcu_logged flag makes the state machine easier to reason about. The sync lifecycle is now: spawn → check precondition → either sync or exit cleanly.

Minor observations:

  • The log level change from conditional info/debug to unconditional info is appropriate since the function now returns immediately rather than looping, preventing log spam while still recording the event.

  • Ensure sync_to_head() is idempotent and safe to call repeatedly on subsequent forkchoiceUpdate notifications (which the comment suggests it is).

Verdict: Approve. The fix correctly addresses the race condition described in the detailed comment and follows Tokio best practices for lock management.


Automated review by Kimi (Moonshot AI) · kimi-k2.5 · custom prompt

@github-actions

Copy link
Copy Markdown

🤖 Codex Code Review

  1. crates/networking/p2p/sync_manager.rs:220 introduces a lost-wakeup race that can leave restart-resume sync permanently idle until another FCU arrives. The problematic interleaving is: new() calls start_sync(), the spawned task acquires syncer, reads last_fcu_head == 0, then before it executes return, a CL forkchoiceUpdated arrives and sync_to_head() stores the real head at sync_manager.rs:103 but sees is_active() == true, so it does not spawn a new task. The original loop would eventually pick that head up on its next iteration; the new early return drops the only wakeup. On a quiet chain after restart, that means snap/full resume can stay stuck indefinitely until the next block or another FCU. I’d treat this as a correctness regression. The fix needs an explicit re-arm path instead of assuming “the next forkchoiceUpdate” will always happen.

Open question: I did not find a regression test covering “checkpoint present + FCU arrives while the bootstrap sync task is exiting because head is still zero”. This path should have a targeted async test, because it is timing-sensitive and easy to re-break.

Other than that race, the intent of avoiding a permanently active spinner makes sense, and I did not see additional security or EVM/consensus-specific concerns in this diff.


Automated review by OpenAI Codex · gpt-5.4 · custom prompt

@greptile-apps

greptile-apps Bot commented Jun 26, 2026

Copy link
Copy Markdown

Greptile Summary

This PR changes the sync manager's zero-head resume behavior. The main changes are:

  • Return from the zero forkchoice-head branch instead of sleeping while holding the syncer lock.
  • Log the waiting state once at info level.
  • Remove the now-unused Duration, sleep, and debug imports.

Confidence Score: 5/5

This looks safe to merge.

  • No blocking issues found in the changed code.
  • The changed branch releases the syncer lock so later sync triggers can re-arm syncing.
  • The removed imports have no remaining uses in the changed module.

Important Files Changed

Filename Overview
crates/networking/p2p/sync_manager.rs Updates zero-head sync startup to return and release the syncer lock, with unused imports removed.

Reviews (1): Last reviewed commit: "fix(l1): log the waiting-for-forkchoice ..." | Re-trigger Greptile

@github-actions

github-actions Bot commented Jun 26, 2026

Copy link
Copy Markdown

Lines of code report

Total lines added: 254
Total lines removed: 71
Total lines changed: 325

Detailed view
+------------------------------------------------------------------------+-------+------+
| File                                                                   | Lines | Diff |
+------------------------------------------------------------------------+-------+------+
| ethrex/crates/blockchain/blockchain.rs                                 | 2766  | +14  |
+------------------------------------------------------------------------+-------+------+
| ethrex/crates/blockchain/error.rs                                      | 155   | +6   |
+------------------------------------------------------------------------+-------+------+
| ethrex/crates/blockchain/mempool.rs                                    | 560   | -43  |
+------------------------------------------------------------------------+-------+------+
| ethrex/crates/blockchain/payload.rs                                    | 864   | +10  |
+------------------------------------------------------------------------+-------+------+
| ethrex/crates/blockchain/tracing.rs                                    | 242   | +1   |
+------------------------------------------------------------------------+-------+------+
| ethrex/crates/common/trie/trie.rs                                      | 599   | +56  |
+------------------------------------------------------------------------+-------+------+
| ethrex/crates/common/types/blobs_bundle.rs                             | 553   | +35  |
+------------------------------------------------------------------------+-------+------+
| ethrex/crates/common/types/block_access_list.rs                        | 1417  | +4   |
+------------------------------------------------------------------------+-------+------+
| ethrex/crates/common/types/block_execution_witness.rs                  | 634   | +8   |
+------------------------------------------------------------------------+-------+------+
| ethrex/crates/common/validation.rs                                     | 259   | +1   |
+------------------------------------------------------------------------+-------+------+
| ethrex/crates/guest-program/src/common/execution.rs                    | 147   | +1   |
+------------------------------------------------------------------------+-------+------+
| ethrex/crates/guest-program/src/l1/mod.rs                              | 19    | +3   |
+------------------------------------------------------------------------+-------+------+
| ethrex/crates/guest-program/src/l1/program.rs                          | 527   | +4   |
+------------------------------------------------------------------------+-------+------+
| ethrex/crates/l2/networking/rpc/rpc.rs                                 | 316   | +1   |
+------------------------------------------------------------------------+-------+------+
| ethrex/crates/l2/sequencer/block_producer/payload_builder.rs           | 278   | +7   |
+------------------------------------------------------------------------+-------+------+
| ethrex/crates/l2/sequencer/l1_watcher.rs                               | 547   | +1   |
+------------------------------------------------------------------------+-------+------+
| ethrex/crates/networking/p2p/rlpx/connection/server.rs                 | 1536  | +6   |
+------------------------------------------------------------------------+-------+------+
| ethrex/crates/networking/p2p/rlpx/eth/transactions.rs                  | 330   | +2   |
+------------------------------------------------------------------------+-------+------+
| ethrex/crates/networking/p2p/snap/server.rs                            | 154   | +4   |
+------------------------------------------------------------------------+-------+------+
| ethrex/crates/networking/p2p/sync_manager.rs                           | 188   | -12  |
+------------------------------------------------------------------------+-------+------+
| ethrex/crates/networking/p2p/tx_broadcaster.rs                         | 325   | +16  |
+------------------------------------------------------------------------+-------+------+
| ethrex/crates/networking/rpc/engine/blobs.rs                           | 370   | +34  |
+------------------------------------------------------------------------+-------+------+
| ethrex/crates/networking/rpc/engine/fork_choice.rs                     | 573   | +5   |
+------------------------------------------------------------------------+-------+------+
| ethrex/crates/networking/rpc/engine/payload.rs                         | 1375  | +2   |
+------------------------------------------------------------------------+-------+------+
| ethrex/crates/networking/rpc/eth/block_access_list.rs                  | 123   | +1   |
+------------------------------------------------------------------------+-------+------+
| ethrex/crates/networking/rpc/eth/logs.rs                               | 381   | +1   |
+------------------------------------------------------------------------+-------+------+
| ethrex/crates/networking/rpc/lib.rs                                    | 33    | +1   |
+------------------------------------------------------------------------+-------+------+
| ethrex/crates/networking/rpc/types/block.rs                            | 205   | +5   |
+------------------------------------------------------------------------+-------+------+
| ethrex/crates/networking/rpc/types/fork_choice.rs                      | 57    | +2   |
+------------------------------------------------------------------------+-------+------+
| ethrex/crates/vm/backends/levm/mod.rs                                  | 2619  | +12  |
+------------------------------------------------------------------------+-------+------+
| ethrex/crates/vm/levm/src/call_frame.rs                                | 402   | +11  |
+------------------------------------------------------------------------+-------+------+
| ethrex/crates/vm/levm/src/opcode_handlers/arithmetic.rs                | 206   | -7   |
+------------------------------------------------------------------------+-------+------+
| ethrex/crates/vm/levm/src/opcode_handlers/bitwise_comparison.rs        | 200   | -8   |
+------------------------------------------------------------------------+-------+------+
| ethrex/crates/vm/levm/src/opcode_handlers/stack_memory_storage_flow.rs | 377   | -1   |
+------------------------------------------------------------------------+-------+------+

@github-actions

Copy link
Copy Markdown

🤖 Claude Code Review

Now I have enough to synthesize a precise review. Let me write it up.


PR Review: fix(l1): release the syncer lock when waiting for a forkchoice head

What the PR does: Fixes a production sync-wedge where SyncManager::start_sync spawned a task that acquired the syncer mutex, then entered an infinite sleep(5s) + continue loop when last_fcu_head was H256::zero(). Because is_active() is implemented as self.syncer.try_lock().is_err(), the spinning task held is_active() == true permanently, turning every subsequent sync_to_head() call (from both forkchoiceUpdated and newPayload) into a silent no-op. The fix replaces the spin with an immediate return, releasing the lock so the next FCU can re-arm the syncer.

The fix is correct. The core reasoning holds: the try_lock guard is dropped synchronously before the return, so is_active() correctly flips back to false, and the CL's next slot FCU re-enters sync_to_head() which finds the syncer idle and respawns. The snap multi-cycle loop is untouched.


Finding 1 — Pre-existing: set_head() can silently drop FCU head updates

File: crates/networking/p2p/sync_manager.rs:177–183

fn set_head(&self, fcu_head: H256) {
    if let Ok(mut latest_fcu_head) = self.last_fcu_head.try_lock() {
        *latest_fcu_head = fcu_head;
    } else {
        warn!("Failed to update latest fcu head for syncing")  // head permanently lost
    }
}

The spawned task also try_lock()s last_fcu_head briefly to copy the value (lines 206–211). If set_head() races that window, the new FCU head is dropped with only a warn! and no retry. In the full-sync single-iteration path (loop breaks after syncer.start_sync with no checkpoint), there is no subsequent loop iteration to re-read the head, so recovery requires the CL to send another FCU.

This is pre-existing and not introduced by this PR. The fix actually improves the situation — previously the spinning task held last_fcu_head locked every 5 seconds for the read, creating a larger contention window. The collision window is now sub-microsecond (a 32-byte Copy), and recovery is guaranteed within one CL slot (~12 s). Not a blocker, but replacing the Arc<Mutex<H256>> with an ArcSwap<H256> or an atomic pair would eliminate the window entirely and is worth a follow-up.


Finding 2 — Log message is misleading about node state

File: crates/networking/p2p/sync_manager.rs:221–224

info!(
    "No forkchoice head yet (e.g. after a node restart); waiting for the next forkchoice update to start syncing"
);
return;

The message says "waiting for the next forkchoice update" but the task has already returned — the node is not waiting, it has released the lock and exited. An operator tailing logs who sees this message and no subsequent sync activity might reasonably believe the node is in a blocking wait state when in fact it is idle and depends on the CL re-driving the flow. A more precise phrasing would be: "No forkchoice head yet (e.g. after a node restart); released syncer lock — will re-arm on the next forkchoice update". Minor cosmetic issue, but in an incident scenario log clarity matters.


Acknowledged residual TOCTOU

The PR description correctly identifies the transient race: if an FCU's is_active() check observes the boot task still holding the lock in the few instructions before its return, that FCU's spawn is skipped. Recovery occurs on the next slot. The inner try_lock on syncer inside the spawned task acts as a secondary guard against duplicate concurrent tasks in any case. This is a strict improvement over the baseline (which held the lock indefinitely) and is acceptable.


Overall: The fix is sound and addresses the stated root cause precisely. Finding 1 is a pre-existing design concern worth tracking; Finding 2 is a log-clarity nit. No blockers.


Automated review by Claude (Anthropic) · sonnet · custom prompt

@ilitteri ilitteri changed the base branch from glamsterdam-devnet-6 to main June 29, 2026 13:37
@ilitteri ilitteri requested a review from a team as a code owner June 29, 2026 13:37
@ilitteri ilitteri changed the base branch from main to glamsterdam-devnet-6 June 29, 2026 13:38
ilitteri added 2 commits June 29, 2026 10:42
start_sync spun on a zero forkchoice head (log + sleep + continue) while
holding the syncer lock, so is_active() stayed true and every sync_to_head
(from both forkchoiceUpdated and newPayload) became a silent no-op. A node
that restarted before its first forkchoiceUpdate -- or whose fcu-head update
was dropped by set_head's try_lock under contention -- could stay wedged
indefinitely with zero sync logs. Return and release the syncer lock instead;
the next forkchoiceUpdate re-triggers start_sync with the head populated. The
non-zero-head path and the snap multi-cycle loop are unchanged.
Keep operator visibility into the restart-resume state (a node waiting for
its first forkchoiceUpdate) at the default log level, now as a one-shot line
since start_sync returns instead of spinning.
@ilitteri ilitteri force-pushed the fix/sync-resume-lock-wedge branch from 53e1dfa to 6f8ce21 Compare June 29, 2026 13:48
@ilitteri ilitteri changed the base branch from glamsterdam-devnet-6 to main June 29, 2026 13:48
@github-actions

Copy link
Copy Markdown

Benchmark Results Comparison

No significant difference was registered for any benchmark run.

Detailed Results

Benchmark Results: BubbleSort

Command Mean [s] Min [s] Max [s] Relative
main_revm_BubbleSort 2.974 ± 0.033 2.941 3.060 1.15 ± 0.01
main_levm_BubbleSort 2.612 ± 0.016 2.591 2.633 1.01 ± 0.01
pr_revm_BubbleSort 2.961 ± 0.022 2.941 3.007 1.14 ± 0.01
pr_levm_BubbleSort 2.593 ± 0.011 2.577 2.616 1.00

Benchmark Results: ERC20Approval

Command Mean [s] Min [s] Max [s] Relative
main_revm_ERC20Approval 1.015 ± 0.098 0.974 1.294 1.04 ± 0.10
main_levm_ERC20Approval 0.993 ± 0.026 0.969 1.048 1.02 ± 0.03
pr_revm_ERC20Approval 0.986 ± 0.012 0.975 1.010 1.01 ± 0.02
pr_levm_ERC20Approval 0.977 ± 0.014 0.964 1.013 1.00

Benchmark Results: ERC20Mint

Command Mean [ms] Min [ms] Max [ms] Relative
main_revm_ERC20Mint 132.8 ± 0.8 131.5 134.3 1.00 ± 0.01
main_levm_ERC20Mint 151.0 ± 0.7 150.0 152.2 1.14 ± 0.01
pr_revm_ERC20Mint 132.7 ± 1.3 131.5 135.1 1.00
pr_levm_ERC20Mint 151.9 ± 2.7 150.3 159.3 1.14 ± 0.02

Benchmark Results: ERC20Transfer

Command Mean [ms] Min [ms] Max [ms] Relative
main_revm_ERC20Transfer 232.5 ± 2.6 229.5 237.5 1.01 ± 0.01
main_levm_ERC20Transfer 241.6 ± 1.4 239.3 243.6 1.05 ± 0.01
pr_revm_ERC20Transfer 231.0 ± 0.9 229.9 233.1 1.00
pr_levm_ERC20Transfer 240.7 ± 1.8 238.1 243.7 1.04 ± 0.01

Benchmark Results: Factorial

Command Mean [ms] Min [ms] Max [ms] Relative
main_revm_Factorial 225.6 ± 1.6 223.6 228.3 1.00
main_levm_Factorial 252.3 ± 2.0 250.6 256.3 1.12 ± 0.01
pr_revm_Factorial 226.0 ± 1.6 224.5 230.3 1.00 ± 0.01
pr_levm_Factorial 252.7 ± 1.9 250.6 256.7 1.12 ± 0.01

Benchmark Results: FactorialRecursive

Command Mean [s] Min [s] Max [s] Relative
main_revm_FactorialRecursive 1.663 ± 0.050 1.558 1.711 1.03 ± 0.07
main_levm_FactorialRecursive 9.225 ± 0.022 9.198 9.263 5.74 ± 0.36
pr_revm_FactorialRecursive 1.608 ± 0.101 1.368 1.695 1.00
pr_levm_FactorialRecursive 9.243 ± 0.027 9.197 9.280 5.75 ± 0.36

Benchmark Results: Fibonacci

Command Mean [ms] Min [ms] Max [ms] Relative
main_revm_Fibonacci 204.7 ± 1.3 203.2 208.0 1.00 ± 0.01
main_levm_Fibonacci 215.7 ± 10.9 210.3 245.1 1.06 ± 0.05
pr_revm_Fibonacci 204.2 ± 1.4 200.4 205.5 1.00
pr_levm_Fibonacci 217.0 ± 11.7 209.4 246.8 1.06 ± 0.06

Benchmark Results: FibonacciRecursive

Command Mean [ms] Min [ms] Max [ms] Relative
main_revm_FibonacciRecursive 880.1 ± 7.7 868.9 892.1 1.30 ± 0.01
main_levm_FibonacciRecursive 679.6 ± 4.7 674.6 688.6 1.00 ± 0.01
pr_revm_FibonacciRecursive 882.3 ± 11.2 855.5 895.7 1.30 ± 0.02
pr_levm_FibonacciRecursive 678.7 ± 5.0 672.3 687.8 1.00

Benchmark Results: ManyHashes

Command Mean [ms] Min [ms] Max [ms] Relative
main_revm_ManyHashes 8.7 ± 0.1 8.6 9.0 1.00 ± 0.02
main_levm_ManyHashes 9.5 ± 0.1 9.4 9.6 1.10 ± 0.01
pr_revm_ManyHashes 8.7 ± 0.1 8.6 9.0 1.00
pr_levm_ManyHashes 9.5 ± 0.2 9.3 9.8 1.09 ± 0.02

Benchmark Results: MstoreBench

Command Mean [ms] Min [ms] Max [ms] Relative
main_revm_MstoreBench 263.6 ± 7.0 258.5 277.2 1.40 ± 0.04
main_levm_MstoreBench 188.0 ± 1.7 185.9 190.2 1.00
pr_revm_MstoreBench 261.6 ± 5.7 256.8 276.8 1.39 ± 0.03
pr_levm_MstoreBench 189.2 ± 1.2 186.7 190.9 1.01 ± 0.01

Benchmark Results: Push

Command Mean [ms] Min [ms] Max [ms] Relative
main_revm_Push 291.2 ± 1.1 289.9 293.5 1.23 ± 0.01
main_levm_Push 238.1 ± 6.4 234.8 255.9 1.01 ± 0.03
pr_revm_Push 292.3 ± 2.4 288.9 297.0 1.24 ± 0.01
pr_levm_Push 236.0 ± 1.1 234.3 238.2 1.00

Benchmark Results: SstoreBench_no_opt

Command Mean [ms] Min [ms] Max [ms] Relative
main_revm_SstoreBench_no_opt 171.3 ± 3.2 164.7 175.0 1.73 ± 0.03
main_levm_SstoreBench_no_opt 99.0 ± 0.2 98.7 99.5 1.00
pr_revm_SstoreBench_no_opt 169.5 ± 2.6 164.6 173.7 1.71 ± 0.03
pr_levm_SstoreBench_no_opt 99.9 ± 2.5 98.9 107.0 1.01 ± 0.03

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

Labels

L1 Ethereum client

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

2 participants