test: complete bounded HTTP2 TLS soak and flow control checks
This commit is contained in:
116
transport/http2/admission_test.go
Normal file
116
transport/http2/admission_test.go
Normal file
@@ -0,0 +1,116 @@
|
||||
package http2
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
xhttp2 "golang.org/x/net/http2"
|
||||
)
|
||||
|
||||
func TestTLSConnectionLimitRecoversAfterHandshakeTimeout(t *testing.T) {
|
||||
certificate, roots := testCertificate(t)
|
||||
var handled atomic.Int32
|
||||
server := New(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
handled.Add(1)
|
||||
_, _ = io.WriteString(writer, "ok")
|
||||
}))
|
||||
server.MaxConnections = 1
|
||||
server.TLSHandshakeTimeout = time.Second
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- server.ServeTLS(context.Background(), listener, &tls.Config{
|
||||
Certificates: []tls.Certificate{certificate}, MinVersion: tls.VersionTLS12,
|
||||
})
|
||||
}()
|
||||
t.Cleanup(func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
if err := server.Shutdown(ctx); err != nil {
|
||||
t.Errorf("shutdown: %v", err)
|
||||
}
|
||||
_ = listener.Close()
|
||||
if err := serveResult(t, done); err != nil {
|
||||
t.Errorf("serve: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
// Occupy the only connection slot without sending a TLS ClientHello.
|
||||
held, err := net.DialTimeout("tcp", listener.Addr().String(), time.Second)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer held.Close()
|
||||
waitHTTP2Stats(t, server, func(stats Stats) bool { return stats.ActiveConnections == 1 })
|
||||
|
||||
rejected, err := net.DialTimeout("tcp", listener.Addr().String(), time.Second)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer rejected.Close()
|
||||
if err := rejected.SetReadDeadline(time.Now().Add(250 * time.Millisecond)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var buffer [1]byte
|
||||
if _, err := rejected.Read(buffer[:]); err == nil {
|
||||
t.Fatal("connection above the cap was not closed")
|
||||
} else if timeout, ok := err.(net.Error); ok && timeout.Timeout() {
|
||||
t.Fatal("connection above the cap was not rejected promptly")
|
||||
}
|
||||
if stats := server.Stats(); stats.RejectedConnections != 1 || handled.Load() != 0 {
|
||||
t.Fatalf("unexpected admission state: %+v handled=%d", stats, handled.Load())
|
||||
}
|
||||
|
||||
// The handshake deadline, rather than a client disconnect, must release it.
|
||||
waitHTTP2Stats(t, server, func(stats Stats) bool { return stats.ActiveConnections == 0 })
|
||||
if err := held.SetReadDeadline(time.Now().Add(250 * time.Millisecond)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := held.Read(buffer[:]); err == nil {
|
||||
t.Fatal("timed-out TLS connection remained open")
|
||||
} else if timeout, ok := err.(net.Error); ok && timeout.Timeout() {
|
||||
t.Fatal("timed-out TLS connection was not closed")
|
||||
}
|
||||
|
||||
transport := &xhttp2.Transport{TLSClientConfig: &tls.Config{
|
||||
RootCAs: roots, ServerName: "localhost", MinVersion: tls.VersionTLS12,
|
||||
NextProtos: []string{"h2"},
|
||||
}}
|
||||
defer transport.CloseIdleConnections()
|
||||
client := &http.Client{Transport: transport, Timeout: 3 * time.Second}
|
||||
response, err := client.Get("https://" + listener.Addr().String() + "/health")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body, readErr := io.ReadAll(response.Body)
|
||||
response.Body.Close()
|
||||
if readErr != nil || response.StatusCode != http.StatusOK || response.ProtoMajor != 2 || string(body) != "ok" {
|
||||
t.Fatalf("TLS admission did not recover: status=%d protocol=%s body=%q err=%v", response.StatusCode, response.Proto, body, readErr)
|
||||
}
|
||||
transport.CloseIdleConnections()
|
||||
waitHTTP2Stats(t, server, func(stats Stats) bool { return stats.ActiveConnections == 0 && stats.ActiveRequests == 0 })
|
||||
if stats := server.Stats(); stats.AcceptedConnections != 3 || stats.RejectedConnections != 1 || stats.ProtocolErrors != 0 || handled.Load() != 1 {
|
||||
t.Fatalf("unexpected recovered admission state: %+v handled=%d", stats, handled.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func waitHTTP2Stats(t *testing.T, server *Server, predicate func(Stats) bool) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(3 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if predicate(server.Stats()) {
|
||||
return
|
||||
}
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
t.Fatalf("HTTP/2 stats did not reach expected state: %+v", server.Stats())
|
||||
}
|
||||
129
transport/http2/flowControl_test.go
Normal file
129
transport/http2/flowControl_test.go
Normal file
@@ -0,0 +1,129 @@
|
||||
package http2
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
xhttp2 "golang.org/x/net/http2"
|
||||
"golang.org/x/net/http2/hpack"
|
||||
)
|
||||
|
||||
// A zero receive window simulates a client that has stopped consuming DATA.
|
||||
// Headers need no flow-control credit, so a 204 on another stream should still
|
||||
// complete while the large response waits for a WINDOW_UPDATE or cancellation.
|
||||
func TestSlowResponseStreamIsolationAndRecovery(t *testing.T) {
|
||||
payload := strings.Repeat("x", 32<<10)
|
||||
started := make(chan struct{}, 2)
|
||||
written := make(chan error, 2)
|
||||
server := New(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
if request.URL.Path == "/health" {
|
||||
writer.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
if request.URL.Path == "/ready" {
|
||||
_, _ = io.WriteString(writer, "ready")
|
||||
return
|
||||
}
|
||||
writer.Header().Set("Content-Length", fmt.Sprint(len(payload)))
|
||||
writer.(http.Flusher).Flush()
|
||||
started <- struct{}{}
|
||||
_, err := io.WriteString(writer, payload)
|
||||
written <- err
|
||||
}))
|
||||
server.MaxConnections = 2
|
||||
server.MaxConcurrentRequests = 2
|
||||
address, stop := startServer(t, server)
|
||||
defer stop()
|
||||
|
||||
slow := newFramePeer(t, address, xhttp2.Setting{ID: xhttp2.SettingInitialWindowSize, Val: 0})
|
||||
defer slow.Close()
|
||||
slow.framer.ReadMetaHeaders = hpack.NewDecoder(4096, nil)
|
||||
slow.get(t, 1, "/large")
|
||||
waitSignal(t, started)
|
||||
|
||||
// The same TCP connection can still carry response headers for other streams.
|
||||
slow.get(t, 3, "/health")
|
||||
readNoContent(t, slow, 3)
|
||||
select {
|
||||
case err := <-written:
|
||||
t.Fatalf("large response escaped the zero stream window: %v", err)
|
||||
default:
|
||||
}
|
||||
|
||||
// A second connection also remains usable while the first handler is blocked.
|
||||
fast := newFramePeer(t, address)
|
||||
defer fast.Close()
|
||||
fast.framer.ReadMetaHeaders = hpack.NewDecoder(4096, nil)
|
||||
fast.get(t, 1, "/ready")
|
||||
if body := fast.body(t, 1); body != "ready" {
|
||||
t.Fatalf("other connection response: %q", body)
|
||||
}
|
||||
if stats := server.Stats(); stats.ActiveConnections != 2 || stats.RejectedRequests != 0 {
|
||||
t.Fatalf("slow response affected other traffic: %+v", stats)
|
||||
}
|
||||
|
||||
// Restoring stream credit must resume the existing response without reconnecting.
|
||||
if err := slow.framer.WriteWindowUpdate(1, uint32(len(payload))); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if body := slow.body(t, 1); body != payload {
|
||||
t.Fatalf("resumed response mismatch: got %d bytes, want %d", len(body), len(payload))
|
||||
}
|
||||
select {
|
||||
case err := <-written:
|
||||
if err != nil {
|
||||
t.Fatalf("resumed write: %v", err)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("resumed write did not return")
|
||||
}
|
||||
|
||||
// A new zero-window stream must release its handler when the client cancels.
|
||||
slow.get(t, 5, "/large")
|
||||
waitSignal(t, started)
|
||||
if err := slow.framer.WriteRSTStream(5, xhttp2.ErrCodeCancel); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
select {
|
||||
case err := <-written:
|
||||
if err == nil {
|
||||
t.Fatal("canceled zero-window write unexpectedly succeeded")
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("RST_STREAM did not unblock the writer")
|
||||
}
|
||||
|
||||
slow.get(t, 7, "/health")
|
||||
readNoContent(t, slow, 7)
|
||||
deadline := time.Now().Add(time.Second)
|
||||
for server.Stats().ActiveRequests != 0 && time.Now().Before(deadline) {
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
if stats := server.Stats(); stats.ActiveRequests != 0 || stats.RSTStreams != 1 || stats.ProtocolErrors != 0 || stats.RejectedRequests != 0 {
|
||||
t.Fatalf("flow control did not recover: %+v", stats)
|
||||
}
|
||||
}
|
||||
|
||||
func readNoContent(t *testing.T, peer *framePeer, streamID uint32) {
|
||||
t.Helper()
|
||||
for {
|
||||
switch frame := peer.read(t).(type) {
|
||||
case *xhttp2.MetaHeadersFrame:
|
||||
if frame.StreamID != streamID {
|
||||
continue
|
||||
}
|
||||
if frame.PseudoValue("status") != "204" || !frame.StreamEnded() {
|
||||
t.Fatalf("stream %d: expected complete 204, got %+v", streamID, frame)
|
||||
}
|
||||
return
|
||||
case *xhttp2.DataFrame:
|
||||
t.Fatalf("unexpected DATA on zero-window stream %d", frame.StreamID)
|
||||
case *xhttp2.RSTStreamFrame:
|
||||
t.Fatalf("unexpected server reset: %+v", frame)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,7 @@ type framePeer struct {
|
||||
framer *xhttp2.Framer
|
||||
}
|
||||
|
||||
func newFramePeer(t *testing.T, address string) *framePeer {
|
||||
func newFramePeer(t *testing.T, address string, settings ...xhttp2.Setting) *framePeer {
|
||||
t.Helper()
|
||||
c, err := net.DialTimeout("tcp", address, time.Second)
|
||||
if err != nil {
|
||||
@@ -32,7 +32,7 @@ func newFramePeer(t *testing.T, address string) *framePeer {
|
||||
if _, err = io.WriteString(c, xhttp2.ClientPreface); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = p.framer.WriteSettings(); err != nil {
|
||||
if err = p.framer.WriteSettings(settings...); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return p
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
|
||||
当前已提供独立的 `transport/http2` 实验 Runner。它已经验证 TLS/h2、h2c、本地连接和请求闸门、GOAWAY 排空以及 RST_STREAM 取消;这些结果用于验证生命周期设计,不代表 native 已经启用 HTTP/2。实验 Runner 还覆盖了默认 1 MiB 头部上限、8 MiB 请求体上限、128 路同连接 stream 多路复用,以及 2 MiB 慢读取 body 在窗口耗尽后的恢复。`TestTLSSoak` 默认运行 3 秒,Linux 长测通过 `python3 scripts/verifyHTTP2.py --duration 30m` 启动。
|
||||
|
||||
2026-09-23 已在 Linux amd64 完成单 TLS 连接、128 个 worker 的 30 分钟长测:58,102,476 次请求,0 失败与协议错误,采样 RSS 峰值约 22.6 MiB,退出后连接、活动请求和 FD 回收。测试容器限制为 2 核、2 GiB,测量包含同一进程中的客户端与服务端,见 [报告](../../performance/http2SoakLinux.json)。本次包含慢 Handler 与 64 KiB body;多连接、持续慢客户端及代理 HTTP/2 回源验收仍未完成。
|
||||
|
||||
## 目标边界
|
||||
|
||||
- HTTP/2 必须作为显式 opt-in 协议,不改变 `omni.New()`、`transport/nethttp` 或 native HTTP/1.1 的默认行为。
|
||||
@@ -28,10 +30,10 @@ HTTP/2 版本需要在 native `Stats` 之外至少记录:活动 stream、被
|
||||
|
||||
- TLS ALPN:只协商 `h2`,HTTP/1.1 客户端仍由现有 native 路径处理;不支持的 ALPN 在握手前后都不会误入错误解析器。
|
||||
- 多路复用:一个连接并发至少 128 个 stream,响应和 requestId 不串线,单个慢 stream 不阻塞其他 stream(实验 Runner 已覆盖 128 路同连接 stream)。
|
||||
- 流控:慢读取、慢写、窗口耗尽和大 body 都能恢复;内存不会随未消费 body 无限增长(实验 Runner 已覆盖 2 MiB 慢读取 body 和请求体上限)。
|
||||
- 流控:慢读取、慢写、窗口耗尽和大 body 都能恢复;内存不会随未消费 body 无限增长。实验 Runner 已覆盖 2 MiB 慢读取 body 和请求体上限,以及客户端将 stream 窗口设为零时的大响应阻塞、同连接 204、其他连接带 body 响应、WINDOW_UPDATE 恢复发送和 RST_STREAM 取消回收;连接级窗口耗尽和持续慢客户端混合长测仍需补齐。
|
||||
- 取消:RST_STREAM、客户端断开、服务关闭分别只影响应受影响的请求,并能在 metrics 中看到回收;当前实验 Runner 已覆盖 RST_STREAM 与服务关闭。
|
||||
- 背压:连接上限、全局 stream 上限、单连接 stream 上限和 header/body 上限均能快速拒绝并自动恢复。
|
||||
- 代理联调:Nginx/Cloudflare 的 TLS、Keep-Alive、超时、GOAWAY 和优雅发布行为与 HTTP/1.1 回源结果一致。
|
||||
- 长测:Linux amd64 至少 30 分钟 TLS、多连接、多 stream、慢客户端混合场景;要求 0 协议错误、0 请求串线,连接、stream、goroutine 和 FD 在停止后回收。
|
||||
- 背压:连接上限、全局 stream 上限、单连接 stream 上限和 header/body 上限均能快速拒绝并自动恢复。实验 Runner 已增加 TLS 未完成握手占满连接槽位时的及时拒绝、握手超时回收及后续正常 HTTPS 请求恢复验收。
|
||||
- 代理联调:Nginx/Cloudflare 的 TLS、Keep-Alive、超时、GOAWAY 和优雅发布行为与 HTTP/1.1 回源结果一致。现有 `verifyProxy.py` 检查边缘 HTTP/2 协商与 native HTTP/1.1 TLS 回源,不构成 HTTP/2 上游 GOAWAY/排空的验收依据。
|
||||
- 长测:Linux amd64 至少 30 分钟 TLS、多连接、多 stream、慢客户端混合场景;要求 0 协议错误、0 请求串线,连接、stream、goroutine 和 FD 在停止后回收。单连接 30 分钟场景已通过,完整混合场景仍待验证。
|
||||
|
||||
只有上述矩阵完成,才把 `h2` 加入 native 的 TLS ALPN,并考虑把 HTTP/2 作为独立版本能力发布。此前生产环境使用 Nginx/Cloudflare 终止 HTTP/2 后以 HTTP/1.1 回源,或使用支持 HTTP/2 的 `transport/nethttp`。
|
||||
|
||||
Reference in New Issue
Block a user