Wait for IRC listener cleanup and HTTP shutdown on stop (closes #159)
check / check (push) Canceled after 0s
check / check (push) Canceled after 0s
Stopping the server returned before the IRC listener's connections had run their cleanup, and the database closed under them, so their sessions, clients and channel memberships stayed and their nicks stayed taken after a restart. The IRC listener's stop now waits up to five seconds for every connection's cleanup, which now runs on a context that stopping does not cancel. The HTTP server's stop hook now runs Shutdown itself and waits for it; httpServer is set before serving starts, and the server's own signal watcher, which raced fx's stop hooks, is gone. Model: opus-5-5
This commit is contained in:
@@ -1,6 +1,13 @@
|
||||
package server
|
||||
|
||||
import "github.com/go-chi/chi/v5"
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"sneak.berlin/go/neoirc/internal/config"
|
||||
)
|
||||
|
||||
// NewTestSPAServer creates a Server whose router serves only
|
||||
// the embedded web client.
|
||||
@@ -10,3 +17,28 @@ func NewTestSPAServer() *Server {
|
||||
|
||||
return srv
|
||||
}
|
||||
|
||||
// NewTestHTTPServer creates a Server whose router serves
|
||||
// handler at / and whose HTTP server listens on port once
|
||||
// Start is called.
|
||||
func NewTestHTTPServer(port int, handler http.HandlerFunc) *Server {
|
||||
router := chi.NewRouter()
|
||||
router.Get("/", handler)
|
||||
|
||||
return &Server{
|
||||
log: slog.New(slog.DiscardHandler),
|
||||
router: router,
|
||||
params: Params{Config: &config.Config{Port: port}},
|
||||
}
|
||||
}
|
||||
|
||||
// Start starts the HTTP server, as the start hook does once
|
||||
// the router is built.
|
||||
func (srv *Server) Start() {
|
||||
srv.startHTTP()
|
||||
}
|
||||
|
||||
// Stop does what the stop hook does.
|
||||
func (srv *Server) Stop(ctx context.Context) {
|
||||
srv.cleanShutdown(ctx)
|
||||
}
|
||||
|
||||
+21
-46
@@ -45,11 +45,8 @@ type Params struct {
|
||||
// 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
|
||||
@@ -78,13 +75,16 @@ func New(
|
||||
srv.enableSentry()
|
||||
srv.SetupRoutes()
|
||||
|
||||
// The start hook's context ends when the hook
|
||||
// returns; serving must outlive it.
|
||||
go srv.serve() //nolint:contextcheck,gosec // G118
|
||||
// A closed stdout or stderr must not end the process.
|
||||
signal.Ignore(syscall.SIGPIPE)
|
||||
|
||||
srv.startHTTP()
|
||||
|
||||
return nil
|
||||
},
|
||||
OnStop: func(_ context.Context) error {
|
||||
OnStop: func(ctx context.Context) error {
|
||||
srv.cleanShutdown(ctx)
|
||||
|
||||
return nil
|
||||
},
|
||||
})
|
||||
@@ -129,44 +129,15 @@ func (srv *Server) enableSentry() {
|
||||
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
|
||||
|
||||
// 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(
|
||||
context.Background(), shutdownTimeout,
|
||||
ctx, shutdownTimeout,
|
||||
)
|
||||
|
||||
err := srv.httpServer.Shutdown(ctxShutdown)
|
||||
@@ -191,7 +162,11 @@ func (srv *Server) configure() {
|
||||
// Server configuration placeholder.
|
||||
}
|
||||
|
||||
func (srv *Server) serveUntilShutdown() {
|
||||
// startHTTP creates the HTTP server and serves it 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() {
|
||||
listenAddr := fmt.Sprintf(
|
||||
":%d", srv.params.Config.Port,
|
||||
)
|
||||
@@ -204,16 +179,16 @@ func (srv *Server) serveUntilShutdown() {
|
||||
Handler: srv,
|
||||
}
|
||||
|
||||
go srv.serveUntilShutdown()
|
||||
}
|
||||
|
||||
func (srv *Server) serveUntilShutdown() {
|
||||
srv.log.Info(
|
||||
"http begin listen", "listenaddr", listenAddr,
|
||||
"http begin listen", "listenaddr", srv.httpServer.Addr,
|
||||
)
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
package server_test
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/neoirc/internal/server"
|
||||
)
|
||||
|
||||
const (
|
||||
testTimeout = 5 * time.Second
|
||||
|
||||
// listenCheckStep is how often waitUntilListening tries
|
||||
// to connect.
|
||||
listenCheckStep = 10 * time.Millisecond
|
||||
|
||||
// stopHoldTime is how long a test waits to see that Stop
|
||||
// has not returned.
|
||||
stopHoldTime = 200 * time.Millisecond
|
||||
)
|
||||
|
||||
// TestStopWaitsForRequestInProgress checks that stopping the
|
||||
// server waits for a request it is serving to finish, and
|
||||
// that the request gets its response.
|
||||
func TestStopWaitsForRequestInProgress(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
started := make(chan struct{})
|
||||
release := make(chan struct{})
|
||||
|
||||
port := freePort(t)
|
||||
addr := net.JoinHostPort("127.0.0.1", strconv.Itoa(port))
|
||||
|
||||
srv := server.NewTestHTTPServer(
|
||||
port,
|
||||
func(writer http.ResponseWriter, _ *http.Request) {
|
||||
close(started)
|
||||
<-release
|
||||
writer.WriteHeader(http.StatusNoContent)
|
||||
},
|
||||
)
|
||||
srv.Start()
|
||||
|
||||
waitUntilListening(t, addr)
|
||||
|
||||
statuses := make(chan int, 1)
|
||||
|
||||
go func() {
|
||||
statuses <- get(t, "http://"+addr+"/")
|
||||
}()
|
||||
|
||||
<-started
|
||||
|
||||
stopped := make(chan struct{})
|
||||
|
||||
go func() {
|
||||
srv.Stop(t.Context())
|
||||
close(stopped)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-stopped:
|
||||
t.Error("Stop returned while a request was in progress")
|
||||
case <-time.After(stopHoldTime):
|
||||
}
|
||||
|
||||
close(release)
|
||||
|
||||
status := <-statuses
|
||||
if status != http.StatusNoContent {
|
||||
t.Errorf("status %d, want %d", status, http.StatusNoContent)
|
||||
}
|
||||
|
||||
select {
|
||||
case <-stopped:
|
||||
case <-time.After(testTimeout):
|
||||
t.Fatal("Stop did not return after the request finished")
|
||||
}
|
||||
}
|
||||
|
||||
// freePort returns a TCP port that nothing was listening on a
|
||||
// moment ago.
|
||||
func freePort(t *testing.T) int {
|
||||
t.Helper()
|
||||
|
||||
var listenConfig net.ListenConfig
|
||||
|
||||
listener, err := listenConfig.Listen(
|
||||
t.Context(), "tcp", "127.0.0.1:0",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
|
||||
tcpAddr, ok := listener.Addr().(*net.TCPAddr)
|
||||
if !ok {
|
||||
t.Fatalf("listener address %v is not TCP", listener.Addr())
|
||||
}
|
||||
|
||||
err = listener.Close()
|
||||
if err != nil {
|
||||
t.Fatalf("close listener: %v", err)
|
||||
}
|
||||
|
||||
return tcpAddr.Port
|
||||
}
|
||||
|
||||
// waitUntilListening waits until addr accepts connections,
|
||||
// and fails the test if it does not within testTimeout.
|
||||
func waitUntilListening(t *testing.T, addr string) {
|
||||
t.Helper()
|
||||
|
||||
var dialer net.Dialer
|
||||
|
||||
deadline := time.Now().Add(testTimeout)
|
||||
|
||||
for {
|
||||
conn, err := dialer.DialContext(t.Context(), "tcp", addr)
|
||||
if err == nil {
|
||||
_ = conn.Close()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("nothing listening on %s: %v", addr, err)
|
||||
}
|
||||
|
||||
time.Sleep(listenCheckStep)
|
||||
}
|
||||
}
|
||||
|
||||
// get sends a GET request to url and returns the response
|
||||
// status, or 0 if there was no response.
|
||||
func get(t *testing.T, url string) int {
|
||||
t.Helper()
|
||||
|
||||
request, err := http.NewRequestWithContext(
|
||||
t.Context(), http.MethodGet, url, nil,
|
||||
)
|
||||
if err != nil {
|
||||
t.Errorf("new request: %v", err)
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
client := http.Client{Timeout: testTimeout}
|
||||
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
t.Errorf("get %s: %v", url, err)
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
_ = response.Body.Close()
|
||||
|
||||
return response.StatusCode
|
||||
}
|
||||
Reference in New Issue
Block a user