server: a port that cannot be bound stops startup (closes #266)
check / check (push) Canceled after 0s
check / check (push) Canceled after 0s
The start hook now binds the listen port itself and serves on it in a goroutine. A bind failure, such as a port already in use or a privileged port without root, is returned to fx, which stops startup and exits 1 with an error like "starting http server: listen tcp :8080: bind: address already in use". Before, the bind happened inside the goroutine and its error was only logged while the watcher kept running without the dashboard, API or health check. The http.Server is now built before that goroutine starts, so Shutdown no longer reads a field another goroutine may still be writing. TestPortInUseStopsStartup holds a port and checks that starting the server on it fails with an error naming it. Model: opus-5-5
This commit was merged in pull request #275.
This commit is contained in:
@@ -13,8 +13,8 @@ import (
|
||||
const RequestTimeout time.Duration = requestTimeout
|
||||
|
||||
// SetListenPort overrides the port Run binds. A test uses it to hand
|
||||
// Run an unbindable port so ListenAndServe fails immediately and Run
|
||||
// returns after storing its http.Server.
|
||||
// Run an unbindable port, so that Run stores its http.Server and then
|
||||
// returns an error without listening.
|
||||
func SetListenPort(s *Server, port int) {
|
||||
s.port = port
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
@@ -112,7 +113,7 @@ func New(
|
||||
}
|
||||
|
||||
lifecycle.Append(fx.Hook{
|
||||
OnStart: func(_ context.Context) error {
|
||||
OnStart: func(ctx context.Context) error {
|
||||
srv.startupTime = time.Now()
|
||||
|
||||
err := srv.enableSentry()
|
||||
@@ -120,9 +121,7 @@ func New(
|
||||
return err
|
||||
}
|
||||
|
||||
go srv.Run()
|
||||
|
||||
return nil
|
||||
return srv.Run(ctx)
|
||||
},
|
||||
OnStop: func(ctx context.Context) error {
|
||||
return srv.Shutdown(ctx)
|
||||
@@ -149,8 +148,10 @@ func newHTTPServer(
|
||||
}
|
||||
}
|
||||
|
||||
// Run starts the HTTP server.
|
||||
func (s *Server) Run() {
|
||||
// Run binds the listen port, then serves HTTP on it in a new goroutine
|
||||
// and returns. A port that cannot be bound is returned as an error, so
|
||||
// that startup stops instead of running without the HTTP server.
|
||||
func (s *Server) Run(ctx context.Context) error {
|
||||
s.SetupRoutes()
|
||||
|
||||
listenAddr := fmt.Sprintf(":%d", s.port)
|
||||
@@ -158,10 +159,21 @@ func (s *Server) Run() {
|
||||
|
||||
s.log.Info("http server starting", "addr", listenAddr)
|
||||
|
||||
err := s.httpServer.ListenAndServe()
|
||||
if err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
s.log.Error("http server error", "error", err)
|
||||
lc := &net.ListenConfig{}
|
||||
|
||||
listener, err := lc.Listen(ctx, "tcp", listenAddr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("starting http server: %w", err)
|
||||
}
|
||||
|
||||
go func() {
|
||||
err := s.httpServer.Serve(listener)
|
||||
if err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
s.log.Error("http server error", "error", err)
|
||||
}
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Shutdown gracefully shuts down the server, then sends the error
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package server_test
|
||||
|
||||
import (
|
||||
"net"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/viper"
|
||||
@@ -58,15 +60,13 @@ func buildServer(t *testing.T) *server.Server {
|
||||
}
|
||||
|
||||
// TestRunWiresSocketTimeouts pins that the http.Server the running
|
||||
// server actually serves — the one Run builds and hands to
|
||||
// ListenAndServe — carries every socket-level timeout, plus the two
|
||||
// server actually serves — the one Run builds and serves on the port
|
||||
// it binds — carries every socket-level timeout, plus the two
|
||||
// relationships the values must satisfy.
|
||||
//
|
||||
// Run is driven to completion with an unbindable port: it builds and
|
||||
// stores s.httpServer, then ListenAndServe fails at once and Run
|
||||
// returns without ever listening. The assertions run in the same
|
||||
// goroutine after Run returns, so reading s.httpServer is free of any
|
||||
// data race. Nothing here measures elapsed time.
|
||||
// Run is given an unbindable port: it builds and stores s.httpServer,
|
||||
// then the bind fails and Run returns the error without ever
|
||||
// listening. Nothing here measures elapsed time.
|
||||
//
|
||||
// ReadTimeout must be at least ReadHeaderTimeout. net/http reads the
|
||||
// headers under ReadHeaderTimeout, then sets the read deadline for the
|
||||
@@ -84,7 +84,10 @@ func TestRunWiresSocketTimeouts(t *testing.T) {
|
||||
srv := buildServer(t)
|
||||
server.SetListenPort(srv, -1)
|
||||
|
||||
srv.Run()
|
||||
err := srv.Run(t.Context())
|
||||
if err == nil {
|
||||
t.Fatal("Run bound port -1")
|
||||
}
|
||||
|
||||
hs := server.HTTPServerOf(srv)
|
||||
if hs == nil {
|
||||
@@ -133,3 +136,38 @@ func TestRunWiresSocketTimeouts(t *testing.T) {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPortInUseStopsStartup(t *testing.T) {
|
||||
// Bound on every address, as Run binds, so that Run's bind fails.
|
||||
lc := &net.ListenConfig{}
|
||||
|
||||
taken, err := lc.Listen(t.Context(), "tcp", ":0")
|
||||
if err != nil {
|
||||
t.Fatalf("binding a port for the test: %v", err)
|
||||
}
|
||||
|
||||
defer func() { _ = taken.Close() }()
|
||||
|
||||
_, port, err := net.SplitHostPort(taken.Addr().String())
|
||||
if err != nil {
|
||||
t.Fatalf("reading the bound port: %v", err)
|
||||
}
|
||||
|
||||
viper.Reset()
|
||||
t.Setenv("DNSWATCHER_TARGETS", "example.com")
|
||||
t.Setenv("DNSWATCHER_DATA_DIR", t.TempDir())
|
||||
t.Setenv("PORT", port)
|
||||
|
||||
app := newServerApp(fx.Invoke(func(*server.Server) {}))
|
||||
|
||||
err = app.Start(t.Context())
|
||||
if err == nil {
|
||||
_ = app.Stop(t.Context())
|
||||
|
||||
t.Fatal("startup succeeded on a port that is already bound")
|
||||
}
|
||||
|
||||
if !strings.Contains(err.Error(), ":"+port) {
|
||||
t.Errorf("startup error does not name port %s: %v", port, err)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user