Skip to content

fix(autobahn): prevent block-sync livelock after restart catch-up - #3831

Merged
wen-coding merged 5 commits into
mainfrom
wen/autobahn_restart_liveness
Jul 31, 2026
Merged

fix(autobahn): prevent block-sync livelock after restart catch-up#3831
wen-coding merged 5 commits into
mainfrom
wen/autobahn_restart_liveness

Conversation

@wen-coding

Copy link
Copy Markdown
Contributor

Summary

  • Stop Autobahn validators from consuming catch-up GetBlock on the self dial (RunSelfClient), which always returns empty for missing local heights and can starve the contiguous prefix.
  • Treat in-memory gap-fills as already fetched via HaveBlock, so the block fetcher does not keep re-requesting heights it already holds while a lower hole is open.
  • Make the Autobahn major-upgrade post-downgrade height wait assert WAIT_FOR_HEIGHT_PASS explicitly, so a live stalled node cannot false-green as the expected upgrade panic.

Test plan

  • go test ./autobahn/... ./internal/autobahn/... (253 passed)
  • Autobahn TestUpgradeMajor on local docker cluster
  • Autobahn TestUpgradeMinor on fresh local docker cluster
  • CI Autobahn upgrade-module major/minor jobs

Made with Cursor

wen-coding and others added 2 commits July 30, 2026 18:48
Exclude the validator self-dial from block sync and treat in-memory
gap-fills as already fetched so restart catch-up cannot livelock.
Also make the major-upgrade height wait assert explicitly so a stalled
node cannot false-green as an expected panic.

Co-authored-by: Cursor <cursoragent@cursor.com>
@github-actions

github-actions Bot commented Jul 31, 2026

Copy link
Copy Markdown

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

BuildFormatLintBreakingUpdated (UTC)
✅ passed✅ passed✅ passed✅ passedJul 31, 2026, 4:14 PM

@codecov

codecov Bot commented Jul 31, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 93.75000% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 60.39%. Comparing base (2d2628f) to head (95a6ec2).
⚠️ Report is 2 commits behind head on main.

Files with missing lines Patch % Lines
sei-tendermint/internal/autobahn/data/state.go 85.71% 1 Missing ⚠️
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main    #3831      +/-   ##
==========================================
- Coverage   61.28%   60.39%   -0.90%     
==========================================
  Files        2351     2259      -92     
  Lines      197283   186786   -10497     
==========================================
- Hits       120907   112811    -8096     
+ Misses      65530    63982    -1548     
+ Partials    10846     9993     -853     
Flag Coverage Δ
sei-chain-pr 75.69% <93.75%> (?)
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/internal/p2p/giga/data.go 65.68% <100.00%> (+0.92%) ⬆️
sei-tendermint/internal/p2p/giga/service.go 71.42% <100.00%> (+0.51%) ⬆️
...i-tendermint/internal/p2p/giga_router_validator.go 96.29% <100.00%> (+0.29%) ⬆️
sei-tendermint/internal/autobahn/data/state.go 80.14% <85.71%> (+0.09%) ⬆️

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

The core fix is sound: the old runBlockFetcher used TryBlock, which hides gap-fills, so a goroutine for an already-held height never exited and permanently leaked one of the 100 semaphore slots — HaveBlock correctly closes that (and gap-fills above nextBlock are never evicted, since evictBelowBound only drops below first <= nextBlock, so the early return can't strand a height); dropping clientGetBlock from the self dial is likewise correct. No blockers — remaining points are drift risk in the duplicated RunSelfClient, thin test coverage, and an overstated integration-test assertion.

Findings: 0 blocking | 11 non-blocking | 6 posted inline

Blockers

  • None at the file/PR level.

Non-blocking

  • The Cursor second-opinion file (cursor-review.md) is empty — that pass produced no output. Codex's single finding (missing regression test for the RunSelfClient wiring) is merged into the inline comment on service.go.
  • Only the node-0 post-downgrade wait_for_height.sh call is hardened; the three calls for nodes 1–3 (major_upgrade_test.yaml:82-87) still silently swallow a timeout. Consider making wait_for_height.sh itself echo a terminal PASS/FAIL line and asserting on it at every call site, rather than repeating inline shell in the YAML.
  • The new WAIT_FOR_HEIGHT_PASS assertion is largely subsumed by the existing PANIC_AT_BLOCK_HEIGHT_NODE_0 == "PASS" verifier (a live, stalled node makes verify_panic.sh print FAIL after its 60 attempts). Still worth keeping as an earlier, clearer failure signal — just don't rely on it as the sole guard.
  • Edge case worth a note in the code: on a single-member committee the self dial was the only outbound client, so after this change there is no clientGetBlock consumer at all and runBlockFetcher blocks in utils.Send instead of spinning. Behaviorally equivalent (a lone validator has no peer to fetch from either way) and still ctx-cancellable, but it becomes a silent stall rather than a retrying one — worth a sentence if 1-validator committees are a supported configuration.
  • Verified during review, no action needed: HaveBlock's n < nextBlock branch exactly covers the old err == nil || errors.Is(err, types.ErrPruned) skip set (TryBlock only reached blockFromDB, and hence ErrPruned, for n < nextBlock), and it now avoids a BlockDB read on every retry.
  • 6 suggestion(s)/nit(s) flagged inline on specific lines.

// block sync. The self connection is required for the validator's own votes,
// but it must not consume catch-up GetBlock requests: by definition this node
// cannot serve a block that is missing from its own data state.
func (x *Service) RunSelfClient(ctx context.Context, client rpc.Client[API]) 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.

[suggestion] RunSelfClient duplicates 7 of RunClient's 9 spawns. Nothing ties the two lists together, so a stream added to RunClient later will silently be missing on the self connection (or, as Codex notes, clientGetBlock could be accidentally re-added here and quietly restore the restart livelock).

Consider factoring the shared set into a helper both entry points call, e.g.:

func (x *Service) spawnConsensusClientStreams(ctx context.Context, s scope.Scope, client rpc.Client[API]) {
	s.Spawn(func() error { return x.clientPing(ctx, client) })
	s.Spawn(func() error { return x.clientConsensus(ctx, client) })
	// ... lane/commit/app streams
}

with RunClient additionally spawning clientStreamFullCommitQCs + clientGetBlock. That makes "self = consensus streams only" a single-line difference instead of a copy that can drift.

Also worth a targeted regression test — the 4-node harness in giga_router_validator_test.go could assert the self connection never issues a GetBlock, or a unit test could assert the spawn set of each entry point.


// RunSelfClient runs validator-local consensus and availability streams without
// block sync. The self connection is required for the validator's own votes,
// but it must not consume catch-up GetBlock requests: by definition this node

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[nit] The comment explains why GetBlock is excluded, but clientStreamFullCommitQCs is also dropped and the PR description only mentions GetBlock. It's harmless (streaming our own QCs back into our own PushQC is a no-op via insertQC's gr.Next <= i.nextQC skip), but since it's a second, unexplained omission it deserves a clause — e.g. "...and the self QC stream, which only re-pushes QCs we already hold."

// consensus/availability only: a loopback GetBlock consumer always
// returns empty for missing catch-up heights and can starve the
// contiguous prefix while higher gap-fills keep retrying.
selfKey := r.key.Public()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[nit] This file now holds two different notions of "self": selfKey (the p2p node key, r.key.Public()) and the validatorKey field (the consensus key, used by EvmProxy). Matching addr.Key against the node key is correct here since GigaNodeAddr.Key is a NodePublicKey — but a one-line comment saying so would save the next reader from wondering whether validatorKey was meant.

if n < inner.nextBlock {
return true
}
return inner.blocks[n] != 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.

[nit] insertBlock uses the comma-ok form (if _, ok := i.blocks[n]; ok) for the same lookup. Equivalent today since no nil block is ever stored, but _, ok := inner.blocks[n]; return ok matches the surrounding code and doesn't depend on that invariant.

_, err := state.TryBlock(last)
require.ErrorIs(t, err, types.ErrNotFound, "gap-fill above nextBlock must stay hidden")
have := state.HaveBlock(last)
require.True(t, have, "HaveBlock must see gap-fills so the fetcher does not re-request them")

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] Good coverage of the gap-fill and hole cases. The one branch not covered is the one that replaced the deleted errors.Is(err, types.ErrPruned) handling in runBlockFetcher: a height below first (evicted from RAM, possibly pruned from BlockDB) must still report true, otherwise the fetcher would loop forever on a height it can never obtain. Worth an assertion after eviction has advanced first.

Minor: require.True/require.False here are re-exported testify vars, so they take the expression directly — require.True(t, state.HaveBlock(last), ...) avoids the intermediate have variable and its reassignment on line 831.

# The integration runner records non-zero script exits but does not fail the
# input step. Capture an explicit result so a live, stalled node cannot be
# mistaken for the expected upgrade panic.
- cmd: wait_for_height.sh $TARGET_HEIGHT node_admin; WAIT_STATUS=$?; if [ "$WAIT_STATUS" -eq 0 ]; then echo WAIT_FOR_HEIGHT_PASS; else echo WAIT_FOR_HEIGHT_FAIL; fi

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] wait_for_height.sh exits 0 on two paths: the target height was reached, and pgrep found no running seid ("Seid no longer running (panic)" → break → exit 0). So WAIT_FOR_HEIGHT_PASS really asserts "the wait did not time out", not "node 0 reached $TARGET_HEIGHT" — the env name DOWNGRADED_NODE_0_REACHED_TARGET (line 135) overstates what is checked. Either rename it (e.g. DOWNGRADED_NODE_0_WAIT_STATUS) or make the script distinguish the two exits so the assertion matches the comment above it.

Style nit: WAIT_STATUS is used exactly once, so wait_for_height.sh $TARGET_HEIGHT node_admin && echo WAIT_FOR_HEIGHT_PASS || echo WAIT_FOR_HEIGHT_FAIL expresses the same thing in one clause.

@claude claude 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.

I didn't find any additional bugs beyond what the bug hunting system already ruled out, but this PR touches the Autobahn consensus/p2p block-sync layer (self-dial wiring, in-memory gap-fill visibility, block fetcher backoff) — critical-path networking code with a data-race-sensitive locking scheme — so it's worth a human look before merging.

Extended reasoning...

Overview

The PR changes catch-up block-sync behavior in the Autobahn consensus/p2p stack: (1) it stops a validator's self-dial connection from participating in GetBlock catch-up fetches (RunSelfClient in service.go, wired in giga_router_validator.go), since a node can never serve a block it doesn't have locally; (2) it adds State.HaveBlock in state.go so the block fetcher in giga/data.go treats in-memory gap-fills as already-fetched, avoiding redundant re-requests that could starve the contiguous prefix; and (3) it hardens an integration test (major_upgrade_test.yaml) so a stalled node can't false-green a wait_for_height.sh step as the expected upgrade panic.

Security risks

No new attack surface — this is sync/liveness logic, not auth, crypto, or app-hash-affecting state transition (the PR carries the non-app-hash-breaking label). The main risk category is correctness/liveness: a subtle bug here could reintroduce sync stalls or, in the other direction, cause blocks to be considered 'had' when they actually are not, corrupting the no-gap contract that other code (BlockDB persistence, eviction, AppProposal) depends on.

Level of scrutiny

This warrants a careful human pass. It's a small diff by line count, but it modifies locking-sensitive shared state (inner.blocks/nextBlock accessed under s.inner.Lock()) and changes fan-out wiring for validator-to-validator connections in the consensus networking path. Bugs in this class of code (block-sync livelocks, self-dial semantics) are exactly the kind that are easy to miss in review and expensive to discover in production. The bug hunting system found nothing, and I did not find additional issues in HaveBlock's lock/contract logic, the runBlockFetcher replacement of TryBlock with HaveBlock, or the RunSelfClient fan-out change — but this falls squarely in the 'critical code path, human should weigh in' category rather than the 'obviously safe to auto-approve' category.

Other factors

Test coverage is reasonable: new unit tests in state_test.go directly exercise the gap-fill visibility contract added by HaveBlock, and the PR description states it was manually validated against local docker clusters for both major and minor upgrade scenarios, with CI runs still pending per the PR's own test-plan checklist.

Share consensus client stream spawns between RunClient and RunSelfClient,
clarify self-key vs validator-key matching, and use comma-ok HaveBlock
lookups consistent with insertBlock.

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

@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.

The three changes (self-dial no longer consumes catch-up GetBlock, HaveBlock so gap-fills stop being re-requested, explicit pass/fail capture for the downgrade height wait) are coherent and I found no correctness bug: the self-key comparison is backed by the selfAddr.Key == NodeSecretKey(nodeKey).Public() invariant enforced in buildValidatorGigaConfig, and gap-fills can only be dropped by skipTo, which runs during recovery before the fetcher starts. Remaining notes are non-blocking (test-harness semantics and coverage gaps).

Findings: 0 blocking | 6 non-blocking | 3 posted inline

Blockers

  • None at the file/PR level.

Non-blocking

  • Cursor's second-opinion pass produced no output (cursor-review.md is empty), so that review is effectively missing. Codex reported no material findings.
  • The other wait_for_height.sh steps are still unasserted and carry the same false-green risk this PR fixes for one call site: integration_test/upgrade_module/major_upgrade_test.yaml:82-86 and integration_test/upgrade_module/minor_upgrade_test.yaml:89-95. Consider folding the $?-capture idiom into a small wrapper script (e.g. wait_for_height_checked.sh) and using it everywhere instead of inlining shell in YAML.
  • HaveBlock has no test for the evicted-but-below-nextBlock case (n < first, served from / pruned out of BlockDB). That path replaces the old errors.Is(err, types.ErrPruned) branch in runBlockFetcher, so it is worth one assertion alongside the new gap-fill assertions in TestTryBlockHidesGapFills.
  • 3 suggestion(s)/nit(s) flagged inline on specific lines.

# The integration runner records non-zero script exits but does not fail the
# input step. Capture an explicit result so a live, stalled node cannot be
# mistaken for the expected upgrade panic.
- cmd: wait_for_height.sh $TARGET_HEIGHT node_admin; WAIT_STATUS=$?; if [ "$WAIT_STATUS" -eq 0 ]; then echo WAIT_FOR_HEIGHT_PASS; else echo WAIT_FOR_HEIGHT_FAIL; fi

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] Two things about this assertion:

  1. wait_for_height.sh also exits 0 on the pgrep-miss path (Seid no longer running (panic)break), so WAIT_FOR_HEIGHT_PASS means "did not time out", not "reached $TARGET_HEIGHT". That's the right signal for the stated goal (a live stalled node now fails instead of being read as the expected panic), and verify_upgrade_needed_log.sh $TARGET_HEIGHT covers the height separately — but the env var name DOWNGRADED_NODE_0_REACHED_TARGET and the comment claim more than the check delivers. Worth renaming to something like DOWNGRADED_NODE_0_WAIT_RESULT.

  2. env: captures the command's whole trimmed stdout, and wait_for_height.sh prints a Waiting for block ... line every iteration for up to MAX_WAIT_SECONDS=360. The runner then re-injects that value as docker exec -e ... on every subsequent step in the case, so a slow run makes a multi-KB single argv entry (and a huge t.Logf line). Redirecting the script's chatter (wait_for_height.sh ... >/dev/null or >&2) so only the marker lands on stdout would keep the capture small — at the cost of losing the progress log, since dockerExecer.run discards stderr on success.

selfKey := r.key.Public()
for _, addr := range r.cfg.ValidatorAddrs {
runClient := r.service.RunClient
if addr.Key == selfKey {

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 self-detection is sound — buildValidatorGigaConfig rejects startup unless the committee entry for our own validator key carries exactly NodeSecretKey(nodeKey).Public(), and node keys are deduped in loadAutobahnCommittee — so this comparison can't silently miss. But the fix has no direct test: nothing asserts that the self dial gets RunSelfClient (existing validator router tests only exercise it implicitly via consensus still working). A small unit test that the self connection never consumes from getBlockReqs would lock in the property this PR exists to establish, and would fail loudly if someone later reintroduces block-sync streams into spawnConsensusClientStreams.

// satisfy this height. Re-fetching them while a lower hole is
// empty wastes the shared per-peer GetBlock queue and can
// starve the contiguous prefix.
if x.data.HaveBlock(n) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[nit] Behavior note, not a defect: the fetcher no longer surfaces store errors here. The old TryBlock path returned fmt.Errorf("TryBlock(%d)", ...) for anything that wasn't ErrNotFound/ErrPruned, aborting the fetcher on a BlockDB read failure; HaveBlock never touches BlockDB, so those failures now go unobserved at this call site. That looks intentional and harmless (the pruned case already returned nil before, and DB failures still surface via Block/persist paths), but it's worth confirming no monitoring relied on the fetcher dying here.

@claude claude 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.

No new bugs found beyond what's already flagged inline. This PR touches core Autobahn consensus/P2P block-sync paths (self-dial routing, block fetcher dedup logic) — critical-path networking code, so it should get a human look rather than an automated approval.

Extended reasoning...

Overview

The PR fixes a livelock in Autobahn's block-sync catch-up: the self-dial connection (a validator's loopback connection to itself) was spawning the full RunClient handler set, including clientGetBlock, which would consume catch-up GetBlock requests that can never succeed against a node's own missing local data. It also adds a HaveBlock check so the block fetcher treats in-memory gap-fills as already-fetched, avoiding redundant re-requests while a lower hole is open, and hardens an integration test assertion so a stalled node can't be mistaken for the expected upgrade panic.

Security risks

No security-sensitive surface (auth/crypto/permissions) is touched. The self-key comparison in giga_router_validator.go correctly compares against the p2p node key (NodePublicKey), matching GigaNodeAddr.Key's type, so there's no type-confusion risk with the separate validatorKey (consensus signing key) field.

Level of scrutiny

This warrants a careful human look: it's a targeted but non-trivial change to core consensus/P2P block-sync control flow (self-dial handler selection, block fetcher dedup logic) in Autobahn — a critical path where a subtle regression could reintroduce a livelock or cause missed block propagation. I traced through HaveBlock's semantics against the old TryBlock+ErrPruned logic it replaced and confirmed the new logic is equivalent (and removes a possible DB-error propagation path), and confirmed the self-dial loopback connection is real (not a no-op) via dialAndRunConn/tcp.Dial. I did not find additional correctness issues beyond what seidroid already flagged (RunSelfClient/RunClient duplication risk, a missing eviction-boundary test case, and the wait_for_height.sh dual-exit-path ambiguity) — those are legitimate maintainability/coverage suggestions, not bugs blocking approval.

Other factors

Test coverage is reasonable (new HaveBlock unit test covers both the gap-fill-hit and hole-miss branches; patch coverage 95%+ per Codecov) and the PR description documents manual testing on a local docker cluster for both major and minor upgrade scenarios. Given the critical-path nature of the code and the existing unresolved review suggestions (helper duplication, comment omission), a human reviewer should weigh in before merge.

@philipsu522 philipsu522 left a comment

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.

this looks good, i just wonder if we need to bound outgoing requests, given previously we had a pseudo-limit via holding the permit for an existing block? otherwise would we oom / overwhelm the node with this new (more performant) behavior?

// spawnConsensusClientStreams starts the streams shared by peer and self
// clients. Block-sync streams (FullCommitQCs / GetBlock) are added only by
// RunClient so the self dial cannot drift back into catch-up fetches.
func (x *Service) spawnConsensusClientStreams(ctx context.Context, s scope.Scope, client rpc.Client[API]) {

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.

don't take scope as an arg, just make it a run...() command and create another scope internally.

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

@pompon0 pompon0 left a comment

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.

The mismatch between TryBlock and fetcher semantics was definitely worth fixing, thank you. Special handling of loopback-connection is something that I'd really like to avoid, because it obfuscates deeper problems: like here - we fix the specific case where node asks itself about the block that it doesn't have, but the problem persists if we have a peer which also doesn't have the block. Indeed the fetching strategy has a lot of room for improvements, but let's make them uniform.

// nextBlock. Unlike TryBlock, gap-fills count as present so the block fetcher
// does not keep re-requesting heights it already holds while waiting for a
// lower contiguous hole to close. Does not consult BlockDB.
func (s *State) HaveBlock(n types.GlobalBlockNumber) bool {

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.

name is misleading, since the block could have been already pruned. One option would be to invert the semantics and call it "NeedBlock", which does not imply the block is accessible (pruned/ahead of nextBlock)

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

return scope.Run(ctx, func(ctx context.Context, s scope.Scope) error {
s.Spawn(func() error { return x.clientPing(ctx, client) })
s.Spawn(func() error { return x.clientConsensus(ctx, client) })
x.spawnConsensusClientStreams(ctx, s, client)

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.

until we implement a robust retry strategy (please add a TODO), for now it would be fine to add a flag to RunClient deciding whether to run GetBlock or not, as a minimal asymmetry addressing the issue at hand.

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

Rename HaveBlock to NeedBlock, fold self-dial into RunClient(getBlock),
and nest consensus client streams in their own scope.Run. Add a TODO for
a uniform GetBlock retry strategy so self asymmetry can go away later.

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

Copy link
Copy Markdown
Contributor Author

this looks good, i just wonder if we need to bound outgoing requests, given previously we had a pseudo-limit via holding the permit for an existing block? otherwise would we oom / overwhelm the node with this new (more performant) behavior?

@philipsu522 we still have MaxConcurrentBlockFetches=100 cap the in flight requests, there is just less waste.

@wen-coding
wen-coding requested a review from pompon0 July 31, 2026 14:55

@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.

The livelock fix is sound: NeedBlock correctly treats verified gap-fills as satisfied (gap-fills are hash-checked against the covering QC and never evicted above nextBlock), and the self-dial getBlock=false gate is exact because startup already enforces that the committee entry's node key equals the local node key. Remaining findings are naming/doc accuracy, an overstated integration-test assertion, and test-coverage gaps — no blockers.

Findings: 0 blocking | 7 non-blocking | 3 posted inline

Blockers

  • None at the file/PR level.

Non-blocking

  • Second-opinion passes: Codex reported "No material issues found"; the Cursor review file (cursor-review.md) is empty, i.e. that pass produced no output.
  • The other wait_for_height.sh steps still silently ignore failure — major_upgrade_test.yaml:82,84,86 and minor_upgrade_test.yaml:89,91,93,95. They have exactly the same false-green property this PR fixes for one step. Consider either applying the same wrapper there or (better) fixing it centrally: dockerExecer.run swallowing *exec.ExitError into a nil error (integration_test/runner/runner.go:205-210) is the root cause, and an opt-in fail_on_error: true on the input step would avoid the per-step shell boilerplate.
  • No test covers the new RunClient(..., false) path or the self-key skip in gigaValidatorRouter.Run; data_test.go only updates the call site to true. A router-level test asserting the self dial spawns no clientGetBlock (or that getBlock is false exactly for the self entry) would lock in the fix that motivates this PR.
  • NewGigaValidatorRouter itself doesn't check that key.Public() appears in cfg.ValidatorAddrs — that invariant lives in buildValidatorGigaConfig (node/setup.go:242-245). In production the two are always paired so the self-skip is exact, but a direct constructor caller with a mismatched key silently gets the old behavior. A one-line logger.Info/assert when no self entry is found would make that misconfiguration visible rather than silently degrading.
  • 3 suggestion(s)/nit(s) flagged inline on specific lines.

}

// NeedBlock reports whether catch-up still needs to fetch height n.
// False when n is already past nextBlock (including heights pruned or

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[nit] The wording is inverted relative to the code. n < inner.nextBlock means nextBlock has already advanced past n, but "n is already past nextBlock" reads as n > nextBlock — which is the branch that returns true. Suggest: "False when the contiguous prefix has already passed n (n < nextBlock, including heights pruned or evicted from RAM)…".

}

func (x *Service) RunClient(ctx context.Context, client rpc.Client[API]) error {
func (x *Service) RunClient(ctx context.Context, client rpc.Client[API], getBlock bool) 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.

[suggestion] The getBlock flag gates clientStreamFullCommitQCs in addition to clientGetBlock, so the name is narrower than the effect. Disabling the QC stream on the self dial looks fine on its own (serverStreamFullCommitQCs on the loopback just re-pushes QCs the node already has), but it's a second behavior change that neither the parameter name nor the PR description mentions. Consider renaming to something like blockSync/catchUp, or taking a small options struct — a bare bool at the call site (RunClient(ctx, client, getBlock)) doesn't convey that two streams are involved.

# The integration runner records non-zero script exits but does not fail the
# input step. Capture an explicit result so a live, stalled node cannot be
# mistaken for the expected upgrade panic.
- cmd: wait_for_height.sh $TARGET_HEIGHT node_admin; WAIT_STATUS=$?; if [ "$WAIT_STATUS" -eq 0 ]; then echo WAIT_FOR_HEIGHT_PASS; else echo WAIT_FOR_HEIGHT_FAIL; fi

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] WAIT_FOR_HEIGHT_PASS proves "the script did not time out", not "node 0 reached TARGET_HEIGHT" — so the capture name DOWNGRADED_NODE_0_REACHED_TARGET overstates it. wait_for_height.sh also breaks (exit 0) on the pgrep-miss path ("Seid no longer running (panic)"), so a node that dies at any height before TARGET_HEIGHT still yields PASS. The check does catch the stated failure mode (a live, stalled node hits the 360s timeout → exit 1), so this isn't wrong — just consider renaming the var to something like DOWNGRADED_NODE_0_WAIT_RESULT, or having the script emit distinct markers for "reached height" vs "process gone" so the verifier can assert the former.

Minor: the WAIT_STATUS capture is redundant — if wait_for_height.sh $TARGET_HEIGHT node_admin; then echo WAIT_FOR_HEIGHT_PASS; else echo WAIT_FOR_HEIGHT_FAIL; fi is equivalent and shorter.

@claude claude 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.

No bugs found. Given this PR touches consensus-critical block-sync/livelock logic in autobahn, I'm deferring to human review rather than auto-approving, since a human reviewer (pompon0) is already actively engaged on this thread.

Extended reasoning...

Overview

This PR fixes a block-sync livelock in Autobahn: (1) validators no longer consume GetBlock on the self-dial connection (a loopback that always returns empty for missing catch-up heights, which could starve the contiguous prefix), (2) the block fetcher now checks NeedBlock instead of TryBlock so in-memory gap-fills count as satisfied and don't get re-requested while a lower hole is open, and (3) an integration test assertion is tightened so a live-but-stalled node during the major-upgrade downgrade step can't be misread as the expected panic.

Security risks

No new external attack surface — this is internal p2p/consensus plumbing between committee members that are already mutually authenticated via the giga transport. The main risk category is correctness/liveness (a bad change here could reintroduce a sync livelock or, worse, a silent data gap), not confidentiality or injection-style vulnerabilities.

Level of scrutiny

This warrants a human look. It touches consensus-critical code paths (block/QC state machine, self-dial peer routing) with subtle invariants around eviction, gap-fills, and the contiguous block prefix — exactly the kind of change where a logic slip is easy to miss and expensive to hit in production. The PR has already been through one round of human review (pompon0), and I can see from the current commit history (2794def refactor(autobahn): address review feedback on NeedBlock and RunClient) that all three of their prior comments (don't pass scope as an arg, rename to NeedBlock, add a getBlock flag with a TODO) have been incorporated.

Other factors

Test coverage looks reasonable (new NeedBlock unit tests for the gap-fill/hole cases, go test and manual docker-cluster runs per the PR description), and the bug-hunting system found no issues. Since a human reviewer is already actively iterating on this PR and the changes are non-trivial, I'm deferring rather than shadow-approving.

// so connections that lack a height (including self) do not need a separate
// getBlock=false path to avoid starving the shared fetch queue.
return scope.Run(ctx, func(ctx context.Context, s scope.Scope) error {
s.Spawn(func() error { return x.runConsensusClientStreams(ctx, client) })

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.

nit: inline back runConsensusClientStreams

return scope.Run(ctx, func(ctx context.Context, s scope.Scope) error {
s.Spawn(func() error { return x.runConsensusClientStreams(ctx, client) })
if getBlock {
s.Spawn(func() error { return x.clientStreamFullCommitQCs(ctx, client) })

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.

clientStreamFullCommitQCs can be unconditional, no need for asymmetry here

Inline consensus client streams and keep FullCommitQC streaming on for
all dials; only GetBlock stays behind the getBlock flag.

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

@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.

The automated review did not complete; see the failing AI Review check for details.

@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.

The automated review did not complete; see the failing AI Review check for details.

@wen-coding
wen-coding added this pull request to the merge queue Jul 31, 2026
Merged via the queue into main with commit c84a975 Jul 31, 2026
73 of 77 checks passed
@wen-coding
wen-coding deleted the wen/autobahn_restart_liveness branch July 31, 2026 16:41
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.

5 participants