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
108 changes: 66 additions & 42 deletions http2/transport.go
Original file line number Diff line number Diff line change
Expand Up @@ -327,7 +327,8 @@ type ClientConn struct {
idleTimeout time.Duration // or 0 for never
idleTimer *time.Timer

inflow flow // peer's conn-level flow control
inflow flow // peer's conn-level flow control, as announced to the peer
unsentConnRefund int32 // conn-level credit not yet announced with a WINDOW_UPDATE; guarded by mu
initialWindowSize uint32

lastActive time.Time
Expand Down Expand Up @@ -2441,22 +2442,17 @@ func (b transportResponseBody) Read(p []byte) (n int, err error) {
cc.mu.Lock()
defer cc.mu.Unlock()

var connAdd, streamAdd int32

// Check the conn-level first, before the stream-level.
// Use dynamic connFlow logic
if v := cc.inflow.available(); v < int32(cc.connFlow/2) {
connAdd = int32(cc.connFlow) - v
cc.inflow.add(connAdd)
}
var streamAdd int32

if err == nil {
// Consider any buffered body data (read from the conn but not
// consumed by the client) when computing flow control for this
// stream.
// stream. Use the stream-only window: available() is capped by the
// connection window, which routinely sits below its full size while
// connection-level refunds are batched.

// Use dynamic streamFlow logic
unsent := int(cc.streamFlow) - int(cs.inflow.available()) + cs.bufPipe.Len()
unsent := int(cc.streamFlow) - int(cs.inflow.n) + cs.bufPipe.Len()

// ------------------------------------------------------------------
// FIX: Adaptive Logic
Expand All @@ -2483,15 +2479,10 @@ func (b transportResponseBody) Read(p []byte) (n int, err error) {
}
}

if connAdd != 0 || streamAdd != 0 {
if streamAdd != 0 {
cc.wmu.Lock()
defer cc.wmu.Unlock()
if connAdd != 0 {
cc.fr.WriteWindowUpdate(0, mustUint31(connAdd))
}
if streamAdd != 0 {
cc.fr.WriteWindowUpdate(cs.ID, mustUint31(streamAdd))
}
cc.fr.WriteWindowUpdate(cs.ID, mustUint31(streamAdd))
cc.bw.Flush()
}

Expand All @@ -2500,25 +2491,40 @@ func (b transportResponseBody) Read(p []byte) (n int, err error) {

var errClosedResponseBody = errors.New("http2: response body closed")

// refundConnFlow returns n bytes of connection-level flow control credit.
// Every byte taken from the connection window is refunded exactly once:
// padding and data for reset or unknown streams are refunded as the frame is
// processed, and response data is refunded once buffered in the stream's
// pipe. Refunds accumulate in cc.unsentConnRefund and are only announced to
// the peer once they reach half the connection window, matching the
// refresh-below-half cadence browsers use; cc.inflow tracks the window as
// announced. It returns the increment to send in a stream-0 WINDOW_UPDATE,
// or 0 if the refund was buffered.
//
// cc.mu must be held.
func (cc *ClientConn) refundConnFlow(n int32) int32 {
cc.unsentConnRefund += n
if cc.unsentConnRefund < int32(cc.connFlow/2) {
return 0
}
send := cc.unsentConnRefund
cc.unsentConnRefund = 0
cc.inflow.add(send)

return send
}

func (b transportResponseBody) Close() error {
cs := b.cs
cc := cs.cc

serverSentStreamEnd := cs.bufPipe.Err() == io.EOF
unread := cs.bufPipe.Len()

if unread > 0 || !serverSentStreamEnd {
if !serverSentStreamEnd {
cc.mu.Lock()
cc.wmu.Lock()
if !serverSentStreamEnd {
cc.fr.WriteRSTStream(cs.ID, ErrCodeCancel)
cs.didReset = true
}
// Return connection-level flow control.
if unread > 0 {
cc.inflow.add(int32(unread))
cc.fr.WriteWindowUpdate(0, uint32(unread))
}
cc.fr.WriteRSTStream(cs.ID, ErrCodeCancel)
cs.didReset = true
cc.bw.Flush()
cc.wmu.Unlock()
cc.mu.Unlock()
Expand Down Expand Up @@ -2554,13 +2560,15 @@ func (rl *clientConnReadLoop) processData(f *DataFrame) error {
// But at least return their flow control:
if f.Length > 0 {
cc.mu.Lock()
cc.inflow.add(int32(f.Length))
connAdd := cc.refundConnFlow(int32(f.Length))
cc.mu.Unlock()

cc.wmu.Lock()
cc.fr.WriteWindowUpdate(0, uint32(f.Length))
cc.bw.Flush()
cc.wmu.Unlock()
if connAdd > 0 {
cc.wmu.Lock()
cc.fr.WriteWindowUpdate(0, uint32(connAdd))
cc.bw.Flush()
cc.wmu.Unlock()
}
}

return nil
Expand Down Expand Up @@ -2609,15 +2617,19 @@ func (rl *clientConnReadLoop) processData(f *DataFrame) error {
refund += len(data)
}
if refund > 0 {
cc.inflow.add(int32(refund))
cc.wmu.Lock()
cc.fr.WriteWindowUpdate(0, uint32(refund))
if !didReset {
cs.inflow.add(int32(refund))
cc.fr.WriteWindowUpdate(cs.ID, uint32(refund))
connAdd := cc.refundConnFlow(int32(refund))
if connAdd > 0 || !didReset {
cc.wmu.Lock()
if connAdd > 0 {
cc.fr.WriteWindowUpdate(0, uint32(connAdd))
}
if !didReset {
cs.inflow.add(int32(refund))
cc.fr.WriteWindowUpdate(cs.ID, uint32(refund))
}
cc.bw.Flush()
cc.wmu.Unlock()
}
cc.bw.Flush()
cc.wmu.Unlock()
}
cc.mu.Unlock()

Expand All @@ -2627,6 +2639,18 @@ func (rl *clientConnReadLoop) processData(f *DataFrame) error {

return err
}

// Return connection-level flow control once the DATA is buffered.
// Stream-level flow control remains tied to body reads below.
cc.mu.Lock()
connAdd := cc.refundConnFlow(int32(len(data)))
if connAdd > 0 {
cc.wmu.Lock()
cc.fr.WriteWindowUpdate(0, uint32(connAdd))
cc.bw.Flush()
cc.wmu.Unlock()
}
cc.mu.Unlock()
}
}

Expand Down
165 changes: 165 additions & 0 deletions http2/transport_flow_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
// Copyright 2026 The fhttp 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

import (
"net"
"strconv"
"testing"
"time"

http "github.com/bogdanfinn/fhttp"
)

// TestTransportPausedBodiesDoNotExhaustConnectionWindow verifies that response
// bodies which stop being read do not prevent other streams from making
// progress once their DATA has been buffered.
//
// The three paused bodies need more connection credit than the window holds,
// so before connection-level credit was refunded at buffer time this test
// failed with "paused bodies did not fill their stream windows": buffering
// itself stalled once the connection window ran dry.
func TestTransportPausedBodiesDoNotExhaustConnectionWindow(t *testing.T) {
const (
streamWindow = 6 << 20
connWindow = 15663105
bodySize = streamWindow + 1<<20
)

ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer ln.Close()

serverDone := make(chan struct{})
go func() {
defer close(serverDone)
conn, err := ln.Accept()
if err != nil {
return
}
defer conn.Close()

(&Server{}).ServeConn(conn, &ServeConnOpts{
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Length", strconv.Itoa(bodySize))
w.WriteHeader(http.StatusOK)
chunk := make([]byte, 16<<10)
for written := 0; written < bodySize; written += len(chunk) {
if _, err := w.Write(chunk); err != nil {
return
}
}
}),
})
}()

conn, err := net.Dial("tcp", ln.Addr().String())
if err != nil {
t.Fatal(err)
}

tr := &Transport{
Settings: map[SettingID]uint32{
SettingInitialWindowSize: streamWindow,
},
SettingsOrder: []SettingID{SettingInitialWindowSize},
ConnectionFlow: connWindow,
PseudoHeaderOrder: []string{":method", ":authority", ":scheme", ":path"},
}
cc, err := tr.NewClientConn(conn)
if err != nil {
t.Fatal(err)
}
defer cc.Close()

responses := make([]*http.Response, 0, 4)
defer func() {
for _, resp := range responses {
resp.Body.Close()
}
}()

for i := 0; i < 3; i++ {
req, err := http.NewRequest(http.MethodGet, "https://example.test/paused/"+strconv.Itoa(i), nil)
if err != nil {
t.Fatal(err)
}
resp, err := cc.RoundTrip(req)
if err != nil {
t.Fatal(err)
}
responses = append(responses, resp)
}

deadline := time.Now().Add(5 * time.Second)
for {
cc.mu.Lock()
available := cc.inflow.available()
pending := cc.unsentConnRefund
streams := make([]*clientStream, 0, len(cc.streams))
for _, cs := range cc.streams {
streams = append(streams, cs)
}
cc.mu.Unlock()

allBuffered := len(streams) == 3
for _, cs := range streams {
if cs.bufPipe.Len() > streamWindow {
t.Fatalf("stream buffered %d bytes, more than its %d byte window", cs.bufPipe.Len(), streamWindow)
}
if cs.bufPipe.Len() < streamWindow {
allBuffered = false
break
}
}
if allBuffered {
// Refunds may still be batched in unsentConnRefund rather than
// added to the announced window; both count as returned credit.
if available+pending < connWindow {
t.Fatalf("paused bodies reduced connection window: available=%d pending=%d, want at least %d", available, pending, connWindow)
}
break
}
if time.Now().After(deadline) {
t.Fatalf("paused bodies did not fill their stream windows")
}
time.Sleep(10 * time.Millisecond)
}

req, err := http.NewRequest(http.MethodGet, "https://example.test/fourth", nil)
if err != nil {
t.Fatal(err)
}
fourth, err := cc.RoundTrip(req)
if err != nil {
t.Fatal(err)
}
responses = append(responses, fourth)

readDone := make(chan error, 1)
go func() {
buf := make([]byte, 1)
_, err := fourth.Body.Read(buf)
readDone <- err
}()

select {
case err := <-readDone:
if err != nil {
t.Fatalf("fourth response body read failed: %v", err)
}
case <-time.After(2 * time.Second):
t.Fatal("fourth response body remained blocked behind paused bodies")
}

cc.Close()
select {
case <-serverDone:
case <-time.After(2 * time.Second):
t.Fatal("server did not stop after client connection closed")
}
}
Loading