diff --git a/internal/crew/manager.go b/internal/crew/manager.go index 0782b706..8d7d6866 100644 --- a/internal/crew/manager.go +++ b/internal/crew/manager.go @@ -12,6 +12,7 @@ import ( "github.com/steveyegge/gastown/internal/git" "github.com/steveyegge/gastown/internal/rig" "github.com/steveyegge/gastown/internal/templates" + "github.com/steveyegge/gastown/internal/util" ) // Common errors @@ -254,15 +255,10 @@ func (m *Manager) Get(name string) (*CrewWorker, error) { return m.loadState(name) } -// saveState persists crew worker state to disk. +// saveState persists crew worker state to disk using atomic write. func (m *Manager) saveState(crew *CrewWorker) error { - data, err := json.MarshalIndent(crew, "", " ") - if err != nil { - return fmt.Errorf("marshaling state: %w", err) - } - stateFile := m.stateFile(crew.Name) - if err := os.WriteFile(stateFile, data, 0644); err != nil { + if err := util.AtomicWriteJSON(stateFile, crew); err != nil { return fmt.Errorf("writing state: %w", err) } diff --git a/internal/daemon/types.go b/internal/daemon/types.go index f20c9d63..62ab8c5d 100644 --- a/internal/daemon/types.go +++ b/internal/daemon/types.go @@ -13,6 +13,8 @@ import ( "os" "path/filepath" "time" + + "github.com/steveyegge/gastown/internal/util" ) // Config holds daemon configuration. @@ -82,7 +84,7 @@ func LoadState(townRoot string) (*State, error) { return &state, nil } -// SaveState saves daemon state to disk. +// SaveState saves daemon state to disk using atomic write. func SaveState(townRoot string, state *State) error { stateFile := StateFile(townRoot) @@ -91,12 +93,7 @@ func SaveState(townRoot string, state *State) error { return err } - data, err := json.MarshalIndent(state, "", " ") - if err != nil { - return err - } - - return os.WriteFile(stateFile, data, 0644) + return util.AtomicWriteJSON(stateFile, state) } // LifecycleAction represents a lifecycle request action. diff --git a/internal/polecat/namepool.go b/internal/polecat/namepool.go index aa5d9225..41918ea4 100644 --- a/internal/polecat/namepool.go +++ b/internal/polecat/namepool.go @@ -7,6 +7,8 @@ import ( "path/filepath" "sort" "sync" + + "github.com/steveyegge/gastown/internal/util" ) const ( @@ -175,7 +177,7 @@ func (p *NamePool) Load() error { return nil } -// Save persists the pool state to disk. +// Save persists the pool state to disk using atomic write. func (p *NamePool) Save() error { p.mu.RLock() defer p.mu.RUnlock() @@ -185,12 +187,7 @@ func (p *NamePool) Save() error { return err } - data, err := json.MarshalIndent(p, "", " ") - if err != nil { - return err - } - - return os.WriteFile(p.stateFile, data, 0644) + return util.AtomicWriteJSON(p.stateFile, p) } // Allocate returns a name from the pool. diff --git a/internal/refinery/manager.go b/internal/refinery/manager.go index c92c143c..3fd2cc8f 100644 --- a/internal/refinery/manager.go +++ b/internal/refinery/manager.go @@ -18,6 +18,7 @@ import ( "github.com/steveyegge/gastown/internal/mail" "github.com/steveyegge/gastown/internal/rig" "github.com/steveyegge/gastown/internal/tmux" + "github.com/steveyegge/gastown/internal/util" ) // Common errors @@ -80,19 +81,14 @@ func (m *Manager) loadState() (*Refinery, error) { return &ref, nil } -// saveState persists refinery state to disk. +// saveState persists refinery state to disk using atomic write. func (m *Manager) saveState(ref *Refinery) error { dir := filepath.Dir(m.stateFile()) if err := os.MkdirAll(dir, 0755); err != nil { return err } - data, err := json.MarshalIndent(ref, "", " ") - if err != nil { - return err - } - - return os.WriteFile(m.stateFile(), data, 0644) + return util.AtomicWriteJSON(m.stateFile(), ref) } // Status returns the current refinery status. diff --git a/internal/util/atomic.go b/internal/util/atomic.go new file mode 100644 index 00000000..214818cb --- /dev/null +++ b/internal/util/atomic.go @@ -0,0 +1,41 @@ +// Package util provides common utilities for Gas Town. +package util + +import ( + "encoding/json" + "os" +) + +// AtomicWriteJSON writes JSON data to a file atomically. +// It first writes to a temporary file, then renames it to the target path. +// This prevents data corruption if the process crashes during write. +// The rename operation is atomic on POSIX systems. +func AtomicWriteJSON(path string, v interface{}) error { + data, err := json.MarshalIndent(v, "", " ") + if err != nil { + return err + } + return AtomicWriteFile(path, data, 0644) +} + +// AtomicWriteFile writes data to a file atomically. +// It first writes to a temporary file, then renames it to the target path. +// This prevents data corruption if the process crashes during write. +// The rename operation is atomic on POSIX systems. +func AtomicWriteFile(path string, data []byte, perm os.FileMode) error { + tmpFile := path + ".tmp" + + // Write to temp file + if err := os.WriteFile(tmpFile, data, perm); err != nil { + return err + } + + // Atomic rename (on POSIX systems) + if err := os.Rename(tmpFile, path); err != nil { + // Clean up temp file on failure + _ = os.Remove(tmpFile) + return err + } + + return nil +} diff --git a/internal/util/atomic_test.go b/internal/util/atomic_test.go new file mode 100644 index 00000000..91fbe33a --- /dev/null +++ b/internal/util/atomic_test.go @@ -0,0 +1,88 @@ +package util + +import ( + "os" + "path/filepath" + "testing" +) + +func TestAtomicWriteJSON(t *testing.T) { + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "test.json") + + // Test basic write + data := map[string]string{"key": "value"} + if err := AtomicWriteJSON(testFile, data); err != nil { + t.Fatalf("AtomicWriteJSON error: %v", err) + } + + // Verify file exists + if _, err := os.Stat(testFile); os.IsNotExist(err) { + t.Fatal("File was not created") + } + + // Verify temp file was cleaned up + tmpFile := testFile + ".tmp" + if _, err := os.Stat(tmpFile); !os.IsNotExist(err) { + t.Fatal("Temp file was not cleaned up") + } + + // Read and verify content + content, err := os.ReadFile(testFile) + if err != nil { + t.Fatalf("ReadFile error: %v", err) + } + if string(content) != "{\n \"key\": \"value\"\n}" { + t.Fatalf("Unexpected content: %s", content) + } +} + +func TestAtomicWriteFile(t *testing.T) { + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "test.txt") + + // Test basic write + data := []byte("hello world") + if err := AtomicWriteFile(testFile, data, 0644); err != nil { + t.Fatalf("AtomicWriteFile error: %v", err) + } + + // Verify content + content, err := os.ReadFile(testFile) + if err != nil { + t.Fatalf("ReadFile error: %v", err) + } + if string(content) != "hello world" { + t.Fatalf("Unexpected content: %s", content) + } + + // Verify temp file was cleaned up + tmpFile := testFile + ".tmp" + if _, err := os.Stat(tmpFile); !os.IsNotExist(err) { + t.Fatal("Temp file was not cleaned up") + } +} + +func TestAtomicWriteOverwrite(t *testing.T) { + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "test.json") + + // Write initial content + if err := AtomicWriteJSON(testFile, "first"); err != nil { + t.Fatalf("First write error: %v", err) + } + + // Overwrite with new content + if err := AtomicWriteJSON(testFile, "second"); err != nil { + t.Fatalf("Second write error: %v", err) + } + + // Verify new content + content, err := os.ReadFile(testFile) + if err != nil { + t.Fatalf("ReadFile error: %v", err) + } + if string(content) != "\"second\"" { + t.Fatalf("Unexpected content: %s", content) + } +} diff --git a/internal/witness/manager.go b/internal/witness/manager.go index 06696bdf..747d92ff 100644 --- a/internal/witness/manager.go +++ b/internal/witness/manager.go @@ -8,6 +8,7 @@ import ( "time" "github.com/steveyegge/gastown/internal/rig" + "github.com/steveyegge/gastown/internal/util" ) // Common errors @@ -56,19 +57,14 @@ func (m *Manager) loadState() (*Witness, error) { return &w, nil } -// saveState persists witness state to disk. +// saveState persists witness state to disk using atomic write. func (m *Manager) saveState(w *Witness) error { dir := filepath.Dir(m.stateFile()) if err := os.MkdirAll(dir, 0755); err != nil { return err } - data, err := json.MarshalIndent(w, "", " ") - if err != nil { - return err - } - - return os.WriteFile(m.stateFile(), data, 0644) + return util.AtomicWriteJSON(m.stateFile(), w) } // Status returns the current witness status.