windows: an account list read while another magpie writes it is waited for, not taken for no accounts (Packing1 on Discord, Windows 11: 早上更新重启后之前登录的Antigravity订阅供应商和账号都没了 … 整行都没了,供应商里面antigravity直接消失了) — on Windows a file being renamed over (every write magpie makes: a temp file renamed onto it) is, for a moment of up to ~2ms, "The system cannot find the file specified" to anyone else looking at it, with MoveFileEx and with POSIX-semantics renames alike. filememo.Read gave that back as an error, readLogins as no accounts, and whatever change came next (any account's read-modify-write) wrote the empty list back: every built-in account gone, its subscription with it. Measured on Windows 11 (10.0.0.132): one process replacing a 200 KB file, another stat-ing and reading it, saw the file "not there" in a few windows each 20 s. Now steady.Stat and steady.ReadThere wait out a moment of a file not there (15 tries a millisecond apart, Windows only), and filememo looks through them, remembering a file found not there so one that really isn't costs the wait once. A magpie started by an update now waits for the one it replaces before its start-up moves write any file (AwaitPredecessor at the top of run, not only in the GUI). TestLoginsKeptWhileAnotherMagpieWrites (Windows: two writers replacing logins.json, readLogins never without the Antigravity account) failed 1 in 3 and 2 in 8 runs on the old code ("read as no accounts 8 times"), passed 11 of 11 on this; filememo's TestReadWhileReplaced the same (1180 misses on the old code); TestStatWaitsOutAReplace everywhere. A real update in a sandbox home on the Windows box, magpie-cli 0.1.880 → 0.1.942 from the release, kept the account and logins.json byte for byte. go vet (darwin, and windows nogui), builds (darwin, GOOS=linux nogui, GOOS=windows) and go test -tags nogui -count=1 ./... under a temp HOME pass.

This commit is contained in:
yetone committed 2026-10-05 10:57:45 +08:00
1 parent e85a803eaa
commit d5619d5a92
6 files changed
+267 -6

No files matched your search

+34 -2
View File
@@ -4,6 +4,8 @@ package filememo
import (
"bytes"
"errors"
"io/fs"
"os"
"sync"
"time"
@@ -37,6 +39,10 @@ var (
// on by every write magpie makes (Forget)
holds int
gen uint64
// gone is the files found not there last time looked at, not waited
// on again (steady.Stat): one there before and not now may be being
// replaced (Windows), and is
gone = map[string]bool{}
)
// heldFor is how long a file found unchanged is taken as unchanged, while
@@ -82,7 +88,7 @@ func Read[T any](kind, path string, parse func([]byte) (T, error)) (T, error) {
}
g := gen
mu.Unlock()
fi, err := os.Stat(path)
fi, err := statFile(path)
if err != nil {
return zero, err
}
@@ -95,7 +101,7 @@ func Read[T any](kind, path string, parse func([]byte) (T, error)) (T, error) {
mu.Unlock()
return e.v.(T), nil
}
b, err := steady.ReadFile(path)
b, err := steady.ReadThere(path)
if err != nil {
return zero, err
}
@@ -123,3 +129,29 @@ func Read[T any](kind, path string, parse func([]byte) (T, error)) (T, error) {
mu.Unlock()
return v, nil
}
// statFile is os.Stat of path, waiting out a moment of it not there
// (another process replacing it, on Windows) unless it wasn't there last
// time either: taken for gone, a file of accounts read as no accounts, and
// the next write left them all out (Packing1 on Discord, Windows 11:
// Antigravity's account gone after an update).
func statFile(path string) (os.FileInfo, error) {
mu.Lock()
wasGone := gone[path]
mu.Unlock()
var fi os.FileInfo
var err error
if wasGone {
fi, err = os.Stat(path)
} else {
fi, err = steady.Stat(path)
}
mu.Lock()
if errors.Is(err, fs.ErrNotExist) {
gone[path] = true
} else {
delete(gone, path)
}
mu.Unlock()
return fi, err
}
@@ -0,0 +1,60 @@
package filememo
import (
"bytes"
"os"
"path/filepath"
"sync/atomic"
"testing"
"time"
"github.com/yetone/magpie/internal/steady"
)
// Another magpie replacing the file (a write is a temp file renamed over
// it) has Windows say, for a moment, that the file isn't there at all: read
// so, a file of accounts was no accounts, and the next write left them out
// (Packing1 on Discord, Windows 11: Antigravity's provider and its account
// gone after an update). Read waits that moment out.
func TestReadWhileReplaced(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "logins.json")
body := bytes.Repeat([]byte("x"), 100_000)
os.WriteFile(p, body, 0o600)
var stop atomic.Bool
done := make(chan struct{})
for range 2 {
go func() {
defer func() { done <- struct{}{} }()
for !stop.Load() {
tmp, err := os.CreateTemp(dir, ".logins.json.*")
if err != nil {
continue
}
tmp.Write(body)
tmp.Close()
if steady.Rename(tmp.Name(), p) != nil {
os.Remove(tmp.Name())
}
}
}()
}
defer func() {
stop.Store(true)
<-done
<-done
}()
parse := func(b []byte) (int, error) { return len(b), nil }
misses := 0
for end := time.Now().Add(5 * time.Second); time.Now().Before(end); {
if n, err := Read("replaced", p, parse); err != nil || n != len(body) {
misses++
if misses < 4 {
t.Logf("read %d bytes, %v", n, err)
}
}
}
if misses > 0 {
t.Fatalf("the file read as not there %d times while replaced", misses)
}
}
+70
View File
@@ -0,0 +1,70 @@
package provider
import (
"os"
"path/filepath"
"slices"
"sync/atomic"
"testing"
"time"
"github.com/yetone/magpie/internal/steady"
)
// Another magpie writing logins.json (a temp file renamed over it: the one
// an update is replacing, a second one started) has Windows say, for a
// moment, that it isn't there: read so, it was no accounts, and the write
// that followed left every account out (Packing1 on Discord, Windows 11:
// 「整行都没了,供应商里面antigravity直接消失了」 after an update).
func TestLoginsKeptWhileAnotherMagpieWrites(t *testing.T) {
googleSandbox(t, &fakeGoogle{})
if err := addGoogleLogin("antigravity", "a@x.com", "", googleAuth{RefreshToken: "1//a", Project: "pa"}); err != nil {
t.Fatal(err)
}
p := loginsPath()
body, err := os.ReadFile(p)
if err != nil {
t.Fatal(err)
}
var stop atomic.Bool
done := make(chan struct{})
for range 2 {
go func() {
defer func() { done <- struct{}{} }()
for !stop.Load() {
tmp, err := os.CreateTemp(filepath.Dir(p), ".logins.json.*")
if err != nil {
continue
}
tmp.Write(body)
tmp.Close()
if steady.Rename(tmp.Name(), p) != nil {
os.Remove(tmp.Name())
}
}
}()
}
has := func(ls []savedLogin) bool {
return slices.ContainsFunc(ls, func(l savedLogin) bool { return l.Agent == "antigravity" && l.User == "a@x.com" })
}
misses := 0
for end := time.Now().Add(5 * time.Second); time.Now().Before(end); {
loginsMu.Lock()
if ls := readLogins(); !has(ls) {
misses++
// what every change to an account does next: the list read,
// changed, written back
_ = writeLogins(ls)
}
loginsMu.Unlock()
}
stop.Store(true)
<-done
<-done
if !has(readLogins()) {
t.Fatalf("the Antigravity account is gone from logins.json (read as none %d times)", misses)
}
if misses > 0 {
t.Fatalf("logins.json read as no accounts %d times while another magpie wrote it", misses)
}
}
+57 -4
View File
@@ -2,8 +2,11 @@
// replacing at that moment. On Windows a file being renamed over refuses
// to open, and one held open refuses to be renamed over, both for a
// moment ("Access is denied"): read as none, a file of accounts is no
// accounts, and the next write leaves them out. Elsewhere a rename is
// atomic and these are os.ReadFile and os.Rename.
// accounts, and the next write leaves them out. A file being renamed over
// is also, for a moment (two milliseconds seen), not there at all, to
// another process or thread: Stat and ReadThere wait that out for a file
// that was there. Elsewhere a rename is atomic and these are os.Stat,
// os.ReadFile and os.Rename.
package steady
import (
@@ -21,20 +24,70 @@ const (
pause = 10 * time.Millisecond
)
// goneTries and gonePause bound the wait on a file not there to 15ms or
// so: the moment a rename over it leaves is a couple of milliseconds, and
// a file that is really not there is waited on that long.
const (
goneTries = 15
gonePause = time.Millisecond
)
// windows is whether files behave as Windows' do; a test sets it.
var windows = runtime.GOOS == "windows"
// stat and readFile are os.Stat and os.ReadFile; a test stands in for them.
var (
stat = os.Stat
readFile = os.ReadFile
)
func retry(f func() error) error {
err := f()
for i := 0; err != nil && runtime.GOOS == "windows" && !errors.Is(err, fs.ErrNotExist) && i < tries; i++ {
for i := 0; err != nil && windows && !errors.Is(err, fs.ErrNotExist) && i < tries; i++ {
time.Sleep(pause)
err = f()
}
return err
}
// retryGone is retry, waiting out a moment of the file not there too.
func retryGone(f func() error) error {
err := retry(f)
for i := 0; err != nil && windows && errors.Is(err, fs.ErrNotExist) && i < goneTries; i++ {
time.Sleep(gonePause)
err = retry(f)
}
return err
}
// Stat is os.Stat of a file that was there: one being replaced is waited
// for, so it isn't taken for gone. A file not there costs the wait, so a
// caller asking again remembers it gone (filememo does).
func Stat(p string) (fs.FileInfo, error) {
var fi fs.FileInfo
err := retryGone(func() (err error) {
fi, err = stat(p)
return err
})
return fi, err
}
// ReadThere is ReadFile of a file just found there (Stat): not there now is
// it being replaced, waited out.
func ReadThere(p string) ([]byte, error) {
var b []byte
err := retryGone(func() (err error) {
b, err = readFile(p)
return err
})
return b, err
}
// ReadFile is os.ReadFile, waiting out a file being replaced.
func ReadFile(p string) ([]byte, error) {
var b []byte
err := retry(func() (err error) {
b, err = os.ReadFile(p)
b, err = readFile(p)
return err
})
return b, err
+43
View File
@@ -0,0 +1,43 @@
package steady
import (
"errors"
"io/fs"
"os"
"runtime"
"testing"
)
// as Windows has it: a file being renamed over is, for a moment, not there
func TestStatWaitsOutAReplace(t *testing.T) {
windows = true
defer func() { windows, stat, readFile = runtime.GOOS == "windows", os.Stat, os.ReadFile }()
missing := 3
stat = func(p string) (fs.FileInfo, error) {
if missing > 0 {
missing--
return nil, fs.ErrNotExist
}
return os.Stat(p)
}
p := t.TempDir()
if _, err := Stat(p); err != nil {
t.Fatalf("a file being replaced taken for gone: %v", err)
}
missing = 3
readFile = func(p string) ([]byte, error) {
if missing > 0 {
missing--
return nil, &fs.PathError{Op: "open", Path: p, Err: fs.ErrNotExist}
}
return []byte("accounts"), nil
}
if b, err := ReadThere(p); err != nil || string(b) != "accounts" {
t.Fatalf("read %q, %v", b, err)
}
// one really gone is said so, after the wait
stat = os.Stat
if _, err := Stat(p + "-none"); !errors.Is(err, fs.ErrNotExist) {
t.Fatalf("a file not there: %v", err)
}
}
+3
View File
@@ -190,6 +190,9 @@ func run(args []string) error {
// (agent.DisconnectPreview)
return agent.DryRun(args[1])
}
// started by an update, the magpie it replaces goes first: the moves
// below write the files it may still be writing
update.AwaitPredecessor()
makeDirs()
settings.Migrate()
// the providers and settings read once for every agent's fields, which