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:
10 files changed
+182
-19
No files matched your search
+37
-4
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user