Wait for IRC listener cleanup and HTTP shutdown on stop (closes #159)
check / check (push) Waiting to run

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 was merged in pull request #166.
This commit is contained in:
2026-10-20 23:48:30 +02:00
parent 108e381428
commit 497514d3df
6 changed files with 433 additions and 48 deletions
+21 -46
View File
@@ -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()
}
}
}