From 2dfc5a26ff4f9bf95a8e58bd84d4e11b90ee3450 Mon Sep 17 00:00:00 2001 From: AnyCPU Date: Mon, 16 Feb 2026 17:25:34 +0200 Subject: [PATCH] Use atomic write for state file to prevent corruption on crash --- internal/server/router.go | 22 ++++++++++++++++++++-- internal/server/router_test.go | 26 ++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/internal/server/router.go b/internal/server/router.go index 051f0be8..e7c3e949 100644 --- a/internal/server/router.go +++ b/internal/server/router.go @@ -8,6 +8,7 @@ import ( "log/slog" "net/http" "os" + "path/filepath" "strings" "sync" "time" @@ -350,17 +351,34 @@ func (r *Router) saveStateSnapshot() error { return nil }) - f, err := os.Create(r.statePath) + tmp, err := os.CreateTemp(filepath.Dir(r.statePath), ".kamal-proxy.state.*") if err != nil { + slog.Error("Unable to create temp state file", "error", err) return err } + defer os.Remove(tmp.Name()) // clean up on any failure path - err = json.NewEncoder(f).Encode(services) + err = json.NewEncoder(tmp).Encode(services) if err != nil { + tmp.Close() slog.Error("Unable to save state", "error", err, "path", r.statePath) return err } + err = tmp.Sync() + if err != nil { + tmp.Close() + slog.Error("Unable to sync state file", "error", err) + return err + } + tmp.Close() + + err = os.Rename(tmp.Name(), r.statePath) + if err != nil { + slog.Error("Unable to rename state file", "error", err) + return err + } + slog.Debug("Saved state", "path", r.statePath) return nil } diff --git a/internal/server/router_test.go b/internal/server/router_test.go index c1252b3c..5387c55b 100644 --- a/internal/server/router_test.go +++ b/internal/server/router_test.go @@ -778,6 +778,32 @@ func testRouter(t *testing.T) *Router { return NewRouter(statePath) } +func TestRouter_StateFileSurvivesRestart(t *testing.T) { + statePath := filepath.Join(t.TempDir(), "state.json") + _, target := testBackend(t, "first", http.StatusOK) + + router := NewRouter(statePath) + require.NoError(t, router.DeployService("service1", []string{target}, + defaultEmptyReaders, defaultServiceOptions, defaultTargetOptions, defaultDeploymentOptions)) + + // Verify state file exists and is valid JSON + f, err := os.Open(statePath) + require.NoError(t, err) + defer f.Close() + + var services []*Service + require.NoError(t, json.NewDecoder(f).Decode(&services)) + assert.Len(t, services, 1) + + // Verify no temp files left behind + entries, err := os.ReadDir(filepath.Dir(statePath)) + require.NoError(t, err) + for _, entry := range entries { + assert.False(t, strings.HasPrefix(entry.Name(), ".kamal-proxy.state."), + "temp file should not remain: %s", entry.Name()) + } +} + func sendGETRequest(router *Router, url string) (int, string) { req := httptest.NewRequest(http.MethodGet, url, nil) return sendRequest(router, req)