From 2a4042a94d39717fbda8829790ed6241f20d60da Mon Sep 17 00:00:00 2001 From: yetone Date: Mon, 5 Oct 2026 02:59:08 +0800 Subject: [PATCH] =?UTF-8?q?perf:=20magpie=20opens=20and=20shows=20the=20Ag?= =?UTF-8?q?ents=20page=20in=20a=20fraction=20of=20the=20time=20it=20took?= =?UTF-8?q?=20(Junjie=20Zhou=20on=20Discord:=208=20=E4=B8=AA=20agent=2030?= =?UTF-8?q?=20=E4=B8=AA=20provider=20=E2=80=A6=20=E6=AF=8F=E6=AC=A1?= =?UTF-8?q?=E6=88=91=E6=89=93=E5=BC=80=E7=9A=84=E6=97=B6=E5=80=99=E9=83=BD?= =?UTF-8?q?=E4=BC=9A=E5=BE=88=E6=85=A2=EF=BC=8C=E8=A6=81=E5=A5=BD=E5=87=A0?= =?UTF-8?q?=E7=A7=92=E3=80=82setting=20=E9=A1=B5=E9=9D=A2=E7=A7=92?= =?UTF-8?q?=E8=BF=9B=E5=90=8E=20=E6=88=91=E5=88=87=E6=8D=A2=E5=88=B0=20age?= =?UTF-8?q?nt=20=E5=B0=B1=E6=9C=89=E9=97=AE=E9=A2=98)=20=E2=80=94=20at=20s?= =?UTF-8?q?tart,=20writing=20the=20agents'=20model=20lists=20(agent.SyncCa?= =?UTF-8?q?talog)=20built=20every=20provider's=20catalog=20thousands=20of?= =?UTF-8?q?=20times=20with=20nothing=20held,=20each=20build=20reading=20mi?= =?UTF-8?q?grations.json,=20settings=20and=20groups=20again:=206000+=20fil?= =?UTF-8?q?e=20reads=20and=20~8.5s=20of=20CPU=20in=20the=20first=208s=20wi?= =?UTF-8?q?th=20the=20owner's=2030-odd=20providers,=20all=20competing=20wi?= =?UTF-8?q?th=20the=20first=20/api/state=20and=20the=20Agents=20page.=20No?= =?UTF-8?q?w=20SyncCatalog,=20magpieModels=20and=20main's=20start-up=20mov?= =?UTF-8?q?es=20run=20under=20provider.Hold,=20migrations,=20groups,=20set?= =?UTF-8?q?tings=20and=20a=20provider's=20fetched=20models=20are=20held=20?= =?UTF-8?q?for=20a=20second=20like=20the=20rest,=20and=20filememo.Hold=20l?= =?UTF-8?q?ets=20a=20GET=20/api/*=20not=20stat=20a=20file=20it=20confirmed?= =?UTF-8?q?=20unchanged=20within=20the=20last=20second=20(any=20magpie=20w?= =?UTF-8?q?rite,=20Changed()=20or=20a=20recent=20mtime=20still=20reads=20i?= =?UTF-8?q?t=20again).=20A=20big=20~/.codex/config.toml=20parsed=20in=20qu?= =?UTF-8?q?adratic=20time=20(the=20TOML=20parser's=20Shape=20counts=20newl?= =?UTF-8?q?ines=20from=20the=20start=20for=20every=20key):=20a=203=20MB=20?= =?UTF-8?q?one=20took=205.4s,=20now=200.04s=20(tomlShaper).=20theInstalled?= =?UTF-8?q?=20reads=20through=20edit.Read's=20memo.=20Measured=20with=20a?= =?UTF-8?q?=20copy=20of=20the=20owner's=20magpie=20config=20and=20agent=20?= =?UTF-8?q?configs=20under=20a=20sandbox=20HOME,=20the=20real=20server=20i?= =?UTF-8?q?n=20WebKit:=20CPU=20in=20the=20first=208s=208.59s=20=E2=86=92?= =?UTF-8?q?=200.39s,=20page=20up=20475=20=E2=86=92=2075ms,=20Agents=20rows?= =?UTF-8?q?=20drawn=20775=20=E2=86=92=20325ms=20after=20start;=20the=20age?= =?UTF-8?q?nts'=20written=20model=20files=20are=20unchanged=20(pi=20byte?= =?UTF-8?q?=20for=20byte,=20opencode=20the=20same=20models).=20TestTOMLSha?= =?UTF-8?q?perMatchesParser,=20TestTOMLBigFileParsesInLinearTime=20(fails?= =?UTF-8?q?=20on=20the=20old=20code),=20TestHold=20(filememo),=20TestHoldR?= =?UTF-8?q?eadsMigrationsOnce.=20go=20vet,=20builds=20(darwin,=20GOOS=3Dli?= =?UTF-8?q?nux=20nogui,=20GOOS=3Dwindows)=20and=20go=20test=20-tags=20nogu?= =?UTF-8?q?i=20./...=20under=20a=20temp=20HOME=20pass.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/agent/applied.go | 3 +- internal/agent/route.go | 3 ++ internal/agent/sync.go | 5 +++ internal/edit/file.go | 12 ++++- internal/edit/toml.go | 25 ++++++++++- internal/edit/toml_shape_test.go | 71 ++++++++++++++++++++++++++++++ internal/filememo/filememo.go | 57 ++++++++++++++++++++++-- internal/filememo/filememo_test.go | 46 +++++++++++++++++++ internal/gateway/vision.go | 3 +- internal/gui/api.go | 3 ++ internal/provider/group.go | 4 +- internal/provider/hold.go | 6 +++ internal/provider/hold_test.go | 22 +++++++++ internal/provider/logins.go | 1 + internal/provider/migrate.go | 8 +++- internal/provider/models.go | 8 +++- internal/settings/settings.go | 1 + main.go | 4 ++ 18 files changed, 266 insertions(+), 16 deletions(-) create mode 100644 internal/edit/toml_shape_test.go diff --git a/internal/agent/applied.go b/internal/agent/applied.go index f27ec04c..e81bfa05 100644 --- a/internal/agent/applied.go +++ b/internal/agent/applied.go @@ -12,6 +12,7 @@ import ( "sync" "time" + "github.com/yetone/magpie/internal/edit" "github.com/yetone/magpie/internal/gateway" "github.com/yetone/magpie/internal/provider" "github.com/yetone/magpie/internal/usage" @@ -182,7 +183,7 @@ func (a *Agent) theInstalled() bool { if !elsewhere() || a.Path == "" { return false } - b, err := os.ReadFile(a.Path) + b, err := edit.Read(a.Path) if err != nil { return false } diff --git a/internal/agent/route.go b/internal/agent/route.go index ea4c37d8..80c4e272 100644 --- a/internal/agent/route.go +++ b/internal/agent/route.go @@ -94,6 +94,9 @@ func firstOf(xs []string) string { // magpieModels is the catalog as agent is shown it, as catalog.Models, for // agents that keep their own model files. func magpieModels(agent string) []catalog.Model { + // the providers built once for all the models' prices, not for each: + // dsh's round at every start built them for each of hundreds + defer provider.Hold()() var out []catalog.Model shown, _ := provider.CatalogFor(agent) labels := provider.Labels(shown) diff --git a/internal/agent/sync.go b/internal/agent/sync.go index af931fb6..62323a0c 100644 --- a/internal/agent/sync.go +++ b/internal/agent/sync.go @@ -9,6 +9,7 @@ import ( "sync" "github.com/yetone/magpie/internal/edit" + "github.com/yetone/magpie/internal/provider" "gopkg.in/yaml.v3" ) @@ -40,11 +41,15 @@ func SyncCatalog() { syncing.running = true syncing.Unlock() for { + // the catalog built once for every agent's lists, not for each + // look-up of each (thousands at magpie's start, with 30 providers) + release := provider.Hold() for _, a := range All() { if a.Sync != nil { _ = a.Sync() } } + release() syncing.Lock() if !syncing.again { syncing.running = false diff --git a/internal/edit/file.go b/internal/edit/file.go index ce8917ab..08455bf1 100644 --- a/internal/edit/file.go +++ b/internal/edit/file.go @@ -13,16 +13,23 @@ import ( "strings" "time" + "github.com/yetone/magpie/internal/filememo" "github.com/yetone/magpie/internal/steady" ) // Read returns the file contents, or (nil, nil) when the file does not exist. +// A file unchanged since it was last read is not read again (the Agents +// page looks at each agent's files many times over), and what is returned +// is the caller's own to change. func Read(path string) ([]byte, error) { - b, err := os.ReadFile(path) + b, err := filememo.Read("bytes", path, func(b []byte) ([]byte, error) { return b, nil }) if errors.Is(err, fs.ErrNotExist) { return nil, nil } - return b, err + if err != nil { + return nil, err + } + return bytes.Clone(b), nil } // WriteAtomic writes data to path via a temp file + rename so a crash can @@ -32,6 +39,7 @@ func Read(path string) ([]byte, error) { // written in place, see writeInPlace. Once written, temp files earlier // writes of path left behind go, see removeStaleTemps. func WriteAtomic(path string, data []byte) error { + defer filememo.Forget() // read again, where a request holds it path, err := Target(path) if err != nil { return err diff --git a/internal/edit/toml.go b/internal/edit/toml.go index 554b2169..a21d1011 100644 --- a/internal/edit/toml.go +++ b/internal/edit/toml.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "io/fs" + "sort" "strconv" "strings" @@ -413,6 +414,7 @@ type tomlFile struct { func parseTOMLFile(lines []string) (tomlFile, error) { p := unstable.Parser{KeepComments: true} p.Reset([]byte(strings.Join(lines, "\n"))) + shapeOf := tomlShaper(lines) root := tomlTableSpan{to: len(lines)} var tables []tomlTableSpan current := func() *tomlTableSpan { @@ -470,7 +472,7 @@ func parseTOMLFile(lines []string) (tomlFile, error) { keyRange.Length = key.Raw.Offset + key.Raw.Length - keyRange.Offset } } - shape := p.Shape(keyRange) + shape := shapeOf(keyRange) i := shape.Start.Line - 1 if err := finishKey(i); err != nil { return tomlFile{}, err @@ -496,7 +498,7 @@ func parseTOMLFile(lines []string) (tomlFile, error) { value: string(value.Data), scalar: value.Kind != unstable.Array && value.Kind != unstable.InlineTable, } if comment := expr.Next(); comment != nil && comment.Kind == unstable.Comment { - kv.comment = p.Shape(comment.Raw).Start.Column - 1 + kv.comment = shapeOf(comment.Raw).Start.Column - 1 } table.keys = append(table.keys, kv) } @@ -510,6 +512,25 @@ func parseTOMLFile(lines []string) (tomlFile, error) { return tomlFile{root: root, tables: tables}, nil } +// tomlShaper is the parser's Shape for the document of lines, found from +// where each line starts rather than by counting the newlines before each +// range, as Shape does: that is a pass over the file for each key, which +// took a third of a second on a 1.5 MB config.toml of Codex's. +func tomlShaper(lines []string) func(unstable.Range) unstable.Shape { + starts := make([]int, len(lines)) + for i, off := 1, 0; i < len(lines); i++ { + off += len(lines[i-1]) + 1 + starts[i] = off + } + at := func(off int) unstable.Position { + i := sort.Search(len(starts), func(i int) bool { return starts[i] > off }) - 1 + return unstable.Position{Offset: off, Line: i + 1, Column: off - starts[i] + 1} + } + return func(r unstable.Range) unstable.Shape { + return unstable.Shape{Start: at(int(r.Offset)), End: at(int(r.Offset + r.Length))} + } +} + func tomlParseError(p *unstable.Parser, err error) error { pe, ok := err.(*unstable.ParserError) if !ok { diff --git a/internal/edit/toml_shape_test.go b/internal/edit/toml_shape_test.go new file mode 100644 index 00000000..fdd8043f --- /dev/null +++ b/internal/edit/toml_shape_test.go @@ -0,0 +1,71 @@ +package edit + +import ( + "fmt" + "strings" + "testing" + "time" + + "github.com/pelletier/go-toml/v2/unstable" +) + +// tomlShaper finds every key and comment where the parser's own Shape does. +func TestTOMLShaperMatchesParser(t *testing.T) { + doc := "# top\na = 1 # one\n\n[t]\n b.c = \"x#y\" # two\n\"q\" = [\n 1,\n]\n[[arr]]\nz = { k = 1 }\n" + lines := splitLines(doc) + shapeOf := tomlShaper(lines) + p := unstable.Parser{KeepComments: true} + p.Reset([]byte(strings.Join(lines, "\n"))) + n := 0 + check := func(it *unstable.Node) { + if it.Raw.Length == 0 { + return + } + if got, want := shapeOf(it.Raw), p.Shape(it.Raw); got != want { + t.Errorf("%v %q: %+v, the parser says %+v", it.Kind, p.Raw(it.Raw), got, want) + } + n++ + } + for p.NextExpression() { + e := p.Expression() + check(e) + if e.Kind != unstable.Comment { + for k := e.Key(); k.Next(); { + check(k.Node()) + } + } + if c := e.Next(); c != nil { + check(c) + } + } + if err := p.Error(); err != nil || n < 8 { + t.Fatalf("parsed %d nodes: %v", n, err) + } +} + +// A big config.toml is parsed in a time that grows with its size, not its +// square: Codex's own reached 1.5 MB, and each key's line was counted from +// the top of the file, a third of a second at every magpie start. +func TestTOMLBigFileParsesInLinearTime(t *testing.T) { + var b strings.Builder + for i := 0; b.Len() < 3<<20; i++ { + fmt.Fprintf(&b, "[projects.\"/Users/me/p%d\"]\ntrust_level = \"trusted\" # note\n\n", i) + } + b.WriteString("model_provider = \"magpie\"\n") + lines := splitLines("model_provider = \"x\"\n" + b.String()) + start := time.Now() + f, err := parseTOMLFile(lines) + if err != nil { + t.Fatal(err) + } + if d := time.Since(start); d > time.Second { + t.Errorf("a 3 MB file took %v to parse", d) + } + if len(f.tables) < 30000 || f.root.keys[0].name != "model_provider" { + t.Fatalf("parsed %d tables, root %+v", len(f.tables), f.root.keys) + } + last := f.tables[len(f.tables)-1] + if k := last.keys[len(last.keys)-1]; k.name != "model_provider" || k.from != len(lines)-2 && k.from != len(lines)-1 { + t.Errorf("last key %+v of %d lines", k, len(lines)) + } +} diff --git a/internal/filememo/filememo.go b/internal/filememo/filememo.go index 0e92e45f..fe4c4551 100644 --- a/internal/filememo/filememo.go +++ b/internal/filememo/filememo.go @@ -20,6 +20,10 @@ type entry struct { // milliseconds) coarse, so two writes of one size inside a tick (two // accounts swapped) leave the same time and size raw []byte + // when the file was last found unchanged, while a request held them + // (Hold), and in which of Forget's generations + checked time.Time + gen uint64 } // settled is how old a file's time must be for any later write to stamp @@ -29,23 +33,66 @@ const settled = 2 * time.Second var ( mu sync.Mutex seen = map[string]entry{} + // holds is how many requests hold what was read (Hold); gen is moved + // on by every write magpie makes (Forget) + holds int + gen uint64 ) +// heldFor is how long a file found unchanged is taken as unchanged, while +// a request holds what was read, before it is looked at again. +const heldFor = time.Second + +// Hold has a file found unchanged not looked at again, for a request that +// only reads, until release is called or a second has gone by: the GUI's +// state looked at each agent's files hundreds of times over, a few +// thousand looks at the disk that a slow one (a Mac's antivirus looking +// at each) made seconds. A write meanwhile (Forget) has them looked at. +func Hold() (release func()) { + mu.Lock() + holds++ + mu.Unlock() + return sync.OnceFunc(func() { + mu.Lock() + holds-- + mu.Unlock() + }) +} + +// Forget has every file looked at again on its next read: magpie wrote +// one. +func Forget() { + mu.Lock() + gen++ + mu.Unlock() +} + // Read is parse of the file at path, from what it gave last time when the // file has the same size and time as then (and, written just now, the same // bytes). kind tells apart two parses of // one file. What it returns is shared: the caller must not change it. func Read[T any](kind, path string, parse func([]byte) (T, error)) (T, error) { var zero T + key := kind + "\x00" + path + mu.Lock() + e, ok := seen[key] + if ok && holds > 0 && e.raw == nil && e.gen == gen && time.Since(e.checked) < heldFor { + mu.Unlock() + return e.v.(T), nil + } + g := gen + mu.Unlock() fi, err := os.Stat(path) if err != nil { return zero, err } - key := kind + "\x00" + path - mu.Lock() - e, ok := seen[key] - mu.Unlock() if ok && e.mod.Equal(fi.ModTime()) && e.size == fi.Size() && e.raw == nil { + mu.Lock() + if holds > 0 && g == gen { + e.checked, e.gen = time.Now(), g + seen[key] = e + } + mu.Unlock() return e.v.(T), nil } b, err := steady.ReadFile(path) @@ -69,6 +116,8 @@ func Read[T any](kind, path string, parse func([]byte) (T, error)) (T, error) { e = entry{mod: fi.ModTime(), size: fi.Size(), v: v} if time.Since(e.mod) <= settled { e.raw = b + } else if holds > 0 && g == gen { + e.checked, e.gen = time.Now(), g } seen[key] = e mu.Unlock() diff --git a/internal/filememo/filememo_test.go b/internal/filememo/filememo_test.go index 314313c5..aee28c79 100644 --- a/internal/filememo/filememo_test.go +++ b/internal/filememo/filememo_test.go @@ -50,3 +50,49 @@ func TestReadSameSizeSameTick(t *testing.T) { } } } + +// While a request holds what was read, a file found unchanged is not looked +// at again (the GUI's state looked a few thousand times, seconds on a slow +// disk); a write magpie makes (Forget), a second gone by or the release has +// it looked at, and a file just written is always read again. +func TestHold(t *testing.T) { + p := filepath.Join(t.TempDir(), "f") + os.WriteFile(p, []byte("a"), 0o600) + old := time.Now().Add(-time.Minute) + os.Chtimes(p, old, old) + parse := func(b []byte) (string, error) { return string(b), nil } + write := func(s string) { + os.WriteFile(p, []byte(s), 0o600) + at := old.Add(time.Duration(len(s)) * time.Second) + os.Chtimes(p, at, at) + } + release := Hold() + if v, _ := Read("hold", p, parse); v != "a" { + t.Fatal(v) + } + write("bb") + if v, _ := Read("hold", p, parse); v != "a" { + t.Fatalf("looked at again while held: %q", v) + } + Forget() + if v, _ := Read("hold", p, parse); v != "bb" { + t.Fatalf("after a write magpie made: %q", v) + } + write("ccc") + release() + release() + if v, _ := Read("hold", p, parse); v != "ccc" { + t.Fatalf("released: %q", v) + } + if holds != 0 { + t.Fatalf("holds left: %d", holds) + } + // a file written just now is read each time, held or not + defer Hold()() + os.WriteFile(p, []byte("d"), 0o600) + Read("hold", p, parse) + os.WriteFile(p, []byte("e"), 0o600) + if v, _ := Read("hold", p, parse); v != "e" { + t.Fatalf("a file written just now: %q", v) + } +} diff --git a/internal/gateway/vision.go b/internal/gateway/vision.go index 7d56f672..bfdd36d6 100644 --- a/internal/gateway/vision.go +++ b/internal/gateway/vision.go @@ -15,7 +15,6 @@ import ( "github.com/yetone/magpie/internal/catalog" "github.com/yetone/magpie/internal/provider" - "github.com/yetone/magpie/internal/settings" ) // A model that can't see images is given what a model that can says of @@ -96,7 +95,7 @@ func blindTo(pid, model string, in *bool) bool { // seer is the model that describes images: the Settings' Vision while it // resolves, else AutoVision's. None when Vision is off or no model sees. func seer() (string, bool) { - switch v := settings.Load().Vision; v { + switch v := provider.HeldSettings().Vision; v { case "off": return "", false case "": diff --git a/internal/gui/api.go b/internal/gui/api.go index 0e335347..dc7e0ee8 100644 --- a/internal/gui/api.go +++ b/internal/gui/api.go @@ -29,6 +29,7 @@ import ( "github.com/yetone/magpie/internal/agent" "github.com/yetone/magpie/internal/autostart" "github.com/yetone/magpie/internal/catalog" + "github.com/yetone/magpie/internal/filememo" "github.com/yetone/magpie/internal/fx" "github.com/yetone/magpie/internal/gateway" "github.com/yetone/magpie/internal/library" @@ -1274,6 +1275,8 @@ func held(h http.Handler) http.Handler { return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { if r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/api/") { defer provider.Hold()() + // and the files read, not looked at again for each look-up + defer filememo.Hold()() } else { defer provider.Changed() } diff --git a/internal/provider/group.go b/internal/provider/group.go index 9544f095..d19b9d79 100644 --- a/internal/provider/group.go +++ b/internal/provider/group.go @@ -225,7 +225,7 @@ func Groups() []Group { } func groupsIn(entries []Entry) []Group { - f := load() + f := heldOf("file", load) var out []Group hidden := map[string]bool{} for _, g := range f.Groups { @@ -238,7 +238,7 @@ func groupsIn(entries []Entry) []Group { if f.NoAutoGroups { return orderedGroups(out, f.GroupOrder) } - for _, g := range autoGroups(entries, settings.Load().ModelSameAs) { + for _, g := range autoGroups(entries, heldSettings().ModelSameAs) { if slices.ContainsFunc(out, func(o Group) bool { return o.ID == g.ID }) { continue // the user changed it: theirs now } diff --git a/internal/provider/hold.go b/internal/provider/hold.go index a5c7f8d9..b1fbf2f5 100644 --- a/internal/provider/hold.go +++ b/internal/provider/hold.go @@ -6,6 +6,7 @@ import ( "time" "github.com/yetone/magpie/internal/catalog" + "github.com/yetone/magpie/internal/filememo" "github.com/yetone/magpie/internal/plugin" "github.com/yetone/magpie/internal/settings" ) @@ -56,6 +57,7 @@ func Hold() (release func()) { // Changed drops what is held: a provider, an account, a plugin or a // setting was written. func Changed() { + filememo.Forget() held.Lock() held.gen++ held.built = nil @@ -102,3 +104,7 @@ func heldPlugins() []plugin.Provider { // catalog: a look at the agents asks them for every model of every agent. // Only for reading — what it gives is shared by the request's look-ups. func heldSettings() settings.Settings { return heldOf("settings", settings.Load) } + +// HeldSettings is the settings as heldSettings reads them, for a look +// outside the package made many times in one request. +func HeldSettings() settings.Settings { return heldSettings() } diff --git a/internal/provider/hold_test.go b/internal/provider/hold_test.go index 7afc1e4b..f9a0586d 100644 --- a/internal/provider/hold_test.go +++ b/internal/provider/hold_test.go @@ -172,3 +172,25 @@ func TestCursorKeychainReadOnce(t *testing.T) { t.Fatalf("after a fresh read: %q, %d runs", tok, runs()) } } + +// migrations.json is read once while a request holds the catalog (the +// GUI's state looked at it thousands of times, one per provider's API), +// and a move written meanwhile, even one read from the file as it was +// before, is seen at once. +func TestHoldReadsMigrationsOnce(t *testing.T) { + defer Hold()() + if Moved("held-sub") { + t.Fatal("moved before any move") + } + os.MkdirAll(filepath.Dir(migrationsPath()), 0o755) + os.WriteFile(migrationsPath(), []byte(`{"other-sub":{"state":"plugin"}}`), 0o600) + if Moved("other-sub") { + t.Fatal("migrations.json read again while held") + } + if err := setMigration("held-sub", func(m *Migration) { m.State = MovePlugin }); err != nil { + t.Fatal(err) + } + if !Moved("held-sub") || !Moved("other-sub") { + t.Fatalf("a move written while held: %v %v", Moved("held-sub"), Moved("other-sub")) + } +} diff --git a/internal/provider/logins.go b/internal/provider/logins.go index 3254fce5..7d2ca3af 100644 --- a/internal/provider/logins.go +++ b/internal/provider/logins.go @@ -156,6 +156,7 @@ func writeLogins(ls []savedLogin) error { // writePrivate replaces a file readable by the user alone, atomically, so // an agent reading it at that moment sees either version, never half. func writePrivate(path string, b []byte) error { + defer filememo.Forget() // read again, where a request holds it path, err := edit.Target(path) // a symlink stays, its target written if err != nil { return err diff --git a/internal/provider/migrate.go b/internal/provider/migrate.go index 28d2a090..4b993cbf 100644 --- a/internal/provider/migrate.go +++ b/internal/provider/migrate.go @@ -291,7 +291,11 @@ func migrationsPath() string { return filepath.Join(filepath.Dir(Path()), "migra var migrationsMu sync.Mutex -func readMigrations() map[string]Migration { +// readMigrations is held while a request reads: the GUI's state asks each +// provider's move several times over, thousands of looks at the file. +func readMigrations() map[string]Migration { return heldOf("migrations", readMigrationsFile) } + +func readMigrationsFile() map[string]Migration { m, _ := filememo.Read("migrations", migrationsPath(), func(b []byte) (map[string]Migration, error) { var m map[string]Migration _ = json.Unmarshal(b, &m) @@ -344,7 +348,7 @@ func setMigration(id string, f func(m *Migration)) error { migrationsMu.Lock() defer migrationsMu.Unlock() all := map[string]Migration{} - for k, v := range readMigrations() { + for k, v := range readMigrationsFile() { all[k] = v } m := all[id] diff --git a/internal/provider/models.go b/internal/provider/models.go index ec83afe9..abd9f7e9 100644 --- a/internal/provider/models.go +++ b/internal/provider/models.go @@ -132,7 +132,13 @@ func (p Provider) live() ([]catalog.Model, time.Time, bool) { if p.IsPlugin() { return nil, time.Time{}, false } - return catalog.Live(p.ID) + type live struct { + ms []catalog.Model + at time.Time + ok bool + } + l := heldOf("fetched:"+p.ID, func() live { ms, at, ok := catalog.Live(p.ID); return live{ms, at, ok} }) + return l.ms, l.at, l.ok } // Fetch asks the vendor which models it serves and remembers the answer. diff --git a/internal/settings/settings.go b/internal/settings/settings.go index 942ca4a5..cebbc49b 100644 --- a/internal/settings/settings.go +++ b/internal/settings/settings.go @@ -631,6 +631,7 @@ func CheckProxy(p string) error { func Save(s Settings) error { fileMu.Lock() defer fileMu.Unlock() + defer filememo.Forget() // read again, where a request holds it s = s.normal() if !slices.Contains(Themes, s.Theme) { return fmt.Errorf("theme must be one of %v, not %q", Themes, s.Theme) diff --git a/main.go b/main.go index 772e579e..a455264b 100644 --- a/main.go +++ b/main.go @@ -192,10 +192,14 @@ func run(args []string) error { } makeDirs() settings.Migrate() + // the providers and settings read once for every agent's fields, which + // the moves below look at (a write among them reads them again) + release := provider.Hold() agent.RenameLegacy() agent.MoveCursorEfforts() agent.MoveAntigravityEfforts() agent.MoveOffAccountIDs() + release() // a provider added, edited or removed, or a list fetched anew, reaches // the model lists agents keep in files of their own catalog.Changed = agent.SyncCatalog