diff --git a/cmd/pad/main.go b/cmd/pad/main.go index e2dc0cd9..4103e323 100644 --- a/cmd/pad/main.go +++ b/cmd/pad/main.go @@ -195,7 +195,8 @@ func serveCmd() *cobra.Command { srv.SetSecureCookies(cfg.SecureCookies) // Attach event bus for real-time SSE - srv.SetEventBus(events.New()) + eventBus := events.New() + srv.SetEventBus(eventBus) // Attach webhook dispatcher for outgoing notifications srv.SetWebhookDispatcher(webhooks.NewDispatcher(s)) @@ -225,7 +226,42 @@ func serveCmd() *cobra.Command { } } - return srv.ListenAndServe(cfg.Addr()) + // Graceful shutdown: listen for SIGINT/SIGTERM + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + // Start server in a goroutine + errCh := make(chan error, 1) + go func() { + errCh <- srv.ListenAndServe(cfg.Addr()) + }() + + // Wait for signal or server error + select { + case err := <-errCh: + // Server failed to start or crashed + return err + case <-ctx.Done(): + // Received shutdown signal + log.Println("Shutting down server (30s grace period)...") + stop() // Reset signal handling so a second signal force-kills + + shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + if err := srv.Shutdown(shutdownCtx); err != nil { + log.Printf("HTTP server shutdown error: %v", err) + } + + // Close event bus (terminates SSE connections) + if eventBus != nil { + eventBus.Close() + log.Println("Event bus closed") + } + + log.Println("Server stopped") + return nil + } }, } diff --git a/internal/events/bus.go b/internal/events/bus.go index 8660b5c3..9f6b322c 100644 --- a/internal/events/bus.go +++ b/internal/events/bus.go @@ -115,6 +115,18 @@ func (b *Bus) Publish(event Event) { } } +// Close shuts down the event bus by closing all subscriber channels. +// SSE handler goroutines will see the channel close and exit cleanly. +func (b *Bus) Close() { + b.mu.Lock() + defer b.mu.Unlock() + + for ch := range b.subscribers { + delete(b.subscribers, ch) + close(ch) + } +} + // SubscriberCount returns the number of active subscribers (for testing/debugging). func (b *Bus) SubscriberCount() int { b.mu.RLock() diff --git a/internal/server/server.go b/internal/server/server.go index 71cbb503..f6d86978 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -1,6 +1,7 @@ package server import ( + "context" "encoding/json" "fmt" "io/fs" @@ -8,6 +9,7 @@ import ( "net/http" "strings" "sync" + "time" "github.com/go-chi/chi/v5" chimiddleware "github.com/go-chi/chi/v5/middleware" @@ -24,6 +26,7 @@ type Server struct { store *store.Store router *chi.Mux routerOnce sync.Once // ensures setupRouter runs once, after all config + httpServer *http.Server // underlying HTTP server (set during ListenAndServe) webFS fs.FS // embedded web UI static files (optional) events *events.Bus // real-time event bus (optional) webhooks *webhooks.Dispatcher // webhook dispatcher (optional) @@ -327,6 +330,7 @@ func (s *Server) setupRouter() { // SetWebUI sets the embedded web UI filesystem for serving the SPA. func (s *Server) SetWebUI(fsys fs.FS) { s.webFS = fsys + s.ensureRouter() s.router.Handle("/*", s.spaHandler()) } @@ -376,8 +380,35 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { func (s *Server) ListenAndServe(addr string) error { s.ensureRouter() + + s.httpServer = &http.Server{ + Addr: addr, + Handler: s.router, + ReadTimeout: 15 * time.Second, + ReadHeaderTimeout: 5 * time.Second, + IdleTimeout: 120 * time.Second, + // WriteTimeout left at 0 — SSE connections are long-lived. + // Non-SSE handlers should use per-request context deadlines. + } + log.Printf("Pad server listening on %s", addr) - return http.ListenAndServe(addr, s.router) + return s.httpServer.ListenAndServe() +} + +// Shutdown gracefully drains in-flight requests and stops the HTTP server. +// The provided context controls how long to wait for active connections. +func (s *Server) Shutdown(ctx context.Context) error { + if s.httpServer == nil { + return nil + } + return s.httpServer.Shutdown(ctx) +} + +// Handler returns the configured HTTP handler (router). +// Useful for testing with httptest.NewServer. +func (s *Server) Handler() http.Handler { + s.ensureRouter() + return s.router } // --- helpers ---