fix: graceful SIGINT shutdown and SIGHUP config reload SIGINT now exits cleanly with code 0 instead of propagating http.ErrServerClosed as a fatal error. SIGHUP re-reads the config file and atomically swaps the config pointer — settings like site_title, favicon_path, behind_proxy, and require_auth_for_browse take effect immediately without restarting. Config is stored as *config.Config throughout so handlers and middleware read the live value at request time. Co-authored-by: Crush
Maxwell Jensen maxwelljensen@posteo.net
Wed, 13 May 2026 20:10:38 +0200
4 files changed,
35 insertions(+),
14 deletions(-)
M
cmd/weimar/serve.go
→
cmd/weimar/serve.go
@@ -5,6 +5,7 @@ "embed"
"fmt" "io/fs" "log/slog" + "os" "os/signal" "syscall"@@ -57,20 +58,31 @@ slog.Info("created initial admin user", "username", adminEmail)
} } - srv, err := server.New(cfg, webSub, database) + srv, err := server.New(&cfg, webSub, database) if err != nil { return fmt.Errorf("creating server: %w", err) } + // Handle SIGHUP for config reload. + sig := make(chan os.Signal, 1) + signal.Notify(sig, syscall.SIGHUP) + go func() { + for range sig { + slog.Info("received SIGHUP, reloading config") + if err := initConfig(); err != nil { + slog.Warn("config reload failed", "error", err) + continue + } + srv.ReloadConfig(config.FromViper()) + slog.Info("config reloaded") + } + }() + ctx, stop := signal.NotifyContext(cmd.Context(), syscall.SIGINT, syscall.SIGTERM) defer stop() slog.Info("starting weimar", "host", cfg.Server.Host, "port", cfg.Server.Port) - if err := srv.Start(ctx); err != nil { - return fmt.Errorf("server error: %w", err) - } - - return nil + return srv.Start(ctx) }, }
M
internal/api/api.go
→
internal/api/api.go
@@ -26,10 +26,10 @@ type API struct {
DB *db.DB Auth *auth.SessionManager Storage *storage.Storage - Cfg config.Config + Cfg *config.Config } -func New(database *db.DB, sm *auth.SessionManager, st *storage.Storage, cfg config.Config) *API { +func New(database *db.DB, sm *auth.SessionManager, st *storage.Storage, cfg *config.Config) *API { return &API{ DB: database, Auth: sm,
M
internal/api/api_test.go
→
internal/api/api_test.go
@@ -50,7 +50,7 @@ if err != nil {
t.Fatalf("login: %v", err) } - return New(database, sm, st, cfg), token + return New(database, sm, st, &cfg), token } // authenticatedRequest creates a request with the session cookie.@@ -100,7 +100,7 @@ cfg := config.Config{
Auth: config.AuthConfig{AllowRegistration: allowRegistration}, Upload: config.UploadConfig{MaxSizeMB: 10}, } - return New(database, sm, st, cfg) + return New(database, sm, st, &cfg) } func TestHandleRegister_Success(t *testing.T) {@@ -359,7 +359,7 @@ sm := auth.NewSessionManager(database)
auth.Register(context.Background(), database, "alice", "password123", false) t.Cleanup(func() { database.Close() }) st, _ := storage.New(t.TempDir()) - a := New(database, sm, st, config.Config{ + a := New(database, sm, st, &config.Config{ Upload: config.UploadConfig{MaxSizeMB: 0}, // 0 = no max size, so no rejection })
M
internal/server/server.go
→
internal/server/server.go
@@ -13,7 +13,7 @@ "github.com/mjensen/weimar/internal/db"
"github.com/mjensen/weimar/internal/storage" ) -func New(cfg config.Config, webFS fs.FS, database *db.DB) (*Server, error) { +func New(cfg *config.Config, webFS fs.FS, database *db.DB) (*Server, error) { sm := auth.NewSessionManager(database) st, err := storage.New(cfg.Storage.Path) if err != nil {@@ -73,7 +73,7 @@ return s, nil
} type Server struct { - cfg config.Config + cfg *config.Config mux *http.ServeMux auth *auth.SessionManager }@@ -91,7 +91,16 @@ <-ctx.Done()
srv.Shutdown(context.Background()) }() - return srv.ListenAndServe() + if err := srv.ListenAndServe(); err != http.ErrServerClosed { + return err + } + return nil +} + +// ReloadConfig atomically swaps the server config. Handlers and middleware +// read from the pointer at request time and will pick up the new values. +func (s *Server) ReloadConfig(cfg config.Config) { + *s.cfg = cfg } func itoa(n int) string {