Files
caddy/modules/caddyhttp/app_test.go
Kévin Dunglas ff112bb230 caddyhttp: wait for servers from previous configs on exit (#8009)
Track pending HTTP server shutdowns across configuration reloads so
termination waits for responses still served by an older configuration.
Bound that wait by the terminating app's grace period and retain each
reload's grace context until its servers finish shutting down.

Cover HTTP/1.1, HTTP/2, and finite termination grace periods with
regression tests.
2026-09-30 11:15:18 -06:00

187 lines
5.3 KiB
Go

// Copyright 2015 Matthew Holt and The Caddy Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package caddyhttp
import (
"context"
"fmt"
"io"
"net/http"
"net/http/httptest"
"sync"
"testing"
"time"
"github.com/caddyserver/caddy/v2"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
"go.uber.org/zap/zaptest/observer"
)
func TestStopWaitsForPreviousConfiguration(t *testing.T) {
for _, http2 := range []bool{false, true} {
for _, grace := range []time.Duration{0, 5 * time.Second} {
t.Run(fmt.Sprintf("http2=%t/grace=%s", http2, grace), func(t *testing.T) {
previous, response, release := appWithPendingResponse(t, http2)
previous.GracePeriod = caddy.Duration(grace)
if err := previous.stop(false); err != nil {
t.Fatal(err)
}
current := &App{logger: zap.NewNop()}
stopped := make(chan error, 1)
go func() { stopped <- current.stop(true) }()
select {
case err := <-stopped:
t.Fatalf("termination returned while the previous response was active: %v", err)
case <-time.After(50 * time.Millisecond):
}
release()
body, err := io.ReadAll(response.Body)
if err != nil {
t.Fatal(err)
}
if string(body) != "before\nafter\n" {
t.Fatalf("unexpected response body: %q", body)
}
select {
case err := <-stopped:
if err != nil {
t.Fatal(err)
}
case <-time.After(2 * time.Second):
t.Fatal("termination did not finish after the previous response completed")
}
})
}
}
}
func TestStopPreviousConfigurationGracePeriod(t *testing.T) {
previous, response, release := appWithPendingResponse(t, false)
if err := previous.stop(false); err != nil {
t.Fatal(err)
}
current := &App{GracePeriod: caddy.Duration(50 * time.Millisecond), logger: zap.NewNop()}
stopped := make(chan error, 1)
start := time.Now()
go func() { stopped <- current.stop(true) }()
select {
case err := <-stopped:
if err != nil {
t.Fatal(err)
}
case <-time.After(2 * time.Second):
t.Fatal("termination ignored its grace period while waiting for the previous configuration")
}
if elapsed := time.Since(start); elapsed < time.Duration(current.GracePeriod) {
t.Errorf("termination returned after %s, before its grace period expired", elapsed)
}
release()
if _, err := io.Copy(io.Discard, response.Body); err != nil {
t.Fatal(err)
}
}
func appWithPendingResponse(t *testing.T, http2 bool) (*App, *http.Response, func()) {
t.Helper()
released := make(chan struct{})
release := sync.OnceFunc(func() { close(released) })
server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "before")
http.NewResponseController(w).Flush()
select {
case <-released:
fmt.Fprintln(w, "after")
case <-r.Context().Done():
}
}))
server.EnableHTTP2 = http2
server.StartTLS()
t.Cleanup(server.Close)
t.Cleanup(release)
request, err := http.NewRequestWithContext(t.Context(), http.MethodGet, server.URL, nil)
if err != nil {
t.Fatal(err)
}
response, err := server.Client().Do(request)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { response.Body.Close() })
if http2 && response.ProtoMajor != 2 {
t.Fatalf("expected HTTP/2, got %s", response.Proto)
}
app := &App{
Servers: map[string]*Server{"test": {server: server.Config}},
logger: zap.NewNop(),
}
t.Cleanup(func() {
release()
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
server.Config.Shutdown(ctx)
})
return app, response, release
}
// TestServerErrorLoggerLevels verifies that recovered net/http handler panics
// written to http.Server.ErrorLog surface at ERROR level (so they're visible
// at the default log level), while other standard library server messages stay
// at DEBUG. Regression test for #7923.
func TestServerErrorLoggerLevels(t *testing.T) {
for _, tc := range []struct {
name string
message string
wantLevel zapcore.Level
}{
{
name: "recovered handler panic logs at error",
message: "http: panic serving 127.0.0.1:12345: boom\ngoroutine 1 [running]:\nmain.handler()",
wantLevel: zapcore.ErrorLevel,
},
{
name: "other server message logs at debug",
message: "http: TLS handshake error from 127.0.0.1:12345: EOF",
wantLevel: zapcore.DebugLevel,
},
} {
t.Run(tc.name, func(t *testing.T) {
core, logs := observer.New(zapcore.DebugLevel)
serverLogger := serverErrorLogger(zap.New(core))
serverLogger.Print(tc.message)
entries := logs.All()
if len(entries) != 1 {
t.Fatalf("expected exactly 1 log entry, got %d", len(entries))
}
got := entries[0]
if got.Level != tc.wantLevel {
t.Errorf("expected level %s, got %s", tc.wantLevel, got.Level)
}
if got.Message != tc.message {
t.Errorf("expected message %q, got %q", tc.message, got.Message)
}
})
}
}