🐛 fix: wire up readiness cancellation and stabilise graceful shutdown test
All checks were successful
CI/CD Pipeline / Build Docker Cache (push) Successful in 12s
CI/CD Pipeline / CI Pipeline (push) Successful in 4m21s
CI/CD Pipeline / Trigger Docker Push (push) Has been skipped

Three related issues fixed together:

1. Readiness context was never cancelled during shutdown
   server.Run() had a type assertion for a Cancel() method that no standard
   context.Context implements, so readiness stayed "ready" through the entire
   shutdown window. Added CancelableContext to pkg/server — a thin wrapper that
   exposes Cancel() — and switched cmd/server/main.go to use it. Test servers
   and CLI continue passing context.Background() unchanged.

2. "Server exited" log was never emitted
   The test script expected it; main.go had no log after server.Run() returned.
   Added log.Trace().Msg("Server exited") after the Run() call.

3. Double-SIGTERM caused non-JSON "signal: terminated" in server.log
   test-graceful-shutdown.sh sent SIGTERM, then called $SERVER_CMD stop which
   sent a second SIGTERM. After signal.NotifyContext is cancelled, the second
   signal hits the default handler and Go prints "signal: terminated" to stderr,
   breaking the all-JSON-lines assertion. Fixed by waiting for the PID to exit
   ourselves instead of re-invoking the stop script.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-12 19:49:04 +02:00
parent 18ac000d30
commit 928fa9170c
3 changed files with 59 additions and 6 deletions

View File

@@ -48,8 +48,10 @@ func main() {
log.Fatal().Err(err).Msg("Failed to load configuration") log.Fatal().Err(err).Msg("Failed to load configuration")
} }
// Create readiness context to control readiness state // Create readiness context to control readiness state.
readyCtx, readyCancel := context.WithCancel(context.Background()) // 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() defer readyCancel()
// Create and run server // Create and run server
@@ -57,4 +59,5 @@ func main() {
if err := server.Run(); err != nil { if err := server.Run(); err != nil {
log.Fatal().Err(err).Msg("Server failed") log.Fatal().Err(err).Msg("Server failed")
} }
log.Trace().Msg("Server exited")
} }

View File

@@ -33,6 +33,28 @@ import (
//go:embed docs/swagger.json //go:embed docs/swagger.json
var swaggerJSON embed.FS 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 { type Server struct {
router *chi.Mux router *chi.Mux
readyCtx context.Context readyCtx context.Context

View File

@@ -60,11 +60,39 @@ echo "Response: $GREET_NAME_RESPONSE"
echo "" echo ""
echo "Stopping server gracefully..." echo "Stopping server gracefully..."
# Test readiness during shutdown (in background) # Send SIGTERM once and probe /api/ready during the 1-second propagation window
(curl -s http://localhost:8080/api/ready > /dev/null 2>&1 &) # 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 kill -TERM "$SERVER_PID"
sleep 3 # 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 ""
echo "Analyzing server logs..." echo "Analyzing server logs..."