feat: make HTTP2 shutdown graceful
This commit is contained in:
@@ -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` 接口。
|
||||
|
||||
@@ -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 回收正常。
|
||||
|
||||
243
transport/http2/lifecycle_test.go
Normal file
243
transport/http2/lifecycle_test.go
Normal 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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 在停止后回收。
|
||||
|
||||
Reference in New Issue
Block a user