gpulock: GPU arbitration proxy for Ollama + ComfyUI
Implements SPEC.md: two listeners, one writer-preferring two-mode lock, Ollama unload before image jobs, ComfyUI history polling + VRAM free, optional model warm-up, healthz/metrics endpoints, streaming-safe reverse proxies, Dockerfile and Gitea Actions CI.
This commit is contained in:
@@ -0,0 +1,285 @@
|
||||
// Package proxy contains the HTTP handlers for both gpulock listeners:
|
||||
// reverse proxies to Ollama and ComfyUI with GPU lock arbitration in front
|
||||
// of the endpoints that load models.
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"gpu-turnstile/internal/comfy"
|
||||
"gpu-turnstile/internal/lock"
|
||||
"gpu-turnstile/internal/metrics"
|
||||
"gpu-turnstile/internal/ollama"
|
||||
)
|
||||
|
||||
// captureLimit bounds how much of a /prompt response body is buffered while
|
||||
// looking for prompt_id. The body still passes through to the client
|
||||
// unchanged regardless of size.
|
||||
const captureLimit = 64 * 1024
|
||||
|
||||
// Config wires a Server.
|
||||
type Config struct {
|
||||
OllamaURL string
|
||||
ComfyURL string
|
||||
|
||||
Lock *lock.Lock
|
||||
Ollama *ollama.Client
|
||||
Comfy *comfy.Client
|
||||
Metrics *metrics.Metrics
|
||||
Log *slog.Logger
|
||||
|
||||
LLMWaitTimeout time.Duration
|
||||
UnloadTimeout time.Duration
|
||||
JobTimeout time.Duration
|
||||
WarmModel string
|
||||
}
|
||||
|
||||
// Server serves both gpulock listeners.
|
||||
type Server struct {
|
||||
cfg Config
|
||||
log *slog.Logger
|
||||
|
||||
ollamaProxy *httputil.ReverseProxy
|
||||
comfyProxy *httputil.ReverseProxy
|
||||
}
|
||||
|
||||
// New builds a Server, validating the upstream URLs.
|
||||
func New(cfg Config) (*Server, error) {
|
||||
ollamaURL, err := url.Parse(cfg.OllamaURL)
|
||||
if err != nil || ollamaURL.Scheme == "" || ollamaURL.Host == "" {
|
||||
return nil, fmt.Errorf("invalid OLLAMA_URL %q", cfg.OllamaURL)
|
||||
}
|
||||
comfyURL, err := url.Parse(cfg.ComfyURL)
|
||||
if err != nil || comfyURL.Scheme == "" || comfyURL.Host == "" {
|
||||
return nil, fmt.Errorf("invalid COMFY_URL %q", cfg.ComfyURL)
|
||||
}
|
||||
log := cfg.Log
|
||||
if log == nil {
|
||||
log = slog.Default()
|
||||
}
|
||||
return &Server{
|
||||
cfg: cfg,
|
||||
log: log,
|
||||
ollamaProxy: newReverseProxy(ollamaURL, log.With("upstream", "ollama")),
|
||||
comfyProxy: newReverseProxy(comfyURL, log.With("upstream", "comfy")),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func newReverseProxy(target *url.URL, log *slog.Logger) *httputil.ReverseProxy {
|
||||
return &httputil.ReverseProxy{
|
||||
Rewrite: func(pr *httputil.ProxyRequest) {
|
||||
pr.SetURL(target)
|
||||
pr.SetXForwarded()
|
||||
},
|
||||
// Flush after every write so NDJSON/SSE streams and websocket
|
||||
// upgrades pass through unbuffered.
|
||||
FlushInterval: -1,
|
||||
ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) {
|
||||
log.Warn("upstream error", "path", r.URL.Path, "err", err)
|
||||
http.Error(w, "upstream unavailable", http.StatusBadGateway)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) writeHealthz(w http.ResponseWriter) {
|
||||
state, n, pending := s.cfg.Lock.Snapshot()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"state": state,
|
||||
"llm_inflight": n,
|
||||
"image_pending": pending,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) writeMetrics(w http.ResponseWriter) {
|
||||
state, n, pending := s.cfg.Lock.Snapshot()
|
||||
w.Header().Set("Content-Type", "text/plain; version=0.0.4")
|
||||
s.cfg.Metrics.Render(w, string(state), n, pending)
|
||||
}
|
||||
|
||||
// llmPaths are the Ollama endpoints that load models into VRAM and therefore
|
||||
// take the LLM lock. Everything else passes through unlocked.
|
||||
var llmPaths = map[string]bool{
|
||||
"/api/generate": true,
|
||||
"/api/chat": true,
|
||||
"/api/embed": true,
|
||||
"/api/embeddings": true,
|
||||
"/v1/chat/completions": true,
|
||||
"/v1/completions": true,
|
||||
"/v1/embeddings": true,
|
||||
}
|
||||
|
||||
func isLLMRequest(r *http.Request) bool {
|
||||
return r.Method == http.MethodPost && llmPaths[r.URL.Path]
|
||||
}
|
||||
|
||||
// OllamaHandler serves the Ollama-facing listener.
|
||||
func (s *Server) OllamaHandler() http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/healthz":
|
||||
s.writeHealthz(w)
|
||||
return
|
||||
case "/metrics":
|
||||
s.writeMetrics(w)
|
||||
return
|
||||
}
|
||||
if !isLLMRequest(r) {
|
||||
s.ollamaProxy.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
wctx, cancel := context.WithTimeout(r.Context(), s.cfg.LLMWaitTimeout)
|
||||
err := s.cfg.Lock.AcquireLLM(wctx)
|
||||
cancel()
|
||||
s.cfg.Metrics.ObserveLockWait("llm", time.Since(start).Seconds())
|
||||
if err != nil {
|
||||
if errors.Is(err, context.DeadlineExceeded) && r.Context().Err() == nil {
|
||||
http.Error(w, "GPU busy: timed out waiting for the lock", http.StatusServiceUnavailable)
|
||||
}
|
||||
return
|
||||
}
|
||||
defer s.cfg.Lock.ReleaseLLM()
|
||||
s.ollamaProxy.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// ComfyHandler serves the ComfyUI-facing listener.
|
||||
func (s *Server) ComfyHandler() http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/healthz" {
|
||||
s.writeHealthz(w)
|
||||
return
|
||||
}
|
||||
if r.Method == http.MethodPost && r.URL.Path == "/prompt" {
|
||||
s.handlePrompt(w, r)
|
||||
return
|
||||
}
|
||||
s.comfyProxy.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// captureWriter passes the response through unchanged while recording the
|
||||
// status code and the first captureLimit bytes of the body.
|
||||
type captureWriter struct {
|
||||
http.ResponseWriter
|
||||
status int
|
||||
buf bytes.Buffer
|
||||
}
|
||||
|
||||
func (w *captureWriter) WriteHeader(code int) {
|
||||
w.status = code
|
||||
w.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
|
||||
func (w *captureWriter) Write(p []byte) (int, error) {
|
||||
if w.buf.Len() < captureLimit {
|
||||
w.buf.Write(p)
|
||||
}
|
||||
return w.ResponseWriter.Write(p)
|
||||
}
|
||||
|
||||
func (w *captureWriter) Flush() {
|
||||
if f, ok := w.ResponseWriter.(http.Flusher); ok {
|
||||
f.Flush()
|
||||
}
|
||||
}
|
||||
|
||||
func (w *captureWriter) Unwrap() http.ResponseWriter { return w.ResponseWriter }
|
||||
|
||||
// handlePrompt implements the image job flow from the spec: acquire the
|
||||
// image lock, unload Ollama, forward to ComfyUI, then track the job in the
|
||||
// background and free VRAM before releasing the lock.
|
||||
func (s *Server) handlePrompt(w http.ResponseWriter, r *http.Request) {
|
||||
log := s.log.With("op", "image")
|
||||
|
||||
start := time.Now()
|
||||
if err := s.cfg.Lock.AcquireImage(r.Context()); err != nil {
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
http.Error(w, "GPU busy: timed out waiting for the lock", http.StatusServiceUnavailable)
|
||||
}
|
||||
return
|
||||
}
|
||||
s.cfg.Metrics.ObserveLockWait("image", time.Since(start).Seconds())
|
||||
log.Info("image lock acquired")
|
||||
|
||||
uctx, ucancel := context.WithTimeout(r.Context(), s.cfg.UnloadTimeout)
|
||||
elapsed, uerr := s.cfg.Ollama.UnloadAll(uctx)
|
||||
ucancel()
|
||||
s.cfg.Metrics.ObserveUnload(elapsed.Seconds())
|
||||
switch {
|
||||
case r.Context().Err() != nil:
|
||||
s.cfg.Lock.ReleaseImage()
|
||||
return
|
||||
case uerr != nil:
|
||||
// Degrade, don't fail the user's request on a misbehaving neighbour.
|
||||
log.Warn("ollama unload incomplete; continuing", "err", uerr)
|
||||
default:
|
||||
log.Info("ollama models unloaded", "seconds", elapsed.Seconds())
|
||||
}
|
||||
|
||||
cw := &captureWriter{ResponseWriter: w, status: http.StatusOK}
|
||||
s.comfyProxy.ServeHTTP(cw, r)
|
||||
|
||||
var accepted struct {
|
||||
PromptID string `json:"prompt_id"`
|
||||
}
|
||||
if cw.status == http.StatusOK {
|
||||
_ = json.Unmarshal(cw.buf.Bytes(), &accepted)
|
||||
}
|
||||
if accepted.PromptID == "" {
|
||||
log.Info("prompt not accepted; releasing image lock", "status", cw.status)
|
||||
s.cfg.Lock.ReleaseImage()
|
||||
return
|
||||
}
|
||||
s.cfg.Metrics.IncImageJobs()
|
||||
go s.finishImageJob(accepted.PromptID)
|
||||
}
|
||||
|
||||
// finishImageJob runs after the prompt has been accepted by ComfyUI: wait
|
||||
// for the job to finish, free ComfyUI's models, release the lock, and
|
||||
// optionally warm the chat model.
|
||||
func (s *Server) finishImageJob(promptID string) {
|
||||
log := s.log.With("prompt_id", promptID)
|
||||
log.Info("image job running")
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), s.cfg.JobTimeout)
|
||||
err := s.cfg.Comfy.WaitJob(ctx, promptID)
|
||||
cancel()
|
||||
if err != nil {
|
||||
log.Warn("image job did not complete cleanly; releasing lock anyway", "err", err)
|
||||
} else {
|
||||
log.Info("image job completed")
|
||||
}
|
||||
|
||||
freeCtx, freeCancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
if err := s.cfg.Comfy.Free(freeCtx); err != nil {
|
||||
log.Warn("failed to free ComfyUI models", "err", err)
|
||||
}
|
||||
freeCancel()
|
||||
|
||||
s.cfg.Lock.ReleaseImage()
|
||||
log.Info("image lock released")
|
||||
|
||||
if s.cfg.WarmModel != "" {
|
||||
if state, _, _ := s.cfg.Lock.Snapshot(); state == lock.StateIdle {
|
||||
wctx, wcancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
if err := s.cfg.Ollama.Warm(wctx, s.cfg.WarmModel); err != nil {
|
||||
log.Warn("warm model reload failed", "model", s.cfg.WarmModel, "err", err)
|
||||
} else {
|
||||
log.Info("warm model reloaded", "model", s.cfg.WarmModel)
|
||||
}
|
||||
wcancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gpu-turnstile/internal/comfy"
|
||||
"gpu-turnstile/internal/lock"
|
||||
"gpu-turnstile/internal/metrics"
|
||||
"gpu-turnstile/internal/ollama"
|
||||
)
|
||||
|
||||
// recorder collects upstream call events in order.
|
||||
type recorder struct {
|
||||
mu sync.Mutex
|
||||
events []string
|
||||
}
|
||||
|
||||
func (r *recorder) add(e string) {
|
||||
r.mu.Lock()
|
||||
r.events = append(r.events, e)
|
||||
r.mu.Unlock()
|
||||
}
|
||||
|
||||
func (r *recorder) index(e string) int {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
for i, ev := range r.events {
|
||||
if ev == e {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// fakes wires up fake Ollama and ComfyUI upstreams plus the gpulock server.
|
||||
type fakes struct {
|
||||
rec *recorder
|
||||
ollama *httptest.Server
|
||||
comfy *httptest.Server
|
||||
server *httptest.Server // Ollama-facing gpulock listener
|
||||
comfySrv *httptest.Server // ComfyUI-facing gpulock listener
|
||||
freeCh chan struct{}
|
||||
chatCh chan struct{}
|
||||
historyMu sync.Mutex
|
||||
history string
|
||||
}
|
||||
|
||||
func newFakes(t *testing.T) *fakes {
|
||||
t.Helper()
|
||||
f := &fakes{rec: &recorder{}, freeCh: make(chan struct{}), chatCh: make(chan struct{})}
|
||||
|
||||
var psCalls int
|
||||
var psMu sync.Mutex
|
||||
|
||||
f.ollama = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/api/version":
|
||||
io.WriteString(w, `{"version":"0.0.0"}`)
|
||||
case "/api/ps":
|
||||
psMu.Lock()
|
||||
psCalls++
|
||||
c := psCalls
|
||||
psMu.Unlock()
|
||||
f.rec.add("ps")
|
||||
if c == 1 {
|
||||
io.WriteString(w, `{"models":[{"name":"chat-model"}]}`)
|
||||
} else {
|
||||
io.WriteString(w, `{"models":[]}`)
|
||||
}
|
||||
case "/api/generate":
|
||||
f.rec.add("unload")
|
||||
io.WriteString(w, `{}`)
|
||||
case "/api/chat":
|
||||
f.rec.add("chat")
|
||||
close(f.chatCh)
|
||||
w.Header().Set("Content-Type", "application/x-ndjson")
|
||||
io.WriteString(w, `{"done":true}`+"\n")
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
t.Cleanup(f.ollama.Close)
|
||||
|
||||
f.comfy = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.URL.Path == "/system_stats":
|
||||
io.WriteString(w, `{}`)
|
||||
case r.URL.Path == "/prompt":
|
||||
f.rec.add("prompt")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
io.WriteString(w, `{"prompt_id":"p1"}`)
|
||||
case r.URL.Path == "/history/p1":
|
||||
f.rec.add("history")
|
||||
f.historyMu.Lock()
|
||||
h := f.history
|
||||
f.historyMu.Unlock()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if h == "" {
|
||||
io.WriteString(w, `{}`)
|
||||
} else {
|
||||
fmt.Fprintf(w, `{"p1":{"status":{"completed":%s,"status_str":"success"}}}`, h)
|
||||
}
|
||||
case r.URL.Path == "/free":
|
||||
f.rec.add("free")
|
||||
close(f.freeCh)
|
||||
io.WriteString(w, `{}`)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
t.Cleanup(f.comfy.Close)
|
||||
|
||||
ollamaClient, err := ollama.New(f.ollama.URL, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ollamaClient.PollInterval = 5 * time.Millisecond
|
||||
comfyClient, err := comfy.New(f.comfy.URL, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
comfyClient.PollInterval = 5 * time.Millisecond
|
||||
|
||||
srv, err := New(Config{
|
||||
OllamaURL: f.ollama.URL,
|
||||
ComfyURL: f.comfy.URL,
|
||||
Lock: lock.New(nil),
|
||||
Ollama: ollamaClient,
|
||||
Comfy: comfyClient,
|
||||
Metrics: metrics.New(),
|
||||
LLMWaitTimeout: 2 * time.Second,
|
||||
UnloadTimeout: 2 * time.Second,
|
||||
JobTimeout: 2 * time.Second,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f.server = httptest.NewServer(srv.OllamaHandler())
|
||||
t.Cleanup(f.server.Close)
|
||||
f.comfySrv = httptest.NewServer(srv.ComfyHandler())
|
||||
t.Cleanup(f.comfySrv.Close)
|
||||
return f
|
||||
}
|
||||
|
||||
func (f *fakes) completeJob() {
|
||||
f.historyMu.Lock()
|
||||
f.history = "true"
|
||||
f.historyMu.Unlock()
|
||||
}
|
||||
|
||||
func TestImageJobSequenceAndLLMBlocked(t *testing.T) {
|
||||
f := newFakes(t)
|
||||
|
||||
// Start the image job. The response returns as soon as ComfyUI has
|
||||
// accepted the prompt; history polling and /free run in the background.
|
||||
resp, err := http.Post(f.comfySrv.URL+"/prompt", "application/json", strings.NewReader(`{"workflow":{}}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != 200 || !strings.Contains(string(body), `"prompt_id":"p1"`) {
|
||||
t.Fatalf("prompt response = %d %s", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
// While the image job is running (history not yet complete), an LLM
|
||||
// request must be held.
|
||||
chatDone := make(chan struct{})
|
||||
go func() {
|
||||
defer close(chatDone)
|
||||
resp, err := http.Post(f.server.URL+"/api/chat", "application/json", strings.NewReader(`{}`))
|
||||
if err == nil {
|
||||
io.Copy(io.Discard, resp.Body)
|
||||
resp.Body.Close()
|
||||
}
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-f.chatCh:
|
||||
t.Fatal("chat reached Ollama while image job still running")
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
}
|
||||
|
||||
// Finish the job; the background poller then calls /free, releases the
|
||||
// lock, and the chat request goes through.
|
||||
f.completeJob()
|
||||
select {
|
||||
case <-chatDone:
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("chat request never completed")
|
||||
}
|
||||
|
||||
// Assert the call sequence for one image job.
|
||||
for _, pair := range [][2]string{
|
||||
{"ps", "unload"},
|
||||
{"unload", "prompt"},
|
||||
{"prompt", "history"},
|
||||
{"history", "free"},
|
||||
{"free", "chat"},
|
||||
} {
|
||||
a, b := f.rec.index(pair[0]), f.rec.index(pair[1])
|
||||
if a < 0 || b < 0 || a >= b {
|
||||
f.rec.mu.Lock()
|
||||
t.Fatalf("expected %s before %s; events: %v", pair[0], pair[1], f.rec.events)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPromptRejectedReleasesLock(t *testing.T) {
|
||||
f := newFakes(t)
|
||||
// Make ComfyUI reject the prompt.
|
||||
f.comfy.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/prompt" {
|
||||
f.rec.add("prompt")
|
||||
http.Error(w, "bad workflow", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
})
|
||||
|
||||
resp, err := http.Post(f.comfySrv.URL+"/prompt", "application/json", strings.NewReader(`{}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != 400 || !strings.Contains(string(body), "bad workflow") {
|
||||
t.Fatalf("prompt response = %d %s", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
// The lock must already be free: a chat request goes straight through.
|
||||
resp, err = http.Post(f.server.URL+"/api/chat", "application/json", strings.NewReader(`{}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("chat status = %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamingNotBuffered(t *testing.T) {
|
||||
gate := make(chan struct{})
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/x-ndjson")
|
||||
fl := w.(http.Flusher)
|
||||
io.WriteString(w, `{"chunk":1}`+"\n")
|
||||
fl.Flush()
|
||||
<-gate // hold chunk 2 back until the client has seen chunk 1
|
||||
io.WriteString(w, `{"chunk":2}`+"\n")
|
||||
fl.Flush()
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
srv, err := New(Config{
|
||||
OllamaURL: upstream.URL,
|
||||
ComfyURL: "http://127.0.0.1:1",
|
||||
Lock: lock.New(nil),
|
||||
Metrics: metrics.New(),
|
||||
LLMWaitTimeout: time.Second,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
front := httptest.NewServer(srv.OllamaHandler())
|
||||
defer front.Close()
|
||||
|
||||
resp, err := http.Post(front.URL+"/api/chat", "application/json", strings.NewReader(`{}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// If gpulock buffered the stream, this read would never complete while
|
||||
// the gate is closed.
|
||||
line, err := bufio.NewReader(resp.Body).ReadString('\n')
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(line, `"chunk":1`) {
|
||||
t.Fatalf("first line = %q", line)
|
||||
}
|
||||
close(gate)
|
||||
rest, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(rest), `"chunk":2`) {
|
||||
t.Fatalf("rest = %q", rest)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealthzAndMetrics(t *testing.T) {
|
||||
f := newFakes(t)
|
||||
|
||||
for _, base := range []string{f.server.URL, f.comfySrv.URL} {
|
||||
resp, err := http.Get(base + "/healthz")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != 200 || !strings.Contains(string(body), `"state":"idle"`) {
|
||||
t.Fatalf("healthz = %d %s", resp.StatusCode, body)
|
||||
}
|
||||
}
|
||||
|
||||
resp, err := http.Get(f.server.URL + "/metrics")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
text := string(body)
|
||||
for _, want := range []string{
|
||||
`gpulock_state{state="idle"} 1`,
|
||||
"gpulock_llm_inflight 0",
|
||||
"gpulock_image_jobs_total 0",
|
||||
`gpulock_lock_wait_seconds_bucket{kind="llm",le="+Inf"} 0`,
|
||||
"gpulock_unload_seconds_count{} 0",
|
||||
} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Fatalf("metrics missing %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPassThroughNoLock(t *testing.T) {
|
||||
f := newFakes(t)
|
||||
resp, err := http.Get(f.server.URL + "/api/version")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != 200 || !strings.Contains(string(body), "0.0.0") {
|
||||
t.Fatalf("pass-through = %d %s", resp.StatusCode, body)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user