fix: bound HTTP2 soak resources and cleanup

This commit is contained in:
2026-09-23 01:23:20 +08:00
parent 1a534c7976
commit d94df4fc54
7 changed files with 279 additions and 23 deletions

View File

@@ -7,6 +7,9 @@ import (
"net"
"net/http"
"os"
"runtime"
"strconv"
"strings"
"sync"
"sync/atomic"
"testing"
@@ -18,6 +21,7 @@ import (
// TestTLSSoak is short by default so it remains part of normal package
// verification. Linux acceptance runs set GO_OMNI_HTTP2_SOAK_DURATION=30m.
func TestTLSSoak(t *testing.T) {
started := time.Now()
duration := 3 * time.Second
if value := os.Getenv("GO_OMNI_HTTP2_SOAK_DURATION"); value != "" {
parsed, err := time.ParseDuration(value)
@@ -26,6 +30,7 @@ func TestTLSSoak(t *testing.T) {
}
duration = parsed
}
logSoakResources(t, started, "before", 0, 0, Stats{})
certificate, roots := testCertificate(t)
server := New(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
@@ -44,6 +49,7 @@ func TestTLSSoak(t *testing.T) {
return
}
}
writer.Header().Set("X-Soak-Request-Id", request.Header.Get("X-Soak-Request-Id"))
_, _ = io.WriteString(writer, request.URL.Path)
}))
server.MaxConnections = 8
@@ -73,13 +79,37 @@ func TestTLSSoak(t *testing.T) {
client := &http.Client{Transport: transport, Timeout: 3 * time.Second}
var requests, failures atomic.Uint64
var firstFailure string
var failureOnce sync.Once
recordFailure := func(message string) {
failures.Add(1)
failureOnce.Do(func() { firstFailure = message })
}
monitorStop, monitorDone := make(chan struct{}), make(chan struct{})
go func() {
defer close(monitorDone)
ticker := time.NewTicker(15 * time.Second)
defer ticker.Stop()
for {
select {
case <-ticker.C:
logSoakResources(t, started, "running", requests.Load(), failures.Load(), server.Stats())
case <-monitorStop:
return
}
}
}()
defer func() {
close(monitorStop)
<-monitorDone
}()
deadline := time.Now().Add(duration)
var workers sync.WaitGroup
for worker := 0; worker < 128; worker++ {
workers.Add(1)
go func(worker int) {
defer workers.Done()
for time.Now().Before(deadline) {
for sequence := uint64(0); time.Now().Before(deadline); sequence++ {
path := "/health"
method := http.MethodGet
var body io.Reader
@@ -95,24 +125,26 @@ func TestTLSSoak(t *testing.T) {
request, err := http.NewRequestWithContext(requestContext, method, "https://"+listener.Addr().String()+path, body)
if err != nil {
cancelRequest()
failures.Add(1)
recordFailure(err.Error())
continue
}
requestID := strconv.Itoa(worker) + ":" + strconv.FormatUint(sequence, 10)
request.Header.Set("X-Soak-Request-Id", requestID)
response, err := client.Do(request)
if err != nil {
cancelRequest()
if time.Now().Before(deadline) {
failures.Add(1)
}
recordFailure(err.Error())
continue
}
_, readErr := io.Copy(io.Discard, response.Body)
responseBody, readErr := io.ReadAll(io.LimitReader(response.Body, 1024))
response.Body.Close()
cancelRequest()
if readErr != nil || response.StatusCode != http.StatusOK {
if time.Now().Before(deadline) {
failures.Add(1)
}
if readErr != nil {
recordFailure(readErr.Error())
continue
}
if response.StatusCode != http.StatusOK || response.ProtoMajor != 2 || string(responseBody) != path || response.Header.Get("X-Soak-Request-Id") != requestID {
recordFailure("unexpected status, protocol, body or request ID: " + response.Status)
continue
}
requests.Add(1)
@@ -131,14 +163,37 @@ func TestTLSSoak(t *testing.T) {
if err := <-done; err != nil {
t.Fatalf("TLS soak serve: %v", err)
}
logSoakResources(t, started, "after", requests.Load(), failures.Load(), server.Stats())
if failures.Load() != 0 || requests.Load() == 0 {
t.Fatalf("TLS soak requests=%d failures=%d stats=%+v", requests.Load(), failures.Load(), server.Stats())
t.Fatalf("TLS soak requests=%d failures=%d firstFailure=%q stats=%+v", requests.Load(), failures.Load(), firstFailure, server.Stats())
}
if stats := server.Stats(); stats.ActiveConnections != 0 || stats.ActiveRequests != 0 {
t.Fatalf("TLS soak did not reclaim resources: %+v", stats)
if stats := server.Stats(); stats.ActiveConnections != 0 || stats.ActiveRequests != 0 || stats.ProtocolErrors != 0 || stats.RejectedRequests != 0 {
t.Fatalf("TLS soak protocol errors, unexpected rejection or unreclaimed resources: %+v", stats)
}
}
// These measurements cover the client and server in the same test process.
// RSS includes race runtime allocations that do not appear in Go heap stats.
func logSoakResources(t *testing.T, started time.Time, phase string, requests, failures uint64, stats Stats) {
t.Helper()
var memory runtime.MemStats
runtime.ReadMemStats(&memory)
rssBytes, fileDescriptors := int64(-1), -1
if data, err := os.ReadFile("/proc/self/statm"); err == nil {
if fields := strings.Fields(string(data)); len(fields) >= 2 {
if pages, err := strconv.ParseInt(fields[1], 10, 64); err == nil {
rssBytes = pages * int64(os.Getpagesize())
}
}
}
if entries, err := os.ReadDir("/proc/self/fd"); err == nil {
fileDescriptors = len(entries)
}
t.Logf("phase=%s elapsed=%s requests=%d failures=%d heapBytes=%d rssBytes=%d goroutines=%d fileDescriptors=%d stats=%+v",
phase, time.Since(started).Round(time.Millisecond), requests, failures,
memory.HeapAlloc, rssBytes, runtime.NumGoroutine(), fileDescriptors, stats)
}
type repeatingReader struct {
remaining int
}