mirror of
https://github.com/luxfi/consensus.git
synced 2026-09-24 00:26:58 +00:00
deps: pulsar v1.9.0 pkg/ layout + threshold v1.12.0 dealerless daemon
Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
@@ -138,6 +138,6 @@ For k=32 sampling with β=15 rounds, expected unique signers ≈ 50-80.
|
||||
## References
|
||||
|
||||
- Existing Quasar family: `~/work/lux/consensus/protocol/{photon,wave,flare,focus,horizon,ray,nova,nebula,prism,quasar}`
|
||||
- Avalanche/Snowman metastable family (academic prior art; superseded by Quasar/Nova per LP-020, LP-134)
|
||||
- the classical metastable consensus family (academic prior art; superseded by Quasar/Nova per LP-020, LP-134)
|
||||
- LP-045 hierarchical quorum certs
|
||||
- `~/work/lux/proofs/pq-finality-no-bls.tex`
|
||||
|
||||
+43
-17
@@ -162,16 +162,20 @@ type PendingBlock struct {
|
||||
rePollBackoff time.Duration
|
||||
|
||||
// rePollAttempts counts how many times the re-poll loop has re-solicited this
|
||||
// block. Once it reaches maxRePollAttempts the block is ABANDONED for re-poll
|
||||
// purposes (rePollAbandoned) — re-soliciting the SAME block to peers who
|
||||
// cannot vote (they are behind its parent) is pure spam and never recovers it;
|
||||
// the catch-up path (requestCatchup) is what recovers a behind frontier.
|
||||
// block. For a NON-OWN (gossiped) block, once it reaches maxRePollAttempts the
|
||||
// block is ABANDONED for re-poll purposes (rePollAbandoned) — re-soliciting a
|
||||
// gossiped block to peers who cannot vote (they are behind its parent) is pure
|
||||
// spam and never recovers it; the catch-up path (requestCatchup) is what recovers
|
||||
// a behind frontier. An OWN proposal is never abandoned (the proposer drives it to
|
||||
// finality), so this counter only paces its bounded-backoff re-solicitation.
|
||||
rePollAttempts int
|
||||
|
||||
// rePollAbandoned is set once rePollAttempts hits the cap. An abandoned block
|
||||
// is NEVER re-polled again (no infinite spam), but is NOT deleted: it remains
|
||||
// pending and recoverable — a late cert (HandleIncomingCert) or a catch-up
|
||||
// that fills its parent can still finalize it.
|
||||
// rePollAbandoned is set once a NON-OWN block's rePollAttempts hits the cap. An
|
||||
// abandoned block is NEVER re-polled again (no infinite spam), but is NOT deleted:
|
||||
// it remains pending and recoverable — a late cert (HandleIncomingCert) or a
|
||||
// catch-up that fills its parent can still finalize it. An OWN proposal never sets
|
||||
// this flag: it is re-solicited until it decides, so a down/wedged/forked
|
||||
// designated proposer cannot halt the chain by starving the substitute's votes.
|
||||
rePollAbandoned bool
|
||||
|
||||
// IsOwnProposal is true when this node built and proposed the block. It now
|
||||
@@ -1106,11 +1110,15 @@ const (
|
||||
// every maxRePollBackoff, turning a 250ms storm into a trickle.
|
||||
maxRePollBackoff = 16 * time.Second
|
||||
|
||||
// maxRePollAttempts is the hard cap on re-poll attempts per block. After this
|
||||
// many re-solicitations the block is abandoned for re-poll (rePollAbandoned)
|
||||
// — it is never re-polled again (but stays pending, recoverable via cert or
|
||||
// catch-up). With doubling from a 250ms base, 8 attempts span
|
||||
// 0.25+0.5+1+2+4+8+16+16 ≈ 48s of backstop before giving up on re-poll.
|
||||
// maxRePollAttempts is the hard cap on re-poll attempts for a NON-OWN (gossiped)
|
||||
// block. After this many re-solicitations such a block is abandoned for re-poll
|
||||
// (rePollAbandoned) — it is never re-polled again (but stays pending, recoverable
|
||||
// via cert or catch-up): re-soliciting a gossiped block whose voters are behind
|
||||
// its parent is spam that re-poll cannot fix. An UNDECIDED OWN proposal is NEVER
|
||||
// abandoned (see rePollAllPending): the proposer keeps re-soliciting it until it
|
||||
// finalizes, matching avalanchego's "re-poll a processing block until decided".
|
||||
// With doubling from a 250ms base, 8 attempts span
|
||||
// 0.25+0.5+1+2+4+8+16+16 ≈ 48s of backstop before giving up on a gossiped block.
|
||||
maxRePollAttempts = 8
|
||||
|
||||
// catchupCooldown rate-limits ancestor fetches per missing parent: at most one
|
||||
@@ -1261,8 +1269,9 @@ func (t *Transitive) rePollAllPending(ctx context.Context, base time.Duration) {
|
||||
continue
|
||||
}
|
||||
|
||||
// This block is due. Record the attempt, advance the backoff (double, cap),
|
||||
// and abandon once the cap is hit so it is NEVER re-polled again.
|
||||
// This block is due. Record the attempt and advance the backoff (double,
|
||||
// cap) so re-solicitation is a bounded trickle (≤ maxRePollBackoff), never a
|
||||
// storm.
|
||||
pending.lastRePoll = now
|
||||
pending.rePollAttempts++
|
||||
next := window * 2
|
||||
@@ -1270,9 +1279,26 @@ func (t *Transitive) rePollAllPending(ctx context.Context, base time.Duration) {
|
||||
next = maxRePollBackoff
|
||||
}
|
||||
pending.rePollBackoff = next
|
||||
if pending.rePollAttempts >= maxRePollAttempts {
|
||||
// LIVENESS (the down/wedged/forked-proposer halt): an UNDECIDED OWN proposal
|
||||
// is NEVER abandoned. This node BUILT it on the finalized tip (its voters
|
||||
// therefore HAVE the parent and CAN vote), and as the proposer it owns driving
|
||||
// it to an α-of-K cert — so it must keep re-soliciting until the block decides
|
||||
// (then it leaves pendingBlocks and the re-poll quiesces). This is exactly
|
||||
// avalanchego's contract: re-poll a PROCESSING block until it is decided, and
|
||||
// only quiesce at NumProcessing()==0 (snow/engine/snowman voter.go +
|
||||
// Engine.Gossip's 100ms repoll). Abandoning an own proposal after a fixed
|
||||
// attempt cap was the Lux-only divergence that froze mainnet C-Chain: the
|
||||
// substitute's canonical block stopped being re-solicited and the chain halted
|
||||
// even though the honest majority was ready to vote (zero-margin 4-of-5 once a
|
||||
// 5th validator forks/wedges). The bounded backoff above keeps this storm-safe.
|
||||
//
|
||||
// A NON-own (gossiped) block keeps the attempt cap: re-soliciting a block whose
|
||||
// voters are BEHIND its parent (the gossip-from-an-ahead-peer case) is pure spam
|
||||
// and never recovers it — that block recovers via cert-gossip or the catch-up
|
||||
// fetch, not by re-poll. So the cap still bounds the follower path.
|
||||
if !pending.IsOwnProposal && pending.rePollAttempts >= maxRePollAttempts {
|
||||
pending.rePollAbandoned = true
|
||||
t.log.Warn("re-poll: block abandoned after attempt cap — not re-soliciting further (recoverable via cert/catch-up)",
|
||||
t.log.Warn("re-poll: gossiped block abandoned after attempt cap — not re-soliciting further (recoverable via cert/catch-up)",
|
||||
"blockID", blockID, "attempts", pending.rePollAttempts)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,292 @@
|
||||
// Copyright (C) 2019-2026, Lux Industries, Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
// proposer_liveness_test.go — the DURABLE regression suite for the
|
||||
// down/wedged/forked-designated-proposer LIVENESS halt (Lux mainnet C-Chain,
|
||||
// stuck producing 1082825 above a finalized 1082824).
|
||||
//
|
||||
// ROOT CAUSE (in the engine's terms): a height's designated proposer is
|
||||
// down/wedged/FORKED (present, sampled, gossiping, but it never produces a
|
||||
// canonical-extending block that finalizes). A SUBSTITUTE (the next slot's
|
||||
// designated proposer) builds the canonical block, self-votes, and solicits peer
|
||||
// votes ONCE. If the α-of-K signed votes do not assemble on that first
|
||||
// solicitation — the common case when a follower is briefly behind, or the first
|
||||
// PushQuery is dropped, or (the mainnet reality) one of the five validators is a
|
||||
// permanent non-voter so the OTHER four must ALL vote with ZERO margin — nothing
|
||||
// re-solicits the missing votes once `rePollAllPending` ABANDONS the block after
|
||||
// `maxRePollAttempts` (the Lux-only divergence from avalanchego, which re-polls a
|
||||
// processing block until it decides and only quiesces at NumProcessing()==0). The
|
||||
// block then sits in pendingBlocks, never re-solicited, and the chain HALTS.
|
||||
//
|
||||
// THE FIX (proven here): an UNDECIDED OWN proposal is NEVER abandoned for re-poll.
|
||||
// It is re-solicited forever on the bounded (capped) backoff schedule until it
|
||||
// decides (and is removed from pendingBlocks, so the re-poll naturally quiesces) —
|
||||
// matching avalanchego's "re-poll while processing, quiesce when decided". This is
|
||||
// a pure LIVENESS retry: it changes NOTHING about the finality predicate (a block
|
||||
// still finalizes ONLY on a verified α-of-K cert that clears the stake gate), so
|
||||
// the safety tests below (sub-quorum, forged, out-of-set, tampered) still reject.
|
||||
//
|
||||
// fails-before / passes-after is shown for every behavior: the pre-fix abandon is
|
||||
// asserted RED, the post-fix persistence GREEN.
|
||||
package chain
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/luxfi/consensus/config"
|
||||
"github.com/luxfi/ids"
|
||||
)
|
||||
|
||||
// params5Prod returns the PRODUCTION 5-validator quorum params: K=5, α=4
|
||||
// (⌊2·5/3⌋+1 — what bftCommittee derives for a 5-node value network, see
|
||||
// dynamic_test.go which asserts K5/α4). The finality_test.go params5() uses α=3
|
||||
// for legacy count tests; the liveness/safety properties here must hold at the
|
||||
// REAL production threshold where the 4 healthy nodes are the EXACT quorum (zero
|
||||
// margin — the mainnet condition after a 5th validator forks/wedges).
|
||||
func params5Prod() config.Parameters {
|
||||
p := params5()
|
||||
p.AlphaPreference = 4
|
||||
p.AlphaConfidence = 4
|
||||
return p
|
||||
}
|
||||
|
||||
// reSolicitProbe is a BlockProposer that records how many times the engine
|
||||
// re-solicits votes for each block (RequestVotes), and fires an optional hook on
|
||||
// each solicitation. It is the instrument that makes the re-poll behavior
|
||||
// observable: pre-fix the count plateaus at the abandon cap; post-fix it grows
|
||||
// without bound while the block is undecided. The hook models a follower that
|
||||
// only votes once it is (re-)solicited — the production reality, where a node that
|
||||
// missed the first gossip casts its vote only when the proposer re-sends it.
|
||||
type reSolicitProbe struct {
|
||||
mu sync.Mutex
|
||||
requests map[ids.ID]int
|
||||
proposed map[ids.ID]int
|
||||
onRequest func(blockID ids.ID, cumulative int)
|
||||
}
|
||||
|
||||
func newReSolicitProbe() *reSolicitProbe {
|
||||
return &reSolicitProbe{requests: map[ids.ID]int{}, proposed: map[ids.ID]int{}}
|
||||
}
|
||||
|
||||
func (p *reSolicitProbe) Propose(_ context.Context, proposal BlockProposal) error {
|
||||
p.mu.Lock()
|
||||
p.proposed[proposal.BlockID]++
|
||||
p.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *reSolicitProbe) RequestVotes(_ context.Context, req VoteRequest) error {
|
||||
p.mu.Lock()
|
||||
p.requests[req.BlockID]++
|
||||
n := p.requests[req.BlockID]
|
||||
hook := p.onRequest
|
||||
p.mu.Unlock()
|
||||
if hook != nil {
|
||||
hook(req.BlockID, n)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *reSolicitProbe) requestCount(id ids.ID) int {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
return p.requests[id]
|
||||
}
|
||||
|
||||
// driveRePoll forces `cycles` re-poll passes over the engine's pending blocks
|
||||
// synchronously, each pass made "due" by resetting every undecided block's backoff
|
||||
// window to the distant past. This drives the re-poll attempt counter deterministically
|
||||
// WITHOUT waiting on the exponential backoff (so the test is fast and not timing-flaky),
|
||||
// and is isolated from the background ticker by the caller using a long RoundTO so the
|
||||
// background loop never fires during the synchronous drive.
|
||||
func driveRePoll(e *Transitive, cycles int) {
|
||||
for i := 0; i < cycles; i++ {
|
||||
e.mu.Lock()
|
||||
for _, pb := range e.pendingBlocks {
|
||||
if pb.Decided {
|
||||
continue
|
||||
}
|
||||
pb.lastRePoll = time.Now().Add(-time.Hour)
|
||||
pb.rePollBackoff = time.Nanosecond
|
||||
}
|
||||
e.mu.Unlock()
|
||||
e.rePollAllPending(context.Background(), time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// LIVENESS — the core bug. An undecided own proposal must be re-solicited
|
||||
// forever, never abandoned, so the substitute's canonical block finalizes once
|
||||
// the honest majority's votes arrive.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
// TestProposerLiveness_UndecidedOwnProposal_NeverAbandoned is the DIRECT
|
||||
// fails-before/passes-after proof of the fix. A substitute proposer (node 0)
|
||||
// builds the canonical next block with only a sub-quorum of votes present (the
|
||||
// wedged/forked 5th validator never votes, and a 4th honest vote has not yet
|
||||
// arrived). The engine must keep re-soliciting it forever.
|
||||
//
|
||||
// PRE-FIX: rePollAllPending sets rePollAbandoned after maxRePollAttempts (8), then
|
||||
// SKIPS the block — RequestVotes plateaus near 8 and the block is never re-solicited
|
||||
// again → permanent halt.
|
||||
// POST-FIX: the undecided own proposal is never abandoned; RequestVotes keeps firing
|
||||
// every pass.
|
||||
func TestProposerLiveness_UndecidedOwnProposal_NeverAbandoned(t *testing.T) {
|
||||
vs := newTestValidatorSet(5)
|
||||
params := params5Prod()
|
||||
params.RoundTO = 10 * time.Second // park the background ticker; we drive re-poll manually
|
||||
rec := &recordingGossiper{}
|
||||
e, chainID := newQuorumEngine(t, params, vs, 0, rec)
|
||||
|
||||
cp := newReSolicitProbe()
|
||||
e.SetProposer(cp)
|
||||
|
||||
// Node 0 is the substitute: it builds the canonical block at the stuck height.
|
||||
blk := newTestBlock(1, ids.Empty, "substitute-canonical")
|
||||
pos := trackProposal(e, chainID, blk, 0)
|
||||
// Only one peer (node 1) has voted so far → 2 of 5, below α=4. The block is a
|
||||
// genuine, healthy, undecided own proposal awaiting the rest of the honest quorum.
|
||||
e.ReceiveVote(vs.signedVote(1, pos))
|
||||
|
||||
const cycles = 40 // >> maxRePollAttempts (8)
|
||||
driveRePoll(e, cycles)
|
||||
|
||||
e.mu.RLock()
|
||||
pb := e.pendingBlocks[blk.id]
|
||||
abandoned := pb != nil && pb.rePollAbandoned
|
||||
e.mu.RUnlock()
|
||||
reqs := cp.requestCount(blk.id)
|
||||
|
||||
if abandoned {
|
||||
t.Fatalf("LIVENESS HALT: undecided own proposal was ABANDONED for re-poll after %d attempts "+
|
||||
"(rePollAbandoned=true) — the substitute's canonical block stops being re-solicited and the "+
|
||||
"chain halts forever. avalanchego re-polls a processing block until it decides.", reqs)
|
||||
}
|
||||
if reqs <= maxRePollAttempts {
|
||||
t.Fatalf("re-solicitation stopped early: RequestVotes fired only %d times over %d passes "+
|
||||
"(expected continuous re-solicitation > maxRePollAttempts=%d). A block awaiting the honest "+
|
||||
"quorum must keep being solicited.", reqs, cycles, maxRePollAttempts)
|
||||
}
|
||||
}
|
||||
|
||||
// TestProposerLiveness_LateHonestVote_FinalizesViaPersistentRePoll is the
|
||||
// EMERGENT proof: a 4th honest voter (node 3) only casts its vote in RESPONSE to a
|
||||
// re-solicitation that arrives AFTER the pre-fix abandon point (it was briefly
|
||||
// behind). The block finalizes iff the proposer keeps re-soliciting past that
|
||||
// point — i.e. only post-fix. The wedged/forked 5th validator (node 4) never votes.
|
||||
func TestProposerLiveness_LateHonestVote_FinalizesViaPersistentRePoll(t *testing.T) {
|
||||
vs := newTestValidatorSet(5)
|
||||
params := params5Prod()
|
||||
params.RoundTO = 10 * time.Second
|
||||
rec := &recordingGossiper{}
|
||||
e, chainID := newQuorumEngine(t, params, vs, 0, rec)
|
||||
|
||||
cp := newReSolicitProbe()
|
||||
e.SetProposer(cp)
|
||||
|
||||
blk := newTestBlock(7, ids.Empty, "late-quorum")
|
||||
pos := trackProposal(e, chainID, blk, 0) // node 0 self-vote = 1
|
||||
e.ReceiveVote(vs.signedVote(1, pos)) // node 1
|
||||
e.ReceiveVote(vs.signedVote(2, pos)) // node 2 → 3 of 5, still < α=4
|
||||
|
||||
// Node 3 is briefly behind: it casts its (4th, quorum-completing) vote ONLY once
|
||||
// the proposer has re-solicited past the abandon point. Node 4 (wedged/forked)
|
||||
// never votes.
|
||||
const lateSolicit = maxRePollAttempts + 4 // 12: only reachable if re-poll never abandons
|
||||
var once sync.Once
|
||||
cp.onRequest = func(id ids.ID, n int) {
|
||||
if id == blk.id && n >= lateSolicit {
|
||||
once.Do(func() { e.ReceiveVote(vs.signedVote(3, pos)) })
|
||||
}
|
||||
}
|
||||
|
||||
// Drive re-poll well past both the abandon cap and the late-solicit threshold.
|
||||
driveRePoll(e, 60)
|
||||
|
||||
finalized := waitFor(3*time.Second, func() bool { return e.IsAccepted(blk.id) })
|
||||
reqs := cp.requestCount(blk.id)
|
||||
if !finalized {
|
||||
t.Fatalf("LIVENESS HALT: block never finalized. RequestVotes fired %d times; the late honest "+
|
||||
"vote is only solicited at attempt %d, but the pre-fix abandon caps re-poll at "+
|
||||
"maxRePollAttempts=%d, so node 3 is never re-solicited and the 4-of-5 quorum never assembles.",
|
||||
reqs, lateSolicit, maxRePollAttempts)
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// SAFETY — the fix must NOT lower the BFT threshold. Persistent re-solicitation
|
||||
// re-sends a block; it can NEVER manufacture a vote, so a sub-quorum, a forged
|
||||
// vote, or an out-of-set signer still cannot finalize.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
// TestProposerSafety_SubQuorumNeverFinalizes_EvenWithPersistentRePoll proves the
|
||||
// liveness retry does not weaken safety: 3 of 5 (< α=4) is re-solicited forever and
|
||||
// STILL never finalizes. A genuine minority cannot finalize no matter how long it
|
||||
// re-polls.
|
||||
func TestProposerSafety_SubQuorumNeverFinalizes_EvenWithPersistentRePoll(t *testing.T) {
|
||||
vs := newTestValidatorSet(5)
|
||||
params := params5Prod()
|
||||
params.RoundTO = 10 * time.Second
|
||||
rec := &recordingGossiper{}
|
||||
e, chainID := newQuorumEngine(t, params, vs, 0, rec)
|
||||
e.SetProposer(newReSolicitProbe())
|
||||
|
||||
blk := newTestBlock(3, ids.Empty, "minority")
|
||||
pos := trackProposal(e, chainID, blk, 0) // self = 1
|
||||
e.ReceiveVote(vs.signedVote(1, pos)) // 2
|
||||
e.ReceiveVote(vs.signedVote(2, pos)) // 3 of 5 < α=4
|
||||
|
||||
driveRePoll(e, 60) // re-solicit far past the old abandon cap
|
||||
|
||||
if waitFor(500*time.Millisecond, func() bool { return e.IsAccepted(blk.id) }) {
|
||||
t.Fatal("SAFETY VIOLATION: a sub-quorum (3 of 5, below α=4) finalized — persistent re-poll " +
|
||||
"must re-SOLICIT only; it must never lower the α-of-K threshold.")
|
||||
}
|
||||
rec.mu.Lock()
|
||||
certs := len(rec.certs)
|
||||
rec.mu.Unlock()
|
||||
if certs != 0 {
|
||||
t.Fatalf("no cert may be produced below α; got %d", certs)
|
||||
}
|
||||
}
|
||||
|
||||
// TestProposerSafety_ForgedAndOutOfSetVotes_NeverCount proves re-solicitation
|
||||
// cannot be exploited: an out-of-set signer and a real validator's signature lifted
|
||||
// to the WRONG position are both dropped, so even re-solicited forever the block
|
||||
// stays at its 3 genuine votes (< α) and never finalizes.
|
||||
func TestProposerSafety_ForgedAndOutOfSetVotes_NeverCount(t *testing.T) {
|
||||
vs := newTestValidatorSet(5)
|
||||
params := params5Prod()
|
||||
params.RoundTO = 10 * time.Second
|
||||
rec := &recordingGossiper{}
|
||||
e, chainID := newQuorumEngine(t, params, vs, 0, rec)
|
||||
e.SetProposer(newReSolicitProbe())
|
||||
|
||||
blk := newTestBlock(9, ids.Empty, "forged-attack")
|
||||
pos := trackProposal(e, chainID, blk, 0) // self = 1
|
||||
e.ReceiveVote(vs.signedVote(1, pos)) // 2
|
||||
e.ReceiveVote(vs.signedVote(2, pos)) // 3 (genuine)
|
||||
|
||||
// (a) out-of-set signer: keys unknown to vs.
|
||||
outsider := newTestValidatorSet(1)
|
||||
e.ReceiveVote(Vote{
|
||||
BlockID: blk.id, NodeID: outsider.nodeID(0), Accept: true, SignedAt: time.Now(),
|
||||
Signature: outsider.sign(0, pos), ParentID: pos.ParentID, Round: pos.Round,
|
||||
})
|
||||
// (b) real validator (node 3) signature over a DIFFERENT (wrong) position.
|
||||
wrong := pos
|
||||
wrong.Height++
|
||||
e.ReceiveVote(Vote{
|
||||
BlockID: blk.id, NodeID: vs.nodeID(3), Accept: true, SignedAt: time.Now(),
|
||||
Signature: vs.sign(3, wrong), ParentID: pos.ParentID, Round: pos.Round,
|
||||
})
|
||||
|
||||
driveRePoll(e, 60)
|
||||
|
||||
if waitFor(500*time.Millisecond, func() bool { return e.IsAccepted(blk.id) }) {
|
||||
t.Fatal("SAFETY VIOLATION: forged/out-of-set/wrong-position votes were counted toward the quorum.")
|
||||
}
|
||||
}
|
||||
@@ -200,11 +200,17 @@ func TestRecovery_RePollBacksOffExponentially(t *testing.T) {
|
||||
time.Sleep(window)
|
||||
|
||||
got := prop.count()
|
||||
// Linear would be ~ window/RoundTO = 50. Backoff (10,20,40,80,160,...) capped
|
||||
// at maxRePollAttempts (8) yields at most maxRePollAttempts re-polls EVER.
|
||||
if got > int64(maxRePollAttempts) {
|
||||
t.Fatalf("re-poll must be bounded by the attempt cap (%d), got %d over %v — poll storm not fixed",
|
||||
maxRePollAttempts, got, window)
|
||||
// Linear (a fixed-cadence storm) would be ~ window/RoundTO = 50. EXPONENTIAL
|
||||
// backoff (10,20,40,80,160,…) makes the count GEOMETRIC in the window —
|
||||
// log2(window/RoundTO) ≈ log2(50) ≈ 6 — so it is bounded far below linear. This
|
||||
// is the storm-safety invariant, and it does NOT depend on the attempt cap: an
|
||||
// own proposal is re-solicited until it decides (never abandoned), but the
|
||||
// backoff keeps that a slow trickle, not a 250ms storm. Ceiling 12 is a safe
|
||||
// geometric bound for this window, still ≪ the linear 50.
|
||||
const geometricCeiling = 12
|
||||
if got > geometricCeiling {
|
||||
t.Fatalf("re-poll must be bounded by exponential backoff (geometric ≈%d), got %d over %v — poll storm not fixed",
|
||||
geometricCeiling, got, window)
|
||||
}
|
||||
if got == 0 {
|
||||
t.Fatalf("liveness: a stuck own block must be re-polled at least once, got 0")
|
||||
|
||||
@@ -13,19 +13,19 @@ require (
|
||||
github.com/luxfi/crypto v1.19.26
|
||||
github.com/luxfi/database v1.19.3
|
||||
github.com/luxfi/dkg v0.3.5
|
||||
github.com/luxfi/ids v1.2.15
|
||||
github.com/luxfi/ids v1.3.0
|
||||
github.com/luxfi/lattice/v7 v7.1.4
|
||||
github.com/luxfi/log v1.4.3
|
||||
github.com/luxfi/magnetar v1.2.3
|
||||
github.com/luxfi/math v1.4.1
|
||||
github.com/luxfi/metric v1.5.8
|
||||
github.com/luxfi/p2p v1.21.1
|
||||
github.com/luxfi/pulsar v1.8.0
|
||||
github.com/luxfi/pulsar v1.9.0
|
||||
github.com/luxfi/runtime v1.1.1
|
||||
github.com/luxfi/threshold v1.10.2
|
||||
github.com/luxfi/threshold v1.12.0
|
||||
github.com/luxfi/validators v1.2.0
|
||||
github.com/luxfi/version v1.0.1
|
||||
github.com/luxfi/warp v1.19.3
|
||||
github.com/luxfi/warp v1.24.0
|
||||
github.com/luxfi/zap v0.8.1
|
||||
github.com/prometheus/client_golang v1.23.2
|
||||
github.com/prometheus/client_model v0.6.2
|
||||
|
||||
@@ -216,6 +216,8 @@ github.com/luxfi/geth v1.17.11 h1:opyVqYiH3t4qglAdMymudxKIO09O1LWB9tKGc57o9sA=
|
||||
github.com/luxfi/geth v1.17.11/go.mod h1:3vQfQJd9JC+AVBjxNXa9PYQOqpbE2dKu8E3jqhPZ3LU=
|
||||
github.com/luxfi/ids v1.2.15 h1:omE+E4+0Poj9DzM11ejSFgteaSQ3KDHi5g54iH6jcxI=
|
||||
github.com/luxfi/ids v1.2.15/go.mod h1:Fj73K5xcblvdE0SxU/ip+jE8VqNdu+80548su5KJ7xI=
|
||||
github.com/luxfi/ids v1.3.0 h1:11xnwRDm6zQzbqcRnkFujOYkvhK4Fs/+g+sKRlRUNsU=
|
||||
github.com/luxfi/ids v1.3.0/go.mod h1:6vpdcdZW0qxeade+3xby8aLTutbcJ7O0r8+fNQrksGI=
|
||||
github.com/luxfi/lattice/v7 v7.1.4 h1:hQR02M6cHTAV5+joOPi9gb9Gm+z/hKJnhJF4IlciIJs=
|
||||
github.com/luxfi/lattice/v7 v7.1.4/go.mod h1:DmIQFi3mJiehVsR235l1NKYEU0JhU649OX5p7gMEW2c=
|
||||
github.com/luxfi/lens v0.1.4 h1:goGjGDXx2BNdjzXDunL5QT8elK2ZyCcc0z8TAbtWYrg=
|
||||
@@ -246,12 +248,16 @@ github.com/luxfi/pulsar v1.7.1 h1:q/TVn0TWeMImm67EJZ3CMmBAGQCW+bhIX7YQGL3aYK0=
|
||||
github.com/luxfi/pulsar v1.7.1/go.mod h1:wW+V2mUVOqppN68+n5u9Qtlfcscd0vOz4acvubcSOKI=
|
||||
github.com/luxfi/pulsar v1.8.0 h1:ZWOwVQ8HcEJzWljj2blkNIRKwktngPJc05knkZtRjVM=
|
||||
github.com/luxfi/pulsar v1.8.0/go.mod h1:wW+V2mUVOqppN68+n5u9Qtlfcscd0vOz4acvubcSOKI=
|
||||
github.com/luxfi/pulsar v1.9.0 h1:c0JnatYF79aN87aof9VlYjIoCzmixxrgNPeUUuh8ScU=
|
||||
github.com/luxfi/pulsar v1.9.0/go.mod h1:1+/atAiiiOm9RnXM3c66eHF3garjAa3C+sn4rAU7JUU=
|
||||
github.com/luxfi/runtime v1.1.1 h1:vOMe82PL3bpSbslS7p69dKRpbr2qW2vOOAwej3UkkmU=
|
||||
github.com/luxfi/runtime v1.1.1/go.mod h1:fmG6+Zxj4wSNlXwiUfDthQDY+SfxVu6S0fX2lL6VbrE=
|
||||
github.com/luxfi/sampler v1.1.0 h1:u3iRDl7V06ARh0e85h3HT+aZ1saCFo2yMMsh+dCJbqk=
|
||||
github.com/luxfi/sampler v1.1.0/go.mod h1:kJa53S3tC9+VSbuV3RFu68MmbCCBlr2UM39LOClQ/Hs=
|
||||
github.com/luxfi/threshold v1.10.2 h1:FFj6H+wd3DI/Y5J1Jb3bClCAtFi/ueItWtBoNBIdIo4=
|
||||
github.com/luxfi/threshold v1.10.2/go.mod h1:GETt6Px9vrQ19J9Mnpf5LgMXAdvJu8ijyByOp+QtD48=
|
||||
github.com/luxfi/threshold v1.12.0 h1:JJ369xC/YyDvrqXj+xFoK98nP2rUM099qFs03hBvq/M=
|
||||
github.com/luxfi/threshold v1.12.0/go.mod h1:iuRQGDAy8ZKjQhZjkSKg7NtbP75/8Up9zj52y7IuyZo=
|
||||
github.com/luxfi/utils v1.2.0 h1:gtEiI7/NM6PQ/OasEpH0PvB+e5hIS/tpum9r64pYjMc=
|
||||
github.com/luxfi/utils v1.2.0/go.mod h1:T2OCKT1xG9jtKR/gyJQoSkticzrE9WFQ8eohJHGu9Fg=
|
||||
github.com/luxfi/validators v1.2.0 h1:VygpiBqBAdGrfkb7xzE2yrVmnXaqE+hm8FLWdGXO7G8=
|
||||
@@ -262,6 +268,8 @@ github.com/luxfi/vm v1.2.3 h1:IdfB02OtDmdy/YNxaIR8DquPp5Aq0fi5QXBqGHoRkug=
|
||||
github.com/luxfi/vm v1.2.3/go.mod h1:ZXmw1sKA6GL3Ma3BfYUv5Fzvx3aWu2QMtkxF3lL/3R4=
|
||||
github.com/luxfi/warp v1.19.3 h1:tU6aAniYrPiRGrht7oFSwsUmk5KBb9RDFSyZq3lJ4HY=
|
||||
github.com/luxfi/warp v1.19.3/go.mod h1:kqyk7Fa5mgw1zzMLqjrZWVLuTef35CCW0gOEv10UlZk=
|
||||
github.com/luxfi/warp v1.24.0 h1:jrcJNlbOiZsAEopJMy9bSaCwI5NDZ8qgp/6sNoXqepg=
|
||||
github.com/luxfi/warp v1.24.0/go.mod h1:bKvTi24JHlANsl7qkWZAVr/DsMfvwy42f+Cc9x4+Sq8=
|
||||
github.com/luxfi/zap v0.8.1 h1:IzYWf4T35QjsUJhoNbuhew8MDDRYmlZUVx4gKBNLjMA=
|
||||
github.com/luxfi/zap v0.8.1/go.mod h1:gUoQ/V9nTLLIb4/n0bY/L2zQDYK6pDBG121rK4t8bpg=
|
||||
github.com/luxfi/zapdb v1.10.0 h1:1lLHEmkyC0BucnA/zjQYsMkUVxuEo2vQkEaQGjYfuuc=
|
||||
|
||||
@@ -46,7 +46,7 @@ import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
pulsarwire "github.com/luxfi/pulsar/ref/go/pkg/pulsar"
|
||||
pulsarwire "github.com/luxfi/pulsar/pkg/pulsar"
|
||||
coronaThreshold "github.com/luxfi/threshold/protocols/corona"
|
||||
)
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ import (
|
||||
|
||||
"github.com/luxfi/crypto/bls"
|
||||
magnetar "github.com/luxfi/magnetar/ref/go/pkg/magnetar"
|
||||
pulsarwire "github.com/luxfi/pulsar/ref/go/pkg/pulsar"
|
||||
pulsarwire "github.com/luxfi/pulsar/pkg/pulsar"
|
||||
coronaThreshold "github.com/luxfi/threshold/protocols/corona"
|
||||
pulsar "github.com/luxfi/threshold/protocols/pulsar"
|
||||
)
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/luxfi/ids"
|
||||
pulsarwire "github.com/luxfi/pulsar/ref/go/pkg/pulsar"
|
||||
pulsarwire "github.com/luxfi/pulsar/pkg/pulsar"
|
||||
)
|
||||
|
||||
// mapResolver is a test CommitteeKeyResolver backed by a map. A committee with
|
||||
|
||||
Reference in New Issue
Block a user