diff --git a/cmd/server/main.go b/cmd/server/main.go index 6f8cf38..abe8fa1 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -48,8 +48,10 @@ func main() { log.Fatal().Err(err).Msg("Failed to load configuration") } - // Create readiness context to control readiness state - readyCtx, readyCancel := context.WithCancel(context.Background()) + // Create readiness context to control readiness state. + // CancelableContext exposes Cancel() so that Server.Run() can cancel + // readiness at the start of graceful shutdown (before the propagation sleep). + readyCtx, readyCancel := server.NewCancelableContext(context.Background()) defer readyCancel() // Create and run server @@ -57,4 +59,5 @@ func main() { if err := server.Run(); err != nil { log.Fatal().Err(err).Msg("Server failed") } + log.Trace().Msg("Server exited") } diff --git a/pkg/server/server.go b/pkg/server/server.go index 8b7286a..9c20539 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -33,6 +33,28 @@ import ( //go:embed docs/swagger.json var swaggerJSON embed.FS +// CancelableContext wraps a context.Context and exposes a Cancel() method so +// that Server.Run() can cancel readiness during graceful shutdown via the type +// assertion it already performs. Callers that don't need controlled cancellation +// (tests, CLI) can pass a plain context.Background() — the assertion silently +// fails and readiness is never explicitly cancelled, which is harmless. +type CancelableContext struct { + context.Context + cancel context.CancelFunc +} + +// NewCancelableContext creates a CancelableContext whose Cancel() method will +// be invoked by Server.Run() at the start of graceful shutdown, before the +// 1-second readiness propagation window. The returned CancelFunc is a no-op +// after Cancel() has been called, so it is safe to defer in main. +func NewCancelableContext(parent context.Context) (*CancelableContext, context.CancelFunc) { + ctx, cancel := context.WithCancel(parent) + return &CancelableContext{Context: ctx, cancel: cancel}, cancel +} + +// Cancel satisfies the interface checked in Run() and cancels the context. +func (c *CancelableContext) Cancel() { c.cancel() } + type Server struct { router *chi.Mux readyCtx context.Context diff --git a/scripts/test-graceful-shutdown.sh b/scripts/test-graceful-shutdown.sh index a5a0691..22c41b8 100755 --- a/scripts/test-graceful-shutdown.sh +++ b/scripts/test-graceful-shutdown.sh @@ -60,11 +60,39 @@ echo "Response: $GREET_NAME_RESPONSE" echo "" echo "Stopping server gracefully..." -# Test readiness during shutdown (in background) -(curl -s http://localhost:8080/api/ready > /dev/null 2>&1 &) +# Send SIGTERM once and probe /api/ready during the 1-second propagation window +# the server holds open (pkg/server/server.go: time.Sleep(1s) after readiness +# cancel). Previously the curl fired *before* the signal — it always saw "ready". +# We also avoid calling "$SERVER_CMD stop" afterwards because that would send a +# second SIGTERM: after signal.NotifyContext is done, the default handler kicks in +# and the process terminates with a non-JSON "signal: terminated" on stderr. +SERVER_PID=$(cat "$PID_FILE" 2>/dev/null || echo "") +if [[ -z "$SERVER_PID" ]]; then + echo -e "\033[0;31m❌ FAIL: PID file not found\033[0m" + exit 1 +fi -$SERVER_CMD stop -sleep 3 +kill -TERM "$SERVER_PID" +# Brief yield so the signal handler runs and CancelableContext.Cancel() fires +sleep 0.2 +curl -s http://localhost:8080/api/ready > /dev/null 2>&1 || true + +# Wait for the process to exit cleanly (up to 30s) without sending another signal +echo "Waiting for server to exit..." +for i in {1..30}; do + if ! ps -p "$SERVER_PID" > /dev/null 2>&1; then + echo "Server stopped successfully" + rm -f "$PID_FILE" + break + fi + sleep 1 +done +if ps -p "$SERVER_PID" > /dev/null 2>&1; then + echo -e "\033[0;31m❌ FAIL: Server did not stop within 30s\033[0m" + kill -9 "$SERVER_PID" 2>/dev/null || true + exit 1 +fi +sleep 0.5 echo "" echo "Analyzing server logs..."