Files
neoirc/internal/server/server.go
T
clawbot 0e306867d2
check / check (push) Waiting to run
Fail startup when the HTTP listener cannot bind (closes #167)
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 itself and returns the
bind error, so fx stops startup and neoircd exits 1 with an error
naming the address. The bound listener is then served in a goroutine;
if serving ends with anything other than a normal shutdown, the server
stops the app through fx.Shutdowner with exit code 1.

A new test holds a port and checks that starting the app on it fails
with an error naming the address.

Model: opus-5-5
2026-10-20 21:54:54 +00:00

217 lines
4.9 KiB
Go

// Package server implements the main HTTP server for the neoirc application.
package server
import (
"context"
"errors"
"fmt"
"log/slog"
"net"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"go.uber.org/fx"
"sneak.berlin/go/neoirc/internal/config"
"sneak.berlin/go/neoirc/internal/globals"
"sneak.berlin/go/neoirc/internal/handlers"
"sneak.berlin/go/neoirc/internal/logger"
"sneak.berlin/go/neoirc/internal/middleware"
"github.com/getsentry/sentry-go"
"github.com/go-chi/chi/v5"
_ "github.com/joho/godotenv/autoload" // loads .env file
)
const (
shutdownTimeout = 5 * time.Second
sentryFlushTime = 2 * time.Second
)
// Params defines the dependencies for creating a Server.
type Params struct {
fx.In
Logger *logger.Logger
Globals *globals.Globals
Config *config.Config
Middleware *middleware.Middleware
Handlers *handlers.Handlers
Shutdowner fx.Shutdowner
}
// Server is the main HTTP server.
// It manages routing, middleware, and lifecycle.
type Server struct {
startupTime time.Time
sentryEnabled bool
log *slog.Logger
httpServer *http.Server
router *chi.Mux
params Params
mw *middleware.Middleware
handlers *handlers.Handlers
}
// New creates a new Server and registers its lifecycle hooks.
func New(
lifecycle fx.Lifecycle, params Params,
) (*Server, error) {
srv := &Server{ //nolint:exhaustruct // fields set during lifecycle
params: params,
mw: params.Middleware,
handlers: params.Handlers,
log: params.Logger.Get(),
}
lifecycle.Append(fx.Hook{
OnStart: func(ctx context.Context) error {
srv.startupTime = time.Now()
// Build the router before OnStart returns, so srv can
// handle requests as soon as the app has started.
srv.configure()
srv.enableSentry()
srv.SetupRoutes()
// A closed stdout or stderr must not end the process.
signal.Ignore(syscall.SIGPIPE)
return srv.startHTTP(ctx)
},
OnStop: func(ctx context.Context) error {
srv.cleanShutdown(ctx)
return nil
},
})
return srv, nil
}
// ServeHTTP delegates to the chi router.
func (srv *Server) ServeHTTP(
writer http.ResponseWriter,
request *http.Request,
) {
srv.router.ServeHTTP(writer, request)
}
// MaintenanceMode reports whether the server is in maintenance mode.
func (srv *Server) MaintenanceMode() bool {
return srv.params.Config.MaintenanceMode
}
func (srv *Server) enableSentry() {
srv.sentryEnabled = false
if srv.params.Config.SentryDSN == "" {
return
}
err := sentry.Init(sentry.ClientOptions{ //nolint:exhaustruct // only essential fields
Dsn: srv.params.Config.SentryDSN,
Release: fmt.Sprintf(
"%s-%s",
srv.params.Globals.Appname,
srv.params.Globals.Version,
),
})
if err != nil {
srv.log.Error("sentry init failure", "error", err)
os.Exit(1)
}
srv.log.Info("sentry error reporting activated")
srv.sentryEnabled = true
}
func (srv *Server) cleanupForExit() {
srv.log.Info("cleaning up")
}
// cleanShutdown stops the HTTP server, waiting up to
// shutdownTimeout for the requests in progress to finish.
func (srv *Server) cleanShutdown(ctx context.Context) {
ctxShutdown, shutdownCancel := context.WithTimeout(
ctx, shutdownTimeout,
)
err := srv.httpServer.Shutdown(ctxShutdown)
if err != nil {
srv.log.Error(
"server clean shutdown failed", "error", err,
)
}
if shutdownCancel != nil {
shutdownCancel()
}
srv.cleanupForExit()
if srv.sentryEnabled {
sentry.Flush(sentryFlushTime)
}
}
func (srv *Server) configure() {
// Server configuration placeholder.
}
// startHTTP binds the HTTP listen address, so that a port
// that is taken fails startup, then serves the HTTP server
// from a new goroutine. srv.httpServer is set before that
// goroutine starts and never changes, so it and cleanShutdown
// read it without a lock.
func (srv *Server) startHTTP(ctx context.Context) error {
listenAddr := fmt.Sprintf(
":%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
Addr: listenAddr,
ReadTimeout: httpReadTimeout,
WriteTimeout: httpWriteTimeout,
MaxHeaderBytes: maxHeaderBytes,
Handler: srv,
}
go func() {
srv.serveUntilShutdown(listener)
}()
return nil
}
// 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(
"http begin listen", "listenaddr", srv.httpServer.Addr,
)
err := srv.httpServer.Serve(listener)
if errors.Is(err, http.ErrServerClosed) {
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)
}
}