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
6 changes: 6 additions & 0 deletions action/protocol/execution/evm/evm.go
Original file line number Diff line number Diff line change
Expand Up @@ -634,6 +634,12 @@ func executeInEVM(ctx context.Context, evmParams *Params, stateDB stateDB) ([]by
)
evm := vm.NewEVM(evmParams.context, stateDB, chainConfig, evmParams.evmConfig)
evm.SetTxContext(evmParams.txCtx)
// during a traced simulation, register this EVM so the trace-timeout
// watchdog can abort opcode execution (see TraceCanceller); never set on
// the consensus path
if tc := GetTraceCanceller(ctx); tc != nil {
tc.register(evm.Cancel)
}
if g.IsOkhotsk(blockHeight) {
accessList = evmParams.accessList
}
Expand Down
74 changes: 74 additions & 0 deletions action/protocol/execution/evm/tracecancel.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
// Copyright (c) 2026 IoTeX Foundation
// This source code is provided 'as is' and no warranties are given as to title or non-infringement, merchantability
// or fitness for purpose and, to the extent permitted by law, all liability for your use of the code is disclaimed.
// This source code is governed by Apache License 2.0 that can be found in the LICENSE file.

package evm

import (
"context"
"sync"
)

type traceCancellerCtxKey struct{}

// TraceCanceller collects the cancel functions of EVMs created while serving a
// traced simulation (debug_trace*), so that a trace-timeout watchdog can abort
// opcode execution. Stopping the tracer alone only stops result collection —
// the EVM would keep executing until gas exhaustion; upstream geth pairs
// tracer.Stop with EVM.Cancel for the same reason.
//
// It is safe for concurrent use: the watchdog goroutine calls Cancel while the
// execution goroutine registers EVMs. Once Cancel has fired, any EVM
// registered afterwards (e.g. later transactions of a block trace) is
// cancelled immediately at registration.
type TraceCanceller struct {
mu sync.Mutex
fired bool
cancels []func()
}

// NewTraceCanceller creates an empty TraceCanceller.
func NewTraceCanceller() *TraceCanceller {
return &TraceCanceller{}
}

// register adds an EVM cancel function; if Cancel has already fired, the
// function is invoked immediately.
func (tc *TraceCanceller) register(cancel func()) {
tc.mu.Lock()
fired := tc.fired
if !fired {
tc.cancels = append(tc.cancels, cancel)
}
tc.mu.Unlock()
if fired {
cancel()
}
}

// Cancel aborts every EVM registered so far and marks the canceller fired so
// that later registrations abort immediately. Idempotent.
func (tc *TraceCanceller) Cancel() {
tc.mu.Lock()
tc.fired = true
cancels := tc.cancels
tc.cancels = nil
tc.mu.Unlock()
for _, cancel := range cancels {
cancel()
}
}

// WithTraceCanceller attaches a TraceCanceller to the context; executeInEVM
// registers every EVM it creates with it. Only trace/simulation entry points
// should set this — consensus block processing must never carry one.
func WithTraceCanceller(ctx context.Context, tc *TraceCanceller) context.Context {
return context.WithValue(ctx, traceCancellerCtxKey{}, tc)
}

// GetTraceCanceller returns the TraceCanceller attached to the context, or nil.
func GetTraceCanceller(ctx context.Context) *TraceCanceller {
tc, _ := ctx.Value(traceCancellerCtxKey{}).(*TraceCanceller)
return tc
}
118 changes: 118 additions & 0 deletions action/protocol/execution/evm/tracecancel_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
// Copyright (c) 2026 IoTeX Foundation
// This source code is provided 'as is' and no warranties are given as to title or non-infringement, merchantability
// or fitness for purpose and, to the extent permitted by law, all liability for your use of the code is disclaimed.
// This source code is governed by Apache License 2.0 that can be found in the LICENSE file.

package evm

import (
"context"
"math/big"
"sync/atomic"
"testing"
"time"

"github.com/iotexproject/go-pkgs/hash"
"github.com/stretchr/testify/require"
"go.uber.org/mock/gomock"

"github.com/iotexproject/iotex-core/v2/action"
"github.com/iotexproject/iotex-core/v2/action/protocol"
"github.com/iotexproject/iotex-core/v2/blockchain/genesis"
"github.com/iotexproject/iotex-core/v2/state"
"github.com/iotexproject/iotex-core/v2/test/identityset"
"github.com/iotexproject/iotex-core/v2/test/mock/mock_chainmanager"
)

func TestTraceCancellerRegisterThenCancel(t *testing.T) {
r := require.New(t)
tc := NewTraceCanceller()
var n atomic.Int32
tc.register(func() { n.Add(1) })
tc.register(func() { n.Add(1) })
r.EqualValues(0, n.Load())
tc.Cancel()
r.EqualValues(2, n.Load())
// idempotent: second Cancel must not re-invoke
tc.Cancel()
r.EqualValues(2, n.Load())
}

func TestTraceCancellerRegisterAfterFired(t *testing.T) {
r := require.New(t)
tc := NewTraceCanceller()
tc.Cancel()
var n atomic.Int32
// registered after the watchdog fired -> cancelled immediately
tc.register(func() { n.Add(1) })
r.EqualValues(1, n.Load())
}

func TestTraceCancellerContext(t *testing.T) {
r := require.New(t)
r.Nil(GetTraceCanceller(context.Background()))
tc := NewTraceCanceller()
ctx := WithTraceCanceller(context.Background(), tc)
r.Same(tc, GetTraceCanceller(ctx))
}

// TestTraceCancellerAbortsEVM proves the whole chain: an infinite-loop
// contract executed through ExecuteContract is aborted by TraceCanceller.Cancel
// instead of running until gas exhaustion.
func TestTraceCancellerAbortsEVM(t *testing.T) {
r := require.New(t)
ctrl := gomock.NewController(t)
sm := mock_chainmanager.NewMockStateManager(ctrl)
sm.EXPECT().State(gomock.Any(), gomock.Any()).Return(uint64(0), state.ErrStateNotExist).AnyTimes()
sm.EXPECT().PutState(gomock.Any(), gomock.Any()).Return(uint64(0), nil).AnyTimes()
sm.EXPECT().DelState(gomock.Any()).Return(uint64(0), nil).AnyTimes()
sm.EXPECT().Snapshot().Return(1).AnyTimes()
sm.EXPECT().Revert(gomock.Any()).Return(nil).AnyTimes()

// deploy-style execution whose init code is an infinite loop:
// JUMPDEST; PUSH1 0; JUMP (0x5b600056)
loop := []byte{0x5b, 0x60, 0x00, 0x56}
gasLimit := uint64(100_000_000) // huge: uncancelled execution would spin for a long time
e := action.NewExecution("", big.NewInt(0), loop)
elp := (&action.EnvelopeBuilder{}).SetNonce(1).SetGasPrice(big.NewInt(0)).
SetGasLimit(gasLimit).SetAction(e).Build()

g := genesis.TestDefault()
ctx := protocol.WithActionCtx(context.Background(), protocol.ActionCtx{
Caller: identityset.Address(27),
})
ctx = protocol.WithBlockCtx(ctx, protocol.BlockCtx{
Producer: identityset.Address(27),
GasLimit: gasLimit,
})
ctx = genesis.WithGenesisContext(ctx, g)
ctx = protocol.WithBlockchainCtx(protocol.WithFeatureCtx(ctx), protocol.BlockchainCtx{
ChainID: 1,
EvmNetworkID: 100,
})
ctx = WithHelperCtx(ctx, HelperContext{
GetBlockHash: func(uint64) (hash.Hash256, error) { return hash.ZeroHash256, nil },
GetBlockTime: func(uint64) (time.Time, error) { return time.Time{}, nil },
DepositGasFunc: func(context.Context, protocol.StateManager, *big.Int, ...protocol.DepositOption) ([]*action.TransactionLog, error) {
return nil, nil
},
})
tc := NewTraceCanceller()
ctx = WithTraceCanceller(ctx, tc)

go func() {
time.Sleep(100 * time.Millisecond)
tc.Cancel()
}()
start := time.Now()
_, receipt, err := ExecuteContract(ctx, sm, elp)
elapsed := time.Since(start)
r.NoError(err)
r.NotNil(receipt)
// abort halts via errStopToken (clean stop), so the deploy "succeeds"
// without consuming the full gas budget; the tracer layer reports the
// timeout error to the caller. The key assertions: it returned promptly
// and did not burn the whole gas limit spinning.
r.Less(elapsed, 10*time.Second, "execution was not aborted by Cancel")
r.Less(receipt.GasConsumed, gasLimit, "aborted execution must not consume the full gas limit")
}
7 changes: 7 additions & 0 deletions api/coreservice.go
Original file line number Diff line number Diff line change
Expand Up @@ -2247,6 +2247,9 @@ func (core *coreService) traceContext(ctx context.Context, txctx *tracers.Contex
GetBlockTime: bcCtx.GetBlockTime,
DepositGasFunc: rewarding.DepositGas,
})
// executeInEVM registers each EVM it creates with this canceller, letting
// parseTracer's timeout watchdog abort opcode execution
ctx = evm.WithTraceCanceller(ctx, evm.NewTraceCanceller())
tracer, cleanup, err := parseTracer(ctx, txctx, config)
if err != nil {
return nil, nil, nil, err
Expand Down Expand Up @@ -2379,6 +2382,10 @@ func (core *coreService) traceBlock(ctx context.Context, blk *block.Block, confi
// trace timeout is enforced per transaction, like geth. Rearming disarms the
// previous transaction's watchdog; the deferred cleanup disarms the last one
// after the block trace has executed (WorkingSetAtTransaction below).
// executeInEVM registers each EVM it creates with this canceller; a fired
// per-tx watchdog aborts the in-flight EVM (and any created afterwards),
// and traceErr below fails the whole request
ctx = evm.WithTraceCanceller(ctx, evm.NewTraceCanceller())
sharedHooks := new(tracing.Hooks)
var (
curTracer *tracers.Tracer
Expand Down
7 changes: 7 additions & 0 deletions api/web3server_utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -785,10 +785,17 @@ func parseTracer(ctx context.Context, txctx *tracers.Context, config *tracers.Tr
// return the "execution timeout" error, which the caller surfaces as the RPC
// error.
deadlineCtx, cancel := context.WithTimeout(context.Background(), timeout)
evmCanceller := evm.GetTraceCanceller(ctx)
go func() {
<-deadlineCtx.Done()
if errors.Is(deadlineCtx.Err(), context.DeadlineExceeded) {
tracer.Stop(errors.New("execution timeout"))
// stopping the tracer only stops result collection; also abort
// the EVM(s) so the execution doesn't keep burning CPU until gas
// exhaustion (geth pairs tracer.Stop with EVM.Cancel the same way)
if evmCanceller != nil {
evmCanceller.Cancel()
}
}
}()
return tracer, cancel, nil
Expand Down
Loading