diff --git a/defaults.go b/defaults.go index 4a7ad7d..f9bc7bd 100644 --- a/defaults.go +++ b/defaults.go @@ -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, @@ -131,8 +130,6 @@ func baseDefaults() *SeiConfig { PruneIntervalSeconds: 600, ImportNumWorkers: 1, KeepLastVersion: true, - WriteMode: WriteModeCosmosOnly, - ReadMode: ReadModeCosmosOnly, }, ReceiptStore: ReceiptStoreConfig{ Backend: BackendPebbleDB, diff --git a/io.go b/io.go index 352474f..dac5012 100644 --- a/io.go +++ b/io.go @@ -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 { @@ -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 } @@ -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 } diff --git a/io_write_test.go b/io_write_test.go new file mode 100644 index 0000000..9b0c4a8 --- /dev/null +++ b/io_write_test.go @@ -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) + } + } +} diff --git a/legacy.go b/legacy.go index 5753923..dc2f1da 100644 --- a/legacy.go +++ b/legacy.go @@ -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"` @@ -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"` } diff --git a/types.go b/types.go index aa14744..19148e1 100644 --- a/types.go +++ b/types.go @@ -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: