From 5959720b3c3d8a3f32b70b8758bd7d32d13869d6 Mon Sep 17 00:00:00 2001 From: Yacov Manevich Date: Fri, 28 Aug 2026 16:05:03 +0200 Subject: [PATCH] Build an empty block if tip is notarized but not finalized This commit makes the epoch detect that the tip of the chain is only notarized but not finalized for a prolonged period of time, and then cancel the block building or waiting for a need to build a block by passing a ErrShouldBuildEmptyBlock cause to the context. Then, the MSM returns an empty block. Signed-off-by: Yacov Manevich --- common/api.go | 13 +- msm/msm.go | 9 ++ msm/msm_test.go | 111 ++++++++++++++ simplex/empty_block_builder.go | 54 +++++++ simplex/empty_block_builder_test.go | 229 ++++++++++++++++++++++++++++ simplex/epoch.go | 26 +++- simplex/epoch_test.go | 115 ++++++++++++++ testutil/controlled.go | 4 + 8 files changed, 554 insertions(+), 7 deletions(-) create mode 100644 simplex/empty_block_builder.go create mode 100644 simplex/empty_block_builder_test.go diff --git a/common/api.go b/common/api.go index 51bfbc95..ce6cc288 100644 --- a/common/api.go +++ b/common/api.go @@ -6,6 +6,7 @@ package common import ( "bytes" "context" + "errors" "fmt" "slices" @@ -36,11 +37,17 @@ type Logger interface { Verbo(msg string, fields ...zap.Field) } +var ( + ErrShouldBuildEmptyBlock = errors.New("should build empty block") +) + type BlockBuilder interface { // BuildBlock blocks until some transactions are available to be batched into a block, - // in which case a block and true are returned. - // When the given context is cancelled by the caller, returns false. - // The given metadata and blacklist are encoded into the built block. + // and the given metadata and blacklist are encoded into the built block. + // Returns a block and true unless the given context is cancelled by the caller. + // When the given context is cancelled by the caller: + // returns an empty block and true, if the context was cancelled with ErrShouldBuildEmptyBlock + // returns nil, false otherwise. BuildBlock(ctx context.Context, metadata ProtocolMetadata, blacklist Blacklist) (VerifiedBlock, bool) // WaitForPendingBlock returns when either the given context is cancelled, diff --git a/msm/msm.go b/msm/msm.go index 72d4831c..5e1f264a 100644 --- a/msm/msm.go +++ b/msm/msm.go @@ -552,6 +552,12 @@ func (sm *StateMachine) buildBlockOrTransitionEpoch(ctx context.Context, parentB blockBuildingDecider := sm.createBlockBuildingDecider(newSimplexEpochInfo.PChainReferenceHeight) decisionToBuildBlock, err := blockBuildingDecider.shouldBuildBlock(ctx) if err != nil { + if errors.Is(context.Cause(ctx), common.ErrShouldBuildEmptyBlock) { + now := sm.GetTime() + icmEpochInfo := computeICMEpochInfo(parentBlock, sm.ComputeICMEpoch, now) + pChainHeight := sm.GetPChainHeightForProposing() + return wrapBlock(nil, newSimplexEpochInfo, pChainHeight, simplexMetadata, simplexBlacklist, now, icmEpochInfo, nil), nil + } return nil, err } @@ -574,6 +580,9 @@ func (sm *StateMachine) buildBlockOrTransitionEpoch(ctx context.Context, parentB if decisionToBuildBlock.buildInnerBlock { innerBlock, err = sm.BlockBuilder.BuildBlock(ctx, icmEpochInfo.PChainEpochHeight) if err != nil { + if errors.Is(context.Cause(ctx), common.ErrShouldBuildEmptyBlock) { + return wrapBlock(nil, newSimplexEpochInfo, decisionToBuildBlock.pChainHeight, simplexMetadata, simplexBlacklist, now, icmEpochInfo, nil), nil + } return nil, err } } diff --git a/msm/msm_test.go b/msm/msm_test.go index 52c6ad41..7474f7fb 100644 --- a/msm/msm_test.go +++ b/msm/msm_test.go @@ -7,6 +7,7 @@ import ( "context" "crypto/rand" "crypto/sha256" + "errors" "fmt" "math" "testing" @@ -2194,3 +2195,113 @@ func TestMSMWaitForPendingBlock(t *testing.T) { }) } } + +// buildFirstSimplexBlock builds and stores the first simplex ("zero") block on top of +// genesis, so that a follow-up normal-op block can be built on it. +func buildFirstSimplexBlock(t *testing.T, sm *StateMachine, tc *testConfig) *StateMachineBlock { + md := common.ProtocolMetadata{Round: 1, Seq: 1, Epoch: 1, Prev: genesisBlock.Digest()} + block, err := sm.BuildBlock(context.Background(), md, emptyBlacklist) + require.NoError(t, err) + require.NotNil(t, block) + tc.blockStore[1] = &outerBlock{block: *block} + return block +} + +// emptyBlockRequestingBuilder is a BlockBuilder that models the VM's inner block builder +// being cancelled: BuildBlock optionally cancels the build context with a given cause and +// then returns an error. WaitForPendingBlock is a no-op so the block-building decider +// proceeds to decide that an inner block should be built. +type emptyBlockRequestingBuilder struct { + cancel context.CancelCauseFunc + cancelCause error // if non-nil, BuildBlock cancels the context with this cause before failing + err error // error returned by BuildBlock +} + +func (b *emptyBlockRequestingBuilder) BuildBlock(context.Context, uint64) (avalanchego.VMBlock, error) { + if b.cancelCause != nil { + b.cancel(b.cancelCause) + } + return nil, b.err +} + +func (b *emptyBlockRequestingBuilder) WaitForPendingBlock(context.Context) {} + +// TestMSMBuildBlockBuildsEmptyBlockWhenBlockBuildingCancelled covers the branch in +// buildBlockOrTransitionEpoch where the block-building decider returns an error: when the +// context was cancelled with ErrShouldBuildEmptyBlock, an empty block is returned instead +// of propagating the error. +func TestMSMBuildBlockBuildsEmptyBlockWhenBlockBuildingCancelled(t *testing.T) { + t.Run("empty-block cause yields an empty block", func(t *testing.T) { + sm, tc := newStateMachine(t) + block1 := buildFirstSimplexBlock(t, sm, tc) + + md := common.ProtocolMetadata{Round: 2, Seq: 2, Epoch: 1, Prev: block1.Digest()} + ctx, cancel := context.WithCancelCause(context.Background()) + cancel(common.ErrShouldBuildEmptyBlock) + + block, err := sm.BuildBlock(ctx, md, emptyBlacklist) + require.NoError(t, err) + require.NotNil(t, block) + require.Nil(t, block.InnerBlock) + require.Nil(t, block.Metadata.AuxiliaryInfoBatch) + require.Equal(t, md, block.Metadata.SimplexProtocolMetadata) + // The empty block carries the current P-chain height for proposing (100 in this config). + require.Equal(t, uint64(100), block.Metadata.PChainHeight) + }) + + t.Run("other cancellation propagates the error", func(t *testing.T) { + sm, tc := newStateMachine(t) + block1 := buildFirstSimplexBlock(t, sm, tc) + + md := common.ProtocolMetadata{Round: 2, Seq: 2, Epoch: 1, Prev: block1.Digest()} + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + block, err := sm.BuildBlock(ctx, md, emptyBlacklist) + require.ErrorIs(t, err, context.Canceled) + require.Nil(t, block) + }) +} + +// TestMSMBuildBlockBuildsEmptyBlockWhenInnerBlockBuildingCancelled covers the branch in +// buildBlockOrTransitionEpoch where the inner (VM) block builder fails: when the context +// was cancelled with ErrShouldBuildEmptyBlock, an empty block is returned instead of +// propagating the error. +func TestMSMBuildBlockBuildsEmptyBlockWhenInnerBlockBuildingCancelled(t *testing.T) { + errVMBuildFailed := errors.New("vm failed to build inner block") + + t.Run("empty-block cause yields an empty block", func(t *testing.T) { + sm, tc := newStateMachine(t) + block1 := buildFirstSimplexBlock(t, sm, tc) + + // The decider runs first with a live context and decides to build an inner block; + // the inner builder then cancels the context with the empty-block cause and fails. + ctx, cancel := context.WithCancelCause(context.Background()) + sm.BlockBuilder = &emptyBlockRequestingBuilder{ + cancel: cancel, + cancelCause: common.ErrShouldBuildEmptyBlock, + err: errVMBuildFailed, + } + + md := common.ProtocolMetadata{Round: 2, Seq: 2, Epoch: 1, Prev: block1.Digest()} + block, err := sm.BuildBlock(ctx, md, emptyBlacklist) + require.NoError(t, err) + require.NotNil(t, block) + require.Nil(t, block.InnerBlock) + require.Equal(t, md, block.Metadata.SimplexProtocolMetadata) + // The empty block carries the decided P-chain height (100 in this config). + require.Equal(t, uint64(100), block.Metadata.PChainHeight) + }) + + t.Run("inner build failure without empty-block cause propagates the error", func(t *testing.T) { + sm, tc := newStateMachine(t) + block1 := buildFirstSimplexBlock(t, sm, tc) + + sm.BlockBuilder = &emptyBlockRequestingBuilder{err: errVMBuildFailed} + + md := common.ProtocolMetadata{Round: 2, Seq: 2, Epoch: 1, Prev: block1.Digest()} + block, err := sm.BuildBlock(context.Background(), md, emptyBlacklist) + require.ErrorIs(t, err, errVMBuildFailed) + require.Nil(t, block) + }) +} diff --git a/simplex/empty_block_builder.go b/simplex/empty_block_builder.go new file mode 100644 index 00000000..4d990834 --- /dev/null +++ b/simplex/empty_block_builder.go @@ -0,0 +1,54 @@ +// Copyright (C) 2019-2025, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package simplex + +import ( + "context" + "time" + + "github.com/ava-labs/simplex/common" +) + +// EmptyBlockBuilder is a BlockBuilder that builds an empty block if the given shouldBuildEmptyBlock function returns true. +// The given shouldBuildEmptyBlock function blocks until the given context is cancelled. +type EmptyBlockBuilder struct { + Timeout time.Duration + BB common.BlockBuilder + ShouldBuildEmptyBlock func(context.Context) bool +} + +// BuildBlock builds a block using the underlying BlockBuilder. +// If within the timeout, shouldBuildEmptyBlock returns true, it cancels the context with ErrShouldBuildEmptyBlock. +func (ebb *EmptyBlockBuilder) BuildBlock(ctx context.Context, metadata common.ProtocolMetadata, blacklist common.Blacklist) (common.VerifiedBlock, bool) { + ctx, outerCancel := context.WithCancelCause(ctx) + defer outerCancel(nil) + + go func() { + innerContext, innerCancel := context.WithTimeoutCause(ctx, ebb.Timeout, common.ErrShouldBuildEmptyBlock) + defer innerCancel() + + if ebb.ShouldBuildEmptyBlock(innerContext) { + outerCancel(common.ErrShouldBuildEmptyBlock) + } + }() + return ebb.BB.BuildBlock(ctx, metadata, blacklist) +} + +// WaitForPendingBlock waits for the underlying BlockBuilder to have a pending block. +// If within the timeout, shouldBuildEmptyBlock returns true, it cancels the context with ErrShouldBuildEmptyBlock. +func (ebb *EmptyBlockBuilder) WaitForPendingBlock(ctx context.Context) { + ctx, outerCancel := context.WithCancel(ctx) + defer outerCancel() + + go func() { + innerContext, innerCancel := context.WithTimeoutCause(ctx, ebb.Timeout, common.ErrShouldBuildEmptyBlock) + defer innerCancel() + + if ebb.ShouldBuildEmptyBlock(innerContext) { + outerCancel() + } + }() + + ebb.BB.WaitForPendingBlock(ctx) +} diff --git a/simplex/empty_block_builder_test.go b/simplex/empty_block_builder_test.go new file mode 100644 index 00000000..d63d9985 --- /dev/null +++ b/simplex/empty_block_builder_test.go @@ -0,0 +1,229 @@ +// Copyright (C) 2019-2025, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package simplex_test + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/ava-labs/simplex/common" + "github.com/ava-labs/simplex/simplex" + "github.com/ava-labs/simplex/testutil" + + "github.com/stretchr/testify/require" +) + +// blocksUntilDone mimics the production ShouldBuildEmptyBlock contract +// (Epoch.haveUnFinalizedButNotarizedSuffix): it blocks until the given context is +// cancelled and then reports whether the cancellation was because the empty-block +// timeout elapsed. +func blocksUntilDone(ctx context.Context) bool { + <-ctx.Done() + return errors.Is(context.Cause(ctx), common.ErrShouldBuildEmptyBlock) +} + +// waitsForCancel blocks until the context is cancelled and never asks for an empty block. +func waitsForCancel(ctx context.Context) bool { + <-ctx.Done() + return false +} + +// TestEmptyBlockBuilderReturnsRealBlockWhenBuilt verifies that when the underlying +// builder produces a block, that block is returned and the metadata is forwarded. +func TestEmptyBlockBuilderReturnsRealBlockWhenBuilt(t *testing.T) { + bb := testutil.NewTestControlledBlockBuilder(t) + md := common.ProtocolMetadata{Version: 1, Epoch: 2, Round: 3, Seq: 4} + + ebb := &simplex.EmptyBlockBuilder{ + // A long timeout guarantees the empty-block path never fires in this test. + Timeout: time.Hour, + BB: bb, + ShouldBuildEmptyBlock: waitsForCancel, + } + + bb.TriggerNewBlock() + block, ok := ebb.BuildBlock(context.Background(), md, common.NewBlacklist(5)) + require.True(t, ok) + require.NotNil(t, block) + require.Equal(t, md.Seq, block.BlockHeader().Seq) + require.Equal(t, md.Epoch, block.BlockHeader().Epoch) +} + +// TestEmptyBlockBuilderReturnsEmptyBlockWhenShouldBuildEmpty verifies the core wiring: +// when ShouldBuildEmptyBlock fires (because the empty-block timeout elapsed), the +// context handed to the underlying builder is cancelled with ErrShouldBuildEmptyBlock +// as its cause, so the builder returns an empty block and true (per the BlockBuilder +// contract). No block is triggered, so this can only succeed via the empty-block path. +func TestEmptyBlockBuilderReturnsEmptyBlockWhenShouldBuildEmpty(t *testing.T) { + bb := testutil.NewTestControlledBlockBuilder(t) + + ebb := &simplex.EmptyBlockBuilder{ + // A short timeout makes ShouldBuildEmptyBlock fire promptly. + Timeout: 10 * time.Millisecond, + BB: bb, + ShouldBuildEmptyBlock: blocksUntilDone, + } + + block, ok := ebb.BuildBlock(context.Background(), common.ProtocolMetadata{}, common.NewBlacklist(1)) + require.True(t, ok) + require.NotNil(t, block) +} + +// TestEmptyBlockBuilderReturnsNoBlockOnCallerCancel verifies that a caller-initiated +// cancellation aborts the underlying builder, which returns (nil, false). +func TestEmptyBlockBuilderReturnsNoBlockOnCallerCancel(t *testing.T) { + bb := testutil.NewTestControlledBlockBuilder(t) + + ebb := &simplex.EmptyBlockBuilder{ + Timeout: time.Hour, + BB: bb, + ShouldBuildEmptyBlock: blocksUntilDone, + } + + ctx, cancel := context.WithCancel(context.Background()) + cancel() // caller cancels before the call. + + block, ok := ebb.BuildBlock(ctx, common.ProtocolMetadata{}, common.NewBlacklist(1)) + require.False(t, ok) + require.Nil(t, block) +} + +// TestEmptyBlockBuilderDoesNotCancelWhenShouldBuildReturnsFalse verifies that when the +// timeout elapses but ShouldBuildEmptyBlock declines (returns false, e.g. there is no +// notarized-but-unfinalized suffix), the underlying builder is NOT preempted and can +// still return a real block afterwards. +func TestEmptyBlockBuilderDoesNotCancelWhenShouldBuildReturnsFalse(t *testing.T) { + bb := testutil.NewTestControlledBlockBuilder(t) + declined := make(chan struct{}) + + ebb := &simplex.EmptyBlockBuilder{ + Timeout: 10 * time.Millisecond, + BB: bb, + ShouldBuildEmptyBlock: func(ctx context.Context) bool { + <-ctx.Done() // wait for the empty-block timeout to elapse + close(declined) + return false // ...but decline to build an empty block + }, + } + + // Only release the real block after ShouldBuildEmptyBlock has declined, so the + // test deterministically exercises the "timeout fired, but no cancel" path. + go func() { + <-declined + bb.TriggerNewBlock() + }() + + block, ok := ebb.BuildBlock(context.Background(), common.ProtocolMetadata{}, common.NewBlacklist(1)) + require.True(t, ok) + require.NotNil(t, block) +} + +// TestEmptyBlockBuilderCleansUpAfterBuild verifies that once BuildBlock returns, the +// spawned ShouldBuildEmptyBlock goroutine is cancelled (no goroutine leak). +func TestEmptyBlockBuilderCleansUpAfterBuild(t *testing.T) { + bb := testutil.NewTestControlledBlockBuilder(t) + exited := make(chan struct{}) + + ebb := &simplex.EmptyBlockBuilder{ + Timeout: time.Hour, + BB: bb, + ShouldBuildEmptyBlock: func(ctx context.Context) bool { + <-ctx.Done() + close(exited) + return false + }, + } + + bb.TriggerNewBlock() + _, ok := ebb.BuildBlock(context.Background(), common.ProtocolMetadata{}, common.NewBlacklist(1)) + require.True(t, ok) + + select { + case <-exited: + case <-time.After(time.Minute): + require.Fail(t, "ShouldBuildEmptyBlock goroutine was not cancelled after BuildBlock returned") + } +} + +// TestEmptyBlockBuilderWaitForPendingBlockCancelledByShouldBuildEmpty verifies that +// when ShouldBuildEmptyBlock fires, WaitForPendingBlock cancels the underlying +// WaitForPendingBlock and returns. +func TestEmptyBlockBuilderWaitForPendingBlockCancelledByShouldBuildEmpty(t *testing.T) { + bb := testutil.NewTestBlockBuilder() + + ebb := &simplex.EmptyBlockBuilder{ + Timeout: 10 * time.Millisecond, + BB: bb, + ShouldBuildEmptyBlock: blocksUntilDone, + } + + done := make(chan struct{}) + go func() { + ebb.WaitForPendingBlock(context.Background()) + close(done) + }() + + select { + case <-done: + case <-time.After(5 * time.Second): + require.Fail(t, "WaitForPendingBlock did not return after ShouldBuildEmptyBlock fired") + } +} + +// TestEmptyBlockBuilderWaitForPendingBlockReturnsWhenUnderlyingReturns verifies that +// when the underlying builder signals a pending block, WaitForPendingBlock returns +// without waiting for the empty-block timeout. +func TestEmptyBlockBuilderWaitForPendingBlockReturnsWhenUnderlyingReturns(t *testing.T) { + bb := testutil.NewTestBlockBuilder() + + ebb := &simplex.EmptyBlockBuilder{ + // A long timeout ensures the empty-block path is not what unblocks us. + Timeout: time.Hour, + BB: bb, + ShouldBuildEmptyBlock: waitsForCancel, + } + + done := make(chan struct{}) + go func() { + ebb.WaitForPendingBlock(context.Background()) + close(done) + }() + + bb.BlockShouldBeBuilt <- struct{}{} // application signals a pending block. + + select { + case <-done: + case <-time.After(time.Minute): + require.Fail(t, "WaitForPendingBlock did not return after the underlying builder returned") + } +} + +// TestEmptyBlockBuilderWaitForPendingBlockCancelledByCaller verifies that a +// caller-initiated cancellation unblocks WaitForPendingBlock. +func TestEmptyBlockBuilderWaitForPendingBlockCancelledByCaller(t *testing.T) { + bb := testutil.NewTestBlockBuilder() + + ebb := &simplex.EmptyBlockBuilder{ + Timeout: time.Hour, + BB: bb, + ShouldBuildEmptyBlock: waitsForCancel, + } + + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + go func() { + ebb.WaitForPendingBlock(ctx) + close(done) + }() + + cancel() + + select { + case <-done: + case <-time.After(time.Minute): + require.Fail(t, "WaitForPendingBlock did not return after the caller cancelled the context") + } +} diff --git a/simplex/epoch.go b/simplex/epoch.go index 7e3c95a6..5020b14a 100644 --- a/simplex/epoch.go +++ b/simplex/epoch.go @@ -91,6 +91,7 @@ type EpochConfig struct { type Epoch struct { EpochConfig // Runtime + blockBuilder common.BlockBuilder epochSealed atomic.Bool signatureAggregator common.SignatureAggregator oneTimeVerifier *OneTimeVerifier @@ -235,6 +236,11 @@ func (e *Epoch) init() error { for _, node := range e.validatorNodeIDs { e.futureMessages[string(node)] = make(map[uint64]*messagesForRound) } + e.blockBuilder = &EmptyBlockBuilder{ + ShouldBuildEmptyBlock: e.haveUnFinalizedButNotarizedSuffix, + Timeout: e.MaxProposalWait, + BB: e.BlockBuilder, + } err := e.loadLastBlock() if err != nil { return err @@ -313,6 +319,18 @@ func (e *Epoch) Start() error { return nil } +func (e *Epoch) haveUnFinalizedButNotarizedSuffix(ctx context.Context) bool { + <-ctx.Done() + + _, ok := e.haveNotFinalizedNotarizedRound() + + if errors.Is(context.Cause(ctx), common.ErrShouldBuildEmptyBlock) { + return ok + } + + return false +} + func (e *Epoch) sequenceAlreadyIndexed(seq uint64) bool { return seq < e.nextSeqToCommit() } @@ -2595,15 +2613,15 @@ func (e *Epoch) createBlockBuildingTask(metadata common.ProtocolMetadata, blackl } e.lock.Unlock() - block, ok := e.BlockBuilder.BuildBlock(context, metadata, blacklist) + block, ok := e.blockBuilder.BuildBlock(context, metadata, blacklist) e.lock.Lock() defer e.lock.Unlock() canceled := context.Err() != nil cancel() - if !ok { - if !canceled { + if !ok || canceled { + if !ok && !canceled { e.Logger.Debug("Failed building block") } return common.Digest{} @@ -2840,7 +2858,7 @@ func (e *Epoch) monitorProgress(round uint64) { } // This invocation blocks until the block builder tells us it's time to build a new block. - e.BlockBuilder.WaitForPendingBlock(ctx) + e.blockBuilder.WaitForPendingBlock(ctx) // While we waited, a block might have been notarized. // If so, then don't start monitoring for it being notarized. if cancelled.Load() { diff --git a/simplex/epoch_test.go b/simplex/epoch_test.go index 9a6fb0f2..8dab22fc 100644 --- a/simplex/epoch_test.go +++ b/simplex/epoch_test.go @@ -2132,3 +2132,118 @@ func TestEpochVoteSentTwiceKeepsVerifiedVote(t *testing.T) { } } } + +// TestNotarizedNotFinalizedTipCausesEmptyBlockProposal verifies that when the leader +// has a notarized-but-not-finalized tip and no transactions are available, it proposes +// an empty block instead of stalling. +func TestNotarizedNotFinalizedTipCausesEmptyBlockProposal(t *testing.T) { + nodes := []NodeID{{1}, {2}, {3}, {4}} + // nodes[1] leads round 1, so once round 0 is notarized-but-not-finalized it must + // build the round-1 block itself. + require.Equal(t, nodes[1], LeaderForRound(nodes, 1)) + + bb := testutil.NewTestControlledBlockBuilder(t) + recordingComm := &recordingComm{ + Communication: testutil.NewNoopComm(nodes), + BroadcastMessages: make(chan *Message, 100), + SentMessages: make(chan *Message, 100), + } + conf, _, _ := testutil.DefaultTestNodeEpochConfig(t, nodes[1], recordingComm, bb) + conf.MaxProposalWait = 50 * time.Millisecond + + e, err := NewEpoch(conf) + require.NoError(t, err) + t.Cleanup(e.Stop) + require.NoError(t, e.Start()) + + // Round 0 becomes notarized but is never finalized: a notarized-but-not-finalized tip. + notarizeRoundNotFinalized(t, e, nodes, 0) + + // The node is now the leader of round 1, but we never trigger a real block. Because + // there is a notarized-but-not-finalized tip, after MaxProposalWait the empty-block + // builder makes it propose an empty block for round 1. + timeout := time.After(30 * time.Second) + var proposal *VerifiedBlockMessage + for proposal == nil { + select { + case msg := <-recordingComm.BroadcastMessages: + if msg.VerifiedBlockMessage != nil { + proposal = msg.VerifiedBlockMessage + } + case <-timeout: + require.FailNow(t, "timed out waiting for a block proposal") + } + } + require.Equal(t, uint64(1), proposal.VerifiedBlock.BlockHeader().Round) +} + +// TestNotarizedNotFinalizedTipStuckLeaderCausesEmptyNotarization verifies that +// a node with a notarized-but-not-finalized tip with a stuck leader +// and no pending transactions, the node still times out and casts an empty vote for the +// round. +func TestNotarizedNotFinalizedTipStuckLeaderCausesEmptyNotarization(t *testing.T) { + nodes := []NodeID{{1}, {2}, {3}, {4}} + // nodes[2] is a follower for rounds 0 and 1; nodes[1] leads round 1 and stays stuck. + require.False(t, nodes[2].Equals(LeaderForRound(nodes, 0))) + require.False(t, nodes[2].Equals(LeaderForRound(nodes, 1))) + + bb := testutil.NewTestControlledBlockBuilder(t) + conf, wal, _ := testutil.DefaultTestNodeEpochConfig(t, nodes[2], testutil.NewNoopComm(nodes), bb) + conf.MaxProposalWait = 50 * time.Millisecond + startTime := conf.StartTime + + e, err := NewEpoch(conf) + require.NoError(t, err) + t.Cleanup(e.Stop) + require.NoError(t, e.Start()) + + // Round 0 becomes notarized but is never finalized: a notarized-but-not-finalized tip. + notarizeRoundNotFinalized(t, e, nodes, 0) + + // The node is now a follower in round 1 whose leader is stuck, with no pending + // transactions. The notarized-but-not-finalized tip must still make it time out and + // cast an empty vote for round 1. + testutil.WaitForBlockProposerTimeout(t, e, &startTime, 1) + wal.AssertEmptyVote(1) + + // Its empty vote plus a quorum of others assembles an empty notarization for round 1. + for _, from := range []NodeID{nodes[0], nodes[1]} { + vote := ToBeSignedEmptyVote{EmptyVoteMetadata: EmptyVoteMetadata{Round: 1, Epoch: e.Epoch}} + sig, err := vote.Sign(&testutil.TestSigner{}) + require.NoError(t, err) + ev := &EmptyVote{Vote: vote, Signature: Signature{Signer: from, Value: sig}} + require.NoError(t, e.HandleMessage(&Message{EmptyVoteMessage: ev}, from)) + } + + require.Equal(t, EmptyNotarizationRecordType, wal.AssertNotarization(1)) +} + +// notarizeRoundNotFinalized drives the epoch node (which must be a follower for the given +// round) to notarize the round's block without finalizing it, leaving a +// notarized-but-not-finalized round in the rounds map. +func notarizeRoundNotFinalized(t *testing.T, e *Epoch, nodes []NodeID, round uint64) VerifiedBlock { + leader := LeaderForRound(nodes, round) + require.False(t, e.ID.Equals(leader), "epoch node must be a follower for the notarized round") + + md := e.Metadata() + require.Equal(t, round, md.Round) + block := testutil.NewTestBlock(md, NewBlacklist(uint16(len(nodes)))) + + vote, err := testutil.NewTestVote(block, leader) + require.NoError(t, err) + require.NoError(t, e.HandleMessage(&Message{ + BlockMessage: &BlockMessage{Block: block, Vote: *vote}, + }, leader)) + + validators := e.Comm.Validators() + quorum := Quorum(len(validators)) + sigAggr := e.SignatureAggregatorCreator(validators) + notarization, err := testutil.NewNotarization(e.Logger, sigAggr, block, validators.NodeIDs()[:quorum]) + require.NoError(t, err) + // Deliver the notarization from any node other than ourselves (a notarization from + // self is ignored). + testutil.InjectTestNotarization(t, e, notarization, leader) + + e.WAL.(*testutil.TestWAL).AssertNotarization(round) + return block +} diff --git a/testutil/controlled.go b/testutil/controlled.go index 56d5b5d9..c953fab0 100644 --- a/testutil/controlled.go +++ b/testutil/controlled.go @@ -4,6 +4,7 @@ package testutil import ( "context" + "errors" "testing" "time" @@ -214,6 +215,9 @@ func (t *TestControlledBlockBuilder) BuildBlock(ctx context.Context, metadata co select { case <-t.control: case <-ctx.Done(): + if errors.Is(context.Cause(ctx), common.ErrShouldBuildEmptyBlock) { + return NewTestBlock(metadata, blacklist), true + } return nil, false } return t.TestBlockBuilder.BuildBlock(ctx, metadata, blacklist)