fix(xdc): relay timeouts even when the local epoch lookup fails - #12787
fix(xdc): relay timeouts even when the local epoch lookup fails#12787ak88 wants to merge 1 commit into
Conversation
|
Claude finished @ak88's task in 5m 38s —— View job Review: relay timeouts for rounds the node has not reached yet
The core change is right. Relay belonged in Two things should be addressed before merge. HighRelay is unbounded in the forward round direction —
That was harmless before: a far-future timeout was verified and then dropped at So any key in MediumThe exception allowlist does not achieve the invariant its comment states — I confirmed both cited throw sites are real (
A single guard at the boundary ( Low
The README sentence is accurate and the diagram above it stays consistent with the new flow. Test design is otherwise good — the parameterized stale/current/future table and the two-shape Not verified: I could not compile or run the suite — |
| //TODO: Broadcast Timeout | ||
| // Relay before processing: a timeout for a round this node has not reached yet is still | ||
| // useful to peers, while HandleTimeoutVote only accumulates timeouts for the current round. | ||
| BroadcastTimeout(timeout); |
There was a problem hiding this comment.
High — relay is now unbounded in the forward round direction.
OnReceiveTimeout bounds only the gap distance (gapDiff > 3 * epochLength), and FilterTimeout rejects only timeout.Round < CurrentRound. There is no upper bound on timeout.Round, and the existing test at line 245 of the test file ([TestCase(500UL, 0UL, true, true)] // Far away round but should get filtered in) confirms that's deliberate for the filter.
Before this change that didn't matter: a far-future timeout was verified and then dropped at HandleTimeoutVote's round gate, with no network effect. Now every such message is fanned out to every peer.
The vote path — cited in the PR description as the model — does bound this, in both directions (VotesManager.cs:47-48, 279-285):
private const int _maxRoundDistance = 7; // Maximum allowed distance from the current round
...
if (blockDiff > _maxBlockDistance ||
Math.Abs((long)vote.ProposedBlockInfo.Round - (long)_ctx.CurrentRound) > _maxRoundDistance)
return Task.CompletedTask; // discarded, before BroadcastVoteConsequence: any key in snapshot.NextEpochCandidates can sign Timeout(CurrentRound + K, validGap) for arbitrary, ever-varying K. Each distinct message is relayed once to every peer by every node that receives it (ShouldNotifyTimeout's _notifiedTimeouts cache dedups repeats of the same message, but not a stream of distinct ones), so one inbound message becomes N outbound, network-wide. No node can ever pool those rounds either, so the relay is pure cost with no liveness benefit.
Bounding the forward distance the same way the vote path does keeps the entire benefit of this fix — a peer one or a few rounds behind still gets the timeout — while removing the amplification surface:
if (gapDiff > 3 * epochLength ||
timeout.Round > _consensusContext.CurrentRound + MaxRoundDistance)
{
// Discarded propagated timeout, too far away
return Task.CompletedTask;
}(Stale rounds are already dropped by FilterTimeout, so only the forward bound is needed here.)
| EpochSwitchInfo epochSwitchInfo; | ||
| try | ||
| { | ||
| epochSwitchInfo = _epochSwitchManager.GetEpochSwitchInfo(xdcHeader); | ||
| } | ||
| catch (Exception e) when (e is InvalidOperationException or ArgumentException) | ||
| { | ||
| // Walking back to the epoch switch throws on a gap in the header store, and the subnet | ||
| // manager throws when the stored snapshot type does not match. Timeouts are handled on the | ||
| // P2P message loop, so neither may escape; the timeout stays pooled for a later arrival. | ||
| if (_logger.IsWarn) _logger.Warn($"Cannot evaluate timeouts for round {timeout.Round}: {e.Message}"); | ||
| return Task.CompletedTask; | ||
| } |
There was a problem hiding this comment.
Medium — the exception allowlist does not achieve the invariant it states.
The comment says "Timeouts are handled on the P2P message loop, so neither may escape", but on that same path several throws remain unguarded, including one of the very same type:
OnReceiveTimeoutline 251:_blockTree.Head ?? throw new InvalidOperationException("Failed to get current block")— thrown synchronously intoXdcProtocolHandler.Handle(TimeoutMsg)before this catch is ever reached.GetEpochSwitchInfodereferencesheader.Hashon its first line (BaseEpochSwitchManager.cs:32).xdcHeaderhere is_blockTree.Head?.Header as XdcBlockHeader, which isnullwhenever the head header isn't anXdcBlockHeader— that's aNullReferenceException, not in the allowlist._specProvider.GetXdcSpec(xdcHeader, ...)on line 99 with the same possibly-null header.
Enumerating exception types at one call site is brittle: it also has to be revisited every time IEpochSwitchManager grows a new throw (SubnetEpochSwitchManager.GetEpochSwitchInfoBetween already throws NotImplementedException, SendTimeout throws DataExtractionException). A single guard at the boundary — Handle(TimeoutMsg)/Handle(VoteMsg) in XdcProtocolHandler, or the top of OnReceiveTimeout — covers all of them, is a smaller diff, and doesn't need the list kept in sync. If you prefer to keep the catch here, please at least drop the null-header hole and the line-251 throw, otherwise the P2P loop is still reachable by an exception.
Separately: ArgumentException also catches ArgumentNullException / ArgumentOutOfRangeException, so a genuine programming error downstream is now downgraded to a Warn line.
| // Walking back to the epoch switch throws on a gap in the header store, and the subnet | ||
| // manager throws when the stored snapshot type does not match. Timeouts are handled on the | ||
| // P2P message loop, so neither may escape; the timeout stays pooled for a later arrival. | ||
| if (_logger.IsWarn) _logger.Warn($"Cannot evaluate timeouts for round {timeout.Round}: {e.Message}"); |
There was a problem hiding this comment.
Low — Warn-level log on a condition the PR itself describes as persistent.
Per the PR's own Remarks, a missing gap-block snapshot makes this path fail "persistently for a whole epoch", and this line runs once per received timeout. On a freshly-synced subnet node that is a Warn per inbound timeout message from every peer for EpochLength - Gap blocks — enough to bury anything else in the log. Consider IsDebug/Debug here, or throttling to once per round (the _tcBuildStartedByRound-style pattern already in this class).
Also, {e.Message} discards the stack trace, which for the header-store-gap case is where the diagnostic value is. ILogger.Warn has no exception overload, so {e} is the way to keep it.
| Timeout timeout = XdcTestHelper.BuildSignedTimeout(keys[0], timeoutRound, Gap); | ||
| await manager.OnReceiveTimeout(timeout); | ||
|
|
||
| _ = syncPeerPool.Received(expectedRelays).AllPeers; |
There was a problem hiding this comment.
Low (test quality). Two small things:
syncPeerPool.Received(0).AllPeersreads better asDidNotReceive(), but more importantly the assertion is a proxy: it verifiesBroadcastTimeoutwas entered, not that a timeout was handed to a peer. That's a reasonable compromise givenXdcProtocolHandleris an internal class that can't be substituted, but the test will silently start passing/failing if any otherAllPeersaccess enters this code path (e.g. theSendSyncInfoloop inOnCountdownTimer). A one-line comment saying that's the intent would save the next reader the trip.BuildManagerForReceivedTimeoutslargely re-creates the setup already present inHandleTimeoutVote_ThresholdReached_RetainsRecentTimeouts(lines 200-228) andFilterTimeout_DifferentCases_ReturnsExpected(lines 251-269), and sits alongside the existingBuildTimeoutCertificateManagerhelper. AGENTS.md asks for shared setup to be factored rather than copied — folding those call sites onto the new helper (or giving the existing one optional substitutes) would shrink the file instead of growing it.
Nit: the CurrentRound/Gap consts are declared between test methods; convention in this file (and .editorconfig) puts fields at the top of the type.
5bc4921 to
a1d7701
Compare
BroadcastTimeout sat below the epoch-switch-info null check in HandleTimeoutVote, so a node that cannot resolve its own epoch stopped relaying current-round timeouts entirely -- it kept accumulating them locally (_timeouts.Add is above the check) but went silent towards peers that were at the same round and needed them to build a TC. That state is not always transient: GetEpochSwitchInfo returns null when the gap block snapshot is missing, and TryRecoverSnapshot needs the gap block both processed and to have state, so a freshly synced or state-pruned node can stay in it for a whole epoch. Subnets feel it more sharply -- a 3-5 validator set needs 2-of-3 or 3-of-4 for a TC, so one silent node is 20-33% of the quorum. Move the broadcast above the lookup. Relay stays restricted to the current round, so the set of messages this node will forward is unchanged; only the dependency on local epoch resolution is removed. The //TODO in OnReceiveTimeout is dropped: relaying received timeouts is what BroadcastTimeout already does from HandleTimeoutVote, and extending relay to rounds this node has not reached yet is deliberately not done here. Upstream XDPoSChain does relay those (eth/bft/bft_handler.go broadcasts before timeoutHandler's round gate, and VerifyTimeoutMessage bounds only round < currentRound), but it also drops duplicates at a node-global knownTimeouts LRU before verification, which this client has no equivalent of. Without that, forwarding an unbounded forward-round message space -- any candidate key can sign Timeout(currentRound + K) for arbitrary K -- turns one inbound message into N outbound at every hop for no liveness gain, since no node at a lower round can pool it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
a1d7701 to
dc235b0
Compare
Changes
BroadcastTimeoutabove theepochSwitchInfo is nullcheck inTimeoutCertificateManager.HandleTimeoutVote, so relaying a current-round timeout no longer depends on this node being able to resolve its own epoch.//TODO: Broadcast TimeoutinOnReceiveTimeout(see below — relaying received timeouts already happens, and extending it to future rounds is deliberately not done).Why
_timeouts.Addsits above the epoch lookup but the broadcast sat below it, so a node that cannot resolve its epoch keeps accumulating timeouts locally while going silent towards its peers. Those peers are at the same round and need exactly those timeouts to reachmasternodes × CertificateThresholdand build a TC.That condition is not always transient:
GetEpochSwitchInforeturns null when the gap-block snapshot is missing, andTryRecoverSnapshotrequires the gap block to be both processed and to have state available, so a freshly synced or state-pruned node can remain in it for a whole epoch. Subnets feel it more sharply — a 3–5 validator set needs 2-of-3 or 3-of-4 for a TC, so one silent node is 20–33% of the quorum rather than statistical noise.Relay stays restricted to the current round, so the set of messages this node will forward is unchanged. Only the dependency on local epoch resolution is removed.
Why not also relay future-round timeouts
An earlier revision of this PR hoisted the relay all the way into
OnReceiveTimeout, which additionally forwarded timeouts for rounds this node has not reached. That has been dropped.Upstream XDPoSChain does relay those —
eth/bft/bft_handler.godoesverify → broadcastCh <- timeout → timeoutHandler, andVerifyTimeoutMessagebounds onlyround < currentRound, with no forward cap — so the wider version was at parity with the Go client. But upstream also drops duplicates at a node-globalknownTimeoutsLRU before verification (eth/handler.go:869), and this client has no equivalent:Handle(TimeoutMsg)goes straight toOnReceiveTimeout, so every arrival costs a freshecrecoverinFilterTimeout. Copying upstream's relay breadth without upstream's dedup imports the cost and not the mitigation.Concretely:
FilterTimeoutrejects onlyround < CurrentRound, so any key insnapshot.NextEpochCandidatescan signTimeout(CurrentRound + K, validGap)for arbitraryK. EachKis a distinctTimeout.Hash, so the per-peer_notifiedTimeoutscache dedups repeats but not a varying stream — one inbound message becomes N outbound at every hop, and no node at a lower round can pool those rounds, so there is no liveness gain to offset it. The benefit only exists for peers a few rounds ahead, which the originator's own all-peers broadcast, the per-TimeoutPeriodre-emission, andSyncInfoalready cover.Suggested follow-ups, in order: add a node-global receive-side dedup for votes and timeouts matching upstream's
knownTimeouts(a win independent of any of this), then revisit forward-round relay behind a forward bound mirroringVotesManager._maxRoundDistance.Types of changes
What types of changes does your code introduce?
Testing
Requires testing
If yes, did you write tests?
Notes on testing
OnReceiveTimeout_EpochSwitchInfoUnavailable_StillRelaysTimeoutcovers the fix.OnReceiveTimeout_DifferentRounds_RelaysAndPoolsAsExpectedis a guardrail pinning the stale / current / future relay-and-pool matrix, including that a future-round timeout is not relayed — so a future change in that direction has to be deliberate.Verified the fix is a genuine regression: with
TimeoutCertificateManager.csreset to the branch base, exactly one case fails —OnReceiveTimeout_EpochSwitchInfoUnavailable_StillRelaysTimeout("Expected to receive exactly 1 call… Actually received no matching calls"). The three round cases pass on the base too, as guardrails should.Full
Nethermind.Xdc.Testsuite: 556 passed, 0 failed.dotnet format whitespace --verify-no-changesclean.Documentation
Requires documentation update
Requires explanation in Release Notes