feat: make HTTP2 shutdown graceful

This commit is contained in:
2026-09-22 22:25:35 +08:00
parent fcd6f48b53
commit a072da4a31
5 changed files with 319 additions and 26 deletions

View File

@@ -2,7 +2,7 @@
## Unreleased
- 新增独立 `transport/http2` 实验 Runner:支持 h2c/TLS h2、连接上限、全局请求并发闸门和生命周期统计;不改变 native HTTP/1.1 默认路径。
- 新增独立 `transport/http2` 实验 Runner:支持 h2c/TLS h2、连接上限、全局请求并发闸门、GOAWAY 排空、RST_STREAM 单流取消和生命周期统计;不改变 native HTTP/1.1 默认路径。
- native TLS 明确限制 ALPN 为 `http/1.1`:缺省自动宣告该协议,误配置 `h2` 等未实现协议时在启动阶段报错。
- native `Server.Stats()` 新增连接和请求过载拒绝计数;新增可信代理配置,访问日志只在 socket 对端属于配置网段时解析 `X-Forwarded-For`。
- 新增 transport-neutral `metrics` 包,支持请求数、错误数、in-flight、累计耗时和状态分布,并可直接注册 `/metrics` 接口。

View File

@@ -50,7 +50,7 @@
- 已验证 `MaxConcurrentRequests` 满载返回 503,活动请求结束后槽位释放,后续请求可以恢复处理。
- native `Stats()` 已记录连接和请求过载拒绝数;访问日志已支持可信代理网段配置,默认拒绝伪造的 `X-Forwarded-For`。
- native TLS 已明确只宣告 HTTP/1.1 ALPN;HTTP/2 暂由边缘代理终止或使用 `transport/nethttp`,已整理独立协议设计、流控和压测门槛,见 [native HTTP/2 进入条件](transport/native/HTTP2.md),完成后再接入 native。
- 已加入独立 `transport/http2` 实验 Runner,验证 h2c/TLS h2、连接上限、全局请求并发拒绝和恢复;它使用 `x/net/http2` 作为临时帧/流控实现,尚未达到 native 生产协议的 GOAWAY、流控和长测门槛。
- 已加入独立 `transport/http2` 实验 Runner,验证 h2c/TLS h2、连接上限、全局请求并发拒绝和恢复,以及 GOAWAY 排空、RST_STREAM 单流取消和服务关闭超时;它使用 `x/net/http2` 作为临时帧/流控实现,尚未达到 native 生产协议的流控和长测门槛。
- 已用 16 个并发 worker 完成 3,200 次建连/断连 race 验收;MaxConnections 满载拒绝、槽位释放和 TLS 连接上限均已覆盖。
- 新增 `scripts/verifyLinux.py`,固定在 Go 1.25 Linux 容器内运行 race、vet、build 和可选 TLS/非 TLS perfcheck;perfcheck 记录堆、goroutine、连接和 Linux 文件描述符。
- Linux/arm64 已完成 30 秒、128 并发、512 空闲连接的 TLS 与非 TLS 持续回归,报告见 `performance/linuxSoak.json` 和 `performance/linuxSoakTLS.json`,均为 0 错误且连接、goroutine、FD 回收正常。

View File

@@ -0,0 +1,243 @@
package http2
import (
"bytes"
"context"
"errors"
"io"
"net"
"net/http"
"testing"
"time"
xhttp2 "golang.org/x/net/http2"
"golang.org/x/net/http2/hpack"
)
// A raw peer makes GOAWAY and RST_STREAM observable without client retries.
type framePeer struct {
net.Conn
framer *xhttp2.Framer
}
func newFramePeer(t *testing.T, address string) *framePeer {
t.Helper()
c, err := net.DialTimeout("tcp", address, time.Second)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { c.Close() })
c.SetDeadline(time.Now().Add(5 * time.Second))
p := &framePeer{c, xhttp2.NewFramer(c, c)}
if _, err = io.WriteString(c, xhttp2.ClientPreface); err != nil {
t.Fatal(err)
}
if err = p.framer.WriteSettings(); err != nil {
t.Fatal(err)
}
return p
}
func (p *framePeer) get(t *testing.T, id uint32, path string) {
t.Helper()
var b bytes.Buffer
e := hpack.NewEncoder(&b)
for _, field := range []hpack.HeaderField{{Name: ":method", Value: "GET"}, {Name: ":scheme", Value: "http"}, {Name: ":authority", Value: "localhost"}, {Name: ":path", Value: path}} {
if err := e.WriteField(field); err != nil {
t.Fatal(err)
}
}
if err := p.framer.WriteHeaders(xhttp2.HeadersFrameParam{StreamID: id, EndStream: true, EndHeaders: true, BlockFragment: b.Bytes()}); err != nil {
t.Fatal(err)
}
}
func (p *framePeer) read(t *testing.T) xhttp2.Frame {
t.Helper()
f, err := p.framer.ReadFrame()
if err != nil {
t.Fatal(err)
}
if s, ok := f.(*xhttp2.SettingsFrame); ok && !s.IsAck() {
if err := p.framer.WriteSettingsAck(); err != nil {
t.Fatal(err)
}
}
return f
}
func (p *framePeer) body(t *testing.T, id uint32) string {
t.Helper()
var b bytes.Buffer
for {
switch f := p.read(t).(type) {
case *xhttp2.DataFrame:
if f.StreamID != id {
t.Fatalf("unexpected stream %d", f.StreamID)
}
b.Write(f.Data())
if f.StreamEnded() {
return b.String()
}
case *xhttp2.HeadersFrame:
if f.StreamID == id && f.StreamEnded() {
return b.String()
}
case *xhttp2.RSTStreamFrame:
t.Fatalf("unexpected RST_STREAM: %v", f)
}
}
}
func waitSignal(t *testing.T, signal <-chan struct{}) {
t.Helper()
select {
case <-signal:
case <-time.After(3 * time.Second):
t.Fatal("timed out waiting for event")
}
}
func launchServer(t *testing.T, s *Server, ctx context.Context) (string, <-chan error) {
t.Helper()
l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
done := make(chan error, 1)
go func() { done <- s.Serve(ctx, l) }()
t.Cleanup(func() {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
if err := s.Shutdown(ctx); err != nil {
t.Errorf("cleanup shutdown: %v", err)
}
l.Close()
})
return l.Addr().String(), done
}
func serveResult(t *testing.T, done <-chan error) error {
t.Helper()
select {
case err := <-done:
return err
case <-time.After(3 * time.Second):
t.Fatal("Serve did not finish")
return nil
}
}
func TestShutdownSendsGoAwayAndDrains(t *testing.T) {
started := make(chan struct{})
release := make(chan struct{})
defer close(release)
requestCtx := make(chan context.Context, 1)
s := New(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/hold" {
t.Error("new stream reached handler during drain")
return
}
requestCtx <- r.Context()
close(started)
select {
case <-release:
case <-r.Context().Done():
return
}
io.WriteString(w, "completed")
}))
address, done := launchServer(t, s, context.Background())
p := newFramePeer(t, address)
p.get(t, 1, "/hold")
waitSignal(t, started)
rctx := <-requestCtx
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
stopped := make(chan error, 1)
go func() { stopped <- s.Shutdown(ctx) }()
for {
if f, ok := p.read(t).(*xhttp2.GoAwayFrame); ok {
if f.ErrCode != xhttp2.ErrCodeNo || f.LastStreamID != 1 {
t.Fatalf("GOAWAY=%+v", f)
}
break
}
}
if err := rctx.Err(); err != nil {
t.Fatalf("graceful shutdown canceled active request: %v", err)
}
select {
case err := <-done:
t.Fatalf("Serve returned before drain: %v", err)
default:
}
p.get(t, 3, "/late")
release <- struct{}{}
if got := p.body(t, 1); got != "completed" {
t.Fatalf("body=%q", got)
}
p.Close()
if err := serveResult(t, stopped); err != nil {
t.Fatal(err)
}
if err := serveResult(t, done); err != nil {
t.Fatal(err)
}
if stats := s.Stats(); stats.ActiveConnections != 0 || stats.ActiveRequests != 0 {
t.Fatalf("not drained: %+v", stats)
}
}
func TestRSTStreamCancelsOnlyOneRequest(t *testing.T) {
started := make(chan struct{}, 2)
canceled := make(chan string, 2)
release := make(chan struct{})
defer close(release)
s := New(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
started <- struct{}{}
select {
case <-r.Context().Done():
canceled <- r.URL.Path
return
case <-release:
io.WriteString(w, r.URL.Path)
}
}))
s.MaxConcurrentRequests = 2
address, _ := launchServer(t, s, context.Background())
p := newFramePeer(t, address)
p.get(t, 1, "/cancel")
p.get(t, 3, "/survive")
waitSignal(t, started)
waitSignal(t, started)
if err := p.framer.WriteRSTStream(1, xhttp2.ErrCodeCancel); err != nil {
t.Fatal(err)
}
select {
case path := <-canceled:
if path != "/cancel" {
t.Fatalf("canceled %s", path)
}
case <-time.After(time.Second):
t.Fatal("RST did not cancel request")
}
release <- struct{}{}
if got := p.body(t, 3); got != "/survive" {
t.Fatalf("sibling body=%q", got)
}
p.get(t, 5, "/reused")
waitSignal(t, started)
release <- struct{}{}
if got := p.body(t, 5); got != "/reused" {
t.Fatalf("connection reuse body=%q", got)
}
}
func TestCanceledServeContextWaitsForShutdownDeadline(t *testing.T) {
started, canceled := make(chan struct{}), make(chan struct{})
s := New(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { close(started); <-r.Context().Done(); close(canceled) }))
s.ShutdownTimeout = 100 * time.Millisecond
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
address, done := launchServer(t, s, ctx)
p := newFramePeer(t, address)
p.get(t, 1, "/hold")
waitSignal(t, started)
cancel()
if err := serveResult(t, done); !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("Serve should report drain timeout, got %v", err)
}
waitSignal(t, canceled)
}

View File

@@ -42,6 +42,9 @@ type Server struct {
mu sync.Mutex
handler http.Handler
listener net.Listener
shutdownConfig *http.Server
shutdownDone chan struct{}
shutdownOnce sync.Once
closing bool
cancel context.CancelFunc
conns map[net.Conn]struct{}
@@ -176,9 +179,14 @@ func (s *Server) Serve(ctx context.Context, listener net.Listener) error {
s.mu.Unlock()
return errors.New("go-omni/http2: server is already running")
}
baseCtx, cancel := context.WithCancel(ctx)
// Keep the connection context independent from the caller's context. A
// canceled parent starts graceful shutdown; it must not cancel active
// streams before the GOAWAY/drain phase has completed.
baseCtx, cancel := context.WithCancel(context.Background())
s.cancel = cancel
s.listener = listener
s.shutdownDone = make(chan struct{})
s.shutdownOnce = sync.Once{}
s.closing = false
if s.conns == nil {
s.conns = make(map[net.Conn]struct{})
@@ -200,24 +208,43 @@ func (s *Server) Serve(ctx context.Context, listener net.Listener) error {
MaxConcurrentStreams: s.MaxConcurrentStreams,
IdleTimeout: s.IdleTimeout,
}
shutdownDone := make(chan struct{})
go func() {
select {
case <-ctx.Done():
shutdownCtx, cancel := context.WithTimeout(context.Background(), s.shutdownTimeout())
_ = s.Shutdown(shutdownCtx)
cancel()
case <-shutdownDone:
}
}()
defer func() {
close(shutdownDone)
shutdownConfig := &http.Server{IdleTimeout: s.IdleTimeout}
if err := xhttp2.ConfigureServer(shutdownConfig, protocolServer); err != nil {
cancel()
s.mu.Lock()
s.listener = nil
s.cancel = nil
s.mu.Unlock()
return err
}
s.mu.Lock()
s.shutdownConfig = shutdownConfig
s.mu.Unlock()
contextWakeDone := make(chan struct{})
go func() {
select {
case <-ctx.Done():
// Wake Accept. Serve performs the graceful drain synchronously so
// its return value reports a shutdown timeout to the caller.
_ = listener.Close()
case <-contextWakeDone:
}
}()
defer func() {
close(contextWakeDone)
s.mu.Lock()
closing := s.closing
shutdownDone := s.shutdownDone
s.listener = nil
s.shutdownConfig = nil
s.cancel = nil
s.mu.Unlock()
if !closing {
cancel()
} else if shutdownDone != nil {
<-shutdownDone
}
}()
for {
@@ -251,7 +278,13 @@ func (s *Server) Serve(ctx context.Context, listener net.Listener) error {
s.activeConns.Add(1)
s.wg.Add(1)
s.mu.Unlock()
go s.serveConn(baseCtx, protocolServer, gate, conn)
go s.serveConn(baseCtx, protocolServer, shutdownConfig, gate, conn)
}
if ctx.Err() != nil && !s.isClosing() {
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), s.shutdownTimeout())
err := s.Shutdown(shutdownCtx)
shutdownCancel()
return err
}
return nil
}
@@ -263,7 +296,7 @@ func (s *Server) shutdownTimeout() time.Duration {
return defaultShutdownTimeout
}
func (s *Server) serveConn(ctx context.Context, protocolServer *xhttp2.Server, handler http.Handler, conn net.Conn) {
func (s *Server) serveConn(ctx context.Context, protocolServer *xhttp2.Server, baseConfig *http.Server, handler http.Handler, conn net.Conn) {
defer s.wg.Done()
defer func() {
s.mu.Lock()
@@ -283,11 +316,7 @@ func (s *Server) serveConn(ctx context.Context, protocolServer *xhttp2.Server, h
_ = conn.SetDeadline(time.Time{})
}
protocolServer.ServeConn(conn, &xhttp2.ServeConnOpts{
Context: ctx,
Handler: handler,
BaseConfig: &http.Server{
IdleTimeout: s.IdleTimeout,
},
Context: ctx, Handler: handler, BaseConfig: baseConfig,
})
}
@@ -321,6 +350,7 @@ func (s *Server) Shutdown(ctx context.Context) error {
}
s.closing = true
listener := s.listener
shutdownConfig := s.shutdownConfig
cancel := s.cancel
connections := make([]net.Conn, 0, len(s.conns))
for conn := range s.conns {
@@ -330,7 +360,12 @@ func (s *Server) Shutdown(ctx context.Context) error {
if listener != nil {
_ = listener.Close()
}
cancel()
// x/net/http2's configured shutdown callback sends GOAWAY and lets active
// streams finish. The base request context remains live until the timeout
// path below, where connections are forcibly closed.
if shutdownConfig != nil {
_ = shutdownConfig.Shutdown(context.Background())
}
done := make(chan struct{})
go func() {
@@ -339,15 +374,28 @@ func (s *Server) Shutdown(ctx context.Context) error {
}()
select {
case <-done:
cancel()
s.finishShutdown()
return nil
case <-ctx.Done():
for _, conn := range connections {
_ = conn.Close()
}
cancel()
s.finishShutdown()
return ctx.Err()
}
}
func (s *Server) finishShutdown() {
s.mu.Lock()
done := s.shutdownDone
s.mu.Unlock()
if done != nil {
s.shutdownOnce.Do(func() { close(done) })
}
}
func (s *Server) isClosing() bool {
s.mu.Lock()
closing := s.closing

View File

@@ -2,6 +2,8 @@
当前 `transport/native` 是经过 Linux 长连接和反向代理验收的 HTTP/1.1 实现。HTTP/2 暂不直接打开,原因是它把一个 TCP 连接变成多个独立请求流,连接级的 Keep-Alive、超时和并发限制不能直接复用现有 HTTP/1.1 状态机。
当前已提供独立的 `transport/http2` 实验 Runner。它已经验证 TLS/h2、h2c、本地连接和请求闸门、GOAWAY 排空以及 RST_STREAM 取消;这些结果用于验证生命周期设计,不代表 native 已经启用 HTTP/2。
## 目标边界
- HTTP/2 必须作为显式 opt-in 协议,不改变 `omni.New()`、`transport/nethttp` 或 native HTTP/1.1 的默认行为。
@@ -15,7 +17,7 @@
2. TLS 协商得到 `h2` 后进入 HTTP/2 connection state;其他 ALPN 直接关闭,不能把 HTTP/2 preface 交给 HTTP/1.1 解析器。
3. 每个 stream 创建独立的 `http.Request` 和取消 context。`RST_STREAM` 只取消当前请求,TCP 断开才取消该连接上的所有请求。
4. `MaxConcurrentRequests` 是所有连接共享的全局请求闸门;`MaxConcurrentStreams` 限制单连接并发流;两者都必须在收到完整请求体前生效。
5. 服务关闭时停止 accept,发送 GOAWAY 拒绝新流,等待活动流排空;超过 `ShutdownTimeout` 后关闭连接并取消剩余请求。
5. 服务关闭时停止 accept,发送 GOAWAY 拒绝新流,等待活动流排空;超过 `ShutdownTimeout` 后关闭连接并取消剩余请求。实验 Runner 已覆盖这条顺序。
## 必须可观测的状态
@@ -26,7 +28,7 @@ HTTP/2 版本需要在 native `Stats` 之外至少记录:活动 stream、被
- TLS ALPN:只协商 `h2`,HTTP/1.1 客户端仍由现有 native 路径处理;不支持的 ALPN 在握手前后都不会误入错误解析器。
- 多路复用:一个连接并发至少 128 个 stream,响应和 requestId 不串线,单个慢 stream 不阻塞其他 stream。
- 流控:慢读取、慢写、窗口耗尽和大 body 都能恢复;内存不会随未消费 body 无限增长。
- 取消:RST_STREAM、客户端断开、服务关闭分别只影响应受影响的请求,并能在 metrics 中看到回收。
- 取消: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 在停止后回收。