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
13 changes: 5 additions & 8 deletions defaults.go
Original file line number Diff line number Diff line change
Expand Up @@ -115,13 +115,12 @@ func baseDefaults() *SeiConfig {
PruningInterval: "0",
SnapshotKeepRecent: 2,
IAVLDisableFastNode: true,
// WriteMode tracks the stable released seid (v6.5.1), which accepts
// cosmos_only and rejects memiavl_only. Nightly/main callers override
// to memiavl_only. Bump this default when 6.6.0 ships memiavl_only.
// WriteMode/ReadMode are left unset so render omits them and each seid
// binary applies its own native default. Their valid values differ
// across binary versions, so no single rendered value boots every
// target; a caller that needs a specific mode sets it explicitly.
StateCommit: StateCommitConfig{
Enable: true,
WriteMode: WriteModeCosmosOnly,
ReadMode: ReadModeCosmosOnly,
Enable: true,
},
StateStore: StateStoreConfig{
Enable: true,
Expand All @@ -131,8 +130,6 @@ func baseDefaults() *SeiConfig {
PruneIntervalSeconds: 600,
ImportNumWorkers: 1,
KeepLastVersion: true,
WriteMode: WriteModeCosmosOnly,
ReadMode: ReadModeCosmosOnly,
},
ReceiptStore: ReceiptStoreConfig{
Backend: BackendPebbleDB,
Expand Down
85 changes: 54 additions & 31 deletions io.go
Original file line number Diff line number Diff line change
Expand Up @@ -104,8 +104,8 @@ func rejectEmptyScalarStringHook(from, to reflect.Type, data any) (any, error) {
}

// WriteConfigToDir writes the SeiConfig as config.toml and app.toml into
// homeDir/config/. Writes are atomic (temp file + rename) to prevent
// corruption on crash.
// homeDir/config/. Both files are staged (temp + fsync) then committed together,
// so a staging failure leaves the existing files untouched.
func WriteConfigToDir(cfg *SeiConfig, homeDir string) error {
cfgDir := filepath.Join(homeDir, configDir)
if err := os.MkdirAll(cfgDir, 0o755); err != nil {
Expand All @@ -115,16 +115,46 @@ func WriteConfigToDir(cfg *SeiConfig, homeDir string) error {
configPath := filepath.Join(cfgDir, configTomlFile)
appPath := filepath.Join(cfgDir, appTomlFile)

tm := cfg.toLegacyTendermint()
if err := atomicWriteTOML(configPath, tm); err != nil {
return fmt.Errorf("writing %s: %w", configPath, err)
// Stage both files before committing either: encode + write + fsync each to a
// temp file first, so if any of that fails the home is left untouched.
stagedConfig, err := stageTOML(cfgDir, cfg.toLegacyTendermint())
if err != nil {
return fmt.Errorf("staging %s: %w", configPath, err)
}
stagedApp, err := stageTOML(cfgDir, cfg.toLegacyApp())
if err != nil {
_ = os.Remove(stagedConfig)
return fmt.Errorf("staging %s: %w", appPath, err)
}

app := cfg.toLegacyApp()
if err := atomicWriteTOML(appPath, app); err != nil {
return fmt.Errorf("writing %s: %w", appPath, err)
// Commit both renames back-to-back. Residual window: any interruption before
// the dir fsync completes (a failed/crashed second rename, or a crash after
// both renames but before the fsync) can leave config.toml new / app.toml old
// — each file individually valid, cross-file inconsistent.
if err := os.Rename(stagedConfig, configPath); err != nil {
_ = os.Remove(stagedConfig)
_ = os.Remove(stagedApp)
return fmt.Errorf("committing %s: %w", configPath, err)
}
if err := os.Rename(stagedApp, appPath); err != nil {
_ = os.Remove(stagedApp)
return fmt.Errorf("committing %s: %w", appPath, err)
}

// fsync the config dir so both renames are durable together; a sync failure
// means the renames may not be on disk, so surface it rather than report
// success.
dir, err := os.Open(cfgDir)
if err != nil {
return fmt.Errorf("opening config dir for fsync: %w", err)
}
if err := dir.Sync(); err != nil {
_ = dir.Close()
return fmt.Errorf("fsyncing config dir: %w", err)
}
if err := dir.Close(); err != nil {
return fmt.Errorf("closing config dir: %w", err)
}
return nil
}

Expand Down Expand Up @@ -153,46 +183,39 @@ func ApplyOverrides(cfg *SeiConfig, overrides map[string]string) error {
return nil
}

// atomicWriteTOML encodes v as TOML and writes it atomically to path.
func atomicWriteTOML(path string, v any) error {
// stageTOML encodes v as TOML into a synced, chmod'd temp file in dir and
// returns its path without renaming it into place. Callers stage several files
// and then commit them together (rename each), so a partial write never lands.
func stageTOML(dir string, v any) (string, error) {
var buf bytes.Buffer
enc := toml.NewEncoder(&buf)
if err := enc.Encode(v); err != nil {
return err
if err := toml.NewEncoder(&buf).Encode(v); err != nil {
return "", err
}

dir := filepath.Dir(path)
tmp, err := os.CreateTemp(dir, ".sei-config-*.tmp")
if err != nil {
return fmt.Errorf("creating temp file: %w", err)
return "", fmt.Errorf("creating temp file: %w", err)
}
tmpPath := tmp.Name()
cleanup := func() { _ = os.Remove(tmpPath) }

if _, err := tmp.Write(buf.Bytes()); err != nil {
_ = tmp.Close()
cleanup()
return fmt.Errorf("writing temp file: %w", err)
_ = os.Remove(tmpPath)
return "", fmt.Errorf("writing temp file: %w", err)
}
if err := tmp.Sync(); err != nil {
_ = tmp.Close()
cleanup()
return fmt.Errorf("syncing temp file: %w", err)
_ = os.Remove(tmpPath)
return "", fmt.Errorf("syncing temp file: %w", err)
}
if err := tmp.Close(); err != nil {
cleanup()
return fmt.Errorf("closing temp file: %w", err)
_ = os.Remove(tmpPath)
return "", fmt.Errorf("closing temp file: %w", err)
}

if err := os.Chmod(tmpPath, 0o644); err != nil {
cleanup()
return fmt.Errorf("setting permissions: %w", err)
_ = os.Remove(tmpPath)
return "", fmt.Errorf("setting permissions: %w", err)
}

if err := os.Rename(tmpPath, path); err != nil {
cleanup()
return fmt.Errorf("renaming temp file: %w", err)
}

return nil
return tmpPath, nil
}
109 changes: 109 additions & 0 deletions io_write_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
package seiconfig

import (
"os"
"path/filepath"
"strings"
"testing"
)

// TestWriteConfigToDir_RoundTrip renders a default config, confirms both legacy
// files land, and reads them back. The default leaves write/read-mode unset, so
// render must omit those keys (letting each seid binary apply its own native
// default — their valid values differ across versions).
func TestWriteConfigToDir_RoundTrip(t *testing.T) {
home := t.TempDir()
if err := WriteConfigToDir(DefaultForMode(ModeValidator), home); err != nil {
t.Fatalf("WriteConfigToDir: %v", err)
}

for _, name := range []string{configTomlFile, appTomlFile} {
p := filepath.Join(home, configDir, name)
if fi, err := os.Stat(p); err != nil || fi.Size() == 0 {
t.Fatalf("expected non-empty %s: stat err=%v", name, err)
}
}

app, err := os.ReadFile(filepath.Join(home, configDir, appTomlFile))
if err != nil {
t.Fatalf("ReadFile app.toml: %v", err)
}
for _, key := range []string{"sc-write-mode", "sc-read-mode", "ss-write-mode", "ss-read-mode"} {
if strings.Contains(string(app), key) {
t.Errorf("default render must omit %q so the binary applies its own default, but it is present", key)
}
}

got, err := ReadConfigFromDir(home)
if err != nil {
t.Fatalf("ReadConfigFromDir: %v", err)
}
if wm := got.Storage.StateCommit.WriteMode; wm != "" {
t.Errorf("state_commit.write_mode: got %q, want unset", wm)
}
if wm := got.Storage.StateStore.WriteMode; wm != "" {
t.Errorf("state_store.write_mode: got %q, want unset", wm)
}
}

// TestWriteConfigToDir_ExplicitWriteModeRendered is the escape hatch: a mode set
// explicitly on the model IS rendered (only the unset default is omitted).
func TestWriteConfigToDir_ExplicitWriteModeRendered(t *testing.T) {
home := t.TempDir()
cfg := DefaultForMode(ModeValidator)
cfg.Storage.StateCommit.WriteMode = WriteModeMemiavlOnly
if err := WriteConfigToDir(cfg, home); err != nil {
t.Fatalf("WriteConfigToDir: %v", err)
}
got, err := ReadConfigFromDir(home)
if err != nil {
t.Fatalf("ReadConfigFromDir: %v", err)
}
if wm := got.Storage.StateCommit.WriteMode; wm != WriteModeMemiavlOnly {
t.Errorf("explicit state_commit.write_mode: got %q, want %q", wm, WriteModeMemiavlOnly)
}
}

// TestWriteConfigToDir_NoTempResidue asserts a successful write leaves no
// .sei-config-*.tmp staging files behind (they are renamed into place, not
// orphaned). Failure-path cleanup is not exercised here.
func TestWriteConfigToDir_NoTempResidue(t *testing.T) {
home := t.TempDir()
if err := WriteConfigToDir(DefaultForMode(ModeFull), home); err != nil {
t.Fatalf("WriteConfigToDir: %v", err)
}
entries, err := os.ReadDir(filepath.Join(home, configDir))
if err != nil {
t.Fatalf("ReadDir: %v", err)
}
for _, e := range entries {
if strings.HasPrefix(e.Name(), ".sei-config-") && strings.HasSuffix(e.Name(), ".tmp") {
t.Errorf("orphaned staging temp file left behind: %s", e.Name())
}
}
}

// TestWriteConfigToDir_ReceiptStoreUsesRSBackend guards the receipt-store key
// asymmetry: the backend key is rs-backend (prefixed); the binary rejects an
// unprefixed `backend` under [receipt-store] at startup, so render must never
// emit one.
func TestWriteConfigToDir_ReceiptStoreUsesRSBackend(t *testing.T) {
home := t.TempDir()
if err := WriteConfigToDir(DefaultForMode(ModeFull), home); err != nil {
t.Fatalf("WriteConfigToDir: %v", err)
}
b, err := os.ReadFile(filepath.Join(home, configDir, appTomlFile))
if err != nil {
t.Fatalf("ReadFile app.toml: %v", err)
}
app := string(b)
if !strings.Contains(app, "rs-backend") {
t.Errorf("app.toml missing rs-backend key")
}
for line := range strings.SplitSeq(app, "\n") {
s := strings.TrimSpace(line)
if strings.HasPrefix(s, "backend =") || strings.HasPrefix(s, "backend=") {
t.Errorf("app.toml emits an unprefixed backend key the binary rejects: %q", s)
}
}
}
8 changes: 4 additions & 4 deletions legacy.go
Original file line number Diff line number Diff line change
Expand Up @@ -257,8 +257,8 @@ type legacyStateCommit struct {
Enable bool `toml:"sc-enable"`
Directory string `toml:"sc-directory"`
AsyncCommitBuffer int `toml:"sc-async-commit-buffer"`
WriteMode string `toml:"sc-write-mode"`
ReadMode string `toml:"sc-read-mode"`
WriteMode string `toml:"sc-write-mode,omitempty"`
ReadMode string `toml:"sc-read-mode,omitempty"`

KeepRecent uint32 `toml:"sc-keep-recent"`
SnapshotInterval uint32 `toml:"sc-snapshot-interval"`
Expand All @@ -277,8 +277,8 @@ type legacyStateStore struct {
ImportNumWorkers int `toml:"ss-import-num-workers"`
KeepLastVersion bool `toml:"ss-keep-last-version"`
UseDefaultComparer bool `toml:"ss-use-default-comparer"`
WriteMode string `toml:"ss-write-mode"`
ReadMode string `toml:"ss-read-mode"`
WriteMode string `toml:"ss-write-mode,omitempty"`
ReadMode string `toml:"ss-read-mode,omitempty"`
EVMDBDirectory string `toml:"ss-evm-db-directory"`
}

Expand Down
7 changes: 4 additions & 3 deletions types.go
Original file line number Diff line number Diff line change
Expand Up @@ -90,9 +90,10 @@ func (m WriteMode) IsValid() bool {
case WriteModeMemiavlOnly, WriteModeMigrateEVM, WriteModeEVMMigrated,
WriteModeMigrateAllButBank, WriteModeAllMigratedButBank,
WriteModeMigrateBank, WriteModeFlatKVOnly, WriteModeTestOnlyDualWrite,
// Deprecated v1 modes remain valid: the stable released seid (v6.5.1)
// still accepts them and rejects the v2 names. The v1→v2 migration
// renames them; validation must not reject configs targeting v6.5.1.
// Deprecated v1 modes remain valid here: older seid releases accept them
// and the v1→v2 migration renames them, so validation must not reject a
// config that still carries one. (The current binary's own enum has
// dropped these — render no longer emits them by default.)
WriteModeCosmosOnly, WriteModeDualWrite, WriteModeSplitWrite:
return true
default:
Expand Down
Loading