Native Windows deployment: config file, service, signed auto-update
- internal/config: .env-style config file (gpu-turnstile.env next to the exe, -config flag or GPU_TURNSTILE_CONFIG); process env overrides file. - internal/service: Windows service via golang.org/x/sys/windows/svc — graceful SCM stop, 'service install/remove' commands, restart-on-failure recovery (also applies staged updates). First external dependency, Windows-only; Linux/Docker build unaffected (go.mod stays at 1.23). - internal/update: polls the Gitea releases API, verifies the Ed25519 signature of the downloaded binary against an embedded public key (openssl-signed by CI), swaps it in next to the running exe, and once the GPU lock is idle exits with code 3 so service recovery restarts onto the new version. Dev builds and empty pubkey never update. - CI: tag builds additionally produce gpu-turnstile.exe + .sig + .sha256 attached to a Gitea release. - LOG_FILE env var so the service has somewhere to log.
This commit is contained in:
+260
-181
@@ -6,256 +6,335 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strconv"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"gpu-turnstile/internal/comfy"
|
||||
"gpu-turnstile/internal/config"
|
||||
"gpu-turnstile/internal/lock"
|
||||
"gpu-turnstile/internal/metrics"
|
||||
"gpu-turnstile/internal/ollama"
|
||||
"gpu-turnstile/internal/proxy"
|
||||
"gpu-turnstile/internal/service"
|
||||
"gpu-turnstile/internal/update"
|
||||
)
|
||||
|
||||
// version is injected at build time via -ldflags "-X main.version=...".
|
||||
var version = "dev"
|
||||
|
||||
type config struct {
|
||||
listenOllama string
|
||||
listenComfy string
|
||||
ollamaURL string
|
||||
comfyURL string
|
||||
unloadTimeout time.Duration
|
||||
jobTimeout time.Duration
|
||||
llmWaitTimeout time.Duration
|
||||
|
||||
unloadPollInterval time.Duration
|
||||
historyPollInterval time.Duration
|
||||
probeTimeout time.Duration
|
||||
freeTimeout time.Duration
|
||||
warmTimeout time.Duration
|
||||
shutdownTimeout time.Duration
|
||||
backoffInitial time.Duration
|
||||
backoffMax time.Duration
|
||||
promptCaptureLimit int64
|
||||
|
||||
warmModel string
|
||||
logLevel slog.Level
|
||||
logJSON bool
|
||||
}
|
||||
|
||||
func envDuration(getenv func(string) string, name string, dst *time.Duration) error {
|
||||
v := getenv(name)
|
||||
if v == "" {
|
||||
return nil
|
||||
}
|
||||
d, err := time.ParseDuration(v)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: %w", name, err)
|
||||
}
|
||||
*dst = d
|
||||
return nil
|
||||
}
|
||||
|
||||
func loadConfig(getenv func(string) string) (config, error) {
|
||||
cfg := config{
|
||||
listenOllama: ":11434",
|
||||
listenComfy: ":8188",
|
||||
ollamaURL: "http://127.0.0.1:11435",
|
||||
comfyURL: "http://127.0.0.1:8189",
|
||||
unloadTimeout: time.Minute,
|
||||
jobTimeout: 15 * time.Minute,
|
||||
llmWaitTimeout: 10 * time.Minute,
|
||||
|
||||
unloadPollInterval: 500 * time.Millisecond,
|
||||
historyPollInterval: time.Second,
|
||||
probeTimeout: 5 * time.Second,
|
||||
freeTimeout: 30 * time.Second,
|
||||
warmTimeout: 2 * time.Minute,
|
||||
shutdownTimeout: 10 * time.Second,
|
||||
backoffInitial: time.Second,
|
||||
backoffMax: time.Minute,
|
||||
promptCaptureLimit: 64 * 1024,
|
||||
|
||||
logLevel: slog.LevelWarn,
|
||||
}
|
||||
for _, e := range []struct {
|
||||
name string
|
||||
dst *string
|
||||
}{
|
||||
{"LISTEN_OLLAMA", &cfg.listenOllama},
|
||||
{"LISTEN_COMFY", &cfg.listenComfy},
|
||||
{"OLLAMA_URL", &cfg.ollamaURL},
|
||||
{"COMFY_URL", &cfg.comfyURL},
|
||||
{"WARM_MODEL", &cfg.warmModel},
|
||||
} {
|
||||
if v := getenv(e.name); v != "" {
|
||||
*e.dst = v
|
||||
}
|
||||
}
|
||||
for _, e := range []struct {
|
||||
name string
|
||||
dst *time.Duration
|
||||
}{
|
||||
{"UNLOAD_TIMEOUT", &cfg.unloadTimeout},
|
||||
{"JOB_TIMEOUT", &cfg.jobTimeout},
|
||||
{"LLM_WAIT_TIMEOUT", &cfg.llmWaitTimeout},
|
||||
{"UNLOAD_POLL_INTERVAL", &cfg.unloadPollInterval},
|
||||
{"HISTORY_POLL_INTERVAL", &cfg.historyPollInterval},
|
||||
{"PROBE_TIMEOUT", &cfg.probeTimeout},
|
||||
{"FREE_TIMEOUT", &cfg.freeTimeout},
|
||||
{"WARM_TIMEOUT", &cfg.warmTimeout},
|
||||
{"SHUTDOWN_TIMEOUT", &cfg.shutdownTimeout},
|
||||
{"BACKOFF_INITIAL", &cfg.backoffInitial},
|
||||
{"BACKOFF_MAX", &cfg.backoffMax},
|
||||
} {
|
||||
if err := envDuration(getenv, e.name, e.dst); err != nil {
|
||||
return cfg, err
|
||||
}
|
||||
}
|
||||
if v := getenv("PROMPT_CAPTURE_LIMIT"); v != "" {
|
||||
n, err := strconv.ParseInt(v, 10, 64)
|
||||
if err != nil || n < 0 {
|
||||
return cfg, fmt.Errorf("PROMPT_CAPTURE_LIMIT: must be a non-negative integer (bytes)")
|
||||
}
|
||||
cfg.promptCaptureLimit = n
|
||||
}
|
||||
// LOGLEVEL is the canonical spelling; LOG_LEVEL is kept as an alias.
|
||||
logLevelValue := getenv("LOGLEVEL")
|
||||
if logLevelValue == "" {
|
||||
logLevelValue = getenv("LOG_LEVEL")
|
||||
}
|
||||
if logLevelValue != "" {
|
||||
var level slog.Level
|
||||
if err := level.UnmarshalText([]byte(logLevelValue)); err != nil {
|
||||
return cfg, fmt.Errorf("LOGLEVEL: %w", err)
|
||||
}
|
||||
cfg.logLevel = level
|
||||
}
|
||||
switch strings.ToLower(getenv("LOG_FORMAT")) {
|
||||
case "", "text":
|
||||
case "json":
|
||||
cfg.logJSON = true
|
||||
default:
|
||||
return cfg, fmt.Errorf("LOG_FORMAT: must be \"text\" or \"json\"")
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
// exitCodeUpdate tells the service recovery configuration to restart the
|
||||
// process: a signed update has been staged and the GPU lock is idle.
|
||||
const exitCodeUpdate = 3
|
||||
|
||||
func main() {
|
||||
cfg, err := loadConfig(os.Getenv)
|
||||
configPath, args := splitConfigFlag(os.Args[1:])
|
||||
if len(args) > 0 && args[0] == "service" {
|
||||
os.Exit(serviceCommand(configPath, args[1:]))
|
||||
}
|
||||
if len(args) > 0 {
|
||||
fmt.Fprintf(os.Stderr, "usage: gpu-turnstile [-config path] | gpu-turnstile service install|remove [-config path]\n")
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
if exePath, err := os.Executable(); err == nil {
|
||||
update.CleanupOld(exePath)
|
||||
}
|
||||
|
||||
cfg, err := loadMergedConfig(configPath)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "gpu-turnstile: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
log, logOut, logCloser := newLogger(cfg)
|
||||
defer logCloser.Close()
|
||||
|
||||
opts := &slog.HandlerOptions{Level: cfg.logLevel}
|
||||
var handler slog.Handler = slog.NewTextHandler(os.Stderr, opts)
|
||||
if cfg.logJSON {
|
||||
handler = slog.NewJSONHandler(os.Stderr, opts)
|
||||
if service.IsService() {
|
||||
if err := service.Run(func(ctx context.Context) error { return run(ctx, cfg, log, logOut, true) }); err != nil {
|
||||
log.Error("service failed", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
return
|
||||
}
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
if err := run(ctx, cfg, log, logOut, false); err != nil {
|
||||
log.Error("listener failed", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// splitConfigFlag extracts -config <path> (or -config=<path>) from args.
|
||||
func splitConfigFlag(args []string) (string, []string) {
|
||||
var configPath string
|
||||
rest := args[:0]
|
||||
for i := 0; i < len(args); i++ {
|
||||
switch {
|
||||
case args[i] == "-config" && i+1 < len(args):
|
||||
configPath = args[i+1]
|
||||
i++
|
||||
case strings.HasPrefix(args[i], "-config="):
|
||||
configPath = strings.TrimPrefix(args[i], "-config=")
|
||||
default:
|
||||
rest = append(rest, args[i])
|
||||
}
|
||||
}
|
||||
return configPath, rest
|
||||
}
|
||||
|
||||
// defaultConfigPath returns gpu-turnstile.env next to the executable.
|
||||
func defaultConfigPath() string {
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return "gpu-turnstile.env"
|
||||
}
|
||||
return filepath.Join(filepath.Dir(exe), "gpu-turnstile.env")
|
||||
}
|
||||
|
||||
// resolveConfigPath applies the precedence: -config flag, then
|
||||
// GPU_TURNSTILE_CONFIG, then the default next to the executable.
|
||||
func resolveConfigPath(flagValue string) string {
|
||||
if flagValue != "" {
|
||||
return flagValue
|
||||
}
|
||||
if v := os.Getenv("GPU_TURNSTILE_CONFIG"); v != "" {
|
||||
return v
|
||||
}
|
||||
return defaultConfigPath()
|
||||
}
|
||||
|
||||
// loadMergedConfig reads the config file (if present) and overlays process
|
||||
// environment variables on top. A missing file is fine; an unreadable or
|
||||
// malformed file is fatal.
|
||||
func loadMergedConfig(flagValue string) (config.Config, error) {
|
||||
path := resolveConfigPath(flagValue)
|
||||
values := map[string]string{}
|
||||
if f, err := os.Open(path); err == nil {
|
||||
defer f.Close()
|
||||
parsed, err := config.ParseEnvFile(f)
|
||||
if err != nil {
|
||||
return config.Config{}, fmt.Errorf("%s: %w", path, err)
|
||||
}
|
||||
values = parsed
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
return config.Config{}, fmt.Errorf("read config file: %w", err)
|
||||
}
|
||||
getenv := func(key string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return values[key]
|
||||
}
|
||||
return config.Load(getenv)
|
||||
}
|
||||
|
||||
// newLogger builds the slog logger and returns the output writer (stderr or
|
||||
// the opened LOG_FILE) plus a closer for it.
|
||||
func newLogger(cfg config.Config) (*slog.Logger, io.Writer, io.Closer) {
|
||||
out := io.Writer(os.Stderr)
|
||||
closer := io.NopCloser(nil)
|
||||
if cfg.LogFile != "" {
|
||||
if f, err := os.OpenFile(cfg.LogFile, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644); err == nil {
|
||||
out = f
|
||||
closer = f
|
||||
} else {
|
||||
fmt.Fprintf(os.Stderr, "gpu-turnstile: cannot open LOG_FILE %s: %v (logging to stderr)\n", cfg.LogFile, err)
|
||||
}
|
||||
}
|
||||
opts := &slog.HandlerOptions{Level: cfg.LogLevel}
|
||||
var handler slog.Handler = slog.NewTextHandler(out, opts)
|
||||
if cfg.LogJSON {
|
||||
handler = slog.NewJSONHandler(out, opts)
|
||||
}
|
||||
log := slog.New(handler)
|
||||
slog.SetDefault(log)
|
||||
return log, out, closer
|
||||
}
|
||||
|
||||
func serviceCommand(configPath string, args []string) int {
|
||||
if len(args) != 1 || (args[0] != "install" && args[0] != "remove") {
|
||||
fmt.Fprintf(os.Stderr, "usage: gpu-turnstile service install|remove [-config path]\n")
|
||||
return 2
|
||||
}
|
||||
var err error
|
||||
if args[0] == "install" {
|
||||
path := resolveConfigPath(configPath)
|
||||
if abs, absErr := filepath.Abs(path); absErr == nil {
|
||||
path = abs
|
||||
}
|
||||
err = service.Install(path)
|
||||
} else {
|
||||
err = service.Remove()
|
||||
}
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "gpu-turnstile service %s: %v\n", args[0], err)
|
||||
return 1
|
||||
}
|
||||
fmt.Printf("service %s: %sd\n", service.Name, args[0])
|
||||
return 0
|
||||
}
|
||||
|
||||
func run(ctx context.Context, cfg config.Config, log *slog.Logger, logOut io.Writer, isService bool) error {
|
||||
// The startup line carries the version and every setting and is emitted
|
||||
// at WARN so it is visible even with the default (quiet) log level.
|
||||
log.Log(context.Background(), slog.LevelWarn, "starting gpu-turnstile",
|
||||
log.Log(ctx, slog.LevelWarn, "starting gpu-turnstile",
|
||||
"version", version,
|
||||
"listen_ollama", cfg.listenOllama,
|
||||
"listen_comfy", cfg.listenComfy,
|
||||
"ollama_url", cfg.ollamaURL,
|
||||
"comfy_url", cfg.comfyURL,
|
||||
"unload_timeout", cfg.unloadTimeout,
|
||||
"job_timeout", cfg.jobTimeout,
|
||||
"llm_wait_timeout", cfg.llmWaitTimeout,
|
||||
"unload_poll_interval", cfg.unloadPollInterval,
|
||||
"history_poll_interval", cfg.historyPollInterval,
|
||||
"probe_timeout", cfg.probeTimeout,
|
||||
"free_timeout", cfg.freeTimeout,
|
||||
"warm_timeout", cfg.warmTimeout,
|
||||
"shutdown_timeout", cfg.shutdownTimeout,
|
||||
"backoff_initial", cfg.backoffInitial,
|
||||
"backoff_max", cfg.backoffMax,
|
||||
"prompt_capture_limit", cfg.promptCaptureLimit,
|
||||
"warm_model", cfg.warmModel,
|
||||
"log_level", cfg.logLevel,
|
||||
"log_format", map[bool]string{true: "json", false: "text"}[cfg.logJSON],
|
||||
"listen_ollama", cfg.ListenOllama,
|
||||
"listen_comfy", cfg.ListenComfy,
|
||||
"ollama_url", cfg.OllamaURL,
|
||||
"comfy_url", cfg.ComfyURL,
|
||||
"unload_timeout", cfg.UnloadTimeout,
|
||||
"job_timeout", cfg.JobTimeout,
|
||||
"llm_wait_timeout", cfg.LLMWaitTimeout,
|
||||
"unload_poll_interval", cfg.UnloadPollInterval,
|
||||
"history_poll_interval", cfg.HistoryPollInterval,
|
||||
"probe_timeout", cfg.ProbeTimeout,
|
||||
"free_timeout", cfg.FreeTimeout,
|
||||
"warm_timeout", cfg.WarmTimeout,
|
||||
"shutdown_timeout", cfg.ShutdownTimeout,
|
||||
"backoff_initial", cfg.BackoffInitial,
|
||||
"backoff_max", cfg.BackoffMax,
|
||||
"prompt_capture_limit", cfg.PromptCaptureLimit,
|
||||
"warm_model", cfg.WarmModel,
|
||||
"auto_update", cfg.AutoUpdate,
|
||||
"update_interval", cfg.UpdateInterval,
|
||||
"update_repo", cfg.UpdateRepo,
|
||||
"update_asset", cfg.UpdateAsset,
|
||||
"log_level", cfg.LogLevel,
|
||||
"log_format", map[bool]string{true: "json", false: "text"}[cfg.LogJSON],
|
||||
"log_file", cfg.LogFile,
|
||||
)
|
||||
|
||||
ollamaClient, err := ollama.New(cfg.ollamaURL, log)
|
||||
lk := lock.New(log)
|
||||
ollamaClient, err := ollama.New(cfg.OllamaURL, log)
|
||||
if err != nil {
|
||||
log.Error("invalid configuration", "err", err)
|
||||
os.Exit(1)
|
||||
return err
|
||||
}
|
||||
comfyClient, err := comfy.New(cfg.comfyURL, log)
|
||||
comfyClient, err := comfy.New(cfg.ComfyURL, log)
|
||||
if err != nil {
|
||||
log.Error("invalid configuration", "err", err)
|
||||
os.Exit(1)
|
||||
return err
|
||||
}
|
||||
|
||||
srv, err := proxy.New(proxy.Config{
|
||||
OllamaURL: cfg.ollamaURL,
|
||||
ComfyURL: cfg.comfyURL,
|
||||
Lock: lock.New(log),
|
||||
OllamaURL: cfg.OllamaURL,
|
||||
ComfyURL: cfg.ComfyURL,
|
||||
Lock: lk,
|
||||
Ollama: ollamaClient,
|
||||
Comfy: comfyClient,
|
||||
Metrics: metrics.New(),
|
||||
Log: log,
|
||||
LogColor: !cfg.logJSON && os.Getenv("NO_COLOR") == "",
|
||||
LLMWaitTimeout: cfg.llmWaitTimeout,
|
||||
UnloadTimeout: cfg.unloadTimeout,
|
||||
JobTimeout: cfg.jobTimeout,
|
||||
UnloadPollInterval: cfg.unloadPollInterval,
|
||||
HistoryPollInterval: cfg.historyPollInterval,
|
||||
FreeTimeout: cfg.freeTimeout,
|
||||
WarmTimeout: cfg.warmTimeout,
|
||||
BackoffInitial: cfg.backoffInitial,
|
||||
BackoffMax: cfg.backoffMax,
|
||||
PromptCaptureLimit: cfg.promptCaptureLimit,
|
||||
WarmModel: cfg.warmModel,
|
||||
LogColor: !cfg.LogJSON && cfg.LogFile == "" && os.Getenv("NO_COLOR") == "",
|
||||
LogWriter: logOut,
|
||||
LLMWaitTimeout: cfg.LLMWaitTimeout,
|
||||
UnloadTimeout: cfg.UnloadTimeout,
|
||||
JobTimeout: cfg.JobTimeout,
|
||||
UnloadPollInterval: cfg.UnloadPollInterval,
|
||||
HistoryPollInterval: cfg.HistoryPollInterval,
|
||||
FreeTimeout: cfg.FreeTimeout,
|
||||
WarmTimeout: cfg.WarmTimeout,
|
||||
BackoffInitial: cfg.BackoffInitial,
|
||||
BackoffMax: cfg.BackoffMax,
|
||||
PromptCaptureLimit: cfg.PromptCaptureLimit,
|
||||
WarmModel: cfg.WarmModel,
|
||||
})
|
||||
if err != nil {
|
||||
log.Error("invalid configuration", "err", err)
|
||||
os.Exit(1)
|
||||
return err
|
||||
}
|
||||
|
||||
// Probe both upstreams once; failure is logged, not fatal.
|
||||
probeCtx, probeCancel := context.WithTimeout(context.Background(), cfg.probeTimeout)
|
||||
probeCtx, probeCancel := context.WithTimeout(ctx, cfg.ProbeTimeout)
|
||||
if err := ollamaClient.Probe(probeCtx); err != nil {
|
||||
log.Warn("ollama probe failed", "url", cfg.ollamaURL, "err", err)
|
||||
log.Warn("ollama probe failed", "url", cfg.OllamaURL, "err", err)
|
||||
}
|
||||
if err := comfyClient.Probe(probeCtx); err != nil {
|
||||
log.Warn("comfy probe failed", "url", cfg.comfyURL, "err", err)
|
||||
log.Warn("comfy probe failed", "url", cfg.ComfyURL, "err", err)
|
||||
}
|
||||
probeCancel()
|
||||
|
||||
ollamaSrv := &http.Server{Addr: cfg.listenOllama, Handler: srv.OllamaHandler()}
|
||||
comfySrv := &http.Server{Addr: cfg.listenComfy, Handler: srv.ComfyHandler()}
|
||||
ollamaSrv := &http.Server{Addr: cfg.ListenOllama, Handler: srv.OllamaHandler()}
|
||||
comfySrv := &http.Server{Addr: cfg.ListenComfy, Handler: srv.ComfyHandler()}
|
||||
|
||||
errCh := make(chan error, 2)
|
||||
go func() { errCh <- ollamaSrv.ListenAndServe() }()
|
||||
go func() { errCh <- comfySrv.ListenAndServe() }()
|
||||
|
||||
sigCtx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
if cfg.AutoUpdate {
|
||||
go updateLoop(ctx, cfg, log, lk, isService)
|
||||
}
|
||||
|
||||
select {
|
||||
case err := <-errCh:
|
||||
if err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
log.Error("listener failed", "err", err)
|
||||
os.Exit(1)
|
||||
return err
|
||||
}
|
||||
case <-sigCtx.Done():
|
||||
case <-ctx.Done():
|
||||
log.Info("shutting down")
|
||||
}
|
||||
|
||||
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), cfg.shutdownTimeout)
|
||||
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), cfg.ShutdownTimeout)
|
||||
defer shutdownCancel()
|
||||
ollamaSrv.Shutdown(shutdownCtx)
|
||||
comfySrv.Shutdown(shutdownCtx)
|
||||
return nil
|
||||
}
|
||||
|
||||
// updateLoop checks for signed updates on startup and every UPDATE_INTERVAL.
|
||||
// In service mode a staged update is applied by exiting with exitCodeUpdate
|
||||
// once the GPU lock is idle; the service recovery configuration restarts the
|
||||
// process with the new binary. Interactively it only logs.
|
||||
func updateLoop(ctx context.Context, cfg config.Config, log *slog.Logger, lk *lock.Lock, isService bool) {
|
||||
exePath, err := os.Executable()
|
||||
if err != nil {
|
||||
log.Warn("auto-update disabled: cannot locate executable", "err", err)
|
||||
return
|
||||
}
|
||||
u := &update.Updater{Repo: cfg.UpdateRepo, Asset: cfg.UpdateAsset, Version: version, Log: log}
|
||||
for {
|
||||
staged, err := u.Check(ctx, exePath)
|
||||
if err != nil && ctx.Err() == nil {
|
||||
log.Warn("auto-update check failed", "err", err)
|
||||
}
|
||||
if staged {
|
||||
if !isService {
|
||||
log.Warn("auto-update: new binary staged; restart gpu-turnstile to apply")
|
||||
return
|
||||
}
|
||||
log.Warn("auto-update: staged; restarting once the GPU is idle")
|
||||
if waitForIdle(ctx, lk, 24*time.Hour) {
|
||||
log.Warn("auto-update: restarting to apply update")
|
||||
os.Exit(exitCodeUpdate)
|
||||
}
|
||||
return
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(cfg.UpdateInterval):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// waitForIdle polls the lock until no LLM or image work is active or
|
||||
// pending, max at most. Returns false on timeout or cancellation.
|
||||
func waitForIdle(ctx context.Context, lk *lock.Lock, max time.Duration) bool {
|
||||
deadline := time.Now().Add(max)
|
||||
for {
|
||||
if state, _, pending := lk.Snapshot(); state == lock.StateIdle && !pending {
|
||||
return true
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
return false
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return false
|
||||
case <-time.After(5 * time.Second):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user