Files
llmgw/internal/comfy/gate.go
T
Ilker ManapandClaude Opus 5.5 354461d3bf The gate makes the directories ComfyUI dies without
ComfyUI lists its base directory's custom_nodes before anything else and
exits when there is none.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 00:55:23 +02:00

299 lines
8.9 KiB
Go

package comfy
import (
"context"
"crypto/subtle"
"errors"
"fmt"
"io"
"log/slog"
"net"
"net/http"
"net/http/httputil"
"net/url"
"os"
"os/exec"
"path/filepath"
"regexp"
"strconv"
"strings"
"syscall"
"time"
)
// The gate is how a node runs ComfyUI as one of llmgw's engines.
//
// ComfyUI asks nobody for a key: whoever reaches its port can queue work,
// upload files and install nodes. Every other engine here demands the key
// the control plane made for its replica, so ComfyUI is put behind a gate
// that does: ComfyUI listens on the loopback only, and the gate on the
// replica's port, letting through only the requests the router makes. The
// gate also answers what the agent asks every engine — /health once
// ComfyUI is up, and /v1/models naming the model — and lays the weights out
// where ComfyUI looks for them.
//
// ComfyUI runs as the gate's child, in its process group, so stopping the
// replica stops both, the card memory ComfyUI holds is counted as the
// replica's, and either one exiting ends the other.
// GateConfig is one replica's gate.
type GateConfig struct {
// Listen is the replica's address, the one the router is given.
Listen string
// Model is the name the agent asks /v1/models for.
Model string
// Key is what every request must carry as a bearer token; empty lets
// every request through, as an engine started without one does.
Key string
// Weights is the model as the agent fetched it: a file, or a directory
// of files.
Weights string
// State is the replica's own directory: ComfyUI's base directory, with
// its output, temp and user directories, and the models it is shown.
State string
// Python runs ComfyUI's main.py, found in Dir.
Python, Dir string
// Args are passed on to main.py, after the gate's own.
Args []string
// Output is where ComfyUI's output goes.
Output io.Writer
Log *slog.Logger
}
// The requests the router makes of ComfyUI: queueing, waiting, fetching and
// cancelling. Nothing that uploads, installs or changes ComfyUI.
var gatePaths = []string{"/prompt", "/history/", "/view", "/queue", "/interrupt", "/system_stats", "/object_info"}
// modelFolders are the kinds of file ComfyUI's loaders look for. Each is
// shown the same directory: a workflow names its files, so a file being
// listed where it is not wanted costs nothing.
var modelFolders = []string{"checkpoints", "diffusion_models", "text_encoders", "vae", "loras",
"clip_vision", "controlnet", "upscale_models", "embeddings", "style_models"}
// RunGate lays out the replica, starts ComfyUI and serves the gate until
// ctx ends or ComfyUI exits.
func RunGate(ctx context.Context, cfg GateConfig) error {
log := cfg.Log
if log == nil {
log = slog.Default()
}
state, err := filepath.Abs(cfg.State)
if err != nil {
return err
}
// What ComfyUI expects to find in its base directory, and dies
// without: custom_nodes is listed before anything else is done.
for _, d := range []string{"custom_nodes", "input", "output", "temp", "user"} {
if err := os.MkdirAll(filepath.Join(state, d), 0o755); err != nil {
return err
}
}
models := filepath.Join(state, "models")
if err := LinkWeights(cfg.Weights, models); err != nil {
return err
}
extra := filepath.Join(state, "extra_model_paths.yaml")
if err := os.WriteFile(extra, []byte(extraModelPaths(models)), 0o644); err != nil { //nolint:gosec // not secret
return err
}
inner, err := freePort()
if err != nil {
return err
}
args := append([]string{filepath.Join(cfg.Dir, "main.py"),
"--listen", "127.0.0.1", "--port", strconv.Itoa(inner),
"--base-directory", state, "--extra-model-paths-config", extra,
"--disable-auto-launch"}, cfg.Args...)
child := exec.Command(cfg.Python, args...) //nolint:gosec // the agent's own configuration
child.Dir = cfg.Dir
child.Env = os.Environ()
out := cfg.Output
if out == nil {
out = os.Stdout
}
child.Stdout, child.Stderr = out, out
if err := child.Start(); err != nil {
return fmt.Errorf("starting ComfyUI: %w", err)
}
exited := make(chan error, 1)
go func() { exited <- child.Wait() }()
upstream := &url.URL{Scheme: "http", Host: net.JoinHostPort("127.0.0.1", strconv.Itoa(inner))}
srv := &http.Server{
Addr: cfg.Listen,
Handler: NewGate(upstream, cfg.Key, cfg.Model),
ReadHeaderTimeout: 10 * time.Second,
}
served := make(chan error, 1)
go func() { served <- srv.ListenAndServe() }()
log.Info("ComfyUI gate up", "listen", cfg.Listen, "comfyui", upstream.Host, "weights", cfg.Weights)
stop := func() {
_ = child.Process.Signal(syscall.SIGTERM)
select {
case <-exited:
case <-time.After(20 * time.Second):
_ = child.Process.Kill()
<-exited
}
}
select {
case <-ctx.Done():
shutdown(srv)
stop()
return nil
case err := <-exited:
shutdown(srv)
if err == nil {
err = errors.New("exited")
}
return fmt.Errorf("ComfyUI %w", err)
case err := <-served:
stop()
return fmt.Errorf("the gate stopped serving: %w", err)
}
}
func shutdown(srv *http.Server) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = srv.Shutdown(ctx)
}
// NewGate is the gate's handler, in front of the ComfyUI at upstream.
func NewGate(upstream *url.URL, key, model string) http.Handler {
proxy := httputil.NewSingleHostReverseProxy(upstream)
base := proxy.Director
proxy.Director = func(r *http.Request) {
base(r)
r.Header.Del("Authorization")
// ComfyUI refuses a request whose Origin is not its own host; the
// router sends none, and nothing else gets this far.
r.Header.Del("Origin")
r.Host = upstream.Host
}
client := &http.Client{Timeout: 3 * time.Second}
up := func(ctx context.Context) error {
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, upstream.String()+"/system_stats", nil)
resp, err := client.Do(req)
if err != nil {
return err
}
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("ComfyUI answered %s", resp.Status)
}
return nil
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if key != "" {
got, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ")
if !ok || subtle.ConstantTimeCompare([]byte(got), []byte(key)) != 1 {
http.Error(w, "this engine wants its key", http.StatusUnauthorized)
return
}
}
switch r.URL.Path {
case "/health":
if err := up(r.Context()); err != nil {
http.Error(w, "ComfyUI is not up yet: "+err.Error(), http.StatusServiceUnavailable)
return
}
w.WriteHeader(http.StatusOK)
return
case "/v1/models":
if err := up(r.Context()); err != nil {
http.Error(w, "ComfyUI is not up yet: "+err.Error(), http.StatusServiceUnavailable)
return
}
w.Header().Set("Content-Type", "application/json")
fmt.Fprintf(w, `{"object":"list","data":[{"id":%q,"object":"model","owned_by":"comfyui"}]}`, model)
return
}
for _, p := range gatePaths {
if r.URL.Path == p || strings.HasSuffix(p, "/") && strings.HasPrefix(r.URL.Path, p) {
proxy.ServeHTTP(w, r)
return
}
}
http.NotFound(w, r)
})
}
// cachedName is a file the agent fetched by itself: its cache names it by
// the source's hash, then the name it had.
var cachedName = regexp.MustCompile(`^[0-9a-f]{16}-`)
// FileName is what a weights file is called where ComfyUI sees it: its own
// name, without the prefix the agent's cache gives it.
func FileName(path string) string {
return cachedName.ReplaceAllString(filepath.Base(path), "")
}
// LinkWeights shows ComfyUI the weights: a link in dir to the file, or to
// every file of the directory. Links made before are taken away first, so
// a replica started again on other weights sees only those.
func LinkWeights(weights, dir string) error {
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
old, err := os.ReadDir(dir)
if err != nil {
return err
}
for _, e := range old {
if e.Type()&os.ModeSymlink != 0 {
_ = os.Remove(filepath.Join(dir, e.Name()))
}
}
weights, err = filepath.Abs(weights)
if err != nil {
return err
}
info, err := os.Stat(weights)
if err != nil {
return fmt.Errorf("the weights: %w", err)
}
files := []string{weights}
if info.IsDir() {
entries, err := os.ReadDir(weights)
if err != nil {
return err
}
files = files[:0]
for _, e := range entries {
if e.Type().IsRegular() && !strings.HasPrefix(e.Name(), ".") {
files = append(files, filepath.Join(weights, e.Name()))
}
}
if len(files) == 0 {
return fmt.Errorf("the weights directory %s has no files", weights)
}
}
for _, f := range files {
if err := os.Symlink(f, filepath.Join(dir, FileName(f))); err != nil {
return err
}
}
return nil
}
func extraModelPaths(models string) string {
var b strings.Builder
fmt.Fprintf(&b, "llmgw:\n base_path: %q\n is_default: true\n", models)
for _, f := range modelFolders {
fmt.Fprintf(&b, " %s: .\n", f)
}
return b.String()
}
func freePort() (int, error) {
l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
return 0, err
}
defer l.Close()
return l.Addr().(*net.TCPAddr).Port, nil
}