From 5e7ae97b57086b56b91d2b919364a78fa6bcb592 Mon Sep 17 00:00:00 2001 From: Kharec Date: Thu, 15 Oct 2026 17:44:56 +0200 Subject: [PATCH] fix: make atomic saves durable and preserve the file writeAtomic renamed the temporary file over the target without syncing either: on ext4 with delayed allocation a power cut just after a save could leave a zero-length script, the exact failure autosave exists to prevent. Sync the temp file before the rename and the directory after it. While here, keep an existing file's permissions instead of forcing 0o644, and resolve symlinks first so saving through a link replaces the file it points to, not the link itself (Dropbox/dotfile setups). --- CHANGELOG.md | 4 ++++ app.go | 38 +++++++++++++++++++++++++++++++++----- 2 files changed, 37 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ebe06bd..89888c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,10 @@ version is in the `VERSION` file. - The unsaved-changes marker is now updated atomically: an edit landing while a save finished could make the close confirmation unreliable. +- Saving is now durable: the file is synced to disk before it replaces the old one (a + power cut just after a save could leave an empty script). An existing file keeps its + permissions, and saving through a symlink replaces the file it points to instead of + replacing the link. ## [1.4] - 2026-10-15 diff --git a/app.go b/app.go index 331a7b8..1ce46f3 100644 --- a/app.go +++ b/app.go @@ -386,20 +386,48 @@ func (a *App) saveDialog(title, current, ext string, filters []runtime.FileFilte return path, nil } +// writeAtomic writes data to path atomically and durably: a temporary file +// in the same directory is written, given the file's permissions and synced +// to disk, then renamed over path and the directory itself synced, so a +// power cut just after a save cannot leave an empty script behind (the very +// failure autosave, which calls this every 1.2 s, exists to prevent). A +// symlinked path is resolved first: the file it points to is replaced, not +// the link. An existing file keeps its permissions; a new one gets 0o644. func writeAtomic(path string, data []byte) error { + if target, err := filepath.EvalSymlinks(path); err == nil { + path = target + } + mode := os.FileMode(0o644) + if st, err := os.Stat(path); err == nil { + mode = st.Mode().Perm() + } tmp, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+".*") if err != nil { return err } - _, werr := tmp.Write(data) - cerr := tmp.Close() - if err := errors.Join(werr, cerr); err != nil { + if _, err = tmp.Write(data); err == nil { + err = tmp.Chmod(mode) + } + if err == nil { + err = tmp.Sync() + } + if cerr := tmp.Close(); err == nil { + err = cerr + } + if err != nil { os.Remove(tmp.Name()) return err } - if err := os.Chmod(tmp.Name(), 0o644); err != nil { + if err := os.Rename(tmp.Name(), path); err != nil { os.Remove(tmp.Name()) return err } - return os.Rename(tmp.Name(), path) + // The rename is a directory entry: sync the directory so it too + // survives a crash. + dir, err := os.Open(filepath.Dir(path)) + if err != nil { + return err + } + defer dir.Close() + return dir.Sync() }