Skip to content

(Autobahn) dial peers from the live commit-epoch committee (CON-358) - #4086

Open
wen-coding wants to merge 3 commits into
mainfrom
wen/dynamic_epoch_peers
Open

(Autobahn) dial peers from the live commit-epoch committee (CON-358)#4086
wen-coding wants to merge 3 commits into
mainfrom
wen/dynamic_epoch_peers

Conversation

@wen-coding

Copy link
Copy Markdown
Contributor

Summary

  • Dial outbound giga and EVM RPC from the live commit-epoch committee instead of the static address book. Departing members are cancelled, then awaited, before the slot is reused.
  • Inbound giga role is fixed at accept: a membership change closes the connection so the peer's dialer reconnects into the role it now has. The inbound fullnode cap is still decided at accept.
  • Skip republishing NextCommitEpoch when the epoch index is unchanged so watchers do not thrash on every QC.

Linear: CON-358

Test plan

  • cd sei-tendermint && GOWORK=off go test ./internal/p2p/ -count=1 -run 'TestGigaRouter'
  • cd sei-tendermint && GOWORK=off go test ./internal/p2p/... -count=1
  • Confirm a validator that leaves the committee is dropped from outbound giga/EVM and, if it redials, is admitted as a capped block-sync inbound
  • Confirm a fullnode block-sync connection is not dropped on an epoch tick that does not change the committee

Made with Cursor

…tic address book.

Inbound giga role is fixed at accept: a membership change closes the connection so the peer's dialer reconnects into the role it now has. Skip republishing an unchanged next-commit epoch so watchers do not thrash every QC.

Co-authored-by: Cursor <cursoragent@cursor.com>
@wen-coding wen-coding changed the title Dial Autobahn peers from the live commit-epoch committee, not the static address book (CON-358) (Autobahn) dial peers from the live commit-epoch committee (CON-358) Sep 2, 2026
@github-actions

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown

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

BuildFormatLintBreakingUpdated (UTC)
✅ passed✅ passed✅ passed✅ passedSep 3, 2026, 7:18 AM

@codecov

codecov Bot commented Sep 2, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 71.00592% with 49 lines in your changes missing coverage. Please review.
✅ Project coverage is 60.26%. Comparing base (7273a43) to head (ad3ab06).
⚠️ Report is 1 commits behind head on main.

Files with missing lines Patch % Lines
...ei-tendermint/internal/p2p/giga_router_fullnode.go 3.33% 29 Missing ⚠️
sei-tendermint/internal/p2p/giga_router_common.go 87.73% 9 Missing and 4 partials ⚠️
sei-tendermint/node/setup.go 0.00% 5 Missing ⚠️
...i-tendermint/internal/p2p/giga_router_validator.go 87.50% 1 Missing and 1 partial ⚠️
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main    #4086      +/-   ##
==========================================
- Coverage   61.30%   60.26%   -1.05%     
==========================================
  Files        2178     2070     -108     
  Lines      190788   178403   -12385     
==========================================
- Hits       116968   107512    -9456     
+ Misses      62796    60908    -1888     
+ Partials    11024     9983    -1041     
Flag Coverage Δ
sei-chain-pr 73.18% <71.00%> (?)
sei-db 69.80% <ø> (ø)
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/config/config.go 82.85% <ø> (ø)
sei-tendermint/internal/autobahn/data/state.go 77.61% <100.00%> (+0.83%) ⬆️
sei-tendermint/libs/utils/mutex.go 97.97% <100.00%> (+0.22%) ⬆️
...i-tendermint/internal/p2p/giga_router_validator.go 90.32% <87.50%> (-2.90%) ⬇️
sei-tendermint/node/setup.go 58.38% <0.00%> (ø)
sei-tendermint/internal/p2p/giga_router_common.go 65.58% <87.73%> (+7.24%) ⬆️
...ei-tendermint/internal/p2p/giga_router_fullnode.go 32.75% <3.33%> (-12.25%) ⬇️

... and 110 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.

seidroid[bot]
seidroid Bot previously requested changes Sep 3, 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.

Moving outbound giga/EVM dialing onto the live commit-epoch committee is a solid restructuring with good unit coverage of runPerCommitteeMember / runUntilMembershipChange. However, the inbound half of the design does not actually work: on a membership change RunInboundConn stops serving but never closes the connection, because server.Run is a main task in the same scope that is waiting for it.

Findings: 1 blocking | 2 non-blocking | 3 posted inline

Blockers

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

Non-blocking

  • None at the file/PR level.
  • 2 suggestion(s)/nit(s) flagged inline on specific lines.

}
return nil
}
changed, err := r.runUntilMembershipChange(ctx, v, isCommittee, func(ctx context.Context) error {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[blocker] A membership change does not close the inbound connection, so the documented mechanism ("a membership change ends it, and the peer's dialer reconnects into the role it now has") does not happen.

server.Run is spawned a few lines above with s.Spawn, i.e. as a main task of this scope. scope.Run does s.Spawn(main); s.main.Wait(); s.cancel() — the scope context is only cancelled after every main task returns. So when runUntilMembershipChange cancels its own (inner) scope and RunServer returns, this main function returns nil and then scope.Run blocks in main.Wait() on server.Run, which itself only returns when the mux ctx is cancelled or the socket dies. The inner scope's s.Cancel(nil) is on a descendant context and cannot reach it.

Concretely: validator V (in the address book) is accepted as a committee peer; V leaves the committee. We log "inbound giga peer changed committee membership; closing", tear down all Serve loops, and then sit on a live TCP connection that serves nothing. router.go applies no timeout to RunInboundConn. The peer only notices via clientPing's 10s interval + 5s timeout, then redials, and only that redial (evicting the entry from poolIn) unblocks us. For the reverse case — a fullnode-slot peer that joins the committee — the stale connection also keeps holding its inboundFullnodeCap slot (defer r.inboundFullnodeCount.Add(-1) runs only once InsertAndRun returns) while the replacement connection takes a second one.

The outbound path does not have this problem because there the cancellation always arrives from an ancestor context.

Minimal fix: spawn the mux as a background task —

s.SpawnBg(func() error { return server.Run(ctx, hConn.conn) })

SpawnBg still calls s.Cancel(err) on failure, so the existing "connection died" path is unchanged, but background tasks are cancelled once the main tasks return, which closes the conn immediately on a membership change. Worth a test that asserts the inbound conn actually terminates after the committee flips — the current tests cover runPerCommitteeMember/runUntilMembershipChange in isolation but not RunInboundConn.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

done

}
taskCtx, cancel := context.WithCancel(ctx)
done := make(chan struct{})
live[validator] = &memberSession{cancel: cancel, done: done}

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] Entries are only removed from live by stopDepartingMembers, i.e. when the validator leaves the committee. If a committeeMemberTask ever returns on its own (all tasks in the member scope returning ends the session), done closes but the entry stays in live, so that member is treated as running forever and is never restarted while it remains in the committee — a silent loss of the connection and EVM proxy for that peer.

Today's two tasks (runCommitteePeer, runEvmProxy) only return context errors, so this is latent, but committeeMemberTask is a general extension point whose godoc doesn't state the contract. Either say explicitly on committeeMemberTask that a task must run until its context is cancelled, or reap finished sessions (e.g. drop entries whose done is already closed at the top of each iteration).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

added doc

})
}

// runCommitteePeer maintains an outbound connection to a committee member.

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 rationale that used to sit next to this code — why self disables GetBlock, and why the comparison is against r.key.Public() (a NodePublicKey) rather than validatorKey — is still parked as an inline block above s.SpawnNamed("committeeMembers", ...) in Run, several lines away from the getBlock := addr.Key != r.key.Public() it explains. Per AGENTS.md ("the step name carries the what, the doc comment carries the why"; extracting a step means moving its rationale to that step's doc comment), that paragraph belongs on runCommitteePeer, leaving Run reading as a plain list of named steps.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

done

…for the peer.

Spawn the mux as a background task so the inbound scope can cancel it; document that committee-member tasks run until cancel.

Co-authored-by: Cursor <cursoragent@cursor.com>
@wen-coding

Copy link
Copy Markdown
Contributor Author

@seidroid re-review please

@seidroid

seidroid Bot commented Sep 3, 2026

Copy link
Copy Markdown

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


Re-review — all three findings are addressed

  • Fetch PR diff and identify what changed since the last review
  • Verify blocker fix: SpawnBg actually closes the inbound mux on membership change
  • Verify the two suggestion fixes
  • Re-review the rest of the diff (epoch publish gating, fullnode subscriber ring, inbound cap accounting)
  • Post findings

Verdict: the blocker is genuinely resolved. Two small non-blocking items below; nothing that should hold the PR.


Blocker — resolved, traced end to end

sei-tendermint/internal/p2p/giga_router_common.go:658 now spawns the mux with s.SpawnBg. I followed the full teardown chain rather than taking the comment's word for it:

  1. scope.Run (libs/utils/scope/start.go:143) is Spawn(main); main.Wait(); cancel(); all.Wait() — background tasks are cancelled once the main tasks return, so the membership change now reaches server.Run's ctx instead of deadlocking against it.
  2. Mux.RunrunSend/runRecv return on that ctx.
  3. The socket really closes: inbound, router.go:207 holds defer tcpConn.Close() and fires as soon as RunInboundConn returns; outbound, tcp.Conn.Run (libs/utils/tcp/tcp.go:103-105) does <-ctx.Done()c.conn.Close().

The "connection died" path is unchanged: SpawnBg still calls s.Cancel(err), the error wins errOnce, and changed stays false so the "changed committee membership; closing" line is not logged on a socket death. The inboundFullnodeCap slot is also released promptly now, since defer r.inboundFullnodeCount.Add(-1) is tied to RunInboundConn returning.

The two suggestions are addressed as well: committeeMemberTask now carries the run-until-cancelled contract (giga_router_common.go:519), and the fan-out/self-GetBlock rationale moved onto runCommitteePeer, leaving Run as a plain list of named steps.

I also re-checked the parts I had not chased on the first pass and found them sound:

  • publishNextCommitEpoch's new index gate is safe. nextCommitEpoch is initialised with the genesis epoch (data/state.go:301), never a nil *Epoch, so Load().EpochIndex() cannot panic; and within one epoch.Registry an index determines the committee, so equal indexes really do mean an unchanged committee.
  • No bootstrap regression from dropping the static address book as the dial source: Store.ReadSuffix scans backwards until it has both a CommitQC and an AppQC, so any non-empty store replays at least one QC through advanceCommitRoad and publishes the real epoch before the router subscribes. Only a genuinely empty store starts on the genesis committee, which is correct.
  • runPerCommitteeMember doesn't accumulate: finished member tasks call main.Done(), and stopDepartingMembers awaits done before the validator can be re-added in a later iteration, so a rejoin can't race the departing session's poolOut slot.

Non-blocking

1. The contract godoc states the mechanism backwardsgiga_router_common.go:519

returning earlier leaves the member unmarked in live

It's the opposite: an early return leaves the entry in live (nothing removes it except stopDepartingMembers), and that is precisely why the member is never restarted. As written, a reader who trusts the comment would look for the missing-entry bug that isn't there. Per AGENTS.md the godoc should state what the contract is — "must run until ctx is cancelled" — and leave the map state out of it. Fix this →

2. The fixed half is still the untested half. The new tests cover runPerCommitteeMember and runUntilMembershipChange well (including the cancel-all-then-await ordering and both flip directions), but RunInboundConn — the function the blocker was in — has no test that the connection actually terminates after the committee flips. Codecov agrees: giga_router_fullnode.go patch coverage is 0%. A test that flips nextCommitEpoch and asserts RunInboundConn returns would pin the SpawnBg-vs-Spawn distinction, which is the kind of thing a later refactor silently reverts. Fix this →


One design consequence worth tracking

The TODO: support committee members absent from the address book at giga_router_common.go:632 is now load-bearing in a way it wasn't before. Previously a committee member missing from ValidatorAddrs was invisible, because you dialed the whole book anyway. Now runPerCommitteeMember logs committee member has no configured address; not dialing (giga_router_common.go:573) and skips it — so on a rotation that adds a validator an operator hasn't put in the static book, that peer is simply never dialed. That's a consensus-liveness surface, not just a log line, and it repeats on every committee change. Worth making sure the follow-up that sources addresses from the epoch is tracked alongside CON-358.


Not verified: go test, go vet and gofmt all require approval in this environment, so this review is static analysis only — please make sure the PR's own test-plan checkboxes get run.
· branch wen/dynamic_epoch_peers

if err := utils.Sleep(ctx, r.cfg.DialInterval); err != nil {
return err
for {
ep := r.nextCommitEpoch.Load()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

what happens if this node stops and restarts when the committee view has changed, and all it knows are stale validators? i.e.

  • epoch 1, committee {A, B}
  • node stops
  • epoch 2, committee {C}
  • node starts up, only will try committee {A, B}

(ffti if this edge case is addressed in future work)

@wen-coding wen-coding Sep 3, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Yes, right now if epoch 1 and epoch 2 have no overlap in committee, this validator will get stuck. I don't know whether we need to handle this case though, because that should be very rare in reality.

As long as there is one member in both epoch 1 and epoch 2 which can serve QCs, this guy will eventually reach epoch 2 and talk with the correct guys.

}()
return r.nextCommitEpoch.Iter(ctx, func(_ context.Context, epoch *atypes.Epoch) error {
committee := epoch.Committee()
if err := stopDepartingMembers(ctx, live, committee); err != nil {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

will this cause issues if enough validators change at epoch boundary? specifically because of the lag between consensus and execution, i think we run into an issue if 33%+ validator state changes between epochs, because we immediately disconnect from those 33%+ validators but we still need them to reach AppQC. specifically

  • epoch 1, committee A,B,C, we know that next commitee is C,D,E
  • (disconnect from A,B, but still finalizing App state, we can't reach quorum because we need AppVotes form A,B)
  • (unreachable) epoch 2, committee C,D,E

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Nice catch, I should disconnect members in epoch 1 only when AppQC has been received, changed

}
isCommittee := false
if v, ok := validator.Get(); ok {
isCommittee = r.nextCommitEpoch.Load().Committee().HasReplica(v)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

would this mean if the val is not caught up then inbound will also be refused? (might be okay but want to check if it's intentional)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Yes. If we can't confirm you are in the committee we won't accept your inbound connections as a validator, just to be safe. If there is fullnode capacity you can still stream blocks/QCs as a fullnode; that leaves a small door so joiners can catch up.

…commit.

Dropping N-1 members as soon as nextCommitEpoch advances cuts AppVote
paths that are still owed. Dial and prune the union of the commit and
Anchor committees, and only prune once Anchor is present and at most one
epoch behind.

Co-authored-by: Cursor <cursoragent@cursor.com>
@wen-coding

Copy link
Copy Markdown
Contributor Author

@seidroid re-review please

@seidroid

seidroid Bot commented Sep 3, 2026

Copy link
Copy Markdown

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


Re-review — the Anchor gate holds up

  • Fetch the diff and identify what changed since my last review
  • Trace the new commit ad3ab06 against @philipsu522's AppQC-quorum concern
  • Re-check WaitEither, the rewritten runPerCommitteeMember loop, and session lifecycle
  • Re-check the previously flagged items and the fullnode ring
  • Post findings

Verdict: no blockers. The keepReplicas union + Anchor gate is a correct fix for the AppVote window, and I traced it rather than taking the commit message's word for it. Three non-blocking items below, one of them substantive.


What I verified

The gate is exhaustive, not just conservative. stopDepartingMembers (giga_router_common.go:555) returns early whenever commitEpoch > anchor.Epoch+1, so the only states in which pruning runs are anchor == commit and anchor == commit-1 — and keepReplicas covers both committees in the union. There is no reachable state where an intermediate epoch's committee gets dropped while its AppVotes are still owed. That's the property that matters, and it's the reason the >+1 gate has to be there rather than just unioning.

Anchor's semantics actually support the claim. setAnchor (data/state.go:993) stores the row at inner.first, and the persist loop pins first = persisted.NextAppQC - 1 (state.go:926), so anchor.Epoch is the epoch of the newest AppQC'd row. anchor.Epoch == commitEpoch therefore genuinely means every epoch-N row already has an AppQC and nothing more is owed from C(N). Anchor also never regresses to None and only advances on persist, so a stalled node fails in the "keeps too many peers" direction — the safe one.

WaitEither (libs/utils/mutex.go:130) is race-free. Both updated channels are loaded before pred(), matching atomicWatch.Wait's ordering: a Store landing between the load and pred is seen by pred; one landing after closes a channel already held. And the loop's pred compares against snapshots taken at the top of the iteration, before stopDepartingMembers blocks, so a change during that await is caught by the first pred evaluation — no missed wakeup. Anchor republishing on every persist batch wakes it constantly, but the index compare short-circuits.

Session lifecycle is sound. Sessions are main tasks on the outer scope, and the defer at :583 cancels them before the closure returns, so main.Wait() can't deadlock on them. stopDepartingMembers cancels all departing sessions before awaiting any, and awaits before the loop can re-add — so a validator that rotates its consensus key while keeping the same GigaNodeAddr.Key can't race the old session's poolOut slot. SpawnNamed is WaitGroup-backed (scope/start.go:113), so respawning per epoch doesn't accumulate.

The new tests are the right ones: KeepsLeaversWhileAnchorLags covers both anchor == None and anchor == commit-1, and DialsBothCommitteesUntilStable covers the >+1 gap and the union.


Non-blocking

1. The inbound half is now asymmetric with the fix, and the mismatch is silent rather than an errorgiga_router_common.go:660, :682

The new godoc is honest that inbound role follows nextCommitEpoch alone. What's worth spelling out is what the peer experiences, because it isn't a clean failure:

  • Service.RunServer gates the validator streams on isCommittee (giga/service.go:78), but Service.RunClient has no such gate — it runs validatorService.RunClient whenever the node has consensus state (giga/service.go:96).
  • So a validator peer served the fullnode subset still calls StreamAppVotes / StreamCommitQCs / Consensus / the lane streams. RPC.Callmux.Accept, documented at mux/mux.go:469 as "Blocks until peer opens a connect stream." The server never Connects those kinds, so those calls park forever — no error, no reconnect — while clientPing keeps the socket looking healthy.

Concretely, the mirror image of the scenario this commit fixes: on N→N+1, X keeps its outbound to departing V (✓ X gets V's AppVotes), but V's outbound to X is X's inbound, which X closes and re-accepts as a fullnode. Symmetrically for every other member of C(N), so no departing member can collect AppVotes for the epoch-N tail from anyone. They still recover, because StreamAppQCs/StreamFullCommitQCs are in the fullnode subset — they receive the finished AppQC instead of aggregating it, and the aggregation happens on the staying nodes, which is exactly what this commit guarantees. So it's survivable, not a liveness bug. But the aggregation capability is now one-sided, and the degradation is invisible: no error, no log, no metric on the parked streams.

Same mechanism answers @codchen's question more completely than "refused": when the local node is merely behind and misclassifies a genuine committee member, that peer's consensus streams park silently until the local node catches up and runUntilMembershipChange flips the role and closes the conn.

The symmetric fix is small — compute isCommittee from keepReplicas(r.anchor.Load(), r.nextCommitEpoch.Load()) and have runUntilMembershipChange watch that same predicate, so accept uses the window dial now uses. If you'd rather keep the asymmetry deliberately, the parked-stream behaviour is worth a sentence in the godoc, since "downgraded to fullnode" reads like the peer degrades gracefully and it doesn't. Fix this →

2. A fullnode whose address book misses the whole committee stops syncing silentlygiga_router_fullnode.go:94

len(validators) == 0 sleeps DialInterval and retries with no log at all. Given the TODO: support committee members absent from the address book at giga_router_common.go:672, this is precisely the state a fullnode lands in after a rotation to validators the operator hasn't added to the static book: block sync stops entirely and nothing is emitted. runPerCommitteeMember logs per-validator in the same situation (:605); this path should too. Fix this →

3. The committeeMemberTask godoc still states the mechanism backwardsgiga_router_common.go:522

returning earlier leaves the member unmarked in live

The entry stays in live — nothing removes it except stopDepartingMembers — and that is exactly why the member is never restarted. The clause after it is right; this one contradicts it. Dropping the map-state clause and keeping "must run until ctx is cancelled; otherwise it is never restarted" says the whole contract.


Smaller notes

  • RunInboundConn is still the untested half — no test asserts the conn terminates after a committee flip, and the new fullnode ring (Equal(snap) reshuffle, left failover) has none either. Codecov puts giga_router_fullnode.go patch coverage at 3.33%.
  • giga_router_fullnode.go:115 drops err when left is true, so a connection error racing a membership change is reported only as "left the committee."
  • The fan-out rationale from the old Run ("validators dial every committee member in parallel — consensus voting needs fan-out, not stickiness; the same connections serve block sync between committee peers") was deleted rather than relocated; only the self/GetBlock half made it onto runCommitteePeer. AGENTS.md's rule is relocate, not delete.

Not verified: go build, go test, go vet and gofmt all require approval in this environment, so this is static analysis only — please make sure the PR's own test-plan checkboxes get run.
· branch wen/dynamic_epoch_peers

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.

3 participants