From afa7fe3f69a2a8d5e03399615a0cff31c6c32097 Mon Sep 17 00:00:00 2001 From: Eric Jacksch Date: Thu, 19 Mar 2026 14:50:38 -0400 Subject: [PATCH] fix(launcher): detect and display externally-managed gateway as running TryAutoStartGateway only checked gateway.cmd, which tracks processes the launcher itself spawned. A gateway managed externally (e.g. via systemd) was invisible to this check, causing two problems: 1. The launcher started a duplicate gateway instance on every launch. 2. The WebUI showed "Gateway Not Running" even when it was healthy. Fix: probe the gateway health endpoint in two places: - TryAutoStartGateway: skip auto-start if the health endpoint responds. - gatewayStatusData: report "running" when the launcher has no owned process but the health endpoint is responding. Launcher-owned transition states (restarting/error) take precedence over the probe. Co-Authored-By: Claude Sonnet 4.6 --- web/backend/api/gateway.go | 37 ++++++++++++++++++++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/web/backend/api/gateway.go b/web/backend/api/gateway.go index 1813cac92..55e787119 100644 --- a/web/backend/api/gateway.go +++ b/web/backend/api/gateway.go @@ -72,6 +72,13 @@ func (h *Handler) TryAutoStartGateway() { gateway.cmd = nil } + // Check whether a gateway is already running externally (e.g. via systemd). + // If the health endpoint responds we skip auto-start to avoid a duplicate. + if h.isGatewayHealthy() { + log.Printf("Skip auto-starting gateway: already running externally") + return + } + ready, reason, err := h.gatewayStartReady() if err != nil { log.Printf("Skip auto-starting gateway: %v", err) @@ -117,6 +124,27 @@ func (h *Handler) gatewayStartReady() (bool, string, error) { return true, "", nil } +// isGatewayHealthy probes the gateway health endpoint and returns true if it +// responds with HTTP 200. Used to detect an externally-managed gateway process. +func (h *Handler) isGatewayHealthy() bool { + cfg, err := config.LoadConfig(h.configPath) + if err != nil { + return false + } + host := gatewayProbeHost(h.effectiveGatewayBindHost(cfg)) + port := cfg.Gateway.Port + if port == 0 { + port = 18790 + } + url := fmt.Sprintf("http://%s/health", net.JoinHostPort(host, strconv.Itoa(port))) + resp, err := gatewayHealthGet(url, 1*time.Second) + if err != nil { + return false + } + resp.Body.Close() + return resp.StatusCode == http.StatusOK +} + func lookupModelConfig(cfg *config.Config, modelName string) *config.ModelConfig { modelCfg, err := cfg.GetModelConfig(modelName) if err != nil { @@ -596,8 +624,15 @@ func (h *Handler) gatewayStatusData() map[string]any { if !processAlive { gateway.mu.Lock() - data["gateway_status"] = currentGatewayStatusLocked(false) + launcherStatus := currentGatewayStatusLocked(false) gateway.mu.Unlock() + // Only probe for an external gateway when the launcher has no in-progress + // state (restarting/error). Those states take precedence. + if launcherStatus == "stopped" && h.isGatewayHealthy() { + data["gateway_status"] = "running" + } else { + data["gateway_status"] = launcherStatus + } } else { // Process is alive — probe its health endpoint host := "127.0.0.1"