Fail startup when the HTTP listener cannot bind (closes #167)
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 is contained in:
2026-10-20 22:28:56 +00:00
committed by sneak
parent 497514d3df
commit bc1ab1a7f6
4 changed files with 176 additions and 31 deletions
+2
View File
@@ -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)
+11 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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.