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
44 changes: 44 additions & 0 deletions go/display/webkit/pkg/window/core_helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand Down
2 changes: 1 addition & 1 deletion go/display/webkit/pkg/window/layout.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion go/display/webkit/pkg/window/state.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
69 changes: 69 additions & 0 deletions go/display/webkit/pkg/window/state_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
Loading