Files
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

153 lines
3.7 KiB
Go

package gui
import (
"time"
"github.com/yetone/magpie/internal/gateway"
)
// A restart to update ends the gateway this process serves, and with it
// whatever the agents have in flight through it (#577: an agent's reply
// streaming, a Claude Code turn waiting on its caller's tool results). So a
// restart asked for while the gateway is busy waits until it is idle:
// nothing in flight, no turn waiting on tools, and nothing ended in the
// last idleSettle — the gap of a tool chain, an agent running a tool
// between one request and the next. It waits waitMost at most; then the
// update stays downloaded, put in on quitting or at the next restart, and
// a newer version out meanwhile is the one restarted into. "Restart now"
// restarts at once, whatever is in flight.
var (
idleSettle = 10 * time.Second
waitMost = time.Hour
waitTick = time.Second
)
// gatewayBusy is what the gateway this process serves has in flight;
// nothing when another magpie serves it, which a restart here leaves be.
var gatewayBusy = func() gateway.Busy {
if gw := served.Load(); gw != nil {
return gw.Busy()
}
return gateway.Busy{}
}
// waitRecheck asks the feed again before the restart (updater.recheck).
var waitRecheck = (*updater).recheck
// idleAt is whether b leaves nothing for a restart at now to cut short.
func idleAt(b gateway.Busy, now time.Time) bool {
return !b.Any() && now.Sub(b.Last) >= idleSettle
}
// idle is whether the gateway is idle now.
func (u *updater) idle() bool { return idleAt(gatewayBusy(), time.Now()) }
// waitIdle has restart run once the gateway is idle; a wait under way
// takes the newer restart (the window as it is now).
func (u *updater) waitIdle(restart func() bool) {
u.mu.Lock()
u.whenIdle, u.gaveUp = restart, false
if u.waiting {
u.mu.Unlock()
return
}
u.waiting, u.waitFrom = true, time.Now()
u.waitGen++
gen := u.waitGen
u.mu.Unlock()
u.told()
u.loops.Add(1)
go func() {
defer u.loops.Done()
u.waitFor(gen)
}()
}
// cancelWait stops a restart waiting.
func (u *updater) cancelWait() {
u.mu.Lock()
was := u.waiting
u.waiting, u.whenIdle = false, nil
u.waitGen++
u.mu.Unlock()
if was {
u.told()
}
}
// waitingFor is whether a restart waits, and on what.
func (u *updater) waitingFor() (bool, gateway.Busy) {
u.mu.Lock()
w := u.waiting
u.mu.Unlock()
if !w {
return false, gateway.Busy{}
}
return true, gatewayBusy()
}
func (u *updater) told() {
u.mu.Lock()
f := u.onWait
u.mu.Unlock()
if f != nil {
go f() // off the caller's thread: the tray's relabel runs on the main one
}
}
// waitFor restarts once the gateway is idle and the version downloaded is
// the latest, unless the wait is called off (gen no longer its) or runs out.
func (u *updater) waitFor(gen int) {
var last gateway.Busy
for {
time.Sleep(waitTick)
u.mu.Lock()
if !u.waiting || u.waitGen != gen {
u.mu.Unlock()
return
}
if time.Since(u.waitFrom) >= waitMost {
u.waiting, u.gaveUp, u.whenIdle = false, true, nil
u.mu.Unlock()
u.told()
return
}
u.mu.Unlock()
b := gatewayBusy()
if b.Requests != last.Requests || b.Tools != last.Tools {
last = b
u.told()
}
if !idleAt(b, time.Now()) {
continue
}
switch u.json().State {
case "ready":
// a newer version out since is downloaded first, and waited for
waitRecheck(u)
if u.json().State != "ready" {
continue
}
case "checking", "downloading":
continue
default:
// nothing left to restart into (a newer one failed to download)
u.cancelWait()
return
}
u.mu.Lock()
if !u.waiting || u.waitGen != gen {
u.mu.Unlock()
return
}
restart := u.whenIdle
u.waiting, u.whenIdle = false, nil
u.mu.Unlock()
u.told()
if restart != nil {
restart()
}
return
}
}