Files
magpie/internal/gui/update_wait_test.go
yetone 52e7e4fc8d update: a restart to update waits until the gateway is idle instead of cutting off what the agents have in flight (#577: 正式版更新时保持网关请求与工具链不中断 — a Codex reply streaming through magpie, or a tool chain under way, was cut off by Restart to update) — the app serves the gateway in its own process and a restart to update is install, quit, relaunch, so whatever was in flight ended with it: a stream mid-reply, and a Claude Code turn of the subscription bridge waiting on its caller's tool results, whose Claude Code lives only in this process. The gateway now says what a restart would cut short (Server.Busy): its requests in flight (all but GETs and the bridge's own MCP callbacks), the bridge's runs parked on tool results, and when the last request ended. Restart to update (Settings → Version, the header's Update pill, the tray's item) restarts at once only when nothing is in flight, no turn waits on tools and nothing ended in the last 10 s (a tool chain's gap, the agent running a tool between two requests); otherwise it waits, saying what for: Settings shows "restarts once the gateway is idle · 2 in flight · 1 awaiting tool results" (网关空闲后自动重启 · 2 个请求进行中 · 1 个工具调用待返回) with Restart now and Cancel, the pill reads "Waiting to update" (等待更新) and its next click (not a double click's second half) restarts now, the tray item becomes "Restart Now to Update (3 in flight)" (立即重启以更新(3 个进行中)). The wait asks the feed again once idle, so versions out meanwhile collapse into the newest, downloaded before the restart; it gives up after an hour, saying so, the update then going in at the next restart or quit. A gateway served by another magpie (magpie serve, an older one) is not this one's to wait for. Handing the gateway over to the new version instead (the dev handover, e0c6892) is not used for releases: it rests on SO_REUSEPORT, which Windows lacks, and the new app would have to start beside the old one (single-instance, the Mac's bundle swapped under it). TestBusy (a stream counts until it ends, a GET never, a parked bridge run until it is answered or ends), TestRestartToUpdateWaitsForTheGateway (fails without the change: the click restarted with 2 requests in flight), TestRestartWhenIdle (not while streaming, parked or in a tool chain's gap; then once; an hour's wait gives up), TestRestartWhenIdleIntoTheNewest (0.1.11 staged, 0.1.12 and 0.1.13 out while waiting: restarted into 0.1.13) and TestTrayRestartNow are new; update-wait.test.cjs (WebKit, en and zh: what is in flight, waiting with Restart now and Cancel, counts following the gateway, the hour's give-up, the pill's wait and restart now, an idle click restarting at once, no click scrolling, no left border) fails without the change and passes with it. update-hide, update-check, sync-update, update-auto and click-scroll pass in WebKit. go vet ./internal/..., go build (darwin GUI), go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui -count=1 ./... under a temp HOME pass (TestFXInState's known TempDir cleanup flake aside; passes on rerun).
2026-10-02 23:36:43 +08:00

241 lines
7.2 KiB
Go

package gui
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/yetone/magpie/internal/gateway"
"github.com/yetone/magpie/internal/update"
)
// fakeBusy stands in for the gateway's Busy in these tests.
type fakeBusy struct {
mu sync.Mutex
b gateway.Busy
}
func (f *fakeBusy) set(b gateway.Busy) {
f.mu.Lock()
f.b = b
f.mu.Unlock()
}
func (f *fakeBusy) get() gateway.Busy {
f.mu.Lock()
defer f.mu.Unlock()
return f.b
}
// quick has u's waits run in milliseconds, against busy, with no feed asked.
func quick(t *testing.T, busy *fakeBusy, u *updater) {
t.Setenv("MAGPIE_UPDATE_FEED", "http://127.0.0.1:1/") // never the real one
oldBusy, oldSettle, oldMost, oldTick, oldRecheck := gatewayBusy, idleSettle, waitMost, waitTick, waitRecheck
gatewayBusy, idleSettle, waitMost, waitTick = busy.get, 80*time.Millisecond, time.Minute, 5*time.Millisecond
waitRecheck = func(*updater) {}
t.Cleanup(func() {
u.cancelWait()
u.loops.Wait()
gatewayBusy, idleSettle, waitMost, waitTick, waitRecheck = oldBusy, oldSettle, oldMost, oldTick, oldRecheck
})
}
func eventually(t *testing.T, what string, ok func() bool) {
t.Helper()
for deadline := time.Now().Add(3 * time.Second); time.Now().Before(deadline); time.Sleep(5 * time.Millisecond) {
if ok() {
return
}
}
t.Fatal(what)
}
type quitCount struct {
Windows
quits atomic.Int32
}
func (q *quitCount) Quit() { q.quits.Add(1) }
func (q *quitCount) MainShown() bool { return false }
// #577: Restart to update with the gateway streaming a reply, or a Claude
// Code turn waiting on its tool results, doesn't restart: it says what is in
// flight and waits; Cancel calls the wait off.
func TestRestartToUpdateWaitsForTheGateway(t *testing.T) {
busy := &fakeBusy{}
busy.set(gateway.Busy{Requests: 2, Tools: 1, Last: time.Now()})
old := updates
// nothing staged: were it to restart, there would be nothing to put in
updates = &updater{state: "ready", latest: &update.Release{Version: "0.1.999"}}
t.Cleanup(func() { updates = old })
quick(t, busy, updates)
w := &quitCount{}
mux := http.NewServeMux()
updateRoutes(mux, w)
post := func(body string) updateJSON {
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, httptest.NewRequest("POST", "/api/update/install", strings.NewReader(body)))
var j updateJSON
if rec.Code != 200 || json.Unmarshal(rec.Body.Bytes(), &j) != nil {
t.Fatalf("%d %s", rec.Code, rec.Body)
}
return j
}
j := post(`{}`)
if !j.Waiting || j.Busy == nil || j.Busy.Requests != 2 || j.Busy.Tools != 1 || j.State != "ready" {
t.Fatalf("restart with the gateway busy: %+v", j)
}
time.Sleep(50 * time.Millisecond)
if w.quits.Load() != 0 {
t.Fatal("quit with requests in flight")
}
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, httptest.NewRequest("GET", "/api/update", nil))
if !strings.Contains(rec.Body.String(), `"waiting":true`) || !strings.Contains(rec.Body.String(), `"busy":{"requests":2,"tools":1}`) {
t.Fatalf("the state: %s", rec.Body)
}
if j := post(`{"when":"cancel"}`); j.Waiting {
t.Fatalf("cancelled: %+v", j)
}
if w.quits.Load() != 0 {
t.Fatal("quit")
}
}
// The wait ends in the restart once nothing is in flight, no turn waits on
// tools and the last request ended idleSettle ago — not in the gap of a
// tool chain, between one request and the next.
func TestRestartWhenIdle(t *testing.T) {
busy := &fakeBusy{}
busy.set(gateway.Busy{Requests: 1})
u := &updater{state: "ready", latest: &update.Release{Version: "0.1.999"}}
quick(t, busy, u)
var told atomic.Int32
u.onWait = func() { told.Add(1) }
var restarts atomic.Int32
u.waitIdle(func() bool { restarts.Add(1); return true })
time.Sleep(40 * time.Millisecond)
if restarts.Load() != 0 {
t.Fatal("restarted while streaming")
}
// the reply ended asking for a tool, whose result Claude Code waits on
busy.set(gateway.Busy{Tools: 1, Last: time.Now()})
time.Sleep(40 * time.Millisecond)
if restarts.Load() != 0 {
t.Fatal("restarted with a turn waiting on its tool results")
}
// the result came, the turn's last reply ended just now
busy.set(gateway.Busy{Last: time.Now()})
time.Sleep(30 * time.Millisecond)
if restarts.Load() != 0 {
t.Fatal("restarted in a tool chain's gap")
}
eventually(t, "never restarted once idle", func() bool { return restarts.Load() == 1 })
if waiting, _ := u.waitingFor(); waiting {
t.Fatal("still waiting")
}
eventually(t, "the tray was not told", func() bool { return told.Load() >= 2 })
time.Sleep(30 * time.Millisecond)
if restarts.Load() != 1 {
t.Fatalf("%d restarts", restarts.Load())
}
// a wait that runs out leaves the update for a later restart or quit
u.loops.Wait()
waitMost = 40 * time.Millisecond
busy.set(gateway.Busy{Requests: 1})
u.waitIdle(func() bool { restarts.Add(1); return true })
eventually(t, "never gave up", func() bool { return u.json().GaveUp })
if j := u.json(); j.Waiting || restarts.Load() != 1 || j.Busy == nil {
t.Fatalf("gave up: %+v, %d restarts", j, restarts.Load())
}
}
// Versions out while a restart waits collapse into the newest: the restart
// is into the last one, downloaded once the gateway is idle, not into each.
func TestRestartWhenIdleIntoTheNewest(t *testing.T) {
busy := &fakeBusy{}
u := &updater{}
quick(t, busy, u)
waitRecheck = (*updater).recheck
body := []byte("new magpie")
sum := sha256.Sum256(body)
var latest atomic.Value
latest.Store("0.1.11")
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/asset" {
w.Write(body)
return
}
json.NewEncoder(w).Encode(update.Release{Version: latest.Load().(string), Assets: map[string]update.Asset{
update.BinaryAsset(): {URL: "http://" + r.Host + "/asset", SHA256: hex.EncodeToString(sum[:])},
}})
}))
defer srv.Close()
t.Setenv("MAGPIE_UPDATE_FEED", srv.URL)
old := Version
Version = "0.1.10"
defer func() { Version = old }()
exe, err := update.Executable()
if err != nil {
t.Fatal(err)
}
u.exe = exe
u.self, _ = os.Stat(exe)
defer os.Remove(exe + ".new")
u.check()
if j := u.json(); j.State != "ready" || j.Latest != "0.1.11" {
t.Fatalf("%+v", j)
}
busy.set(gateway.Busy{Requests: 1})
into := make(chan string, 4)
u.waitIdle(func() bool {
u.mu.Lock()
into <- u.latest.Version
u.mu.Unlock()
return true
})
latest.Store("0.1.12")
time.Sleep(30 * time.Millisecond)
latest.Store("0.1.13")
busy.set(gateway.Busy{})
select {
case v := <-into:
if v != "0.1.13" {
t.Fatalf("restarted into %s", v)
}
case <-time.After(5 * time.Second):
t.Fatalf("never restarted: %+v", u.json())
}
if j := u.json(); j.State != "ready" || j.Latest != "0.1.13" {
t.Fatalf("%+v", j)
}
}
// The tray's item, while a restart waits, restarts at once and says what
// that cuts short.
func TestTrayRestartNow(t *testing.T) {
for _, c := range []struct {
lang string
b gateway.Busy
want string
}{
{"en", gateway.Busy{Requests: 2, Tools: 1}, "Restart Now to Update (3 in flight)"},
{"zh", gateway.Busy{Requests: 1}, "立即重启以更新(1 个进行中)"},
{"en", gateway.Busy{}, "Restart Now to Update"},
{"zh", gateway.Busy{}, "立即重启以更新"},
} {
if got := trayRestartNow(c.lang, c.b); got != c.want {
t.Errorf("%s %+v: %q, want %q", c.lang, c.b, got, c.want)
}
}
}