Files
netwatch/backend/internal/server/server.go
T
sneak dd762ce759
check / check (push) Failing after 0s
fix(server): shut down through fx so buffered reports flush (closes #22)
The server ran os.Exit at the end of its own goroutine, which raced fx's
teardown and could kill the process before reportbuf's OnStop flushed the
buffer — losing up to a full flush window of telemetry on every restart,
silently and with exit 0. The server now requests shutdown through
fx.Shutdowner, so fx runs every OnStop in dependency order.

The http.Server is built synchronously in OnStart before the serving
goroutine starts, so shutdown can no longer race or nil-deref it; the
field is never written and read from two goroutines without a
happens-before edge. A listen failure now exits non-zero via
fx.ExitCode(1). reportbuf's OnStop is guarded by sync.Once. writeTimeout
now exceeds the chi per-request budget, with a comment, so that budget is
reachable. Dead startupTime, exitCode, and cancelFunc fields are gone.

A new test buffers a report and asserts it reaches disk after the fx
lifecycle stops.

Model: opus-4-8
2026-09-21 12:49:59 +00:00

104 lines
2.4 KiB
Go

// Package server provides the HTTP server lifecycle,
// including startup, routing, and graceful shutdown. The
// process lifetime is owned by fx: shutdown is requested
// through fx.Shutdowner so every component's OnStop hook runs
// in dependency order.
package server
import (
"context"
"log/slog"
"net/http"
"sneak.berlin/go/netwatch/internal/config"
"sneak.berlin/go/netwatch/internal/globals"
"sneak.berlin/go/netwatch/internal/handlers"
"sneak.berlin/go/netwatch/internal/logger"
"sneak.berlin/go/netwatch/internal/middleware"
"github.com/go-chi/chi/v5"
"go.uber.org/fx"
)
// Params defines the dependencies for Server.
type Params struct {
fx.In
Config *config.Config
Globals *globals.Globals
Handlers *handlers.Handlers
Logger *logger.Logger
Middleware *middleware.Middleware
Shutdowner fx.Shutdowner
}
// Server is the top-level HTTP server orchestrator.
type Server struct {
h *handlers.Handlers
httpServer *http.Server
log *slog.Logger
mw *middleware.Middleware
params Params
router *chi.Mux
shutdowner fx.Shutdowner
}
// New creates a Server and registers lifecycle hooks for
// starting and stopping it.
func New(
lc fx.Lifecycle,
params Params,
) (*Server, error) {
s := new(Server)
s.params = params
s.mw = params.Middleware
s.h = params.Handlers
s.log = params.Logger.Get()
s.shutdowner = params.Shutdowner
lc.Append(fx.Hook{
OnStart: func(_ context.Context) error {
// Build the router and http.Server synchronously
// here, before spawning the serving goroutine, so
// httpServer is fully constructed by the time OnStop
// (or an early signal) can read it. fx guarantees
// OnStart returns before OnStop runs, so no
// synchronization or nil check is needed at shutdown.
s.SetupRoutes()
s.httpServer = s.newHTTPServer()
go s.listenAndServe()
return nil
},
OnStop: func(ctx context.Context) error {
return s.shutdown(ctx)
},
})
return s, nil
}
// ServeHTTP delegates to the chi router.
func (s *Server) ServeHTTP(
w http.ResponseWriter,
r *http.Request,
) {
s.router.ServeHTTP(w, r)
}
// shutdown gracefully stops the HTTP server within the
// deadline of the context fx provides for OnStop.
func (s *Server) shutdown(ctx context.Context) error {
err := s.httpServer.Shutdown(ctx)
if err != nil {
s.log.Error("server clean shutdown failed", "error", err)
return err
}
s.log.Info("server stopped")
return nil
}