diff --git a/CHANGELOG.md b/CHANGELOG.md index 06765fd..55f7ffd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,7 @@ ## Unreleased - 新增独立 `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 和退出回收。 - 新增 `scripts/verifyHTTP2.py`,固定在 Go 1.25 Linux 容器中运行 HTTP/2 TLS race 长测。 - native TLS 明确限制 ALPN 为 `http/1.1`:缺省自动宣告该协议,误配置 `h2` 等未实现协议时在启动阶段报错。 diff --git a/transport/http2/frameObserver.go b/transport/http2/frameObserver.go new file mode 100644 index 0000000..49a8df1 --- /dev/null +++ b/transport/http2/frameObserver.go @@ -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) + } + } +} diff --git a/transport/http2/lifecycle_test.go b/transport/http2/lifecycle_test.go index b3e3dda..6037b46 100644 --- a/transport/http2/lifecycle_test.go +++ b/transport/http2/lifecycle_test.go @@ -160,6 +160,9 @@ func TestShutdownSendsGoAwayAndDrains(t *testing.T) { if err := rctx.Err(); err != nil { 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 { case err := <-done: t.Fatalf("Serve returned before drain: %v", err) @@ -214,6 +217,9 @@ func TestRSTStreamCancelsOnlyOneRequest(t *testing.T) { case <-time.After(time.Second): 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{}{} if got := p.body(t, 3); got != "/survive" { t.Fatalf("sibling body=%q", got) diff --git a/transport/http2/server.go b/transport/http2/server.go index ea0493a..939229d 100644 --- a/transport/http2/server.go +++ b/transport/http2/server.go @@ -59,6 +59,8 @@ type Server struct { rejectedReqs atomic.Uint64 protocolErrors atomic.Uint64 goAwaySent atomic.Uint64 + rstStreams atomic.Uint64 + goAwayFrames atomic.Uint64 activeConns atomic.Int64 activeRequests atomic.Int64 wg sync.WaitGroup @@ -72,6 +74,8 @@ type Stats struct { RejectedRequests uint64 `json:"rejectedRequests"` ProtocolErrors uint64 `json:"protocolErrors"` GoAwaySent uint64 `json:"goAwaySent"` + RSTStreams uint64 `json:"rstStreams"` + GoAwayFrames uint64 `json:"goAwayFrames"` ActiveConnections int64 `json:"activeConnections"` ActiveRequests int64 `json:"activeRequests"` } @@ -97,6 +101,8 @@ func (s *Server) Stats() Stats { RejectedRequests: s.rejectedReqs.Load(), ProtocolErrors: s.protocolErrors.Load(), GoAwaySent: s.goAwaySent.Load(), + RSTStreams: s.rstStreams.Load(), + GoAwayFrames: s.goAwayFrames.Load(), ActiveConnections: s.activeConns.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.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, }) } diff --git a/transport/native/HTTP2.md b/transport/native/HTTP2.md index 803df5f..930ddf1 100644 --- a/transport/native/HTTP2.md +++ b/transport/native/HTTP2.md @@ -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 中锁住连接状态机。 ## 进入实现前的验收矩阵