From 6dcb7a95dd4f79a117c14d6825e1a863b363c49c Mon Sep 17 00:00:00 2001 From: Milan Malfait <38256462+milanmlft@users.noreply.github.com> Date: Fri, 10 Apr 2026 14:58:53 +0100 Subject: [PATCH 1/7] Add `graceful.ServeTLS()` --- pkg/graceful/serve.go | 30 ++++++++++++++++++++++++------ 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/pkg/graceful/serve.go b/pkg/graceful/serve.go index 2120b54..dc6b754 100644 --- a/pkg/graceful/serve.go +++ b/pkg/graceful/serve.go @@ -16,7 +16,31 @@ import ( func Serve(server *http.Server, shutdownDuration time.Duration) { go listenAndServe(server) log.Info().Msg("Started HTTP server") + serveGracefully(server, shutdownDuration) +} + +func listenAndServe(server *http.Server) { + if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Err(err).Msg("Failed to serve") + } +} + +// ServeTLS serves a https handler with graceful shutdown of connections on +// SIGINT and SIGTERM. +func ServeTLS(server *http.Server, shutdownDuration time.Duration) { + go listenAndServeTLS(server) + log.Info().Msg("Started HTTPS server") + serveGracefully(server, shutdownDuration) +} +func listenAndServeTLS(server *http.Server) { + // cert/key are in server.TLSConfig, so pass empty strings + if err := server.ListenAndServeTLS("", ""); err != nil && err != http.ErrServerClosed { + log.Err(err).Msg("Failed to serve") + } +} + +func serveGracefully(server *http.Server, shutdownDuration time.Duration) { signalChan := make(chan os.Signal, 1) signal.Notify(signalChan, syscall.SIGINT, syscall.SIGTERM) <-signalChan @@ -30,9 +54,3 @@ func Serve(server *http.Server, shutdownDuration time.Duration) { } log.Info().Msg("Server exited") } - -func listenAndServe(server *http.Server) { - if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { - log.Err(err).Msg("Failed to serve") - } -} From a0ada82f52355a86a76032c1c807e060db5330d3 Mon Sep 17 00:00:00 2001 From: Milan Malfait <38256462+milanmlft@users.noreply.github.com> Date: Fri, 10 Apr 2026 15:08:13 +0100 Subject: [PATCH 2/7] Add test --- pkg/graceful/serve_test.go | 50 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/pkg/graceful/serve_test.go b/pkg/graceful/serve_test.go index bd4b191..60dd354 100644 --- a/pkg/graceful/serve_test.go +++ b/pkg/graceful/serve_test.go @@ -2,7 +2,15 @@ package graceful import ( "bytes" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" "io" + "math/big" "net/http" "os" "regexp" @@ -44,6 +52,48 @@ func TestServeLogStream(t *testing.T) { assert.Regexp(t, regexp.MustCompile(`Started.*\n.*Received termination signal`), logStream) } +func TestServeTLSLogStream(t *testing.T) { + logBuffer := &bytes.Buffer{} + log.Logger = zerolog.New(logBuffer) + + key := must(ecdsa.GenerateKey(elliptic.P256(), rand.Reader)) + template := x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "localhost"}, + } + certDER := must(x509.CreateCertificate(rand.Reader, &template, &template, &key.PublicKey, key)) + keyDER := must(x509.MarshalECPrivateKey(key)) + tlsCert := must(tls.X509KeyPair( + pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER}), + pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}), + )) + + server := http.Server{ + Handler: http.NewServeMux(), + Addr: "127.0.0.1:8443", + TLSConfig: &tls.Config{Certificates: []tls.Certificate{tlsCert}}, + } + go ServeTLS(&server, 10*time.Millisecond) + time.Sleep(100 * time.Millisecond) // some startup time + + process := must(os.FindProcess(os.Getpid())) + err := process.Signal(syscall.SIGINT) + assert.NoError(t, err) + time.Sleep(20 * time.Millisecond) // some shutdown time > shutdown duration + + logStream := string(must(io.ReadAll(logBuffer))) + expectedLines := []string{ + "Started HTTPS server", + "Received termination signal", + "Closing server", + "Server exited", + } + for _, expectedLine := range expectedLines { + assert.Contains(t, logStream, expectedLine) + } + assert.Regexp(t, regexp.MustCompile(`Started.*\n.*Received termination signal`), logStream) +} + func must[T any](obj T, err error) T { if err != nil { panic(err) From c0f56664074c81894f638df2e7f8ef697ea1c990 Mon Sep 17 00:00:00 2001 From: Milan Malfait <38256462+milanmlft@users.noreply.github.com> Date: Fri, 10 Apr 2026 16:19:03 +0200 Subject: [PATCH 3/7] Fix doc comment Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- pkg/graceful/serve.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/graceful/serve.go b/pkg/graceful/serve.go index dc6b754..4fde471 100644 --- a/pkg/graceful/serve.go +++ b/pkg/graceful/serve.go @@ -25,7 +25,7 @@ func listenAndServe(server *http.Server) { } } -// ServeTLS serves a https handler with graceful shutdown of connections on +// ServeTLS serves an HTTPS handler with graceful shutdown of connections on // SIGINT and SIGTERM. func ServeTLS(server *http.Server, shutdownDuration time.Duration) { go listenAndServeTLS(server) From d94fac8d844cb3c9adbabd8d54668c871e433818 Mon Sep 17 00:00:00 2001 From: Milan Malfait <38256462+milanmlft@users.noreply.github.com> Date: Fri, 10 Apr 2026 16:21:01 +0200 Subject: [PATCH 4/7] Add deferred `signal.Stop` Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- pkg/graceful/serve.go | 1 + 1 file changed, 1 insertion(+) diff --git a/pkg/graceful/serve.go b/pkg/graceful/serve.go index 4fde471..1fc17bd 100644 --- a/pkg/graceful/serve.go +++ b/pkg/graceful/serve.go @@ -43,6 +43,7 @@ func listenAndServeTLS(server *http.Server) { func serveGracefully(server *http.Server, shutdownDuration time.Duration) { signalChan := make(chan os.Signal, 1) signal.Notify(signalChan, syscall.SIGINT, syscall.SIGTERM) + defer signal.Stop(signalChan) <-signalChan log.Info().Msg("Received termination signal") From 3ffc3d6503fdef689691c1c82d579eb0ac3fc079 Mon Sep 17 00:00:00 2001 From: Milan Malfait <38256462+milanmlft@users.noreply.github.com> Date: Fri, 10 Apr 2026 15:23:39 +0100 Subject: [PATCH 5/7] Add guard against misconfigured TLS --- pkg/graceful/serve.go | 6 +++++- pkg/graceful/serve_test.go | 8 ++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/pkg/graceful/serve.go b/pkg/graceful/serve.go index 1fc17bd..3518af5 100644 --- a/pkg/graceful/serve.go +++ b/pkg/graceful/serve.go @@ -26,8 +26,12 @@ func listenAndServe(server *http.Server) { } // ServeTLS serves an HTTPS handler with graceful shutdown of connections on -// SIGINT and SIGTERM. +// SIGINT and SIGTERM. The server's TLSConfig must have at least one certificate +// configured. func ServeTLS(server *http.Server, shutdownDuration time.Duration) { + if server.TLSConfig == nil || len(server.TLSConfig.Certificates) == 0 { + panic("ServeTLS requires TLSConfig with at least one certificate") + } go listenAndServeTLS(server) log.Info().Msg("Started HTTPS server") serveGracefully(server, shutdownDuration) diff --git a/pkg/graceful/serve_test.go b/pkg/graceful/serve_test.go index 60dd354..d1902cb 100644 --- a/pkg/graceful/serve_test.go +++ b/pkg/graceful/serve_test.go @@ -94,6 +94,14 @@ func TestServeTLSLogStream(t *testing.T) { assert.Regexp(t, regexp.MustCompile(`Started.*\n.*Received termination signal`), logStream) } +func TestServeTLSPanicsWithoutTLSConfig(t *testing.T) { + server := http.Server{ + Handler: http.NewServeMux(), + Addr: "127.0.0.1:8443", + } + assert.Panics(t, func() { ServeTLS(&server, 10*time.Millisecond) }) +} + func must[T any](obj T, err error) T { if err != nil { panic(err) From b346191c56b0cdcf12adb20ebc5f8a62bc72ec50 Mon Sep 17 00:00:00 2001 From: Milan Malfait <38256462+milanmlft@users.noreply.github.com> Date: Fri, 10 Apr 2026 15:25:33 +0100 Subject: [PATCH 6/7] Clearer error log --- pkg/graceful/serve.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/graceful/serve.go b/pkg/graceful/serve.go index 3518af5..8dec5e5 100644 --- a/pkg/graceful/serve.go +++ b/pkg/graceful/serve.go @@ -40,7 +40,7 @@ func ServeTLS(server *http.Server, shutdownDuration time.Duration) { func listenAndServeTLS(server *http.Server) { // cert/key are in server.TLSConfig, so pass empty strings if err := server.ListenAndServeTLS("", ""); err != nil && err != http.ErrServerClosed { - log.Err(err).Msg("Failed to serve") + log.Err(err).Msg("Failed to serve TLS") } } From 9931be15d75f933fb7494d59e4ee76b493736f7f Mon Sep 17 00:00:00 2001 From: Milan Malfait <38256462+milanmlft@users.noreply.github.com> Date: Fri, 10 Apr 2026 15:26:18 +0100 Subject: [PATCH 7/7] Restore original logger after tests --- pkg/graceful/serve_test.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pkg/graceful/serve_test.go b/pkg/graceful/serve_test.go index d1902cb..ba40105 100644 --- a/pkg/graceful/serve_test.go +++ b/pkg/graceful/serve_test.go @@ -25,6 +25,8 @@ import ( func TestServeLogStream(t *testing.T) { logBuffer := &bytes.Buffer{} + original := log.Logger + t.Cleanup(func() { log.Logger = original }) log.Logger = zerolog.New(logBuffer) server := http.Server{ @@ -54,6 +56,8 @@ func TestServeLogStream(t *testing.T) { func TestServeTLSLogStream(t *testing.T) { logBuffer := &bytes.Buffer{} + original := log.Logger + t.Cleanup(func() { log.Logger = original }) log.Logger = zerolog.New(logBuffer) key := must(ecdsa.GenerateKey(elliptic.P256(), rand.Reader))