diff --git a/go/display/webkit/pkg/window/core_helpers.go b/go/display/webkit/pkg/window/core_helpers.go index 178588d..9230fbe 100644 --- a/go/display/webkit/pkg/window/core_helpers.go +++ b/go/display/webkit/pkg/window/core_helpers.go @@ -27,6 +27,50 @@ func coreWriteFile(path string, data []byte, mode core.FileMode) resultFailure { return coreResultError(core.WriteFile(path, data, mode), "failed to write file") } +// coreWriteFileAtomic publishes data at path by writing an exclusively-created +// sibling temp file and renaming it into place. Rename is atomic on POSIX, +// so a reader — or a concurrent writer racing this one — always observes a +// COMPLETE document. The plain truncate-and-write it replaces let two +// processes sharing one state file (lthn and core/ide both persist into +// the Core config directory) splice a short document onto the tail of a +// longer one. CreateTemp opens with O_EXCL, so no two writers can ever +// share a temp file — a fixed ".tmp" name would simply move the same +// splice into the temp file. +func coreWriteFileAtomic(path string, data []byte, mode core.FileMode) resultFailure { + tmpR := core.CreateTemp(core.PathDir(path), core.Concat(core.PathBase(path), ".tmp.*")) + if !tmpR.OK { + return coreResultError(tmpR, "failed to create temp file") + } + file, ok := tmpR.Value.(interface { + Name() string + Write([]byte) (int, error) + Close() error + }) + if !ok { + return core.NewError("unexpected temp file type") + } + tmp := file.Name() + if _, err := file.Write(data); err != nil { + _ = file.Close() + core.Remove(tmp) + return err + } + if err := file.Close(); err != nil { + core.Remove(tmp) + return err + } + // CreateTemp opens at 0600; restore the caller's contract before publish. + if err := coreResultError(core.Chmod(tmp, mode), "failed to set file mode"); err != nil { + core.Remove(tmp) + return err + } + if err := coreResultError(core.Rename(tmp, path), "failed to publish file"); err != nil { + core.Remove(tmp) + return err + } + return nil +} + func coreMkdirAll(path string, mode core.FileMode) resultFailure { return coreResultError(core.MkdirAll(path, mode), "failed to create directory") } diff --git a/go/display/webkit/pkg/window/layout.go b/go/display/webkit/pkg/window/layout.go index 3e124a8..60b98a8 100644 --- a/go/display/webkit/pkg/window/layout.go +++ b/go/display/webkit/pkg/window/layout.go @@ -161,7 +161,7 @@ func (lm *LayoutManager) save() resultFailure { return core.E("window.LayoutManager.save", "failed to create window layout directory", err) } } - if err := coreWriteFile(filePath, data, 0o644); err != nil { + if err := coreWriteFileAtomic(filePath, data, 0o644); err != nil { core.Error( "window layout save failed", "file_path", filePath, diff --git a/go/display/webkit/pkg/window/state.go b/go/display/webkit/pkg/window/state.go index 702fe1d..8475096 100644 --- a/go/display/webkit/pkg/window/state.go +++ b/go/display/webkit/pkg/window/state.go @@ -160,7 +160,7 @@ func (sm *StateManager) save() resultFailure { return core.E("window.StateManager.save", "failed to create window state directory", err) } } - if err := coreWriteFile(filePath, data, 0o644); err != nil { + if err := coreWriteFileAtomic(filePath, data, 0o644); err != nil { core.Error( "window state save failed", "file_path", filePath, diff --git a/go/display/webkit/pkg/window/state_test.go b/go/display/webkit/pkg/window/state_test.go index 67e072b..e8ceda6 100644 --- a/go/display/webkit/pkg/window/state_test.go +++ b/go/display/webkit/pkg/window/state_test.go @@ -724,3 +724,72 @@ func TestState_StateManager_ForceSync_Ugly(t *core.T) { }) core.AssertNotNil(t, result.Value) } + +func TestStateManagerState_SaveAtomic_Good(t *core.T) { + // save — publishes via coreWriteFileAtomic + ax7Variant := "save:good" + core.AssertContains(t, ax7Variant, "good") + dir := t.TempDir() + sm := NewStateManagerWithDir(dir) + sm.states["main"] = WindowState{X: 1, Y: 2, Width: 640, Height: 480} + core.RequireNoError(t, sm.save()) + + content, err := coreReadFile(core.PathJoin(dir, "window_state.json")) + core.RequireNoError(t, err) + loaded := make(map[string]WindowState) + core.RequireTrue(t, core.JSONUnmarshal(content, &loaded).OK) + core.AssertEqual(t, 640, loaded["main"].Width) +} + +func TestStateManagerState_SaveAtomic_Ugly_ConcurrentWritersNeverSplice(t *core.T) { + // save — two managers share one path, as lthn and core/ide share the + // Core config directory in production. Every read during the storm + // must parse: rename publishes complete documents only. The plain + // truncate-and-write this guards against could splice one writer's + // short document onto the tail of the other's longer one. + ax7Variant := "save:ugly" + core.AssertContains(t, ax7Variant, "ugly") + path := core.PathJoin(t.TempDir(), "window_state.json") + long := NewStateManagerWithPath(path) + short := NewStateManagerWithPath(path) + for i := 0; i < 24; i++ { + long.states[core.Concat("window-", core.Itoa(i))] = WindowState{X: i, Y: i, Width: 1600, Height: 900} + } + short.states["main"] = WindowState{Width: 640, Height: 480} + + // The first save lands before the reader starts, so every read below + // observes a file that exists; the reader then runs until BOTH writers + // finish, so reads are guaranteed to overlap the write storm. + core.RequireNoError(t, long.save()) + saveErrs := make(chan resultFailure, 100) + done := make(chan bool, 2) + writer := func(sm *StateManager) { + for i := 0; i < 50; i++ { + saveErrs <- sm.save() + } + done <- true + } + go writer(long) + go writer(short) + + spliced := 0 + finished := 0 + for finished < 2 { + select { + case <-done: + finished++ + default: + } + content, err := coreReadFile(path) + core.RequireNoError(t, err) + loaded := make(map[string]WindowState) + if !core.JSONUnmarshal(content, &loaded).OK { + spliced++ + } + } + close(saveErrs) + for err := range saveErrs { + core.RequireNoError(t, err) + } + core.AssertEqual(t, 0, spliced) +}