From 0923ac62906a4f61e91d8d303c48a5df282444ec Mon Sep 17 00:00:00 2001 From: Nicholas Wiersma Date: Wed, 6 Dec 2023 08:04:52 +0200 Subject: [PATCH] feat: add server with hooks and healthz (#109) --- http/server/doc.go | 2 + http/server/healthz.go | 131 +++++++++++ http/server/hooks.go | 188 +++++++++++++++ http/server/server.go | 203 ++++++++++++++++ http/server/server_test.go | 466 +++++++++++++++++++++++++++++++++++++ 5 files changed, 990 insertions(+) create mode 100644 http/server/doc.go create mode 100644 http/server/healthz.go create mode 100644 http/server/hooks.go create mode 100644 http/server/server.go create mode 100644 http/server/server_test.go diff --git a/http/server/doc.go b/http/server/doc.go new file mode 100644 index 0000000..d9f921d --- /dev/null +++ b/http/server/doc.go @@ -0,0 +1,2 @@ +// Package server provides a generic HTTP server. +package server diff --git a/http/server/healthz.go b/http/server/healthz.go new file mode 100644 index 0000000..9c191f4 --- /dev/null +++ b/http/server/healthz.go @@ -0,0 +1,131 @@ +package server + +import ( + "errors" + "fmt" + "net/http" + "strings" + + lctx "github.com/hamba/logger/v2/ctx" + "github.com/hamba/pkg/v2/http/healthz" + "github.com/hamba/pkg/v2/http/middleware" +) + +// MustAddHealthzChecks adds health checks to both readyz and livez, panicking if there is an error. +func (s *GenericServer[T]) MustAddHealthzChecks(checks ...healthz.HealthChecker) { + if err := s.AddHealthzChecks(checks...); err != nil { + panic(err) + } +} + +// AddHealthzChecks adds health checks to both readyz and livez. +func (s *GenericServer[T]) AddHealthzChecks(checks ...healthz.HealthChecker) error { + if err := s.AddReadyzChecks(checks...); err != nil { + return err + } + return s.AddLivezChecks(checks...) +} + +// MustAddReadyzChecks adds health checks to readyz, panicking if there is an error. +func (s *GenericServer[T]) MustAddReadyzChecks(checks ...healthz.HealthChecker) { + if err := s.AddReadyzChecks(checks...); err != nil { + panic(err) + } +} + +// AddReadyzChecks adds health checks to readyz. +func (s *GenericServer[T]) AddReadyzChecks(checks ...healthz.HealthChecker) error { + s.readyzMu.Lock() + defer s.readyzMu.Unlock() + if s.readyzInstalled { + return errors.New("could not add checks as readyz has already been installed") + } + s.readyzChecks = append(s.readyzChecks, checks...) + return nil +} + +// MustAddLivezChecks adds health checks to livez, panicking if there is an error. +func (s *GenericServer[T]) MustAddLivezChecks(checks ...healthz.HealthChecker) { + if err := s.AddLivezChecks(checks...); err != nil { + panic(err) + } +} + +// AddLivezChecks adds health checks to livez. +func (s *GenericServer[T]) AddLivezChecks(checks ...healthz.HealthChecker) error { + s.livezMu.Lock() + defer s.livezMu.Unlock() + if s.livezInstalled { + return errors.New("could not add checks as livez has already been installed") + } + s.livezChecks = append(s.livezChecks, checks...) + return nil +} + +func (s *GenericServer[T]) installChecks(h http.Handler, shutdownCh chan struct{}) http.Handler { + mux := http.NewServeMux() + mux.Handle("/", h) + s.installLivezChecks(mux) + + // When shutdown is started, the readyz check should start failing. + if err := s.AddReadyzChecks(shutdownCheck{ch: shutdownCh}); err != nil { + s.Log.Error("Could not install readyz shutdown check", lctx.Err(err)) + } + s.installReadyzChecks(mux) + + return mux +} + +func (s *GenericServer[T]) installReadyzChecks(mux *http.ServeMux) { + s.readyzMu.Lock() + defer s.readyzMu.Unlock() + s.readyzInstalled = true + s.installCheckers(mux, "/readyz", s.readyzChecks) +} + +func (s *GenericServer[T]) installLivezChecks(mux *http.ServeMux) { + s.livezMu.Lock() + defer s.livezMu.Unlock() + s.livezInstalled = true + s.installCheckers(mux, "/livez", s.livezChecks) +} + +func (s *GenericServer[T]) installCheckers(mux *http.ServeMux, path string, checks []healthz.HealthChecker) { + if len(checks) == 0 { + checks = []healthz.HealthChecker{healthz.PingHealth} + } + + s.Log.Info("Installing health checkers", + lctx.Str("path", path), + lctx.Str("checks", strings.Join(checkNames(checks), ",")), + ) + + name := strings.TrimPrefix(path, "/") + h := healthz.Handler(name, func(output string) { + s.Log.Info(fmt.Sprintf("%s check failed\n%s", name, output)) + }, checks...) + mux.Handle(path, middleware.WithStats(name, s.Stats, h)) +} + +func checkNames(checks []healthz.HealthChecker) []string { + names := make([]string, len(checks)) + for i, check := range checks { + names[i] = check.Name() + } + return names +} + +type shutdownCheck struct { + ch <-chan struct{} +} + +func (s shutdownCheck) Name() string { return "shutdown" } + +func (s shutdownCheck) Check(*http.Request) error { + select { + case <-s.ch: + return errors.New("server is shutting down") + default: + return nil + } +} diff --git a/http/server/hooks.go b/http/server/hooks.go new file mode 100644 index 0000000..9ca4197 --- /dev/null +++ b/http/server/hooks.go @@ -0,0 +1,188 @@ +package server + +import ( + "context" + "errors" + "fmt" + "net/http" + + lctx "github.com/hamba/logger/v2/ctx" +) + +// PostStartHookFunc is a function called after server start. +type PostStartHookFunc[T context.Context] func(T) error + +// PreShutdownHookFunc is a function called before server shutdown. +type PreShutdownHookFunc func() error + +type postStartHookEntry[T context.Context] struct { + fn PostStartHookFunc[T] + doneCh chan struct{} +} + +// MustAddPostStartHook adds a post-start hook, panicking if there is an error. +func (s *GenericServer[T]) MustAddPostStartHook(name string, fn PostStartHookFunc[T]) { + if err := s.AddPostStartHook(name, fn); err != nil { + panic(err) + } +} + +// AddPostStartHook adds a post-start hook. +func (s *GenericServer[T]) AddPostStartHook(name string, fn PostStartHookFunc[T]) error { + if name == "" { + return errors.New("name is required") + } + if fn == nil { + return errors.New("fn is required") + } + + s.postStartHookMu.Lock() + defer s.postStartHookMu.Unlock() + + if s.postStartHooksCalled { + return errors.New("hooks have already been called") + } + if _, exists := s.postStartHooks[name]; exists { + return fmt.Errorf("hook %q as it is already registered", name) + } + + if s.postStartHooks == nil { + s.postStartHooks = map[string]postStartHookEntry[T]{} + } + + doneCh := make(chan struct{}) + err := s.AddReadyzChecks(postStartHookHealth{ + name: "postStartHook:" + name, + doneCh: doneCh, + }) + if err != nil { + return fmt.Errorf("adding readyz check: %w", err) + } + + s.postStartHooks[name] = postStartHookEntry[T]{ + fn: fn, + doneCh: doneCh, + } + return nil +} + +// MustAddPreShutdownHook adds a pre-shutdown hook, panicking if there is an error. +func (s *GenericServer[T]) MustAddPreShutdownHook(name string, fn PreShutdownHookFunc) { + if err := s.AddPreShutdownHook(name, fn); err != nil { + panic(err) + } +} + +// AddPreShutdownHook adds a pre-shutdown hook. +func (s *GenericServer[T]) AddPreShutdownHook(name string, fn PreShutdownHookFunc) error { + if name == "" { + return errors.New("name is required") + } + if fn == nil { + return errors.New("fn is required") + } + + s.preShutdownHookMu.Lock() + defer s.preShutdownHookMu.Unlock() + + if s.preShutdownHooksCalled { + return errors.New("hooks have already been called") + } + if _, exists := s.preShutdownHooks[name]; exists { + return fmt.Errorf("hook %q as it is already registered", name) + } + + if s.preShutdownHooks == nil { + s.preShutdownHooks = map[string]PreShutdownHookFunc{} + } + + s.preShutdownHooks[name] = fn + return nil +} + +func (s *GenericServer[T]) runPostStartHooks(ctx T) { + s.postStartHookMu.Lock() + defer s.postStartHookMu.Unlock() + + s.postStartHooksCalled = true + + for name, entry := range s.postStartHooks { + go s.runPostStartHook(ctx, name, entry) + } +} + +func (s *GenericServer[T]) runPostStartHook(ctx T, name string, entry postStartHookEntry[T]) { + defer func() { + if v := recover(); v != nil { + s.Log.Error("Panic while running post-start hook", + lctx.Interface("error", v), + lctx.Stack("stack"), + ) + } + }() + + s.Log.Info("Running post-start hook", lctx.Str("hook", name)) + + if err := entry.fn(ctx); err != nil { + s.Log.Error("Could not run post-start hook", lctx.Str("name", name), lctx.Err(err)) + } + close(entry.doneCh) +} + +func (s *GenericServer[T]) hasPreShutdownHooks() bool { + s.preShutdownHookMu.Lock() + defer s.preShutdownHookMu.Unlock() + + return len(s.preShutdownHooks) > 0 +} + +func (s *GenericServer[T]) runPreShutdownHooks() error { + s.preShutdownHookMu.Lock() + defer s.preShutdownHookMu.Unlock() + + s.preShutdownHooksCalled = true + + var errs error + for name, fn := range s.preShutdownHooks { + if err := s.runPreShutdownHook(name, fn); err != nil { + errs = errors.Join(errs, err) + } + } + return errs +} + +func (s *GenericServer[T]) runPreShutdownHook(name string, fn PreShutdownHookFunc) error { + defer func() { + if v := recover(); v != nil { + s.Log.Error("Panic while running pre-shutdown hook", + lctx.Interface("error", v), + lctx.Stack("stack"), + ) + } + }() + + s.Log.Info("Running pre-shutdown hook", lctx.Str("hook", name)) + + if err := fn(); err != nil { + return fmt.Errorf("running preshutdown hook %q: %w", name, err) + } + return nil +} + +type postStartHookHealth struct { + name string + doneCh chan struct{} +} + +func (h postStartHookHealth) Name() string { + return h.name +} + +func (h postStartHookHealth) Check(*http.Request) error { + select { + case <-h.doneCh: + return nil + default: + return errors.New("not finished") + } +} diff --git a/http/server/server.go b/http/server/server.go new file mode 100644 index 0000000..3a2c2fc --- /dev/null +++ b/http/server/server.go @@ -0,0 +1,203 @@ +package server + +import ( + "context" + "crypto/tls" + "errors" + "fmt" + "log" + "net" + "net/http" + "sync" + "time" + + "github.com/hamba/logger/v2" + lctx "github.com/hamba/logger/v2/ctx" + "github.com/hamba/pkg/v2/http/healthz" + "github.com/hamba/statter/v2" + "golang.org/x/net/http2" + "golang.org/x/net/http2/h2c" +) + +var testHookServerServe func(net.Listener) + +// GenericServer is an HTTP server. +// +// The server handles `/livez` and `/readyz` endpoints as well as +// post-start and pre-shutdown hooks. +type GenericServer[T context.Context] struct { + Addr string + TLSConfig *tls.Config + Handler http.Handler + ReadHeaderTimeout time.Duration + ReadTimeout time.Duration + WriteTimeout time.Duration + IdleTimeout time.Duration + ShutdownTimeout time.Duration + + readyzMu sync.Mutex + readyzInstalled bool + readyzChecks []healthz.HealthChecker + + livezMu sync.Mutex + livezInstalled bool + livezChecks []healthz.HealthChecker + + postStartHookMu sync.Mutex + postStartHooks map[string]postStartHookEntry[T] + postStartHooksCalled bool + + preShutdownHookMu sync.Mutex + preShutdownHooks map[string]PreShutdownHookFunc + preShutdownHooksCalled bool + + Stats *statter.Statter + Log *logger.Logger +} + +// Run runs the server, managing the full server lifecycle. +// +// If the server fails to start, e.g. bind error, no hooks are run. +// This function is blocking. +func (s *GenericServer[T]) Run(ctx T) error { + if s.Handler == nil { + return errors.New("handler must not be empty") + } + if s.Stats == nil { + return errors.New("stats must not be empty") + } + if s.Log == nil { + return errors.New("log must not be empty") + } + + shutdownCh := make(chan struct{}) + h := s.installChecks(s.Handler, shutdownCh) + + stopServerCh := make(chan struct{}) + srvStoppedCh, srvShutdownCh, err := s.runServer(ctx, h, stopServerCh) + if err != nil { + return fmt.Errorf("starting server: %w", err) + } + + s.runPostStartHooks(ctx) + + // Wait for the server to be stopped. + select { + case <-srvStoppedCh: + // The server stopped prematurely, return. + return errors.New("server stopped prematurely") + case <-ctx.Done(): + } + + s.Log.Info("Shutting the server down...") + + // Run the pre shutdown hooks. + func() { + defer func() { + if s.hasPreShutdownHooks() { + s.Log.Info("Pre-shutdown hooks completed") + } + + close(shutdownCh) + close(stopServerCh) + }() + + err = s.runPreShutdownHooks() + }() + if err != nil { + return fmt.Errorf("running pre-shutdown hooks: %w", err) + } + + <-srvShutdownCh + <-srvStoppedCh + + return nil +} + +func (s *GenericServer[T]) runServer( + ctx context.Context, h http.Handler, doneCh <-chan struct{}, +) (<-chan struct{}, <-chan struct{}, error) { + addr := s.Addr + if addr == "" { + addr = ":http" + if s.TLSConfig != nil { + addr = ":https" + } + } + ln, err := (&net.ListenConfig{}).Listen(ctx, "tcp", addr) + if err != nil { + return nil, nil, err + } + if s.TLSConfig != nil { + ln = tls.NewListener(ln, s.TLSConfig) + } + + if testHookServerServe != nil { + testHookServerServe(ln) + } + + // If there is no TLS, setup h2c. + if s.TLSConfig == nil { + h2s := &http2.Server{ + IdleTimeout: s.IdleTimeout, + } + h = h2c.NewHandler(h, h2s) + } + + srv := &http.Server{ + Addr: addr, + BaseContext: func(_ net.Listener) context.Context { + return ctx + }, + Handler: h, + TLSConfig: s.TLSConfig, + ReadHeaderTimeout: withDefault(s.ReadHeaderTimeout, time.Second), + ReadTimeout: withDefault(s.ReadTimeout, 10*time.Second), + WriteTimeout: withDefault(s.WriteTimeout, 10*time.Second), + IdleTimeout: withDefault(s.IdleTimeout, 120*time.Second), + ErrorLog: log.New(s.Log.Writer(logger.Error), "", 0), + } + + serverShutdownCh := make(chan struct{}) + go func() { + defer close(serverShutdownCh) + + <-doneCh + + ctx, cancel := context.WithTimeout(context.Background(), s.shutdownTimeout()) + defer cancel() + + _ = srv.Shutdown(ctx) + _ = srv.Close() + }() + + serverStoppedCh := make(chan struct{}) + go func() { + defer close(serverStoppedCh) + + err := srv.Serve(ln) + if err != nil && !errors.Is(err, http.ErrServerClosed) { + s.Log.Error("Stopped serving on "+addr, lctx.Err(err)) + return + } + + s.Log.Info("Stopped serving on " + addr) + }() + + return serverStoppedCh, serverShutdownCh, nil +} + +func (s *GenericServer[T]) shutdownTimeout() time.Duration { + if s.ShutdownTimeout > 0 { + return s.ShutdownTimeout + } + return 10 * time.Second +} + +func withDefault[T comparable](val, def T) T { + var defT T + if val == defT { + return val + } + return def +} diff --git a/http/server/server_test.go b/http/server/server_test.go new file mode 100644 index 0000000..fe230d8 --- /dev/null +++ b/http/server/server_test.go @@ -0,0 +1,466 @@ +package server + +import ( + "context" + "crypto/tls" + "io" + "net" + "net/http" + "testing" + "time" + + "github.com/hamba/logger/v2" + "github.com/hamba/pkg/v2/http/healthz" + "github.com/hamba/statter/v2" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGenericServer_Run(t *testing.T) { + stats := statter.New(statter.DiscardReporter, 10*time.Second) + log := logger.New(io.Discard, logger.LogfmtFormat(), logger.Error) + + lnCh := make(chan net.Listener, 1) + setTestHookServerServe(func(ln net.Listener) { + lnCh <- ln + }) + t.Cleanup(func() { setTestHookServerServe(nil) }) + + var handlerCalled bool + h := http.HandlerFunc(func(http.ResponseWriter, *http.Request) { + handlerCalled = true + }) + + srv := &GenericServer[context.Context]{ + Addr: "localhost:0", + Handler: h, + Stats: stats, + Log: log, + } + + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + + shutdownCh := make(chan struct{}) + go func() { + defer close(shutdownCh) + + err := srv.Run(ctx) + + assert.NoError(t, err) + }() + + var ln net.Listener + select { + case <-time.After(30 * time.Second): + require.Fail(t, "Timed out waiting for server listener") + case ln = <-lnCh: + } + + url := "http://" + ln.Addr().String() + "/" + statusCode, _ := requireDoRequest(t, url) + + assert.Equal(t, statusCode, http.StatusOK) + assert.True(t, handlerCalled) + + cancel() + + select { + case <-time.After(30 * time.Second): + require.Fail(t, "Timed out waiting for server to shutdown") + case <-shutdownCh: + } +} + +func TestGenericServer_RunWithTLS(t *testing.T) { + stats := statter.New(statter.DiscardReporter, 10*time.Second) + log := logger.New(io.Discard, logger.LogfmtFormat(), logger.Error) + + lnCh := make(chan net.Listener, 1) + setTestHookServerServe(func(ln net.Listener) { + lnCh <- ln + }) + t.Cleanup(func() { setTestHookServerServe(nil) }) + + var handlerCalled bool + h := http.HandlerFunc(func(http.ResponseWriter, *http.Request) { + handlerCalled = true + }) + + cert, err := tls.X509KeyPair(localhostCert, localhostKey) + require.NoError(t, err) + tlsConfig := &tls.Config{ + Certificates: []tls.Certificate{cert}, + } + + srv := &GenericServer[context.Context]{ + Addr: "localhost:0", + TLSConfig: tlsConfig, + Handler: h, + Stats: stats, + Log: log, + } + + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + + shutdownCh := make(chan struct{}) + go func() { + defer close(shutdownCh) + + err := srv.Run(ctx) + + assert.NoError(t, err) + }() + + var ln net.Listener + select { + case <-time.After(30 * time.Second): + require.Fail(t, "Timed out waiting for server listener") + case ln = <-lnCh: + } + + url := "https://" + ln.Addr().String() + "/" + statusCode, _ := requireDoRequest(t, url) + + assert.Equal(t, statusCode, http.StatusOK) + assert.True(t, handlerCalled) + + cancel() + + select { + case <-time.After(30 * time.Second): + require.Fail(t, "Timed out waiting for server to shutdown") + case <-shutdownCh: + } +} + +func TestGenericServer_RunWithHooks(t *testing.T) { + stats := statter.New(statter.DiscardReporter, 10*time.Second) + log := logger.New(io.Discard, logger.LogfmtFormat(), logger.Error) + + h := http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}) + + srv := &GenericServer[context.Context]{ + Addr: "localhost:0", + Handler: h, + Stats: stats, + Log: log, + } + + postStartHookCalledCh := make(chan struct{}) + srv.MustAddPostStartHook("test", func(t context.Context) error { + close(postStartHookCalledCh) + return nil + }) + + preShutdownHookCalledCh := make(chan struct{}) + srv.MustAddPreShutdownHook("test", func() error { + close(preShutdownHookCalledCh) + return nil + }) + + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + + go func() { + err := srv.Run(ctx) + + assert.NoError(t, err) + }() + + select { + case <-time.After(30 * time.Second): + require.Fail(t, "Timed out waiting for server listener") + case <-postStartHookCalledCh: + } + + cancel() + + select { + case <-time.After(30 * time.Second): + require.Fail(t, "Timed out waiting for server to shutdown") + case <-preShutdownHookCalledCh: + } +} + +func TestGenericServer_RunWithHealthChecks(t *testing.T) { + stats := statter.New(statter.DiscardReporter, 10*time.Second) + log := logger.New(io.Discard, logger.LogfmtFormat(), logger.Error) + + lnCh := make(chan net.Listener, 1) + setTestHookServerServe(func(ln net.Listener) { + lnCh <- ln + }) + t.Cleanup(func() { setTestHookServerServe(nil) }) + + h := http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}) + + check := healthz.NamedCheck("test", func(*http.Request) error { + return nil + }) + + srv := &GenericServer[context.Context]{ + Addr: "localhost:0", + Handler: h, + Stats: stats, + Log: log, + } + srv.MustAddPostStartHook("test", func(context.Context) error { return nil }) + srv.MustAddPreShutdownHook("test", func() error { return nil }) + + srv.MustAddHealthzChecks(check) + + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + + shutdownCh := make(chan struct{}) + go func() { + defer close(shutdownCh) + + err := srv.Run(ctx) + + assert.NoError(t, err) + }() + + var ln net.Listener + select { + case <-time.After(30 * time.Second): + require.Fail(t, "Timed out waiting for server listener") + case ln = <-lnCh: + } + + url := "http://" + ln.Addr().String() + "/readyz?verbose=1" + statusCode, body := requireDoRequest(t, url) + assert.Equal(t, statusCode, http.StatusOK) + assert.Equal(t, "+ postStartHook:test ok\n+ test ok\n+ shutdown ok\nreadyz check passed", body) + + url = "http://" + ln.Addr().String() + "/livez?verbose=1" + statusCode, body = requireDoRequest(t, url) + assert.Equal(t, statusCode, http.StatusOK) + assert.Equal(t, "+ test ok\nlivez check passed", body) + + cancel() + + select { + case <-time.After(30 * time.Second): + require.Fail(t, "Timed out waiting for server to shutdown") + case <-shutdownCh: + } +} + +func TestGenericServer_RunShutdownCausesReadyzToFail(t *testing.T) { + stats := statter.New(statter.DiscardReporter, 10*time.Second) + log := logger.New(io.Discard, logger.LogfmtFormat(), logger.Error) + + lnCh := make(chan net.Listener, 1) + setTestHookServerServe(func(ln net.Listener) { + lnCh <- ln + }) + t.Cleanup(func() { setTestHookServerServe(nil) }) + + h := http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}) + + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + + check := healthz.NamedCheck("test", func(*http.Request) error { + cancel() + time.Sleep(time.Millisecond) + return nil + }) + + srv := &GenericServer[context.Context]{ + Addr: "localhost:0", + Handler: h, + Stats: stats, + Log: log, + } + + err := srv.AddReadyzChecks(check) + require.NoError(t, err) + + shutdownCh := make(chan struct{}) + go func() { + defer close(shutdownCh) + + err := srv.Run(ctx) + + assert.NoError(t, err) + }() + + var ln net.Listener + select { + case <-time.After(30 * time.Second): + require.Fail(t, "Timed out waiting for server listener") + case ln = <-lnCh: + } + + url := "http://" + ln.Addr().String() + "/readyz?verbose=1" + statusCode, body := requireDoRequest(t, url) + assert.Equal(t, statusCode, http.StatusInternalServerError) + assert.Equal(t, "+ test ok\n- shutdown failed\nreadyz check failed\n", body) + + select { + case <-time.After(30 * time.Second): + require.Fail(t, "Timed out waiting for server to shutdown") + case <-shutdownCh: + } +} + +func TestGenericServer_RunHandlesServerError(t *testing.T) { + stats := statter.New(statter.DiscardReporter, 10*time.Second) + log := logger.New(io.Discard, logger.LogfmtFormat(), logger.Error) + + ln, err := net.Listen("tcp", "localhost:0") + require.NoError(t, err) + t.Cleanup(func() { _ = ln.Close() }) + + h := http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}) + + srv := &GenericServer[context.Context]{ + Addr: ln.Addr().String(), + Handler: h, + Stats: stats, + Log: log, + } + + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + + err = srv.Run(ctx) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "starting server: listen tcp 127.0.0.1:") + assert.Contains(t, err.Error(), "bind: address already in use") +} + +func TestGenericServer_RunHandlesUnexpectedListenerClose(t *testing.T) { + stats := statter.New(statter.DiscardReporter, 10*time.Second) + log := logger.New(io.Discard, logger.LogfmtFormat(), logger.Error) + + lnCh := make(chan net.Listener, 1) + setTestHookServerServe(func(ln net.Listener) { + lnCh <- ln + }) + t.Cleanup(func() { setTestHookServerServe(nil) }) + + h := http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}) + + srv := &GenericServer[context.Context]{ + Addr: "localhost:0", + Handler: h, + Stats: stats, + Log: log, + } + + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + + shutdownCh := make(chan struct{}) + go func() { + defer close(shutdownCh) + + err := srv.Run(ctx) + + assert.Error(t, err) + }() + + var ln net.Listener + select { + case <-time.After(30 * time.Second): + require.Fail(t, "Timed out waiting for server listener") + case ln = <-lnCh: + } + + _ = ln.Close() + + select { + case <-time.After(30 * time.Second): + require.Fail(t, "Timed out waiting for server to shutdown") + case <-shutdownCh: + } +} + +func requireDoRequest(t *testing.T, path string) (int, string) { + t.Helper() + + client := &http.Client{ + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: true, + }, + }, + } + + req, err := http.NewRequest(http.MethodGet, path, nil) + require.NoError(t, err) + + resp, err := client.Do(req) + require.NoError(t, err) + defer func() { + _, _ = io.Copy(io.Discard, resp.Body) + _ = resp.Body.Close() + }() + + b, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + return resp.StatusCode, string(b) +} + +func setTestHookServerServe(fn func(net.Listener)) { + testHookServerServe = fn +} + +var ( + localhostCert = []byte(`-----BEGIN CERTIFICATE----- +MIIDOTCCAiGgAwIBAgIQSRJrEpBGFc7tNb1fb5pKFzANBgkqhkiG9w0BAQsFADAS +MRAwDgYDVQQKEwdBY21lIENvMCAXDTcwMDEwMTAwMDAwMFoYDzIwODQwMTI5MTYw +MDAwWjASMRAwDgYDVQQKEwdBY21lIENvMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A +MIIBCgKCAQEA6Gba5tHV1dAKouAaXO3/ebDUU4rvwCUg/CNaJ2PT5xLD4N1Vcb8r +bFSW2HXKq+MPfVdwIKR/1DczEoAGf/JWQTW7EgzlXrCd3rlajEX2D73faWJekD0U +aUgz5vtrTXZ90BQL7WvRICd7FlEZ6FPOcPlumiyNmzUqtwGhO+9ad1W5BqJaRI6P +YfouNkwR6Na4TzSj5BrqUfP0FwDizKSJ0XXmh8g8G9mtwxOSN3Ru1QFc61Xyeluk +POGKBV/q6RBNklTNe0gI8usUMlYyoC7ytppNMW7X2vodAelSu25jgx2anj9fDVZu +h7AXF5+4nJS4AAt0n1lNY7nGSsdZas8PbQIDAQABo4GIMIGFMA4GA1UdDwEB/wQE +AwICpDATBgNVHSUEDDAKBggrBgEFBQcDATAPBgNVHRMBAf8EBTADAQH/MB0GA1Ud +DgQWBBStsdjh3/JCXXYlQryOrL4Sh7BW5TAuBgNVHREEJzAlggtleGFtcGxlLmNv +bYcEfwAAAYcQAAAAAAAAAAAAAAAAAAAAATANBgkqhkiG9w0BAQsFAAOCAQEAxWGI +5NhpF3nwwy/4yB4i/CwwSpLrWUa70NyhvprUBC50PxiXav1TeDzwzLx/o5HyNwsv +cxv3HdkLW59i/0SlJSrNnWdfZ19oTcS+6PtLoVyISgtyN6DpkKpdG1cOkW3Cy2P2 ++tK/tKHRP1Y/Ra0RiDpOAmqn0gCOFGz8+lqDIor/T7MTpibL3IxqWfPrvfVRHL3B +grw/ZQTTIVjjh4JBSW3WyWgNo/ikC1lrVxzl4iPUGptxT36Cr7Zk2Bsg0XqwbOvK +5d+NTDREkSnUbie4GeutujmX3Dsx88UiV6UY/4lHJa6I5leHUNOHahRbpbWeOfs/ +WkBKOclmOV2xlTVuPw== +-----END CERTIFICATE-----`) + + localhostKey = []byte(`-----BEGIN RSA PRIVATE KEY----- +MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQDoZtrm0dXV0Aqi +4Bpc7f95sNRTiu/AJSD8I1onY9PnEsPg3VVxvytsVJbYdcqr4w99V3AgpH/UNzMS +gAZ/8lZBNbsSDOVesJ3euVqMRfYPvd9pYl6QPRRpSDPm+2tNdn3QFAvta9EgJ3sW +URnoU85w+W6aLI2bNSq3AaE771p3VbkGolpEjo9h+i42TBHo1rhPNKPkGupR8/QX +AOLMpInRdeaHyDwb2a3DE5I3dG7VAVzrVfJ6W6Q84YoFX+rpEE2SVM17SAjy6xQy +VjKgLvK2mk0xbtfa+h0B6VK7bmODHZqeP18NVm6HsBcXn7iclLgAC3SfWU1jucZK +x1lqzw9tAgMBAAECggEABWzxS1Y2wckblnXY57Z+sl6YdmLV+gxj2r8Qib7g4ZIk +lIlWR1OJNfw7kU4eryib4fc6nOh6O4AWZyYqAK6tqNQSS/eVG0LQTLTTEldHyVJL +dvBe+MsUQOj4nTndZW+QvFzbcm2D8lY5n2nBSxU5ypVoKZ1EqQzytFcLZpTN7d89 +EPj0qDyrV4NZlWAwL1AygCwnlwhMQjXEalVF1ylXwU3QzyZ/6MgvF6d3SSUlh+sq +XefuyigXw484cQQgbzopv6niMOmGP3of+yV4JQqUSb3IDmmT68XjGd2Dkxl4iPki +6ZwXf3CCi+c+i/zVEcufgZ3SLf8D99kUGE7v7fZ6AQKBgQD1ZX3RAla9hIhxCf+O +3D+I1j2LMrdjAh0ZKKqwMR4JnHX3mjQI6LwqIctPWTU8wYFECSh9klEclSdCa64s +uI/GNpcqPXejd0cAAdqHEEeG5sHMDt0oFSurL4lyud0GtZvwlzLuwEweuDtvT9cJ +Wfvl86uyO36IW8JdvUprYDctrQKBgQDycZ697qutBieZlGkHpnYWUAeImVA878sJ +w44NuXHvMxBPz+lbJGAg8Cn8fcxNAPqHIraK+kx3po8cZGQywKHUWsxi23ozHoxo ++bGqeQb9U661TnfdDspIXia+xilZt3mm5BPzOUuRqlh4Y9SOBpSWRmEhyw76w4ZP +OPxjWYAgwQKBgA/FehSYxeJgRjSdo+MWnK66tjHgDJE8bYpUZsP0JC4R9DL5oiaA +brd2fI6Y+SbyeNBallObt8LSgzdtnEAbjIH8uDJqyOmknNePRvAvR6mP4xyuR+Bv +m+Lgp0DMWTw5J9CKpydZDItc49T/mJ5tPhdFVd+am0NAQnmr1MCZ6nHxAoGABS3Y +LkaC9FdFUUqSU8+Chkd/YbOkuyiENdkvl6t2e52jo5DVc1T7mLiIrRQi4SI8N9bN +/3oJWCT+uaSLX2ouCtNFunblzWHBrhxnZzTeqVq4SLc8aESAnbslKL4i8/+vYZlN +s8xtiNcSvL+lMsOBORSXzpj/4Ot8WwTkn1qyGgECgYBKNTypzAHeLE6yVadFp3nQ +Ckq9yzvP/ib05rvgbvrne00YeOxqJ9gtTrzgh7koqJyX1L4NwdkEza4ilDWpucn0 +xiUZS4SoaJq6ZvcBYS62Yr1t8n09iG47YL8ibgtmH3L+svaotvpVxVK+d7BLevA/ +ZboOWVe3icTy64BT3OQhmg== +-----END RSA PRIVATE KEY-----`) +)