internal/http3: add support for plugging into net/http

This CL adds RegisterServer and RegisterTransport, which are used to
plug our HTTP/3 implementation into net/http. For now, (inaccurately)
assume that RegisterTransport do not take any configs for simplicity.

Various exported symbols have also been changed to be unexported, since
HTTP/3 will be used only via net/http and will not support direct usage.

For golang/go#77440

Change-Id: I7b9528c193c18f5049da7f497d4259153b8770eb
Reviewed-on: https://go-review.googlesource.com/c/net/+/739881
Reviewed-by: Damien Neil <dneil@google.com>
LUCI-TryBot-Result: Go LUCI <golang-scoped@luci-project-accounts.iam.gserviceaccount.com>
Reviewed-by: Nicholas Husin <husin@google.com>
This commit is contained in:
Nicholas S. Husin
2026-02-27 14:49:59 -05:00
committed by Nicholas Husin
parent 4a812844d8
commit 9767a42264
6 changed files with 271 additions and 72 deletions

View File

@@ -0,0 +1,98 @@
// Copyright 2026 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
//go:build go1.27
package http3_test
import (
"crypto/tls"
"io"
"net/http"
"slices"
"testing"
"time"
_ "unsafe" // for linkname
"golang.org/x/net/internal/http3"
"golang.org/x/net/internal/testcert"
"golang.org/x/net/quic"
)
//go:linkname protocolSetHTTP3
func protocolSetHTTP3(p *http.Protocols)
func newTestTLSConfig() *tls.Config {
testCert := func() tls.Certificate {
cert, err := tls.X509KeyPair(testcert.LocalhostCert, testcert.LocalhostKey)
if err != nil {
panic(err)
}
return cert
}()
config := &tls.Config{
InsecureSkipVerify: true,
Certificates: []tls.Certificate{testCert},
}
return config
}
func TestNetHTTPIntegration(t *testing.T) {
body := []byte("some body")
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write(body)
})
srv := &http.Server{
Addr: "127.0.0.1:0",
Handler: handler,
TLSConfig: newTestTLSConfig(),
}
srv.Protocols = &http.Protocols{}
protocolSetHTTP3(srv.Protocols)
var listenAddr string
listenAddrSet := make(chan any)
http3.RegisterServer(srv, http3.ServerOpts{
ListenQUIC: func(addr string, config *quic.Config) (*quic.Endpoint, error) {
e, err := quic.Listen("udp", addr, config)
listenAddr = e.LocalAddr().String()
listenAddrSet <- struct{}{}
return e, err
},
})
go func() {
if err := srv.ListenAndServeTLS("", ""); err != nil {
panic(err)
}
}()
tr := &http.Transport{TLSClientConfig: newTestTLSConfig()}
tr.Protocols = &http.Protocols{}
protocolSetHTTP3(tr.Protocols)
http3.RegisterTransport(tr)
client := &http.Client{
Transport: tr,
Timeout: time.Second,
}
<-listenAddrSet
req, err := http.NewRequest("GET", "https://"+listenAddr, nil)
if err != nil {
t.Fatal(err)
}
resp, err := client.Do(req)
if err != nil {
t.Errorf("got %v err, want nil", err)
}
defer resp.Body.Close()
b, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatal(err)
}
if !slices.Equal(b, body) {
t.Errorf("got %v, want %v", string(b), string(body))
}
}

View File

@@ -18,7 +18,7 @@ import (
)
type roundTripState struct {
cc *ClientConn
cc *clientConn
st *stream
// Request body, provided by the caller.
@@ -87,7 +87,7 @@ func (rt *roundTripState) maybeCallWait100Continue() {
}
// RoundTrip sends a request on the connection.
func (cc *ClientConn) RoundTrip(req *http.Request) (_ *http.Response, err error) {
func (cc *clientConn) RoundTrip(req *http.Request) (_ *http.Response, err error) {
// Each request gets its own QUIC stream.
st, err := newConnStream(req.Context(), cc.qconn, streamTypeRequest)
if err != nil {
@@ -239,7 +239,7 @@ func actualContentLength(req *http.Request) int64 {
// writeBodyAndTrailer handles writing the body and trailer for a given
// request, if any. This function will close the write direction of the stream.
func (cc *ClientConn) writeBodyAndTrailer(rt *roundTripState, req *http.Request) {
func (cc *clientConn) writeBodyAndTrailer(rt *roundTripState, req *http.Request) {
defer rt.closeReqBody()
declaredTrailer := req.Trailer.Clone()
@@ -339,7 +339,7 @@ func parseResponseContentLength(method string, statusCode int, h http.Header) (i
return int64(contentLen), nil
}
func (cc *ClientConn) handleHeaders(st *stream) (statusCode int, h http.Header, err error) {
func (cc *clientConn) handleHeaders(st *stream) (statusCode int, h http.Header, err error) {
haveStatus := false
cookie := ""
// Issue #71374: Consider tracking the never-indexed status of headers
@@ -406,7 +406,7 @@ func (cc *ClientConn) handleHeaders(st *stream) (statusCode int, h http.Header,
return statusCode, h, err
}
func (cc *ClientConn) handlePushPromise(st *stream) error {
func (cc *clientConn) handlePushPromise(st *stream) error {
// "A client MUST treat receipt of a PUSH_PROMISE frame that contains a
// larger push ID than the client has advertised as a connection error of H3_ID_ERROR."
// https://www.rfc-editor.org/rfc/rfc9114.html#section-7.2.5-5

View File

@@ -6,6 +6,7 @@ package http3
import (
"context"
"crypto/tls"
"io"
"maps"
"net/http"
@@ -20,52 +21,120 @@ import (
"golang.org/x/net/quic"
)
// A Server is an HTTP/3 server.
// The zero value for Server is a valid server.
type Server struct {
// Handler to invoke for requests, http.DefaultServeMux if nil.
Handler http.Handler
// A server is an HTTP/3 server.
// The zero value for server is a valid server.
type server struct {
// handler to invoke for requests, http.DefaultServeMux if nil.
handler http.Handler
// Config is the QUIC configuration used by the server.
// The Config may be nil.
//
// ListenAndServe may clone and modify the Config.
// The Config must not be modified after calling ListenAndServe.
Config *quic.Config
config *quic.Config
listenQUIC func(addr string, config *quic.Config) (*quic.Endpoint, error)
initOnce sync.Once
serveCtx context.Context
}
func (s *Server) init() {
// netHTTPHandler is an interface that is implemented by
// net/http.http3ServerHandler in std.
//
// It provides a way for information to be passed between x/net and net/http
// that would otherwise be inaccessible, such as the TLS configs that users
// have supplied to net/http servers.
//
// This allows us to integrate our HTTP/3 server implementation with the
// net/http server when RegisterServer is called.
type netHTTPHandler interface {
http.Handler
TLSConfig() *tls.Config
BaseContext() context.Context
Addr() string
ListenErrHook(err error)
}
type ServerOpts struct {
// ListenQUIC determines how the server will open a QUIC endpoint.
// By default, quic.Listen("udp", addr, config) is used.
ListenQUIC func(addr string, config *quic.Config) (*quic.Endpoint, error)
// QUICConfig is the QUIC configuration used by the server.
// QUICConfig may be nil and should not be modified after calling
// RegisterServer.
// If QUICConfig.TLSConfig is nil, the TLSConfig of the net/http Server
// given to RegisterServer will be used.
QUICConfig *quic.Config
}
// RegisterServer adds HTTP/3 support to a net/http Server.
//
// RegisterServer must be called before s begins serving, and only affects
// s.ListenAndServeTLS.
func RegisterServer(s *http.Server, opts ServerOpts) {
if s.TLSNextProto == nil {
s.TLSNextProto = make(map[string]func(*http.Server, *tls.Conn, http.Handler))
}
s.TLSNextProto["http/3"] = func(s *http.Server, c *tls.Conn, h http.Handler) {
stdHandler, ok := h.(netHTTPHandler)
if !ok {
panic("RegisterServer was given a server that does not implement netHTTPHandler")
}
if opts.QUICConfig == nil {
opts.QUICConfig = &quic.Config{}
}
if opts.QUICConfig.TLSConfig == nil {
opts.QUICConfig.TLSConfig = stdHandler.TLSConfig()
}
s3 := &server{
config: opts.QUICConfig,
listenQUIC: opts.ListenQUIC,
handler: stdHandler,
serveCtx: stdHandler.BaseContext(),
}
stdHandler.ListenErrHook(s3.listenAndServe(stdHandler.Addr()))
}
}
func (s *server) init() {
s.initOnce.Do(func() {
s.Config = initConfig(s.Config)
if s.Handler == nil {
s.Handler = http.DefaultServeMux
s.config = initConfig(s.config)
if s.handler == nil {
s.handler = http.DefaultServeMux
}
if s.serveCtx == nil {
s.serveCtx = context.Background()
}
if s.listenQUIC == nil {
s.listenQUIC = func(addr string, config *quic.Config) (*quic.Endpoint, error) {
return quic.Listen("udp", addr, config)
}
}
})
}
// ListenAndServe listens on the UDP network address addr
// listenAndServe listens on the UDP network address addr
// and then calls Serve to handle requests on incoming connections.
func (s *Server) ListenAndServe(addr string) error {
func (s *server) listenAndServe(addr string) error {
s.init()
e, err := quic.Listen("udp", addr, s.Config)
e, err := s.listenQUIC(addr, s.config)
if err != nil {
return err
}
return s.Serve(e)
go s.serve(e)
return nil
}
// Serve accepts incoming connections on the QUIC endpoint e,
// serve accepts incoming connections on the QUIC endpoint e,
// and handles requests from those connections.
func (s *Server) Serve(e *quic.Endpoint) error {
func (s *server) serve(e *quic.Endpoint) error {
s.init()
defer e.Close(s.serveCtx)
for {
qconn, err := e.Accept(context.Background())
qconn, err := e.Accept(s.serveCtx)
if err != nil {
return err
}
go newServerConn(qconn, s.Handler)
go newServerConn(qconn, s.handler)
}
}

View File

@@ -934,7 +934,7 @@ func TestServer304NotModified(t *testing.T) {
type testServer struct {
t testing.TB
s *Server
s *server
tn testNet
*testQUICEndpoint
@@ -957,16 +957,16 @@ func newTestServer(t testing.TB, handler http.Handler) *testServer {
t.Helper()
ts := &testServer{
t: t,
s: &Server{
Config: &quic.Config{
s: &server{
config: &quic.Config{
TLSConfig: testTLSConfig,
},
Handler: handler,
handler: handler,
},
}
e := ts.tn.newQUICEndpoint(t, ts.s.Config)
e := ts.tn.newQUICEndpoint(t, ts.s.config)
ts.addr = e.LocalAddr()
go ts.s.Serve(e)
go ts.s.serve(e)
return ts
}

View File

@@ -7,60 +7,92 @@ package http3
import (
"context"
"fmt"
"net/http"
"net/url"
"sync"
"golang.org/x/net/quic"
)
// A Transport is an HTTP/3 transport.
// A transport is an HTTP/3 transport.
//
// It does not manage a pool of connections,
// and therefore does not implement net/http.RoundTripper.
//
// TODO: Provide a way to register an HTTP/3 transport with a net/http.Transport's
// TODO: Provide a way to register an HTTP/3 transport with a net/http.transport's
// connection pool.
type Transport struct {
// Endpoint is the QUIC endpoint used by connections created by the transport.
type transport struct {
// endpoint is the QUIC endpoint used by connections created by the transport.
// If unset, it is initialized by the first call to Dial.
Endpoint *quic.Endpoint
endpoint *quic.Endpoint
// Config is the QUIC configuration used for client connections.
// The Config may be nil.
// config is the QUIC configuration used for client connections.
// The config may be nil.
//
// Dial may clone and modify the Config.
// The Config must not be modified after calling Dial.
Config *quic.Config
// Dial may clone and modify the config.
// The config must not be modified after calling Dial.
config *quic.Config
initOnce sync.Once
initErr error
}
func (tr *Transport) init() error {
// netHTTPTransport implements the net/http.dialClientConner interface,
// allowing our HTTP/3 transport to integrate with net/http.
type netHTTPTransport struct {
*transport
}
// RoundTrip is defined since Transport.RegisterProtocol takes in a
// RoundTripper. However, this method will never be used as net/http's
// dialClientConner interface does not have a RoundTrip method and will only
// use DialClientConn to create a new RoundTripper.
func (t netHTTPTransport) RoundTrip(*http.Request) (*http.Response, error) {
panic("netHTTPTransport.RoundTrip should never be called")
}
func (t netHTTPTransport) DialClientConn(ctx context.Context, addr string, _ *url.URL, _ func()) (http.RoundTripper, error) {
return t.transport.dial(ctx, addr)
}
// RegisterTransport configures a net/http HTTP/1 Transport to use HTTP/3.
//
// TODO: most likely, add another arg for transport configuration.
func RegisterTransport(tr *http.Transport) {
tr3 := &transport{
config: &quic.Config{
TLSConfig: tr.TLSClientConfig.Clone(),
},
}
tr.RegisterProtocol("http/3", netHTTPTransport{tr3})
}
func (tr *transport) init() error {
tr.initOnce.Do(func() {
tr.Config = initConfig(tr.Config)
if tr.Endpoint == nil {
tr.Endpoint, tr.initErr = quic.Listen("udp", ":0", nil)
tr.config = initConfig(tr.config)
if tr.endpoint == nil {
tr.endpoint, tr.initErr = quic.Listen("udp", ":0", nil)
}
})
return tr.initErr
}
// Dial creates a new HTTP/3 client connection.
func (tr *Transport) Dial(ctx context.Context, target string) (*ClientConn, error) {
// dial creates a new HTTP/3 client connection.
func (tr *transport) dial(ctx context.Context, target string) (*clientConn, error) {
if err := tr.init(); err != nil {
return nil, err
}
qconn, err := tr.Endpoint.Dial(ctx, "udp", target, tr.Config)
qconn, err := tr.endpoint.Dial(ctx, "udp", target, tr.config)
if err != nil {
return nil, err
}
return newClientConn(ctx, qconn)
}
// A ClientConn is a client HTTP/3 connection.
// A clientConn is a client HTTP/3 connection.
//
// Multiple goroutines may invoke methods on a ClientConn simultaneously.
type ClientConn struct {
// Multiple goroutines may invoke methods on a clientConn simultaneously.
type clientConn struct {
qconn *quic.Conn
genericConn
@@ -68,8 +100,8 @@ type ClientConn struct {
dec qpackDecoder
}
func newClientConn(ctx context.Context, qconn *quic.Conn) (*ClientConn, error) {
cc := &ClientConn{
func newClientConn(ctx context.Context, qconn *quic.Conn) (*clientConn, error) {
cc := &clientConn{
qconn: qconn,
}
cc.enc.init()
@@ -86,10 +118,10 @@ func newClientConn(ctx context.Context, qconn *quic.Conn) (*ClientConn, error) {
return cc, nil
}
// Close closes the connection.
// close closes the connection.
// Any in-flight requests are canceled.
// Close does not wait for the peer to acknowledge the connection closing.
func (cc *ClientConn) Close() error {
// close does not wait for the peer to acknowledge the connection closing.
func (cc *clientConn) close() error {
// Close the QUIC connection immediately with a status of NO_ERROR.
cc.qconn.Abort(nil)
@@ -99,7 +131,7 @@ func (cc *ClientConn) Close() error {
return cc.qconn.Wait(ctx)
}
func (cc *ClientConn) handleControlStream(st *stream) error {
func (cc *clientConn) handleControlStream(st *stream) error {
// "A SETTINGS frame MUST be sent as the first frame of each control stream [...]"
// https://www.rfc-editor.org/rfc/rfc9114.html#section-7.2.4-2
if err := st.readSettings(func(settingsType, settingsValue int64) error {
@@ -145,17 +177,17 @@ func (cc *ClientConn) handleControlStream(st *stream) error {
}
}
func (cc *ClientConn) handleEncoderStream(*stream) error {
func (cc *clientConn) handleEncoderStream(*stream) error {
// TODO
return nil
}
func (cc *ClientConn) handleDecoderStream(*stream) error {
func (cc *clientConn) handleDecoderStream(*stream) error {
// TODO
return nil
}
func (cc *ClientConn) handlePushStream(*stream) error {
func (cc *clientConn) handlePushStream(*stream) error {
// "A client MUST treat receipt of a push stream as a connection error
// of type H3_ID_ERROR when no MAX_PUSH_ID frame has been sent [...]"
// https://www.rfc-editor.org/rfc/rfc9114.html#section-4.6-3
@@ -165,7 +197,7 @@ func (cc *ClientConn) handlePushStream(*stream) error {
}
}
func (cc *ClientConn) handleRequestStream(st *stream) error {
func (cc *clientConn) handleRequestStream(st *stream) error {
// "Clients MUST treat receipt of a server-initiated bidirectional
// stream as a connection error of type H3_STREAM_CREATION_ERROR [...]"
// https://www.rfc-editor.org/rfc/rfc9114.html#section-6.1-3
@@ -176,7 +208,7 @@ func (cc *ClientConn) handleRequestStream(st *stream) error {
}
// abort closes the connection with an error.
func (cc *ClientConn) abort(err error) {
func (cc *clientConn) abort(err error) {
if e, ok := err.(*connectionError); ok {
cc.qconn.Abort(&quic.ApplicationError{
Code: uint64(e.code),

View File

@@ -370,8 +370,8 @@ func (ts *testQUICStream) Flush() error {
// A testClientConn is a ClientConn on a test network.
type testClientConn struct {
tr *Transport
cc *ClientConn
tr *transport
cc *clientConn
// *testQUICConn is the server half of the connection.
*testQUICConn
@@ -380,19 +380,19 @@ type testClientConn struct {
func newTestClientConn(t testing.TB) *testClientConn {
e1, e2 := newQUICEndpointPair(t)
tr := &Transport{
Endpoint: e1,
Config: &quic.Config{
tr := &transport{
endpoint: e1,
config: &quic.Config{
TLSConfig: testTLSConfig,
},
}
cc, err := tr.Dial(t.Context(), e2.LocalAddr().String())
cc, err := tr.dial(t.Context(), e2.LocalAddr().String())
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
cc.Close()
cc.close()
})
srvConn, err := e2.Accept(t.Context())
if err != nil {