Skip to content

Commit 9954ebf

Browse files
vmarkushinclaude
andcommitted
fix(chain-orchestrator): refill pool + loopback parse; revert startup EL/DB reconciliation (PR review pass 69)
Codex review pass 69 raised two P1s, one P2, and one P3. The two P1s are both on the pass-66..68 startup execution-node/database reconciliation, which has now produced six findings across four passes without converging. Revert the startup EL-vs-DB head reconciliation (the args.rs additions from passes 66, 67, and 68), returning startup to the stable, pre-existing repair-loop behavior. Root cause of the non-convergence: correctly reconciling a persisted L2 head of 0 requires distinguishing a FRESH database (bootstrap against an already-synced execution datadir — adopt the execution node) from a database UNWOUND to genesis (authoritative at 0 — rewind the execution node), and no such freshness signal exists today (`l2_head_block` is always seeded to 0, and `startup_refusal` also treats 0 as bootstrap). Getting this right is a self-contained crash-recovery hardening task (freshness marker, forkchoice push to the execution node with a VALID gate, pool refill, e2e restart coverage) that is disproportionate to this CI-stabilization PR and kept regressing when patched incrementally. It is recorded in .claude/vmark-pr-review-follow-ups.md. This resolves pass-67 P1, pass-68 P1a/P1b, and pass-69 P1a/P1b together. The sign-failure fail-stop that motivated it is retained; restart re-converges via the pre-existing loop with its documented limitations. P2 — refill the transaction pool after the optimistic-head rewind to safe. The recheck's recovery from an INVALID optimistic head (added pass 66) rewinds the engine head down to the safe head but, because it is a target==mirror path, the existing `rewound_backward` collection never ran, so the discarded blocks' transactions were dropped from the pool. Collect them (safe+1 ..= mirror) before the rewind FCU — while those blocks are still canonical — and reinsert on VALID, matching every other rewind seam. (Their L1-message mappings were already cleared when optimistic_sync purged all mappings on entry, so no purge is paired here.) P3 — classify the remote-source plaintext-http loopback exemption by the PARSED address, not a string prefix. `host.starts_with("127.")` treated DNS names like `127.rpc.example` as loopback and silenced the MITM warning; parse the host as an IP and use `is_loopback` (stripping IPv6 brackets), exempting only the exact `localhost` domain. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 3e79c44 commit 9954ebf

3 files changed

Lines changed: 80 additions & 139 deletions

File tree

.claude/vmark-pr-review-follow-ups.md

Lines changed: 34 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -5,26 +5,40 @@ from every pass is either fixed in the PR or recorded here.
55

66
## Unresolved
77

8-
- **No e2e coverage for the startup rewind of an execution node that ran ahead
9-
of the persisted head** (`crates/node/src/args.rs` startup head-repair + the
10-
new post-`Engine::new` forkchoice push)
11-
- Impact/evidence: Codex pass 67 P1 (2026-09-03) found that the pass-66
12-
startup reconciliation only moved the in-memory mirror and the database, and
13-
never pushed `engine_forkchoiceUpdated` to Reth, so a quiescent node would
14-
keep serving — and followers keep importing — the discarded head. FIXED this
15-
pass by issuing a checked FCU to the execution node after the engine is
16-
built whenever startup repair lowered the mirror below the execution node's
17-
live head (also closes the same latent gap in the pre-existing repair loop).
18-
The new path has no automated test: it needs an e2e restart where the
19-
execution node holds a block above the persisted L2 head (an unsigned
20-
sequenced block left by a crash between commit and sign), asserting the
21-
execution node's RPC head is rewound on restart.
22-
- First/most-recent pass: Codex pass 67 (2026-09-03).
23-
- Why unaddressed: exercising it requires a full node restart against a real
24-
execution client with a head deliberately ahead of the database — an e2e
25-
harness addition beyond this review loop's local-fix scope; the production
26-
fix itself is landed and verified by build/clippy.
27-
- Suggested Linear title: "rollup-node: e2e test for startup rewind when the execution node runs ahead of the persisted head"
8+
- **Startup reconciliation of an execution node whose head diverges from the
9+
persisted L2 head is unsolved and was REVERTED** (`crates/node/src/args.rs`
10+
startup head-repair loop; the sign-failure fail-stop in
11+
`crates/chain-orchestrator/src/lib.rs`)
12+
- Impact/evidence: the pre-existing startup repair loop only reconciles the
13+
engine head down to the persisted head while the persisted head sits ABOVE
14+
finalized, and (Codex pass 67) never pushes the resulting forkchoice to the
15+
execution node at all — so a quiescent node keeps serving, and followers keep
16+
importing, a head the node has locally discarded. This bites at least two
17+
concrete cases: (a) a sequenced block committed to the engine but not signed
18+
or persisted, left behind by the sign-failure fail-stop this PR adds, when
19+
the prior block is already finalized (original Codex pass 66 P2); and (b) an
20+
L1/administrative unwind that persists the L2 head at genesis and crashes
21+
before its FCU, where the populated database is authoritative at 0 (Codex
22+
pass 69 P1). Attempts to fix this inside the review loop (passes 66-68) each
23+
surfaced a further edge — pushing the FCU (67), a fresh-DB-vs-authoritative-0
24+
ambiguity that rewinds a bootstrapping node to genesis (68/69), requiring a
25+
VALID answer, and failing on a missing target block (69) — because the fix
26+
needs a reliable "is this database fresh or was it unwound to genesis"
27+
signal that does NOT exist today (`l2_head_block` is always seeded to 0, and
28+
`startup_refusal` also treats 0 as bootstrap), plus a proper transaction-pool
29+
refill and an execution-node forkchoice push, all exercised by an e2e restart
30+
harness. The pass-66..68 additions were REVERTED to the stable pre-existing
31+
behavior to stop destabilizing this CI PR.
32+
- First/most-recent pass: Codex pass 66 P2 (2026-09-03); Codex pass 69 P1x2
33+
(2026-09-03).
34+
- Why unaddressed: correct startup EL-vs-DB reconciliation is a self-contained
35+
crash-recovery hardening task — it needs a database freshness/history marker
36+
(likely a new migration), an execution-node forkchoice push with a VALID
37+
gate, pool refill on the rewind, and e2e restart coverage — disproportionate
38+
to this CI-stabilization PR and repeatedly regressing when patched
39+
incrementally. The sign-failure fail-stop itself is retained (restart
40+
re-converges via the pre-existing loop, with its documented limitations).
41+
- Suggested Linear title: "rollup-node: startup reconciliation when the execution node head diverges from the persisted L2 head (fresh-vs-unwound DB signal, FCU push, pool refill, e2e)"
2842

2943
- **Remote block source has no metrics** (`crates/node/src/add_ons/remote_block_source.rs`)
3044
- Impact/evidence: Claude pass 1 m6 and pass 5 m2 — the add-on exports no

crates/chain-orchestrator/src/lib.rs

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2430,6 +2430,30 @@ impl<
24302430
// (derivation-validated, a finalized-descendant) and latch onto
24312431
// safe, so the next probe adopts it through the normal
24322432
// exit-sync path (which consolidates and persists the head).
2433+
//
2434+
// This is a backward head move (safe < mirror), so — like every
2435+
// other rewind seam — the transactions in the discarded blocks
2436+
// (safe+1 ..= mirror) must be refilled into the pool. Collect
2437+
// them BEFORE the FCU: on VALID it commits the mirror to `safe`,
2438+
// after which the canonical lookups find nothing above it. (The
2439+
// L1-message mappings for those blocks were already cleared when
2440+
// optimistic_sync purged all mappings on entry, so no purge is
2441+
// paired here.)
2442+
let reverted_transactions = match self
2443+
.collect_reverted_txs_in_range(safe.number.saturating_add(1), mirror.number)
2444+
.await
2445+
{
2446+
Ok(txs) => txs,
2447+
Err(err) => {
2448+
tracing::warn!(
2449+
target: "scroll::chain_orchestrator",
2450+
%err,
2451+
"Failed to collect reverted transactions for the pool refill before \
2452+
an optimistic-head rewind to safe; continuing without them"
2453+
);
2454+
Vec::new()
2455+
}
2456+
};
24332457
match self.engine.update_fcs_checked(Some(safe), None, None).await {
24342458
Ok(r) if r.is_valid() => {
24352459
tracing::warn!(
@@ -2439,6 +2463,7 @@ impl<
24392463
"Optimistic head rejected as INVALID; rewound the engine head to the \
24402464
safe head and re-latched onto it"
24412465
);
2466+
self.reinsert_txs_into_pool(reverted_transactions).await;
24422467
self.l2_sync_recheck_target = Some(L2SyncRecheck {
24432468
target: safe,
24442469
latched_from: safe,
@@ -3811,8 +3836,14 @@ mod run_loop_policy_tests {
38113836
engine_client
38123837
.push_fork_choice_updated(ScriptedResponse::Ok(fcu(PayloadStatusEnum::Valid, None)));
38133838
let asserter = Asserter::new();
3814-
asserter.push_success(&Option::<()>::None);
3815-
asserter.push_success(&Option::<()>::None);
3839+
// The rewind to safe collects reverted txs from the discarded blocks
3840+
// (safe+1 ..= mirror = 5 blocks) BEFORE the rewind FCU; answer each
3841+
// getBlockByNumber with a null block so the collection returns empty
3842+
// (the refill mechanics themselves are covered by the backward-adopt
3843+
// tests — here we only assert the rewind and re-latch).
3844+
for _ in 0..5 {
3845+
asserter.push_success(&Option::<()>::None);
3846+
}
38163847
// Optimistic head M (height SAFE) sits ABOVE the derivation-validated
38173848
// safe head S (height SAFE - 5) — the realistic optimistic-sync shape.
38183849
let optimistic_head = info(SAFE, 0x11);

crates/node/src/args.rs

Lines changed: 13 additions & 117 deletions
Original file line numberDiff line numberDiff line change
@@ -249,8 +249,18 @@ impl ScrollRollupNodeConfig {
249249
// fine.
250250
if url.scheme() == "http" {
251251
let host = url.host_str().unwrap_or_default();
252-
let loopback = matches!(host, "localhost" | "127.0.0.1" | "::1" | "[::1]") ||
253-
host.starts_with("127.");
252+
// Classify by the PARSED address, not a string prefix: a prefix
253+
// check treats DNS names like `127.rpc.example` as loopback even
254+
// though they resolve to arbitrary addresses, silencing the very
255+
// warning that matters. `host_str()` brackets IPv6 literals, so
256+
// strip those before parsing; only the exact `localhost` name is
257+
// exempt among domains.
258+
let host_ip =
259+
host.strip_prefix('[').and_then(|h| h.strip_suffix(']')).unwrap_or(host);
260+
let loopback = match host_ip.parse::<std::net::IpAddr>() {
261+
Ok(ip) => ip.is_loopback(),
262+
Err(_) => host.eq_ignore_ascii_case("localhost"),
263+
};
254264
if !loopback {
255265
tracing::warn!(
256266
target: "scroll::node::args",
@@ -582,13 +592,6 @@ impl ScrollRollupNodeConfig {
582592
}
583593
};
584594

585-
// The execution node's live head, before any startup head-repair below
586-
// moves the in-memory mirror. If a repair rewinds the mirror BELOW this,
587-
// the rewind must be pushed to the execution node (see the FCU after the
588-
// engine is built); otherwise only the mirror and database move and the
589-
// execution node keeps serving the discarded head.
590-
let provider_head_number = fcs.head_block_info().number;
591-
592595
let (l1_block_startup_info, mut l2_head_block_number) = db
593596
.tx_mut(move |tx| async move {
594597
// On startup we replay the latest batch of blocks from the database as such we set
@@ -669,55 +672,6 @@ impl ScrollRollupNodeConfig {
669672
l2_head_block_number -= 1;
670673
}
671674

672-
// The loop above only runs while the persisted head sits ABOVE finalized,
673-
// so it never fires when the persisted head is at or below finalized. But
674-
// the execution node's head can sit ABOVE the persisted head there too: a
675-
// sequenced block is committed to the engine BEFORE it is signed and its
676-
// head persisted, so a crash between those steps (e.g. a signing failure
677-
// that fail-stops the node) leaves the engine one block ahead of the
678-
// database. Resuming on that block would sequence on top of a block this
679-
// node never signed or announced — forking it from its peers. Drag the
680-
// engine head back down to the persisted head; the persisted head is at
681-
// or below finalized here, so it is guaranteed present in the EN.
682-
//
683-
// Excluded when the persisted head is 0: a fresh rollup database against
684-
// an already-synced execution datadir is a legitimate bootstrap that
685-
// `startup_refusal` permits, and the zero anchor is NOT authoritative
686-
// there — treating it as such would rewind the execution node to genesis
687-
// (or fail with SafeBelowFinalized when its finalized marker is above 0).
688-
// Bootstrap adopts the provider forkchoice unchanged, exactly as the
689-
// loop above (whose `> finalized` bound is likewise never met at 0) does.
690-
if l2_head_block_number > 0 && fcs.head_block_info().number > l2_head_block_number {
691-
if let Some(block) = l2_provider
692-
.get_block(l2_head_block_number.into())
693-
.full()
694-
.await?
695-
.map(|b| b.into_consensus().map_transactions(|tx| tx.inner.into_inner()))
696-
{
697-
let block_info: BlockInfo = (&block).into();
698-
tracing::warn!(
699-
target: "scroll::node::args",
700-
engine_head = ?fcs.head_block_info(),
701-
persisted_head = ?block_info,
702-
"Execution node head sits above the persisted L2 head at startup; clamping \
703-
it down (a built block was likely committed to the engine without \
704-
completing signing and persistence)"
705-
);
706-
// Clamp safe too if it sits above the head we are resuming from,
707-
// the same rule the loop above and the runtime rewind paths apply.
708-
if fcs.safe_block_info().number > block_info.number {
709-
fcs = ForkchoiceState::new(block_info, block_info, *fcs.finalized_block_info());
710-
} else {
711-
fcs.update(Some(block_info), None, None)?;
712-
}
713-
db.tx_mut(move |tx| async move {
714-
tx.set_l2_head_block_number(l2_head_block_number).await?;
715-
tx.purge_l1_message_to_l2_block_mappings(Some(l2_head_block_number + 1)).await
716-
})
717-
.await?;
718-
}
719-
}
720-
721675
let chain_spec = Arc::new(chain_spec.clone());
722676

723677
// Instantiate the network manager
@@ -738,65 +692,7 @@ impl ScrollRollupNodeConfig {
738692
ctx.task_executor.spawn_task(scroll_network_manager.run());
739693

740694
tracing::info!(target: "scroll::node::args", fcs = ?fcs, payload_building_duration = ?self.sequencer_args.payload_building_duration, "Starting engine driver");
741-
let mut engine = Engine::new(Arc::new(engine_api), fcs);
742-
743-
// If the startup head-repair above rewound the mirror BELOW the
744-
// execution node's live head, the repair so far has only moved the
745-
// in-memory mirror and the database — the execution node still holds the
746-
// discarded head (e.g. an unsigned block committed to the engine by a
747-
// crash between commit and sign, or an EL that simply ran ahead). Push
748-
// and validate the rewind against the execution node now, before
749-
// launching: otherwise a quiescent node keeps serving — and followers
750-
// keep importing — the block this repair discarded, because nothing in
751-
// the run loop reissues a head FCU on an idle chain. Skipped when the
752-
// provider forkchoice could not be read (engine unreachable / genesis
753-
// fallback), since there is no live head to rewind.
754-
if !provider_fcs_missing && engine.fcs().head_block_info().number < provider_head_number {
755-
let head = *engine.fcs().head_block_info();
756-
match engine.update_fcs_checked(Some(head), None, None).await {
757-
Ok(result) if result.is_valid() => {
758-
tracing::info!(
759-
target: "scroll::node::args",
760-
?head,
761-
provider_head_number,
762-
"Rewound the execution node head to the recovered startup head"
763-
);
764-
}
765-
Ok(result) if result.is_invalid() => {
766-
// The execution node actively rejected a head the node
767-
// recovered from its own persisted state — a genuine
768-
// divergence that must not launch.
769-
eyre::bail!(
770-
"execution node rejected the recovered startup head {head:?} as INVALID: \
771-
{:?}",
772-
result.payload_status.status
773-
);
774-
}
775-
Ok(result) => {
776-
// Non-VALID (SYNCING/ACCEPTED): the execution node has NOT
777-
// adopted the rewind. The rewind target is an ANCESTOR the
778-
// execution node already holds, so VALID is the only correct
779-
// answer — anything else means it is in an unexpected state
780-
// (e.g. still snap-syncing). We cannot launch anyway: the
781-
// orchestrator starts L2-Synced with no recheck latch, and
782-
// its periodic recheck only fires while L2 is syncing WITH a
783-
// target, so nothing would reissue this FCU on a quiescent
784-
// node — it would keep serving the discarded head. Fail
785-
// startup so the supervisor restarts and retries.
786-
eyre::bail!(
787-
"execution node did not adopt the recovered startup head {head:?} \
788-
(status {:?}); refusing to launch on the discarded head",
789-
result.payload_status.status
790-
);
791-
}
792-
Err(err) => {
793-
eyre::bail!(
794-
"failed to push the recovered startup head {head:?} to the execution \
795-
node: {err}"
796-
);
797-
}
798-
}
799-
}
695+
let engine = Engine::new(Arc::new(engine_api), fcs);
800696

801697
// Create the consensus.
802698
let authorized_signer = if let Some(provider) = l1_provider.as_ref() {

0 commit comments

Comments
 (0)