Skip to content

Fix HTTP/2 stream receive window taken from client SETTINGS - #394

Open
lav45 wants to merge 1 commit into
amphp:3.xfrom
lav45:fix-http2-stream-receive-window
Open

lav45 wants to merge 1 commit into
amphp:3.xfrom
lav45:fix-http2-stream-receive-window

Conversation

@lav45

@lav45 lav45 commented Sep 15, 2026

Copy link
Copy Markdown

Problem

A client that advertises a larger SETTINGS_INITIAL_WINDOW_SIZE than the server stalls after
uploading 65535 bytes of request body on a stream. The server never sends a stream-level
WINDOW_UPDATE, so the client waits for flow-control credit that never comes.

This came up with Envoy as a client of a gRPC server built on this driver. Envoy advertises a
256 MiB stream window by default. On a long-lived bidirectional stream it sent
16384 × 3 + 8663 + 7720 = 65535 bytes (exactly the window the server advertised) and stopped.
Limiting Envoy's initial_stream_window_size to 65535 works around it.

Root cause

Http2Driver::createStream() initialises both flow-control windows of a new stream from
$this->initialWindowSize:

return $this->streams[$id] = new Http2Stream(
    $bodySizeLimit,
    $this->initialWindowSize,   // serverWindow: how much the client may still send
    $this->initialWindowSize,   // clientWindow: how much the server may send
    $flags,
);

handleSettings() overwrites $this->initialWindowSize with the client's
SETTINGS_INITIAL_WINDOW_SIZE. Per RFC 9113 §6.5.2, that setting is the sender's initial window
for receiving data. It therefore limits only what the server sends (clientWindow). The
server's receive window (serverWindow) has to start at the size the server advertised in its
own SETTINGS frame, which is DEFAULT_WINDOW_SIZE.

With a 256 MiB client window, serverWindow starts at 268435456. handleData() only requests
more credit once $stream->serverWindow <= MINIMUM_WINDOW, which in practice never happens.
The client, meanwhile, is correctly tracking the 65535-byte window the server advertised, so it
runs out of credit after 65535 bytes.

The existing tests miss this. testFlowControl does send a larger client window (66000), but
it only checks data sent by the server, not a request body uploaded by the client.

Fix

serverWindow now starts at self::DEFAULT_WINDOW_SIZE. This is the same constant the driver
writes into its SETTINGS frame, so the advertised window and the tracked window cannot drift
apart. clientWindow still starts at the client's advertised value. handleSettings() is
unchanged, because it already adjusts only the send windows of open streams.

golang.org/x/net/http2 handles this the same way. It keeps initialStreamSendWindowSize
(taken from the peer's SETTINGS) separate from initialStreamRecvWindowSize (what the server
advertises), and newStream() initialises st.flow from the former and st.inflow from the
latter.

Test

testRequestStreamWindowIsReplenishedWhenClientAdvertisesLargerInitialWindow:

  • advertises a 256 MiB client stream window;
  • opens a POST stream and uploads exactly the 65535 bytes the server advertised;
  • lets the request handler consume the body;
  • expects a WINDOW_UPDATE frame on that stream.

The test does not check the size of the increment, since that depends on how the driver decides
to refill the window. On 3.x it fails with "The server did not replenish the stream window the
client has used up". With the fix it passes, and so does the full suite.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

1 participant