Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions doc/godebug.md
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,14 @@ Setting `x509sslcertoverrideplatform=0` disables this behavior in favor of using
the platform certificate store instead of honoring the environment variables. We
plan to remove this setting in Go 1.31.

Go 1.27 added a new `http2reuseframes` setting that controls whether the
net/http HTTP/2 server and Transport opt in to `Framer.SetReuseFrames`,
which reuses parsed `*DataFrame`, `*WindowUpdateFrame`, `*HeadersFrame`,
and `*MetaHeadersFrame` structs across `ReadFrame` calls to reduce
per-frame heap allocation. The default is reuse on. Setting
`http2reuseframes=0` reverts to allocating each parsed frame fresh,
matching the pre-Go 1.27 behavior.

### Go 1.26

Go 1.26 added a new `httpcookiemaxnum` setting that controls the maximum number
Expand Down
1 change: 1 addition & 0 deletions src/internal/godebugs/table.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ var All = []Info{
{Name: "htmlmetacontenturlescape", Package: "html/template"},
{Name: "http2client", Package: "net/http"},
{Name: "http2debug", Package: "net/http", Opaque: true},
{Name: "http2reuseframes", Package: "net/http"},
{Name: "http2server", Package: "net/http"},
{Name: "httpcookiemaxnum", Package: "net/http", Changed: 24, Old: "0"},
{Name: "httplaxcontentlength", Package: "net/http", Changed: 22, Old: "1"},
Expand Down
84 changes: 75 additions & 9 deletions src/net/http/internal/http2/frame.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"encoding/binary"
"errors"
"fmt"
"internal/godebug"
"io"
"log"
"slices"
Expand All @@ -22,6 +23,12 @@ import (
"golang.org/x/net/http/httpguts"
)

// http2reuseframes controls whether the per-connection Framer in the
// stdlib server and Transport opts in to SetReuseFrames. Default is
// reuse on; GODEBUG=http2reuseframes=0 reverts to allocating each
// parsed frame fresh, matching the pre-CL behavior.
var http2reuseframes = godebug.New("http2reuseframes")

const frameHeaderLen = 9

var padZeros = make([]byte, 255) // zeros for padding
Expand Down Expand Up @@ -433,7 +440,10 @@ func (fr *Framer) SetReuseFrames() {
}

type frameCache struct {
dataFrame DataFrame
dataFrame DataFrame
windowUpdateFrame WindowUpdateFrame
headersFrame HeadersFrame
metaHeadersFrame MetaHeadersFrame
}

func (fc *frameCache) getDataFrame() *DataFrame {
Expand All @@ -443,6 +453,27 @@ func (fc *frameCache) getDataFrame() *DataFrame {
return &fc.dataFrame
}

func (fc *frameCache) getWindowUpdateFrame() *WindowUpdateFrame {
if fc == nil {
return &WindowUpdateFrame{}
}
return &fc.windowUpdateFrame
}

func (fc *frameCache) getHeadersFrame() *HeadersFrame {
if fc == nil {
return &HeadersFrame{}
}
return &fc.headersFrame
}

func (fc *frameCache) getMetaHeadersFrame() *MetaHeadersFrame {
if fc == nil {
return &MetaHeadersFrame{}
}
return &fc.metaHeadersFrame
}

// NewFramer returns a Framer that writes frames to w and reads them from r.
func NewFramer(w io.Writer, r io.Reader) *Framer {
fr := &Framer{
Expand Down Expand Up @@ -996,12 +1027,25 @@ func parseUnknownFrame(_ *frameCache, fh FrameHeader, countError func(string), p

// A WindowUpdateFrame is used to implement flow control.
// See https://httpwg.org/specs/rfc7540.html#rfc.section.6.9
//
// When [Framer.SetReuseFrames] is in effect, the same *WindowUpdateFrame
// is returned by every (*Framer).ReadFrame call that parses a
// WINDOW_UPDATE and its fields are overwritten on each call, so callers
// must consume the StreamID and Increment fields before the next
// ReadFrame and must not retain the pointer.
type WindowUpdateFrame struct {
FrameHeader
Increment uint32 // never read with high bit set
}

func parseWindowUpdateFrame(_ *frameCache, fh FrameHeader, countError func(string), p []byte) (Frame, error) {
// parseWindowUpdateFrame populates the *WindowUpdateFrame returned by
// frameCache.getWindowUpdateFrame. When [Framer.SetReuseFrames] is in
// effect, that struct is reused across ReadFrame calls, so every field
// of WindowUpdateFrame must be assigned on the success path or stale
// data from a previous frame will be visible to the next caller. If a
// field is added to WindowUpdateFrame, update both this function and
// TestReadFrameWindowUpdateOverwrites.
func parseWindowUpdateFrame(fc *frameCache, fh FrameHeader, countError func(string), p []byte) (Frame, error) {
if len(p) != 4 {
countError("frame_windowupdate_bad_len")
return nil, ConnectionError(ErrCodeFrameSize)
Expand All @@ -1021,10 +1065,10 @@ func parseWindowUpdateFrame(_ *frameCache, fh FrameHeader, countError func(strin
countError("frame_windowupdate_zero_inc_stream")
return nil, streamError(fh.StreamID, ErrCodeProtocol)
}
return &WindowUpdateFrame{
FrameHeader: fh,
Increment: inc,
}, nil
wuf := fc.getWindowUpdateFrame()
wuf.FrameHeader = fh
wuf.Increment = inc
return wuf, nil
}

// WriteWindowUpdate writes a WINDOW_UPDATE frame.
Expand All @@ -1043,6 +1087,12 @@ func (f *Framer) WriteWindowUpdate(streamID, incr uint32) error {

// A HeadersFrame is used to open a stream and additionally carries a
// header block fragment.
//
// When [Framer.SetReuseFrames] is in effect, the same *HeadersFrame
// is returned by every (*Framer).ReadFrame call that parses a HEADERS
// and its fields are overwritten on each call. The headerFragBuf
// slice always aliases the framer's read buffer and must not be
// retained past the next ReadFrame regardless of this setting.
type HeadersFrame struct {
FrameHeader

Expand All @@ -1069,8 +1119,16 @@ func (f *HeadersFrame) HasPriority() bool {
return f.FrameHeader.Flags.Has(FlagHeadersPriority)
}

func parseHeadersFrame(_ *frameCache, fh FrameHeader, countError func(string), p []byte) (_ Frame, err error) {
hf := &HeadersFrame{
// parseHeadersFrame populates the *HeadersFrame returned by
// frameCache.getHeadersFrame. When [Framer.SetReuseFrames] is in
// effect, that struct is reused across ReadFrame calls; the explicit
// `*hf = HeadersFrame{...}` reset below zeros every field so stale
// values (Priority, headerFragBuf) cannot leak from a prior frame.
// If a field is added to HeadersFrame, update the reset and
// TestReadFrameHeadersOverwrites.
func parseHeadersFrame(fc *frameCache, fh FrameHeader, countError func(string), p []byte) (_ Frame, err error) {
hf := fc.getHeadersFrame()
*hf = HeadersFrame{
FrameHeader: fh,
}
if fh.StreamID == 0 {
Expand Down Expand Up @@ -1593,6 +1651,11 @@ type headersOrContinuation interface {
//
// This type of frame does not appear on the wire and is only returned
// by the Framer when Framer.ReadMetaHeaders is set.
//
// When [Framer.SetReuseFrames] is in effect, the same *MetaHeadersFrame
// is returned by every ReadFrame call that produces one and its fields
// are overwritten on each call. Callers must consume Fields and
// Truncated before the next ReadFrame and must not retain the pointer.
type MetaHeadersFrame struct {
*HeadersFrame

Expand Down Expand Up @@ -1710,7 +1773,10 @@ func (fr *Framer) readMetaFrame(hf *HeadersFrame) (Frame, error) {
if fr.AllowIllegalReads {
return nil, errors.New("illegal use of AllowIllegalReads with ReadMetaHeaders")
}
mh := &MetaHeadersFrame{
mh := fr.frameCache.getMetaHeadersFrame()
// Wholesale reset so values from a previous parse (Fields,
// Truncated, or any future-added field) cannot leak through.
*mh = MetaHeadersFrame{
HeadersFrame: hf,
}
var remainSize = fr.maxHeaderListSize()
Expand Down
114 changes: 114 additions & 0 deletions src/net/http/internal/http2/frame_reuse_e2e_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
// 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.

package http2_test

import (
"io"
"net/http"
"strings"
"sync"
"testing"
)

// TestFrameReuseEndToEndStress drives concurrent multiplexed requests
// through the real net/http HTTP/2 server and Transport (both of
// which call SetReuseFrames on the per-connection Framer) and touches
// every field reachable from a request/response in ways that would
// race against the read loop's next ReadFrame if anything still
// aliased the cached frame after the readMore gate (server) or
// read-loop iteration (Transport).
//
// Under -race this is a regression test against future refactors of
// processHeaders / handleResponse / processTrailers / processData
// that fail to copy aliased data. The synthetic Framer-pair tests in
// frame_reuse_race_test.go cannot reach the production process* code
// paths; this one does.
//
// The handler and client iterate Name/Value byte-by-byte so that any
// aliasing leak shows up as a concrete read concurrent with a write
// in bytes.(*Reader).Read inside ReadFrame.
func TestFrameReuseEndToEndStress(t *testing.T) {
if testing.Short() {
t.Skip("skipping stress test in short mode")
}

const responseBody = "the quick brown fox jumps over the lazy dog"

touchHeader := func(h http.Header) byte {
var sink byte
for k, vs := range h {
for i := 0; i < len(k); i++ {
sink ^= k[i]
}
for _, v := range vs {
for i := 0; i < len(v); i++ {
sink ^= v[i]
}
}
}
return sink
}

ts := newTestServer(t,
func(w http.ResponseWriter, r *http.Request) {
_ = touchHeader(r.Header)
if _, err := io.Copy(io.Discard, r.Body); err != nil {
t.Errorf("server body copy: %v", err)
return
}
_ = touchHeader(r.Trailer)
w.Header().Set("Trailer", "X-Server-Trailer")
w.Header().Set("X-Response-Header", "response-value")
w.WriteHeader(http.StatusOK)
io.WriteString(w, responseBody)
w.Header().Set("X-Server-Trailer", "server-trailer-value")
},
optQuiet,
)

tr := newTransport(t)

const (
workers = 8
iterations = 25
)

var wg sync.WaitGroup
for w := 0; w < workers; w++ {
wg.Add(1)
go func() {
defer wg.Done()
for i := 0; i < iterations; i++ {
body := strings.NewReader("client request body content")
req, err := http.NewRequest("POST", ts.URL, body)
if err != nil {
t.Errorf("NewRequest: %v", err)
return
}
req.Header.Set("X-Test-Header", "client-header-value")
req.Trailer = http.Header{
"X-Client-Trailer": []string{"client-trailer-value"},
}
res, err := tr.RoundTrip(req)
if err != nil {
t.Errorf("RoundTrip: %v", err)
return
}
_ = touchHeader(res.Header)
if _, err := io.Copy(io.Discard, res.Body); err != nil {
res.Body.Close()
t.Errorf("client body copy: %v", err)
return
}
if err := res.Body.Close(); err != nil {
t.Errorf("body close: %v", err)
return
}
_ = touchHeader(res.Trailer)
}
}()
}
wg.Wait()
}
Loading