dev: a Go change hands the backend over instead of cutting the agents off (为什么老是会reconnecting) — make dev killed the backend, and the dev gateway with it, on every Go save, so Cindy's Pi on :3426 lost its stream and reconnected; now the new backend listens beside the old one (SO_REUSEPORT, dev only), and once it does the old one is sent USR2, stops taking requests, finishes those in flight — a stream as long as it takes — and exits. A backend that builds but never comes up leaves the old one serving. Tested in a sandbox: a 12s stream through the gateway ran to [DONE] across a handover, new requests went to the new backend, and the old one exited when the stream ended

This commit is contained in:
yetone committed 2026-09-28 18:56:50 +08:00
1 parent 9eccb96a51
commit e0c6892f56
10 files changed
+182 -19

No files matched your search

+37 -4
View File
@@ -7,17 +7,51 @@
# (internal/gui/app.go, dev_on.go) restarts the windows too. Quitting magpie
# from the tray, or Ctrl-C, ends it all.
#
# A restart is a handover, so an agent streaming through the dev gateway
# isn't cut off: the new backend listens on the same ports beside the old
# one, which is then told (USR2) to stop taking requests, finish those it
# has and exit. A backend that builds but never comes up leaves the old one
# serving.
#
# make passes MAGPIE_ADDR, MAGPIE_DEV_UI, MAGPIE_DEV_BACKEND and
# MAGPIE_DEV_CONTROL.
set -u
cd "$(dirname "$0")/.."
shell='' backend='' watch='' fsw=''
shell='' backend='' watch='' fsw='' draining=''
stop() { [ -n "$1" ] && kill "$1" 2>/dev/null && wait "$1" 2>/dev/null; }
start_backend() { MAGPIE_DEV_ROLE=backend ./magpie-dev-backend app & backend=$!; }
# listening waits (up to 30s) for backend $1 to listen on the gateway's
# port and the backend's; no, once it has exited
listening() {
local i
for ((i = 0; i < 150; i++)); do
kill -0 "$1" 2>/dev/null || return 1
listens "$1" "${MAGPIE_ADDR##*:}" && listens "$1" "${MAGPIE_DEV_BACKEND##*:}" && return 0
sleep 0.2
done
return 1
}
listens() { lsof -nP -a -p "$1" -iTCP:"$2" -sTCP:LISTEN >/dev/null 2>&1; }
# handover starts a new backend and, once it listens, lets the old one go
handover() {
local old=$backend
start_backend
if listening "$backend"; then
kill -USR2 "$old" 2>/dev/null
draining="$draining $old"
return
fi
echo " the new backend didn't come up · the one before it keeps running"
kill "$backend" 2>/dev/null
wait "$backend" 2>/dev/null
backend=$old
}
# the shell, and a watcher that ends everything once it is quit
start_shell() {
MAGPIE_DEV_ROLE=shell ./magpie-dev app & shell=$!
@@ -31,7 +65,7 @@ stop_shell() {
cleanup() {
trap - INT TERM
stop_shell; stop "$backend"; kill "$fsw" 2>/dev/null
stop_shell; stop "$backend"; kill "$fsw" $draining 2>/dev/null
exit 0
}
trap cleanup INT TERM
@@ -66,8 +100,7 @@ while read -r -u 3 path; do
echo " build failed · the backend before it keeps running"
continue
fi
stop "$backend"
start_backend
handover
case $changed in
*/internal/gui/app.go* | */internal/gui/dev_on.go*)
echo " window code changed · reopening the windows"
+13 -4
View File
@@ -211,11 +211,13 @@ func (s *Server) record(c Call) {
// two at once.
var WhileServing []func(context.Context)
// ListenAndServe runs the gateway until ctx ends. A bind error means
// another magpie is already serving, which is fine for the caller to ignore.
// ListenAndServe runs the gateway until ctx ends, and returns once the
// requests in flight then have finished: within 2s, or, handing over, as
// long as a stream takes. A bind error means another magpie is already
// serving, which is fine for the caller to ignore.
func (s *Server) ListenAndServe(ctx context.Context) error {
loadLANKey()
ln, err := net.Listen("tcp", listenAddr())
ln, err := Listen(listenAddr())
if err != nil {
return err
}
@@ -234,9 +236,15 @@ func (s *Server) ListenAndServe(ctx context.Context) error {
for _, f := range WhileServing {
go f(ctx)
}
drained := make(chan struct{})
go func() {
defer close(drained)
<-ctx.Done()
c, cancel := context.WithTimeout(context.Background(), 2*time.Second)
wait := 2 * time.Second
if Handover {
wait = 15 * time.Minute
}
c, cancel := context.WithTimeout(context.Background(), wait)
defer cancel()
srv.Shutdown(c)
s.subscription.abortAll()
@@ -244,6 +252,7 @@ func (s *Server) ListenAndServe(ctx context.Context) error {
for {
err := srv.Serve(ln)
if err == nil || errors.Is(err, http.ErrServerClosed) {
<-drained
return nil
}
// Relisten closed it to move the gateway: serve the one it opened
+2 -2
View File
@@ -94,9 +94,9 @@ func (s *Server) Relisten() error {
was := s.ln.Addr().String()
// the port is the same, so the old one goes first
s.ln.Close()
ln, err := net.Listen("tcp", to)
ln, err := Listen(to)
if err != nil {
if ln, _ = net.Listen("tcp", was); ln == nil {
if ln, _ = Listen(was); ln == nil {
return err
}
s.ln = ln
+22
View File
@@ -0,0 +1,22 @@
package gateway
import (
"context"
"net"
)
// Handover lets another process listen on the same address beside this
// one, for `make dev`: a restarted backend takes the gateway's port up
// before the one before it lets go, and that one finishes the streams it
// has instead of cutting them. Set before ListenAndServe; off, a second
// magpie can't bind the port, which is how it learns one already serves.
var Handover bool
// Listen listens on addr, beside another process when Handover is on.
func Listen(addr string) (net.Listener, error) {
if !Handover {
return net.Listen("tcp", addr)
}
lc := net.ListenConfig{Control: reusePort}
return lc.Listen(context.Background(), "tcp", addr)
}
+9
View File
@@ -0,0 +1,9 @@
//go:build !darwin && !linux
package gateway
import "syscall"
// Elsewhere a second process can't share the port: the handover is a
// plain restart.
func reusePort(_, _ string, _ syscall.RawConn) error { return nil }
+15
View File
@@ -0,0 +1,15 @@
//go:build darwin || linux
package gateway
import "syscall"
func reusePort(_, _ string, c syscall.RawConn) error {
var err error
if cerr := c.Control(func(fd uintptr) {
err = syscall.SetsockoptInt(int(fd), syscall.SOL_SOCKET, syscall.SO_REUSEPORT, 1)
}); cerr != nil {
return cerr
}
return err
}
+20 -2
View File
@@ -3,6 +3,7 @@ package gui
import (
"context"
"log"
"sync"
"sync/atomic"
"time"
@@ -16,6 +17,19 @@ import (
// has it.
var served atomic.Pointer[gateway.Server]
// backendCtx ends the gateway this process serves (stopServing).
var backendCtx, endBackend = context.WithCancel(context.Background())
// serving is the gateway this process serves, until it has finished.
var serving sync.WaitGroup
// stopServing stops this process's gateway taking requests and returns
// once those in flight have finished.
func stopServing() {
endBackend()
serving.Wait()
}
// startBackend starts what serves the page and the agents: the gateway,
// unless another magpie has it (then that one serves and this one only
// shows its status, and gw is nil — until that one is gone: a magpie left
@@ -73,13 +87,17 @@ func watchGateway() {
// serveGateway starts the gateway here when no magpie has it: the one
// started, or nil.
func serveGateway() *gateway.Server {
if gateway.Running() {
// handing over, the one there is this one's predecessor, which lets go
// once this one listens beside it
if !gateway.Handover && gateway.Running() {
return nil
}
gw := gateway.New()
served.Store(gw)
serving.Add(1)
go func() {
if err := gw.ListenAndServe(context.Background()); err != nil {
defer serving.Done()
if err := gw.ListenAndServe(backendCtx); err != nil {
log.Println("gateway:", err)
served.CompareAndSwap(gw, nil) // another took the port first
}
+12
View File
@@ -0,0 +1,12 @@
//go:build dev && !windows
package gui
import (
"os"
"syscall"
)
// handoverSignal tells a dev backend its successor listens: USR2, so an
// INT or TERM still ends it at once.
var handoverSignal os.Signal = syscall.SIGUSR2
+8
View File
@@ -0,0 +1,8 @@
//go:build dev && windows
package gui
import "os"
// No handover on Windows: build/dev.sh restarts the backend there.
var handoverSignal os.Signal
+44 -7
View File
@@ -14,11 +14,14 @@ import (
"net/http/httputil"
"net/url"
"os"
"os/signal"
"path/filepath"
"runtime"
"strconv"
"strings"
"time"
"github.com/yetone/magpie/internal/gateway"
)
// Development build (`make dev`): the UI is read from internal/gui/assets on
@@ -120,24 +123,58 @@ func devListen(h http.Handler) {
if addr == "" {
return
}
go func() {
log.Println("dev ui:", "http://"+addr+"/?theme=dark")
if err := http.ListenAndServe(addr, h); err != nil {
log.Println("dev ui:", err)
}
}()
ln, err := gateway.Listen(addr)
if err != nil {
log.Println("dev ui:", err)
return
}
log.Println("dev ui:", "http://"+addr+"/?theme=dark")
devUI = &http.Server{Handler: h}
go devUI.Serve(ln)
}
// devUI is the page served on MAGPIE_DEV_UI, nil when it isn't.
var devUI *http.Server
// devRole is this process's half of `make dev`: "backend", "shell", or ""
// for the whole app in one process.
func devRole() string { return os.Getenv("MAGPIE_DEV_ROLE") }
// devBackend serves the page and the API on MAGPIE_DEV_BACKEND for the shell.
//
// A restart is a handover (build/dev.sh): the new backend listens on the
// same addresses beside this one, then this one is sent handoverSignal and
// stops taking requests, finishes those in flight — an agent's stream
// through the gateway as long as it takes — and exits, so a Go change no
// longer cuts off the agents using the dev gateway.
func devBackend(handler func(Windows) http.Handler) error {
gateway.Handover = handoverSignal != nil
addr := os.Getenv("MAGPIE_DEV_BACKEND")
h := handler(remoteWindows(os.Getenv("MAGPIE_DEV_CONTROL")))
ln, err := gateway.Listen(addr)
if err != nil {
return err
}
log.Println("dev backend:", "http://"+addr)
return http.ListenAndServe(addr, h)
srv := &http.Server{Handler: h}
if handoverSignal == nil {
return srv.Serve(ln)
}
go srv.Serve(ln)
sig := make(chan os.Signal, 1)
signal.Notify(sig, handoverSignal)
<-sig
log.Println("dev backend: handed over · finishing what's in flight")
// the page's long polls end within 20s; what the gateway streams, when it ends
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
go srv.Shutdown(ctx)
if devUI != nil {
go devUI.Shutdown(ctx)
}
stopServing()
srv.Shutdown(ctx)
return nil
}
// remoteWindows are the shell's windows, seen from the backend.