Add --reload-env: service re-reads and validates its config, restarts when idle if it changed
This commit is contained in:
+95
-30
@@ -16,6 +16,7 @@ import (
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -39,7 +40,8 @@ import (
|
||||
var version = "dev"
|
||||
|
||||
// exitCodeUpdate tells the service recovery configuration to restart the
|
||||
// process: a signed update has been staged and the GPU lock is idle.
|
||||
// process: a signed update has been staged or a changed config reload was
|
||||
// requested, and the GPU lock is idle.
|
||||
const exitCodeUpdate = 3
|
||||
|
||||
// exitCodeStaged is returned by an elevated --force-update child when it
|
||||
@@ -59,7 +61,7 @@ func stdoutIsTerminal() bool {
|
||||
// --install-service / --remove-service switches, --no-copy, -h/--help,
|
||||
// -v/--version, --force-update and the hidden --elevated-child marker from
|
||||
// args.
|
||||
func parseFlags(args []string) (configPath string, install, remove, noCopy, help, showVersion, forceUpdate, updateNow, monitor, elevatedChild bool, rest []string) {
|
||||
func parseFlags(args []string) (configPath string, install, remove, noCopy, help, showVersion, forceUpdate, updateNow, reloadEnv, monitor, elevatedChild bool, rest []string) {
|
||||
rest = args[:0]
|
||||
for i := 0; i < len(args); i++ {
|
||||
switch {
|
||||
@@ -82,6 +84,8 @@ func parseFlags(args []string) (configPath string, install, remove, noCopy, help
|
||||
forceUpdate = true
|
||||
case args[i] == "--update-now" || args[i] == "-update-now":
|
||||
updateNow = true
|
||||
case args[i] == "--reload-env" || args[i] == "-reload-env":
|
||||
reloadEnv = true
|
||||
case args[i] == "--monitor" || args[i] == "-monitor" || args[i] == "-m":
|
||||
monitor = true
|
||||
case args[i] == "--elevated-child":
|
||||
@@ -90,7 +94,7 @@ func parseFlags(args []string) (configPath string, install, remove, noCopy, help
|
||||
rest = append(rest, args[i])
|
||||
}
|
||||
}
|
||||
return configPath, install, remove, noCopy, help, showVersion, forceUpdate, updateNow, monitor, elevatedChild, rest
|
||||
return configPath, install, remove, noCopy, help, showVersion, forceUpdate, updateNow, reloadEnv, monitor, elevatedChild, rest
|
||||
}
|
||||
|
||||
// versionLine is printed at the top of every help and error screen.
|
||||
@@ -109,6 +113,9 @@ Usage:
|
||||
(no admin needed when the service runs)
|
||||
gpu-turnstile --update-now like --force-update, but only
|
||||
through the running service
|
||||
gpu-turnstile --reload-env make the service re-read and
|
||||
validate its config file, then
|
||||
restart onto it if it changed
|
||||
gpu-turnstile -m | --monitor live status view (downstreams,
|
||||
GPU lock, queue); Ctrl+C quits
|
||||
gpu-turnstile -h | --help this help
|
||||
@@ -141,12 +148,12 @@ func fatalUsage(format string, args ...any) {
|
||||
}
|
||||
|
||||
func main() {
|
||||
configPath, install, remove, noCopy, help, showVersion, forceUpdate, updateNow, monitor, elevatedChild, args := parseFlags(os.Args[1:])
|
||||
configPath, install, remove, noCopy, help, showVersion, forceUpdate, updateNow, reloadEnv, monitor, elevatedChild, args := parseFlags(os.Args[1:])
|
||||
if showVersion {
|
||||
fmt.Println(version)
|
||||
return
|
||||
}
|
||||
bare := configPath == "" && !install && !remove && !forceUpdate && !updateNow && !monitor && !elevatedChild && len(args) == 0
|
||||
bare := configPath == "" && !install && !remove && !forceUpdate && !updateNow && !reloadEnv && !monitor && !elevatedChild && len(args) == 0
|
||||
if help || (bare && stdoutIsTerminal()) {
|
||||
// Bare invocation in a terminal (e.g. double-clicked on Windows)
|
||||
// shows the help instead of starting a proxy window with no visible
|
||||
@@ -171,7 +178,9 @@ func main() {
|
||||
fatalUsage("error: --force-update cannot be combined with --install-service/--remove-service")
|
||||
case updateNow && (install || remove || forceUpdate):
|
||||
fatalUsage("error: --update-now cannot be combined with other commands")
|
||||
case monitor && (install || remove || forceUpdate || updateNow):
|
||||
case reloadEnv && (install || remove || forceUpdate || updateNow):
|
||||
fatalUsage("error: --reload-env cannot be combined with other commands")
|
||||
case monitor && (install || remove || forceUpdate || updateNow || reloadEnv):
|
||||
fatalUsage("error: --monitor cannot be combined with other commands")
|
||||
case install:
|
||||
os.Exit(serviceCommand(configPath, true, noCopy, elevatedChild))
|
||||
@@ -181,6 +190,8 @@ func main() {
|
||||
os.Exit(forceUpdateCommand(configPath, elevatedChild))
|
||||
case updateNow:
|
||||
os.Exit(updateNowCommand())
|
||||
case reloadEnv:
|
||||
os.Exit(reloadEnvCommand())
|
||||
case monitor:
|
||||
os.Exit(monitorCommand())
|
||||
}
|
||||
@@ -202,7 +213,7 @@ func main() {
|
||||
syncEnvFile(resolveConfigPath(configPath), cfg.LogFile, log)
|
||||
|
||||
if service.IsService() {
|
||||
if err := service.Run(func(ctx context.Context) error { return run(ctx, cfg, log, logOut, true) }); err != nil {
|
||||
if err := service.Run(func(ctx context.Context) error { return run(ctx, cfg, log, logOut, true, configPath) }); err != nil {
|
||||
log.Error("service failed", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
@@ -210,7 +221,7 @@ func main() {
|
||||
}
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
if err := run(ctx, cfg, log, logOut, false); err != nil {
|
||||
if err := run(ctx, cfg, log, logOut, false, configPath); err != nil {
|
||||
log.Error("listener failed", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
@@ -451,6 +462,18 @@ func updateNowCommand() int {
|
||||
return printControlReply(reply)
|
||||
}
|
||||
|
||||
// reloadEnvCommand asks the running service, over the control channel, to
|
||||
// re-read and validate its config file. The client sends no path — the
|
||||
// service only ever re-reads its own configured file.
|
||||
func reloadEnvCommand() int {
|
||||
reply, err := control.Ask(control.CmdReloadEnv)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "%s\n\ngpu-turnstile: no running service to ask\n", versionLine())
|
||||
return 1
|
||||
}
|
||||
return printControlReply(reply)
|
||||
}
|
||||
|
||||
// reportElevatedUpdate prints the parent's summary of an elevated
|
||||
// --force-update child: exitCodeStaged means the child staged a new binary,
|
||||
// 0 means it found nothing to do. to is the tag the parent's own check
|
||||
@@ -531,7 +554,7 @@ func managedComfyCommand(cfg config.Config) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func run(ctx context.Context, cfg config.Config, log *slog.Logger, logOut io.Writer, isService bool) error {
|
||||
func run(ctx context.Context, cfg config.Config, log *slog.Logger, logOut io.Writer, isService bool, configPath string) error {
|
||||
// ComfyUI can run as a managed child — COMFY_CMD verbatim, or the
|
||||
// standard venv layout derived from COMFY_DIR alone: started on demand
|
||||
// by the proxy, stopped after COMFY_IDLE_TIMEOUT idle (and on shutdown)
|
||||
@@ -747,6 +770,22 @@ func run(ctx context.Context, cfg config.Config, log *slog.Logger, logOut io.Wri
|
||||
|
||||
var u *update.Updater
|
||||
var exePath string
|
||||
// restartWhenIdle exits with exitCodeUpdate once the GPU lock is idle;
|
||||
// the service recovery configuration brings the process back. Shared by
|
||||
// staged updates and config reloads; the once guard makes repeated
|
||||
// triggers idempotent.
|
||||
var restartOnce sync.Once
|
||||
restartWhenIdle := func(reason string) {
|
||||
log.Warn("restarting once the GPU is idle", "reason", reason)
|
||||
restartOnce.Do(func() {
|
||||
go func() {
|
||||
if waitForIdle(ctx, lk, 24*time.Hour) {
|
||||
log.Warn("restarting now", "reason", reason)
|
||||
os.Exit(exitCodeUpdate)
|
||||
}
|
||||
}()
|
||||
})
|
||||
}
|
||||
applyStaged := func(to string) {}
|
||||
if cfg.AutoUpdate {
|
||||
if p, err := os.Executable(); err != nil {
|
||||
@@ -754,34 +793,24 @@ func run(ctx context.Context, cfg config.Config, log *slog.Logger, logOut io.Wri
|
||||
} else {
|
||||
exePath = p
|
||||
u = &update.Updater{Repo: cfg.UpdateRepo, Asset: cfg.UpdateAsset, Version: version, Desired: cfg.AppVersion, Log: log}
|
||||
// applyStaged is shared by the hourly loop and the control
|
||||
// channel; the once guard keeps a second trigger from
|
||||
// double-waiting on the GPU lock.
|
||||
var once sync.Once
|
||||
applyStaged = func(to string) {
|
||||
if !isService {
|
||||
log.Warn("auto-update: new binary staged; restart gpu-turnstile to apply", "version", to)
|
||||
return
|
||||
}
|
||||
log.Warn("auto-update: staged; restarting once the GPU is idle", "version", to)
|
||||
once.Do(func() {
|
||||
go func() {
|
||||
if waitForIdle(ctx, lk, 24*time.Hour) {
|
||||
log.Warn("auto-update: restarting to apply update")
|
||||
os.Exit(exitCodeUpdate)
|
||||
}
|
||||
}()
|
||||
})
|
||||
restartWhenIdle("update to " + to)
|
||||
}
|
||||
go updateLoop(ctx, cfg.UpdateInterval, log, u, exePath, applyStaged)
|
||||
}
|
||||
}
|
||||
|
||||
// The control channel (status for --monitor, update-now trigger) is
|
||||
// served whenever running as a service, independent of AUTO_UPDATE.
|
||||
// The control channel (status for --monitor, update-now and reload-env
|
||||
// triggers) is served whenever running as a service, independent of
|
||||
// AUTO_UPDATE.
|
||||
if isService {
|
||||
serveControl(ctx, log, u, exePath, applyStaged,
|
||||
statusProvider(cfg, lk, comfySup, health, started))
|
||||
statusProvider(cfg, lk, comfySup, health, started),
|
||||
reloadHandler(cfg, configPath, restartWhenIdle))
|
||||
}
|
||||
|
||||
select {
|
||||
@@ -949,17 +978,20 @@ func updateLoop(ctx context.Context, interval time.Duration, log *slog.Logger, u
|
||||
|
||||
// serveControl opens the local control channel (named pipe on Windows,
|
||||
// unix socket on Linux) so unprivileged local users can query status
|
||||
// (--monitor) and trigger an update check (--force-update/--update-now)
|
||||
// without admin rights. The update payload is signature-verified regardless
|
||||
// of who asks; triggers are rate-limited to one per minute so the channel
|
||||
// cannot be used to spam restarts. u is nil when AUTO_UPDATE=false.
|
||||
func serveControl(ctx context.Context, log *slog.Logger, u *update.Updater, exePath string, applyStaged func(to string), status func() string) {
|
||||
// (--monitor), trigger an update check (--force-update/--update-now) and
|
||||
// poke a config reload (--reload-env) without admin rights. The update
|
||||
// payload is signature-verified regardless of who asks; update triggers
|
||||
// are rate-limited to one per minute so the channel cannot be used to spam
|
||||
// restarts. u is nil when AUTO_UPDATE=false.
|
||||
func serveControl(ctx context.Context, log *slog.Logger, u *update.Updater, exePath string, applyStaged func(to string), status func() string, reload func() string) {
|
||||
var mu sync.Mutex
|
||||
var lastTrigger time.Time
|
||||
h := func(cmd string) string {
|
||||
switch cmd {
|
||||
case control.CmdStatus:
|
||||
return "OK " + status()
|
||||
case control.CmdReloadEnv:
|
||||
return reload()
|
||||
case control.CmdUpdateNow:
|
||||
default:
|
||||
return "ERR unknown command: " + cmd
|
||||
@@ -1043,6 +1075,39 @@ type statusSnapshot struct {
|
||||
MonitorNote string `json:"-"`
|
||||
}
|
||||
|
||||
// reloadHandler re-reads and validates the service's config file for
|
||||
// CmdReloadEnv. An invalid config is reported and the service keeps running
|
||||
// untouched; a valid, changed config triggers a GPU-idle-gated restart onto
|
||||
// it (same mechanism as staged updates); unchanged is a no-op.
|
||||
func reloadHandler(current config.Config, configPath string, restartWhenIdle func(reason string)) func() string {
|
||||
return func() string {
|
||||
ncfg, err := loadMergedConfig(configPath)
|
||||
if err != nil {
|
||||
return "ERR config invalid: " + err.Error()
|
||||
}
|
||||
changed := diffConfig(current, ncfg)
|
||||
if len(changed) == 0 {
|
||||
return "OK config unchanged"
|
||||
}
|
||||
restartWhenIdle("config reload (" + strings.Join(changed, ", ") + ")")
|
||||
return "OK config valid; restarting once the GPU is idle (changed: " + strings.Join(changed, ", ") + ")"
|
||||
}
|
||||
}
|
||||
|
||||
// diffConfig lists the names of fields whose values differ between two
|
||||
// configs.
|
||||
func diffConfig(a, b config.Config) []string {
|
||||
va, vb := reflect.ValueOf(a), reflect.ValueOf(b)
|
||||
t := va.Type()
|
||||
var out []string
|
||||
for i := 0; i < t.NumField(); i++ {
|
||||
if !reflect.DeepEqual(va.Field(i).Interface(), vb.Field(i).Interface()) {
|
||||
out = append(out, t.Field(i).Name)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// statusProvider assembles the one-line JSON snapshot for CmdStatus.
|
||||
func statusProvider(cfg config.Config, lk *lock.Lock, comfySup *supervise.Process, health *healthTracker, started time.Time) func() string {
|
||||
return func() string {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"gpu-turnstile/internal/config"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
@@ -39,3 +40,17 @@ func TestFmtDur(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiffConfig(t *testing.T) {
|
||||
a := config.Defaults()
|
||||
b := a
|
||||
if got := diffConfig(a, b); len(got) != 0 {
|
||||
t.Fatalf("identical configs: got %v", got)
|
||||
}
|
||||
b.LogLevel = -4
|
||||
b.GameProcs = []string{"game.exe"}
|
||||
got := diffConfig(a, b)
|
||||
if len(got) != 2 || got[0] != "GameProcs" || got[1] != "LogLevel" {
|
||||
t.Fatalf("got %v, want [GameProcs LogLevel]", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,10 @@ const CmdUpdateNow = "update-now"
|
||||
// CmdStatus asks for a one-line JSON status snapshot (monitor mode).
|
||||
const CmdStatus = "status"
|
||||
|
||||
// CmdReloadEnv asks the service to re-read and validate its config file,
|
||||
// and to restart onto it (once the GPU is idle) when it changed.
|
||||
const CmdReloadEnv = "reload-env"
|
||||
|
||||
// ErrUnavailable means no running service offers the control channel.
|
||||
var ErrUnavailable = errors.New("control channel unavailable")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user