feat: observe HTTP2 reset and goaway frames

This commit is contained in:
2026-09-23 00:20:37 +08:00
parent 73a05b8c3c
commit 1b9215114f
5 changed files with 137 additions and 3 deletions

View File

@@ -3,7 +3,7 @@
## Unreleased ## Unreleased
- 新增独立 `transport/http2` 实验 Runner:支持 h2c/TLS h2、连接上限、全局请求并发闸门、GOAWAY 排空、RST_STREAM 单流取消和生命周期统计;不改变 native HTTP/1.1 默认路径。 - 新增独立 `transport/http2` 实验 Runner:支持 h2c/TLS h2、连接上限、全局请求并发闸门、GOAWAY 排空、RST_STREAM 单流取消和生命周期统计;不改变 native HTTP/1.1 默认路径。
- HTTP/2 实验 Runner 增加标准 1 MiB 头部上限、默认 8 MiB 请求体上限、128 路同连接多路复用、2 MiB 慢读取流控验收、协议错误和 GOAWAY 计数;已声明超限请求在进入业务 handler 前返回 413。 - HTTP/2 实验 Runner 增加标准 1 MiB 头部上限、默认 8 MiB 请求体上限、128 路同连接多路复用、2 MiB 慢读取流控验收、协议错误、RST_STREAM 和 GOAWAY 帧计数;已声明超限请求在进入业务 handler 前返回 413。
- 新增可配置时长的 HTTP/2 TLS soak:默认 3 秒,Linux 验收可通过 `GO_OMNI_HTTP2_SOAK_DURATION=30m` 覆盖,混合验证 128 路 stream、慢 handler、请求体、Keep-Alive 和退出回收。 - 新增可配置时长的 HTTP/2 TLS soak:默认 3 秒,Linux 验收可通过 `GO_OMNI_HTTP2_SOAK_DURATION=30m` 覆盖,混合验证 128 路 stream、慢 handler、请求体、Keep-Alive 和退出回收。
- 新增 `scripts/verifyHTTP2.py`,固定在 Go 1.25 Linux 容器中运行 HTTP/2 TLS race 长测。 - 新增 `scripts/verifyHTTP2.py`,固定在 Go 1.25 Linux 容器中运行 HTTP/2 TLS race 长测。
- native TLS 明确限制 ALPN 为 `http/1.1`:缺省自动宣告该协议,误配置 `h2` 等未实现协议时在启动阶段报错。 - native TLS 明确限制 ALPN 为 `http/1.1`:缺省自动宣告该协议,误配置 `h2` 等未实现协议时在启动阶段报错。

View File

@@ -0,0 +1,106 @@
package http2
import (
"crypto/tls"
"net"
"sync"
)
const (
frameTypeRSTStream byte = 0x3
frameTypeGoAway byte = 0x7
)
type frameDirection uint8
const (
frameRead frameDirection = iota
frameWrite
)
// observedConn keeps the HTTP/2 implementation opaque while exposing the
// small amount of frame-level telemetry that x/net/http2 does not provide.
// It parses only the nine-byte frame header and skips payload bytes without
// buffering them, so large DATA frames cannot increase telemetry memory.
type observedConn struct {
net.Conn
onFrame func(frameDirection, byte)
readParser frameParser
writeParser frameParser
writeMu sync.Mutex
}
func (c *observedConn) Read(buffer []byte) (int, error) {
n, err := c.Conn.Read(buffer)
if n > 0 {
c.readParser.observe(buffer[:n], frameRead, c.onFrame)
}
return n, err
}
func (c *observedConn) Write(buffer []byte) (int, error) {
c.writeMu.Lock()
defer c.writeMu.Unlock()
n, err := c.Conn.Write(buffer)
if n > 0 {
c.writeParser.observe(buffer[:n], frameWrite, c.onFrame)
}
return n, err
}
// observedTLSConn preserves the connection state hook used by x/net/http2 so
// handlers still receive Request.TLS after frame observation is added.
type observedTLSConn struct {
*observedConn
state tls.ConnectionState
}
func (c *observedTLSConn) ConnectionState() tls.ConnectionState { return c.state }
type frameParser struct {
prefaceRemain int
header [9]byte
headerBytes int
payloadRemain int
frameType byte
}
func (p *frameParser) observe(buffer []byte, direction frameDirection, onFrame func(frameDirection, byte)) {
for len(buffer) > 0 {
if p.prefaceRemain > 0 {
skip := p.prefaceRemain
if skip > len(buffer) {
skip = len(buffer)
}
p.prefaceRemain -= skip
buffer = buffer[skip:]
continue
}
if p.payloadRemain > 0 {
skip := p.payloadRemain
if skip > len(buffer) {
skip = len(buffer)
}
p.payloadRemain -= skip
buffer = buffer[skip:]
if p.payloadRemain > 0 {
continue
}
onFrame(direction, p.frameType)
continue
}
copied := copy(p.header[p.headerBytes:], buffer)
p.headerBytes += copied
buffer = buffer[copied:]
if p.headerBytes < len(p.header) {
continue
}
p.payloadRemain = int(p.header[0])<<16 | int(p.header[1])<<8 | int(p.header[2])
p.frameType = p.header[3]
p.headerBytes = 0
if p.payloadRemain == 0 {
onFrame(direction, p.frameType)
}
}
}

View File

@@ -160,6 +160,9 @@ func TestShutdownSendsGoAwayAndDrains(t *testing.T) {
if err := rctx.Err(); err != nil { if err := rctx.Err(); err != nil {
t.Fatalf("graceful shutdown canceled active request: %v", err) t.Fatalf("graceful shutdown canceled active request: %v", err)
} }
if stats := s.Stats(); stats.GoAwayFrames != 1 {
t.Fatalf("expected one observed GOAWAY frame, got %+v", stats)
}
select { select {
case err := <-done: case err := <-done:
t.Fatalf("Serve returned before drain: %v", err) t.Fatalf("Serve returned before drain: %v", err)
@@ -214,6 +217,9 @@ func TestRSTStreamCancelsOnlyOneRequest(t *testing.T) {
case <-time.After(time.Second): case <-time.After(time.Second):
t.Fatal("RST did not cancel request") t.Fatal("RST did not cancel request")
} }
if stats := s.Stats(); stats.RSTStreams != 1 {
t.Fatalf("expected one observed RST_STREAM, got %+v", stats)
}
release <- struct{}{} release <- struct{}{}
if got := p.body(t, 3); got != "/survive" { if got := p.body(t, 3); got != "/survive" {
t.Fatalf("sibling body=%q", got) t.Fatalf("sibling body=%q", got)

View File

@@ -59,6 +59,8 @@ type Server struct {
rejectedReqs atomic.Uint64 rejectedReqs atomic.Uint64
protocolErrors atomic.Uint64 protocolErrors atomic.Uint64
goAwaySent atomic.Uint64 goAwaySent atomic.Uint64
rstStreams atomic.Uint64
goAwayFrames atomic.Uint64
activeConns atomic.Int64 activeConns atomic.Int64
activeRequests atomic.Int64 activeRequests atomic.Int64
wg sync.WaitGroup wg sync.WaitGroup
@@ -72,6 +74,8 @@ type Stats struct {
RejectedRequests uint64 `json:"rejectedRequests"` RejectedRequests uint64 `json:"rejectedRequests"`
ProtocolErrors uint64 `json:"protocolErrors"` ProtocolErrors uint64 `json:"protocolErrors"`
GoAwaySent uint64 `json:"goAwaySent"` GoAwaySent uint64 `json:"goAwaySent"`
RSTStreams uint64 `json:"rstStreams"`
GoAwayFrames uint64 `json:"goAwayFrames"`
ActiveConnections int64 `json:"activeConnections"` ActiveConnections int64 `json:"activeConnections"`
ActiveRequests int64 `json:"activeRequests"` ActiveRequests int64 `json:"activeRequests"`
} }
@@ -97,6 +101,8 @@ func (s *Server) Stats() Stats {
RejectedRequests: s.rejectedReqs.Load(), RejectedRequests: s.rejectedReqs.Load(),
ProtocolErrors: s.protocolErrors.Load(), ProtocolErrors: s.protocolErrors.Load(),
GoAwaySent: s.goAwaySent.Load(), GoAwaySent: s.goAwaySent.Load(),
RSTStreams: s.rstStreams.Load(),
GoAwayFrames: s.goAwayFrames.Load(),
ActiveConnections: s.activeConns.Load(), ActiveConnections: s.activeConns.Load(),
ActiveRequests: s.activeRequests.Load(), ActiveRequests: s.activeRequests.Load(),
} }
@@ -356,7 +362,23 @@ func (s *Server) serveConn(ctx context.Context, protocolServer *xhttp2.Server, b
} }
s.protocolConns[conn] = struct{}{} s.protocolConns[conn] = struct{}{}
s.mu.Unlock() s.mu.Unlock()
protocolServer.ServeConn(conn, &xhttp2.ServeConnOpts{ observed := &observedConn{
Conn: conn,
readParser: frameParser{prefaceRemain: 24},
onFrame: func(direction frameDirection, frameType byte) {
if direction == frameRead && frameType == frameTypeRSTStream {
s.rstStreams.Add(1)
}
if direction == frameWrite && frameType == frameTypeGoAway {
s.goAwayFrames.Add(1)
}
},
}
var protocolConn net.Conn = observed
if tlsConn, ok := conn.(*tls.Conn); ok {
protocolConn = &observedTLSConn{observedConn: observed, state: tlsConn.ConnectionState()}
}
protocolServer.ServeConn(protocolConn, &xhttp2.ServeConnOpts{
Context: ctx, Handler: handler, BaseConfig: baseConfig, Context: ctx, Handler: handler, BaseConfig: baseConfig,
}) })
} }

View File

@@ -22,7 +22,7 @@
## 必须可观测的状态 ## 必须可观测的状态
HTTP/2 版本需要在 native `Stats` 之外至少记录:活动 stream、被拒绝 stream、连接级 protocol error、RST_STREAM、GOAWAY 发送次数,以及因流控或并发闸门触发的拒绝。实验 Runner 当前已通过 `ActiveRequests`、`RejectedRequests`、`ProtocolErrors` 和 `GoAwaySent` 暴露活动/拒绝/协议错误/优雅关闭快照;RST_STREAM 的独立计数仍属于进入 native 前的补齐项。计数必须是原子快照,不能在 metrics handler 中锁住连接状态机。 HTTP/2 版本需要在 native `Stats` 之外至少记录:活动 stream、被拒绝 stream、连接级 protocol error、RST_STREAM、GOAWAY 发送次数,以及因流控或并发闸门触发的拒绝。实验 Runner 当前已通过 `ActiveRequests`、`RejectedRequests`、`ProtocolErrors`、`RSTStreams`、`GoAwaySent` 和 `GoAwayFrames` 暴露活动/拒绝/协议错误/客户端重置/优雅关闭快照;帧观察器只解析 9 字节头部并跳过 payload,不会缓存大 body。计数必须是原子快照,不能在 metrics handler 中锁住连接状态机。
## 进入实现前的验收矩阵 ## 进入实现前的验收矩阵