diff --git a/README.md b/README.md index 14886f1..ca9e53c 100644 --- a/README.md +++ b/README.md @@ -58,8 +58,8 @@ override file values. Invalid values fail at startup. | `LLM_BUSY_STATUS` | `503` | HTTP status for rejected LLM requests in reject mode (400–599, e.g. 429) | | `BUSY_RETRY_AFTER` | `30` | Seconds sent as `Retry-After` on busy responses (both modes) | | `WARM_MODEL` | _(empty)_ | Model to reload after an image job (off by default) | -| `COMFY_CMD` | _(empty = unmanaged)_ | Supervise ComfyUI: start on demand, stop when idle to free VRAM. Requires `COMFY_URL` | -| `COMFY_DIR` | _(empty)_ | Working directory for `COMFY_CMD` | +| `COMFY_CMD` | _(derived from `COMFY_DIR`; both empty = unmanaged)_ | Supervise ComfyUI: start on demand, stop when idle to free VRAM. Requires `COMFY_URL` | +| `COMFY_DIR` | _(empty)_ | Standard venv install root: set alone to supervise ComfyUI with the derived command (`.venv` + `main.py`); also the working directory for `COMFY_CMD` | | `COMFY_IDLE_TIMEOUT` | `5m` | Stop the managed ComfyUI after this long idle | | `COMFY_START_TIMEOUT` | `2m` | Max wait for the managed ComfyUI to come up | | `GAME_PROCS` | _(empty = disabled)_ | Process names (comma-separated); while any runs, the GPU counts as held: requests wait, Ollama unloads, managed ComfyUI stops | @@ -99,18 +99,28 @@ override file values. Invalid values fail at startup. class) in text mode, which renders in `docker compose logs` on Windows Terminal. Set `NO_COLOR` to disable colors. -## Managed ComfyUI (`COMFY_CMD`) +## Managed ComfyUI (`COMFY_CMD` / `COMFY_DIR`) Don't want ComfyUI running 24/7 (it holds VRAM even when idle — and the -Desktop app kills its server when you close it)? Point `COMFY_CMD` at a -standalone launch command and gpu-turnstile supervises it: the first -request starts it, it stops again after `COMFY_IDLE_TIMEOUT` (default 5m) -without work, freeing the GPU for games or the LLM. Example for a Desktop -install (run it once manually to confirm it works): +Desktop app kills its server when you close it)? gpu-turnstile can supervise +it: the first request starts it, it stops again after `COMFY_IDLE_TIMEOUT` +(default 5m) without work, freeing the GPU for games or the LLM. + +The easy way — point `COMFY_DIR` at a standard venv install (a folder with +`.venv` and `main.py`, or `.venv` and `ComfyUI\main.py`) and the launch +command is derived from it, including `--port` from `COMFY_URL`: ``` -COMFY_URL=http://127.0.0.1:8188 -COMFY_CMD="C:\ComfyUI\.venv\Scripts\python.exe ComfyUI\main.py --port 8188" +COMFY_URL=http://127.0.0.1:8189 +COMFY_DIR=C:\ComfyUI +``` + +For other layouts, spell the command out yourself (run it once manually to +confirm it works): + +``` +COMFY_URL=http://127.0.0.1:8189 +COMFY_CMD="C:\ComfyUI\.venv\Scripts\python.exe" main.py --port 8189 COMFY_DIR=C:\ComfyUI ``` diff --git a/SPEC.md b/SPEC.md index 4a050a9..8a77097 100644 --- a/SPEC.md +++ b/SPEC.md @@ -128,10 +128,15 @@ state is `idle`, send `POST /api/generate {"model":WARM_MODEL,"keep_alive":-1}` with empty prompt to reload the chat model so the next chat doesn't pay the load time. Off by default. -### Managed ComfyUI (`COMFY_CMD`) +### Managed ComfyUI (`COMFY_CMD` / `COMFY_DIR`) When `COMFY_CMD` is set, gpu-turnstile runs ComfyUI as a supervised child -process instead of expecting an always-on server: +process instead of expecting an always-on server. Setting only `COMFY_DIR` +enables the same management with the launch command derived from the +standard venv layout under it (`.venv\Scripts\python.exe` on Windows, +`.venv/bin/python` on Linux; `ComfyUI\main.py`, or a flat `main.py` when +that is what exists; `--port` from the `COMFY_URL` port). Missing layout +files are flagged in the startup log. - **Start on demand**: any ComfyUI request spawns it (double quotes in the command line group arguments with spaces; `COMFY_DIR` sets the working @@ -211,8 +216,8 @@ override file values. A missing file is fine; a malformed one is fatal. | `LLM_BUSY_STATUS` | `503` | HTTP status for rejected LLM requests in reject mode (400–599, e.g. 429) | | `BUSY_RETRY_AFTER` | `30` | seconds sent as `Retry-After` on busy responses (both modes) | | `WARM_MODEL` | `` | optional model to reload after an image job | -| `COMFY_CMD` | _(empty = unmanaged)_ | spawn and supervise ComfyUI on demand: first request starts it, idle stop after `COMFY_IDLE_TIMEOUT` frees its VRAM. Requires `COMFY_URL` | -| `COMFY_DIR` | `` | working directory for `COMFY_CMD` | +| `COMFY_CMD` | _(derived from `COMFY_DIR`; both empty = unmanaged)_ | spawn and supervise ComfyUI on demand: first request starts it, idle stop after `COMFY_IDLE_TIMEOUT` frees its VRAM. Requires `COMFY_URL` | +| `COMFY_DIR` | `` | standard venv install root: set alone to supervise ComfyUI with the derived launch command (`.venv` + `main.py`, `--port` from `COMFY_URL`); also the working directory for `COMFY_CMD` | | `COMFY_IDLE_TIMEOUT` | `5m` | stop the managed ComfyUI after this long without requests or jobs | | `COMFY_START_TIMEOUT` | `2m` | how long a request waits for the managed ComfyUI to come up | | `GAME_PROCS` | _(empty = disabled)_ | comma-separated process names (case-insensitive, `.exe` optional); while any runs, the GPU counts as held by it: requests wait, Ollama unloads, the managed ComfyUI stops | diff --git a/cmd/gpu-turnstile/main.go b/cmd/gpu-turnstile/main.go index 0c7514d..d4706af 100644 --- a/cmd/gpu-turnstile/main.go +++ b/cmd/gpu-turnstile/main.go @@ -15,6 +15,7 @@ import ( "os" "os/signal" "path/filepath" + "runtime" "strings" "syscall" "time" @@ -434,7 +435,26 @@ func orDisabled(url string) string { return url } +// managedComfyCommand resolves how ComfyUI is launched when it is managed: +// COMFY_CMD verbatim, or the standard venv layout under COMFY_DIR. Empty +// when neither is set (unmanaged). +func managedComfyCommand(cfg config.Config) string { + if cfg.ComfyCmd != "" { + return cfg.ComfyCmd + } + if cfg.ComfyDir != "" { + return supervise.DefaultComfyCommand(runtime.GOOS, cfg.ComfyDir, cfg.ComfyURL) + } + return "" +} + func run(ctx context.Context, cfg config.Config, log *slog.Logger, logOut io.Writer, isService bool) 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) + // so its VRAM is freed. + comfyCmdLine := managedComfyCommand(cfg) + // 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(ctx, slog.LevelWarn, "starting gpu-turnstile", @@ -460,7 +480,7 @@ func run(ctx context.Context, cfg config.Config, log *slog.Logger, logOut io.Wri "backoff_max", cfg.BackoffMax, "prompt_capture_limit", cfg.PromptCaptureLimit, "warm_model", cfg.WarmModel, - "comfy_cmd", cfg.ComfyCmd, + "comfy_cmd", orDisabled(comfyCmdLine), "comfy_dir", cfg.ComfyDir, "comfy_idle_timeout", cfg.ComfyIdleTimeout, "comfy_start_timeout", cfg.ComfyStartTimeout, @@ -494,13 +514,20 @@ func run(ctx context.Context, cfg config.Config, log *slog.Logger, logOut io.Wri } } - // With COMFY_CMD set, ComfyUI runs as a managed child: started on - // demand by the proxy, stopped after COMFY_IDLE_TIMEOUT idle (and on - // shutdown) so its VRAM is freed. var comfySup *supervise.Process - if cfg.ComfyCmd != "" { + if comfyCmdLine != "" { + if cfg.ComfyCmd == "" { + // Derived from COMFY_DIR: flag a wrong-looking layout early, + // while the operator is still watching the startup log. + python, script := supervise.ComfyLayout(runtime.GOOS, cfg.ComfyDir) + for _, p := range []string{python, script} { + if _, err := os.Stat(p); err != nil { + log.Warn("COMFY_DIR: file not found; ComfyUI requests will fail until it exists", "path", p) + } + } + } var err error - comfySup, err = supervise.New("comfy", cfg.ComfyCmd, cfg.ComfyDir, comfyClient.Probe, cfg.ComfyStartTimeout, log) + comfySup, err = supervise.New("comfy", comfyCmdLine, cfg.ComfyDir, comfyClient.Probe, cfg.ComfyStartTimeout, log) if err != nil { return err } diff --git a/internal/config/config.go b/internal/config/config.go index f6f60fe..a8bb670 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -51,11 +51,14 @@ type Config struct { LLMBusyStatus int BusyRetryAfter int - // ComfyCmd spawns and supervises a ComfyUI server on demand (empty = - // unmanaged, the current behavior). ComfyDir is its working directory. - // The managed server is stopped after ComfyIdleTimeout without - // requests, freeing its VRAM; ComfyStartTimeout bounds how long a - // request waits for it to come up. + // ComfyCmd spawns and supervises a ComfyUI server on demand. When + // ComfyCmd is empty but ComfyDir is set, management is enabled with the + // standard venv layout under ComfyDir (.venv + main.py or + // ComfyUI/main.py; --port from the COMFY_URL port) — ComfyCmd is the + // override for other layouts and doubles as the working directory when + // set explicitly. The managed server is stopped after ComfyIdleTimeout + // without requests, freeing its VRAM; ComfyStartTimeout bounds how long + // a request waits for it to come up. ComfyCmd string ComfyDir string ComfyIdleTimeout time.Duration @@ -305,6 +308,9 @@ func Load(getenv func(string) string) (Config, error) { if cfg.ComfyCmd != "" && cfg.ComfyURL == "" { return cfg, fmt.Errorf("COMFY_CMD requires COMFY_URL to be set (the proxy needs somewhere to forward)") } + if cfg.ComfyCmd == "" && cfg.ComfyDir != "" && cfg.ComfyURL == "" { + return cfg, fmt.Errorf("COMFY_DIR without COMFY_CMD requires COMFY_URL to be set (it enables the managed ComfyUI)") + } if cfg.OllamaURL == "" && cfg.ComfyURL == "" { return cfg, ErrNoConsumer } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 076fb96..402ee54 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -222,3 +222,28 @@ func TestGameDetectionSettings(t *testing.T) { t.Fatal("GPUIgnoreProcs default must not be empty") } } + +func TestComfyDirOnlyEnablesManaged(t *testing.T) { + // COMFY_DIR without COMFY_CMD and without COMFY_URL is a mistake. + _, err := Load(func(k string) string { + if k == "COMFY_DIR" { + return `C:\ComfyUI` + } + return "" + }) + if err == nil || !strings.Contains(err.Error(), "COMFY_DIR") { + t.Fatalf("err = %v, want COMFY_DIR/COMFY_URL validation error", err) + } + // With COMFY_URL it loads — the launch command is derived from the dir. + if _, err := Load(func(k string) string { + switch k { + case "COMFY_DIR": + return `C:\ComfyUI` + case "COMFY_URL": + return "http://127.0.0.1:8189" + } + return "" + }); err != nil { + t.Fatalf("COMFY_DIR with COMFY_URL must load: %v", err) + } +} diff --git a/internal/config/sample.go b/internal/config/sample.go index 24a8a3b..3d7f99a 100644 --- a/internal/config/sample.go +++ b/internal/config/sample.go @@ -28,8 +28,8 @@ func sampleEntries(logFile string) []sampleEntry { {"OLLAMA_URL", "http://127.0.0.1:11434", "Ollama upstream URL; setting it enables the Ollama consumer (default: empty = disabled)", false}, {"COMFY_URL", "http://127.0.0.1:8188", "ComfyUI upstream URL; setting it enables the ComfyUI consumer (default: empty = disabled)", false}, {"WARM_MODEL", "", "Optional model to reload after an image job (default: empty = none)", false}, - {"COMFY_CMD", `"C:\ComfyUI\.venv\Scripts\python.exe" ComfyUI\main.py --port 8188`, "Spawn and supervise ComfyUI on demand: the first request starts it, it stops after COMFY_IDLE_TIMEOUT to free VRAM (default: empty = unmanaged)", false}, - {"COMFY_DIR", `C:\ComfyUI`, "Working directory for COMFY_CMD (default: empty = inherit)", false}, + {"COMFY_CMD", `"C:\ComfyUI\.venv\Scripts\python.exe" ComfyUI\main.py --port 8188`, "Spawn and supervise ComfyUI on demand: the first request starts it, it stops after COMFY_IDLE_TIMEOUT to free VRAM (default: derived from COMFY_DIR; both empty = unmanaged)", false}, + {"COMFY_DIR", `C:\ComfyUI`, "Root of a standard ComfyUI venv install (.venv + main.py): setting it alone supervises ComfyUI with the derived launch command; also the working directory for COMFY_CMD", false}, {"COMFY_IDLE_TIMEOUT", "5m", "Stop the managed ComfyUI after this long without requests or jobs (frees VRAM)", false}, {"COMFY_START_TIMEOUT", "2m", "How long a request waits for the managed ComfyUI to come up", false}, {"GAME_PROCS", "cyberpunk2077.exe,hl2.exe", "While a listed process runs, the GPU counts as held by it: requests wait, Ollama unloads, managed ComfyUI stops (default: empty = disabled)", false}, diff --git a/internal/supervise/supervise.go b/internal/supervise/supervise.go index 396a71e..ad13401 100644 --- a/internal/supervise/supervise.go +++ b/internal/supervise/supervise.go @@ -9,13 +9,54 @@ import ( "fmt" "io" "log/slog" + "net/url" + "os" "os/exec" + "path/filepath" "runtime" "strings" "sync" "time" ) +// ComfyLayout returns the interpreter and script path of a standard ComfyUI +// venv install rooted at dir for the given GOOS: .venv\Scripts\python.exe +// on Windows, .venv/bin/python elsewhere. The script is main.py — either in +// a ComfyUI subdirectory or directly under dir, whichever exists (the +// subdirectory form wins ties and is the default when neither exists yet, +// so the caller's missing-file warning points at the documented layout). +func ComfyLayout(goos, dir string) (python, script string) { + if goos == "windows" { + python = filepath.Join(dir, ".venv", "Scripts", "python.exe") + } else { + python = filepath.Join(dir, ".venv", "bin", "python") + } + script = filepath.Join(dir, "ComfyUI", "main.py") + if _, err := os.Stat(script); err != nil { + if _, err := os.Stat(filepath.Join(dir, "main.py")); err == nil { + script = filepath.Join(dir, "main.py") + } + } + return python, script +} + +// DefaultComfyCommand builds the launch command for the standard venv +// layout (see ComfyLayout): the script is passed relative to dir so dir +// stays the working directory, and --port is taken from comfyURL when the +// URL carries one. +func DefaultComfyCommand(goos, dir, comfyURL string) string { + python, script := ComfyLayout(goos, dir) + rel, err := filepath.Rel(dir, script) + if err != nil { + rel = script + } + cmd := `"` + python + `" ` + rel + if u, err := url.Parse(comfyURL); err == nil && u.Port() != "" { + cmd += " --port " + u.Port() + } + return cmd +} + // Process is one managed child process. type Process struct { name string diff --git a/internal/supervise/supervise_test.go b/internal/supervise/supervise_test.go index fa6633b..8df34d4 100644 --- a/internal/supervise/supervise_test.go +++ b/internal/supervise/supervise_test.go @@ -7,6 +7,8 @@ import ( "net/http" "net/http/httptest" "os" + "path/filepath" + "strings" "testing" "time" ) @@ -190,3 +192,50 @@ func TestWatchIdleRespectsBusyGPU(t *testing.T) { t.Fatal("process was stopped while the GPU was busy") } } + +func TestComfyLayoutAndDefaultCommand(t *testing.T) { + // Nested layout (ComfyUI/main.py under dir) wins. + dir := t.TempDir() + nested := filepath.Join(dir, "ComfyUI", "main.py") + if err := os.MkdirAll(filepath.Dir(nested), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(nested, []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + python, script := ComfyLayout("windows", dir) + if want := filepath.Join(dir, ".venv", "Scripts", "python.exe"); python != want { + t.Errorf("python = %s, want %s", python, want) + } + if script != nested { + t.Errorf("script = %s, want %s", script, nested) + } + cmd := DefaultComfyCommand("windows", dir, "http://127.0.0.1:8189") + want := `"` + filepath.Join(dir, ".venv", "Scripts", "python.exe") + `" ` + filepath.Join("ComfyUI", "main.py") + " --port 8189" + if cmd != want { + t.Errorf("cmd = %q, want %q", cmd, want) + } + + // Flat layout (main.py directly under dir) is found too. + flat := t.TempDir() + if err := os.WriteFile(filepath.Join(flat, "main.py"), []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + if _, script := ComfyLayout("linux", flat); script != filepath.Join(flat, "main.py") { + t.Errorf("flat script = %s", script) + } + cmd = DefaultComfyCommand("linux", flat, "http://comfy.internal") + if strings.Contains(cmd, "--port") { + t.Errorf("cmd = %q, want no --port for a port-less URL", cmd) + } + if !strings.HasSuffix(cmd, `" main.py`) { + t.Errorf("cmd = %q, want quoted python + relative main.py", cmd) + } + + // Neither exists yet: default to the documented nested form so the + // startup warning points there. + empty := t.TempDir() + if _, script := ComfyLayout("windows", empty); script != filepath.Join(empty, "ComfyUI", "main.py") { + t.Errorf("missing-layout script = %s", script) + } +}