From 98d2d714ca126b2d0a9bcd25f0f30b0db3afeaf9 Mon Sep 17 00:00:00 2001 From: mram Date: Tue, 22 Sep 2026 09:02:25 +0200 Subject: [PATCH] Add --reload-env: service re-reads and validates its config, restarts when idle if it changed --- cmd/gpu-turnstile/main.go | 125 +++++++++++++++++++++++------- cmd/gpu-turnstile/monitor_test.go | 15 ++++ internal/control/control.go | 4 + 3 files changed, 114 insertions(+), 30 deletions(-) diff --git a/cmd/gpu-turnstile/main.go b/cmd/gpu-turnstile/main.go index 940d7f4..9e74b86 100644 --- a/cmd/gpu-turnstile/main.go +++ b/cmd/gpu-turnstile/main.go @@ -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 { diff --git a/cmd/gpu-turnstile/monitor_test.go b/cmd/gpu-turnstile/monitor_test.go index b6bbecf..556ce4d 100644 --- a/cmd/gpu-turnstile/monitor_test.go +++ b/cmd/gpu-turnstile/monitor_test.go @@ -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) + } +} diff --git a/internal/control/control.go b/internal/control/control.go index 02226c6..6dacab8 100644 --- a/internal/control/control.go +++ b/internal/control/control.go @@ -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")