Skip to content
Merged
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
2 changes: 2 additions & 0 deletions cmd/seid/cmd/legacy_config_fuzz_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -356,6 +356,7 @@ var appKeys = []appKey{
// concurrency-workers is registered as an Int flag, which is one of the few
// types viper converts rather than passing through as text.
{Key: "concurrency-workers", Values: [3]string{"4", "8", "16"}, Numeric: true, WantGoType: "int"},
{Key: "freeze-height", Values: [3]string{"100", "200", "300"}, Numeric: true, WantGoType: "string"},
}

// FuzzApplyPrecedenceApp pins the same ordering on the other channel. App
Expand Down Expand Up @@ -749,6 +750,7 @@ func FuzzApplyMalformedAppTOML(f *testing.F) {
f.Add([]byte(""))
f.Add([]byte("halt-height = 1\n"))
f.Add([]byte("halt-height = \n"))
f.Add([]byte("freeze-height = 1\n"))
f.Add([]byte("[telemetry\nenabled = true\n"))
f.Add([]byte("moniker = \"app-toml-wins\"\n"))
f.Add([]byte("telemetry.global-labels = \"not-a-list\"\n"))
Expand Down
25 changes: 24 additions & 1 deletion sei-cosmos/server/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package config

import (
"fmt"
"math"
"runtime"
"strings"
"time"
Expand All @@ -12,6 +13,7 @@ import (
sdkerrors "github.com/sei-protocol/sei-chain/sei-cosmos/types/errors"
"github.com/sei-protocol/sei-chain/sei-db/config"
tmcfg "github.com/sei-protocol/sei-chain/sei-tendermint/config"
"github.com/spf13/cast"
"github.com/spf13/viper"
)

Expand Down Expand Up @@ -112,6 +114,10 @@ type BaseConfig struct {
// Note: Commitment of state will be attempted on the corresponding block.
HaltHeight uint64 `mapstructure:"halt-height"`

// FreezeHeight contains a non-zero block height at which the node stops
// before executing the block while continuing to serve RPC.
FreezeHeight uint64 `mapstructure:"freeze-height"`

// HaltTime contains a non-zero minimum block time (in Unix seconds) at which
// a node will gracefully halt and shutdown that can be used to assist
// upgrades and testing.
Expand Down Expand Up @@ -345,6 +351,7 @@ func DefaultConfig() *Config {
PruningKeepRecent: "0",
PruningKeepEvery: "0",
PruningInterval: "0",
FreezeHeight: 0,
MinRetainBlocks: 0,
IndexEvents: nil,
CompactionInterval: 0,
Expand Down Expand Up @@ -421,6 +428,10 @@ func GetConfig(v *viper.Viper) (Config, error) {
if !ok {
return Config{}, fmt.Errorf("failed to parse global-labels config")
}
freezeHeight, err := cast.ToUint64E(v.Get("freeze-height"))
if err != nil {
return Config{}, fmt.Errorf("invalid freeze-height: %w", err)
}

globalLabels := make([][]string, 0, len(globalLabelsRaw))
for idx, glr := range globalLabelsRaw {
Expand Down Expand Up @@ -559,6 +570,7 @@ func GetConfig(v *viper.Viper) (Config, error) {
PruningKeepRecent: v.GetString("pruning-keep-recent"),
PruningInterval: v.GetString("pruning-interval"),
HaltHeight: v.GetUint64("halt-height"),
FreezeHeight: freezeHeight,
HaltTime: v.GetUint64("halt-time"),
IndexEvents: v.GetStringSlice("index-events"),
MinRetainBlocks: v.GetUint64("min-retain-blocks"),
Expand Down Expand Up @@ -647,7 +659,7 @@ func GetConfig(v *viper.Viper) (Config, error) {
}, nil
}

// ValidateBasic returns an error if min-gas-prices field is empty in BaseConfig. Otherwise, it returns nil.
// ValidateBasic validates the server configuration.
func (c Config) ValidateBasic(tendermintConfig *tmcfg.Config) error {
if c.MinGasPrices == "" {
return sdkerrors.ErrAppConfig.Wrap("set min gas price in app.toml or flag or env variable")
Expand All @@ -657,6 +669,17 @@ func (c Config) ValidateBasic(tendermintConfig *tmcfg.Config) error {
"cannot enable state sync snapshots with '%s' pruning setting", storetypes.PruningOptionEverything,
)
}
return c.ValidateFreeze()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] This adds two more rejection conditions to ValidateBasic, which makes FuzzConfigValidateBasic's doc comment stale — it opens "pins the two conditions that reject an otherwise parseable app.toml" and enumerates only min-gas-prices and pruning-everything + snapshots (config_fuzz_test.go:693). That fuzz surface also never varies freeze-height, so the new conditions sit outside it; they are pinned only by the TestValidateBasic table rows.

In a package where these comments are the characterization record, worth updating the count/enumeration (and ideally threading freeze-height/halt-height through the fuzz inputs).

}

// ValidateFreeze validates the configuration that controls freeze mode.
func (c Config) ValidateFreeze() error {
if c.FreezeHeight > math.MaxInt64 {
return sdkerrors.ErrAppConfig.Wrapf("freeze-height must not exceed %d", int64(math.MaxInt64))
}
if c.FreezeHeight > 0 && (c.HaltHeight > 0 || c.HaltTime > 0) {
return sdkerrors.ErrAppConfig.Wrap("freeze-height cannot be combined with halt-height or halt-time")
}

return nil
}
11 changes: 9 additions & 2 deletions sei-cosmos/server/config/config_fuzz_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1152,6 +1152,7 @@ func TestGetConfigAbsentSectionDivergences(t *testing.T) {
{"concurrency-workers", cfg.ConcurrencyWorkers, def.ConcurrencyWorkers, true},
{"occ-enabled", cfg.OccEnabled, def.OccEnabled, true},
{"halt-height", cfg.HaltHeight, def.HaltHeight, false},
{"freeze-height", cfg.FreezeHeight, def.FreezeHeight, false},
{"halt-time", cfg.HaltTime, def.HaltTime, false},
{"min-retain-blocks", cfg.MinRetainBlocks, def.MinRetainBlocks, false},
{"compaction-interval", cfg.CompactionInterval, def.CompactionInterval, false},
Expand Down Expand Up @@ -1232,8 +1233,8 @@ func requireEveryManifestRowIsAnchored(t *testing.T, covered map[string]bool) {
}
}

// baseConfigKeys covers the twelve keys GetConfig reads at the top level of app.toml, the ones
// written without a section header (config.go:555-568). Every one is a bare viper getter.
// baseConfigKeys covers the thirteen keys GetConfig reads at the top level of app.toml, the ones
// written without a section header. FreezeHeight is checked; the other fields use bare viper getters.
var baseConfigKeys = []configtest.KeySpec{
{
Key: "minimum-gas-prices", Path: "MinGasPrices", Cast: configtest.CastString,
Expand Down Expand Up @@ -1297,6 +1298,10 @@ var baseConfigKeys = []configtest.KeySpec{
Why: "the declared default is true and an absent key resolves false, so a node whose " +
"app.toml lacks the key executes without optimistic concurrency control",
},
{
Key: "freeze-height", Path: "FreezeHeight", Cast: configtest.CastUint64, Unguarded: true, Checked: true,
Why: "0 is both the declared default and the spelling for allowing consensus to advance",
},
}

func readBaseConfig(t testing.TB) func(configtest.AppOpts) (any, error) {
Expand All @@ -1320,6 +1325,8 @@ func FuzzBaseConfig(f *testing.F) {
seeds.AddRow(uint(9), fuzzing.KindInt64, "", int64(1000), false)
seeds.AddRow(uint(10), fuzzing.KindInt64, "", int64(7), false)
seeds.AddRow(uint(11), fuzzing.KindBool, "", int64(0), true)
seeds.AddRow(uint(12), fuzzing.KindInt64, "", int64(9000000), false)
seeds.AddRow(uint(12), fuzzing.KindInt64, "", int64(-1), false)

configtest.CheckEveryRowHasADiscriminatingSeed(f, "base_config", readBaseConfig(f),
baseConfigKeys, seeds)
Expand Down
38 changes: 38 additions & 0 deletions sei-cosmos/server/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package config

import (
"bytes"
"math"
"strings"
"testing"
"time"
Expand Down Expand Up @@ -242,6 +243,35 @@ func TestValidateBasic(t *testing.T) {
},
expectErr: true,
},
{
name: "freeze height above maximum int64",
setupCfg: func() *Config {
cfg := DefaultConfig()
cfg.FreezeHeight = uint64(math.MaxInt64) + 1
return cfg
},
expectErr: true,
},
{
name: "freeze and halt heights",
setupCfg: func() *Config {
cfg := DefaultConfig()
cfg.FreezeHeight = 100
cfg.HaltHeight = 100
return cfg
},
expectErr: true,
},
{
name: "freeze height and halt time",
setupCfg: func() *Config {
cfg := DefaultConfig()
cfg.FreezeHeight = 100
cfg.HaltTime = 100
return cfg
},
expectErr: true,
},

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit - add a negative freeze test

}

for _, tt := range tests {
Expand All @@ -257,6 +287,14 @@ func TestValidateBasic(t *testing.T) {
}
}

func TestGetConfigRejectsNegativeFreezeHeight(t *testing.T) {
v := seedViperWithDefaultConfig(t)
v.Set("freeze-height", -1)

_, err := GetConfig(v)
require.Error(t, err)
}

func TestGetMinGasPrices(t *testing.T) {
tests := []struct {
name string
Expand Down
1 change: 1 addition & 0 deletions sei-cosmos/server/config/testdata/base_config.keys.golden
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,5 @@
"compaction-interval"
"concurrency-workers"
"occ-enabled"
"freeze-height"
# keys with a target of their own
1 change: 1 addition & 0 deletions sei-cosmos/server/config/testdata/server_config.golden
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ PruningKeepRecent = string("0")
PruningKeepEvery = string("0")
PruningInterval = string("0")
HaltHeight = uint64(0)
FreezeHeight = uint64(0)
HaltTime = uint64(0)
MinRetainBlocks = uint64(0)
InterBlockCache = bool(true)
Expand Down
4 changes: 4 additions & 0 deletions sei-cosmos/server/config/toml.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,10 @@ occ-enabled = {{ .BaseConfig.OccEnabled }}
# Note: Commitment of state will be attempted on the corresponding block.
halt-height = {{ .BaseConfig.HaltHeight }}

# FreezeHeight contains a non-zero block height at which the node stops before
# executing the block while continuing to serve RPC.
freeze-height = {{ .BaseConfig.FreezeHeight }}

# HaltTime contains a non-zero minimum block time (in Unix seconds) at which
# a node will gracefully halt and shutdown that can be used to assist upgrades
# and testing.
Expand Down
14 changes: 12 additions & 2 deletions sei-cosmos/server/start.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ const (
flagCPUProfile = "cpu-profile"
FlagMinGasPrices = "minimum-gas-prices"
FlagHaltHeight = "halt-height"
FlagFreezeHeight = "freeze-height"
FlagHaltTime = "halt-time"
FlagInterBlockCache = "inter-block-cache"
FlagUnsafeSkipUpgrades = "unsafe-skip-upgrades"
Expand Down Expand Up @@ -102,6 +103,8 @@ the ABCI Commit phase, the node will check if the current block height is greate
the halt-height or if the current block time is greater than or equal to the halt-time. If so, the
node will attempt to gracefully shutdown and the block will not be committed. In addition, the node
will not be able to commit subsequent blocks.
The '--freeze-height' flag instead keeps the process and RPC servers running while preventing block
sync and consensus from executing the block at the configured height or advancing beyond it.
For profiling and benchmarking purposes, CPU profiling can be enabled via the '--cpu-profile' flag
which accepts a path for the resulting pprof file.
The node may be started in a 'query only' mode where only the gRPC and JSON HTTP
Expand Down Expand Up @@ -208,6 +211,7 @@ func addStartNodeFlags(cmd *cobra.Command, defaultNodeHome string) {
cmd.Flags().String(FlagMinGasPrices, "", "Minimum gas prices to accept for transactions; Any fee in a tx must meet this minimum (e.g. 0.01photino;0.0001stake)")
cmd.Flags().IntSlice(FlagUnsafeSkipUpgrades, []int{}, "Skip a set of upgrade heights to continue the old binary")
cmd.Flags().Uint64(FlagHaltHeight, 0, "Block height at which to gracefully halt the chain and shutdown the node")
cmd.Flags().Uint64(FlagFreezeHeight, 0, "Block height to stop before executing while continuing to serve RPC")
cmd.Flags().Uint64(FlagHaltTime, 0, "Minimum block time (in Unix seconds) at which to gracefully halt the chain and shutdown the node")
cmd.Flags().Bool(FlagInterBlockCache, true, "Enable inter-block caching")
cmd.Flags().String(flagCPUProfile, "", "Enable CPU profiling and write to the provided file")
Expand Down Expand Up @@ -304,6 +308,13 @@ func startInProcess(
if err != nil {
return err
}
if err := config.ValidateFreeze(); err != nil {
return err
}
gRPCOnly := ctx.Viper.GetBool(flagGRPCOnly)
if gRPCOnly && config.FreezeHeight > 0 {
return errors.New("freeze-height cannot be used with grpc-only mode")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

grpc-only does not start the EVM rpc nor consensus, so for one there is nothing to freeze (bc there is no consensus) and for the other there is nothing to query from

}

if err := config.ValidateBasic(ctx.Config); err != nil {
logger.Error("WARNING: The minimum-gas-prices config in app.toml is set to the empty string. " +
Expand All @@ -318,8 +329,6 @@ func startInProcess(
}
}()

gRPCOnly := ctx.Viper.GetBool(flagGRPCOnly)

var restartMtx sync.Mutex
restartCh := make(chan struct{})
restartEvent := func() {
Expand Down Expand Up @@ -359,6 +368,7 @@ func startInProcess(
gen,
tracerProviderOptions,
tmtypes.DefaultConsensusPolicy(),
node.WithFreezeHeight(config.FreezeHeight),
)
if err != nil {
return fmt.Errorf("error creating node: %w", err)
Expand Down
37 changes: 37 additions & 0 deletions sei-tendermint/internal/blocksync/reactor.go
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ type SyncerConfig struct {
EventBus *eventbus.EventBus
RestartEvent func()
SelfRemediationConfig *config.SelfRemediationConfig
FreezeHeight uint64
}

// Reactor owns the blocksync channel and always-on query serving path, while
Expand Down Expand Up @@ -154,6 +155,7 @@ type syncController struct {
blocksBehindThreshold uint64
blocksBehindCheckInterval time.Duration
restartCooldownSeconds uint64
freezeHeight uint64

// blocksyncReady fires when the active sync routines should begin processing
// work, either during OnStart or later via SwitchToBlockSync.
Expand Down Expand Up @@ -186,6 +188,7 @@ func NewReactor(
blocksBehindThreshold: cfg.SelfRemediationConfig.BlocksBehindThreshold,
blocksBehindCheckInterval: time.Duration(cfg.SelfRemediationConfig.BlocksBehindCheckIntervalSeconds) * time.Second, //nolint:gosec // validated in config.ValidateBasic against MaxInt64
restartCooldownSeconds: cfg.SelfRemediationConfig.RestartCooldownSeconds,
freezeHeight: cfg.FreezeHeight,
blocksyncReady: utils.NewAtomicSend(utils.None[blocksyncResult]()),
startInBlockSync: cfg.BlockSync,
}
Expand Down Expand Up @@ -375,6 +378,9 @@ func (s *syncController) run(ctx context.Context) error {
if r, ok := s.consReactor.Get(); ok {
logger.Info("switching to consensus reactor", "height", handoff.height, "blocks_synced", handoff.blocksSynced, "state_synced", handoff.stateSynced, "max_peer_height", handoff.maxPeerHeight)
r.SwitchToConsensus(handoff.state, handoff.blocksSynced > 0 || handoff.stateSynced)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Switch to consensus means we still write to wal right? not a huge issue but worth gating any state writes in freeze mode in case it results in hands on involvement to bring the node out of freeze mode.

Operationally, we want the freeze mode to basically be noop for consensus.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

added a check to only write to WAL if not frozen

if s.shouldFreeze(handoff.state) {

@masih masih Aug 14, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What if the handoff state is not already in freeze boundary?

IIUC, that means:

  • the auto restart mechanism starts and runs for the lifetime of the process
  • then when we reach freeze height at H-1 because pool's max peer height keeps growing regardless (status request keeps broadcasting) we will end up restarting.

We can avoid this by checking the freeze boundary inside autoRestartIfBehind loop based on self height.

@codchen codchen Aug 17, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good catch. I made auto remediation also respect the freeze height which I believe would prevent the restart

return nil
}
s.autoRestartIfBehind(ctx, pool)
}
return nil
Expand Down Expand Up @@ -462,6 +468,10 @@ func (s *syncController) requestRoutine(ctx context.Context, pool *BlockPool) er
//
// NOTE: Don't sleep in the FOR_LOOP or otherwise slow it down!
func (s *syncController) poolRoutine(ctx context.Context, pool *BlockPool, initialState sm.State, stateSynced bool) (consensusHandoff, error) {
if handoff, frozen := s.frozenHandoff(pool, initialState, 0, stateSynced); frozen {
return handoff, nil
}

var (
trySyncTicker = time.NewTicker(trySyncIntervalMS * time.Millisecond)
switchToConsensusTicker = time.NewTicker(switchToConsensusIntervalSeconds * time.Second)
Expand Down Expand Up @@ -581,6 +591,9 @@ func (s *syncController) poolRoutine(ctx context.Context, pool *BlockPool, initi

consensus.Global.RecordConsMetrics(first)
blocksSynced++
if handoff, frozen := s.frozenHandoff(pool, state, blocksSynced, stateSynced); frozen {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] This is the call site that matters operationally — stopping an in-flight sync exactly one block before the boundary — and it is untested. TestPoolRoutineHandsOffAtFreezeHeight only reaches the entry-guard at line 471 (it passes a state already at freezeHeight-1, so poolRoutine returns before the loop and never touches blockExec/store).

A test that applies a block and asserts the handoff carries blocksSynced == 1 with state.LastBlockHeight == freezeHeight-1 would also pin the off-by-one that the whole feature rests on (freeze before executing freezeHeight, not after).

return handoff, nil
}

if blocksSynced%100 == 0 {
lastRate = 0.9*lastRate + 0.1*(100/time.Since(lastHundred).Seconds())
Expand All @@ -596,6 +609,26 @@ func (s *syncController) poolRoutine(ctx context.Context, pool *BlockPool, initi
}
}

func (s *syncController) frozenHandoff(pool *BlockPool, state sm.State, blocksSynced uint64, stateSynced bool) (consensusHandoff, bool) {
if !s.shouldFreeze(state) {
return consensusHandoff{}, false
}
height, _, _ := pool.GetStatus()
logger.Info("Block sync stopped before configured freeze height", "last_block_height", state.LastBlockHeight, "freeze_height", s.freezeHeight)
return consensusHandoff{
state: state,
blocksSynced: blocksSynced,
stateSynced: stateSynced,
height: height,
maxPeerHeight: pool.MaxPeerHeight(),
}, true
}

func (s *syncController) shouldFreeze(state sm.State) bool {
height := startHeightForState(state)
return s.freezeHeight > 0 && height >= 0 && uint64(height) >= s.freezeHeight //nolint:gosec // negative heights are rejected first.
}

// autoRestartIfBehind will check if the node is behind the max peer height by
// a certain threshold. If it is, the node will attempt to restart itself.
// TODO(gprusak): this should be a sub task of the consensus reactor instead.
Expand All @@ -612,6 +645,10 @@ func (s *syncController) autoRestartIfBehind(ctx context.Context, pool *BlockPoo
select {
case <-time.After(s.blocksBehindCheckInterval):
selfHeight := s.store.Height()
if s.freezeHeight > 0 && selfHeight >= 0 && uint64(selfHeight) >= s.freezeHeight-1 { //nolint:gosec // negative heights are rejected first.
logger.Info("Auto remediation stopped at configured freeze height", "selfHeight", selfHeight, "freeze_height", s.freezeHeight)
return
}
maxPeerHeight := pool.MaxPeerHeight()
threshold := int64(s.blocksBehindThreshold) //nolint:gosec // validated in config.ValidateBasic against MaxInt64
behindHeight := maxPeerHeight - selfHeight
Expand Down
Loading
Loading