Skip to content

Commit 6c6c195

Browse files
fix(control-plane): implement graceful shutdown on SIGTERM/SIGINT (#715)
* fix(control-plane): implement graceful shutdown (#427) Replace the select{} no-op with real signal handling and HTTP server drain so SIGTERM during rolling deploys no longer kills in-flight requests. Changes: - Install signal.NotifyContext for SIGINT/SIGTERM in runServer - Replace gin Router.Run() with net/http.Server for Shutdown() support - Call server.Stop() on signal: drains HTTP connections, stops background goroutines (presence manager, health monitor, cleanup, OTel, etc.) - Add configurable shutdown_timeout (default 30s) via YAML and AGENTFIELD_SHUTDOWN_TIMEOUT env var - Remove literal '// TODO: Implement graceful shutdown' comments - Add nil guard for healthMonitor.Stop() to prevent panic on empty server Closes #427 * test: add coverage for graceful shutdown paths - Test AGENTFIELD_SHUTDOWN_TIMEOUT env override and defaults - Test Stop() on empty server (nil safety) - Test HTTP server graceful shutdown with active listener - Test HTTP server shutdown timeout + force close - Test defaultWaitForShutdown unblocks on SIGINT (Linux/macOS only) * fix(control-plane): tighten graceful shutdown lifecycle --------- Co-authored-by: santoshkumarradha <instrument.santosh@gmail.com>
1 parent f84f769 commit 6c6c195

7 files changed

Lines changed: 258 additions & 9 deletions

File tree

control-plane/cmd/agentfield-server/main.go

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,17 @@
11
package main
22

33
import (
4+
"context"
45
"fmt"
56
"log"
67
"os"
78
"os/exec"
9+
"os/signal"
810
"path/filepath"
911
"runtime"
1012
"strconv"
1113
"strings"
14+
"syscall"
1215
"time"
1316

1417
"github.com/Agent-Field/agentfield/control-plane/internal/cli"
@@ -36,7 +39,7 @@ var (
3639
buildUIFunc = buildUI
3740
openBrowserFunc = openBrowser
3841
sleepFunc = time.Sleep
39-
waitForShutdownFunc = func() { select {} }
42+
waitForShutdownFunc = defaultWaitForShutdown
4043
commandRunner = defaultCommandRunner
4144
browserLauncher = defaultBrowserLauncher
4245
startAgentFieldServerFunc = defaultStartAgentFieldServer
@@ -236,10 +239,17 @@ func runServer(cmd *cobra.Command, args []string) {
236239
}
237240

238241
fmt.Printf("AgentField server running. Press Ctrl+C to exit.\n")
239-
// Keep main goroutine alive
242+
243+
// Wait for shutdown signal
240244
waitForShutdownFunc()
241245

242-
// TODO: Implement graceful shutdown
246+
// Graceful shutdown
247+
fmt.Println("\nShutdown signal received, draining connections...")
248+
if err := agentfieldServer.Stop(); err != nil {
249+
log.Printf("Error during shutdown: %v", err)
250+
os.Exit(1)
251+
}
252+
fmt.Println("Server stopped gracefully.")
243253
}
244254

245255
// loadConfig loads configuration from file and environment variables.
@@ -498,6 +508,13 @@ func defaultStartAgentFieldServer(s *server.AgentFieldServer) error {
498508
return s.Start()
499509
}
500510

511+
// defaultWaitForShutdown blocks until SIGINT or SIGTERM is received.
512+
func defaultWaitForShutdown() {
513+
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
514+
defer stop()
515+
<-ctx.Done()
516+
}
517+
501518
func openBrowser(url string) {
502519
var err error
503520
switch runtime.GOOS {

control-plane/cmd/agentfield-server/main_test.go

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"path/filepath"
88
"runtime"
99
"sync"
10+
"syscall"
1011
"testing"
1112
"time"
1213

@@ -387,3 +388,33 @@ func TestOpenBrowserUsesLauncher(t *testing.T) {
387388
t.Fatal("expected browserLauncher to be invoked")
388389
}
389390
}
391+
392+
func TestDefaultWaitForShutdown(t *testing.T) {
393+
if runtime.GOOS == "windows" {
394+
t.Skip("sending SIGINT to self is not supported on Windows")
395+
}
396+
397+
// defaultWaitForShutdown should unblock when SIGINT is sent to the process
398+
done := make(chan struct{})
399+
go func() {
400+
defaultWaitForShutdown()
401+
close(done)
402+
}()
403+
404+
// Send SIGINT to self
405+
time.Sleep(50 * time.Millisecond)
406+
p, err := os.FindProcess(os.Getpid())
407+
if err != nil {
408+
t.Fatalf("failed to find self process: %v", err)
409+
}
410+
if err := p.Signal(syscall.SIGINT); err != nil {
411+
t.Fatalf("failed to send SIGINT: %v", err)
412+
}
413+
414+
select {
415+
case <-done:
416+
// success
417+
case <-time.After(3 * time.Second):
418+
t.Fatal("defaultWaitForShutdown did not unblock after SIGINT")
419+
}
420+
}

control-plane/config/agentfield.yaml

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

control-plane/internal/config/config.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ type UIConfig struct {
6969
// AgentFieldConfig holds the core AgentField server configuration.
7070
type AgentFieldConfig struct {
7171
Port int `yaml:"port"`
72+
ShutdownTimeout time.Duration `yaml:"shutdown_timeout" mapstructure:"shutdown_timeout"`
7273
ARD ARDConfig `yaml:"ard" mapstructure:"ard"`
7374
Registration RegistrationConfig `yaml:"registration" mapstructure:"registration"`
7475
NodeHealth NodeHealthConfig `yaml:"node_health" mapstructure:"node_health"`
@@ -485,6 +486,9 @@ func ApplyDefaults(cfg *Config) {
485486
if cfg.Telemetry.Timeout <= 0 {
486487
cfg.Telemetry.Timeout = 800 * time.Millisecond
487488
}
489+
if cfg.AgentField.ShutdownTimeout <= 0 {
490+
cfg.AgentField.ShutdownTimeout = 30 * time.Second
491+
}
488492
if cfg.Logging.Level == "" {
489493
cfg.Logging.Level = "info"
490494
}
@@ -573,6 +577,13 @@ func ApplyEnvOverrides(cfg *Config) {
573577
}
574578
}
575579

580+
// Shutdown timeout override
581+
if val := os.Getenv("AGENTFIELD_SHUTDOWN_TIMEOUT"); val != "" {
582+
if d, err := time.ParseDuration(val); err == nil {
583+
cfg.AgentField.ShutdownTimeout = d
584+
}
585+
}
586+
576587
// Node health monitoring overrides
577588
if val := os.Getenv("AGENTFIELD_HEALTH_CHECK_INTERVAL"); val != "" {
578589
if d, err := time.ParseDuration(val); err == nil {

control-plane/internal/config/config_additional_test.go

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -567,3 +567,39 @@ func TestLoggingEnvOverrides(t *testing.T) {
567567
}
568568
})
569569
}
570+
571+
func TestShutdownTimeoutDefaults(t *testing.T) {
572+
t.Parallel()
573+
574+
cfg := Config{}
575+
ApplyDefaults(&cfg)
576+
577+
if cfg.AgentField.ShutdownTimeout != 30*time.Second {
578+
t.Fatalf("expected default shutdown timeout 30s, got %v", cfg.AgentField.ShutdownTimeout)
579+
}
580+
}
581+
582+
func TestShutdownTimeoutEnvOverride(t *testing.T) {
583+
os.Setenv("AGENTFIELD_SHUTDOWN_TIMEOUT", "45s")
584+
defer os.Unsetenv("AGENTFIELD_SHUTDOWN_TIMEOUT")
585+
586+
cfg := Config{}
587+
ApplyEnvOverrides(&cfg)
588+
589+
if cfg.AgentField.ShutdownTimeout != 45*time.Second {
590+
t.Fatalf("expected shutdown timeout 45s, got %v", cfg.AgentField.ShutdownTimeout)
591+
}
592+
}
593+
594+
func TestShutdownTimeoutEnvOverrideIgnoresInvalidValue(t *testing.T) {
595+
os.Setenv("AGENTFIELD_SHUTDOWN_TIMEOUT", "not-a-duration")
596+
defer os.Unsetenv("AGENTFIELD_SHUTDOWN_TIMEOUT")
597+
598+
cfg := Config{}
599+
cfg.AgentField.ShutdownTimeout = 15 * time.Second
600+
ApplyEnvOverrides(&cfg)
601+
602+
if cfg.AgentField.ShutdownTimeout != 15*time.Second {
603+
t.Fatalf("expected invalid shutdown timeout env value to be ignored, got %v", cfg.AgentField.ShutdownTimeout)
604+
}
605+
}
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
package server
2+
3+
import (
4+
"context"
5+
"net"
6+
"net/http"
7+
"testing"
8+
"time"
9+
10+
"github.com/Agent-Field/agentfield/control-plane/internal/config"
11+
"github.com/stretchr/testify/require"
12+
)
13+
14+
func TestStopGracefulShutdownOnEmptyServer(t *testing.T) {
15+
// Stop() should not panic on a zero-value server (all fields nil)
16+
s := &AgentFieldServer{}
17+
err := s.Stop()
18+
require.NoError(t, err)
19+
}
20+
21+
func TestStopGracefulShutdownWithHTTPServer(t *testing.T) {
22+
// Create a minimal server with an httpServer that's already listening
23+
cfg := &config.Config{}
24+
cfg.AgentField.ShutdownTimeout = 2 * time.Second
25+
26+
srv := &AgentFieldServer{
27+
config: cfg,
28+
httpServer: &http.Server{
29+
Addr: ":0", // random port
30+
},
31+
}
32+
33+
// Start listening in background
34+
ln, err := (&net.ListenConfig{}).Listen(context.Background(), "tcp", ":0")
35+
if err != nil {
36+
t.Fatalf("failed to listen: %v", err)
37+
}
38+
srv.httpServer.Addr = ln.Addr().String()
39+
40+
go func() {
41+
_ = srv.httpServer.Serve(ln)
42+
}()
43+
44+
// Give server a moment to start
45+
time.Sleep(50 * time.Millisecond)
46+
47+
// Stop should shut down gracefully
48+
err = srv.Stop()
49+
require.NoError(t, err)
50+
}
51+
52+
func TestStopHTTPServerShutdownTimeout(t *testing.T) {
53+
// Test that a very short timeout causes force close
54+
cfg := &config.Config{}
55+
cfg.AgentField.ShutdownTimeout = 1 * time.Nanosecond // impossibly short
56+
57+
srv := &AgentFieldServer{
58+
config: cfg,
59+
httpServer: &http.Server{
60+
Addr: ":0",
61+
},
62+
}
63+
64+
// Start listening with a handler that holds connections open
65+
ln, err := (&net.ListenConfig{}).Listen(context.Background(), "tcp", ":0")
66+
if err != nil {
67+
t.Fatalf("failed to listen: %v", err)
68+
}
69+
srv.httpServer.Addr = ln.Addr().String()
70+
srv.httpServer.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
71+
time.Sleep(5 * time.Second) // simulate long-running request
72+
w.WriteHeader(200)
73+
})
74+
75+
go func() {
76+
_ = srv.httpServer.Serve(ln)
77+
}()
78+
time.Sleep(50 * time.Millisecond)
79+
80+
// Make a request that will be in-flight during shutdown
81+
go func() {
82+
client := &http.Client{Timeout: 10 * time.Second}
83+
_, _ = client.Get("http://" + ln.Addr().String() + "/")
84+
}()
85+
time.Sleep(20 * time.Millisecond)
86+
87+
// Stop with impossibly short timeout — should return error
88+
err = srv.Stop()
89+
require.Error(t, err)
90+
}

control-plane/internal/server/server.go

Lines changed: 69 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"errors"
88
"fmt"
99
"net"
10+
"net/http"
1011
"os"
1112
"path/filepath"
1213
"strconv"
@@ -96,6 +97,10 @@ type AgentFieldServer struct {
9697
kb *knowledgebase.KB
9798
// Native scope-aware RAG knowledge store (embed-on-write/search).
9899
knowledgeService *knowledge.Service
100+
// HTTP server for graceful shutdown support
101+
httpServerMu sync.RWMutex
102+
httpServer *http.Server
103+
stopping bool
99104
}
100105

101106
// NewAgentFieldServer creates a new instance of the AgentFieldServer.
@@ -649,9 +654,59 @@ func (s *AgentFieldServer) Start() error {
649654
return fmt.Errorf("failed to start admin gRPC server: %w", err)
650655
}
651656

652-
// TODO: Implement WebSocket, gRPC
653-
// Start HTTP server
654-
return s.Router.Run(":" + strconv.Itoa(s.config.AgentField.Port))
657+
// Start HTTP server (using net/http.Server for graceful shutdown support)
658+
addr := ":" + strconv.Itoa(s.config.AgentField.Port)
659+
httpServer := &http.Server{
660+
Addr: addr,
661+
Handler: s.Router,
662+
}
663+
if !s.setHTTPServer(httpServer) {
664+
return nil
665+
}
666+
if err := httpServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
667+
return fmt.Errorf("failed to start HTTP server on %s: %w", addr, err)
668+
}
669+
return nil
670+
}
671+
672+
func (s *AgentFieldServer) setHTTPServer(httpServer *http.Server) bool {
673+
s.httpServerMu.Lock()
674+
defer s.httpServerMu.Unlock()
675+
if s.stopping {
676+
return false
677+
}
678+
s.httpServer = httpServer
679+
return true
680+
}
681+
682+
func (s *AgentFieldServer) getHTTPServer() *http.Server {
683+
s.httpServerMu.RLock()
684+
defer s.httpServerMu.RUnlock()
685+
return s.httpServer
686+
}
687+
688+
func (s *AgentFieldServer) shutdownHTTPServer() error {
689+
httpServer := s.getHTTPServer()
690+
if httpServer == nil {
691+
return nil
692+
}
693+
694+
var shutdownTimeout time.Duration
695+
if s.config != nil {
696+
shutdownTimeout = s.config.AgentField.ShutdownTimeout
697+
}
698+
if shutdownTimeout <= 0 {
699+
shutdownTimeout = 30 * time.Second
700+
}
701+
ctx, cancel := context.WithTimeout(context.Background(), shutdownTimeout)
702+
defer cancel()
703+
if err := httpServer.Shutdown(ctx); err != nil {
704+
logger.Logger.Error().Err(err).Msg("HTTP server shutdown timed out, forcing close")
705+
_ = httpServer.Close()
706+
return err
707+
}
708+
logger.Logger.Info().Msg("HTTP server shut down gracefully")
709+
return nil
655710
}
656711

657712
func (s *AgentFieldServer) startAdminGRPCServer() error {
@@ -714,6 +769,12 @@ func (s *AgentFieldServer) ListReasoners(ctx context.Context, _ *adminpb.ListRea
714769

715770
// Stop gracefully shuts down the AgentFieldServer.
716771
func (s *AgentFieldServer) Stop() error {
772+
s.httpServerMu.Lock()
773+
s.stopping = true
774+
s.httpServerMu.Unlock()
775+
776+
httpShutdownErr := s.shutdownHTTPServer()
777+
717778
if s.adminGRPCServer != nil {
718779
s.adminGRPCServer.GracefulStop()
719780
}
@@ -731,7 +792,9 @@ func (s *AgentFieldServer) Stop() error {
731792
}
732793

733794
// Stop health monitor service
734-
s.healthMonitor.Stop()
795+
if s.healthMonitor != nil {
796+
s.healthMonitor.Stop()
797+
}
735798

736799
// Stop execution cleanup service
737800
if s.cleanupService != nil {
@@ -784,8 +847,8 @@ func (s *AgentFieldServer) Stop() error {
784847
}
785848
}
786849

787-
// TODO: Implement graceful shutdown for HTTP, WebSocket, gRPC
788-
return nil
850+
// TODO: Implement graceful shutdown for WebSocket
851+
return httpShutdownErr
789852
}
790853

791854
// setupRoutes composes the full HTTP surface by delegating to focused

0 commit comments

Comments
 (0)