Skip to content

Complete multi-epoch ownership across Autobahn layers (CON-358) - #3929

Open
wen-coding wants to merge 1 commit into
mainfrom
wen/autobahn_multi_epoch
Open

Complete multi-epoch ownership across Autobahn layers (CON-358)#3929
wen-coding wants to merge 1 commit into
mainfrom
wen/autobahn_multi_epoch

Conversation

@wen-coding

@wen-coding wen-coding commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Summary

Completes multi-epoch ownership for Autobahn (CON-358): each layer owns one epoch step and publishes what the next layer consumes.

  • Registry — epochs are road-bounded; NewRegistry seeds 0 and 1 with the genesis committee; SetupInitialEpochs fills placeholders over retained CommitQC history; AdvanceIfNeeded seeds only M+1 at LastRoad(M); ActivateEpoch never overwrites an already-registered index
  • data — admits CommitQCs under their verify-epoch and publishes CommitEpoch (may lead AppQC/Anchor); PushAppHash drives registry advance at epoch boundaries
  • avail — sole live writer of the applied epoch (runEpochAdvance), behind seal + Anchor (prune) leashes and registry presence; recounts lane votes under the applied committee; publishes ConsensusSpec
  • consensus — installs tip CommitQC + next-view epoch from ConsensusSpec (no registry consultation); WAL kept only for same-view votes/QCs when tips match
  • p2p — validator/fullnode EvmProxy shards from data.CommitEpoch (recv handle cached at construction)

ConsensusSpec withhold (restart safety)

At an epoch boundary the durable tip sits on LastRoad(E) while applied is still E. Spec is withheld (previous Spec stands) until the next-view epoch is applied — never publishes a predecessor tip. Walking tip back would roll consensus view backward and discard that view's votes.

On restore, a WAL tip ahead of Spec / durable tip after avail catch-up → ErrAvailBehindConsensus (refuse start). Covered by TestRefreshConsensusSpec_WithholdsTipUntilNextViewEpochApplied and TestRestore_BoundaryCatchUpSpecCoversWAL.

Three epoch concepts

Concept Owner Meaning
Registry placeholders / ActivateEpoch tip epoch.Registry Which roads resolve; placeholders copy genesis committee
CommitEpoch data.State Verify-epoch of latest admitted CommitQC (EVM sharding)
Applied epoch avail.State Lane QC weight, CommitQC ingress, producer membership
ConsensusSpec.Epoch avail → consensus Epoch of the view after the durable CommitQC tip

Test plan

  • go test -race -count=1 ./sei-tendermint/autobahn/types/ ./sei-tendermint/internal/autobahn/... ./sei-tendermint/internal/p2p/ -timeout 20m
  • Spot-check boundary restart: node with WAL tip on LastRoad(0) after avail catch-up installs epoch 1 and republishes Spec
  • Spot-check leave/rejoin: ActivateEpoch into epoch 2+ (epoch 1 is genesis-seeded and must not be overwritten)

Known follow-ups

  • ActivateEpoch has no production caller yet — real committee weights still land via tests / future wiring; placeholders remain genesis copies (registry.go TODO)
  • Future-epoch joiner votes still silently dropped (avail/state.go TODO)
  • Consensus package header still documents WAL-centric recovery; newInner is Spec-first

Made with Cursor

@github-actions

github-actions Bot commented Aug 14, 2026

Copy link
Copy Markdown

The latest Buf updates on your PR. Results from workflow Buf / buf (pull_request).

BuildFormatLintBreakingUpdated (UTC)
✅ passed✅ passed✅ passed✅ passedAug 16, 2026, 10:58 PM

@codecov

codecov Bot commented Aug 14, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 85.63969% with 55 lines in your changes missing coverage. Please review.
✅ Project coverage is 58.55%. Comparing base (d17806f) to head (b705b04).

Files with missing lines Patch % Lines
sei-tendermint/internal/autobahn/avail/testonly.go 66.66% 7 Missing and 8 partials ⚠️
sei-tendermint/internal/autobahn/avail/state.go 82.53% 6 Missing and 5 partials ⚠️
sei-tendermint/internal/autobahn/avail/inner.go 85.50% 6 Missing and 4 partials ⚠️
sei-tendermint/internal/autobahn/data/state.go 86.04% 1 Missing and 5 partials ⚠️
...ei-tendermint/internal/autobahn/consensus/inner.go 84.37% 4 Missing and 1 partial ⚠️
sei-tendermint/internal/p2p/giga/avail.go 57.14% 1 Missing and 2 partials ⚠️
...-tendermint/internal/autobahn/avail/block_votes.go 92.85% 2 Missing ⚠️
sei-tendermint/internal/autobahn/epoch/registry.go 97.36% 2 Missing ⚠️
...ei-tendermint/internal/autobahn/consensus/state.go 93.75% 1 Missing ⚠️
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main    #3929      +/-   ##
==========================================
- Coverage   59.51%   58.55%   -0.97%     
==========================================
  Files        2326     2230      -96     
  Lines      199003   188610   -10393     
==========================================
- Hits       118438   110438    -8000     
+ Misses      69300    67744    -1556     
+ Partials    11265    10428     -837     
Flag Coverage Δ
sei-chain-pr 81.69% <85.63%> (?)
sei-db 70.41% <ø> (ø)
sei-db-state-db ?

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
sei-tendermint/autobahn/types/epoch.go 100.00% <ø> (ø)
sei-tendermint/autobahn/types/proposal.go 92.43% <ø> (ø)
...int/internal/autobahn/consensus/persisted_inner.go 97.50% <ø> (-0.10%) ⬇️
sei-tendermint/internal/p2p/giga_router_common.go 58.71% <ø> (ø)
...ei-tendermint/internal/p2p/giga_router_fullnode.go 45.00% <100.00%> (+1.41%) ⬆️
...i-tendermint/internal/p2p/giga_router_validator.go 93.22% <100.00%> (+0.11%) ⬆️
...ei-tendermint/internal/autobahn/consensus/state.go 89.94% <93.75%> (+1.59%) ⬆️
...-tendermint/internal/autobahn/avail/block_votes.go 92.30% <92.85%> (+1.39%) ⬆️
sei-tendermint/internal/autobahn/epoch/registry.go 88.57% <97.36%> (+18.11%) ⬆️
sei-tendermint/internal/p2p/giga/avail.go 58.21% <57.14%> (+1.79%) ⬆️
... and 5 more

... and 102 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Comment thread sei-tendermint/internal/autobahn/avail/inner.go
seidroid[bot]
seidroid Bot previously requested changes Aug 14, 2026

@seidroid seidroid Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Substantial and generally well-structured rework of multi-epoch ownership across data/avail/consensus, with good test coverage of the new registry, backleash, and vote-recount paths. One blocking issue: the consensus restore path can install an avail ConsensusSpec tip that is behind the persisted WAL tip, rolling the view backwards and discarding the anti-equivocation vote record; several hot-path locking regressions and a lost CommitQC verification also need attention.

Findings: 1 blocking | 12 non-blocking | 8 posted inline

Blockers

  • None at the file/PR level.
  • 1 blocking issue(s) flagged inline on specific lines.

Non-blocking

  • Registry placeholders (makeEpoch) are stamped with the genesis committee, and loadFromBlockDB verifies retained CommitQCs against them. Once real rotation lands, a restart after a membership/weight change would fail QC verification at startup. The // TODO: replace placeholders with execution-derived epochs acknowledges this and ActivateEpoch still has no production caller, so it is not reachable today — but it is worth capturing as a tracked follow-up rather than only a TODO, since the failure mode is "node will not start". (Codex raised this as P1; downgraded here because the rotation path is not wired.)
  • Related: ActivateEpoch overwrites an existing placeholder at index latest+1, but data.qcEntry.epoch, avail.road.epoch and Anchor.Epoch already hold pointers to the placeholder. After a real rotation those stashed pointers and the registry entry for the same index would disagree. Worth an explicit invariant (e.g. reject ActivateEpoch for an index already handed out) before rotation is enabled.
  • Registry.state moved from RWMutex to Watch, so EpochByIndex/EpochAt/LatestEpoch/FirstBlock now take an exclusive lock. WaitForEpoch needs the update signal, but the pure reads could stay on a read path; consider keeping an RWMutex plus a separate notification channel if registry reads show up in profiles.
  • RoadRange.IsLastRoad and GlobalRange.IsLastBlock are added but never used outside epoch_test.go (IsLastBlock has no test at all). Either wire them into the boundary checks that currently open-code idx+1 == r.Next / p.Index() == LastRoad(...), or drop them.
  • No test covers restart with the durable avail tip sitting exactly on LastRoad(M) — the case that triggers the ConsensusSpec walk-back in refreshConsensusSpec. Given that path is the one that produces the view rollback flagged inline, it deserves a direct newInner test.
  • 7 suggestion(s)/nit(s) flagged inline on specific lines.

Comment thread sei-tendermint/internal/autobahn/consensus/inner.go Outdated
return inner{}, err
}
logger.Info("restored consensus state", "state", innerProtoConv.Encode(&persisted))
return inner{persistedInner: persisted, epoch: spec.Epoch}, nil

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[suggestion] The persisted CommitQC is now never signature-verified on restore: persistedInner.validate dropped its cqc.Verify(ep) call, this branch returns persisted.CommitQC (not spec.CommitQC), and TestNewInnerCommitQCInvalidSignatureError was deleted. sameTip only compares indices, so a corrupt or tampered WAL entry at the right index is accepted and then used as justification for the node's next proposal.

Since the two are known to be at the same index and spec.CommitQC is the avail-verified one, installing it here keeps the check without re-verifying:

if sameTip {
    if err := persisted.validate(spec.Epoch); err != nil { return inner{}, err }
    persisted.CommitQC = spec.CommitQC // avail-verified
    ...
}

Worth noting that TimeoutQC.Verify(viewEp, p.CommitQC) justifies against p.CommitQC, so an unverified value there weakens that check too.

n := h.BlockNumber()
for inner, ctrl := range s.inner.Lock() {
applied := inner.epoch.Load()
if !laneProposalAccepted(applied, p) &&

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[suggestion] Block verification moved from outside the lock (the removed Registry().VerifyInWindow(...) call) to inside s.inner.Lock(). laneProposalAccepted runs block.Verify (which hashes the entire payload, including every tx) plus p.VerifySig, and on the || fallback it runs both a second time against anchorEpoch. That is now serialized on the single mutex that also guards PushVote, PushCommitQC, RecvBatch, headers, and the persist loop, for every inbound block from every peer — a meaningful throughput/latency regression and an easy remote stall vector.

The epochs needed for verification (applied, anchorEpoch) can be snapshotted under the lock and the verification done outside it, as PushCommitQC does with waitBackleash + qc.Verify.

Separately: verification failure now return nil (silent drop) where it previously returned fmt.Errorf("block.Verify(): %w", err), so a peer sending unverifiable blocks produces no error at all. The PushVote TODO about returning a meaningful validation error was narrowed to "accept future-epoch joiner votes" — the missing-error concern now applies to both paths and is worth keeping in a TODO.

break
votes := make([]*types.Signed[*types.LaneVote], len(batch))
for i, h := range batch {
votes[i] = types.Sign(r.state.key, types.NewLaneVote(h))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[suggestion] Restructuring the loop to return from inside the Lock() iterator moved types.Sign under the avail inner mutex; previously the break exited the lock scope and signing happened outside it (the removed TODO(gprusak) sat exactly there).

This is worst on the new rewind path: after a localJoinGen bump, clear(r.next) makes the next batch cover every retained block across every lane (up to BlocksPerLane × lanes), so that many signature operations run back to back while holding the lock that gates all block/vote/QC ingest.

Keep the collection under the lock and the signing outside, e.g. assign batch to a variable declared before the Lock() loop and break out as before, or collect into a local and sign after the iterator ends.

return nil, false
}

func (bv blockVotes) header(want types.BlockHeaderHash) (*types.BlockHeader, bool) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[suggestion] header replaces what was an O(1) byHash[want] map lookup with a linear scan that recomputes h.Hash() (protobuf encode + hash) for every retained vote. headers() calls this once per block in a LaneRange, per lane, per road — so it is O(range × committee) header hashes, and it runs while holding the inner mutex.

The reason for the change is sound (byHash no longer holds zero-applied-weight votes, so it can miss headers), but the fix can keep the index: add a byHeaderHash map[BlockHeaderHash]*BlockHeader populated in pushVote unconditionally (independent of weight, so recount never touches it) and look up there.

if anchorEpoch != nil {
// Tip epoch after prune: keep applied at least the anchor's verify-epoch.
if i.epoch.Load().EpochIndex() < anchorEpoch.EpochIndex() {
i.epoch.Store(anchorEpoch)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[suggestion] prune now writes i.epoch directly, which bypasses everything ApplyEpoch does for an epoch change: addLane for the new committee's lanes, recountVotes(ep), and the localJoinGen bump.

Concretely, if anchorEpoch is ahead of applied, after this store laneQC evaluates byHash.weight >= ep.Committee().LaneQuorum() against weights that were accumulated under the old committee, lanes introduced by the new epoch are absent (so PushBlock/PushVote silently drop for them), and a local rejoin is not observed by LaneVotesRecv.

If the intent is only to keep applied from lagging the anchor, routing through the same steps as ApplyEpoch (or calling a shared helper) would make the two paths agree by construction.

Also note this invalidates the comment on line 21: "ApplyEpoch is the sole writer after construction" is no longer true, since prune is also called from runEvict.

return utils.None[*ethrpc.Client]()
}
validator := r.data.Registry().LatestEpoch().Committee().EvmShard(sender)
validator := r.data.CommitEpoch().Load().Committee().EvmShard(sender)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[suggestion] CommitEpoch() takes data.State's inner lock to hand back the subscription, so this acquires the central data-plane mutex on every EvmProxy call — i.e. on every routed EVM request. That lock is also held by QC/block insertion, PushAppHash, and the persist/evict loops.

CommitEpoch() returns a stable AtomicRecv whose Load() is lock-free, so capture it once when the router is constructed and store it on gigaValidatorRouter, then just Load() here. Same applies to giga_router_fullnode.go:68.

epochIdx,
types.RoadRange{First: firstRoad, Next: FirstRoad(epochIdx + 1)},
ep0.FirstTimestamp(),
ep0.Committee(),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[suggestion] Placeholders are built with ep0.Committee(), ep0.FirstTimestamp() and ep0.FirstBlock(), and loadFromBlockDB verifies retained CommitQCs against whatever EpochByIndex returns. That is correct only while every epoch has the genesis committee.

The TODO above ensureLocked(s, windowLast+1) covers the committee, but the timestamp and first-block fields are silently wrong too, and they feed ViewSpec.NextTimestamp()/NextGlobalBlock() for any view resolved against a placeholder. Worth widening the TODO to name all three fields, or having makeEpoch mark the epoch as a placeholder so a consumer that needs a real timestamp/first-block can fail loudly instead of reading genesis values.

@wen-coding
wen-coding dismissed seidroid[bot]’s stale review August 15, 2026 05:35

Fixed, please recheck

Comment thread sei-tendermint/internal/autobahn/epoch/registry.go
@wen-coding

Copy link
Copy Markdown
Contributor Author

@seidroid please re-review

@seidroid

seidroid Bot commented Aug 15, 2026

Copy link
Copy Markdown

Claude finished @wen-coding's task in 5m 13s —— View job


Re-review — wen/autobahn_multi_epoch @ 7f8e261

  • Gather context (diff since previous review commit ec44b37)
  • Re-check blocker: consensus restore installs spec tip behind persisted tip
  • Re-check the 7 inline suggestions
  • Check Cursor's new finding (stale applied epoch after ActivateEpoch)
  • Scan new commits for fresh issues
  • Post updated review

The blocker is fixed. No blocking issues remain. One new finding below plus three carried over.


✅ Resolved since last review

Prior finding Where it landed
[blocker] restore installs a spec tip behind the persisted WAL tip, rolling the view back and dropping the anti-equivocation vote record restoreInner now branches on specViewIdx < persistedViewIdx and keeps the WAL tip + votes (consensus/inner.go:162-174). Covered by TestNewInner_KeepsPersistedTipWhenSpecBehind, which asserts both View().Index == last+1 and that the PrepareVote survives.
Persisted CommitQC never signature-verified on restore newInner re-verifies against the QC's own certify epoch (consensus/inner.go:136-144). Since View.Verify checks both EpochIndex match and RoadRange.Has(Index), a tampered EpochIndex can't steer verification to a friendlier committee. TestNewInner_RejectsUnverifiedCommitQC pins it.
prune writing i.epoch directly, bypassing addLane/recountVotes/localJoinGen prune now only sets anchorEpoch; installEpoch is genuinely the sole writer after construction (avail/inner.go:319-345), and the doc comment on line 26 is accurate again.
types.Sign moved under the avail lock in RecvBatch Split into recvHeaders (under lock) + signing in RecvBatch (avail/subscriptions.go:77-117).
Registry().LatestEpoch() taking the data lock on every EvmProxy call commitEpoch is captured once at router construction; EvmProxy is now a lock-free Load() on both routers.
Original Cursor finding — restart rewinds applied epoch at the boundary newInner floors applied at the durable tip's verify-epoch and then re-drives via advanceEpochs, with AppQCCoversEpoch supplying the exec leash from data when avail's in-memory road AppQCs are empty. TestAdvanceEpochs_BoundaryTipUsesDataAppQC pins exactly that path.

Also worth calling out: myView in newState is now seeded with the restored CommitQC/TimeoutQC instead of a bare ViewSpec{Epoch:} — that was a latent restart bug in its own right.


Non-blocking

1. runEpochSync stops republishing ConsensusSpec while parked in WaitForEpochavail/state.go:698-703 (new this round)

refreshConsensusSpec is documented as having exactly one live caller, and that caller can be parked in a wait that only registry updates can end:

if ei, ok := waitRegistry.Get(); ok {
    if _, err := s.data.Registry().WaitForEpoch(ctx, ei); err != nil { return err }
    continue
}

waitRegistry is set when leashesMet && canAdvance == nil — i.e. all of epoch M's roads are admitted (roads.next >= FirstRoad(M+1)) and exec is satisfied, but M+1 isn't registered yet (AdvanceIfNeeded only fires when execution reaches LastRoad(M)). While parked there, markCommitQCsPersisted can advance persistedCommitQC several roads and bumpEpochWake has no listener, so consensusSpec stays frozen at the older durable tip and consensus sits out the tail views of epoch M.

Reachable when a node admits epoch M's CommitQCs from peers faster than its own WAL flush — the roads.next leash is satisfied by peer QCs, but the spec tracks the durable tip. It self-heals once execution registers M+1, so it's a participation gap rather than a deadlock. The fix is to not drop the epochWake subscription across the registry wait (re-check epochWake.Load() != seen before parking, or wait on both). Fix this →

2. Block verification is still inside the avail lock, and failures are still silentavail/state.go:362-367 (carried over, unaddressed)

main ran this outside the lock; the PR moved it in:

for inner, ctrl := range s.inner.Lock() {
    applied := inner.epoch.Load()
    if !laneProposalAccepted(applied, p) && (… || !laneProposalAccepted(inner.anchorEpoch, p)) {
        return nil
    }

laneProposalAcceptedLaneProposal.VerifyBlock.VerifyPayload().Hash(), which protobuf-encodes and hashes the entire tx payload, plus VerifySig — and both run a second time on the anchorEpoch fallback. That is now serialized on the mutex that also gates PushVote, PushCommitQC, RecvBatch, headers, and the persist loop, for every inbound block from every peer. Snapshotting applied/anchorEpoch under the lock and verifying outside it (the shape PushCommitQC already uses with waitBackleash) keeps the semantics. Separately, the verification failure returns nil where main returned fmt.Errorf("block.Verify(): %w", err); the PushVote TODO that used to cover "return a meaningful validation error" was narrowed to joiner votes, so that concern is now unrecorded on both paths. Fix this →

3. blockVotes.header is an O(committee) scan that recomputes Hash()avail/block_votes.go:75-83 (carried over, unaddressed)

The reason for dropping byHash[want] is sound (it no longer holds zero-applied-weight votes, so it can miss headers), but the index can be kept: a byHeaderHash map[BlockHeaderHash]*BlockHeader populated unconditionally in pushVote is weight-independent, so recount never has to touch it. As written, headers() does O(range × committee) header hashes per road, under the inner lock. Header hashing is far cheaper than block hashing, so this is a real but modest cost.

4. ActivateEpoch — Cursor's new finding is correct but latent, and the argument itself is unguardedepoch/registry.go:124-144

ActivateEpoch overwrites s.m[next], which by then may be a placeholder already handed out by reference to data.qcEntry.epoch, avail.road.epoch, Anchor.Epoch, and avail's applied i.epoch. Those stashed pointers would then disagree with the registry entry for the same index, and since ApplyEpoch is gone there is no path that re-drives avail off the new committee. Cursor's report is accurate on mechanism. It is not reachable today — grep finds no production caller of ActivateEpoch — which is why I'm keeping it non-blocking, but it should be a tracked follow-up rather than only a TODO, because it must be closed before rotation is enabled.

While there: roads types.RoadRange is caller-supplied and never checked against {FirstRoad(next), FirstRoad(next+1)}. backleash uses IndexForRoad (pure arithmetic) while epochForRoad uses ep.RoadRange().Has(road), so a mismatched range silently splits those two views of the same road. Rejecting an index already handed out, and validating roads, are both one-line guards at the choke point every activation passes through.

5. Registry placeholders: the TODO under-describes the problemepoch/registry.go:148-160

makeEpoch copies ep0.Committee(), ep0.FirstTimestamp() and ep0.FirstBlock(). The // TODO: replace placeholders with execution-derived epochs names only the committee, but the timestamp and first-block are wrong too and feed ViewSpec.NextTimestamp() / NextGlobalBlock() for any view resolved against a placeholder. Either widen the TODO to name all three, or mark the epoch as a placeholder so a consumer needing a real timestamp fails loudly instead of silently reading genesis.

6. Nits

  • RoadRange.IsLastRoad and GlobalRange.IsLastBlock are still unused outside tests (IsLastBlock has no test at all), while AdvanceIfNeeded open-codes roadIndex != LastRoad(tipEpoch). Wire them in or drop them.
  • Registry.state on Watch means EpochByIndex/EpochAt/LatestEpoch/FirstBlock take an exclusive lock. Only WaitForEpoch needs the notification; an RWMutex plus a separate signal would keep the reads shared if these show up in profiles.
  • giga/avail.go:127-132: when streamLaneProposalsOnce returns nil because the server's epoch closed the lane but the local a.Lane(peer) still reports the same LaneID, closed is set to None and WaitForNextLane returns immediately — a redial spin until the local epoch catches up. Pre-existing (the old code passed None unconditionally), and this change strictly narrows the window, so purely informational.

Verification notes

I could not run go test or go build in this environment — both were denied by the tool policy, so the findings above are from reading the diff rather than from a green run. The PR's own test plan (go test ./sei-tendermint/autobahn/types/ ./sei-tendermint/internal/autobahn/... ./sei-tendermint/internal/p2p/ -count=1) still needs to be confirmed against 7f8e261. To let me run these on future invocations, add Bash(go test:*) / Bash(go build:*) to --allowedTools.
· branch wen/autobahn_multi_epoch

Comment thread sei-tendermint/internal/autobahn/data/state_test.go Outdated
Comment thread sei-tendermint/internal/autobahn/avail/subscriptions.go
Comment thread sei-tendermint/internal/autobahn/avail/state.go

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit c0a76df. Configure here.

Comment thread sei-tendermint/internal/autobahn/epoch/registry.go
@wen-coding

Copy link
Copy Markdown
Contributor Author

@seidroid please re-review

@seidroid

seidroid Bot commented Aug 16, 2026

Copy link
Copy Markdown

Claude finished @wen-coding's task in 7m 18s —— View job


Re-review — wen/autobahn_multi_epoch @ 952aeca

  • Gather context (diff since previous review commit 7f8e261)
  • Re-check carried-over findings (1–6)
  • Check Cursor's new findings (stale epoch after wait; ActivateEpoch Joined chain; broken fake CommitQC)
  • Scan the 15 new commits for fresh issues
  • Post updated review

No blocking issues. The rework since 7f8e261 is a genuine simplification — backleash/advanceEpochs/epochWake/localJoinGen/AppQCCoversEpoch all gone, replaced by a single leashesMet + nextInstallableEpoch pair and a withhold rule. Five of the six prior findings are closed. Details below.


✅ Resolved since last review

Prior finding Where it landed
1. runEpochSync stops republishing ConsensusSpec while parked in WaitForEpoch markCommitQCsPersisted now calls refreshConsensusSpec (avail/state.go:791-796), and so does runEvict after prune. The doc comment names the reason. runEpochAdvance no longer owns spec publication at all.
4. ActivateEpoch overwrites a placeholder already handed out by reference It now scans forward to the first vacant index and never touches a registered entry (epoch/registry.go:134-152). This also closes Cursor's "Stale applied epoch after ActivateEpoch" and "Stale epoch installed after wait" — the *types.Epoch that runEpochAdvance carries across WaitUntil(leashesMet) can no longer be replaced under it, so re-reading at install time is no longer needed.
4b. roads types.RoadRange caller-supplied and unvalidated Parameter removed; the range is derived as {FirstRoad(next), FirstRoad(next+1)} at the choke point.
5. Placeholder TODO named only the committee Widened to committee + FirstTimestamp + FirstBlock (registry.go:74-75).
6. IsLastRoad / IsLastBlock unused Both deleted along with their tests.

Two more worth calling out that weren't asked for: ConsensusSpec.CommitQC is now a required *CommitQC with the option moved to the channel (Option[ConsensusSpec]), which removes the "spec present but empty" state that the old walk-back produced; and leashesMet's exec leash is now just anchorEpoch >= applied. I checked that the second one isn't a liveness regression: data.setAnchor anchors on first = NextAppQC-1 (data/state.go:845,902-910), i.e. the anchor tracks the latest AppQC'd block, not a deep retention watermark — so it fires on the same event the old appTipCovers || AppQCCoversEpoch pair did, with one source instead of three.

On Cursor's data/state_test.go finding (ProposalAt / "Broken fake CommitQC")

Not reproducible at HEAD. ProposalAt builds its Proposal with newProposal(..., ep.FirstBlock()) and a single one-block LaneRange, so GlobalRange().First == ep.FirstBlock() == inner.nextQC on a fresh state and PushQC's gr.First <= nextQC contiguity check passes. The test even documents this in the comment above the call (data/state_test.go:475-478). The QC is stamped at LastRoad(0) in road index while sitting at the first global block — which is exactly the point, since PushQC gates on global-block contiguity, not road contiguity.


Non-blocking

1. ActivateEpoch derives the next committee from a placeholder, not from LatestEpochepoch/registry.go:142-146 (Cursor's finding, and worse than reported)

next := s.latest + 1
for { if _, ok := s.m[next]; !ok { break }; next++ }
prev := s.m[next-1]
committee, err := prev.Committee().DeriveNext(weights, next)

s.m[next-1] is by construction a makeEpoch placeholder — a genesis-committee copy — whenever next > s.latest+1. DeriveNext chains LaneID.Joined off prev.lanes, so every real activation after the first derives its lane IDs from genesis membership rather than from the last activated committee. Two consequences, and the second is the one Cursor didn't name:

  • a validator that left in a prior activation and rejoins keeps its stale Joined (Cursor's case);
  • a validator that joined in a prior activation and never left gets a brand-new Joined = next, because it isn't in the genesis lane map. Its lane ID changes while its membership didn't, orphaning its retained blocks/votes and making Epoch.IsClosed drop the old lane in avail.prune.

The root tension is that "skip to the next vacant index" and "DeriveNext chains from the previous committee" can't both hold while placeholders occupy the gap. Either derive from s.m[s.latest] and accept that the intervening placeholders disagree, or refuse to activate when next != s.latest+1 and force the placeholder range to be resolved first. Not reachable today — ActivateEpoch still has no production caller — but it must be closed before rotation is enabled, so a tracked issue rather than a TODO. Fix this →

Related and smaller: because NewRegistry now seeds both 0 and 1, the first ActivateEpoch always lands at index ≥ 2. That's presumably intended ("epoch 1 is genesis-seeded and must not be overwritten") but it's an invariant the godoc doesn't state — worth one line, since it means no rotation can ever take effect before epoch 2.

2. Lane-vote cursor rewind on rejoin was removed with nothing replacing itavail/subscriptions.go:76-100 (Cursor's ee565b4 finding — real, but narrower than described)

localJoinGen is gone, so LaneVotesRecv.next never rewinds. Cursor's mechanism holds only if a LaneVotesRecv outlives a leave/rejoin, and it can: serverStreamLaneVotes (p2p/giga/avail.go:59-71) creates one sub per client connection and loops forever, and the client side (clientStreamLaneVotes, line 172) is likewise not epoch-scoped — unlike clientStreamLaneProposals, which re-derives its lane through WaitForNextLane on every iteration. If the connection survives the membership gap, the cursor advances past blocks whose votes the receiving peer drops in PushVote (unverifiable under its applied/anchor committee), and reweightVotes can't recover them because they were never stored in byKey.

In practice the exposure is small — LaneQuorum() is f+1, and a peer that redials gets a fresh cursor starting at bq.first. But the commit message frames this as "rejoins continue from the vote cursor", and nothing in the code or tests establishes that the stream is actually torn down across a rejoin. Either tear the vote stream down on epoch change (mirroring WaitForNextLane on the proposal path) or state the redial assumption in LaneVotesRecv's doc, with a test that exercises RecvBatch while out of committee — the current joiner test avoids that path, which is precisely why it stays green.

3. Block/vote verification is still inside the avail lock, and failure is still silentavail/state.go:349-353, 425-429, 452-464 (carried over, unaddressed)

The refactor into laneAcceptedUnder is a readability improvement, but the callback still runs LaneProposal.VerifyBlock.VerifyPayload().Hash() (protobuf-encode + hash of the entire tx payload) plus VerifySig, and it does so from inside for inner, ctrl := range s.inner.Lock() — twice, when the anchor fallback fires. That is serialized on the one mutex that also gates PushVote, PushCommitQC, RecvBatch, headers, and collectPersistBatch, for every inbound block from every peer. PushCommitQC already demonstrates the shape that avoids it: resolve the epoch outside the lock (waitUntilApplied), verify outside, re-acquire to insert. Here that means snapshotting applied and anchorEpoch under the lock and calling accept outside it.

Separately, a verification failure is still return nil with no log and no error, and the only TODO in the area now reads "accept future-epoch joiner votes" — so "unverifiable block/vote is dropped silently" is unrecorded on both paths.

4. blockVotes.header is still an O(committee) scan that recomputes Hash()avail/block_votes.go:70-78 (carried over)

headers() calls it once per block per lane per road, under the inner lock, and each call hashes every retained vote's header until it matches. The qc caching added this round is a nice win on the LaneQC side; the same trick applies here — a byHeaderHash map[BlockHeaderHash]*BlockHeader filled unconditionally in pushVote is weight-independent, so reweight never has to touch it. Header hashing is much cheaper than block hashing, so this stays modest.

5. Three overlapping guards for one invariant; the last one is now deadconsensus/state.go:142-146

if consTip := s.innerRecv.Load().View().Index; durableNext < consTip {

ViewSpec.View() returns NextIndexOpt(vs.CommitQC) unconditionally for the index (types/proposal.go:159-166 — the TimeoutQC branch only changes Number), and newInner sets CommitQC to either spec.CommitQC or nothing. So consTip == specViewIdx, and newInner already rejected persistedViewIdx > specViewIdx and persistedViewIdx > durableNext before returning. This third check can never fire. Per the repo's own "guard at the choke point, never at each caller", newInner is that choke point — the durableNext parameter and this post-construction check can both go, leaving persistedViewIdx > specViewIdx as the single statement of the invariant.

6. SetupInitialEpochs does four ensure passes where one range would doepoch/registry.go:61-79

ensureAround(s, span.Next) is fully subsumed: when span.Next is a boundary it ensures windowLast and windowLast+1 (both covered by the loop and the trailing ensureLocked); when it isn't, it ensures windowLast and windowLast-1, covered by the loop or by ensureAround(s, span.First). The function's actual contract is "register [windowFirst-1, windowLast+1]" — writing that as one loop would make the +1 the TODO refers to legible without tracing four calls. ensureAround then has no remaining caller, so it goes too.

7. Nits

  • newInner's restart catch-up loop (avail/inner.go:137-143) can install E+1 as soon as the blockDB CommitQC window reaches epoch E, whereas the live path gates on AdvanceIfNeeded firing at LastRoad(E) in execution. The two paths therefore admit different epochs at different times. Invisible today because placeholders are genesis copies; once activation is real, restart could apply a committee earlier than the live path would.
  • runEpochAdvance's got+1 != next check (avail/state.go:694-696) returns an error that tears down the whole avail scope. Since runEpochAdvance is the documented sole writer, this is an assertion on an impossible state — a panic would read more honestly than a node-killing error return, or drop it.
  • epochForRoad's third fallback (avail/inner.go:220-225) is a linear scan over roads on every refreshConsensusSpec, which now runs on every persist flush. Bounded and small, just noting it's on a hotter path than before.
  • SubscribeLaneVotes switched to an unkeyed struct literal (&LaneVotesRecv{s, map[...]{}}, subscriptions.go:68) — keyed fields read better for a struct someone will extend.
  • NewRegistry returns an error it can never produce.

Verification notes

go build and go test were both denied by the tool policy in this environment, so everything above comes from reading the code, not from a green run. The PR's own test plan still needs confirming against 952aeca, and the two boundary tests the description leans on do exist and read correctly (TestRefreshConsensusSpec_WithholdsTipUntilNextViewEpochApplied, avail/inner_test.go:319; TestNextInstallableEpoch_BoundaryTipUsesDataAppQC, line 226). To let me run these next time, add Bash(go test:*) / Bash(go build:*) to --allowedTools.
· branch wen/autobahn_multi_epoch

Give data, avail, and consensus each one epoch step: data publishes
CommitEpoch, avail advances the applied epoch and ConsensusSpec, and
consensus restores from Spec. Seed genesis epochs 0 and 1, withhold Spec
at boundaries instead of walking the tip back, and route EVM sharding
through CommitEpoch.

Co-authored-by: Cursor <cursoragent@cursor.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant