mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-11 21:39:01 +00:00
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:
+38
-2
@@ -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
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 ---
|
||||
|
||||
Reference in New Issue
Block a user