http3 0.0.4 → 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,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
@@ -5,7 +5,8 @@ require "uri"
5
5
 
6
6
  module HTTP3
7
7
  # Calls a Rack 3 application for an HTTP3::Request and turns what it
8
- # 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.
9
10
  class RackAdapter
10
11
  # The application's response breaks the Rack 3 specification; the
11
12
  # server answers 500 instead.
@@ -34,27 +35,41 @@ module HTTP3
34
35
  # specification).
35
36
  FORBIDDEN_IN_VALUE = /[\0\r\n]/
36
37
 
37
- 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:)
38
41
  @app = app
42
+ @pipe_capacity = pipe_capacity
43
+ @wake = wake
39
44
  @refusals = Hash.new(0)
45
+ @refusals_lock = Thread::Mutex.new
40
46
  end
41
47
 
42
- # remote is the client's address, an Addrinfo. CONNECT is answered with
43
- # 501, and a request the Rack env cannot carry with 400, without calling
44
- # the application; #take_refusals counts them. Raises whatever the
45
- # application raises, and InvalidResponse for a response that cannot be
46
- # 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.
47
62
  def call(request, remote:)
48
63
  # The server cannot open a tunnel (RFC 9114 section 4.4), and a
49
64
  # CONNECT request has neither :scheme nor :path, so the env would be
50
65
  # made up. The Session leaves nghttp3's enable_connect_protocol off,
51
66
  # so nghttp3 refuses the :protocol of an extended CONNECT (RFC 9220).
52
- return refuse(501) if request.headers.assoc(":method")&.last == "CONNECT"
67
+ return [refuse(501), nil] if request.headers.assoc(":method")&.last == "CONNECT"
53
68
 
54
69
  env = env_for(request, remote)
55
70
  rescue BadRequest
56
71
  # Not logged: the client sent it, the server did not fail.
57
- refuse(400)
72
+ [refuse(400), nil]
58
73
  else
59
74
  status, headers, body = @app.call(env)
60
75
  response_for(request, status, headers, body)
@@ -63,15 +78,17 @@ module HTTP3
63
78
  # The requests answered without the application since the last call,
64
79
  # as {status => count}; the count starts over.
65
80
  def take_refusals
66
- refusals = @refusals
67
- @refusals = Hash.new(0)
68
- refusals
81
+ @refusals_lock.synchronize do
82
+ refusals = @refusals
83
+ @refusals = Hash.new(0)
84
+ refusals
85
+ end
69
86
  end
70
87
 
71
88
  private
72
89
 
73
90
  def refuse(status)
74
- @refusals[status] += 1
91
+ @refusals_lock.synchronize { @refusals[status] += 1 }
75
92
  Response.new(status: status, headers: [], body: nil)
76
93
  end
77
94
 
@@ -125,7 +142,7 @@ module HTTP3
125
142
  "REMOTE_ADDR" => remote.ip_address,
126
143
  "HTTP_HOST" => authority,
127
144
  "rack.url_scheme" => scheme,
128
- "rack.input" => StringIO.new(request.body.b),
145
+ "rack.input" => request.input,
129
146
  "rack.errors" => $stderr
130
147
  }
131
148
  fields.each do |name, values|
@@ -151,10 +168,12 @@ module HTTP3
151
168
  end
152
169
  end
153
170
 
154
- # The Rack body is closed here unless it goes on in the Response, or
155
- # to_ary has closed it (body_for yields then).
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.
156
176
  def response_for(request, status, headers, body)
157
- response = nil
158
177
  status = Integer(status)
159
178
  # A 1xx is no final response, which is all HTTP/3 can send here (Rack
160
179
  # Lint only asks for 100 or more).
@@ -168,15 +187,21 @@ module HTTP3
168
187
  length = content_length(headers)
169
188
 
170
189
  method = request.headers.assoc(":method")&.last
171
- response = if method == "HEAD" || status == 204 || status == 304
190
+ if method == "HEAD" || status == 204 || status == 304
172
191
  # No body is sent, so content-length is not held against one.
173
- close_rack_body(body)
174
- Response.new(status: status, headers: headers, body: nil)
175
- else
176
- Response.new(status: status, headers: headers, body: body_for(body, length) { body = nil })
192
+ return [Response.new(status: status, headers: headers, body: nil), nil]
177
193
  end
194
+
195
+ sent, fill = body_for(body, length, request.input) { body = nil }
196
+ [Response.new(status: status, headers: headers, body: sent), fill]
178
197
  ensure
179
- 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
180
205
  end
181
206
 
182
207
  # The content-length among headers, as an Integer, or nil if there is
@@ -214,10 +239,12 @@ module HTTP3
214
239
  pairs
215
240
  end
216
241
 
217
- # The body to send. One whose length is known before it is sent, a file
218
- # or an Array, is held against length, the content-length, now; one
219
- # read with each, as it is read (#counted).
220
- def body_for(body, length)
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)
221
248
  if body.respond_to?(:to_path)
222
249
  io = ::File.open(body.to_path, "rb")
223
250
  if length && io.size != length
@@ -225,7 +252,7 @@ module HTTP3
225
252
  io.close
226
253
  raise length_mismatch(size)
227
254
  end
228
- Body::File.new(io: io, offset: 0, length: io.size, rack_body: body)
255
+ [Body::File.new(io: io, offset: 0, length: io.size, rack_body: nil), nil]
229
256
  elsif body.respond_to?(:to_ary)
230
257
  # Rack 3 has to_ary close the body itself.
231
258
  chunks = body.to_ary
@@ -238,32 +265,72 @@ module HTTP3
238
265
  size = chunks.sum(&:bytesize)
239
266
  raise length_mismatch(size) if length && size != length
240
267
 
241
- Body::Chunks.new(enum: chunks.each, rack_body: nil)
268
+ [Body::Chunks.new(enum: chunks.each, rack_body: nil), nil]
242
269
  elsif body.respond_to?(:each)
243
- enum = length ? counted(body, length) : body.to_enum(:each)
244
- Body::Chunks.new(enum: enum, 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) }]
245
278
  else
246
- raise InvalidResponse, "streaming bodies (#{body.class}) are not supported"
279
+ raise InvalidResponse, "the body (#{body.class}) responds to neither each nor call"
247
280
  end
248
281
  end
249
282
 
250
- # The chunks of body's each, raising InvalidResponse from next once they
251
- # pass length bytes, or end short of it. Session#fill_bodies aborts the
252
- # stream then, since the headers have gone. A chunk that is not a String
253
- # goes on to fill_bodies, which refuses it.
254
- def counted(body, length)
255
- Enumerator.new do |y|
256
- size = 0
257
- body.each do |chunk|
258
- size += chunk.bytesize if chunk.is_a?(String)
259
- if size > length
260
- raise InvalidResponse, "the body is at least #{size} bytes but content-length says otherwise"
261
- end
262
-
263
- y << chunk
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"
264
299
  end
265
- raise length_mismatch(size) if size < length
300
+
301
+ pipe.write(chunk)
266
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)
267
334
  end
268
335
 
269
336
  def length_mismatch(size)
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