* edit: a config that is a hard link stays one when magpie saves or clears it — WriteAtomic renamed a temp file over the path even when the file had other names, so a models.yml that Orca keeps hard-linked into its overlay split in two: omp's name got the new text, the overlay kept the old one, and each later write drifted further; clearing an instructions file unlinked only magpie's name and left the old text under the others. Now a regular file with more than one link (st_nlink on unix, NumberOfLinks from GetFileInformationByHandle on Windows, one elsewhere) is written in place: the new text first goes to a temp file beside it and is synced, then is written over the file, cut to length and synced, and the temp file goes; a crash part way leaves the whole new text beside it, and a failed write keeps it and names it in the error. Mode, owner and every name stay. Remove empties such a file instead of unlinking one name, as it already did for a symlink, and a failed edit rolled back by Atomically goes back in place too. A file with one link is still replaced by rename, atomically, as before. * edit: a read-only hard-linked config is still written, and temp files a crashed write left beside a config go at its next write Review follow-up on #328: - A hard-linked file that can't be opened for writing (0444 on unix, read-only on Windows) fell over with "permission denied" where main's rename replaced it. WriteAtomic now opens it first and, on fs.ErrPermission, falls back to temp file + rename as before, which splits that name from the others as main always did. On Windows main couldn't rename over a read-only file either, so nothing changes there. - writeInPlace says why it writes over and then cuts to length: a reader can briefly see the new text with the old one's tail, but emptying first would show an empty or half file instead, which a tool may load as a config with nothing set; a mangled one fails to parse. - writeInPlace names the trade with hard-link backups (rsnapshot, cp -al, rsync --link-dest): they change with the file, as with vim's backupcopy=auto. - Remove says that emptying every name is meant: with AGENTS.md and CLAUDE.md one file, clearing one empties both, as #323 does for a symlink's target, since deleting one name would leave the text magpie is taking back under the other. - After a write goes through, both paths remove .<name>.<digits>.tmp beside the file older than ten minutes: the file then holds newer text than any of them, a fresh one may be another write's still under way, and the digits keep other files' temps (.models.yml.bak.1.tmp) out.
182 lines
5.0 KiB
Go
182 lines
5.0 KiB
Go
package edit
|
|
|
|
import (
|
|
"errors"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// hardLink makes link another name of target, skipping where the file
|
|
// system has no hard links.
|
|
func hardLink(t *testing.T, target, link string) {
|
|
t.Helper()
|
|
if err := os.Link(target, link); err != nil {
|
|
t.Skip("no hard links here:", err)
|
|
}
|
|
}
|
|
|
|
func sameFile(t *testing.T, a, b string) {
|
|
t.Helper()
|
|
fa, err := os.Stat(a)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
fb, err := os.Stat(b)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !os.SameFile(fa, fb) {
|
|
t.Fatalf("%s and %s are no longer one file", a, b)
|
|
}
|
|
}
|
|
|
|
// A config another tool keeps as a hard link (Orca links omp's models.yml
|
|
// into its overlay): a write goes into the file itself, so every name has
|
|
// the new text, the mode stays, and nothing is left beside it.
|
|
func TestWriteAtomicKeepsHardLink(t *testing.T) {
|
|
d := t.TempDir()
|
|
overlay := filepath.Join(d, "overlay.yml")
|
|
if err := os.WriteFile(overlay, []byte("providers: {}\n"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
st, _ := os.Stat(overlay)
|
|
p := filepath.Join(d, "models.yml")
|
|
hardLink(t, overlay, p)
|
|
for _, text := range []string{"providers:\n magpie:\n api: openai-completions\n", "x: 1\n"} {
|
|
if err := WriteAtomic(p, []byte(text)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
sameFile(t, p, overlay)
|
|
if got := read(t, overlay); got != text {
|
|
t.Fatalf("other name: %q, want %q", got, text)
|
|
}
|
|
}
|
|
if now, _ := os.Stat(p); now.Mode().Perm() != st.Mode().Perm() {
|
|
t.Errorf("mode %v, was %v", now.Mode().Perm(), st.Mode().Perm())
|
|
}
|
|
if es, _ := os.ReadDir(d); len(es) != 2 {
|
|
t.Errorf("left beside it: %v", es)
|
|
}
|
|
}
|
|
|
|
// Emptying a file with another name empties both and keeps it, as with a
|
|
// symlink: deleting one name would leave the old text under the other.
|
|
func TestRemoveKeepsHardLink(t *testing.T) {
|
|
d := t.TempDir()
|
|
other := filepath.Join(d, "other.md")
|
|
if err := os.WriteFile(other, []byte("old\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
p := filepath.Join(d, "AGENTS.md")
|
|
hardLink(t, other, p)
|
|
if err := Remove(p); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
sameFile(t, p, other)
|
|
if got := read(t, other); got != "" {
|
|
t.Fatalf("other name kept %q", got)
|
|
}
|
|
}
|
|
|
|
// A failed edit puts a hard-linked file back in place, still one file.
|
|
func TestAtomicallyKeepsHardLink(t *testing.T) {
|
|
d := t.TempDir()
|
|
other := filepath.Join(d, "other.json")
|
|
if err := os.WriteFile(other, []byte("{}"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
p := filepath.Join(d, "settings.json")
|
|
hardLink(t, other, p)
|
|
err := Atomically(func() error {
|
|
if err := WriteAtomic(p, []byte("half")); err != nil {
|
|
return err
|
|
}
|
|
return errors.New("fail")
|
|
}, p)
|
|
if err == nil || err.Error() != "fail" {
|
|
t.Fatalf("err %v", err)
|
|
}
|
|
sameFile(t, p, other)
|
|
if got := read(t, other); got != "{}" {
|
|
t.Errorf("other name %q", got)
|
|
}
|
|
}
|
|
|
|
// A hard-linked file that can't be opened for writing (mode 0444) is
|
|
// replaced by rename, as before in-place writes: the write goes through,
|
|
// and that name stops being a link of the other.
|
|
func TestWriteAtomicReadOnlyHardLink(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("Windows renames over no read-only file, linked or not")
|
|
}
|
|
if os.Geteuid() == 0 {
|
|
t.Skip("root writes a 0444 file")
|
|
}
|
|
d := t.TempDir()
|
|
other := filepath.Join(d, "other.yml")
|
|
if err := os.WriteFile(other, []byte("old\n"), 0o444); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
p := filepath.Join(d, "models.yml")
|
|
hardLink(t, other, p)
|
|
if err := WriteAtomic(p, []byte("new\n")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := read(t, p); got != "new\n" {
|
|
t.Fatalf("got %q", got)
|
|
}
|
|
if st, _ := os.Stat(p); st.Mode().Perm() != 0o444 {
|
|
t.Errorf("mode %v", st.Mode().Perm())
|
|
}
|
|
}
|
|
|
|
// A temp file a crashed write left beside a config goes at the next write
|
|
// of it, whether that write renames or writes in place. A fresh one, which
|
|
// a write going on now may be using, stays, and so do other files' temps.
|
|
func TestWriteAtomicClearsStaleTemps(t *testing.T) {
|
|
for _, linked := range []bool{false, true} {
|
|
t.Run(map[bool]string{false: "plain", true: "hardlink"}[linked], func(t *testing.T) {
|
|
d := t.TempDir()
|
|
p := filepath.Join(d, "models.yml")
|
|
if err := os.WriteFile(p, []byte("old\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if linked {
|
|
hardLink(t, p, filepath.Join(d, "overlay.yml"))
|
|
}
|
|
old := time.Now().Add(-time.Hour)
|
|
temps := []struct {
|
|
name string
|
|
stale, stays bool
|
|
}{
|
|
{".models.yml.123.tmp", true, false},
|
|
{".models.yml.456.tmp", false, true},
|
|
{".models.yml.x.789.tmp", true, true}, // models.yml.x's
|
|
}
|
|
for _, tt := range temps {
|
|
f := filepath.Join(d, tt.name)
|
|
if err := os.WriteFile(f, []byte("half"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if tt.stale {
|
|
if err := os.Chtimes(f, old, old); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
}
|
|
if err := WriteAtomic(p, []byte("new\n")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, tt := range temps {
|
|
_, err := os.Stat(filepath.Join(d, tt.name))
|
|
if stays := err == nil; stays != tt.stays {
|
|
t.Errorf("%s: stays %v, want %v", tt.name, stays, tt.stays)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|