@marcel91200 / caddy Mirror · read-only
Fork 0

Fast and extensible multi-platform HTTP/1-2-3 web server with automatic HTTPS

Mirrored from https://github.com/Slazee/caddy.git

Synced Nov 11, 01:00 UTC

master
go 504 lines 14.4 KB
Raw
1 package integration
2
3 import (
4 "bufio"
5 "bytes"
6 "fmt"
7 "io"
8 "net"
9 "net/http"
10 "net/textproto"
11 "os"
12 "runtime"
13 "runtime/debug"
14 "runtime/pprof"
15 "strconv"
16 "strings"
17 "sync"
18 "testing"
19 "time"
20
21 "github.com/caddyserver/caddy/v2/caddytest"
22 )
23
24 const (
25 defaultStressStreamCount = 1
26 defaultStressReloadCount = 1
27 defaultStressCloseDelay = 500 * time.Millisecond
28 )
29
30 func TestReverseProxyReloadStressUpgradedStreamsHeapProfiles(t *testing.T) {
31 tester := caddytest.NewTester(t).WithDefaultOverrides(caddytest.Config{
32 LoadRequestTimeout: 30 * time.Second,
33 TestRequestTimeout: 30 * time.Second,
34 })
35
36 backend := newUpgradeEchoBackend(t)
37 defer backend.Close()
38
39 // Three scenarios, each sequential so they don't share Caddy state:
40 //
41 // legacy – no delay, close on reload immediately (old default)
42 // close_delay – stream_close_delay, the old "keep-alive workaround"
43 // detached – stream_detached, the new explicit detached flag
44 //
45 // Reloads are spread across time and interleaved with echo-checks so
46 // stream health is exercised at each reload boundary, not only at the end.
47 legacy := runReloadStress(t, tester, backend.addr, "legacy", false, 0)
48 closeDelay := runReloadStress(t, tester, backend.addr, "close_delay", false, stressCloseDelay(t))
49 detached := runReloadStress(t, tester, backend.addr, "detached", true, 0)
50
51 if legacy.aliveAfterReloads != 0 {
52 t.Fatalf("legacy mode left %d upgraded streams alive after reloads", legacy.aliveAfterReloads)
53 }
54 if closeDelay.aliveBeforeDelayExpiry == 0 {
55 t.Fatalf("close_delay mode: all streams closed before delay expired (expected them alive)")
56 }
57 if closeDelay.aliveAfterReloads != 0 {
58 t.Fatalf("close_delay mode left %d upgraded streams alive after delay expiry", closeDelay.aliveAfterReloads)
59 }
60 if detached.aliveAfterReloads != detached.streamCount {
61 t.Fatalf("detached mode kept %d/%d upgraded streams alive after reloads", detached.aliveAfterReloads, detached.streamCount)
62 }
63
64 t.Logf("legacy heap: before=%s mid=%s after=%s delta(before→after)=%s objects(before=%d after=%d) handler_frames(before=%d after=%d)",
65 formatBytes(legacy.beforeReload.HeapInuse),
66 formatBytes(legacy.midReload.HeapInuse),
67 formatBytes(legacy.afterReload.HeapInuse),
68 formatBytesDiff(legacy.beforeReload.HeapInuse, legacy.afterReload.HeapInuse),
69 legacy.beforeReload.HeapObjects, legacy.afterReload.HeapObjects,
70 legacy.beforeReload.handlerFrames, legacy.afterReload.handlerFrames,
71 )
72 t.Logf("close_delay heap: before=%s mid=%s after=%s delta(before→after)=%s objects(before=%d after=%d) handler_frames(before=%d after=%d)",
73 formatBytes(closeDelay.beforeReload.HeapInuse),
74 formatBytes(closeDelay.midReload.HeapInuse),
75 formatBytes(closeDelay.afterReload.HeapInuse),
76 formatBytesDiff(closeDelay.beforeReload.HeapInuse, closeDelay.afterReload.HeapInuse),
77 closeDelay.beforeReload.HeapObjects, closeDelay.afterReload.HeapObjects,
78 closeDelay.beforeReload.handlerFrames, closeDelay.afterReload.handlerFrames,
79 )
80 t.Logf("detached heap: before=%s mid=%s after=%s delta(before→after)=%s objects(before=%d after=%d) handler_frames(before=%d after=%d)",
81 formatBytes(detached.beforeReload.HeapInuse),
82 formatBytes(detached.midReload.HeapInuse),
83 formatBytes(detached.afterReload.HeapInuse),
84 formatBytesDiff(detached.beforeReload.HeapInuse, detached.afterReload.HeapInuse),
85 detached.beforeReload.HeapObjects, detached.afterReload.HeapObjects,
86 detached.beforeReload.handlerFrames, detached.afterReload.handlerFrames,
87 )
88 }
89
90 type stressRunResult struct {
91 streamCount int
92 aliveAfterReloads int
93 aliveBeforeDelayExpiry int // only meaningful for close_delay mode
94 beforeReload heapSnapshot
95 midReload heapSnapshot // after all reloads, before delay expiry clean-up
96 afterReload heapSnapshot // after all streams have been fully cleaned up
97 }
98
99 type heapSnapshot struct {
100 HeapInuse uint64
101 HeapObjects uint64
102 handlerFrames int
103 profileBytes int
104 }
105
106 // runReloadStress opens streamCount upgraded streams, then performs reloadCount
107 // config reloads spread over time. An echo check is performed every 6 reloads so
108 // stream health is exercised at each reload boundary rather than only at the end.
109 // closeDelay mirrors the stream_close_delay config option; pass 0 to disable.
110 func runReloadStress(t *testing.T, tester *caddytest.Tester, backendAddr, mode string, detach bool, closeDelay time.Duration) stressRunResult {
111 t.Helper()
112
113 const echoEvery = 6 // perform an echo check every N reloads
114
115 streamCount := envIntOrDefault(t, "CADDY_STRESS_STREAM_COUNT", defaultStressStreamCount)
116 reloadCount := envIntOrDefault(t, "CADDY_STRESS_RELOAD_COUNT", defaultStressReloadCount)
117
118 tester.InitServer(reloadStressConfig(backendAddr, detach, closeDelay, 0), "caddyfile")
119
120 clients := make([]*upgradedStreamClient, 0, streamCount)
121 for i := 0; i < streamCount; i++ {
122 client := newUpgradedStreamClient(t)
123 clients = append(clients, client)
124 if err := client.echo(fmt.Sprintf("%s-warmup-%02d\n", mode, i)); err != nil {
125 closeClients(clients)
126 t.Fatalf("warmup echo failed in %s mode: %v", mode, err)
127 }
128 }
129 defer closeClients(clients)
130
131 before := captureHeapSnapshot(t)
132
133 // Reloads are spread across time; between batches of echoEvery reloads we
134 // pause briefly and measure stream health so the snapshot reflects real-world
135 // reload cadence rather than a tight loop.
136 for i := 1; i <= reloadCount; i++ {
137 loadCaddyfileConfig(t, reloadStressConfig(backendAddr, detach, closeDelay, i))
138
139 // Small pause after each reload to let connection teardown propagate.
140 time.Sleep(50 * time.Millisecond)
141
142 if i%echoEvery == 0 {
143 alive := countAliveStreams(clients)
144 t.Logf("%s mode: %d/%d streams alive after reload %d", mode, alive, streamCount, i)
145
146 // In detached mode, every stream must survive every reload (upstream unchanged).
147 if detach {
148 for j, client := range clients {
149 if err := client.echo(fmt.Sprintf("%s-mid-%02d-%02d\n", mode, i, j)); err != nil {
150 t.Fatalf("detached mode stream %d died at reload %d: %v", j, i, err)
151 }
152 }
153 }
154 }
155 }
156
157 // mid snapshot: after all reloads but before any close_delay timer has fired
158 // (the delay is long enough to still be running at this point).
159 mid := captureHeapSnapshot(t)
160
161 // For legacy mode: the reloads close streams immediately; wait for that to complete.
162 // For close_delay mode: streams are still alive here; wait for the delay to fire.
163 // For detached mode: streams survive indefinitely; no wait needed.
164 var aliveBeforeDelayExpiry int
165 aliveAfterReloads := countAliveStreams(clients)
166 switch {
167 case detach:
168 // nothing to wait for
169 case closeDelay > 0:
170 // streams should still be alive at this point (delay hasn't expired)
171 aliveBeforeDelayExpiry = aliveAfterReloads
172 t.Logf("%s mode: %d/%d streams alive before close_delay expires; waiting %v for cleanup",
173 mode, aliveBeforeDelayExpiry, streamCount, closeDelay)
174 time.Sleep(closeDelay + 200*time.Millisecond)
175 aliveAfterReloads = countAliveStreams(clients)
176 default:
177 deadline := time.Now().Add(2 * time.Second)
178 for aliveAfterReloads > 0 && time.Now().Before(deadline) {
179 time.Sleep(50 * time.Millisecond)
180 aliveAfterReloads = countAliveStreams(clients)
181 }
182 }
183
184 after := captureHeapSnapshot(t)
185 t.Logf("%s mode heap profile size: before=%dB mid=%dB after=%dB objects(before=%d mid=%d after=%d)",
186 mode,
187 before.profileBytes, mid.profileBytes, after.profileBytes,
188 before.HeapObjects, mid.HeapObjects, after.HeapObjects,
189 )
190
191 return stressRunResult{
192 streamCount: streamCount,
193 aliveAfterReloads: aliveAfterReloads,
194 aliveBeforeDelayExpiry: aliveBeforeDelayExpiry,
195 beforeReload: before,
196 midReload: mid,
197 afterReload: after,
198 }
199 }
200
201 func envIntOrDefault(t *testing.T, key string, def int) int {
202 t.Helper()
203 raw := strings.TrimSpace(os.Getenv(key))
204 if raw == "" {
205 return def
206 }
207 v, err := strconv.Atoi(raw)
208 if err != nil || v <= 0 {
209 t.Fatalf("invalid %s=%q: must be a positive integer", key, raw)
210 }
211 return v
212 }
213
214 func stressCloseDelay(t *testing.T) time.Duration {
215 t.Helper()
216
217 const key = "CADDY_STRESS_CLOSE_DELAY"
218 raw := strings.TrimSpace(os.Getenv(key))
219 if raw == "" {
220 return defaultStressCloseDelay
221 }
222 v, err := time.ParseDuration(raw)
223 if err != nil || v <= 0 {
224 t.Fatalf("invalid %s=%q: must be a positive duration", key, raw)
225 }
226 return v
227 }
228
229 func loadCaddyfileConfig(t *testing.T, rawConfig string) {
230 t.Helper()
231
232 client := &http.Client{Timeout: 30 * time.Second}
233 req, err := http.NewRequest(http.MethodPost, "http://localhost:2999/load", strings.NewReader(rawConfig))
234 if err != nil {
235 t.Fatalf("creating load request: %v", err)
236 }
237 req.Header.Set("Content-Type", "text/caddyfile")
238
239 resp, err := client.Do(req)
240 if err != nil {
241 t.Fatalf("loading config: %v", err)
242 }
243 defer resp.Body.Close()
244
245 body, err := io.ReadAll(resp.Body)
246 if err != nil {
247 t.Fatalf("reading load response: %v", err)
248 }
249 if resp.StatusCode != http.StatusOK {
250 t.Fatalf("loading config failed: status=%d body=%s", resp.StatusCode, body)
251 }
252 }
253
254 func reloadStressConfig(backendAddr string, detach bool, closeDelay time.Duration, revision int) string {
255 var directives string
256 if detach {
257 directives += "\n\t\tstream_detached"
258 }
259 if closeDelay > 0 {
260 directives += fmt.Sprintf("\n\t\tstream_close_delay %s", closeDelay)
261 }
262
263 return fmt.Sprintf(`
264 {
265 admin localhost:2999
266 http_port 9080
267 https_port 9443
268 grace_period 1ns
269 skip_install_trust
270 }
271
272 localhost:9080 {
273 reverse_proxy %s {
274 header_up X-Reload-Revision %d%s
275 }
276 }
277 `, backendAddr, revision, directives)
278 }
279
280 func captureHeapSnapshot(t *testing.T) heapSnapshot {
281 t.Helper()
282
283 runtime.GC()
284 debug.FreeOSMemory()
285
286 var mem runtime.MemStats
287 runtime.ReadMemStats(&mem)
288
289 var buf bytes.Buffer
290 if err := pprof.Lookup("heap").WriteTo(&buf, 1); err != nil {
291 t.Fatalf("capturing heap profile: %v", err)
292 }
293 profile := buf.String()
294
295 return heapSnapshot{
296 HeapInuse: mem.HeapInuse,
297 HeapObjects: mem.HeapObjects,
298 handlerFrames: strings.Count(profile, "modules/caddyhttp/reverseproxy.(*Handler)"),
299 profileBytes: buf.Len(),
300 }
301 }
302
303 func countAliveStreams(clients []*upgradedStreamClient) int {
304 alive := 0
305 for index, client := range clients {
306 if err := client.echo(fmt.Sprintf("alive-check-%02d\n", index)); err == nil {
307 alive++
308 }
309 }
310 return alive
311 }
312
313 func closeClients(clients []*upgradedStreamClient) {
314 for _, client := range clients {
315 if client != nil {
316 _ = client.Close()
317 }
318 }
319 }
320
321 func formatBytes(value uint64) string {
322 const unit = 1024
323 if value < unit {
324 return fmt.Sprintf("%d B", value)
325 }
326 div, exp := uint64(unit), 0
327 for n := value / unit; n >= unit; n /= unit {
328 div *= unit
329 exp++
330 }
331 return fmt.Sprintf("%.1f %ciB", float64(value)/float64(div), "KMGTPE"[exp])
332 }
333
334 func formatBytesDiff(before, after uint64) string {
335 if after >= before {
336 return "+" + formatBytes(after-before)
337 }
338 return "-" + formatBytes(before-after)
339 }
340
341 type upgradedStreamClient struct {
342 conn net.Conn
343 reader *bufio.Reader
344 mu sync.Mutex
345 }
346
347 func newUpgradedStreamClient(t *testing.T) *upgradedStreamClient {
348 t.Helper()
349
350 conn, err := net.DialTimeout("tcp", "127.0.0.1:9080", 5*time.Second)
351 if err != nil {
352 t.Fatalf("dialing caddy: %v", err)
353 }
354
355 request := strings.Join([]string{
356 "GET /upgrade HTTP/1.1",
357 "Host: localhost:9080",
358 "Connection: Upgrade",
359 "Upgrade: stress-stream",
360 "",
361 "",
362 }, "\r\n")
363 if _, err := io.WriteString(conn, request); err != nil {
364 _ = conn.Close()
365 t.Fatalf("writing upgrade request: %v", err)
366 }
367
368 reader := bufio.NewReader(conn)
369 tproto := textproto.NewReader(reader)
370 statusLine, err := tproto.ReadLine()
371 if err != nil {
372 _ = conn.Close()
373 t.Fatalf("reading upgrade status line: %v", err)
374 }
375 if !strings.Contains(statusLine, "101") {
376 _ = conn.Close()
377 t.Fatalf("unexpected upgrade status: %s", statusLine)
378 }
379
380 headers, err := tproto.ReadMIMEHeader()
381 if err != nil {
382 _ = conn.Close()
383 t.Fatalf("reading upgrade headers: %v", err)
384 }
385 if !strings.EqualFold(headers.Get("Connection"), "Upgrade") {
386 _ = conn.Close()
387 t.Fatalf("unexpected upgrade response headers: %v", headers)
388 }
389
390 return &upgradedStreamClient{conn: conn, reader: reader}
391 }
392
393 func (c *upgradedStreamClient) echo(payload string) error {
394 c.mu.Lock()
395 defer c.mu.Unlock()
396
397 deadline := time.Now().Add(1 * time.Second)
398 if err := c.conn.SetWriteDeadline(deadline); err != nil {
399 return err
400 }
401 if _, err := io.WriteString(c.conn, payload); err != nil {
402 return err
403 }
404 if err := c.conn.SetReadDeadline(deadline); err != nil {
405 return err
406 }
407
408 buf := make([]byte, len(payload))
409 if _, err := io.ReadFull(c.reader, buf); err != nil {
410 return err
411 }
412 if string(buf) != payload {
413 return fmt.Errorf("unexpected echoed payload: got %q want %q", string(buf), payload)
414 }
415 return nil
416 }
417
418 func (c *upgradedStreamClient) Close() error {
419 c.mu.Lock()
420 defer c.mu.Unlock()
421 return c.conn.Close()
422 }
423
424 type upgradeEchoBackend struct {
425 addr string
426 ln net.Listener
427 mu sync.Mutex
428 conns map[net.Conn]struct{}
429 server *http.Server
430 }
431
432 func newUpgradeEchoBackend(t *testing.T) *upgradeEchoBackend {
433 t.Helper()
434
435 backend := &upgradeEchoBackend{conns: make(map[net.Conn]struct{})}
436 backend.server = &http.Server{
437 Handler: http.HandlerFunc(backend.serveHTTP),
438 }
439
440 ln, err := net.Listen("tcp", "127.0.0.1:0")
441 if err != nil {
442 t.Fatalf("listening for backend: %v", err)
443 }
444 backend.ln = ln
445 backend.addr = ln.Addr().String()
446
447 go func() {
448 _ = backend.server.Serve(ln)
449 }()
450
451 return backend
452 }
453
454 func (b *upgradeEchoBackend) serveHTTP(w http.ResponseWriter, r *http.Request) {
455 if !strings.EqualFold(r.Header.Get("Connection"), "Upgrade") || !strings.EqualFold(r.Header.Get("Upgrade"), "stress-stream") {
456 http.Error(w, "upgrade required", http.StatusUpgradeRequired)
457 return
458 }
459
460 hijacker, ok := w.(http.Hijacker)
461 if !ok {
462 http.Error(w, "hijacking not supported", http.StatusInternalServerError)
463 return
464 }
465
466 conn, rw, err := hijacker.Hijack()
467 if err != nil {
468 return
469 }
470
471 b.trackConn(conn)
472 _, _ = rw.WriteString("HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\nUpgrade: stress-stream\r\n\r\n")
473 _ = rw.Flush()
474
475 go func() {
476 defer b.untrackConn(conn)
477 defer conn.Close()
478 _, _ = io.Copy(conn, conn)
479 }()
480 }
481
482 func (b *upgradeEchoBackend) trackConn(conn net.Conn) {
483 b.mu.Lock()
484 b.conns[conn] = struct{}{}
485 b.mu.Unlock()
486 }
487
488 func (b *upgradeEchoBackend) untrackConn(conn net.Conn) {
489 b.mu.Lock()
490 delete(b.conns, conn)
491 b.mu.Unlock()
492 }
493
494 func (b *upgradeEchoBackend) Close() {
495 _ = b.server.Close()
496 _ = b.ln.Close()
497
498 b.mu.Lock()
499 defer b.mu.Unlock()
500 for conn := range b.conns {
501 _ = conn.Close()
502 }
503 clear(b.conns)
504 }