check / check (push) Waiting to run
The internal/server and internal/ircserver tests picked a port by opening a listener, reading its port and closing it. They then started a server on that number. Another process could take the port in between, and a test failed with "address already in use". The IRC server tests now start the server on 127.0.0.1:0 and read the port back from its listener. The HTTP server tests start the app with port 0. The HTTP server now keeps the listener it binds, so the stop test sends its long poll to the port the app was given. Model: opus-5-5
220 lines
4.9 KiB
Go
220 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
|
|
listener net.Listener
|
|
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.listener = listener
|
|
|
|
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)
|
|
}
|
|
}
|