feat: implement graceful shutdown with request draining (TASK-159)

- Add signal handler for SIGINT/SIGTERM with 30s grace period
- Add Server.Shutdown() for graceful HTTP connection draining
- Add EventBus.Close() to cleanly terminate SSE subscribers
- Configure HTTP server timeouts (read: 15s, header: 5s, idle: 120s)
- Fix SetWebUI nil router panic by calling ensureRouter()
- Add Server.Handler() for httptest compatibility
This commit is contained in:
xarmian
2026-04-05 14:56:56 +00:00
parent 8aa6481421
commit dab6d6c9c9
3 changed files with 82 additions and 3 deletions
+38 -2
View File
@@ -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
}
},
}
+12
View File
@@ -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()
+32 -1
View File
@@ -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 ---