Skip to content

fix(e2e): pause the sequencer before the prover suites' epoch warps - #261

Open
spalladino wants to merge 5 commits into
mainfrom
spl/e2e-prover-safe-epoch-warps
Open

spalladino wants to merge 5 commits into
mainfrom
spl/e2e-prover-safe-epoch-warps

Conversation

@spalladino

@spalladino spalladino commented Sep 18, 2026

Copy link
Copy Markdown
Collaborator

Stacked on #253.

#253 stopped the prover fixture's PXEs from breaking when a warp orphans the block they anchored on. It did not stop the warp from orphaning the block. This does.

The remaining problem

FullProverTest runs its setup under PIPELINING_SETUP_OPTS (minTxsPerBlock: 0), so the sequencer pipelines: it builds the next slot's block during the current one and publishes at the target slot. There is normally a built-but-unpublished block in flight. advanceToNextEpoch() then warps 26 slots, moving that block's target slot into the past before its submission completes, and the archiver prunes it as an orphaned proposal:

Pruning blocks after block 5 due to slot 8 not being checkpointed   slotAtNextL1Block: 33

Healthy work, thrown away by the test's own clock.

What this does

The four warps — the fixture's Move to a clean epoch and three in full.test.ts — go through a new SingleNodeTestContext.advanceToNextEpochWithSequencersPaused. It pauses the sequencer (the existing pause(), which halts the poll loop and lets the in-flight iteration and its pending submissions finish untouched), verifies the work landed, warps, and resumes. Only the sequencer-bearing node is paused; the prover-only node keeps running, since it has no sequencer and has to keep tracking L1 across the warp.

pause() returning is not proof of publication. CheckpointProposalJob.finish() drains with Promise.allSettled, so its return means the submissions settled, not that they succeeded. Before the clock moves, every block the sequencer proposed — the proposed tip sampled at entry, plus every block-proposed seen while draining — must be shown to sit in the checkpointed chain under the same hash, with a bounded wait and an error naming what is outstanding and which sequencer failures fired during the drain. Comparing the proposed and checkpointed tips would not do: a prune makes them equal by deleting the very blocks in question.

Any non-benign sequencer failure recorded while draining also blocks the warp, checked both when the pause returns and after the history check. Warping on top of an unhealthy chain buries the cause under the prune that follows, and only one of the four warp sites sits inside a test-level assertNoFailuresFromSequencers.

opts.timeout (default 120s) bounds the drain and the verification separately. On failure the sequencer is resumed anyway, but a resume error is logged rather than thrown so it cannot replace the reason the advance was unsafe; a drain that never finished leaves the sequencers down instead, rather than racing a restart against submissions still in flight.

block-proposed fires before the proposal reaches the archiver, so the proposed tip is sampled on both sides of the drain. A block that emits in the instant before the helper subscribes and is pruned before the post-drain sample is still uncovered; the doc comment says so.

Checkpoint health is now asserted, not incidental

#253's tradeoff was that checkpointed anchoring removes the incidental way a stalled sequencer used to surface in these suites. The transfer test now says it outright: watchSequencerEvents() over the whole test, drain included, and assertNoFailuresFromSequencers() at the end. Publish failures, checkpoint errors, header-validation failures and discarded pipelined checkpoints fail the test; "insufficient transactions" stays allowed, and there is no exception carved out around the warp. The existing checkpointed receipt waits and L1 proof assertions are untouched, and no new checkpoint-progress waits were needed — the transfers already wait to TxStatus.CHECKPOINTED.

The reward and proven-checkpoint baselines move ahead of the warp. Draining takes at least a slot, and a proof landing during it would fold the very increase expect(newProvenCheckpointNumber).toBeGreaterThan(oldProvenCheckpointNumber) looks for into the "before" value. epoch is still read before the advance, so the reward assertions stay pinned to the epoch the txs landed in even if the drain crosses a boundary.

Also in this PR

watchSequencerEvents returns a stop() so a watch can be scoped to an interval instead of leaking listeners into later phases, and its failure-event list moves to a shared module constant — which drops a duplicated checkpoint-publish-failed that had been registering two listeners for the same event.

Testing

New single-node/sequencer/safe_epoch_warp.test.ts, two cases, both on a checkpointed-tip PXE:

  • an in-flight proposal survives the advance. Waits for a published checkpoint, then for the next block to be in flight, advances, and asserts that block is still the block at that height under the same hash inside a published checkpoint, with no sequencer failures.
  • the advance fails when a proposal never reaches L1. maxSpeedUpAttempts: 0 / cancelTxOnTimeout: false so a dropped tx stays dropped, then sequencerDelayer.cancelNextTx() and a wait until a publication has actually been dropped — arming alone is racy, since the in-flight tx may already be broadcast and the pause could halt the loop before another is sent. The advance rejects — and the wallet's PXE goes on syncing happily to a block that predates the dropped publication, which is precisely why the health check has to be explicit rather than inferred from anchoring.

Red/green on the first case, by swapping the helper for the plain cheatCodes.rollup.advanceToNextEpoch(). With a 60s window to publish, the in-flight block never comes back — the height is taken by a different block:

expect(received).toEqual(expected)
Expected: "0x0e50d9ae37cbe37b94a9f3f20fedb24efe6b2d1821c5c8250048d0d306dfb1f9"
Received: "0x10ec7a72884ede1f1a6d488eba52a8cd23225dd79904f98e63594636c00f9ca2"

With the helper, both cases pass (28s and 36s).

Run locally with fake proofs: single-node/prover/server/full 4/4, single-node/prover/client/client 1/1, bench/tx_stats_bench 3/3 (1 skipped), single-node/sequencer/safe_epoch_warp 2/2. Typecheck and prettier clean.

Not run locally: real proofs (FAKE_PROOFS=0) — no bb toolchain in this worktree — so full.test.ts's invalid-proof and ddos cases (which include a fourth warp) only ran as no-ops. CI is the verification for those.

Out of scope

PXE re-anchoring during an operation is F-895 and is not touched here.

FullProverTest extends SingleNodeTestContext but overrides `setup` and calls the raw
`setup(0, {...})` with no PXE options argument, so it never picked up the base's
`{ syncChainTip: 'checkpointed' }` default and both of its PXEs ran on the proposed tip.

The fixture calls `advanceToNextEpoch()`, which warps L1 by a full epoch while the
pipelining sequencer has an uncheckpointed block in flight. The archiver then prunes that
block as an orphaned proposal, which is correct: its slot closed without a checkpoint. But
the PXE had already anchored an operation to it, and when the prune lands between that
operation's pre-sync and its last anchored read the node no longer serves the hash, so the
operation fails with "Reference block not found ... possibly a reorg has occurred".

Anchoring to the checkpointed tip is not what detects a stalled checkpointer in these
suites: `send().wait()` already defaults to `TxStatus.CHECKPOINTED`, and the suites assert
on `waitForProven` and the L1 proven checkpoint number. Those instruments are unaffected.

Also makes the prover node stop optional during teardown, so a setup failure before the
node exists surfaces as itself rather than as a TypeError from the teardown cascade.
`FullProverTest` warps a full epoch between phases. Under pipelining there is
normally a built-but-unpublished block in flight, and the warp moves its target
slot into the past before its submission lands, so the archiver prunes it as an
orphaned proposal. Anchoring the PXEs on the checkpointed tip stops that from
breaking a PXE operation, but it does not stop the warp from throwing the block
away.

The four warps (the fixture's and three in `full.test.ts`) now go through
`advanceToNextEpochWithSequencersPaused`, which pauses the sequencer, lets its
current iteration and pending L1 submissions finish, proves the work landed, and
only then moves the clock. `pause()` drains with `Promise.allSettled`, so its
return says the submissions settled, not that they succeeded: every block the
sequencer proposed — the tip as of entry plus everything proposed while draining
— must be shown to sit in the checkpointed chain under the same hash first.
Comparing proposed and checkpointed tips would not do, since a prune makes them
equal by deleting the blocks in question. The prover-only node keeps running; it
has no sequencer and has to keep tracking L1 across the warp.

Checkpoint health is now asserted outright in the transfer test rather than left
to surface incidentally: `watchSequencerEvents` over the whole test, drain
included, and `assertNoFailuresFromSequencers` at the end. The reward and
proven-checkpoint baselines move ahead of the warp, since draining takes at least
a slot and a proof landing during it would fold the expected increase into the
"before" values.

`watchSequencerEvents` gains a `stop()` so a watch can be scoped to an interval,
and its failure-event list moves to a shared constant (dropping a duplicate
`checkpoint-publish-failed` that registered two listeners).

New `single-node/sequencer/safe_epoch_warp` covers both directions: an in-flight
proposal survives the advance with its hash intact, and dropping the sequencer's
next L1 tx makes the advance fail while the checkpointed-tip PXE keeps syncing
happily — which is exactly why the health check has to be explicit.
@greptile-apps

greptile-apps Bot commented Sep 18, 2026

Copy link
Copy Markdown

RetriggerConfidence Score: 4/5

The core single-sequencer warp behavior appears sound, but the PR should not merge until the explicit TypeScript cast and structured-logging requirements are satisfied; its cleanup and failure diagnostics should also be corrected.

Fix All in CodexFindings

  1. P2 Failure serialization masks diagnostics
  2. P2 Existing proposals can escape checks
  3. P2 Watcher cleanup is not guaranteed
  4. P2 Unchecked event casts violate guidance
  5. P2 Logs omit structured context
Summary

This PR introduces a guarded epoch-warp flow for prover end-to-end tests. It pauses sequencers, drains and verifies their proposed blocks against the checkpointed chain, advances the epoch, and resumes sequencing.

  • Routes the prover fixture and three full-prover warps through the guarded helper.
  • Adds explicit sequencer-health observation and listener teardown support.
  • Adds success and failed-publication coverage for the safe-warp behavior.
  • Preserves reward and proven-checkpoint baselines across the new drain interval.
Diagram
%%{init: {'theme': 'neutral'}}%%
flowchart TD
  A[Observe current and new proposals] --> B[Pause sequencer poll loops]
  B --> C[Wait for every proposed block hash to be checkpointed]
  C -->|All verified| D[Advance to next epoch]
  C -->|Missing or replaced| E[Reject unsafe warp]
  D --> F[Resume sequencers]
  E --> F
Loading

Reviews (1) · Last reviewed commit: "fix(e2e): pause the sequencer before the..."

`Refusing to warp: the sequencers drained without every proposed block reaching the checkpointed chain, so the warp would orphan them.`,
outstanding.length > 0 ? `Outstanding: ${outstanding.join('; ')}.` : undefined,
`Cause: ${err}.`,
`Sequencer failures while draining: ${failEvents.length === 0 ? 'none' : JSON.stringify(failEvents)}.`,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Failure serialization masks diagnostics

A captured checkpoint-publish-failed event can contain a viem TransactionReceipt with bigint fields. If a proposal remains uncheckpointed, JSON.stringify(failEvents) then throws while building this error, replacing the intended Refusing to warp message and its outstanding-proposal details with a serialization TypeError. Use a bigint-safe representation for these events.

Fix in Codex Fix in Claude Code


// Sampled before the pause: a block proposed before this call started listening is still in flight
// and must survive the warp just the same.
const { proposed } = await node.getChainTips();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Existing proposals can escape checks

The helper collects new proposals from every sequencer in nodes, but samples pre-existing proposals from only the single node argument. Because this public helper accepts multiple sequencer-bearing nodes, another node can already have an unpublished proposal that is never added to proposals. The validation can then pass before the warp moves that proposal's target slot into the past. Seed the proposal set from every node or restrict the API to its supported single-node topology.

Fix in Codex Fix in Claude Code


// Anchoring the PXEs on the checkpointed tip removes the incidental way a stalled sequencer used
// to surface here, so checkpoint health is asserted outright over the whole test, drain included.
const watch = t.watchSequencerEvents(t.getSequencers(t.nodes));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Watcher cleanup is not guaranteed

This describe-scoped watcher is stopped only after every preceding await and assertion succeeds. If the test exits earlier, its listeners remain attached to the sequencer reused by later tests, where they continue collecting and logging unrelated events. The helper has a similar gap because it installs listeners before the awaited getChainTips() call but enters its cleanup finally afterward. Put both watcher lifetimes behind guaranteed cleanup such as a disposable scope or finally.

Fix in Codex Fix in Claude Code

* started rather than before.
*/
function isBenignSequencerFailure(eventName: keyof SequencerEvents, args: unknown): boolean {
return eventName === 'block-build-failed' && (args as { reason?: string }).reason === 'Insufficient valid txs';

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Unchecked event casts violate guidance

The changed event handling uses unchecked as Type assertions both when reading the failure payload here and when unregistering the listener. The repository TypeScript directive requires type guards instead of as Type casts. Narrow the block-build payload with a guard and preserve the listener's concrete event type without asserting it; this repository requirement must be satisfied before merging.

Context Used: yarn-project/CLAUDE.md (source)

Fix in Codex Fix in Claude Code


let advanced = false;
try {
this.logger.warn(`Pausing ${sequencers.length} sequencers before advancing to the next epoch`);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Logs omit structured context

This log interpolates the sequencer count without a structured second argument. The same pattern appears in the new resume log and in the safe-warp test's proposal log. The repository logging directive requires dynamic values in structured context objects so they remain filterable. Update these new logs accordingly before merging.

Context Used: yarn-project/CLAUDE.md (source)

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Codex Fix in Claude Code

…e drain

Review follow-ups on the safe epoch advance.

The helper only used the failure events it recorded to decorate an error message,
so a `checkpoint-error` or publication failure during the drain passed silently
whenever the tracked blocks happened to checkpoint (or none were recorded at
all). Only the transfer test had an outer assertion, and none of the other warp
sites did. Non-benign failures now block the warp outright, before and after the
checkpointed-history check — warping on top of an unhealthy chain buries the
cause under the prune that follows.

`block-proposed` fires before the proposal reaches the archiver, so a block built
in the instant before the helper subscribed appeared in neither the event stream
nor the pre-pause tip sample. The proposed tip is now sampled on both sides of
the drain. A block already pruned by then is still uncovered; the doc comment
says so.

`opts.timeout` only bounded the verification, leaving the drain itself to the
enclosing jest timeout with no useful diagnostic. It now bounds `pause()` too,
and a drain that does not finish leaves the sequencers down rather than racing a
restart against submissions that are still in flight.

The failure-injection case armed `cancelNextTx()` and advanced immediately, which
is racy: the in-flight proposal's tx may already have been broadcast, and the
pause could then halt the loop before another was sent, leaving the delayer armed
and nothing dropped. It now waits until a publication has actually been dropped.

Also: both watches move under `afterEach` so the listeners are detached when a
test throws before its assertion, and the transfer test reads its three baselines
in one batch so a proof cannot land between them.
Two review follow-ups, both wording.

The drain-failure check makes this helper the wrong tool for a recovery scenario,
where `proposer-rollup-check-failed` (transient archiver mismatch) and
`pipelined-checkpoint-discarded` (an unexpected parent arriving) are part of the
behavior under test rather than a fault. Say so before someone reuses it there.

The "leaving sequencers paused" log also fired when the pause had never started
(a failed tip query), which reads as a drain that hung. It now says the pause did
not complete, which covers both.
@spalladino
spalladino added this pull request to stack #265 September 18, 2026 19:54
eslint's no-unsafe-finally rejects a throw inside finally, since it would discard
an in-flight exception. Capture the resume failure and rethrow it after the block:
when the advance itself failed, its error propagates out of the finally first, so
the resume failure still cannot mask it.
Base automatically changed from spl/fix-e2e-prover-pxe-anchoring to main September 18, 2026 22:13
@spalladino
spalladino removed this pull request from stack #265 September 18, 2026 22:13
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