Files
llmgw/internal/comfy/gate.go
T
Ilker ManapandClaude Opus 5.5 5967e93187 ComfyUI's output goes through the gate, which keeps reading it
ComfyUI wrote straight to the agent's pipe. An agent that restarts and
adopts the replica leaves that pipe with no reader, and ComfyUI's next
progress bar failed with a broken pipe, failing the workflow. It now
writes to a pipe of the gate's, which reads for as long as ComfyUI writes
and passes on what it can.

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

335 lines
9.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"
"os/signal"
"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
}
// ComfyUI writes to a pipe of the gate's, never to what the gate was
// given. That is the agent's, and an agent that restarts and adopts the
// replica leaves it with no reader: ComfyUI's next progress bar would
// fail with a broken pipe, and take the workflow it belongs to with it.
// The gate reads the pipe for as long as ComfyUI writes, and passes on
// what it can.
signal.Ignore(syscall.SIGPIPE)
pr, err := child.StdoutPipe()
if err != nil {
return err
}
child.Stderr = child.Stdout
if err := child.Start(); err != nil {
return fmt.Errorf("starting ComfyUI: %w", err)
}
relayed := make(chan struct{})
go func() { relay(pr, out); close(relayed) }()
exited := make(chan error, 1)
go func() {
// Wait closes the pipe; what is in it is read first.
<-relayed
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
}
// relay copies r to w for as long as r has anything, and goes on reading
// after w fails: a writer that stops reading must not stop the one writing.
func relay(r io.Reader, w io.Writer) {
buf := make([]byte, 32<<10)
broken := false
for {
n, err := r.Read(buf)
if n > 0 && !broken {
if _, werr := w.Write(buf[:n]); werr != nil {
broken = true
}
}
if err != nil {
return
}
}
}