From 14120bf4a4a5d02fc9015773490252d2873eee7b Mon Sep 17 00:00:00 2001 From: mram Date: Mon, 21 Sep 2026 16:06:28 +0200 Subject: [PATCH] Supervisor probes before spawning: an external server on the port is used, never fought or killed --- README.md | 6 +++++ SPEC.md | 6 +++++ internal/supervise/supervise.go | 18 +++++++++++++- internal/supervise/supervise_test.go | 36 +++++++++++++++++++++++++++- 4 files changed, 64 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index f662b7e..8d8a827 100644 --- a/README.md +++ b/README.md @@ -112,6 +112,12 @@ COMFY_DIR=C:\ComfyUI (LLM traffic keeps flowing while torch loads); crashes are logged and the next request respawns. Shutting gpu-turnstile down stops the child too. +Running the ComfyUI Desktop app alongside is safe: if something already +answers on the port, gpu-turnstile just uses it instead of spawning +(and never kills it — it only ever stops its own child). If the managed +instance already holds the port when you open the desktop app, the +desktop's server is the one that fails to bind. + ## Build and run ```sh diff --git a/SPEC.md b/SPEC.md index a48d71b..2dffb38 100644 --- a/SPEC.md +++ b/SPEC.md @@ -138,6 +138,12 @@ process instead of expecting an always-on server: freeing the VRAM ComfyUI holds. The next request restarts it. - **Crash**: an unexpected exit is logged; the next request respawns. gpu-turnstile's own shutdown stops the child too. +- **Coexistence**: before spawning, the URL is probed — if another server + already answers (e.g. the ComfyUI desktop app), it is used as-is and no + child is spawned; the idle watcher and shutdown only ever stop the + supervisor's own process, never the external one. The other direction — + starting the desktop app while the managed instance holds the port — + makes the *desktop* server fail to bind; gpu-turnstile is unaffected. - Its stdout/stderr is forwarded to the log at INFO. The health check skips the intentionally-stopped/starting states; a failed probe while the process is alive and was previously ready is logged as DOWN. diff --git a/internal/supervise/supervise.go b/internal/supervise/supervise.go index 512fef0..396a71e 100644 --- a/internal/supervise/supervise.go +++ b/internal/supervise/supervise.go @@ -29,6 +29,7 @@ type Process struct { cmd *exec.Cmd stopping bool ready bool + external bool // someone else serves the port; not our process lastActivity time.Time } @@ -118,7 +119,10 @@ func (p *Process) NoteActivity() { // EnsureRunning starts the child if it is not running. It returns as soon // as the process is spawned; readiness is WaitReady's job (and the proxy's -// retry backoff bridges the gap for plain proxied requests). +// retry backoff bridges the gap for plain proxied requests). When the URL +// already answers — e.g. the ComfyUI desktop app grabbed the port — no +// child is spawned: the external server is used as-is, and the idle +// watcher never touches it (it only kills its own child). func (p *Process) EnsureRunning() error { p.mu.Lock() defer p.mu.Unlock() @@ -126,6 +130,18 @@ func (p *Process) EnsureRunning() error { if p.cmd != nil { return nil } + pctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + err := p.probe(pctx) + cancel() + if err == nil { + p.ready = true + if !p.external { + p.external = true + p.log.Info(p.name + " is already served externally; not spawning a managed instance") + } + return nil + } + p.external = false cmd := exec.Command(p.argv[0], p.argv[1:]...) cmd.Dir = p.dir stdout, err := cmd.StdoutPipe() diff --git a/internal/supervise/supervise_test.go b/internal/supervise/supervise_test.go index bd183d8..fa6633b 100644 --- a/internal/supervise/supervise_test.go +++ b/internal/supervise/supervise_test.go @@ -57,8 +57,10 @@ func TestHelperProcess(t *testing.T) { func newHelper(t *testing.T, name string) *Process { t.Helper() + // The probe always fails: nothing external serves the port, so + // EnsureRunning spawns the helper child. p, err := New(name, `"`+os.Args[0]+`" -test.run=TestHelperProcess`, "", - func(context.Context) error { return nil }, 5*time.Second, slog.Default()) + func(context.Context) error { return errors.New("nothing there") }, 5*time.Second, slog.Default()) if err != nil { t.Fatal(err) } @@ -103,6 +105,38 @@ func TestEnsureRunningAndStop(t *testing.T) { waitStopped(t, p, 5*time.Second) } +func TestEnsureRunningPrefersExternalServer(t *testing.T) { + // The port is already served (e.g. the ComfyUI desktop app): no child + // is spawned, the supervisor reports ready, and Stop is a no-op. + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + probe := func(ctx context.Context) error { + req, _ := http.NewRequestWithContext(ctx, http.MethodGet, srv.URL, nil) + resp, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + resp.Body.Close() + return nil + } + p, err := New("external", `"`+os.Args[0]+`"`, "", probe, 5*time.Second, slog.Default()) + if err != nil { + t.Fatal(err) + } + if err := p.EnsureRunning(); err != nil { + t.Fatal(err) + } + if p.Running() { + t.Fatal("spawned a child even though the port is already served") + } + if !p.Ready() { + t.Fatal("external server should count as ready") + } + p.Stop() // must not touch the external server +} + func TestWaitReady(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK)