diff --git a/http2/transport.go b/http2/transport.go index 594516af..0034f416 100644 --- a/http2/transport.go +++ b/http2/transport.go @@ -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 @@ -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 @@ -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() } @@ -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() @@ -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 @@ -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() @@ -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() } } diff --git a/http2/transport_flow_test.go b/http2/transport_flow_test.go new file mode 100644 index 00000000..c7a928da --- /dev/null +++ b/http2/transport_flow_test.go @@ -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") + } +} diff --git a/http2/transport_test.go b/http2/transport_test.go index 8490d9d0..c1ce8980 100644 --- a/http2/transport_test.go +++ b/http2/transport_test.go @@ -2755,6 +2755,9 @@ func testTransportUsesGoAwayDebugError(t *testing.T, failMidBody bool) { func testTransportReturnsUnusedFlowControl(t *testing.T, oneDataFrame bool) { ct := newClientTester(t) + // Use a small connection window so the 5000-byte refund crosses the + // announce threshold (half the window) and shows up on the wire. + ct.tr.ConnectionFlow = 8000 clientClosed := make(chan struct{}) serverWroteFirstByte := make(chan struct{}) @@ -2811,11 +2814,13 @@ func testTransportReturnsUnusedFlowControl(t *testing.T, oneDataFrame bool) { // - Send one DATA frame with 5000 bytes. // - Send two DATA frames with 1 and 4999 bytes each. // - // In both cases, the client should consume one byte of data, - // refund that byte, then refund the following 4999 bytes. + // In both cases, the client should return all 5000 bytes of + // connection-level flow control. The first case returns the credit + // when the data is buffered; the second also returns the data received + // after the stream has been reset. // // In the second case, the server waits for the client connection to - // close before seconding the second DATA frame. This tests the case + // close before sending the second DATA frame. This tests the case // where the client receives a DATA frame after it has reset the stream. if oneDataFrame { ct.fr.WriteData(hf.StreamID, false /* don't end stream */, make([]byte, 5000)) @@ -2828,28 +2833,44 @@ func testTransportReturnsUnusedFlowControl(t *testing.T, oneDataFrame bool) { ct.fr.WriteData(hf.StreamID, false /* don't end stream */, make([]byte, 4999)) } - waitingFor := "RSTStreamFrame" - for { + var gotReset bool + var gotWindowUpdate uint32 + for !gotReset || gotWindowUpdate < 5000 { f, err := ct.fr.ReadFrame() if err != nil { - return fmt.Errorf("ReadFrame while waiting for %s: %v", waitingFor, err) + return fmt.Errorf("ReadFrame while waiting for flow-control cleanup: %v", err) } if _, ok := f.(*SettingsFrame); ok { continue } - switch waitingFor { - case "RSTStreamFrame": - if rf, ok := f.(*RSTStreamFrame); !ok || rf.ErrCode != ErrCodeCancel { - return fmt.Errorf("Expected a RSTStreamFrame with code cancel; got %v", summarizeFrame(f)) + switch f := f.(type) { + case *RSTStreamFrame: + if f.ErrCode != ErrCodeCancel { + return fmt.Errorf("expected a RSTStreamFrame with code cancel; got %v", summarizeFrame(f)) } - waitingFor = "WindowUpdateFrame" - case "WindowUpdateFrame": - if wuf, ok := f.(*WindowUpdateFrame); !ok || wuf.Increment != 4999 { - return fmt.Errorf("Expected WindowUpdateFrame for 4999 bytes; got %v", summarizeFrame(f)) + gotReset = true + case *WindowUpdateFrame: + if f.StreamID == 0 { + gotWindowUpdate += f.Increment } - return nil } } + if gotWindowUpdate != 5000 { + return fmt.Errorf("connection-level WINDOW_UPDATE credit = %d, want exactly 5000", gotWindowUpdate) + } + // The client must not refund the same bytes twice: drain until the + // read deadline and fail on any further connection-level credit. + ct.sc.SetReadDeadline(time.Now().Add(250 * time.Millisecond)) + for { + f, err := ct.fr.ReadFrame() + if err != nil { + break + } + if wu, ok := f.(*WindowUpdateFrame); ok && wu.StreamID == 0 { + return fmt.Errorf("unexpected extra connection-level WINDOW_UPDATE: %v", summarizeFrame(f)) + } + } + return nil } ct.run() } @@ -2941,6 +2962,9 @@ func TestTransportAdjustsFlowControl(t *testing.T) { // See golang.org/issue/16556 func TestTransportReturnsDataPaddingFlowControl(t *testing.T) { ct := newClientTester(t) + // Use a connection window small enough that the 6-byte padding refund + // crosses the announce threshold (half the window) immediately. + ct.tr.ConnectionFlow = 12 unblockClient := make(chan bool, 1)