http3 0.0.3 → 0.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,160 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HTTP3
4
+ # The stream a response was being sent on is gone: the client reset it or
5
+ # asked for no more of it (STOP_SENDING), the connection ended, the server
6
+ # stopped, or the response was aborted after waiting on its client for
7
+ # response_timeout. Body::Pipe's write, finish and flush raise it. It is
8
+ # an IOError, as a write to a closed socket raises one.
9
+ class StreamClosed < IOError
10
+ end
11
+
12
+ module Body
13
+ # A response body written by the thread that runs the application and
14
+ # taken by the server's I/O loop, which sends it. On the writer's side,
15
+ # #write waits while capacity bytes or more are queued, and #finish ends
16
+ # the body, #fail ends it with an exception, each waiting until all of
17
+ # it has been taken. On the I/O loop's side, #take never waits, and
18
+ # #cancel gives up on the body: from then on the writer's write, finish
19
+ # and flush raise StreamClosed, those waiting included, and fail
20
+ # returns false. Since a writer waits for the
21
+ # end of its body to be taken, the pipes holding chunks are no more than
22
+ # the writers.
23
+ #
24
+ # wake is called, with no lock held, each time the body goes from
25
+ # having nothing to take to having something: a chunk, its end, or the
26
+ # exception that ended it.
27
+ class Pipe
28
+ def initialize(capacity:, wake:)
29
+ @capacity = capacity
30
+ @wake = wake
31
+ @lock = Thread::Mutex.new
32
+ @changed = Thread::ConditionVariable.new
33
+ @chunks = []
34
+ @bytes = 0
35
+ @finished = false
36
+ @end_taken = false
37
+ @error = nil
38
+ @cancelled = false
39
+ end
40
+
41
+ # Queue chunk, a String, first waiting while capacity bytes or more are
42
+ # queued. A String that can still change is copied. Returns its size.
43
+ def write(chunk)
44
+ raise TypeError, "chunk must be a String (got #{chunk.class})" unless chunk.is_a?(String)
45
+
46
+ chunk = chunk.dup.freeze unless chunk.frozen?
47
+ woke = @lock.synchronize do
48
+ @changed.wait(@lock) while @bytes >= @capacity && !@cancelled
49
+ check_writable
50
+ next false if chunk.empty?
51
+
52
+ was_empty = @chunks.empty?
53
+ @chunks << chunk
54
+ @bytes += chunk.bytesize
55
+ was_empty
56
+ end
57
+ @wake.call if woke
58
+ chunk.bytesize
59
+ end
60
+
61
+ # End the body, and wait until the I/O loop has taken all of it, its
62
+ # end included.
63
+ def finish
64
+ woke = @lock.synchronize do
65
+ check_writable
66
+ @finished = true
67
+ @chunks.empty?
68
+ end
69
+ @wake.call if woke
70
+ @lock.synchronize do
71
+ wait_for_the_end
72
+ raise StreamClosed, "stream closed" unless @end_taken
73
+ end
74
+ nil
75
+ end
76
+
77
+ # End the body with error, for the I/O loop to abort the stream once
78
+ # it has taken what is queued, and wait until it has. Returns true
79
+ # once the I/O loop has taken error, and false when it never will:
80
+ # the body had ended or been given up on, and fail did nothing, or it
81
+ # was given up on while fail waited.
82
+ def fail(error)
83
+ woke = @lock.synchronize do
84
+ next nil if @cancelled || @finished || @error
85
+
86
+ @error = error
87
+ @chunks.empty?
88
+ end
89
+ return false if woke.nil?
90
+
91
+ @wake.call if woke
92
+ @lock.synchronize do
93
+ wait_for_the_end
94
+ @end_taken
95
+ end
96
+ end
97
+
98
+ # Wait until the I/O loop has taken every chunk queued.
99
+ def flush
100
+ @lock.synchronize do
101
+ @changed.wait(@lock) until @chunks.empty? || @cancelled
102
+ raise StreamClosed, "stream closed" if @cancelled
103
+ end
104
+ self
105
+ end
106
+
107
+ # The next of what the writer queued, without waiting: a chunk; once
108
+ # they have all been taken, :eof for a body that ended, or the
109
+ # exception given to #fail; nil while there is nothing yet, and after
110
+ # #cancel.
111
+ def take
112
+ @lock.synchronize do
113
+ return nil if @cancelled
114
+
115
+ if (chunk = @chunks.shift)
116
+ @bytes -= chunk.bytesize
117
+ @changed.broadcast
118
+ return chunk
119
+ end
120
+ return nil unless @finished || @error
121
+
122
+ @end_taken = true
123
+ @changed.broadcast
124
+ @error || :eof
125
+ end
126
+ end
127
+
128
+ # True when #take has something new to return: a chunk, or the end.
129
+ def ready?
130
+ @lock.synchronize do
131
+ !@cancelled && (!@chunks.empty? || ((@finished || !@error.nil?) && !@end_taken))
132
+ end
133
+ end
134
+
135
+ # Give up on the body: what is queued is dropped, and the writer's
136
+ # calls, those waiting included, raise StreamClosed.
137
+ def cancel
138
+ @lock.synchronize do
139
+ @cancelled = true
140
+ @chunks.clear
141
+ @bytes = 0
142
+ @changed.broadcast
143
+ end
144
+ nil
145
+ end
146
+
147
+ private
148
+
149
+ # With the lock held.
150
+ def wait_for_the_end
151
+ @changed.wait(@lock) until @end_taken || @cancelled
152
+ end
153
+
154
+ def check_writable
155
+ raise StreamClosed, "stream closed" if @cancelled
156
+ raise IOError, "body already ended" if @finished || @error
157
+ end
158
+ end
159
+ end
160
+ end
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HTTP3
4
+ module Body
5
+ # The stream a streaming body (Rack 3's call(stream)) is called with.
6
+ # What it writes goes to a Body::Pipe, and what it reads comes from the
7
+ # request body, which has arrived in full. Its methods behave as IO's
8
+ # do, as Rack 3 asks: write takes any objects and returns the bytes
9
+ # written, a write or flush after close_write raises IOError, as a read
10
+ # after close_read does, and the closes may be called again.
11
+ #
12
+ # Once the stream has gone away (the client reset it or sent
13
+ # STOP_SENDING, or it was aborted after response_timeout), write and
14
+ # flush raise StreamClosed, and so do close_write and close, once.
15
+ # Writing past length, the content-length, fails the pipe, so that the
16
+ # stream is aborted, and raises StreamClosed, so that the body stops
17
+ # there; ending short of it fails the pipe too.
18
+ class Stream
19
+ def initialize(pipe:, input:, length:)
20
+ @pipe = pipe
21
+ @input = input
22
+ @length = length
23
+ @size = 0
24
+ @failed = false
25
+ @read_closed = false
26
+ @write_closed = false
27
+ @lock = Thread::Mutex.new
28
+ end
29
+
30
+ # Read from the request body, as IO#read does.
31
+ def read(...)
32
+ raise IOError, "not opened for reading" if @read_closed
33
+
34
+ @input.read(...)
35
+ end
36
+
37
+ # Write each of objects, as a String (to_s), first waiting while the
38
+ # pipe is full. Returns the bytes written.
39
+ def write(*objects)
40
+ @lock.synchronize do
41
+ objects.sum do |object|
42
+ chunk = object.to_s
43
+ check_writable
44
+ count(chunk.bytesize)
45
+ @pipe.write(chunk)
46
+ end
47
+ end
48
+ end
49
+
50
+ def <<(object)
51
+ write(object)
52
+ self
53
+ end
54
+
55
+ # Wait until what has been written has been taken to be sent.
56
+ def flush
57
+ @lock.synchronize { check_writable }
58
+ @pipe.flush
59
+ self
60
+ end
61
+
62
+ # Stop reading: close the request body, unless it is closed already.
63
+ def close_read
64
+ @read_closed = true
65
+ @input.close unless @input.closed?
66
+ nil
67
+ end
68
+
69
+ # End the body, and wait until all of it has been taken to be sent;
70
+ # fail the pipe instead if it is short of length.
71
+ def close_write
72
+ @lock.synchronize do
73
+ next if @write_closed
74
+
75
+ @write_closed = true
76
+ next if @failed
77
+
78
+ if @length && @size != @length
79
+ @pipe.fail(length_mismatch("the body is #{@size} bytes"))
80
+ else
81
+ @pipe.finish
82
+ end
83
+ end
84
+ nil
85
+ end
86
+
87
+ def close
88
+ close_read
89
+ close_write
90
+ end
91
+
92
+ def closed?
93
+ @read_closed && @write_closed
94
+ end
95
+
96
+ private
97
+
98
+ # With the lock held.
99
+ def check_writable
100
+ raise IOError, "not opened for writing" if @write_closed
101
+ raise StreamClosed, "stream closed" if @failed
102
+ end
103
+
104
+ # With the lock held. Past length, the pipe fails and StreamClosed is
105
+ # raised.
106
+ def count(bytes)
107
+ @size += bytes
108
+ return unless @length && @size > @length
109
+
110
+ @failed = true
111
+ @pipe.fail(length_mismatch("the body is at least #{@size} bytes"))
112
+ raise StreamClosed, "stream closed"
113
+ end
114
+
115
+ def length_mismatch(what)
116
+ RackAdapter::InvalidResponse.new("#{what} but content-length says otherwise")
117
+ end
118
+ end
119
+ end
120
+ end
@@ -1,15 +1,29 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "stringio"
4
+ require "uri"
4
5
 
5
6
  module HTTP3
6
7
  # Calls a Rack 3 application for an HTTP3::Request and turns what it
7
- # returns into an HTTP3::Response.
8
+ # returns into an HTTP3::Response. Several threads may call it at once,
9
+ # each for a request of its own.
8
10
  class RackAdapter
9
11
  # The application's response breaks the Rack 3 specification; the
10
12
  # server answers 500 instead.
11
13
  class InvalidResponse < StandardError; end
12
14
 
15
+ # The request cannot be put in a Rack env; it is answered with 400.
16
+ class BadRequest < StandardError; end
17
+ private_constant :BadRequest
18
+
19
+ # The schemes a Rack env may carry (Rack also allows ws and wss, which
20
+ # HTTP/3 has no use for).
21
+ SCHEMES = %w[http https].freeze
22
+
23
+ # An authority, "host" or "host:port" (RFC 3986 section 3.2), as Rack
24
+ # Lint checks HTTP_HOST: the port is digits, of any number, or none.
25
+ AUTHORITY = /\A(?<host>#{URI::RFC3986_Parser::HOST})(?::(?<port>\d*))?\z/
26
+
13
27
  # Connection-specific fields, which HTTP/3 forbids (RFC 9114 section
14
28
  # 4.2).
15
29
  CONNECTION_FIELDS = %w[connection keep-alive transfer-encoding upgrade proxy-connection].freeze
@@ -21,20 +35,71 @@ module HTTP3
21
35
  # specification).
22
36
  FORBIDDEN_IN_VALUE = /[\0\r\n]/
23
37
 
24
- def initialize(app)
38
+ # A body read with each, and a streaming body, go through a Body::Pipe
39
+ # of pipe_capacity bytes, whose wake is wake.
40
+ def initialize(app, pipe_capacity:, wake:)
25
41
  @app = app
42
+ @pipe_capacity = pipe_capacity
43
+ @wake = wake
44
+ @refusals = Hash.new(0)
45
+ @refusals_lock = Thread::Mutex.new
26
46
  end
27
47
 
28
- # remote is the client's address, an Addrinfo. Raises whatever the
29
- # application raises, and InvalidResponse for a response that cannot be
30
- # sent; the Rack body is closed in both cases.
48
+ # Returns [response, fill]. remote is the client's address, an
49
+ # Addrinfo. CONNECT is answered with 501, and a request the Rack env
50
+ # cannot carry with 400, without calling the application;
51
+ # #take_refusals counts them. Raises whatever the application raises,
52
+ # and InvalidResponse for a response that cannot be sent; the Rack body
53
+ # is closed in both cases.
54
+ #
55
+ # fill is nil, the Rack body closed already, unless the response sends
56
+ # a body that is read with each or is a streaming body (call): its
57
+ # response has a Body::Pipe, and fill, called once the response has
58
+ # been handed over, writes the body to it and closes the Rack body.
59
+ # fill returns nil, or an exception the body raised that did not abort
60
+ # its stream, the body having ended or the server having given up on
61
+ # it, for the caller to log.
31
62
  def call(request, remote:)
32
- status, headers, body = @app.call(env_for(request, remote))
63
+ # The server cannot open a tunnel (RFC 9114 section 4.4), and a
64
+ # CONNECT request has neither :scheme nor :path, so the env would be
65
+ # made up. The Session leaves nghttp3's enable_connect_protocol off,
66
+ # so nghttp3 refuses the :protocol of an extended CONNECT (RFC 9220).
67
+ return [refuse(501), nil] if request.headers.assoc(":method")&.last == "CONNECT"
68
+
69
+ env = env_for(request, remote)
70
+ rescue BadRequest
71
+ # Not logged: the client sent it, the server did not fail.
72
+ [refuse(400), nil]
73
+ else
74
+ status, headers, body = @app.call(env)
33
75
  response_for(request, status, headers, body)
34
76
  end
35
77
 
78
+ # The requests answered without the application since the last call,
79
+ # as {status => count}; the count starts over.
80
+ def take_refusals
81
+ @refusals_lock.synchronize do
82
+ refusals = @refusals
83
+ @refusals = Hash.new(0)
84
+ refusals
85
+ end
86
+ end
87
+
36
88
  private
37
89
 
90
+ def refuse(status)
91
+ @refusals_lock.synchronize { @refusals[status] += 1 }
92
+ Response.new(status: status, headers: [], body: nil)
93
+ end
94
+
95
+ # Raises BadRequest for what Rack does not allow, or allows in a form
96
+ # the application might read differently from the client, in this
97
+ # order: a scheme other than http and https, a fragment in the path, an
98
+ # authority that is not one (RFC 3986 section 3.2), and an :authority
99
+ # and a host field that disagree (RFC 9114 section 4.3.1). nghttp3 has
100
+ # already refused a request without :scheme, :path, or both :authority
101
+ # and host (but CONNECT); the defaults are for calling the adapter
102
+ # directly.
38
103
  def env_for(request, remote)
39
104
  pseudo = {}
40
105
  fields = {}
@@ -46,23 +111,38 @@ module HTTP3
46
111
  end
47
112
  end
48
113
 
49
- path, query = pseudo.fetch(":path", "/").split("?", 2)
50
- scheme = pseudo.fetch(":scheme", "https")
51
- authority = pseudo[":authority"] || fields["host"]&.first || "localhost"
52
- server_name, server_port = split_authority(authority, scheme)
114
+ # nghttp3 takes either case of http and https; Rack wants them in
115
+ # lowercase.
116
+ scheme = pseudo.fetch(":scheme", "https").downcase
117
+ raise BadRequest, "scheme #{scheme} is not http or https" unless SCHEMES.include?(scheme)
118
+
119
+ # nghttp3 lets a fragment through; Rack forbids one.
120
+ target = pseudo.fetch(":path", "/")
121
+ raise BadRequest, "the path has a fragment" if target.include?("#")
122
+
123
+ path, query = target.split("?", 2)
124
+ host = fields["host"]&.first
125
+ authority = pseudo[":authority"] || host || "localhost"
126
+ match = AUTHORITY.match(authority) or raise BadRequest, "the authority is malformed"
127
+ if pseudo[":authority"] && host && host != pseudo[":authority"]
128
+ raise BadRequest, ":authority and host differ"
129
+ end
130
+
131
+ port = match[:port]
132
+ port = (scheme == "http") ? "80" : "443" if port.nil? || port.empty?
53
133
 
54
134
  env = {
55
135
  "REQUEST_METHOD" => pseudo.fetch(":method", "GET"),
56
136
  "SCRIPT_NAME" => "",
57
137
  "PATH_INFO" => path,
58
138
  "QUERY_STRING" => query || "",
59
- "SERVER_NAME" => server_name,
60
- "SERVER_PORT" => server_port,
139
+ "SERVER_NAME" => match[:host],
140
+ "SERVER_PORT" => port,
61
141
  "SERVER_PROTOCOL" => "HTTP/3",
62
142
  "REMOTE_ADDR" => remote.ip_address,
63
143
  "HTTP_HOST" => authority,
64
144
  "rack.url_scheme" => scheme,
65
- "rack.input" => StringIO.new(request.body.b),
145
+ "rack.input" => request.input,
66
146
  "rack.errors" => $stderr
67
147
  }
68
148
  fields.each do |name, values|
@@ -88,33 +168,51 @@ module HTTP3
88
168
  end
89
169
  end
90
170
 
91
- # "host", "host:port", "[v6]" or "[v6]:port"; the port defaults to the
92
- # scheme's.
93
- def split_authority(authority, scheme)
94
- default_port = (scheme == "http") ? "80" : "443"
95
- if (match = authority.match(/\A(\[[^\]]*\])(?::(\d+))?\z/))
96
- [match[1], match[2] || default_port]
97
- else
98
- host, port = authority.split(":", 2)
99
- [host, port || default_port]
100
- end
101
- end
102
-
103
- # The Rack body is closed here unless it goes on in the Response.
171
+ # [response, fill]. The Rack body is closed here, before anything is
172
+ # sent, unless fill goes on with it, or to_ary has closed it (body_for
173
+ # yields then). Rack 3 only says it is closed "typically after" the
174
+ # body has been sent, and the Body::File reads the file through a
175
+ # descriptor of its own.
104
176
  def response_for(request, status, headers, body)
105
- response = nil
106
177
  status = Integer(status)
178
+ # A 1xx is no final response, which is all HTTP/3 can send here (Rack
179
+ # Lint only asks for 100 or more).
180
+ unless (200..599).cover?(status)
181
+ raise InvalidResponse, "status #{status} is not a final status (200..599)"
182
+ end
107
183
  headers = header_pairs(headers)
184
+ # A 204 must not have one (RFC 9110 section 8.6); it is dropped as the
185
+ # connection-specific fields are.
186
+ headers = headers.reject { |pair| pair.first == "content-length" } if status == 204
187
+ length = content_length(headers)
108
188
 
109
189
  method = request.headers.assoc(":method")&.last
110
- response = if method == "HEAD" || status < 200 || status == 204 || status == 304
111
- close_rack_body(body)
112
- Response.new(status: status, headers: headers, body: nil)
113
- else
114
- Response.new(status: status, headers: headers, body: body_for(body))
190
+ if method == "HEAD" || status == 204 || status == 304
191
+ # No body is sent, so content-length is not held against one.
192
+ return [Response.new(status: status, headers: headers, body: nil), nil]
115
193
  end
194
+
195
+ sent, fill = body_for(body, length, request.input) { body = nil }
196
+ [Response.new(status: status, headers: headers, body: sent), fill]
116
197
  ensure
117
- close_rack_body(body) if response.nil?
198
+ begin
199
+ close_rack_body(body)
200
+ rescue
201
+ # The response goes nowhere: a 500 replaces it.
202
+ sent.io.close if sent.is_a?(Body::File)
203
+ raise
204
+ end
205
+ end
206
+
207
+ # The content-length among headers, as an Integer, or nil if there is
208
+ # none. The message leaves the value out, as header_pairs's do.
209
+ def content_length(headers)
210
+ values = headers.filter_map { |name, value| value if name == "content-length" }
211
+ return nil if values.empty?
212
+ raise InvalidResponse, "content-length is given more than once" if values.size > 1
213
+ raise InvalidResponse, "content-length is not a number" unless values.first.match?(/\A\d+\z/)
214
+
215
+ Integer(values.first, 10)
118
216
  end
119
217
 
120
218
  # Rack 3 headers: lowercase names, each value a String or an Array of
@@ -141,20 +239,104 @@ module HTTP3
141
239
  pairs
142
240
  end
143
241
 
144
- def body_for(body)
242
+ # [the body to send, fill]. One whose length is known before it is
243
+ # sent, a file or an Array, is held against length, the content-length,
244
+ # now; one read with each, and a streaming body, as fill writes it. A
245
+ # streaming body reads input, the request body. body_for yields when
246
+ # the Rack body is no longer for response_for to close.
247
+ def body_for(body, length, input)
145
248
  if body.respond_to?(:to_path)
146
249
  io = ::File.open(body.to_path, "rb")
147
- Body::File.new(io: io, offset: 0, length: io.size, rack_body: body)
250
+ if length && io.size != length
251
+ size = io.size
252
+ io.close
253
+ raise length_mismatch(size)
254
+ end
255
+ [Body::File.new(io: io, offset: 0, length: io.size, rack_body: nil), nil]
148
256
  elsif body.respond_to?(:to_ary)
149
257
  # Rack 3 has to_ary close the body itself.
150
- Body::Chunks.new(enum: body.to_ary.each, rack_body: nil)
258
+ chunks = body.to_ary
259
+ yield
260
+ # Checked before anything is sent, which Session#fill_bodies would
261
+ # only find out once the headers have gone.
262
+ chunks.each do |chunk|
263
+ raise InvalidResponse, "body chunk must be a String (got #{chunk.class})" unless chunk.is_a?(String)
264
+ end
265
+ size = chunks.sum(&:bytesize)
266
+ raise length_mismatch(size) if length && size != length
267
+
268
+ [Body::Chunks.new(enum: chunks.each, rack_body: nil), nil]
151
269
  elsif body.respond_to?(:each)
152
- Body::Chunks.new(enum: body.to_enum(:each), rack_body: body)
270
+ # Rack 3: a body with both each and call is read with each.
271
+ pipe = Body::Pipe.new(capacity: @pipe_capacity, wake: @wake)
272
+ yield
273
+ [pipe, -> { fill_each(body, pipe, length) }]
274
+ elsif body.respond_to?(:call)
275
+ pipe = Body::Pipe.new(capacity: @pipe_capacity, wake: @wake)
276
+ yield
277
+ [pipe, -> { fill_call(body, pipe, length, input) }]
153
278
  else
154
- raise InvalidResponse, "streaming bodies (#{body.class}) are not supported"
279
+ raise InvalidResponse, "the body (#{body.class}) responds to neither each nor call"
155
280
  end
156
281
  end
157
282
 
283
+ # Write the chunks of body's each to pipe, and end it. A chunk that is
284
+ # not a String, one that takes the body past length bytes, an end short
285
+ # of it, and an exception from each fail the pipe, and the stream is
286
+ # aborted then, since the headers have gone. A pipe the server has given
287
+ # up on (the client reset the stream, say) makes write raise
288
+ # StreamClosed, and the body stops there, without failing. Returns an
289
+ # exception the pipe did not take, or nil. The Rack body is closed in
290
+ # every case; an exception from its close is raised.
291
+ def fill_each(body, pipe, length)
292
+ size = 0
293
+ body.each do |chunk|
294
+ raise InvalidResponse, "body chunk must be a String (got #{chunk.class})" unless chunk.is_a?(String)
295
+
296
+ size += chunk.bytesize
297
+ if length && size > length
298
+ raise InvalidResponse, "the body is at least #{size} bytes but content-length says otherwise"
299
+ end
300
+
301
+ pipe.write(chunk)
302
+ end
303
+ raise length_mismatch(size) if length && size < length
304
+
305
+ pipe.finish
306
+ rescue StreamClosed
307
+ nil
308
+ rescue Exception => e # rubocop:disable Lint/RescueException
309
+ # Even exit, on a worker, ends only the body.
310
+ e unless pipe.fail(e)
311
+ ensure
312
+ close_rack_body(body)
313
+ end
314
+
315
+ # Call the streaming body with a Body::Stream that writes to pipe and
316
+ # reads input, and end the body once call returns, unless it has ended
317
+ # already: a write after that, from another thread, raises IOError. As
318
+ # with fill_each, the stream is aborted once the body goes past length
319
+ # bytes or ends short of it, or call raises, and it stops without
320
+ # failing once the server has given up on the pipe. Returns an
321
+ # exception the pipe did not take, such as one call raised after it
322
+ # closed the stream, or nil. The Rack body is closed in every case; an
323
+ # exception from its close is raised.
324
+ def fill_call(body, pipe, length, input)
325
+ stream = Body::Stream.new(pipe: pipe, input: input, length: length)
326
+ body.call(stream)
327
+ stream.close_write
328
+ rescue StreamClosed
329
+ nil
330
+ rescue Exception => e # rubocop:disable Lint/RescueException
331
+ e unless pipe.fail(e)
332
+ ensure
333
+ close_rack_body(body)
334
+ end
335
+
336
+ def length_mismatch(size)
337
+ InvalidResponse.new("the body is #{size} bytes but content-length says otherwise")
338
+ end
339
+
158
340
  def close_rack_body(body)
159
341
  body.close if body.respond_to?(:close)
160
342
  end
data/lib/http3/request.rb CHANGED
@@ -4,6 +4,8 @@ module HTTP3
4
4
  # A request as a Session put it together. headers is an Array of
5
5
  # [name, value] binary Strings with lowercase names, the pseudo-headers
6
6
  # (:method, :scheme, :authority, :path) first; a repeated name keeps one
7
- # pair per field. body is the request body, a binary String.
8
- Request = Data.define(:stream_id, :headers, :body)
7
+ # pair per field. input is the request body, a binary IO at its start,
8
+ # which becomes rack.input: a StringIO, or a temporary File once the body
9
+ # is past the Session's request_body_buffer_size.
10
+ Request = Data.define(:stream_id, :headers, :input)
9
11
  end