// Package http2 provides an opt-in HTTP/2 transport experiment. // // It owns the listener, TLS/ALPN setup, connection admission and global // request backpressure while using x/net/http2 for frame parsing and stream // flow control. It is deliberately separate from transport/native, whose // production preview remains HTTP/1.1. package http2 import ( "context" "crypto/tls" "errors" "fmt" "net" "net/http" "sync" "sync/atomic" "time" xhttp2 "golang.org/x/net/http2" ) const ( defaultShutdownTimeout = 10 * time.Second defaultTLSHandshake = 10 * time.Second defaultMaxBodyBytes = 8 << 20 http2ALPNProtocol = "h2" ) // Server is an opt-in HTTP/2 transport. Run uses cleartext h2c for local // experiments; RunTLS or ServeTLS should be used for deployed TLS traffic. // HTTP/2 stream limits and global request admission are independent: a client // may open many streams, but only admitted handlers run concurrently. type Server struct { MaxConnections int MaxConcurrentRequests int MaxConcurrentStreams uint32 MaxBodyBytes int64 IdleTimeout time.Duration TLSHandshakeTimeout time.Duration ShutdownTimeout time.Duration TLSConfig *tls.Config 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{} connSlots chan struct{} requestSlots chan struct{} accepted atomic.Uint64 rejectedConns atomic.Uint64 rejectedReqs atomic.Uint64 activeConns atomic.Int64 activeRequests atomic.Int64 wg sync.WaitGroup } // Stats is a point-in-time view of HTTP/2 transport admission and lifecycle. // Accepted and rejected counters are monotonic; active counters are gauges. type Stats struct { AcceptedConnections uint64 `json:"acceptedConnections"` RejectedConnections uint64 `json:"rejectedConnections"` RejectedRequests uint64 `json:"rejectedRequests"` ActiveConnections int64 `json:"activeConnections"` ActiveRequests int64 `json:"activeRequests"` } // New creates an HTTP/2 transport with the supplied handler. The handler may // also be supplied by App.RunWith through the Runner interface. func New(handler http.Handler) *Server { return &Server{ handler: handler, conns: make(map[net.Conn]struct{}), MaxBodyBytes: defaultMaxBodyBytes, } } // Stats returns a lock-free snapshot suitable for a metrics handler. func (s *Server) Stats() Stats { if s == nil { return Stats{} } return Stats{ AcceptedConnections: s.accepted.Load(), RejectedConnections: s.rejectedConns.Load(), RejectedRequests: s.rejectedReqs.Load(), ActiveConnections: s.activeConns.Load(), ActiveRequests: s.activeRequests.Load(), } } // Run implements app.Runner. Without TLSConfig it starts an h2c listener for // local testing. Use RunTLS or TLSConfig for real HTTPS traffic. func (s *Server) Run(ctx context.Context, handler http.Handler, address string) error { if handler != nil { s.handler = handler } if s.handler == nil { return errors.New("go-omni/http2: nil handler") } if address == "" { address = ":8080" } listener, err := net.Listen("tcp", address) if err != nil { return err } if s.TLSConfig != nil { return s.ServeTLS(ctx, listener, s.TLSConfig) } return s.Serve(ctx, listener) } // RunTLS loads a certificate pair and serves HTTP/2 over TLS with ALPN h2. func (s *Server) RunTLS(ctx context.Context, handler http.Handler, address, certFile, keyFile string) error { if handler != nil { s.handler = handler } if s.handler == nil { return errors.New("go-omni/http2: nil handler") } if address == "" { address = ":8443" } certificate, err := tls.LoadX509KeyPair(certFile, keyFile) if err != nil { return err } listener, err := net.Listen("tcp", address) if err != nil { return err } return s.ServeTLS(ctx, listener, &tls.Config{Certificates: []tls.Certificate{certificate}, MinVersion: tls.VersionTLS12}) } // ServeTLS wraps listener with TLS and requires ALPN h2. A caller-provided // config is cloned, so the original value is never mutated. func (s *Server) ServeTLS(ctx context.Context, listener net.Listener, config *tls.Config) error { if listener == nil { return errors.New("go-omni/http2: nil listener") } if config == nil { return errors.New("go-omni/http2: nil TLS config") } prepared, err := prepareTLSConfig(config) if err != nil { _ = listener.Close() return err } return s.Serve(ctx, tls.NewListener(listener, prepared)) } func prepareTLSConfig(config *tls.Config) (*tls.Config, error) { clone := config.Clone() if len(clone.NextProtos) > 0 { for _, protocol := range clone.NextProtos { if protocol != http2ALPNProtocol { return nil, fmt.Errorf("go-omni/http2: unsupported TLS ALPN protocol %q; only %q is supported", protocol, http2ALPNProtocol) } } } clone.NextProtos = []string{http2ALPNProtocol} return clone, nil } // Serve accepts h2c connections from listener until ctx is canceled. func (s *Server) Serve(ctx context.Context, listener net.Listener) error { if listener == nil { return errors.New("go-omni/http2: nil listener") } if s.handler == nil { return errors.New("go-omni/http2: nil handler") } if ctx == nil { ctx = context.Background() } s.mu.Lock() if s.cancel != nil { s.mu.Unlock() return errors.New("go-omni/http2: server is already running") } // 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{}) } if s.MaxConnections > 0 { s.connSlots = make(chan struct{}, s.MaxConnections) } else { s.connSlots = nil } if s.MaxConcurrentRequests > 0 { s.requestSlots = make(chan struct{}, s.MaxConcurrentRequests) } else { s.requestSlots = nil } s.mu.Unlock() requestHandler := http.Handler(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { if s.MaxBodyBytes > 0 { if request.ContentLength > s.MaxBodyBytes { _ = request.Body.Close() http.Error(writer, "request body too large\n", http.StatusRequestEntityTooLarge) return } request.Body = http.MaxBytesReader(writer, request.Body, s.MaxBodyBytes) } s.handler.ServeHTTP(writer, request) })) gate := &requestGate{server: s, next: requestHandler, slots: s.requestSlots} protocolServer := &xhttp2.Server{ MaxConcurrentStreams: s.MaxConcurrentStreams, IdleTimeout: s.IdleTimeout, } 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 { conn, err := listener.Accept() if err != nil { if ctx.Err() != nil || s.isClosing() { break } return err } s.accepted.Add(1) if slots := s.connSlots; slots != nil { select { case slots <- struct{}{}: default: s.rejectedConns.Add(1) _ = conn.Close() continue } } s.mu.Lock() if s.closing { s.mu.Unlock() if s.connSlots != nil { <-s.connSlots } _ = conn.Close() continue } s.conns[conn] = struct{}{} s.activeConns.Add(1) s.wg.Add(1) s.mu.Unlock() 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 } func (s *Server) shutdownTimeout() time.Duration { if s.ShutdownTimeout > 0 { return s.ShutdownTimeout } return defaultShutdownTimeout } 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() delete(s.conns, conn) s.activeConns.Add(-1) s.mu.Unlock() if s.connSlots != nil { <-s.connSlots } _ = conn.Close() }() if tlsConn, ok := conn.(*tls.Conn); ok { _ = conn.SetDeadline(time.Now().Add(s.tlsHandshakeTimeout())) if err := tlsConn.HandshakeContext(ctx); err != nil { return } _ = conn.SetDeadline(time.Time{}) } protocolServer.ServeConn(conn, &xhttp2.ServeConnOpts{ Context: ctx, Handler: handler, BaseConfig: baseConfig, }) } func (s *Server) tlsHandshakeTimeout() time.Duration { if s.TLSHandshakeTimeout > 0 { return s.TLSHandshakeTimeout } return defaultTLSHandshake } // Shutdown stops accepting connections and waits for active HTTP/2 sessions. // If the context expires, remaining connections are closed so handlers can // observe cancellation through their request contexts. func (s *Server) Shutdown(ctx context.Context) error { if s == nil { return nil } if ctx == nil { timeout := s.ShutdownTimeout if timeout <= 0 { timeout = defaultShutdownTimeout } var cancel context.CancelFunc ctx, cancel = context.WithTimeout(context.Background(), timeout) defer cancel() } s.mu.Lock() if s.cancel == nil { s.mu.Unlock() return nil } 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 { connections = append(connections, conn) } s.mu.Unlock() if listener != nil { _ = listener.Close() } // 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() { s.wg.Wait() close(done) }() 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 s.mu.Unlock() return closing } type requestGate struct { server *Server next http.Handler slots chan struct{} } func (g *requestGate) ServeHTTP(writer http.ResponseWriter, request *http.Request) { if g.slots != nil { select { case g.slots <- struct{}{}: g.server.activeRequests.Add(1) defer func() { <-g.slots g.server.activeRequests.Add(-1) }() default: g.server.rejectedReqs.Add(1) http.Error(writer, "server busy\n", http.StatusServiceUnavailable) return } } else { g.server.activeRequests.Add(1) defer g.server.activeRequests.Add(-1) } g.next.ServeHTTP(writer, request) }