Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 10 additions & 3 deletions common/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ package common
import (
"bytes"
"context"
"errors"
"fmt"
"slices"

Expand Down Expand Up @@ -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,
Expand Down
9 changes: 9 additions & 0 deletions msm/msm.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand All @@ -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
}
}
Expand Down
111 changes: 111 additions & 0 deletions msm/msm_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"context"
"crypto/rand"
"crypto/sha256"
"errors"
"fmt"
"math"
"testing"
Expand Down Expand Up @@ -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)
})
}
54 changes: 54 additions & 0 deletions simplex/empty_block_builder.go
Original file line number Diff line number Diff line change
@@ -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)
}
Loading
Loading