Files
neoirc/internal/server/server.go
T
clawbot e3324407ca
check / check (push) Successful in 1m19s
Fix the data races and the test timeout that fail make test (closes #104)
The HTTP server built its router inside the goroutine that starts
serving, so the start hook returned before the router existed, and
the internal/handlers tests raced with it or hit a nil router. The
start hook now runs configure, enableSentry and SetupRoutes, in that
order, then serves in the background.

The goroutine that sends queued messages to an IRC client read c.nick
without c.mu while NICK changed it. Every such read now takes the lock.

Under -race in the Docker build, internal/handlers takes over 30s on
database work, not clock waits, so both go test runs in make test use
-timeout 120s. The || retry stays
(#101).

Model: opus-5-5
2026-10-02 10:13:28 +02:00

218 lines
4.5 KiB
Go

// Package server implements the main HTTP server for the neoirc application.
package server
import (
"context"
"errors"
"fmt"
"log/slog"
"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
}
// Server is the main HTTP server.
// It manages routing, middleware, and lifecycle.
type Server struct {
startupTime time.Time
exitCode int
sentryEnabled bool
log *slog.Logger
ctx context.Context //nolint:containedctx // signal handling pattern
cancelFunc context.CancelFunc
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(_ 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()
go srv.serve() //nolint:contextcheck
return nil
},
OnStop: func(_ context.Context) error {
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) serve() int {
srv.ctx, srv.cancelFunc = context.WithCancel(
context.Background(),
)
go func() {
sigCh := make(chan os.Signal, 1)
signal.Ignore(syscall.SIGPIPE)
signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
sig := <-sigCh
srv.log.Info("signal received", "signal", sig)
if srv.cancelFunc != nil {
srv.cancelFunc()
}
}()
go srv.serveUntilShutdown()
<-srv.ctx.Done()
srv.cleanShutdown()
return srv.exitCode
}
func (srv *Server) cleanupForExit() {
srv.log.Info("cleaning up")
}
func (srv *Server) cleanShutdown() {
srv.exitCode = 0
ctxShutdown, shutdownCancel := context.WithTimeout(
context.Background(), 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.
}
func (srv *Server) serveUntilShutdown() {
listenAddr := fmt.Sprintf(
":%d", srv.params.Config.Port,
)
srv.httpServer = &http.Server{ //nolint:exhaustruct // optional fields
Addr: listenAddr,
ReadTimeout: httpReadTimeout,
WriteTimeout: httpWriteTimeout,
MaxHeaderBytes: maxHeaderBytes,
Handler: srv,
}
srv.log.Info(
"http begin listen", "listenaddr", listenAddr,
)
err := srv.httpServer.ListenAndServe()
if err != nil && !errors.Is(err, http.ErrServerClosed) {
srv.log.Error("listen error", "error", err)
if srv.cancelFunc != nil {
srv.cancelFunc()
}
}
}