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).
This commit is contained in:
1 parent
c0a52a9040
commit
5e7ae97b57
2 files changed
+37
-5
No files matched your search
@@ -11,6 +11,10 @@ version is in the `VERSION` file.
|
|||||||
|
|
||||||
- The unsaved-changes marker is now updated atomically: an edit landing while a save
|
- The unsaved-changes marker is now updated atomically: an edit landing while a save
|
||||||
finished could make the close confirmation unreliable.
|
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
|
## [1.4] - 2026-10-15
|
||||||
|
|
||||||
|
|||||||
@@ -386,20 +386,48 @@ func (a *App) saveDialog(title, current, ext string, filters []runtime.FileFilte
|
|||||||
return path, nil
|
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 {
|
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)+".*")
|
tmp, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+".*")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
_, werr := tmp.Write(data)
|
if _, err = tmp.Write(data); err == nil {
|
||||||
cerr := tmp.Close()
|
err = tmp.Chmod(mode)
|
||||||
if err := errors.Join(werr, cerr); err != nil {
|
}
|
||||||
|
if err == nil {
|
||||||
|
err = tmp.Sync()
|
||||||
|
}
|
||||||
|
if cerr := tmp.Close(); err == nil {
|
||||||
|
err = cerr
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
os.Remove(tmp.Name())
|
os.Remove(tmp.Name())
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := os.Chmod(tmp.Name(), 0o644); err != nil {
|
if err := os.Rename(tmp.Name(), path); err != nil {
|
||||||
os.Remove(tmp.Name())
|
os.Remove(tmp.Name())
|
||||||
return err
|
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()
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user