Fail startup when the HTTP listener cannot bind (closes #167)
check / check (push) Waiting to run
check / check (push) Waiting to run
The HTTP server ran ListenAndServe in a goroutine, so a failed bind was only logged and neoircd kept running without its HTTP API. The start hook now binds the listen address and returns the bind error, so neoircd exits 1 with an error naming the address. If serving the bound listener later ends with anything other than a normal shutdown, the server stops the app through fx.Shutdowner with exit code 1; the handler tests now pass it that Shutdowner. Tests check that a taken port fails startup, that a serve failure stops the app with exit code 1, and that a normal stop does not. The server tests no longer run in parallel: building the app writes the global viper instance. Model: opus-5-5
This commit was merged in pull request #169.
This commit is contained in:
@@ -325,6 +325,7 @@ func newTestServerFx(
|
|||||||
cfg *config.Config,
|
cfg *config.Config,
|
||||||
mware *middleware.Middleware,
|
mware *middleware.Middleware,
|
||||||
hdlr *handlers.Handlers,
|
hdlr *handlers.Handlers,
|
||||||
|
shutdowner fx.Shutdowner,
|
||||||
) (*server.Server, error) {
|
) (*server.Server, error) {
|
||||||
srv, err := server.New(lifecycle, server.Params{ //nolint:exhaustruct
|
srv, err := server.New(lifecycle, server.Params{ //nolint:exhaustruct
|
||||||
Logger: log,
|
Logger: log,
|
||||||
@@ -332,6 +333,7 @@ func newTestServerFx(
|
|||||||
Config: cfg,
|
Config: cfg,
|
||||||
Middleware: mware,
|
Middleware: mware,
|
||||||
Handlers: hdlr,
|
Handlers: hdlr,
|
||||||
|
Shutdowner: shutdowner,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("test server: %w", err)
|
return nil, fmt.Errorf("test server: %w", err)
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
package server
|
package server
|
||||||
|
|
||||||
import "github.com/go-chi/chi/v5"
|
import (
|
||||||
|
"net"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
)
|
||||||
|
|
||||||
// NewTestSPAServer creates a Server whose router serves only
|
// NewTestSPAServer creates a Server whose router serves only
|
||||||
// the embedded web client.
|
// the embedded web client.
|
||||||
@@ -10,3 +14,9 @@ func NewTestSPAServer() *Server {
|
|||||||
|
|
||||||
return srv
|
return srv
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ServeUntilShutdown runs serveUntilShutdown, which serves the
|
||||||
|
// HTTP server on listener, and returns when it does.
|
||||||
|
func (srv *Server) ServeUntilShutdown(listener net.Listener) {
|
||||||
|
srv.serveUntilShutdown(listener)
|
||||||
|
}
|
||||||
|
|||||||
+36
-14
@@ -6,6 +6,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
@@ -39,6 +40,7 @@ type Params struct {
|
|||||||
Config *config.Config
|
Config *config.Config
|
||||||
Middleware *middleware.Middleware
|
Middleware *middleware.Middleware
|
||||||
Handlers *handlers.Handlers
|
Handlers *handlers.Handlers
|
||||||
|
Shutdowner fx.Shutdowner
|
||||||
}
|
}
|
||||||
|
|
||||||
// Server is the main HTTP server.
|
// Server is the main HTTP server.
|
||||||
@@ -66,7 +68,7 @@ func New(
|
|||||||
}
|
}
|
||||||
|
|
||||||
lifecycle.Append(fx.Hook{
|
lifecycle.Append(fx.Hook{
|
||||||
OnStart: func(_ context.Context) error {
|
OnStart: func(ctx context.Context) error {
|
||||||
srv.startupTime = time.Now()
|
srv.startupTime = time.Now()
|
||||||
|
|
||||||
// Build the router before OnStart returns, so srv can
|
// Build the router before OnStart returns, so srv can
|
||||||
@@ -78,9 +80,7 @@ func New(
|
|||||||
// A closed stdout or stderr must not end the process.
|
// A closed stdout or stderr must not end the process.
|
||||||
signal.Ignore(syscall.SIGPIPE)
|
signal.Ignore(syscall.SIGPIPE)
|
||||||
|
|
||||||
srv.startHTTP()
|
return srv.startHTTP(ctx)
|
||||||
|
|
||||||
return nil
|
|
||||||
},
|
},
|
||||||
OnStop: func(ctx context.Context) error {
|
OnStop: func(ctx context.Context) error {
|
||||||
srv.cleanShutdown(ctx)
|
srv.cleanShutdown(ctx)
|
||||||
@@ -162,15 +162,23 @@ func (srv *Server) configure() {
|
|||||||
// Server configuration placeholder.
|
// Server configuration placeholder.
|
||||||
}
|
}
|
||||||
|
|
||||||
// startHTTP creates the HTTP server and serves it from a new
|
// startHTTP binds the HTTP listen address, so that a port
|
||||||
// goroutine. srv.httpServer is set before that goroutine
|
// that is taken fails startup, then serves the HTTP server
|
||||||
// starts and never changes, so it and cleanShutdown read it
|
// from a new goroutine. srv.httpServer is set before that
|
||||||
// without a lock.
|
// goroutine starts and never changes, so it and cleanShutdown
|
||||||
func (srv *Server) startHTTP() {
|
// read it without a lock.
|
||||||
|
func (srv *Server) startHTTP(ctx context.Context) error {
|
||||||
listenAddr := fmt.Sprintf(
|
listenAddr := fmt.Sprintf(
|
||||||
":%d", srv.params.Config.Port,
|
":%d", srv.params.Config.Port,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var listenConfig net.ListenConfig
|
||||||
|
|
||||||
|
listener, err := listenConfig.Listen(ctx, "tcp", listenAddr)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("http listen: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
srv.httpServer = &http.Server{ //nolint:exhaustruct // optional fields
|
srv.httpServer = &http.Server{ //nolint:exhaustruct // optional fields
|
||||||
Addr: listenAddr,
|
Addr: listenAddr,
|
||||||
ReadTimeout: httpReadTimeout,
|
ReadTimeout: httpReadTimeout,
|
||||||
@@ -179,16 +187,30 @@ func (srv *Server) startHTTP() {
|
|||||||
Handler: srv,
|
Handler: srv,
|
||||||
}
|
}
|
||||||
|
|
||||||
go srv.serveUntilShutdown()
|
go func() {
|
||||||
|
srv.serveUntilShutdown(listener)
|
||||||
|
}()
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (srv *Server) serveUntilShutdown() {
|
// serveUntilShutdown serves the HTTP server on listener until
|
||||||
|
// cleanShutdown stops it. If serving fails before then, it
|
||||||
|
// stops the app with exit code 1.
|
||||||
|
func (srv *Server) serveUntilShutdown(listener net.Listener) {
|
||||||
srv.log.Info(
|
srv.log.Info(
|
||||||
"http begin listen", "listenaddr", srv.httpServer.Addr,
|
"http begin listen", "listenaddr", srv.httpServer.Addr,
|
||||||
)
|
)
|
||||||
|
|
||||||
err := srv.httpServer.ListenAndServe()
|
err := srv.httpServer.Serve(listener)
|
||||||
if err != nil && !errors.Is(err, http.ErrServerClosed) {
|
if errors.Is(err, http.ErrServerClosed) {
|
||||||
srv.log.Error("listen error", "error", err)
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
srv.log.Error("listen error", "error", err)
|
||||||
|
|
||||||
|
err = srv.params.Shutdowner.Shutdown(fx.ExitCode(1))
|
||||||
|
if err != nil {
|
||||||
|
srv.log.Error("shutdown failed", "error", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+127
-16
@@ -1,3 +1,7 @@
|
|||||||
|
// Tests use a global viper instance for configuration,
|
||||||
|
// making parallel execution unsafe.
|
||||||
|
//
|
||||||
|
//nolint:paralleltest
|
||||||
package server_test
|
package server_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -6,6 +10,7 @@ import (
|
|||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -40,8 +45,6 @@ const (
|
|||||||
// for a long poll the HTTP server is serving to finish, and
|
// for a long poll the HTTP server is serving to finish, and
|
||||||
// that the poll gets its response.
|
// that the poll gets its response.
|
||||||
func TestStopWaitsForRequestInProgress(t *testing.T) {
|
func TestStopWaitsForRequestInProgress(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
port := freePort(t)
|
port := freePort(t)
|
||||||
addr := net.JoinHostPort("127.0.0.1", strconv.Itoa(port))
|
addr := net.JoinHostPort("127.0.0.1", strconv.Itoa(port))
|
||||||
|
|
||||||
@@ -103,12 +106,114 @@ func TestStopWaitsForRequestInProgress(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// startApp starts the server with the dependencies
|
// TestStartFailsWhenHTTPPortIsTaken checks that starting the
|
||||||
// cmd/neoircd gives it, on a fresh in-memory database, with
|
// app, which runs the start hook server.New registers,
|
||||||
// the HTTP server listening on port.
|
// returns an error naming the HTTP listen address when
|
||||||
|
// something else is already listening on its port.
|
||||||
|
func TestStartFailsWhenHTTPPortIsTaken(t *testing.T) {
|
||||||
|
var listenConfig net.ListenConfig
|
||||||
|
|
||||||
|
taken, err := listenConfig.Listen(t.Context(), "tcp", ":0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("listen: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Cleanup(func() { _ = taken.Close() })
|
||||||
|
|
||||||
|
tcpAddr, ok := taken.Addr().(*net.TCPAddr)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("listener address %v is not TCP", taken.Addr())
|
||||||
|
}
|
||||||
|
|
||||||
|
app, _, _ := newApp(t, tcpAddr.Port)
|
||||||
|
|
||||||
|
err = app.Start(t.Context())
|
||||||
|
if err == nil {
|
||||||
|
app.RequireStop()
|
||||||
|
t.Fatal("the app started while its HTTP port was taken")
|
||||||
|
}
|
||||||
|
|
||||||
|
listenAddr := fmt.Sprintf(":%d", tcpAddr.Port)
|
||||||
|
if !strings.Contains(err.Error(), listenAddr) {
|
||||||
|
t.Errorf("start error %q does not name %s", err, listenAddr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestServeFailureStopsApp checks that when serving HTTP fails
|
||||||
|
// after the start hook has bound the listen address, the
|
||||||
|
// server stops the app with exit code 1.
|
||||||
|
func TestServeFailureStopsApp(t *testing.T) {
|
||||||
|
var srv *server.Server
|
||||||
|
|
||||||
|
app, _, _ := startApp(t, freePort(t), fx.Populate(&srv))
|
||||||
|
|
||||||
|
shutdowns := app.Wait()
|
||||||
|
|
||||||
|
srv.ServeUntilShutdown(closedListener(t))
|
||||||
|
|
||||||
|
select {
|
||||||
|
case shutdown := <-shutdowns:
|
||||||
|
if shutdown.ExitCode != 1 {
|
||||||
|
t.Errorf("exit code %d, want 1", shutdown.ExitCode)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
t.Error("the server did not stop the app")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestServeAfterStopDoesNotStopApp checks that serving HTTP
|
||||||
|
// after the app has stopped, which ends at once because the
|
||||||
|
// stop hook has shut the HTTP server down, does not stop the
|
||||||
|
// app. Serving ends the same way on every normal stop.
|
||||||
|
func TestServeAfterStopDoesNotStopApp(t *testing.T) {
|
||||||
|
var srv *server.Server
|
||||||
|
|
||||||
|
app, _, _ := startApp(t, freePort(t), fx.Populate(&srv))
|
||||||
|
|
||||||
|
shutdowns := app.Wait()
|
||||||
|
|
||||||
|
app.RequireStop()
|
||||||
|
|
||||||
|
srv.ServeUntilShutdown(closedListener(t))
|
||||||
|
|
||||||
|
select {
|
||||||
|
case shutdown := <-shutdowns:
|
||||||
|
t.Errorf(
|
||||||
|
"the server stopped the app with exit code %d",
|
||||||
|
shutdown.ExitCode,
|
||||||
|
)
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// startApp starts the app newApp builds, with the HTTP server
|
||||||
|
// listening on port.
|
||||||
func startApp(
|
func startApp(
|
||||||
t *testing.T,
|
t *testing.T,
|
||||||
port int,
|
port int,
|
||||||
|
options ...fx.Option,
|
||||||
|
) (*fxtest.App, *broker.Broker, *db.Database) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
app, brk, database := newApp(t, port, options...)
|
||||||
|
|
||||||
|
app.RequireStart()
|
||||||
|
|
||||||
|
// Stops the app if the test ends before stopping it.
|
||||||
|
t.Cleanup(app.RequireStop)
|
||||||
|
|
||||||
|
return app, brk, database
|
||||||
|
}
|
||||||
|
|
||||||
|
// newApp builds, without starting it, an app that runs the
|
||||||
|
// server with the dependencies cmd/neoircd gives it, on a
|
||||||
|
// fresh in-memory database, with the HTTP port set to port.
|
||||||
|
// options are added to the app, for a test that needs more
|
||||||
|
// from it than the broker and the database.
|
||||||
|
func newApp(
|
||||||
|
t *testing.T,
|
||||||
|
port int,
|
||||||
|
options ...fx.Option,
|
||||||
) (*fxtest.App, *broker.Broker, *db.Database) {
|
) (*fxtest.App, *broker.Broker, *db.Database) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
@@ -146,13 +251,9 @@ func startApp(
|
|||||||
}),
|
}),
|
||||||
fx.Invoke(func(*server.Server) {}),
|
fx.Invoke(func(*server.Server) {}),
|
||||||
fx.Populate(&brk, &database),
|
fx.Populate(&brk, &database),
|
||||||
|
fx.Options(options...),
|
||||||
)
|
)
|
||||||
|
|
||||||
app.RequireStart()
|
|
||||||
|
|
||||||
// Stops the app if the test ends before stopping it.
|
|
||||||
t.Cleanup(app.RequireStop)
|
|
||||||
|
|
||||||
return app, brk, database
|
return app, brk, database
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -161,6 +262,21 @@ func startApp(
|
|||||||
func freePort(t *testing.T) int {
|
func freePort(t *testing.T) int {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
|
listener := closedListener(t)
|
||||||
|
|
||||||
|
tcpAddr, ok := listener.Addr().(*net.TCPAddr)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("listener address %v is not TCP", listener.Addr())
|
||||||
|
}
|
||||||
|
|
||||||
|
return tcpAddr.Port
|
||||||
|
}
|
||||||
|
|
||||||
|
// closedListener returns a TCP listener that has already been
|
||||||
|
// closed, so that accepting a connection on it fails.
|
||||||
|
func closedListener(t *testing.T) net.Listener {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
var listenConfig net.ListenConfig
|
var listenConfig net.ListenConfig
|
||||||
|
|
||||||
listener, err := listenConfig.Listen(
|
listener, err := listenConfig.Listen(
|
||||||
@@ -170,17 +286,12 @@ func freePort(t *testing.T) int {
|
|||||||
t.Fatalf("listen: %v", err)
|
t.Fatalf("listen: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
tcpAddr, ok := listener.Addr().(*net.TCPAddr)
|
|
||||||
if !ok {
|
|
||||||
t.Fatalf("listener address %v is not TCP", listener.Addr())
|
|
||||||
}
|
|
||||||
|
|
||||||
err = listener.Close()
|
err = listener.Close()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("close listener: %v", err)
|
t.Fatalf("close listener: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return tcpAddr.Port
|
return listener
|
||||||
}
|
}
|
||||||
|
|
||||||
// canConnect reports whether addr accepts a TCP connection.
|
// canConnect reports whether addr accepts a TCP connection.
|
||||||
|
|||||||
Reference in New Issue
Block a user