Http2Driver::writeBufferedData() clears $stream->buffer after the suspending writeFrame() call. Any WINDOW_UPDATE that arrives while the fiber is suspended on the underlying socket write schedules EventLoop::defer($this->sendBufferedData(...)), which then re-enters writeBufferedData() for the same stream — and, finding the buffer still populated, re-emits the same DATA frame.
Net effect for HTTP/2 responses larger than a few KB over real (TLS) sockets: the response body is delivered N+1 times, where N is the number of WINDOW_UPDATE frames received during the original write.
Reproduction
A bare server returning a 9000-byte body and a curl --http2 request reliably yield 27000 bytes on the wire (3× duplication), while the same body over HTTP/1.1 (or with HTTP/2 disabled) is delivered exactly once. JSON parsers either fail with "Extra data" or silently parse the first object and discard the rest; binary downloads silently corrupt.
Clear $stream->buffer (and decrement $stream->clientWindow) before the suspending writeFrame() call. A re-entrant writeBufferedData() then sees an empty buffer and is a no-op (sendBufferedData() already skips streams whose buffer is empty).
The lower if ($delta > 0) branch already advances $stream->buffer = \substr($data, $delta) before its await and is therefore unaffected.
Test
Added testStreamBufferIsClearedBeforeSuspendingDataWriteFrame() to Http2DriverTest. Rather than racing the bug through real timing (which the fix turns into a no-op), it asserts the invariant directly: when writeFrame() calls writableStream->write() for a DATA frame, the corresponding stream's buffer must already be empty. The test:
fails on the unbuilt branch with "Stream buffer was still populated when writeFrame() was about to suspend";
passes after the patch.
Test plan
vendor/bin/phpunit — 126 tests, 488 assertions, all green
vendor/bin/psalm.phar — no errors
vendor/bin/php-cs-fixer fix --dry-run — clean
Live curl --http2 reproduction — body delivered exactly once after the patch
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Http2Driver::writeBufferedData() clears $stream->buffer after the suspending writeFrame() call. Any WINDOW_UPDATE that arrives while the fiber is suspended on the underlying socket write schedules EventLoop::defer($this->sendBufferedData(...)), which then re-enters writeBufferedData() for the same stream — and, finding the buffer still populated, re-emits the same DATA frame.
Net effect for HTTP/2 responses larger than a few KB over real (TLS) sockets: the response body is delivered N+1 times, where N is the number of WINDOW_UPDATE frames received during the original write.
Reproduction
A bare server returning a 9000-byte body and a curl --http2 request reliably yield 27000 bytes on the wire (3× duplication), while the same body over HTTP/1.1 (or with HTTP/2 disabled) is delivered exactly once. JSON parsers either fail with "Extra data" or silently parse the first object and discard the rest; binary downloads silently corrupt.
A reproducer is preserved at https://github.com/wtsergo/amphp-bugs (TLS server + curl driver, side-by-side h1/h2 byte counts).
Trace from an instrumented driver, single curl request with a 9 KB body:
Fix
Clear $stream->buffer (and decrement $stream->clientWindow) before the suspending writeFrame() call. A re-entrant writeBufferedData() then sees an empty buffer and is a no-op (sendBufferedData() already skips streams whose buffer is empty).
The lower if ($delta > 0) branch already advances $stream->buffer = \substr($data, $delta) before its await and is therefore unaffected.
Test
Added testStreamBufferIsClearedBeforeSuspendingDataWriteFrame() to Http2DriverTest. Rather than racing the bug through real timing (which the fix turns into a no-op), it asserts the invariant directly: when writeFrame() calls writableStream->write() for a DATA frame, the corresponding stream's buffer must already be empty. The test:
Test plan