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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +46 -0
- data/LICENSE-DEPENDENCIES.txt +2 -2
- data/README.md +76 -17
- data/ext/http3/extconf.rb +8 -5
- data/ext/http3/nghttp3.c +1 -1
- data/ext/http3/nghttp3.h +3 -1
- data/ext/http3/nghttp3_callbacks.c +8 -10
- data/ext/http3/nghttp3_connection.c +1 -1
- data/ext/http3/nghttp3_qpack.c +2 -2
- data/ext/http3/session.c +234 -9
- data/lib/http3/body_pipe.rb +160 -0
- data/lib/http3/body_stream.rb +120 -0
- data/lib/http3/rack_adapter.rb +114 -47
- data/lib/http3/request.rb +4 -2
- data/lib/http3/server.rb +402 -62
- data/lib/http3/session.rb +458 -89
- data/lib/http3/version.rb +1 -1
- data/lib/http3/worker_pool.rb +71 -0
- data/lib/http3.rb +3 -0
- data/sig/http3/body_pipe.rbs +45 -0
- data/sig/http3/body_stream.rbs +41 -0
- data/sig/http3/rack_adapter.rbs +21 -11
- data/sig/http3/request.rbs +5 -3
- data/sig/http3/response.rbs +2 -2
- data/sig/http3/server.rbs +8 -1
- data/sig/http3/session.rbs +104 -25
- data/sig/http3/worker_pool.rbs +19 -0
- metadata +9 -6
- data/.clang-format +0 -1
- data/.vscode/extensions.json +0 -6
- data/.vscode/settings.json +0 -10
|
@@ -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
|
data/lib/http3/rack_adapter.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
|
43
|
-
# 501, and a request the Rack env
|
|
44
|
-
#
|
|
45
|
-
#
|
|
46
|
-
# sent; the Rack body
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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" =>
|
|
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
|
|
155
|
-
# to_ary has closed it (body_for
|
|
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
|
-
|
|
190
|
+
if method == "HEAD" || status == 204 || status == 304
|
|
172
191
|
# No body is sent, so content-length is not held against one.
|
|
173
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
218
|
-
# or an Array, is held against length, the content-length,
|
|
219
|
-
# read with each, as
|
|
220
|
-
|
|
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:
|
|
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
|
-
|
|
244
|
-
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, "
|
|
279
|
+
raise InvalidResponse, "the body (#{body.class}) responds to neither each nor call"
|
|
247
280
|
end
|
|
248
281
|
end
|
|
249
282
|
|
|
250
|
-
#
|
|
251
|
-
#
|
|
252
|
-
#
|
|
253
|
-
#
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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
|
-
|
|
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.
|
|
8
|
-
|
|
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
|