From 58aca0eeb88d204ac731b738bbff718ca82ffe0e Mon Sep 17 00:00:00 2001 From: yetone Date: Thu, 1 Oct 2026 21:09:41 +0800 Subject: [PATCH] =?UTF-8?q?providers:=20an=20account=20whose=20list=20coul?= =?UTF-8?q?dn't=20be=20had=20is=20asked=20again=20a=20minute=20on,=20and?= =?UTF-8?q?=20opening=20the=20panel=20asks=20(#422,=20Q1CHENL:=20after=20t?= =?UTF-8?q?he=20in-app=20update=20Kiro=20still=20offered=20Auto=20alone=20?= =?UTF-8?q?until=20magpie=20was=20restarted=20by=20hand)=20=E2=80=94=20the?= =?UTF-8?q?=20update=20restarts=20magpie=20as=20a=20new=20process,=20whose?= =?UTF-8?q?=20one=20try=20for=20an=20account=20still=20without=20its=20ven?= =?UTF-8?q?dor's=20list=20was=20made=20at=20start-up,=20or=20by=20the=20Pr?= =?UTF-8?q?oviders=20page=20with=208=20seconds=20for=20it;=20one=20that=20?= =?UTF-8?q?failed=20left=20the=20account=20on=20its=20fallback=20(Kiro's?= =?UTF-8?q?=20Auto)=20for=20ten=20minutes,=20and=20only=20the=20Providers?= =?UTF-8?q?=20page=20asked=20again,=20never=20the=20panel=20and=20its=20mo?= =?UTF-8?q?del=20picker.=20The=20retry=20is=20now=20a=20minute,=20and=20GE?= =?UTF-8?q?T=20/api/state=20(the=20panel,=20the=20window's=20Agents=20page?= =?UTF-8?q?)=20starts=20FetchNew=20in=20the=20background,=20no=20more=20th?= =?UTF-8?q?an=20once=20in=2015s=20and=20never=20two=20at=20once,=20so=20th?= =?UTF-8?q?e=20page=20doesn't=20wait=20on=20vendors.=20TestFetchNewRetried?= =?UTF-8?q?Soon=20fails=20without=20the=20change.=20go=20vet,=20go=20build?= =?UTF-8?q?=20-tags=20nogui=20(darwin,=20GOOS=3Dlinux),=20GOOS=3Dwindows?= =?UTF-8?q?=20go=20build,=20go=20test=20-tags=20nogui=20./...=20pass.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/gui/api.go | 4 + internal/provider/fetch_new_soon_test.go | 123 +++++++++++++++++++++++ internal/provider/models.go | 36 ++++++- 3 files changed, 161 insertions(+), 2 deletions(-) create mode 100644 internal/provider/fetch_new_soon_test.go diff --git a/internal/gui/api.go b/internal/gui/api.go index 2b831146..b4015fc2 100644 --- a/internal/gui/api.go +++ b/internal/gui/api.go @@ -401,6 +401,10 @@ func Handler(w Windows, gw *gateway.Server) http.Handler { }) omarchyRoutes(mux, w) mux.HandleFunc("GET /api/state", func(rw http.ResponseWriter, r *http.Request) { + // the panel and its model picker load from here: an account still + // without its vendor's list (one whose try at start-up failed) is + // asked again in the background, not only from the Providers page + provider.FetchNewSoon(8 * time.Second) writeJSON(rw, state()) }) mux.HandleFunc("POST /api/set", func(rw http.ResponseWriter, r *http.Request) { diff --git a/internal/provider/fetch_new_soon_test.go b/internal/provider/fetch_new_soon_test.go new file mode 100644 index 00000000..1ccdd6e3 --- /dev/null +++ b/internal/provider/fetch_new_soon_test.go @@ -0,0 +1,123 @@ +package provider + +import ( + "net/http" + "net/http/httptest" + "path/filepath" + "sync/atomic" + "testing" + "time" + + "github.com/yetone/magpie/internal/catalog" +) + +// An account whose list couldn't be had is asked again a minute on, and +// opening the panel asks without the page waiting (#422: after an update +// Kiro offered Auto alone until magpie was restarted by hand — its one try +// had failed, and it wasn't asked again for ten minutes, and then only by +// the Providers page). +func TestFetchNewRetriedSoon(t *testing.T) { + isolate(t) + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config")) + t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache")) + wbTokens.Lock() + wbTokens.m = map[string]wbCreds{} + wbTokens.Unlock() + newFetches.Lock() + newFetches.m = map[string]time.Time{} + newFetches.Unlock() + newSoonAt.Store(0) + + var asked atomic.Int32 + var down atomic.Bool + down.Store(true) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v3/config" { + w.WriteHeader(404) + return + } + asked.Add(1) + if down.Load() { + w.WriteHeader(502) + return + } + w.Write([]byte(`{"code":0,"data":{"agents":[{"name":"cli","models":["hy4-preview","glm-5.3"]}], + "models":[{"id":"hy4-preview","name":"Hy4 preview"},{"id":"glm-5.3","name":"GLM-5.3"}]}}`)) + })) + defer srv.Close() + old := wbEndpoint + wbEndpoint = srv.URL + defer func() { wbEndpoint = old }() + future := time.Now().Add(24 * time.Hour).UnixMilli() + writeFile(t, wbAuthFile(home), map[string]any{ + "account": map[string]any{"uid": "u1", "nickname": "旅行者"}, + "auth": map[string]any{"accessToken": "a", "refreshToken": "r", + "expiresAt": future, "refreshExpiresAt": future, "domain": "www.codebuddy.cn"}, + }) + forgetAccountCaches() + listed := func() bool { + p, err := Find("workbuddy") + if err != nil { + t.Fatal(err) + } + for _, m := range p.Available() { + if m.ID == "hy4-preview" { + return true + } + } + return false + } + + // the try at start-up fails + FetchNew(5 * time.Second) + if n := asked.Load(); n != 1 { + t.Fatalf("asked %d times at start-up", n) + } + down.Store(false) + FetchNew(5 * time.Second) + if n := asked.Load(); n != 1 { + t.Fatalf("a failed list asked again at once: %d", n) + } + + // opened again straight away: nothing more is started + FetchNewSoon(5 * time.Second) + newSoonAt.Store(0) + waitNewSoon() + if n := asked.Load(); n != 1 { + t.Fatalf("a failed list asked again at once by the panel: %d", n) + } + + // a minute and a bit later the panel is opened: asked again, without + // the page waiting for it + newFetches.Lock() + newFetches.m["workbuddy"] = time.Now().Add(-61 * time.Second) + newFetches.Unlock() + FetchNewSoon(5 * time.Second) + waitNewSoon() + if !listed() || asked.Load() != 2 { + t.Fatalf("not asked again a minute on: asked %d times, listed %v", asked.Load(), listed()) + } + + // and the panel opened again within newSoonEvery starts nothing + if err := catalog.SaveLive("workbuddy", "", nil); err != nil { + t.Fatal(err) + } + newFetches.Lock() + newFetches.m = map[string]time.Time{} + newFetches.Unlock() + FetchNewSoon(5 * time.Second) + waitNewSoon() + if n := asked.Load(); n != 2 { + t.Fatalf("the panel asked again within %v: %d", newSoonEvery, n) + } +} + +// waitNewSoon waits for a FetchNewSoon under way to end. +func waitNewSoon() { + for deadline := time.Now().Add(5 * time.Second); fetchingNew.Load() && time.Now().Before(deadline); { + time.Sleep(10 * time.Millisecond) + } +} diff --git a/internal/provider/models.go b/internal/provider/models.go index a0a0d2df..2a3fb921 100644 --- a/internal/provider/models.go +++ b/internal/provider/models.go @@ -9,6 +9,7 @@ import ( "sort" "strings" "sync" + "sync/atomic" "time" "github.com/yetone/magpie/internal/catalog" @@ -159,8 +160,39 @@ var newFetches = struct { }{m: map[string]time.Time{}} // newFetchRetry is how long FetchNew leaves an account whose list it -// couldn't get before asking again. -var newFetchRetry = 10 * time.Minute +// couldn't get before asking again. Short: until it has its list the +// account offers magpie's fallback (Kiro's Auto alone), and one try that +// failed — the first after an update, cut short by a page's 8 seconds, or +// made before the network was up — left it so for ten minutes, while only +// the Providers page asked again (#422: Auto alone until magpie was +// restarted by hand). +var newFetchRetry = time.Minute + +// fetchingNew is set while a FetchNewSoon runs; newSoonAt is when the last +// one started. +var ( + fetchingNew atomic.Bool + newSoonAt atomic.Int64 +) + +// newSoonEvery is how often FetchNewSoon starts at most. +var newSoonEvery = 15 * time.Second + +// FetchNewSoon is FetchNew in the background, for a page that shouldn't +// wait on vendors (the panel, whose model picker otherwise kept an +// account's fallback list until the Providers page was opened). It does +// nothing while one runs or within newSoonEvery of the last. +func FetchNewSoon(timeout time.Duration) { + now := time.Now().UnixNano() + if now-newSoonAt.Load() < int64(newSoonEvery) || !fetchingNew.CompareAndSwap(false, true) { + return + } + newSoonAt.Store(now) + go func() { + defer fetchingNew.Store(false) + FetchNew(timeout) + }() +} // FetchNew asks each signed-in account whose vendor list magpie hasn't // fetched yet for it, each for at most timeout. Start-up does this for the