From 382bff3c3a39ba0f3d6060c7e896651a2ec09b65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Mon, 24 Aug 2026 15:52:42 +0000 Subject: [PATCH] fix(launcher): make startup health checks bind-aware (#2981) --- apps/backend/internal/launcher/dev.go | 48 +-- apps/backend/internal/launcher/dev_test.go | 30 +- apps/backend/internal/launcher/health.go | 299 ++++++++++++++++-- apps/backend/internal/launcher/health_test.go | 252 ++++++++++++++- apps/backend/internal/launcher/network.go | 105 ++++++ .../backend/internal/launcher/network_test.go | 82 +++++ apps/backend/internal/launcher/run.go | 7 + apps/backend/internal/launcher/start.go | 29 +- apps/backend/internal/launcher/start_test.go | 38 ++- .../internal/launcher/startup_failure.go | 169 ++++++++++ .../internal/launcher/startup_failure_test.go | 122 +++++++ docs/plans/launcher-startup-recovery/plan.md | 104 ++++++ .../task-01-bind-aware-readiness.md | 101 ++++++ .../task-02-actionable-startup-failures.md | 97 ++++++ .../task-03-startup-proxy-guidance.md | 89 ++++++ docs/public/cli.md | 26 +- docs/public/configuration.md | 21 +- docs/specs/launcher/README.md | 57 ++++ .../launcher/requirements/startup-recovery.md | 104 ++++++ .../system-design/startup-recovery.md | 179 +++++++++++ 20 files changed, 1872 insertions(+), 87 deletions(-) create mode 100644 apps/backend/internal/launcher/startup_failure.go create mode 100644 apps/backend/internal/launcher/startup_failure_test.go create mode 100644 docs/plans/launcher-startup-recovery/plan.md create mode 100644 docs/plans/launcher-startup-recovery/task-01-bind-aware-readiness.md create mode 100644 docs/plans/launcher-startup-recovery/task-02-actionable-startup-failures.md create mode 100644 docs/plans/launcher-startup-recovery/task-03-startup-proxy-guidance.md create mode 100644 docs/specs/launcher/README.md create mode 100644 docs/specs/launcher/requirements/startup-recovery.md create mode 100644 docs/specs/launcher/system-design/startup-recovery.md diff --git a/apps/backend/internal/launcher/dev.go b/apps/backend/internal/launcher/dev.go index d371a978e3..4caa758652 100644 --- a/apps/backend/internal/launcher/dev.go +++ b/apps/backend/internal/launcher/dev.go @@ -62,11 +62,15 @@ func runDev(ctx context.Context, opts Options) int { } fmt.Println("[kandev] starting backend...") - if err := waitForHealthFn(ctx, cfg.ports.BackendURL, backend, healthTimeoutForConfig(healthTimeoutDevMS, cfg.startup), healthToken, dumpLogs); err != nil { + readyURL, err := waitForHealthFn(ctx, cfg.endpoints, backend, healthTimeoutForConfig(healthTimeoutDevMS, cfg.startup), healthToken, dumpLogs) + if err != nil { supervisor.shutdown("backend health failure") - fmt.Fprintln(os.Stderr, "[kandev] "+err.Error()) + fmt.Fprintln(os.Stderr, formatStartupFailure(err, cfg.endpoints, cfg.startup, backendLogPathForDevConfig(cfg), startupFailureStoppedBackend(err))) return 1 } + if readyURL != "" { + cfg.ports.BackendURL = readyURL + } fmt.Printf("[kandev] backend ready at %s\n", cfg.ports.BackendURL) webURL := fmt.Sprintf("http://localhost:%d", cfg.ports.WebPort) @@ -93,14 +97,15 @@ func runDev(ctx context.Context, opts Options) int { } type devLaunchConfig struct { - repoRoot string - ports portConfig - dbPath string - extra []string - logLevel string - debug bool - homeDir string - startup *config.Config + repoRoot string + ports portConfig + dbPath string + extra []string + logLevel string + debug bool + homeDir string + startup *config.Config + endpoints backendEndpointSet } // devLaunchConfigFor resolves everything runDev needs before any process is @@ -136,20 +141,27 @@ func devLaunchConfigFor(opts Options, configs ...*config.Config) (devLaunchConfi fmt.Fprintln(os.Stderr, "[kandev] "+err.Error()) return devLaunchConfig{}, 1 } + endpoints, err := resolveBackendEndpoints(startupConfig, ports.BackendPort) + if err != nil { + fmt.Fprintln(os.Stderr, "[kandev] "+err.Error()) + return devLaunchConfig{}, 1 + } + ports.BackendURL = endpoints.accessURL dbPath, extra := resolveDevBackendEnv(repoRoot, startupConfig) homeDir := devKandevHome(repoRoot) if !isInsideKandevTask(repoRoot) && configSourceIsExplicit(startupConfig, "homeDir") { homeDir = startupConfig.ResolvedHomeDir() } return devLaunchConfig{ - repoRoot: repoRoot, - ports: ports, - dbPath: dbPath, - extra: extra, - logLevel: resolveLogLevelForConfig(opts, startupConfig), - debug: opts.Debug, - homeDir: homeDir, - startup: startupConfig, + repoRoot: repoRoot, + ports: ports, + dbPath: dbPath, + extra: extra, + logLevel: resolveLogLevelForConfig(opts, startupConfig), + debug: opts.Debug, + homeDir: homeDir, + startup: startupConfig, + endpoints: endpoints, }, 0 } diff --git a/apps/backend/internal/launcher/dev_test.go b/apps/backend/internal/launcher/dev_test.go index 811c9d6b33..10d2984b34 100644 --- a/apps/backend/internal/launcher/dev_test.go +++ b/apps/backend/internal/launcher/dev_test.go @@ -22,6 +22,7 @@ func TestRunDevLaunchesBackendThenWebAndOpensBrowser(t *testing.T) { t.Setenv("KANDEV_TASK_ID", "") t.Setenv("KANDEV_DATABASE_PATH", "") t.Setenv("KANDEV_HOME_DIR", "") + t.Setenv("KANDEV_SERVER_HOST", "0.0.0.0") oldNewSupervisor := newSupervisorFn oldAttachSignals := attachSignalsFn @@ -42,6 +43,7 @@ func TestRunDevLaunchesBackendThenWebAndOpensBrowser(t *testing.T) { var events []string var backendCfg backendLaunchConfig + var readyURL string var webCmd, webArgs []string var webCWD, webEnvJoined string var webLabel string @@ -59,9 +61,10 @@ func TestRunDevLaunchesBackendThenWebAndOpensBrowser(t *testing.T) { exitCh <- 0 return &restartableBackend{exitCh: exitCh}, func() {}, nil } - waitForHealthFn = func(_ context.Context, url string, _ childState, _ time.Duration, _ string, _ func()) error { - events = append(events, "wait-health:"+url) - return nil + waitForHealthFn = func(_ context.Context, endpoints backendEndpointSet, _ childState, _ time.Duration, _ string, _ func()) (string, error) { + events = append(events, "wait-health:"+endpoints.accessURL) + readyURL = "http://127.0.0.2:" + itoa(backendCfg.Ports.BackendPort) + return readyURL, nil } waitForURLFn = func(_ context.Context, url string, _ childState, _ time.Duration) error { events = append(events, "wait-url:"+url) @@ -91,7 +94,7 @@ func TestRunDevLaunchesBackendThenWebAndOpensBrowser(t *testing.T) { "wait-health:http://localhost:" + itoa(backendCfg.Ports.BackendPort), "start-web:web", "wait-url:http://localhost:" + itoa(backendCfg.Ports.WebPort), - "open:http://localhost:" + itoa(backendCfg.Ports.BackendPort), + "open:" + readyURL, } if !reflect.DeepEqual(events, want) { t.Fatalf("events = %v, want %v", events, want) @@ -132,7 +135,7 @@ func TestRunDevLaunchesBackendThenWebAndOpensBrowser(t *testing.T) { t.Fatalf("web args = %v, want the Vite dev command", webArgs) } for _, expected := range []string{ - "KANDEV_API_BASE_URL=http://localhost:" + itoa(backendCfg.Ports.BackendPort), + "KANDEV_API_BASE_URL=" + readyURL, "PORT=" + itoa(backendCfg.Ports.WebPort), "HOSTNAME=127.0.0.1", "KANDEV_DEBUG=true", @@ -151,6 +154,7 @@ func TestRunDevLaunchesBackendThenWebAndOpensBrowser(t *testing.T) { } func TestRunDevUsesConfiguredHealthTimeoutForBackendAndWeb(t *testing.T) { + clearLauncherConfigurationEnvironment(t) repo := makeRepoTree(t) t.Chdir(repo) writeLauncherConfig(t, filepath.Join(repo, "config.yaml"), "launcher:\n healthTimeoutMs: 12345\n") @@ -183,9 +187,9 @@ func TestRunDevUsesConfiguredHealthTimeoutForBackendAndWeb(t *testing.T) { return &restartableBackend{exitCh: exitCh}, func() {}, nil } var backendTimeout, webTimeout time.Duration - waitForHealthFn = func(_ context.Context, _ string, _ childState, timeout time.Duration, _ string, _ func()) error { + waitForHealthFn = func(_ context.Context, _ backendEndpointSet, _ childState, timeout time.Duration, _ string, _ func()) (string, error) { backendTimeout = timeout - return nil + return "", nil } waitForURLFn = func(_ context.Context, _ string, _ childState, timeout time.Duration) error { webTimeout = timeout @@ -234,8 +238,8 @@ func TestRunDevHeadlessSkipsBrowser(t *testing.T) { exitCh <- 0 return &restartableBackend{exitCh: exitCh}, func() {}, nil } - waitForHealthFn = func(_ context.Context, _ string, _ childState, _ time.Duration, _ string, _ func()) error { - return nil + waitForHealthFn = func(_ context.Context, _ backendEndpointSet, _ childState, _ time.Duration, _ string, _ func()) (string, error) { + return "", nil } waitForURLFn = func(_ context.Context, _ string, _ childState, _ time.Duration) error { return nil @@ -316,8 +320,8 @@ func TestRunDevShutsDownTreeOnBackendHealthFailure(t *testing.T) { launchBackendFn = func(_ backendLaunchConfig) (*restartableBackend, func(), error) { return &restartableBackend{exitCh: make(chan int, 1)}, func() {}, nil } - waitForHealthFn = func(_ context.Context, _ string, _ childState, _ time.Duration, _ string, _ func()) error { - return errTestHealth + waitForHealthFn = func(_ context.Context, _ backendEndpointSet, _ childState, _ time.Duration, _ string, _ func()) (string, error) { + return "", errTestHealth } waitForURLFn = func(_ context.Context, _ string, _ childState, _ time.Duration) error { t.Fatal("waitForURL called after backend health failure") @@ -374,8 +378,8 @@ func TestRunDevShutsDownTreeWhenWebChildExits(t *testing.T) { launchBackendFn = func(_ backendLaunchConfig) (*restartableBackend, func(), error) { return &restartableBackend{exitCh: make(chan int, 1)}, func() {}, nil } - waitForHealthFn = func(_ context.Context, _ string, _ childState, _ time.Duration, _ string, _ func()) error { - return nil + waitForHealthFn = func(_ context.Context, _ backendEndpointSet, _ childState, _ time.Duration, _ string, _ func()) (string, error) { + return "", nil } waitForURLFn = func(_ context.Context, _ string, _ childState, _ time.Duration) error { return nil diff --git a/apps/backend/internal/launcher/health.go b/apps/backend/internal/launcher/health.go index 239ab09905..4495d0fb8b 100644 --- a/apps/backend/internal/launcher/health.go +++ b/apps/backend/internal/launcher/health.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "io" + "net" "net/http" "net/url" "os" @@ -22,13 +23,100 @@ const ( // healthProbeClient bounds every individual health request. http.DefaultClient // has no timeout, so a backend that accepts the connection but never answers -// would otherwise block the launcher forever. -var healthProbeClient = &http.Client{Timeout: healthProbeTimeout} +// would otherwise block the launcher forever. Launcher readiness is local to +// the process, so probes must not be redirected through an inherited proxy. +var healthProbeClient = newHealthProbeClient() + +func newHealthProbeClient() *http.Client { + transport, ok := http.DefaultTransport.(*http.Transport) + if ok { + transport = transport.Clone() + } else { + transport = &http.Transport{} + } + transport.Proxy = nil + return &http.Client{Timeout: healthProbeTimeout, Transport: transport} +} type childState interface { Exited() (bool, int) } +type healthOutcome string + +const ( + healthOutcomeHealthy healthOutcome = "healthy" + healthOutcomeConnectionError healthOutcome = "connection_error" + healthOutcomeHTTPStatus healthOutcome = "http_status" + healthOutcomeForeignProcess healthOutcome = "foreign_process" +) + +type healthObservation struct { + URL string + Outcome healthOutcome + StatusCode int + SafeDetail string +} + +type healthFailureClass string + +const ( + healthFailureEarlyExit healthFailureClass = "early_exit" + healthFailureUnreachable healthFailureClass = "unreachable_backend" + healthFailureUnhealthyHTTP healthFailureClass = "unhealthy_http" + healthFailureForeign healthFailureClass = "foreign_process" + healthFailureCanceled healthFailureClass = "canceled" +) + +type backendHealthError struct { + Class healthFailureClass + EndpointSet backendEndpointSet + Observations []healthObservation + Timeout time.Duration + ChildExited bool + ChildExitCode int + Cause error +} + +func (e *backendHealthError) Error() string { + switch e.Class { + case healthFailureEarlyExit: + return fmt.Sprintf("backend exited (code %d) before healthcheck passed", e.ChildExitCode) + case healthFailureCanceled: + if e.EndpointSet.accessURL != "" { + return fmt.Sprintf("backend healthcheck canceled at %s: %v", e.EndpointSet.accessURL, e.Cause) + } + return fmt.Sprintf("backend healthcheck canceled: %v", e.Cause) + case healthFailureForeign: + for _, observation := range e.Observations { + if observation.Outcome == healthOutcomeForeignProcess { + return fmt.Sprintf( + "backend port %s answered a health check from a different process "+ + "(missing/mismatched launcher token). Another Kandev instance may already own it, "+ + "or the runtime bundle predates v0.66.0", + healthPort(observation.URL), + ) + } + } + case healthFailureUnhealthyHTTP: + for _, observation := range e.Observations { + if observation.Outcome == healthOutcomeHTTPStatus { + return fmt.Sprintf("backend healthcheck timed out after %s at %s (HTTP status %d)", + e.Timeout, observation.URL, observation.StatusCode) + } + } + } + target := e.EndpointSet.accessURL + if target == "" && len(e.EndpointSet.healthTargets) > 0 { + target = e.EndpointSet.healthTargets[0] + } + return fmt.Sprintf("backend healthcheck timed out after %s at %s", e.Timeout, target) +} + +func (e *backendHealthError) Unwrap() error { + return e.Cause +} + func healthTimeout(defaultMS int) time.Duration { raw := os.Getenv("KANDEV_HEALTH_TIMEOUT_MS") if raw == "" { @@ -53,68 +141,213 @@ func healthTimeoutForConfig(defaultMS int, cfg *config.Config) time.Duration { return healthTimeout(defaultMS) } -func waitForHealth(ctx context.Context, baseURL string, proc childState, timeout time.Duration, expectedToken string, onFailure func()) error { +func waitForHealth(ctx context.Context, endpoints backendEndpointSet, proc childState, timeout time.Duration, expectedToken string, onFailure func()) (string, error) { ctx, cancel := context.WithTimeout(ctx, timeout) defer cancel() + if len(endpoints.healthTargets) == 0 && endpoints.accessURL != "" { + endpoints.healthTargets = []string{strings.TrimRight(endpoints.accessURL, "/") + "/health"} + } + if len(endpoints.healthTargets) == 0 { + err := &backendHealthError{ + Class: healthFailureUnreachable, + EndpointSet: endpoints, + Timeout: timeout, + Cause: errors.New("no health targets configured"), + } + return "", finishHealthFailure(err, onFailure) + } - healthURL := baseURL + "/health" - sawForeignProcessResponse := false + latest := make(map[string]healthObservation, len(endpoints.healthTargets)) for ctx.Err() == nil { if exited, code := proc.Exited(); exited { - if onFailure != nil { - onFailure() + err := &backendHealthError{ + Class: healthFailureEarlyExit, + EndpointSet: endpoints, + Observations: observationsInTargetOrder(endpoints.healthTargets, latest), + Timeout: timeout, + ChildExited: true, + ChildExitCode: code, } - return fmt.Errorf("backend exited (code %d) before healthcheck passed", code) + return "", finishHealthFailure(err, onFailure) } - healthy, foreign := probeHealth(ctx, healthURL, expectedToken) - if foreign { - sawForeignProcessResponse = true + + observations, healthyURL := probeHealthTargets(ctx, endpoints.healthTargets, expectedToken) + for _, observation := range observations { + latest[observation.URL] = observation } - if healthy { - return nil + if healthyURL != "" { + return endpoints.browserURLForHealthTarget(healthyURL), nil + } + if exited, code := proc.Exited(); exited { + err := &backendHealthError{ + Class: healthFailureEarlyExit, + EndpointSet: endpoints, + Observations: observationsInTargetOrder(endpoints.healthTargets, latest), + Timeout: timeout, + ChildExited: true, + ChildExitCode: code, + } + return "", finishHealthFailure(err, onFailure) } select { case <-ctx.Done(): case <-time.After(healthPollInterval): } } + + observations := observationsInTargetOrder(endpoints.healthTargets, latest) + if errors.Is(ctx.Err(), context.Canceled) { + err := &backendHealthError{ + Class: healthFailureCanceled, + EndpointSet: endpoints, + Observations: observations, + Timeout: timeout, + Cause: ctx.Err(), + } + return "", finishHealthFailure(err, onFailure) + } + class := classifyHealthObservations(observations) + err := &backendHealthError{ + Class: class, + EndpointSet: endpoints, + Observations: observations, + Timeout: timeout, + } + return "", finishHealthFailure(err, onFailure) +} + +func finishHealthFailure(err *backendHealthError, onFailure func()) error { if onFailure != nil { onFailure() } - if errors.Is(ctx.Err(), context.Canceled) { - return fmt.Errorf("backend healthcheck canceled at %s: %w", healthURL, ctx.Err()) - } - if sawForeignProcessResponse { - return fmt.Errorf( - "backend port %s answered a health check from a different process "+ - "(missing/mismatched launcher token). Another Kandev instance may already own it, "+ - "or the runtime bundle predates v0.66.0", - healthPort(baseURL), - ) - } - return fmt.Errorf("backend healthcheck timed out after %s at %s", timeout, healthURL) + return err } -// probeHealth reports whether a single health request succeeded. The body is -// drained and closed so the connection can be reused by the next poll. -func probeHealth(ctx context.Context, healthURL, expectedToken string) (healthy, foreign bool) { +type healthProbeResult struct { + index int + observation healthObservation +} + +func probeHealthTargets(ctx context.Context, targets []string, expectedToken string) ([]healthObservation, string) { + probeCtx, cancel := context.WithCancel(ctx) + defer cancel() + results := make(chan healthProbeResult, len(targets)) + for index, target := range targets { + go func(index int, target string) { + results <- healthProbeResult{index: index, observation: probeHealth(probeCtx, target, expectedToken)} + }(index, target) + } + + observations := make(map[string]healthObservation, len(targets)) + received := make([]bool, len(targets)) + for completed := 0; completed < len(targets); completed++ { + select { + case <-ctx.Done(): + return observationValues(observations), "" + case result := <-results: + received[result.index] = true + observations[result.observation.URL] = result.observation + if healthyURL := preferredHealthyTarget(targets, observations, received); healthyURL != "" { + return observationValues(observations), healthyURL + } + } + } + return observationValues(observations), "" +} + +func preferredHealthyTarget(targets []string, observations map[string]healthObservation, received []bool) string { + for index, target := range targets { + if !received[index] { + return "" + } + if observations[target].Outcome == healthOutcomeHealthy { + return target + } + } + return "" +} + +func observationValues(observations map[string]healthObservation) []healthObservation { + values := make([]healthObservation, 0, len(observations)) + for _, observation := range observations { + values = append(values, observation) + } + return values +} + +func observationsInTargetOrder(targets []string, observations map[string]healthObservation) []healthObservation { + ordered := make([]healthObservation, 0, len(observations)) + for _, target := range targets { + if observation, ok := observations[target]; ok { + ordered = append(ordered, observation) + } + } + return ordered +} + +func classifyHealthObservations(observations []healthObservation) healthFailureClass { + for _, observation := range observations { + if observation.Outcome == healthOutcomeForeignProcess { + return healthFailureForeign + } + } + for _, observation := range observations { + if observation.Outcome == healthOutcomeHTTPStatus { + return healthFailureUnhealthyHTTP + } + } + return healthFailureUnreachable +} + +// probeHealth reports the latest safe outcome for a single health request. The +// body is drained and closed so the connection can be reused by the next poll. +func probeHealth(ctx context.Context, healthURL, expectedToken string) healthObservation { + observation := healthObservation{URL: healthURL} req, err := http.NewRequestWithContext(ctx, http.MethodGet, healthURL, nil) if err != nil { - return false, false + observation.Outcome = healthOutcomeConnectionError + observation.SafeDetail = "invalid health target" + return observation } resp, err := healthProbeClient.Do(req) if err != nil { - return false, false + observation.Outcome = healthOutcomeConnectionError + observation.SafeDetail = safeProbeError(err) + return observation } defer func() { _ = resp.Body.Close() }() _, _ = io.Copy(io.Discard, resp.Body) if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return false, false + observation.Outcome = healthOutcomeHTTPStatus + observation.StatusCode = resp.StatusCode + observation.SafeDetail = "HTTP response was not successful" + return observation } if expectedToken == "" || resp.Header.Get("X-Kandev-Desktop-Health-Token") == expectedToken { - return true, false + observation.Outcome = healthOutcomeHealthy + return observation } - return false, true + observation.Outcome = healthOutcomeForeignProcess + observation.SafeDetail = "missing or mismatched launcher token" + return observation +} + +func safeProbeError(err error) string { + if errors.Is(err, context.DeadlineExceeded) { + return "timeout" + } + var netErr net.Error + if errors.As(err, &netErr) && netErr.Timeout() { + return "timeout" + } + var urlErr *url.Error + if errors.As(err, &urlErr) { + err = urlErr.Err + } + if strings.Contains(strings.ToLower(err.Error()), "connection refused") { + return "connection refused" + } + return "network error" } func healthPort(baseURL string) string { diff --git a/apps/backend/internal/launcher/health_test.go b/apps/backend/internal/launcher/health_test.go index 3075715c02..cb2efe55b0 100644 --- a/apps/backend/internal/launcher/health_test.go +++ b/apps/backend/internal/launcher/health_test.go @@ -2,9 +2,12 @@ package launcher import ( "context" + "errors" + "net" "net/http" "net/http/httptest" "strings" + "sync" "sync/atomic" "testing" "time" @@ -17,6 +20,14 @@ type fakeChild struct { func (f fakeChild) Exited() (bool, int) { return f.exited, f.code } +func singleHealthTarget(url string) backendEndpointSet { + return backendEndpointSet{ + bindHosts: []string{"localhost"}, + healthTargets: []string{url + "/health"}, + accessURL: url, + } +} + func TestHealthTimeoutUsesDefaultWhenEnvUnusable(t *testing.T) { for name, raw := range map[string]string{ "unset": "", @@ -60,10 +71,13 @@ func TestWaitForHealthReturnsNilOnHealthyResponse(t *testing.T) { t.Cleanup(srv.Close) var failures int - err := waitForHealth(context.Background(), srv.URL, fakeChild{}, 5*time.Second, "expected", func() { failures++ }) + readyURL, err := waitForHealth(context.Background(), singleHealthTarget(srv.URL), fakeChild{}, 5*time.Second, "expected", func() { failures++ }) if err != nil { t.Fatalf("waitForHealth() = %v, want nil", err) } + if readyURL != srv.URL { + t.Fatalf("ready URL = %q, want %q", readyURL, srv.URL) + } if failures != 0 { t.Fatalf("onFailure called %d times, want 0", failures) } @@ -72,6 +86,219 @@ func TestWaitForHealthReturnsNilOnHealthyResponse(t *testing.T) { } } +func TestWaitForHealthAcceptsHealthySiblingAfterHigherPriorityFailure(t *testing.T) { + higherPriority := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusServiceUnavailable) + })) + t.Cleanup(higherPriority.Close) + healthy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("X-Kandev-Desktop-Health-Token", "expected") + w.WriteHeader(http.StatusOK) + })) + t.Cleanup(healthy.Close) + + started := time.Now() + readyURL, err := waitForHealth(context.Background(), backendEndpointSet{ + accessURL: healthy.URL, + healthTargets: []string{ + higherPriority.URL + "/health", + healthy.URL + "/health", + }, + }, fakeChild{}, 2*time.Second, "expected", nil) + if err != nil { + t.Fatalf("waitForHealth() = %v, want nil", err) + } + if readyURL != healthy.URL { + t.Fatalf("ready URL = %q, want healthy sibling %q", readyURL, healthy.URL) + } + if elapsed := time.Since(started); elapsed >= healthProbeTimeout { + t.Fatalf("waitForHealth() took %s, want healthy sibling to win before probe timeout", elapsed) + } +} + +func TestWaitForHealthPreservesWildcardBrowserOrigin(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("X-Kandev-Desktop-Health-Token", "expected") + w.WriteHeader(http.StatusOK) + })) + t.Cleanup(srv.Close) + browserURL := strings.Replace(srv.URL, "127.0.0.1", "localhost", 1) + + readyURL, err := waitForHealth(context.Background(), backendEndpointSet{ + healthTargets: []string{srv.URL + "/health"}, + accessURLs: []string{browserURL}, + accessURL: browserURL, + }, fakeChild{}, time.Second, "expected", nil) + if err != nil { + t.Fatalf("waitForHealth() = %v, want wildcard readiness", err) + } + if readyURL != browserURL { + t.Fatalf("ready URL = %q, want preserved browser origin %q", readyURL, browserURL) + } +} + +func TestWaitForHealthPrefersHigherPriorityTargetWhenLowerRespondsFirst(t *testing.T) { + loopbackStarted := make(chan struct{}) + lanResponded := make(chan struct{}) + releaseLoopback := make(chan struct{}) + var releaseOnce sync.Once + release := func() { releaseOnce.Do(func() { close(releaseLoopback) }) } + t.Cleanup(release) + + loopback := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + close(loopbackStarted) + select { + case <-releaseLoopback: + case <-r.Context().Done(): + return + } + w.Header().Set("X-Kandev-Desktop-Health-Token", "expected") + w.WriteHeader(http.StatusOK) + })) + t.Cleanup(loopback.Close) + lan := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + close(lanResponded) + w.Header().Set("X-Kandev-Desktop-Health-Token", "expected") + w.WriteHeader(http.StatusOK) + })) + t.Cleanup(lan.Close) + + type result struct { + url string + err error + } + done := make(chan result, 1) + go func() { + url, err := waitForHealth(context.Background(), backendEndpointSet{ + accessURL: loopback.URL, + healthTargets: []string{ + loopback.URL + "/health", + lan.URL + "/health", + }, + }, fakeChild{}, time.Second, "expected", nil) + done <- result{url: url, err: err} + }() + + <-loopbackStarted + <-lanResponded + select { + case got := <-done: + t.Fatalf("waitForHealth returned %q before higher-priority target responded (err=%v)", got.url, got.err) + case <-time.After(100 * time.Millisecond): + } + release() + + got := <-done + if got.err != nil { + t.Fatalf("waitForHealth() error = %v, want loopback readiness", got.err) + } + if got.url != loopback.URL { + t.Fatalf("ready URL = %q, want higher-priority loopback %q", got.url, loopback.URL) + } +} + +func TestWaitForHealthBypassesHTTPProxyForSpecificBind(t *testing.T) { + hosts := listHostNetworkAddresses() + if len(hosts) == 0 { + t.Skip("no non-loopback interface available for proxy-bypass coverage") + } + + var proxyRequests atomic.Int32 + proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + proxyRequests.Add(1) + w.WriteHeader(http.StatusBadGateway) + })) + t.Cleanup(proxy.Close) + t.Setenv("HTTP_PROXY", proxy.URL) + t.Setenv("HTTPS_PROXY", proxy.URL) + t.Setenv("NO_PROXY", "") + t.Setenv("no_proxy", "") + + listener, err := net.Listen("tcp", net.JoinHostPort(hosts[0], "0")) + if err != nil { + t.Skipf("cannot bind a non-loopback test listener: %v", err) + } + srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("X-Kandev-Desktop-Health-Token", "expected") + w.WriteHeader(http.StatusOK) + })) + srv.Listener = listener + srv.Start() + t.Cleanup(srv.Close) + + readyURL, err := waitForHealth(context.Background(), singleHealthTarget(srv.URL), fakeChild{}, time.Second, "expected", nil) + if err != nil { + t.Fatalf("waitForHealth() = %v, want proxy-free readiness", err) + } + if readyURL != srv.URL { + t.Fatalf("ready URL = %q, want %q", readyURL, srv.URL) + } + if got := proxyRequests.Load(); got != 0 { + t.Fatalf("proxy requests = %d, want no proxy request for local readiness", got) + } +} + +func TestWaitForHealthRetainsLastSafeObservationPerTarget(t *testing.T) { + status := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusServiceUnavailable) + })) + t.Cleanup(status.Close) + foreign := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("X-Kandev-Desktop-Health-Token", "wrong") + w.WriteHeader(http.StatusOK) + })) + t.Cleanup(foreign.Close) + + _, err := waitForHealth(context.Background(), backendEndpointSet{ + accessURL: status.URL, + healthTargets: []string{ + status.URL + "/health", + foreign.URL + "/health", + "http://127.0.0.1:1/health", + }, + }, fakeChild{}, 350*time.Millisecond, "expected", nil) + if err == nil { + t.Fatal("waitForHealth() = nil, want typed startup failure") + } + var healthErr *backendHealthError + if !errors.As(err, &healthErr) { + t.Fatalf("error type = %T, want *backendHealthError", err) + } + if healthErr.Class != healthFailureForeign { + t.Fatalf("failure class = %q, want %q", healthErr.Class, healthFailureForeign) + } + if len(healthErr.Observations) != 3 { + t.Fatalf("observations = %#v, want one observation per target", healthErr.Observations) + } + byURL := make(map[string]healthObservation, len(healthErr.Observations)) + for _, observation := range healthErr.Observations { + byURL[observation.URL] = observation + } + if got := byURL[status.URL+"/health"]; got.Outcome != healthOutcomeHTTPStatus || got.StatusCode != http.StatusServiceUnavailable { + t.Fatalf("status observation = %#v, want HTTP 503", got) + } + if got := byURL[foreign.URL+"/health"]; got.Outcome != healthOutcomeForeignProcess || got.SafeDetail != "missing or mismatched launcher token" { + t.Fatalf("foreign observation = %#v, want token mismatch without token value", got) + } + if got := byURL["http://127.0.0.1:1/health"]; got.Outcome != healthOutcomeConnectionError || got.SafeDetail == "" { + t.Fatalf("connection observation = %#v, want bounded safe detail", got) + } +} + +func TestWaitForHealthClassifiesUnreachableBackend(t *testing.T) { + _, err := waitForHealth(context.Background(), singleHealthTarget("http://127.0.0.1:1"), fakeChild{}, 50*time.Millisecond, "expected", nil) + if err == nil { + t.Fatal("waitForHealth() = nil, want timeout error") + } + var healthErr *backendHealthError + if !errors.As(err, &healthErr) { + t.Fatalf("error type = %T, want *backendHealthError", err) + } + if healthErr.Class != healthFailureUnreachable { + t.Fatalf("failure class = %q, want %q", healthErr.Class, healthFailureUnreachable) + } +} + func TestWaitForHealthIgnoresHealthyResponseWithoutMatchingToken(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("X-Kandev-Desktop-Health-Token", "wrong") @@ -79,7 +306,7 @@ func TestWaitForHealthIgnoresHealthyResponseWithoutMatchingToken(t *testing.T) { })) t.Cleanup(srv.Close) - err := waitForHealth(context.Background(), srv.URL, fakeChild{}, 400*time.Millisecond, "expected", nil) + _, err := waitForHealth(context.Background(), singleHealthTarget(srv.URL), fakeChild{}, 400*time.Millisecond, "expected", nil) if err == nil { t.Fatal("waitForHealth() = nil, want different-process error") } @@ -99,7 +326,7 @@ func TestWaitForHealthTimesOutOnNon2xxResponse(t *testing.T) { t.Cleanup(srv.Close) var failures int - err := waitForHealth(context.Background(), srv.URL, fakeChild{}, 400*time.Millisecond, "", func() { failures++ }) + _, err := waitForHealth(context.Background(), singleHealthTarget(srv.URL), fakeChild{}, 400*time.Millisecond, "", func() { failures++ }) if err == nil { t.Fatal("waitForHealth() = nil, want timeout error") } @@ -128,7 +355,8 @@ func TestWaitForHealthReturnsWhenServerHangs(t *testing.T) { done := make(chan error, 1) go func() { - done <- waitForHealth(context.Background(), srv.URL, fakeChild{}, 300*time.Millisecond, "", nil) + _, err := waitForHealth(context.Background(), singleHealthTarget(srv.URL), fakeChild{}, 300*time.Millisecond, "", nil) + done <- err }() select { @@ -161,7 +389,8 @@ func TestWaitForHealthReturnsWhenContextCanceled(t *testing.T) { done := make(chan error, 1) go func() { // A generous timeout: only cancellation can end this wait in time. - done <- waitForHealth(ctx, srv.URL, fakeChild{}, time.Minute, "", nil) + _, err := waitForHealth(ctx, singleHealthTarget(srv.URL), fakeChild{}, time.Minute, "", nil) + done <- err }() cancel() @@ -183,7 +412,7 @@ func TestWaitForHealthCallsOnFailureWhenContextCanceled(t *testing.T) { cancel() var failures atomic.Int32 - err := waitForHealth(ctx, "http://127.0.0.1:1", fakeChild{}, time.Minute, "", func() { + _, err := waitForHealth(ctx, singleHealthTarget("http://127.0.0.1:1"), fakeChild{}, time.Minute, "", func() { failures.Add(1) }) if err == nil { @@ -199,9 +428,9 @@ func TestWaitForHealthCallsOnFailureWhenContextCanceled(t *testing.T) { func TestWaitForHealthFailsFastWhenBackendExited(t *testing.T) { var failures int - err := waitForHealth( + _, err := waitForHealth( context.Background(), - "http://127.0.0.1:1", + singleHealthTarget("http://127.0.0.1:1"), fakeChild{exited: true, code: 3}, time.Minute, "", @@ -213,6 +442,13 @@ func TestWaitForHealthFailsFastWhenBackendExited(t *testing.T) { if !strings.Contains(err.Error(), "code 3") { t.Fatalf("error = %v, want the backend exit code", err) } + var healthErr *backendHealthError + if !errors.As(err, &healthErr) { + t.Fatalf("error type = %T, want *backendHealthError", err) + } + if healthErr.Class != healthFailureEarlyExit || !healthErr.ChildExited || healthErr.ChildExitCode != 3 { + t.Fatalf("health error = %#v, want early exit code 3", healthErr) + } if failures != 1 { t.Fatalf("onFailure called %d times, want 1", failures) } diff --git a/apps/backend/internal/launcher/network.go b/apps/backend/internal/launcher/network.go index 36c0b97829..041fc1e001 100644 --- a/apps/backend/internal/launcher/network.go +++ b/apps/backend/internal/launcher/network.go @@ -3,8 +3,11 @@ package launcher import ( "fmt" "net" + "os" "strconv" "strings" + + "github.com/kandev/kandev/internal/common/config" ) var ( @@ -12,6 +15,108 @@ var ( networkInterfaceAddrsFn = func(iface net.Interface) ([]net.Addr, error) { return iface.Addrs() } ) +type backendEndpointSet struct { + bindHosts []string + healthTargets []string + accessURLs []string + accessURL string +} + +func resolveBackendEndpoints(cfg *config.Config, port int) (backendEndpointSet, error) { + server := config.ServerConfig{Host: os.Getenv("KANDEV_SERVER_HOST")} + if cfg != nil { + server = cfg.Server + } + binds, err := server.ResolvedBinds() + if err != nil { + return backendEndpointSet{}, err + } + + loopbackTargets := make([]string, 0, len(binds)) + loopbackAccessURLs := make([]string, 0, len(binds)) + nonLoopbackTargets := make([]string, 0, len(binds)) + nonLoopbackAccessURLs := make([]string, 0, len(binds)) + seen := make(map[string]struct{}, len(binds)) + for _, bind := range binds { + host := endpointHost(bind) + healthURL := backendURLForHost(host, port) + "/health" + accessURL := backendURLForHost(browserEndpointHost(bind, host), port) + if _, ok := seen[healthURL]; ok { + continue + } + seen[healthURL] = struct{}{} + if config.IsLoopbackHost(host) { + loopbackTargets = append(loopbackTargets, healthURL) + loopbackAccessURLs = append(loopbackAccessURLs, accessURL) + continue + } + nonLoopbackTargets = append(nonLoopbackTargets, healthURL) + nonLoopbackAccessURLs = append(nonLoopbackAccessURLs, accessURL) + } + healthTargets := append([]string(nil), loopbackTargets...) + healthTargets = append(healthTargets, nonLoopbackTargets...) + accessURLs := append([]string(nil), loopbackAccessURLs...) + accessURLs = append(accessURLs, nonLoopbackAccessURLs...) + if len(healthTargets) == 0 { + return backendEndpointSet{}, fmt.Errorf("server bind configuration produced no health targets") + } + return backendEndpointSet{ + bindHosts: append([]string(nil), binds...), + healthTargets: healthTargets, + accessURLs: accessURLs, + accessURL: accessURLs[0], + }, nil +} + +func browserEndpointHost(bind, probeHost string) string { + ip := net.ParseIP(strings.TrimSuffix(strings.TrimSpace(bind), ".")) + if ip != nil && ip.IsUnspecified() && ip.To4() != nil { + return "localhost" + } + return probeHost +} + +func endpointHost(bind string) string { + host := strings.ToLower(strings.TrimSuffix(strings.TrimSpace(bind), ".")) + if ip := net.ParseIP(host); ip != nil { + if ip.IsUnspecified() { + if ip.To4() != nil { + return "127.0.0.1" + } + return "::1" + } + return ip.String() + } + return host +} + +func backendURLForHost(host string, port int) string { + return fmt.Sprintf("http://%s", net.JoinHostPort(host, strconv.Itoa(port))) +} + +func endpointSetForAccessURL(accessURL string) backendEndpointSet { + accessURL = strings.TrimRight(strings.TrimSpace(accessURL), "/") + return backendEndpointSet{ + bindHosts: []string{"localhost"}, + healthTargets: []string{accessURL + "/health"}, + accessURLs: []string{accessURL}, + accessURL: accessURL, + } +} + +func (e backendEndpointSet) browserURLForHealthTarget(target string) string { + for index, healthTarget := range e.healthTargets { + if healthTarget != target { + continue + } + if index < len(e.accessURLs) && e.accessURLs[index] != "" { + return e.accessURLs[index] + } + break + } + return strings.TrimSuffix(target, "/health") +} + func listHostNetworkAddresses() []string { interfaces, err := networkInterfacesFn() if err != nil { diff --git a/apps/backend/internal/launcher/network_test.go b/apps/backend/internal/launcher/network_test.go index 8d81b88686..011fb6d8c2 100644 --- a/apps/backend/internal/launcher/network_test.go +++ b/apps/backend/internal/launcher/network_test.go @@ -6,8 +6,11 @@ import ( "io" "net" "os" + "reflect" "strings" "testing" + + "github.com/kandev/kandev/internal/common/config" ) func TestLogStartupPrintsNetworkAddress(t *testing.T) { @@ -133,6 +136,85 @@ func TestNetworkAddressesForBindHost(t *testing.T) { } } +func TestResolveBackendEndpointsMapsBindsAndPrefersLoopback(t *testing.T) { + tests := []struct { + name string + server config.ServerConfig + wantBinds []string + wantURLs []string + wantAccess string + }{ + { + name: "specific address", + server: config.ServerConfig{Host: "192.0.2.10"}, + wantBinds: []string{"192.0.2.10"}, + wantURLs: []string{"http://192.0.2.10:38429/health"}, + wantAccess: "http://192.0.2.10:38429", + }, + { + name: "ipv4 wildcard", + server: config.ServerConfig{Host: "0.0.0.0"}, + wantBinds: []string{"0.0.0.0"}, + wantURLs: []string{"http://127.0.0.1:38429/health"}, + wantAccess: "http://localhost:38429", + }, + { + name: "ipv6 wildcard", + server: config.ServerConfig{Host: "::"}, + wantBinds: []string{"::"}, + wantURLs: []string{"http://[::1]:38429/health"}, + wantAccess: "http://[::1]:38429", + }, + { + name: "multi bind loopback first", + server: config.ServerConfig{Host: "192.0.2.10,127.0.0.1,192.0.2.10"}, + wantBinds: []string{"192.0.2.10", "127.0.0.1"}, + wantURLs: []string{ + "http://127.0.0.1:38429/health", + "http://192.0.2.10:38429/health", + }, + wantAccess: "http://127.0.0.1:38429", + }, + { + name: "hostname normalized", + server: config.ServerConfig{Host: "LOCALHOST."}, + wantBinds: []string{"LOCALHOST."}, + wantURLs: []string{"http://localhost:38429/health"}, + wantAccess: "http://localhost:38429", + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got, err := resolveBackendEndpoints(&config.Config{Server: test.server}, 38429) + if err != nil { + t.Fatalf("resolveBackendEndpoints() error = %v", err) + } + if !reflect.DeepEqual(got.bindHosts, test.wantBinds) { + t.Fatalf("bind hosts = %v, want %v", got.bindHosts, test.wantBinds) + } + if !reflect.DeepEqual(got.healthTargets, test.wantURLs) { + t.Fatalf("health targets = %v, want %v", got.healthTargets, test.wantURLs) + } + if got.accessURL != test.wantAccess { + t.Fatalf("access URL = %q, want %q", got.accessURL, test.wantAccess) + } + }) + } +} + +func TestResolveBackendEndpointsPreservesDefaultBrowserOrigin(t *testing.T) { + got, err := resolveBackendEndpoints(&config.Config{}, 38429) + if err != nil { + t.Fatalf("resolveBackendEndpoints() error = %v", err) + } + if got.healthTargets[0] != "http://127.0.0.1:38429/health" { + t.Fatalf("default health target = %q, want loopback probe", got.healthTargets[0]) + } + if got.accessURL != "http://localhost:38429" { + t.Fatalf("default browser URL = %q, want localhost origin", got.accessURL) + } +} + func TestLogStartupKeepsLocalhostWhenNetworkDiscoveryFails(t *testing.T) { t.Setenv("KANDEV_SERVER_HOST", "") oldNetworkInterfaces := networkInterfacesFn diff --git a/apps/backend/internal/launcher/run.go b/apps/backend/internal/launcher/run.go index 0395da9c2b..7934ae3645 100644 --- a/apps/backend/internal/launcher/run.go +++ b/apps/backend/internal/launcher/run.go @@ -23,6 +23,12 @@ func runInstalled(ctx context.Context, opts Options) int { fmt.Fprintln(os.Stderr, "[kandev] "+err.Error()) return 1 } + endpoints, err := resolveBackendEndpoints(startupConfig, ports.BackendPort) + if err != nil { + fmt.Fprintln(os.Stderr, "[kandev] "+err.Error()) + return 1 + } + ports.BackendURL = endpoints.accessURL bundle, err := resolveRuntimeBundle() if err != nil { fmt.Fprintln(os.Stderr, "[kandev] "+err.Error()) @@ -47,5 +53,6 @@ func runInstalled(ctx context.Context, opts Options) int { LogLevel: logLevel, Opts: opts, Startup: startupConfig, + Endpoints: endpoints, }) } diff --git a/apps/backend/internal/launcher/start.go b/apps/backend/internal/launcher/start.go index eb7e38fa2e..ce2a8c25a4 100644 --- a/apps/backend/internal/launcher/start.go +++ b/apps/backend/internal/launcher/start.go @@ -43,6 +43,12 @@ func runStart(ctx context.Context, opts Options) int { fmt.Fprintln(os.Stderr, "[kandev] "+err.Error()) return 1 } + endpoints, err := resolveBackendEndpoints(startupConfig, ports.BackendPort) + if err != nil { + fmt.Fprintln(os.Stderr, "[kandev] "+err.Error()) + return 1 + } + ports.BackendURL = endpoints.accessURL if err := ensureDataDirForConfig(startupConfig); err != nil { fmt.Fprintln(os.Stderr, "[kandev] "+err.Error()) return 1 @@ -64,6 +70,7 @@ func runStart(ctx context.Context, opts Options) int { LogLevel: logLevel, Opts: opts, Startup: startupConfig, + Endpoints: endpoints, }) } @@ -76,6 +83,7 @@ type managedAppConfig struct { LogLevel string Opts Options Startup *config.Config + Endpoints backendEndpointSet } func resolveLogLevel(opts Options, configs ...*config.Config) string { @@ -108,6 +116,19 @@ func resolveConsoleLogLevel(opts Options) string { } func runManagedApp(ctx context.Context, cfg managedAppConfig) int { + if cfg.Endpoints.accessURL == "" { + if cfg.Startup != nil { + endpoints, err := resolveBackendEndpoints(cfg.Startup, cfg.Ports.BackendPort) + if err != nil { + fmt.Fprintln(os.Stderr, "[kandev] "+err.Error()) + return 1 + } + cfg.Endpoints = endpoints + } else { + cfg.Endpoints = endpointSetForAccessURL(cfg.Ports.BackendURL) + } + } + cfg.Ports.BackendURL = cfg.Endpoints.accessURL ignoreBrokenPipeSignal() logStartup(cfg.Header, cfg.Ports, resolveDatabasePathForConfig(cfg.Startup), cfg.LogLevel, serverHostForConfig(cfg.Startup)) setLauncherShutdownDebug(cfg.Opts.Debug || os.Getenv("KANDEV_SHUTDOWN_DEBUG") == "1") @@ -143,11 +164,15 @@ func runManagedApp(ctx context.Context, cfg managedAppConfig) int { } shutdownDebugf("runManagedApp backend launched") fmt.Println("[kandev] starting backend...") - if err := waitForHealthFn(ctx, cfg.Ports.BackendURL, backend, healthTimeoutForConfig(healthTimeoutReleaseMS, cfg.Startup), healthToken, dumpLogs); err != nil { + readyURL, err := waitForHealthFn(ctx, cfg.Endpoints, backend, healthTimeoutForConfig(healthTimeoutReleaseMS, cfg.Startup), healthToken, dumpLogs) + if err != nil { supervisor.shutdown("backend health failure") - fmt.Fprintln(os.Stderr, "[kandev] "+err.Error()) + fmt.Fprintln(os.Stderr, formatStartupFailure(err, cfg.Endpoints, cfg.Startup, backendLogPathForConfig(cfg.Startup), startupFailureStoppedBackend(err))) return 1 } + if readyURL != "" { + cfg.Ports.BackendURL = readyURL + } fmt.Printf("[kandev] backend ready at %s\n", cfg.Ports.BackendURL) if !shouldOpenBrowser(cfg.Opts, cfg.Startup) { diff --git a/apps/backend/internal/launcher/start_test.go b/apps/backend/internal/launcher/start_test.go index 8cce7be3d5..512c1f25ca 100644 --- a/apps/backend/internal/launcher/start_test.go +++ b/apps/backend/internal/launcher/start_test.go @@ -164,6 +164,36 @@ func TestRunStartUsesSelfExecutableAndBackendCWD(t *testing.T) { } } +func TestRunStartUsesConfiguredBindForBackendURL(t *testing.T) { + clearLauncherConfigurationEnvironment(t) + dir := t.TempDir() + t.Chdir(dir) + homeDir := canonicalTempDir(t) + writeLauncherConfig(t, filepath.Join(dir, "config.yaml"), "homeDir: "+homeDir+"\nserver:\n host: 192.0.2.10\n port: 48123\n") + + oldExecutablePath := executablePath + oldLaunchManaged := launchManaged + t.Cleanup(func() { + executablePath = oldExecutablePath + launchManaged = oldLaunchManaged + }) + executablePath = func() (string, error) { + return filepath.Join(homeDir, "bin", "kandev"), nil + } + var got managedAppConfig + launchManaged = func(_ context.Context, cfg managedAppConfig) int { + got = cfg + return 42 + } + + if code := runStart(context.Background(), Options{Command: CommandStart, Headless: true}); code != 42 { + t.Fatalf("runStart() = %d, want 42", code) + } + if got.Ports.BackendURL != "http://192.0.2.10:48123" { + t.Fatalf("BackendURL = %q, want the configured bind address", got.Ports.BackendURL) + } +} + func TestRunManagedAppAttachesSignalsBeforeBackendLaunch(t *testing.T) { oldNewSupervisor := newSupervisorFn oldLaunchBackend := launchBackendFn @@ -207,10 +237,10 @@ func TestRunManagedAppAttachesSignalsBeforeBackendLaunch(t *testing.T) { events = append(events, "start-parent-watch") return newParentWatchdog(0, nil, nil) } - waitForHealthFn = func(_ context.Context, _ string, _ childState, _ time.Duration, expectedToken string, _ func()) error { + waitForHealthFn = func(_ context.Context, _ backendEndpointSet, _ childState, _ time.Duration, expectedToken string, _ func()) (string, error) { waitedHealthToken = expectedToken events = append(events, "wait-health") - return nil + return "", nil } t.Setenv("KANDEV_HOME_DIR", t.TempDir()) @@ -266,9 +296,9 @@ func TestRunManagedAppPreservesDesktopOwnedHealthToken(t *testing.T) { exitCh <- 0 return &restartableBackend{exitCh: exitCh}, func() {}, nil } - waitForHealthFn = func(_ context.Context, _ string, _ childState, _ time.Duration, expectedToken string, _ func()) error { + waitForHealthFn = func(_ context.Context, _ backendEndpointSet, _ childState, _ time.Duration, expectedToken string, _ func()) (string, error) { waitedHealthToken = expectedToken - return nil + return "", nil } t.Setenv("KANDEV_HOME_DIR", t.TempDir()) t.Setenv("KANDEV_DESKTOP_NATIVE_NOTIFICATIONS", "true") diff --git a/apps/backend/internal/launcher/startup_failure.go b/apps/backend/internal/launcher/startup_failure.go new file mode 100644 index 0000000000..ae42d2df52 --- /dev/null +++ b/apps/backend/internal/launcher/startup_failure.go @@ -0,0 +1,169 @@ +package launcher + +import ( + "errors" + "fmt" + "path/filepath" + "strings" + + "github.com/kandev/kandev/internal/common/config" +) + +const startupTroubleshootingURL = "https://github.com/kdlbs/kandev/blob/main/docs/public/cli.md#startup-health-check-times-out" + +func formatStartupFailure(err error, endpoints backendEndpointSet, cfg *config.Config, backendLogPath string, backendStopped bool) string { + class, observations, healthErr, endpoints := startupFailureEvidence(err, endpoints) + + var output strings.Builder + writeStartupFailureHeading(&output, class, healthErr) + writeStartupFailureState(&output, healthErr, backendStopped) + writeStartupFailureEndpoints(&output, endpoints, observations) + writeStartupConfiguration(&output, cfg) + if backendLogPath == "" { + backendLogPath = backendLogPathForConfig(cfg) + } + fmt.Fprintf(&output, "[kandev] backend log: %s\n", backendLogPath) + fmt.Fprintf(&output, "[kandev] next step: %s\n", startupFailureNextStep(class)) + fmt.Fprintf(&output, "[kandev] troubleshooting: %s\n", startupTroubleshootingURL) + return strings.TrimSuffix(output.String(), "\n") +} + +func startupFailureEvidence(err error, endpoints backendEndpointSet) (healthFailureClass, []healthObservation, *backendHealthError, backendEndpointSet) { + var healthErr *backendHealthError + if !errors.As(err, &healthErr) { + return "", nil, nil, endpoints + } + if len(endpoints.healthTargets) == 0 { + endpoints = healthErr.EndpointSet + } + return healthErr.Class, healthErr.Observations, healthErr, endpoints +} + +func writeStartupFailureHeading(output *strings.Builder, class healthFailureClass, healthErr *backendHealthError) { + fmt.Fprintf(output, "[kandev] startup failed: %s", startupFailureLabel(class)) + if healthErr != nil { + fmt.Fprintf(output, " - %s", healthErr.Error()) + } + output.WriteByte('\n') +} + +func writeStartupFailureState(output *strings.Builder, healthErr *backendHealthError, backendStopped bool) { + switch { + case healthErr != nil && healthErr.ChildExited: + fmt.Fprintf(output, "[kandev] backend state: exited before readiness (code %d)\n", healthErr.ChildExitCode) + case backendStopped: + output.WriteString("[kandev] backend state: launcher stopped the backend after readiness failed\n") + default: + output.WriteString("[kandev] backend state: readiness did not complete\n") + } +} + +func writeStartupFailureEndpoints(output *strings.Builder, endpoints backendEndpointSet, observations []healthObservation) { + binds := strings.Join(endpoints.bindHosts, ", ") + if binds == "" { + binds = "unknown" + } + fmt.Fprintf(output, "[kandev] effective bind addresses: %s\n", binds) + output.WriteString("[kandev] health targets:\n") + observationsByURL := make(map[string]healthObservation, len(observations)) + for _, observation := range observations { + observationsByURL[observation.URL] = observation + } + for _, target := range endpoints.healthTargets { + writeStartupTarget(output, target, observationsByURL) + } +} + +func writeStartupTarget(output *strings.Builder, target string, observations map[string]healthObservation) { + observation, ok := observations[target] + if !ok { + fmt.Fprintf(output, "[kandev] %s: not_attempted\n", target) + return + } + detail := observation.SafeDetail + if observation.StatusCode != 0 { + if detail != "" { + detail += ", " + } + detail += fmt.Sprintf("status=%d", observation.StatusCode) + } + if detail != "" { + detail = " (" + detail + ")" + } + fmt.Fprintf(output, "[kandev] %s: %s%s\n", target, observation.Outcome, detail) +} + +func writeStartupConfiguration(output *strings.Builder, cfg *config.Config) { + file := "none (defaults and environment)" + if cfg != nil && strings.TrimSpace(cfg.Source.FilePath) != "" { + file = cfg.Source.FilePath + } + fmt.Fprintf(output, "[kandev] configuration file: %s\n", file) + fmt.Fprintf(output, "[kandev] server.host source: %s\n", startupServerHostSource(cfg)) +} + +func startupServerHostSource(cfg *config.Config) string { + if cfg == nil { + return "built-in default (0.0.0.0)" + } + switch cfg.SourceFor("server.host") { + case config.SourceEnvironment: + return "environment (KANDEV_SERVER_HOST)" + case config.SourceConfiguration: + return "configuration file" + case config.SourceProfile: + return "profile defaults" + case config.SourceDefault: + return "built-in default (0.0.0.0)" + default: + return string(cfg.SourceFor("server.host")) + } +} + +func startupFailureLabel(class healthFailureClass) string { + switch class { + case healthFailureEarlyExit: + return "early backend exit" + case healthFailureUnreachable: + return "unreachable backend" + case healthFailureUnhealthyHTTP: + return "unhealthy HTTP response" + case healthFailureForeign: + return "foreign process" + case healthFailureCanceled: + return "canceled readiness check" + default: + return "unknown readiness failure" + } +} + +func startupFailureNextStep(class healthFailureClass) string { + switch class { + case healthFailureEarlyExit: + return "read the backend log for the startup error" + case healthFailureForeign: + return "free the selected port or choose another backend port" + case healthFailureUnhealthyHTTP: + return "inspect the reported HTTP status and the backend log" + case healthFailureUnreachable: + return "inspect bind addresses, firewall rules, and environment overrides" + default: + return "read the backend log and follow the startup troubleshooting guide" + } +} + +func backendLogPathForConfig(cfg *config.Config) string { + return filepath.Join(resolveHomeDirForConfig(cfg), "logs", "backend-logs.log") +} + +func backendLogPathForDevConfig(cfg devLaunchConfig) string { + if homeDir := strings.TrimSpace(processEnvValue(cfg.extra, "KANDEV_HOME_DIR")); homeDir != "" { + return filepath.Join(homeDir, "logs", "backend-logs.log") + } + return backendLogPathForConfig(cfg.startup) +} + +func startupFailureStoppedBackend(err error) bool { + var healthErr *backendHealthError + return !errors.As(err, &healthErr) || !healthErr.ChildExited +} diff --git a/apps/backend/internal/launcher/startup_failure_test.go b/apps/backend/internal/launcher/startup_failure_test.go new file mode 100644 index 0000000000..f3ce451b3c --- /dev/null +++ b/apps/backend/internal/launcher/startup_failure_test.go @@ -0,0 +1,122 @@ +package launcher + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/kandev/kandev/internal/common/config" +) + +func TestStartupFailureFormattingIncludesSafeEvidenceAndRecovery(t *testing.T) { + const healthToken = "do-not-print-this-token" + endpoints := backendEndpointSet{ + bindHosts: []string{"192.0.2.10"}, + healthTargets: []string{ + "http://192.0.2.10:38429/health", + }, + accessURL: "http://192.0.2.10:38429", + } + err := &backendHealthError{ + Class: healthFailureForeign, + EndpointSet: endpoints, + Observations: []healthObservation{{ + URL: endpoints.healthTargets[0], + Outcome: healthOutcomeForeignProcess, + SafeDetail: "missing or mismatched launcher token", + }}, + } + cfg := &config.Config{ + Server: config.ServerConfig{Host: "192.0.2.10"}, + Source: config.ConfigSource{FilePath: "/etc/kandev/config.yaml"}, + } + + got := formatStartupFailure(err, endpoints, cfg, "/srv/kandev/logs/backend-logs.log", true) + for _, want := range []string{ + "startup failed", + "foreign process", + "effective bind addresses: 192.0.2.10", + "http://192.0.2.10:38429/health: foreign_process", + "configuration file: /etc/kandev/config.yaml", + "backend log: /srv/kandev/logs/backend-logs.log", + "launcher stopped the backend after readiness failed", + "free the selected port or choose another backend port", + startupTroubleshootingURL, + } { + if !strings.Contains(got, want) { + t.Errorf("formatted failure missing %q:\n%s", want, got) + } + } + if strings.Contains(got, healthToken) || strings.Contains(got, "X-Kandev-Desktop-Health-Token") { + t.Fatalf("formatted failure exposed health-token material:\n%s", got) + } +} + +func TestStartupFailureReportsEffectiveServerHostSource(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "config.yaml"), []byte("server:\n host: 192.0.2.10\n"), 0o600); err != nil { + t.Fatalf("write config: %v", err) + } + t.Setenv("KANDEV_SERVER_HOST", "127.0.0.1") + cfg, err := config.LoadWithPath(dir) + if err != nil { + t.Fatalf("LoadWithPath() = %v", err) + } + if cfg.Server.Host != "127.0.0.1" { + t.Fatalf("effective server.host = %q, want environment override", cfg.Server.Host) + } + if got := cfg.SourceFor("server.host"); got != config.SourceEnvironment { + t.Fatalf("server.host source = %q, want environment", got) + } + + endpoints := backendEndpointSet{ + bindHosts: []string{"127.0.0.1"}, + healthTargets: []string{"http://127.0.0.1:38429/health"}, + accessURL: "http://localhost:38429", + } + err = &backendHealthError{ + Class: healthFailureUnreachable, + EndpointSet: endpoints, + } + + got := formatStartupFailure(err, endpoints, cfg, "/srv/kandev/logs/backend-logs.log", true) + for _, want := range []string{ + "configuration file: " + filepath.Join(dir, "config.yaml"), + "server.host source: environment (KANDEV_SERVER_HOST)", + } { + if !strings.Contains(got, want) { + t.Errorf("formatted failure missing %q:\n%s", want, got) + } + } +} + +func TestBackendLogPathForDevConfigUsesEffectiveBackendHome(t *testing.T) { + repo := t.TempDir() + t.Setenv("KANDEV_TASK_ID", "") + t.Setenv("KANDEV_HOME_DIR", "") + t.Setenv("KANDEV_DATABASE_PATH", "") + defaultExtra := devExtraForTest(t, repo) + got := backendLogPathForDevConfig(devLaunchConfig{startup: nil, extra: defaultExtra}) + want := filepath.Join(repo, ".kandev-dev", "logs", "backend-logs.log") + if got != want { + t.Fatalf("default dev backend log = %q, want %q", got, want) + } + + home := t.TempDir() + t.Setenv("HOME", home) + databasePath := filepath.Join(t.TempDir(), "kandev.db") + t.Setenv("KANDEV_DATABASE_PATH", databasePath) + overrideExtra := devExtraForTest(t, repo) + got = backendLogPathForDevConfig(devLaunchConfig{startup: nil, extra: overrideExtra}) + want = filepath.Join(home, ".kandev", "logs", "backend-logs.log") + if got != want { + t.Fatalf("explicit database dev backend log = %q, want %q", got, want) + } +} + +func devExtraForTest(t *testing.T, repo string) []string { + t.Helper() + _, extra := resolveDevBackendEnv(repo) + return extra +} diff --git a/docs/plans/launcher-startup-recovery/plan.md b/docs/plans/launcher-startup-recovery/plan.md new file mode 100644 index 0000000000..fc95adf73b --- /dev/null +++ b/docs/plans/launcher-startup-recovery/plan.md @@ -0,0 +1,104 @@ +--- +created: 2026-08-24 +status: done +requirements: + - REQ-LAUNCHER-STARTUP-001 + - REQ-LAUNCHER-STARTUP-002 + - REQ-LAUNCHER-STARTUP-003 +system_design: + - ../../specs/launcher/system-design/startup-recovery.md +legacy_specs: + - ../../specs/platform/startup-configuration-parity.md + - ../../specs/auth/trusted-proxies.md +--- + +# Implementation Plan: Launcher Startup Recovery + +## Overview + +Make backend readiness use the effective bind addresses. Then add typed probe +evidence and concise failure output. Update the public CLI and configuration +guides after the runtime behavior is stable. + +## Scope + +### In scope + +- Bind-aware health targets and a reachable access URL. +- Multi-target readiness with the existing health token. +- Typed readiness failures with configuration and probe evidence. +- Clear output that distinguishes a child exit from launcher-initiated + shutdown. +- Reverse-proxy CIDR and startup troubleshooting documentation. + +### Out of scope + +- Automatic private-network trust. +- A new configuration key, CLI flag, or Settings page. +- Changes to `/health` or ACP probing. +- General parsing or ranking of backend log messages. + +## Technical approach + +### Endpoint resolution and readiness + +Add a launcher-owned endpoint resolver beside `network.go`. It consumes +`ServerConfig.ResolvedBinds()` and the selected backend port. It returns an +ordered probe-target set, a corresponding browser-URL set, and the preferred +access URL. An IPv4 wildcard is probed through `127.0.0.1` while its default +browser URL remains `localhost`. Change `waitForHealth` to probe all targets +concurrently while selecting the highest-priority healthy target. Thread the +endpoint set through `runManagedApp` and `runDev` without changing port +selection. + +### Failure evidence and presentation + +Replace string-only readiness errors with a typed error that retains bounded +per-target observations and child state. Format one summary at the launcher +boundary. Include configuration provenance from `config.Config.Source`, the +expected backend log path, and a class-specific next action. Keep the existing +bounded backend-output dump. + +### Public guidance + +Update the CLI health-timeout section with the new output and the distinction +between readiness, ACP closure messages, and trusted-proxy warnings. Update the +configuration guide with exact-IP and CIDR proxy examples. State that the +trusted range must contain controlled proxies, not browser clients. + +## Tests + +- `internal/launcher/network_test.go` covers IPv4, IPv6, wildcard, loopback, + specific-address, multi-bind, and access-URL resolution. +- `internal/launcher/health_test.go` covers concurrent multi-target success, + target-priority selection, target observations, token mismatch, child exit, + cancellation, and timeout. +- `internal/launcher/start_test.go` and `dev_test.go` cover shared endpoint + wiring and failure presentation. +- Public documentation validation covers links, frontmatter, and navigation. + +## Work orders + +- [x] [Task 01: Resolve and probe effective bind addresses](task-01-bind-aware-readiness.md) +- [x] [Task 02: Present actionable startup failures](task-02-actionable-startup-failures.md) +- [x] [Task 03: Document startup and proxy recovery](task-03-startup-proxy-guidance.md) + +## Verification results + +- Task 01 targeted launcher tests passed: `go test ./internal/launcher -run 'Test(BackendEndpoint|WaitForHealth|RunManagedApp|RunDev)' -count=1`. +- Task 02 targeted launcher tests passed: `go test ./internal/launcher -run 'Test(WaitForHealth|StartupFailure|RunManagedApp|RunDev)' -count=1`; concurrent readiness coverage also passed under `go test -race ./internal/launcher -run 'TestWaitForHealth' -count=1`. +- The full launcher package passed: `go test ./internal/launcher -count=1`. +- Public documentation validation passed: `node --test scripts/validate-public-docs.test.mjs`, `node scripts/validate-public-docs.mjs`, and `python3 scripts/lint-spec-files.py --all`. + +The launcher test commands were run with the task runner's inherited +`KANDEV_INTERNAL_CONFIG_FILE` and `KANDEV_INTERNAL_CONFIG_HOME_FILE` unset so +service configuration tests could select their temporary fixtures. + +## Risks + +- Hostnames and IPv6 addresses must use correct URL and socket formatting. +- Multi-target probes must not leak goroutines after cancellation. +- Desktop-owned health tokens and supervisor restarts must retain their current + ownership contracts. +- A broad trusted-proxy example can weaken header trust if the operator exposes + the backend directly. diff --git a/docs/plans/launcher-startup-recovery/task-01-bind-aware-readiness.md b/docs/plans/launcher-startup-recovery/task-01-bind-aware-readiness.md new file mode 100644 index 0000000000..ab54c210f6 --- /dev/null +++ b/docs/plans/launcher-startup-recovery/task-01-bind-aware-readiness.md @@ -0,0 +1,101 @@ +--- +id: "01-bind-aware-readiness" +title: "Resolve and probe effective bind addresses" +status: done +wave: 1 +depends_on: [] +plan: "plan.md" +requirements: + - REQ-LAUNCHER-STARTUP-001 +acceptance_criteria: + - AC-LAUNCHER-STARTUP-001.1 + - AC-LAUNCHER-STARTUP-001.2 + - AC-LAUNCHER-STARTUP-001.3 + - AC-LAUNCHER-STARTUP-001.4 + - AC-LAUNCHER-STARTUP-001.5 + - AC-LAUNCHER-STARTUP-001.6 +system_design: + - ../../specs/launcher/system-design/startup-recovery.md +--- + +# Task 01: Resolve and Probe Effective Bind Addresses + +## Summary + +Derive health targets and the access URL from the effective backend binds. +Probe all targets with the existing launch token so a specific LAN bind works +without a loopback listener. + +## In scope + +- Add the immutable backend endpoint set and resolver. +- Map IPv4 and IPv6 wildcard binds to their loopback families while preserving + the default `localhost` browser origin for IPv4. +- Probe multiple targets concurrently while preserving loopback-first target + priority. +- Use the resolved access URL in `dev`, `start`, and `run` output and browser + opening. + +## Out of scope + +- Human-readable failure summaries beyond the existing error text. +- Changes to port selection or backend listener behavior. +- Proxy-trust configuration. + +## Acceptance + +- A single specific LAN bind becomes ready without a loopback listener. +- Any owned healthy target makes a multi-bind launch ready. +- The printed or opened URL is reachable through the effective bind set. + +## Verification + +```bash +(cd apps/backend && go test ./internal/launcher -run 'Test(BackendEndpoint|WaitForHealth|RunManagedApp|RunDev)' -count=1 && go test ./internal/launcher -count=1) +``` + +## Files likely touched + +- `apps/backend/internal/launcher/network.go` +- `apps/backend/internal/launcher/network_test.go` +- `apps/backend/internal/launcher/health.go` +- `apps/backend/internal/launcher/health_test.go` +- `apps/backend/internal/launcher/start.go` +- `apps/backend/internal/launcher/start_test.go` +- `apps/backend/internal/launcher/dev.go` +- `apps/backend/internal/launcher/dev_test.go` + +## Dependencies + +None. + +## Risks + +- Concurrent probes must end when the shared context ends. +- Desktop token handoff must remain unchanged. + +## Parallelism + +`sequential` + +## Inputs + +- `REQ-LAUNCHER-STARTUP-001` and the endpoint-resolution design. +- Existing `ResolvedBinds`, `waitForHealth`, and launcher-mode tests. + +## Results + +Implemented the endpoint set used by `dev`, `start`, and `run`. It derives +separate health targets and browser URLs from `ResolvedBinds()`, probes IPv4 +wildcards through `127.0.0.1` while preserving the default `localhost` browser +origin, maps IPv6 wildcards to `[::1]`, prefers loopback access, deduplicates +targets, and probes concurrently while retaining target priority. A lower- +priority success waits for higher-priority probes to complete without the +launch token. The launcher preserves the existing health-token ownership +contract. + +Verification passed: + +- `go test ./internal/launcher -run 'Test(BackendEndpoint|WaitForHealth|RunManagedApp|RunDev)' -count=1` +- `go test ./internal/launcher -count=1` +- `go test -race ./internal/launcher -run 'TestWaitForHealth' -count=1` diff --git a/docs/plans/launcher-startup-recovery/task-02-actionable-startup-failures.md b/docs/plans/launcher-startup-recovery/task-02-actionable-startup-failures.md new file mode 100644 index 0000000000..d6db563535 --- /dev/null +++ b/docs/plans/launcher-startup-recovery/task-02-actionable-startup-failures.md @@ -0,0 +1,97 @@ +--- +id: "02-actionable-startup-failures" +title: "Present actionable startup failures" +status: done +wave: 2 +depends_on: + - "01-bind-aware-readiness" +plan: "plan.md" +requirements: + - REQ-LAUNCHER-STARTUP-002 +acceptance_criteria: + - AC-LAUNCHER-STARTUP-002.1 + - AC-LAUNCHER-STARTUP-002.2 + - AC-LAUNCHER-STARTUP-002.3 + - AC-LAUNCHER-STARTUP-002.4 + - AC-LAUNCHER-STARTUP-002.5 + - AC-LAUNCHER-STARTUP-002.6 +system_design: + - ../../specs/launcher/system-design/startup-recovery.md +--- + +# Task 02: Present Actionable Startup Failures + +## Summary + +Return typed readiness evidence and print one concise failure summary. State +whether the backend exited or the launcher stopped a live process. + +## In scope + +- Add typed health observations and failure classes. +- Retain the last safe result for each health target. +- Print effective binds, configuration provenance, log path, and the relevant + next action. +- Keep one bounded backend-output dump and one supervisor shutdown. + +## Out of scope + +- Parsing backend or ACP log messages. +- Changing backend log retention or diagnostic bundles. +- Adding metrics or persisted failure records. + +## Acceptance + +- Output distinguishes early exit, connection timeout, non-success HTTP, and + a foreign process. +- A live backend stopped after timeout is not described as crashed. +- Failure output contains no health token or sensitive configuration value. + +## Verification + +```bash +(cd apps/backend && go test ./internal/launcher -run 'Test(WaitForHealth|StartupFailure|RunManagedApp|RunDev)' -count=1 && go test ./internal/launcher -count=1) +``` + +## Files likely touched + +- `apps/backend/internal/launcher/health.go` +- `apps/backend/internal/launcher/health_test.go` +- `apps/backend/internal/launcher/start.go` +- `apps/backend/internal/launcher/start_test.go` +- `apps/backend/internal/launcher/dev.go` +- `apps/backend/internal/launcher/dev_test.go` +- `apps/backend/internal/launcher/bootstrap.go` + +## Dependencies + +Task 01 supplies the ordered health targets and access URL. + +## Risks + +- Error details must stay bounded and must not expose tokens. +- Output must remain useful in terminal, service, and desktop capture paths. + +## Parallelism + +`sequential` + +## Inputs + +- `REQ-LAUNCHER-STARTUP-002` and the failure-presentation design. +- Existing process-output capture and supervisor shutdown behavior. + +## Results + +Added typed per-target health observations and readiness failure classes for +early exits, unreachable backends, unhealthy HTTP responses, and different +processes. Startup output now includes effective binds, target outcomes, +configuration provenance, backend log path, process state, a class-specific +next step, and the public troubleshooting guide. Health tokens, response +bodies, and sensitive configuration values are not included. Backend output is +still bounded and dumped once before the single supervisor shutdown. + +Verification passed: + +- `go test ./internal/launcher -run 'Test(WaitForHealth|StartupFailure|RunManagedApp|RunDev)' -count=1` +- `go test ./internal/launcher -count=1` diff --git a/docs/plans/launcher-startup-recovery/task-03-startup-proxy-guidance.md b/docs/plans/launcher-startup-recovery/task-03-startup-proxy-guidance.md new file mode 100644 index 0000000000..dd3c14b75d --- /dev/null +++ b/docs/plans/launcher-startup-recovery/task-03-startup-proxy-guidance.md @@ -0,0 +1,89 @@ +--- +id: "03-startup-proxy-guidance" +title: "Document startup and proxy recovery" +status: done +wave: 3 +depends_on: + - "01-bind-aware-readiness" + - "02-actionable-startup-failures" +plan: "plan.md" +requirements: + - REQ-LAUNCHER-STARTUP-003 +acceptance_criteria: + - AC-LAUNCHER-STARTUP-003.1 + - AC-LAUNCHER-STARTUP-003.2 + - AC-LAUNCHER-STARTUP-003.3 +system_design: + - ../../specs/launcher/system-design/startup-recovery.md +--- + +# Task 03: Document Startup and Proxy Recovery + +## Summary + +Update the public CLI and configuration guides after the launcher behavior is +implemented. Give operators copyable exact-IP and CIDR examples and explain +which log messages are unrelated to readiness. + +## In scope + +- Document bind-aware readiness and the new failure summary. +- Explain early exit compared with launcher-initiated shutdown. +- Show exact-IP and narrow-CIDR trusted-proxy examples. +- Explain that `peer` identifies the immediate proxy address. +- State the security effect of trusting a CIDR. + +## Out of scope + +- A new documentation page or navigation entry. +- Deployment-specific Traefik or Kubernetes manifests. +- Automatic proxy discovery. + +## Acceptance + +- The CLI guide gives a short recovery procedure for every launcher failure + class. +- The configuration guide shows stable-proxy and dynamic-proxy-network YAML. +- The guidance does not recommend broad private-network trust as a default. + +## Verification + +```bash +node --test scripts/validate-public-docs.test.mjs +node scripts/validate-public-docs.mjs +``` + +## Files likely touched + +- `docs/public/cli.md` +- `docs/public/configuration.md` + +## Dependencies + +Tasks 01 and 02 define the shipped behavior and exact output. + +## Risks + +- The examples must not imply that browser-client networks are proxy networks. + +## Parallelism + +`sequential` + +## Inputs + +- `REQ-LAUNCHER-STARTUP-003` and the proxy-guidance design. +- Existing CLI troubleshooting and trusted-proxy reference sections. + +## Results + +Updated the public CLI and configuration guides with bind-aware readiness, +startup failure recovery classes, the distinction between readiness and ACP or +forwarded-host messages, exact-IP and narrow-CIDR trusted-proxy examples, and +the immediate proxy-peer security rule. + +Verification passed: + +- `node --test scripts/validate-public-docs.test.mjs` +- `node scripts/validate-public-docs.mjs` +- `python3 scripts/lint-spec-files.py --all` diff --git a/docs/public/cli.md b/docs/public/cli.md index 3e86c81b9c..0a5a4fe48d 100644 --- a/docs/public/cli.md +++ b/docs/public/cli.md @@ -127,8 +127,9 @@ On a normal start, the launcher: 2. creates the Kandev data directory with owner-only permissions where the platform supports Unix modes; 3. selects backend and `agentctl` ports; 4. starts and supervises the backend; -5. waits up to 45 seconds for `/health`; and -6. opens the printed local URL in the default browser. +5. derives one or more `/health` targets from the effective server binds; +6. waits up to 45 seconds for any target to return the launcher's health token; and +7. opens the reachable access URL in the default browser. The launcher remains in the foreground. Press `Ctrl+C` or terminate it to stop the backend and its managed children cleanly. A force-kill can leave worktree processes or containers running; inspect them before deleting data. @@ -225,9 +226,11 @@ Supported actions are `install`, `uninstall`, `start`, `stop`, `restart`, `statu There is no separate web-server port in an installed release: the backend serves embedded assets. For the `run`, `start`, and development launcher flows, if `--port`, `KANDEV_BACKEND_PORT`, or `KANDEV_PORT` specifies a port, the launcher checks it before declaring the backend ready, does not substitute another one, and fails startup if the configured listen address cannot bind it. `kandev service install --port` remains the separate installer behavior described above. -The launcher prints `http://localhost:`. When it can enumerate non-loopback interfaces, it also prints `network:` URLs for each unique non-loopback, non-link-local address on the same port, including local-network and Tailscale addresses. IPv6 addresses are shown in brackets. If `KANDEV_SERVER_HOST` restricts the listener, the launcher only prints matching addresses. These lines are informational and are omitted if interface discovery is unavailable. +The launcher derives readiness targets and the access URL from the effective `server.host` or `server.hosts` values. A specific IP or hostname is probed directly. An IPv4 wildcard is probed through `127.0.0.1`, while the default local browser/access URL remains `http://localhost:` to preserve the established browser origin. An IPv6 wildcard is probed and accessed through `[::1]`. With multiple binds, the launcher probes all targets concurrently but preserves target priority: a lower-priority success is selected only after higher-priority targets complete without the launcher's health token. It prefers a loopback target for the printed or opened access URL. -The backend's default `server.host` is `0.0.0.0`. That can expose Kandev to other machines on the network, and the current local product path is not an authenticated multi-user boundary. Bind it to loopback unless remote access is deliberately protected: +When it can enumerate non-loopback interfaces, the launcher also prints `network:` URLs for each unique non-loopback, non-link-local address on the same port, including local-network and Tailscale addresses. IPv6 addresses are shown in brackets. If the effective bind restricts the listener, the launcher only prints matching addresses. These lines are informational and are omitted if interface discovery is unavailable. + +The backend's default `server.host` is `0.0.0.0`. That can expose Kandev to other machines on the network, and the current local product path is not an authenticated multi-user boundary. The launcher uses `localhost` for its default local access URL, but the backend still listens on every interface. Bind it to loopback unless remote access is deliberately protected: ```bash KANDEV_SERVER_HOST=127.0.0.1 kandev @@ -335,7 +338,20 @@ kandev --verbose KANDEV_HEALTH_TIMEOUT_MS=90000 kandev --verbose ``` -The launcher prints buffered backend output when startup fails. Common causes are an invalid `config.yaml`, a database migration/permission error, an occupied explicit port, or a damaged runtime bundle. +The launcher prints buffered backend output once when startup fails, followed by a bounded summary. The summary includes the effective bind addresses, every attempted health target and its last safe outcome, the selected configuration file, the effective `server.host` source, the backend log path, one next step, and a link to this troubleshooting guide. It never prints the launcher's health token or sensitive configuration values. + +Use the failure class to choose the next action: + +| Failure class | Meaning and recovery | +|---|---| +| Early backend exit | The child stopped before readiness. Read the named backend log for the startup error. | +| Unreachable backend | No target accepted a connection. Check the effective binds, firewall rules, and environment overrides. | +| Unhealthy HTTP response | A target answered with a non-success status. Inspect that status and the backend log. | +| Different process | A selected port answered without the launcher's token. Free the port or choose another backend port. | + +If the backend is still running when readiness expires, the summary says that the launcher stopped it after readiness failed. It does not describe that case as a backend crash. Common underlying causes include an invalid `config.yaml`, a database migration or permission error, an occupied explicit port, or a damaged runtime bundle. + +ACP probe closure messages occur after an agent protocol probe and do not cause a launcher `/health` timeout. Likewise, an `X-Forwarded-Host` warning means that a reverse proxy reached a running backend without trusted-proxy configuration; it is not a launcher readiness failure. Configure the immediate proxy peer under `server.trustedProxies` as described in [Configuration](./configuration.md). ### Browser does not open diff --git a/docs/public/configuration.md b/docs/public/configuration.md index 47ac3b448b..fca6111bfd 100644 --- a/docs/public/configuration.md +++ b/docs/public/configuration.md @@ -64,7 +64,7 @@ Some common camelCase keys have explicit compatibility aliases. Use the document | YAML key | Environment variable | Default | Current behavior | |---|---|---|---| | `homeDir` | `KANDEV_HOME_DIR` | `~/.kandev` | Root for data, tasks, worktrees, cloned repositories, sessions, and logs. A leading `~/` expands. | -| `server.host` | `KANDEV_SERVER_HOST` | `0.0.0.0` | HTTP listen address. Use `127.0.0.1` for local-only access. | +| `server.host` | `KANDEV_SERVER_HOST` | `0.0.0.0` | HTTP listen address. It accepts one hostname/IP or a comma-separated list. Use `127.0.0.1` for local-only access. | | `server.port` | `KANDEV_SERVER_PORT` (`KANDEV_BACKEND_PORT`, `KANDEV_PORT` aliases) | `38429` | UI, HTTP API, WebSocket, and MCP port; must be `1`-`65535`. The launcher normally supplies its selected port. | | `server.readTimeout` | `KANDEV_SERVER_READTIMEOUT` | `30` | HTTP read timeout in seconds. | | `server.writeTimeout` | `KANDEV_SERVER_WRITETIMEOUT` | `30` | HTTP write timeout in seconds. | @@ -72,7 +72,7 @@ Some common camelCase keys have explicit compatibility aliases. Use the document | `server.webTitlePrefix` | `KANDEV_WEB_TITLE_PREFIX` | empty | Prefixes the browser tab title as ` Kandev` (for example `TEST` renders `TEST Kandev`), so several instances stay distinguishable in adjacent tabs. `make dev` defaults to `Dev`; `make start-debug` keeps production defaults, enables diagnostics, and defaults to `Debug`; PR previews use `Preview`. An explicit value overrides these defaults. Empty keeps the plain `Kandev` title. | | `server.trustedProxies` | `KANDEV_TRUSTED_PROXIES` | empty list | IP addresses or CIDR ranges for proxies whose forwarded client headers Kandev accepts. See [Trusted proxies](#trusted-proxies-for-x-forwarded-for). | -The default host exposes the server on every interface even though the CLI prints a `localhost` URL. The current local product path must not be treated as an authenticated multi-user perimeter. For remote access, bind to loopback and use a trusted authenticated tunnel/proxy, or isolate the network at the deployment layer. +When `server.host` is unset, `server.hosts` may provide a YAML list of bind addresses. The launcher derives its health targets and access URL from this resolved set. It probes an IPv4 wildcard through `127.0.0.1` and keeps `localhost` as the default browser/access URL, while it probes and accesses an IPv6 wildcard through `[::1]`. The backend still listens on every interface for a wildcard bind. The current local product path must not be treated as an authenticated multi-user perimeter. For remote access, bind to loopback and use a trusted authenticated tunnel/proxy, or isolate the network at the deployment layer. ### Database @@ -207,15 +207,28 @@ ignored and the TCP peer address is used. The resolved IP feeds the login session record (Settings > Account > Security) and the login rate-limiter key. -For a reverse proxy, use a narrow list of proxy addresses: +For one stable reverse proxy, list its immediate TCP peer as an exact IP: ```yaml server: trustedProxies: - 10.0.0.5 - - 192.168.0.0/16 ``` +For a controlled proxy network whose address changes, use the narrowest CIDR +that contains the proxy peers: + +```yaml +server: + trustedProxies: + - 10.0.0.0/28 +``` + +Use the proxy `peer` address named in the warning. Do not list the browser's +client network. A trusted CIDR lets every directly connected host in that +range supply forwarded identity headers, so use an exact IP when the proxy +address is stable and never trust a broad private-network range by default. + Default: unset, meaning no trusted proxies. Forwarded headers are ignored entirely and the recorded client IP is always the TCP peer. This is the secure default: gin would otherwise trust every proxy by default, which lets diff --git a/docs/specs/launcher/README.md b/docs/specs/launcher/README.md new file mode 100644 index 0000000000..824b9f225d --- /dev/null +++ b/docs/specs/launcher/README.md @@ -0,0 +1,57 @@ +--- +status: active +system: launcher +specification_version: 1 +migration: in_progress +owners: + - kandev +--- + +# Launcher system + +## Purpose + +The launcher system starts and supervises Kandev processes for the `dev`, +`start`, `run`, and service entrypoints. It gives operators one startup result, +one reachable access URL, and useful failure evidence. + +## Ownership + +This system owns launch-mode dispatch, managed-process startup and shutdown, +port selection, backend readiness probes, startup output, and supervisor +handoffs. + +## Exclusions + +- The backend owns HTTP routes and the `/health` response. +- The common configuration system owns configuration discovery, precedence, + parsing, and validation. +- Authentication owns forwarded-header trust after the backend accepts a + request. +- Agent runtime launch and ACP probes are not backend startup readiness. + +## Specification map + +### Requirements + +- [Startup recovery](requirements/startup-recovery.md) + +### System design + +- [Startup recovery](system-design/startup-recovery.md) + +## Migration status + +The startup-recovery documents are authoritative for bind-aware readiness and +launcher failure output. The legacy +[startup configuration parity](../platform/startup-configuration-parity.md) +specification remains authoritative for configuration discovery and +precedence. The legacy [Go development launcher](../go-dev-launcher/spec.md) +specification remains historical implementation context for the native +launcher migration. + +## Related systems + +- [Platform specifications](../platform/): define cross-cutting startup + configuration and diagnostic-log behavior. +- [Authentication specifications](../auth/): define trusted-proxy behavior. diff --git a/docs/specs/launcher/requirements/startup-recovery.md b/docs/specs/launcher/requirements/startup-recovery.md new file mode 100644 index 0000000000..d56b0a237a --- /dev/null +++ b/docs/specs/launcher/requirements/startup-recovery.md @@ -0,0 +1,104 @@ +--- +status: active +system: launcher +created: 2026-08-24 +owners: + - kandev +--- + +# Startup Recovery Requirements + +## Overview + +Operators can bind Kandev to loopback, a specific network interface, multiple +interfaces, or a wildcard address. The native launcher must reach the backend +without requiring an undocumented loopback listener. If startup fails, the +launcher must identify what failed and give the operator a direct next step. + +## Terminology + +- **Bind address:** An effective address on which the backend tries to accept + HTTP connections. +- **Health target:** A local HTTP URL that the launcher probes for readiness. +- **Access URL:** The URL that the launcher prints or opens for a local user. +- **Owned backend:** A backend whose health response contains the launcher's + per-launch health token. + +## Requirements + +### REQ-LAUNCHER-STARTUP-001: Bind-aware readiness + +**Intent:** Let an operator select a valid bind address without also having to +understand the launcher's readiness implementation. + +#### Acceptance criteria + +- **AC-LAUNCHER-STARTUP-001.1:** When the effective configuration contains one + specific non-loopback bind address, the launcher shall probe that address and + shall not require a loopback listener. +- **AC-LAUNCHER-STARTUP-001.2:** When the effective bind address is an IPv4 or + IPv6 wildcard, the launcher shall probe the corresponding loopback family. +- **AC-LAUNCHER-STARTUP-001.3:** When the effective configuration contains + multiple bind addresses, the launcher shall try every health target and + shall become ready after any target returns the matching health token. +- **AC-LAUNCHER-STARTUP-001.4:** When at least one configured address serves the + owned backend, a failed or unavailable sibling address shall not block + startup. +- **AC-LAUNCHER-STARTUP-001.5:** When the launcher prints or opens an access + URL, that URL shall use a reachable effective bind and shall prefer loopback. + For an IPv4 wildcard, readiness shall use `127.0.0.1` while the default + browser URL shall remain `http://localhost:` to preserve the established + browser origin. An IPv6 wildcard shall use `[::1]` for readiness and access. +- **AC-LAUNCHER-STARTUP-001.6:** The `dev`, `start`, and `run` modes shall use + the same bind and readiness rules. + +### REQ-LAUNCHER-STARTUP-002: Actionable startup failure + +**Intent:** Explain a startup failure without requiring the operator to infer +the cause from unrelated backend or ACP log lines. + +#### Acceptance criteria + +- **AC-LAUNCHER-STARTUP-002.1:** When backend readiness fails, the launcher + shall classify the result as an early backend exit, an unreachable backend, + an unhealthy HTTP response, or a response from a different process. +- **AC-LAUNCHER-STARTUP-002.2:** When the backend is still running at the + readiness deadline, the launcher shall state that it stopped the backend + after readiness failed. It shall not describe that result as a backend crash. +- **AC-LAUNCHER-STARTUP-002.3:** A readiness failure shall show the effective + bind addresses, attempted health targets, last safe outcome for each target, + configuration source, backend log path, and one relevant next step. +- **AC-LAUNCHER-STARTUP-002.4:** When a health response contains a missing or + different launcher token, the failure shall identify a different process on + the selected port as the likely cause. +- **AC-LAUNCHER-STARTUP-002.5:** Startup diagnostics shall not print health + tokens, secrets, database credentials, or other configuration values marked + as sensitive. +- **AC-LAUNCHER-STARTUP-002.6:** When the launcher cannot classify the cause, + it shall preserve the backend output and shall link to the startup + troubleshooting guide. + +### REQ-LAUNCHER-STARTUP-003: Proxy configuration guidance + +**Intent:** Help an operator distinguish startup readiness from reverse-proxy +trust configuration. + +#### Acceptance criteria + +- **AC-LAUNCHER-STARTUP-003.1:** Public startup troubleshooting shall state + that ACP probe closure messages and forwarded-host warnings do not cause a + launcher health timeout. +- **AC-LAUNCHER-STARTUP-003.2:** Public trusted-proxy guidance shall show both + exact-IP and CIDR configuration and shall identify the immediate proxy peer, + not the browser client, as the trusted address. +- **AC-LAUNCHER-STARTUP-003.3:** Public guidance shall warn that a trusted CIDR + lets directly connected hosts in that range supply forwarded identity + headers. + +## Out of scope + +- Automatically trusting private, container, or cluster networks. +- Inferring a subnet mask from one observed proxy address. +- Changing the `/health` response or health-token contract. +- Parsing arbitrary backend log text to guess subsystem failures. +- Adding a new configuration key or Settings UI for startup configuration. diff --git a/docs/specs/launcher/system-design/startup-recovery.md b/docs/specs/launcher/system-design/startup-recovery.md new file mode 100644 index 0000000000..55d90bd844 --- /dev/null +++ b/docs/specs/launcher/system-design/startup-recovery.md @@ -0,0 +1,179 @@ +--- +status: current +system: launcher +requirements: + - REQ-LAUNCHER-STARTUP-001 + - REQ-LAUNCHER-STARTUP-002 + - REQ-LAUNCHER-STARTUP-003 +--- + +# Startup Recovery System Design + +## Purpose and boundaries + +The native launcher derives readiness and access endpoints from the same +effective bind configuration that it passes to the backend. It records probe +facts at the launcher boundary and uses those facts for a concise startup +failure report. The launcher does not inspect ACP logs or infer trusted +networks. + +The design applies to `dev`, `start`, and `run`. A managed service reaches the +same behavior when its service command invokes `run` in headless mode. + +## Requirement mapping + +| Requirement | Design section | +| --- | --- | +| `REQ-LAUNCHER-STARTUP-001` | [Endpoint resolution](#endpoint-resolution), [Readiness flow](#readiness-flow) | +| `REQ-LAUNCHER-STARTUP-002` | [Probe evidence](#probe-evidence), [Failure presentation](#failure-presentation) | +| `REQ-LAUNCHER-STARTUP-003` | [Proxy guidance](#proxy-guidance), [Security](#security) | + +## Components and responsibilities + +- `internal/common/config.ServerConfig.ResolvedBinds` remains the authority for + validated and de-duplicated bind addresses. +- `internal/launcher` resolves backend endpoints after it selects the backend + port and before it starts the child process. +- `waitForHealth` probes the resolved health targets and returns typed evidence. +- `runManagedApp` and `runDev` present one failure summary and then use the + existing supervisor shutdown path. +- `internal/auth/httpmw` continues to enforce forwarded-header trust. Public + documentation explains exact-IP and CIDR choices. + +## Endpoint resolution + +The launcher creates one immutable endpoint set for each launch: + +```text +backendEndpointSet + bindHosts effective values from ResolvedBinds + healthTargets ordered local HTTP probe URLs + accessURLs browser URLs corresponding to each health target + accessURL preferred URL for output and browser opening +``` + +The resolver applies these rules: + +1. Map `0.0.0.0` to `127.0.0.1` for the health target and retain `localhost` + as the default browser/access URL. +2. Map `::` to `::1` and format it with brackets for both probing and access. +3. Keep a specific IP or hostname as a target. +4. Put loopback targets before non-loopback targets. +5. Remove duplicate URLs after wildcard mapping and hostname normalization. +6. Probe every target concurrently, but select a healthy target in priority + order. A lower-priority success cannot win until every higher-priority + target has completed without the launch token. +7. Use the access URL corresponding to the selected health target. + +The endpoint set is separate from `portConfig`. Port selection remains a port +concern. Bind resolution remains a configuration concern. + +## Probe evidence + +Each health target has one latest observation: + +```text +healthObservation + URL + outcome healthy | connection_error | http_status | foreign_process + statusCode present only for an HTTP response + safeDetail bounded network error class without a token or response body +``` + +`backendHealthError` contains the failure class, deadline, child exit state, +and final observations. The type supports `errors.As`. Human text is formatted +only at the launcher presentation boundary. + +The launcher never records or prints the expected token. It drains and closes +responses as it does now. + +## Readiness flow + +For each polling interval, the launcher probes all unresolved targets with the +shared deadline and the existing per-request timeout. Probes in one interval +run concurrently so an unreachable address cannot delay a healthy sibling. +The interval completes when all probes return or their request contexts end. +Selection still follows target priority: a LAN response that arrives before a +loopback response is held until the loopback probe completes, so a healthy +loopback target remains preferred. + +A `2xx` response with the matching token makes the backend ready. Other target +results update their observations. A child exit ends polling immediately. The +existing overall deadline and cancellation behavior remain in force. + +The launcher opens or prints `accessURL` only after an owned backend responds. +If a non-loopback-only bind is selected, the launcher uses that address instead +of printing an unreachable localhost URL. + +## Failure presentation + +The launcher prints the captured backend output once. It then prints one +summary with these fields: + +- failure class and plain-language result +- whether the backend exited or the launcher stopped a live backend +- effective bind addresses and value source +- each health target and its last observation +- selected configuration-file path, or `defaults and environment` +- effective source for `server.host`, including `KANDEV_SERVER_HOST` when an + environment override wins over the selected file +- expected backend-log path +- one next action plus the public troubleshooting URL. + +The next action follows the failure class. A foreign-process result tells the +operator to free or change the port. An early exit tells the operator to read +the named backend log. A connection-only timeout tells the operator to inspect +binds, firewall rules, and environment overrides. A non-success HTTP result +shows the status code. + +The report uses short stable labels. Tests assert the facts and labels, not a +large terminal snapshot. + +## Proxy guidance + +The launcher failure summary does not present trusted-proxy warnings as a +readiness cause. The CLI troubleshooting section explains that these warnings +occur only after a reverse proxy reaches the backend. + +The configuration guide provides two copyable examples: + +- one stable reverse-proxy IP +- a narrow CIDR for a controlled proxy network with dynamic addresses. + +The guide tells the operator to use the `peer` value from the warning. It also +explains that the CIDR is the proxy network, not the client network. + +## Failure and recovery + +- If bind resolution fails, startup stops before the backend starts and names + the invalid setting through existing configuration validation. +- If one target is unavailable and another target is healthy, startup + succeeds. +- If every target remains unavailable, the launcher stops the owned backend + through the existing graceful supervisor path. +- If the child exits, the launcher reports its exit code and does not wait for + the readiness deadline. +- If a different process answers, the launcher never accepts it as ready. + +## Security + +The per-launch token remains mandatory for owned-backend readiness. Probe +errors include no response body or token. + +Kandev does not automatically trust RFC 1918, loopback, link-local, container, +or cluster ranges. An automatic rule can let another directly connected host +inside that range supply `X-Forwarded-For` and affect login attribution and rate +limits. Operators explicitly select exact proxy addresses or controlled proxy +CIDRs. + +## Observability + +Startup output records the resolved bind set, access URL, health targets, and +failure class. Existing backend file logs and bounded captured stdout remain +the detailed evidence. ACP probe lifecycle messages remain separate from +backend startup readiness. + +## Related decisions + +- [The Go launcher owns every entrypoint](../../../decisions/2026-08-08-go-launcher-owns-all-launch-modes.md) +- [Startup configuration uses one typed source model](../../../decisions/2026-08-20-startup-configuration-source-parity.md)