From adad2bbd1cb75124962c839a750fcafe031fe8b6 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Tue, 3 Mar 2026 15:43:22 +0000 Subject: [PATCH] fix(server): fail fast on frontend bind errors --- cmd/pulse/commands_test.go | 29 +++++++++---- pkg/server/server.go | 86 ++++++++++++++++++++++---------------- 2 files changed, 71 insertions(+), 44 deletions(-) diff --git a/cmd/pulse/commands_test.go b/cmd/pulse/commands_test.go index 5487560702..fba217e046 100644 --- a/cmd/pulse/commands_test.go +++ b/cmd/pulse/commands_test.go @@ -36,6 +36,16 @@ func createTestEncryptionKey(t *testing.T, dir string) { } } +func getFreeTCPPort(t *testing.T) int { + t.Helper() + l, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("failed to allocate free tcp port: %v", err) + } + defer l.Close() + return l.Addr().(*net.TCPAddr).Port +} + func TestVersionCmd(t *testing.T) { oldVersion := Version oldBuildTime := BuildTime @@ -442,7 +452,7 @@ func TestRunServer(t *testing.T) { tempDir := t.TempDir() t.Setenv("PULSE_DATA_DIR", tempDir) - t.Setenv("PULSE_FRONTEND_PORT", "0") + t.Setenv("FRONTEND_PORT", fmt.Sprintf("%d", getFreeTCPPort(t))) // Create a dummy .env to avoid config load error createTestEncryptionKey(t, tempDir) @@ -474,7 +484,7 @@ func TestSIGHUP(t *testing.T) { tempDir := t.TempDir() t.Setenv("PULSE_DATA_DIR", tempDir) - t.Setenv("PULSE_FRONTEND_PORT", "0") + t.Setenv("FRONTEND_PORT", fmt.Sprintf("%d", getFreeTCPPort(t))) createTestEncryptionKey(t, tempDir) os.WriteFile(filepath.Join(tempDir, "nodes.enc"), []byte("data"), 0644) @@ -602,7 +612,7 @@ func TestRunServer_HTTPS(t *testing.T) { func TestRunServer_ConfigReload(t *testing.T) { tempDir := t.TempDir() t.Setenv("PULSE_DATA_DIR", tempDir) - t.Setenv("PULSE_FRONTEND_PORT", "0") + t.Setenv("FRONTEND_PORT", fmt.Sprintf("%d", getFreeTCPPort(t))) createTestEncryptionKey(t, tempDir) os.WriteFile(filepath.Join(tempDir, "nodes.enc"), []byte("data"), 0644) @@ -638,6 +648,10 @@ func TestRunServer_ConfigReload(t *testing.T) { } func TestMainCmd(t *testing.T) { + oldPort := metricsPort + metricsPort = 0 + defer func() { metricsPort = oldPort }() + // Root command without args should run runServer // But we don't want it to block forever ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) @@ -645,6 +659,7 @@ func TestMainCmd(t *testing.T) { tempDir := t.TempDir() t.Setenv("PULSE_DATA_DIR", tempDir) + t.Setenv("FRONTEND_PORT", fmt.Sprintf("%d", getFreeTCPPort(t))) createTestEncryptionKey(t, tempDir) os.WriteFile(filepath.Join(tempDir, "nodes.enc"), []byte("data"), 0644) @@ -922,11 +937,9 @@ func TestRunServer_FrontendFail(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond) defer cancel() - output := captureOutput(func() { - runServer(ctx) - }) - // Expect "Failed to start HTTP server" - assert.Contains(t, output, "Failed to start HTTP server") + err := runServer(ctx) + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to bind frontend listener") } // Helper to capture stdout and stderr diff --git a/pkg/server/server.go b/pkg/server/server.go index 88cb50b667..6fecec5c16 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -4,6 +4,7 @@ import ( "context" "encoding/base64" "fmt" + "net" "net/http" "os" "os/signal" @@ -256,15 +257,6 @@ func Run(ctx context.Context, version string) error { // Wire multi-tenant monitor to resource handlers for tenant-aware state router.SetMultiTenantMonitor(reloadableMonitor.GetMultiTenantMonitor()) - // Start AI patrol service for background infrastructure monitoring - router.StartPatrol(ctx) - - // Start AI chat service - router.StartAIChat(ctx) - - // Wire alert-triggered AI analysis - router.WireAlertTriggeredAI() - // Create HTTP server with unified configuration srv := &http.Server{ Addr: fmt.Sprintf("%s:%d", cfg.BindAddress, cfg.FrontendPort), @@ -274,6 +266,48 @@ func Run(ctx context.Context, version string) error { IdleTimeout: 120 * time.Second, } + useTLS := cfg.HTTPSEnabled && cfg.TLSCertFile != "" && cfg.TLSKeyFile != "" + if cfg.HTTPSEnabled && !useTLS { + log.Warn().Msg("HTTPS_ENABLED is true but TLS_CERT_FILE or TLS_KEY_FILE not configured, falling back to HTTP") + } + + listener, err := net.Listen("tcp", srv.Addr) + if err != nil { + return fmt.Errorf("failed to bind frontend listener on %s: %w", srv.Addr, err) + } + + serverErrCh := make(chan error, 1) + go func() { + if useTLS { + log.Info(). + Str("host", cfg.BindAddress). + Int("port", cfg.FrontendPort). + Str("protocol", "HTTPS"). + Msg("Server listening") + if err := srv.ServeTLS(listener, cfg.TLSCertFile, cfg.TLSKeyFile); err != nil && err != http.ErrServerClosed { + serverErrCh <- fmt.Errorf("https server failed: %w", err) + } + } else { + log.Info(). + Str("host", cfg.BindAddress). + Int("port", cfg.FrontendPort). + Str("protocol", "HTTP"). + Msg("Server listening") + if err := srv.Serve(listener); err != nil && err != http.ErrServerClosed { + serverErrCh <- fmt.Errorf("http server failed: %w", err) + } + } + }() + + // Start AI patrol service for background infrastructure monitoring + router.StartPatrol(ctx) + + // Start AI chat service + router.StartAIChat(ctx) + + // Wire alert-triggered AI analysis + router.WireAlertTriggeredAI() + // Start config watcher for .env file changes configWatcher, err := config.NewConfigWatcher(cfg) if err != nil { @@ -304,35 +338,10 @@ func Run(ctx context.Context, version string) error { defer configWatcher.Stop() } - // Start server - go func() { - if cfg.HTTPSEnabled && cfg.TLSCertFile != "" && cfg.TLSKeyFile != "" { - log.Info(). - Str("host", cfg.BindAddress). - Int("port", cfg.FrontendPort). - Str("protocol", "HTTPS"). - Msg("Server listening") - if err := srv.ListenAndServeTLS(cfg.TLSCertFile, cfg.TLSKeyFile); err != nil && err != http.ErrServerClosed { - log.Error().Err(err).Msg("Failed to start HTTPS server") - } - } else { - if cfg.HTTPSEnabled { - log.Warn().Msg("HTTPS_ENABLED is true but TLS_CERT_FILE or TLS_KEY_FILE not configured, falling back to HTTP") - } - log.Info(). - Str("host", cfg.BindAddress). - Int("port", cfg.FrontendPort). - Str("protocol", "HTTP"). - Msg("Server listening") - if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { - log.Error().Err(err).Msg("Failed to start HTTP server") - } - } - }() - // Setup signal handlers sigChan := make(chan os.Signal, 1) reloadChan := make(chan os.Signal, 1) + var runErr error signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) signal.Notify(reloadChan, syscall.SIGHUP) @@ -343,6 +352,11 @@ func Run(ctx context.Context, version string) error { log.Info().Msg("Context cancelled, shutting down...") goto shutdown + case err := <-serverErrCh: + log.Error().Err(err).Msg("Frontend server terminated unexpectedly") + runErr = err + goto shutdown + case <-reloadChan: log.Info().Msg("Received SIGHUP, reloading configuration...") if configWatcher != nil { @@ -386,7 +400,7 @@ shutdown: tenantAuditManager.Close() log.Info().Msg("Server stopped") - return nil + return runErr } // startMetricsServer is moved from main.go