Skip to content

Commit 6667b36

Browse files
committed
Implement IO#readpartial API for Connection, Body, and Inflater
Connection#readpartial now raises EOFError instead of returning nil at end-of-stream and accepts an optional outbuf parameter, conforming to Ruby's IO#readpartial contract. This enables IO.copy_stream and other IO-expecting interfaces to work directly with connection objects. Closes #618.
1 parent 67961da commit 6667b36

10 files changed

Lines changed: 106 additions & 53 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1818

1919
### Changed
2020

21+
- **BREAKING** `Connection#readpartial` now raises `EOFError` instead of
22+
returning `nil` at end-of-stream, and supports an `outbuf` parameter,
23+
conforming to the `IO#readpartial` API. `Body#readpartial` and
24+
`Inflater#readpartial` also raise `EOFError` (#618)
2125
- **BREAKING** Stricter timeout options parsing: `.timeout()` with a Hash now
2226
rejects unknown keys, non-numeric values, string keys, and empty hashes (#754)
2327
- Bumped min llhttp dependency version

lib/http/connection.rb

Lines changed: 13 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -104,23 +104,24 @@ def send_request(req)
104104
# @example
105105
# connection.readpartial
106106
#
107+
# @param [Integer] size maximum bytes to read
108+
# @param [String, nil] outbuf buffer to fill with data
107109
# @return [String] data chunk
108-
# @return [nil] when no more data left
110+
# @raise [EOFError] when no more data left
109111
# @api public
110-
def readpartial(size = BUFFER_SIZE)
111-
return unless @pending_response
112+
def readpartial(size = BUFFER_SIZE, outbuf = nil)
113+
raise EOFError unless @pending_response
112114

113115
chunk = @parser.read(size)
114-
return chunk if chunk
115-
116-
eof = read_more(size) == :eof
117-
check_premature_eof(eof)
118-
119-
finished = eof || @parser.finished?
120-
chunk = @parser.read(size)
121-
finish_response if finished
116+
unless chunk
117+
eof = read_more(size) == :eof
118+
check_premature_eof(eof)
119+
finished = eof || @parser.finished?
120+
chunk = @parser.read(size) || "".b
121+
finish_response if finished
122+
end
122123

123-
chunk || "".b
124+
outbuf ? outbuf.replace(chunk) : chunk
124125
end
125126

126127
# Reads data from socket up until headers are loaded
@@ -233,18 +234,6 @@ def check_premature_eof(eof)
233234
raise ConnectionError, "response body ended prematurely"
234235
end
235236

236-
# Check if the response body has a known framing mechanism
237-
#
238-
# @example
239-
# body_framed?
240-
#
241-
# @return [Boolean]
242-
# @api private
243-
def body_framed?
244-
@parser.headers.include?(Headers::TRANSFER_ENCODING) ||
245-
@parser.headers.include?(Headers::CONTENT_LENGTH)
246-
end
247-
248237
# Connect socket and set up proxy/TLS
249238
# @return [void]
250239
# @api private

lib/http/connection/internals.rb

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,18 @@ def set_keep_alive
7979
end
8080
end
8181

82+
# Check if the response body has a known framing mechanism
83+
#
84+
# @example
85+
# body_framed?
86+
#
87+
# @return [Boolean]
88+
# @api private
89+
def body_framed?
90+
@parser.headers.include?(Headers::TRANSFER_ENCODING) ||
91+
@parser.headers.include?(Headers::CONTENT_LENGTH)
92+
end
93+
8294
# Feeds some more data into parser
8395
# @return [void]
8496
# @raise [SocketReadError] when unable to read from socket

lib/http/response.rb

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,8 @@ def initialize(opts)
117117
# Read a chunk of the response body
118118
# @example
119119
# response.readpartial # => "chunk"
120-
# @return [String, nil]
120+
# @return [String]
121+
# @raise [EOFError] when no more data left
121122
# @api public
122123
def_delegator :@body, :readpartial
123124

lib/http/response/body.rb

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -44,13 +44,13 @@ def initialize(stream, encoding: Encoding::BINARY)
4444
# body.readpartial # => "chunk of data"
4545
#
4646
# (see HTTP::Client#readpartial)
47-
# @return [String, nil]
47+
# @return [String]
48+
# @raise [EOFError] when no more data left
4849
# @api public
4950
def readpartial(*)
5051
stream!
5152
chunk = @stream.readpartial(*)
52-
53-
String.new(chunk, encoding: @encoding) if chunk
53+
String.new(chunk, encoding: @encoding)
5454
end
5555

5656
# Iterate over the body, allowing it to be enumerable
@@ -63,9 +63,10 @@ def readpartial(*)
6363
# @return [void]
6464
# @api public
6565
def each
66-
while (chunk = readpartial)
67-
yield chunk
66+
loop do
67+
yield readpartial
6868
end
69+
rescue EOFError # rubocop:disable Lint/SuppressedException
6970
end
7071

7172
# Eagerly consume the entire body as a string
@@ -125,9 +126,10 @@ def inspect
125126
def read_contents
126127
contents = String.new("", encoding: @encoding)
127128

128-
while (chunk = @stream.readpartial)
129-
contents << String.new(chunk, encoding: @encoding)
130-
chunk = nil # deallocate string
129+
loop do
130+
contents << String.new(@stream.readpartial, encoding: @encoding)
131+
rescue EOFError
132+
break
131133
end
132134

133135
contents

lib/http/response/inflater.rb

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,18 +32,19 @@ def initialize(connection)
3232
# @example
3333
# inflater.readpartial # => "decompressed data"
3434
#
35-
# @return [String, nil]
35+
# @return [String]
36+
# @raise [EOFError] when no more data left
3637
# @api public
3738
def readpartial(*)
3839
chunk = @connection.readpartial(*)
39-
return zstream.inflate(chunk) if chunk
40-
40+
zstream.inflate(chunk)
41+
rescue EOFError
4142
unless zstream.closed?
4243
zstream.finish if zstream.total_in.positive?
4344
zstream.close
4445
end
4546

46-
nil
47+
raise
4748
end
4849

4950
private

sig/http.rbs

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -659,7 +659,7 @@ module HTTP
659659
def code: () -> Integer
660660
def to_s: () -> String
661661
alias to_str to_s
662-
def readpartial: (*untyped args) -> String?
662+
def readpartial: (*untyped args) -> String
663663
def connection: () -> untyped
664664
def uri: () -> URI
665665
def to_a: () -> Array[untyped]
@@ -724,7 +724,7 @@ module HTTP
724724
attr_reader connection: untyped
725725

726726
def initialize: (untyped stream, ?encoding: untyped) -> void
727-
def readpartial: (*untyped args) -> untyped
727+
def readpartial: (*untyped args) -> String
728728
def each: () { (String) -> void } -> void
729729
| () -> Enumerator[String, void]
730730
def to_s: () -> untyped
@@ -744,7 +744,7 @@ module HTTP
744744
attr_reader connection: untyped
745745

746746
def initialize: (untyped connection) -> void
747-
def readpartial: (*untyped args) -> String?
747+
def readpartial: (*untyped args) -> String
748748

749749
private
750750

@@ -821,7 +821,7 @@ module HTTP
821821
def initialize: (Request req, Options options) -> void
822822
def failed_proxy_connect?: () -> bool
823823
def send_request: (Request req) -> void
824-
def readpartial: (?Integer size) -> String?
824+
def readpartial: (?Integer size, ?String? outbuf) -> String
825825
def read_headers!: () -> void
826826
def finish_response: () -> void
827827
def close: () -> void
@@ -836,7 +836,6 @@ module HTTP
836836

837837
def init_state: (Options options) -> void
838838
def check_premature_eof: (bool eof) -> void
839-
def body_framed?: () -> bool
840839
def connect_socket: (Request req, Options options) -> void
841840
def start_tls: (untyped req, untyped options) -> void
842841
def send_proxy_connect_request: (untyped req) -> void
@@ -858,6 +857,7 @@ module HTTP
858857
def handle_proxy_connect_response: () -> void
859858
def reset_timer: () -> void
860859
def set_keep_alive: () -> void
860+
def body_framed?: () -> bool
861861
def read_more: (Integer size) -> untyped
862862
end
863863
end

test/http/connection_test.rb

Lines changed: 40 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -280,13 +280,50 @@
280280

281281
conn.read_headers!
282282
buffer = +""
283-
while (s = conn.readpartial(3))
284-
refute_predicate conn, :finished_request? if s != ""
285-
buffer << s
283+
begin
284+
loop do
285+
s = conn.readpartial(3)
286+
refute_predicate conn, :finished_request? if s != ""
287+
buffer << s
288+
end
289+
rescue EOFError
290+
# Expected — end of response
286291
end
287292

288293
assert_equal "1234567890", buffer
289294
assert_predicate conn, :finished_request?
290295
end
296+
297+
it "raises EOFError when no response is pending" do
298+
assert_raises(EOFError) { connection.readpartial }
299+
end
300+
301+
it "fills outbuf when provided" do
302+
call_count = 0
303+
responses = [
304+
"HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\nhello",
305+
:eof
306+
]
307+
ob_socket = fake(
308+
connect: nil,
309+
close: nil,
310+
readpartial: proc {
311+
idx = [call_count, responses.length - 1].min
312+
responses[idx].tap { call_count += 1 }
313+
},
314+
closed?: proc { call_count >= responses.length }
315+
)
316+
ob_timeout_class = fake(new: ob_socket)
317+
ob_opts = HTTP::Options.new(timeout_class: ob_timeout_class)
318+
conn = HTTP::Connection.new(req, ob_opts)
319+
conn.instance_variable_set(:@pending_response, true)
320+
321+
conn.read_headers!
322+
outbuf = +""
323+
result = conn.readpartial(16_384, outbuf)
324+
325+
assert_equal "hello", outbuf
326+
assert_same outbuf, result
327+
end
291328
end
292329
end

test/http/response/body_test.rb

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
let(:body) { HTTP::Response::Body.new(connection, encoding: Encoding::UTF_8) }
88

99
let(:connection) do
10-
fake(sequence_id: 0, readpartial: proc { chunks.shift }, body_completed?: proc {
10+
fake(sequence_id: 0, readpartial: proc { chunks.shift || raise(EOFError) }, body_completed?: proc {
1111
chunks.empty?
1212
})
1313
end
@@ -141,18 +141,25 @@
141141
it "streams decoded body" do
142142
assert_equal "Hi, HTTP ", body.readpartial
143143
assert_equal "here \u263A", body.readpartial
144-
assert_nil body.readpartial
144+
assert_raises(EOFError) { body.readpartial }
145145
end
146146
end
147147
end
148148

149-
context "when inflater receives nil chunk without prior data" do
150-
it "closes the zstream and handles subsequent nil" do
151-
conn = fake(sequence_id: 0, readpartial: proc {}, body_completed?: proc { true })
149+
context "when inflater receives EOFError without prior data" do
150+
it "closes the zstream and re-raises" do
151+
conn = fake(readpartial: proc { raise EOFError })
152152
inflater = HTTP::Response::Inflater.new(conn)
153-
inflater.readpartial
154153

155-
assert_nil inflater.readpartial
154+
assert_raises(EOFError) { inflater.readpartial }
155+
end
156+
157+
it "handles repeated EOFError after zstream is already closed" do
158+
conn = fake(readpartial: proc { raise EOFError })
159+
inflater = HTTP::Response::Inflater.new(conn)
160+
161+
assert_raises(EOFError) { inflater.readpartial }
162+
assert_raises(EOFError) { inflater.readpartial }
156163
end
157164
end
158165
end

test/http/response_test.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -357,7 +357,7 @@
357357
end
358358

359359
let(:connection) do
360-
fake(sequence_id: 0, readpartial: proc { chunks.shift }, body_completed?: proc {
360+
fake(sequence_id: 0, readpartial: proc { chunks.shift || raise(EOFError) }, body_completed?: proc {
361361
chunks.empty?
362362
})
363363
end

0 commit comments

Comments
 (0)