Skip to content
Draft
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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
JVM could garbage-collect llhttp's native callback trampolines while a parser
was still in use, after which `llhttp_execute` succeeded without invoking any
callbacks. Callback procs are now retained for the lifetime of the parser.
- An IO request body is now rewound when writing it fails partway, not only
after it is sent in full. Previously a retried request (e.g. via
`.retriable` after a connection reset mid-upload) sent the body from where
the failed write stopped: fewer bytes than its `Content-Length`, so the
server waited for the rest until the read timed out.

## [6.0.4] - 2026-07-14

Expand Down
17 changes: 15 additions & 2 deletions lib/http/request/body.rb
Original file line number Diff line number Diff line change
Expand Up @@ -90,8 +90,7 @@ def each(&block)
if @source.is_a?(String)
yield @source
elsif @source.respond_to?(:read)
IO.copy_stream(@source, ProcIO.new(block))
rewind(@source)
copy_io(block)
elsif @source
@source.each(&block)
end
Expand All @@ -112,6 +111,20 @@ def ==(other)

private

# Stream an IO source to the block, then rewind it
#
# The rewind also runs when the block raises, so a retried request
# sends the whole body again instead of the unsent remainder.
#
# @param [Proc] block
# @return [void]
# @api private
def copy_io(block)
IO.copy_stream(@source, ProcIO.new(block))
ensure
rewind(@source)
end

# Rewind an IO source if possible
# @return [void]
# @api private
Expand Down
1 change: 1 addition & 0 deletions sig/http.rbs
Original file line number Diff line number Diff line change
Expand Up @@ -906,6 +906,7 @@ module HTTP

private

def copy_io: ((^(String) -> void)? block) -> void
def rewind: (untyped io) -> void
def validate_source_type!: () -> void

Expand Down
18 changes: 18 additions & 0 deletions test/http/request/body_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,24 @@ def test_each_when_body_is_an_enumerable_io_allows_multiple_enumerations
assert(results.all?(data))
end

def test_each_when_body_is_an_io_rewinds_it_when_a_chunk_fails_to_write
data = ("a" * 16 * 1024) + ("b" * 10 * 1024)
subject = build_body(StringIO.new(data))
written = 0

assert_raises(Errno::EPIPE) do
subject.each do |chunk|
raise Errno::EPIPE if written.positive?

written += chunk.bytesize
end
end

chunks = subject.enum_for(:each).map(&:dup)

assert_equal data, chunks.sum("")
end

def test_each_when_body_is_an_enumerable_yields_elements
chunks = build_body(%w[bees cows]).enum_for(:each).map(&:dup)

Expand Down
Loading