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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +92 -0
- data/LICENSE-DEPENDENCIES.txt +2 -2
- data/README.md +124 -8
- data/ext/http3/extconf.rb +8 -5
- data/ext/http3/nghttp3.c +20 -1
- data/ext/http3/nghttp3.h +38 -3
- data/ext/http3/nghttp3_callbacks.c +8 -10
- data/ext/http3/nghttp3_connection.c +378 -206
- data/ext/http3/nghttp3_nv.c +53 -20
- data/ext/http3/nghttp3_qpack.c +66 -22
- data/ext/http3/session.c +429 -55
- data/lib/http3/body_pipe.rb +160 -0
- data/lib/http3/body_stream.rb +120 -0
- data/lib/http3/rack_adapter.rb +220 -38
- data/lib/http3/request.rb +4 -2
- data/lib/http3/server.rb +551 -41
- data/lib/http3/session.rb +614 -66
- 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/nghttp3/connection.rbs +43 -9
- data/sig/http3/nghttp3/nv.rbs +4 -0
- data/sig/http3/nghttp3/qpack.rbs +10 -3
- data/sig/http3/rack_adapter.rbs +29 -4
- data/sig/http3/request.rbs +5 -3
- data/sig/http3/response.rbs +2 -2
- data/sig/http3/server.rbs +14 -1
- data/sig/http3/session.rbs +136 -13
- data/sig/http3/worker_pool.rbs +19 -0
- data/sig/http3.rbs +3 -0
- metadata +9 -6
- data/.clang-format +0 -1
- data/.vscode/extensions.json +0 -6
- data/.vscode/settings.json +0 -10
data/lib/http3/session.rb
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "stringio"
|
|
4
|
+
require "tempfile"
|
|
5
|
+
require "tmpdir"
|
|
6
|
+
|
|
3
7
|
module HTTP3
|
|
4
8
|
# HTTP/3 on one QUIC connection. The C part (ext/http3/session.c) runs
|
|
5
9
|
# nghttp3 inside the connection's packet I/O and calls the on_* methods
|
|
@@ -10,89 +14,356 @@ module HTTP3
|
|
|
10
14
|
# requests, answers them, keeps the bodies flowing and lets go of what
|
|
11
15
|
# the streams no longer need.
|
|
12
16
|
class Session
|
|
13
|
-
# A request still coming in
|
|
14
|
-
#
|
|
15
|
-
|
|
17
|
+
# A request still coming in; started is when its headers began
|
|
18
|
+
# (CLOCK_MONOTONIC). body is a StringIO, or a File once the body has
|
|
19
|
+
# gone past request_body_buffer_size (#spool); received is how many
|
|
20
|
+
# bytes of body have come.
|
|
21
|
+
Incoming = Struct.new(:pseudo, :fields, :body, :started, :received)
|
|
16
22
|
|
|
17
23
|
# A response whose body is being sent.
|
|
18
24
|
Outgoing = Struct.new(:request, :body, :done)
|
|
19
25
|
|
|
26
|
+
# What a field counts for besides its name and value, as RFC 9114
|
|
27
|
+
# section 4.2.2 counts it, and as the C part does for 431.
|
|
28
|
+
FIELD_OVERHEAD = 32
|
|
29
|
+
|
|
30
|
+
# What #next_chunk returns at the end of a body.
|
|
31
|
+
END_OF_BODY = Object.new.freeze
|
|
32
|
+
private_constant :END_OF_BODY
|
|
33
|
+
|
|
34
|
+
# Bytes the requests coming in may hold: a connection's, and through
|
|
35
|
+
# parent the server's. Only the server's I/O loop, one thread, calls
|
|
36
|
+
# it, so it takes no lock.
|
|
37
|
+
class Budget
|
|
38
|
+
attr_reader :limit, :used
|
|
39
|
+
|
|
40
|
+
def initialize(limit, parent: nil)
|
|
41
|
+
@limit = limit
|
|
42
|
+
@used = 0
|
|
43
|
+
@parent = parent
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Count n bytes and return true if they fit both here and in the
|
|
47
|
+
# parent; count nothing and return false if not.
|
|
48
|
+
def reserve(n)
|
|
49
|
+
return false if @used + n > @limit
|
|
50
|
+
return false if @parent && !@parent.reserve(n)
|
|
51
|
+
|
|
52
|
+
@used += n
|
|
53
|
+
true
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def release(n)
|
|
57
|
+
@used -= n
|
|
58
|
+
@parent&.release(n)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
private_constant :Budget
|
|
62
|
+
|
|
63
|
+
# The limits on what the requests of a connection, and through their
|
|
64
|
+
# parents the server's, hold in temporary files: bytes, and files open
|
|
65
|
+
# at once.
|
|
66
|
+
Spool = Struct.new(:bytes, :files)
|
|
67
|
+
private_constant :Spool
|
|
68
|
+
|
|
20
69
|
# Take over quic_conn's streams. A request body over max_request_body
|
|
21
70
|
# bytes is answered with 413, a header section over
|
|
22
|
-
# max_field_section_size bytes (RFC 9114 section 4.2.2) with 431
|
|
23
|
-
|
|
71
|
+
# max_field_section_size bytes (RFC 9114 section 4.2.2) with 431, as
|
|
72
|
+
# soon as the request passes the limit; the rest of it is not read.
|
|
73
|
+
#
|
|
74
|
+
# A request body is kept in memory up to request_body_buffer_size
|
|
75
|
+
# bytes; past that, all of it goes to a temporary file
|
|
76
|
+
# (Session.spool_file).
|
|
77
|
+
#
|
|
78
|
+
# The requests may hold max_connection_request_buffer bytes of memory in
|
|
79
|
+
# all, header fields counted as for 431 and bodies by their bytes, and
|
|
80
|
+
# those of every Session given the same shared_buffer
|
|
81
|
+
# (Session.shared_buffer) its limit. Their temporary files may hold
|
|
82
|
+
# max_connection_request_spool bytes, and be
|
|
83
|
+
# max_connection_request_spool_files many, and those of every Session
|
|
84
|
+
# given the same shared_spool (Session.shared_spool) its limits. A
|
|
85
|
+
# request counts until its stream closes, or, once handed out
|
|
86
|
+
# (#next_request), until #finish, though its stream or connection has
|
|
87
|
+
# gone. A request that would pass any of
|
|
88
|
+
# these limits, or whose file cannot be made or written, is answered
|
|
89
|
+
# with 503.
|
|
90
|
+
#
|
|
91
|
+
# A request not received in full within request_timeout seconds of the
|
|
92
|
+
# start of its headers is answered with 408, and one not handed out
|
|
93
|
+
# within request_timeout seconds of its end with 503
|
|
94
|
+
# (#expire_requests); nil is no limit.
|
|
95
|
+
#
|
|
96
|
+
# A response whose body has waited on its client for response_timeout
|
|
97
|
+
# seconds (#response_stalled_since) is aborted (#expire_responses): a
|
|
98
|
+
# limit on a stall, not on the whole response. nil is no limit. Whoever
|
|
99
|
+
# drives the Session calls #expire_requests and #expire_responses every
|
|
100
|
+
# turn, and waits no longer than #next_request_deadline and
|
|
101
|
+
# #next_response_deadline.
|
|
102
|
+
def self.attach(quic_conn, max_request_body:, max_field_section_size:,
|
|
103
|
+
max_connection_request_buffer: 4_194_304, shared_buffer: nil, request_timeout: nil,
|
|
104
|
+
request_body_buffer_size: 65_536, max_connection_request_spool: 16_777_216,
|
|
105
|
+
max_connection_request_spool_files: 16, shared_spool: nil, response_timeout: nil)
|
|
24
106
|
session = _attach(quic_conn, max_field_section_size)
|
|
25
|
-
|
|
107
|
+
spool = Spool.new(Budget.new(max_connection_request_spool, parent: shared_spool&.bytes),
|
|
108
|
+
Budget.new(max_connection_request_spool_files, parent: shared_spool&.files))
|
|
109
|
+
session.send(:setup, max_request_body, Budget.new(max_connection_request_buffer, parent: shared_buffer),
|
|
110
|
+
request_timeout, request_body_buffer_size, spool, response_timeout)
|
|
26
111
|
session
|
|
27
112
|
end
|
|
28
113
|
|
|
29
|
-
#
|
|
30
|
-
#
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
114
|
+
# A limit of limit bytes for the requests coming in on every Session
|
|
115
|
+
# given it as attach's shared_buffer.
|
|
116
|
+
def self.shared_buffer(limit)
|
|
117
|
+
Budget.new(limit)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Limits of limit bytes in temporary files, and of files open at once,
|
|
121
|
+
# for the requests on every Session given it as attach's shared_spool.
|
|
122
|
+
def self.shared_spool(limit, files:)
|
|
123
|
+
Spool.new(Budget.new(limit), Budget.new(files))
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# A temporary file in Dir.tmpdir for a request body, without a name and
|
|
127
|
+
# in binary mode. O_TMPFILE makes it without one; where the file system
|
|
128
|
+
# does not take that (overlayfs, NFS), a file made with a random name is
|
|
129
|
+
# unlinked at once. Raises SystemCallError when no file can be had.
|
|
130
|
+
def self.spool_file
|
|
131
|
+
dir = Dir.tmpdir
|
|
132
|
+
file = begin
|
|
133
|
+
File.open(dir, File::RDWR | File::TMPFILE, 0o600)
|
|
134
|
+
rescue Errno::EOPNOTSUPP, Errno::EISDIR, Errno::EINVAL
|
|
135
|
+
named = Tempfile.create(["http3-body-", ""], dir)
|
|
136
|
+
begin
|
|
137
|
+
File.unlink(named.path)
|
|
138
|
+
rescue SystemCallError
|
|
139
|
+
named.close
|
|
140
|
+
raise
|
|
141
|
+
end
|
|
142
|
+
named
|
|
143
|
+
end
|
|
144
|
+
# File::BINARY is 0 on Linux: only binmode makes the file ASCII-8BIT.
|
|
145
|
+
file.binmode
|
|
146
|
+
file
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# The requests refused since the last call, each as [request, status]:
|
|
150
|
+
# the status (408, 413, 431, 503) to answer it with directly. A request
|
|
151
|
+
# comes out as soon as it is refused, before the rest of it has arrived,
|
|
152
|
+
# with neither headers nor body.
|
|
153
|
+
def take_refusals
|
|
154
|
+
refused = @refused
|
|
155
|
+
@refused = []
|
|
156
|
+
refused
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# The next request received in full, for the application to answer, or
|
|
160
|
+
# nil. The Session no longer closes its input: the caller closes it once
|
|
161
|
+
# done with the request, its response body included, and then calls
|
|
162
|
+
# #finish. What the request holds, in memory and in its temporary file,
|
|
163
|
+
# counts for the limits until then, even after its stream has closed.
|
|
164
|
+
def next_request
|
|
165
|
+
request, = @ready.shift
|
|
166
|
+
return nil if request.nil?
|
|
167
|
+
|
|
168
|
+
@handed[request.stream_id] = true
|
|
169
|
+
request
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# The caller is done with the request on stream_id that #next_request
|
|
173
|
+
# handed out: what it held no longer counts for the limits. Its stream
|
|
174
|
+
# and its connection may have gone already. Calling it again does
|
|
175
|
+
# nothing.
|
|
176
|
+
def finish(stream_id)
|
|
177
|
+
@handed.delete(stream_id) or return
|
|
178
|
+
release_budget(stream_id)
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# True while the request on stream_id can still be answered: its stream
|
|
182
|
+
# is open, the client has neither reset it nor asked for no more of it
|
|
183
|
+
# (STOP_SENDING), it has not been refused, and it has not been answered.
|
|
184
|
+
def answerable?(stream_id)
|
|
185
|
+
@open.key?(stream_id) && !@reset[stream_id] && !@stopped[stream_id] && !@rejected[stream_id] &&
|
|
186
|
+
!@answered[stream_id]
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# The first exception a temporary file raised since the last call, or
|
|
190
|
+
# nil. The request it was for has been answered with 503.
|
|
191
|
+
def take_spool_error
|
|
192
|
+
error = @spool_error
|
|
193
|
+
@spool_error = nil
|
|
194
|
+
error
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# Refuse with 408 the requests still coming in whose request_timeout
|
|
198
|
+
# has passed at now (CLOCK_MONOTONIC), and with 503 those received in
|
|
199
|
+
# full that have waited that long for #next_request; #take_refusals
|
|
200
|
+
# hands them out.
|
|
201
|
+
def expire_requests(now)
|
|
202
|
+
return if @request_timeout.nil?
|
|
203
|
+
|
|
204
|
+
@incoming.each do |id, request|
|
|
205
|
+
next if @rejected[id] || request.started + @request_timeout > now
|
|
206
|
+
|
|
207
|
+
reject(id, request, 408)
|
|
208
|
+
end
|
|
209
|
+
# In the order they ended, so the first are the oldest.
|
|
210
|
+
while (ended = @ready.first&.last) && ended + @request_timeout <= now
|
|
211
|
+
request, = @ready.shift
|
|
212
|
+
refuse_waiting(request, 503)
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# When the next request still coming in, or waiting for #next_request,
|
|
217
|
+
# runs out of time (CLOCK_MONOTONIC), or nil.
|
|
218
|
+
def next_request_deadline
|
|
219
|
+
return nil if @request_timeout.nil?
|
|
220
|
+
|
|
221
|
+
started = @incoming.filter_map { |id, request| request.started unless @rejected[id] }.min
|
|
222
|
+
[started, @ready.first&.last].compact.min&.+(@request_timeout)
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# Abort, with H3_REQUEST_CANCELLED, the responses whose body has waited
|
|
226
|
+
# on its client (#response_stalled_since) response_timeout seconds or
|
|
227
|
+
# more at now (CLOCK_MONOTONIC): RFC 9114 section 4.1.1 has a server that
|
|
228
|
+
# abandons a response use that code. A Body::Pipe is cancelled, so that
|
|
229
|
+
# its writer's write, finish or flush raises StreamClosed; one busy
|
|
230
|
+
# elsewhere gets it from its next. The Rack body is closed as ever, once
|
|
231
|
+
# the stream closes (#release_closed). Returns how many were aborted.
|
|
232
|
+
def expire_responses(now)
|
|
233
|
+
return 0 if @response_timeout.nil?
|
|
234
|
+
|
|
235
|
+
expired = @outgoing.select do |id, _out|
|
|
236
|
+
since = response_stalled_since(id)
|
|
237
|
+
since && since + @response_timeout <= now
|
|
238
|
+
end
|
|
239
|
+
expired.each do |id, out|
|
|
240
|
+
abort_stream(id, Nghttp3::H3_REQUEST_CANCELLED)
|
|
241
|
+
out.done = true
|
|
242
|
+
out.body.cancel if out.body.is_a?(Body::Pipe)
|
|
243
|
+
end
|
|
244
|
+
expired.size
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# When the next response to wait on its client runs out of time
|
|
248
|
+
# (CLOCK_MONOTONIC), or nil.
|
|
249
|
+
def next_response_deadline
|
|
250
|
+
return nil if @response_timeout.nil?
|
|
251
|
+
|
|
252
|
+
@outgoing.each_key.filter_map { |id| response_stalled_since(id) }.min&.+(@response_timeout)
|
|
36
253
|
end
|
|
37
254
|
|
|
38
255
|
# Send response to request. Its body, if any, is kept until the stream
|
|
39
256
|
# closes, but the IO of a Body::File is closed at once: the C part reads
|
|
40
257
|
# the file through a descriptor of its own. When that descriptor cannot
|
|
41
258
|
# be had (EMFILE, say), the stream is aborted with H3_INTERNAL_ERROR and
|
|
42
|
-
# the block gets the exception.
|
|
259
|
+
# the block gets the exception. A request is answered once: nghttp3
|
|
260
|
+
# would send a second response after the first, so it raises
|
|
261
|
+
# Nghttp3::InvalidStateError.
|
|
43
262
|
def respond(request, response)
|
|
263
|
+
id = request.stream_id
|
|
264
|
+
raise Nghttp3::InvalidStateError, "stream #{id} has been answered" if @answered[id]
|
|
265
|
+
|
|
266
|
+
# Refused while it was still coming in (#reject keeps its Incoming).
|
|
267
|
+
stop_reading(id) if @rejected[id] && @incoming.key?(id)
|
|
44
268
|
headers = [[":status", response.status.to_s], *response.headers]
|
|
45
269
|
body = response.body
|
|
46
|
-
submit_response(
|
|
270
|
+
submit_response(id, headers, !body.nil?)
|
|
271
|
+
@answered[id] = true
|
|
47
272
|
return if body.nil?
|
|
48
273
|
|
|
49
|
-
|
|
274
|
+
# Only the pseudo-headers stay, for the log, while the body is sent:
|
|
275
|
+
# the request body would otherwise be held all along, uncounted.
|
|
276
|
+
pseudo = request.headers.select { |name, _| name.start_with?(":") }
|
|
277
|
+
@outgoing[id] = Outgoing.new(Request.new(stream_id: id, headers: pseudo, input: empty_input), body, false)
|
|
50
278
|
return unless body.is_a?(Body::File)
|
|
51
279
|
|
|
52
280
|
begin
|
|
53
|
-
attach_file(
|
|
281
|
+
attach_file(id, body.io, body.offset, body.length)
|
|
54
282
|
rescue SystemCallError => e
|
|
55
|
-
abort_stream(
|
|
283
|
+
abort_stream(id, Nghttp3::H3_INTERNAL_ERROR)
|
|
56
284
|
yield e if block_given?
|
|
57
285
|
ensure
|
|
58
286
|
body.io.close
|
|
59
287
|
end
|
|
60
288
|
end
|
|
61
289
|
|
|
62
|
-
# Queue more of each Body::Chunks body until limit bytes
|
|
63
|
-
#
|
|
64
|
-
#
|
|
65
|
-
#
|
|
66
|
-
|
|
290
|
+
# Queue more of each Body::Chunks and Body::Pipe body until limit bytes
|
|
291
|
+
# wait unsent on its stream, connection_limit on the whole connection
|
|
292
|
+
# (#unsent_total), and room more bytes have been queued; nil is no
|
|
293
|
+
# limit. A chunk may take any of them past its limit, by less than its
|
|
294
|
+
# size. Returns the bytes queued.
|
|
295
|
+
#
|
|
296
|
+
# A body whose enum raises, or whose Pipe failed, is the application
|
|
297
|
+
# failing after its headers went out: the stream is aborted with
|
|
298
|
+
# H3_INTERNAL_ERROR and the block gets (request, exception). That
|
|
299
|
+
# request has only the pseudo-headers, and no body.
|
|
300
|
+
def fill_bodies(limit, connection_limit: nil, room: nil)
|
|
301
|
+
left = [room, connection_limit && connection_limit - unsent_total].compact.min
|
|
302
|
+
queued = 0
|
|
67
303
|
@outgoing.each_value do |out|
|
|
68
|
-
next if out.done || !out.body
|
|
304
|
+
next if out.done || !generated?(out.body)
|
|
305
|
+
break if left && queued >= left
|
|
69
306
|
|
|
70
|
-
fill_body(out, limit) { |error| yield out.request, error if block_given? }
|
|
307
|
+
queued += fill_body(out, limit, left && left - queued) { |error| yield out.request, error if block_given? }
|
|
71
308
|
end
|
|
309
|
+
queued
|
|
72
310
|
end
|
|
73
311
|
|
|
74
|
-
# True when a Body::Chunks body
|
|
75
|
-
|
|
312
|
+
# True when a Body::Chunks body, or a Body::Pipe with something to take,
|
|
313
|
+
# could take more right now, with the limits of #fill_bodies.
|
|
314
|
+
def wants_fill?(limit, connection_limit: nil, room: nil)
|
|
315
|
+
return false if room && room <= 0
|
|
316
|
+
return false if connection_limit && unsent_total >= connection_limit
|
|
317
|
+
|
|
76
318
|
@outgoing.any? do |id, out|
|
|
77
|
-
|
|
319
|
+
next false if out.done || !generated?(out.body)
|
|
320
|
+
next false if out.body.is_a?(Body::Pipe) && !out.body.ready?
|
|
321
|
+
|
|
322
|
+
unsent_bytes(id) < limit
|
|
78
323
|
end
|
|
79
324
|
end
|
|
80
325
|
|
|
81
|
-
#
|
|
82
|
-
#
|
|
326
|
+
# Bytes of the Body::Chunks and Body::Pipe bodies queued but not taken
|
|
327
|
+
# by nghttp3 yet, on every stream; a body that has ended may still have
|
|
328
|
+
# some.
|
|
329
|
+
def unsent_total
|
|
330
|
+
@outgoing.sum { |id, out| generated?(out.body) ? unsent_bytes(id) : 0 }
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
# True while a request stream is open: received in part, waiting to be
|
|
334
|
+
# handed out or answered, or with a response still being sent.
|
|
83
335
|
def busy?
|
|
84
|
-
!@open.empty?
|
|
336
|
+
!@open.empty? || !@ready.empty?
|
|
85
337
|
end
|
|
86
338
|
|
|
87
|
-
# Let go of the streams nghttp3 closed: their requests are dropped
|
|
88
|
-
#
|
|
89
|
-
#
|
|
339
|
+
# Let go of the streams nghttp3 closed: their requests are dropped, the
|
|
340
|
+
# inputs of those not handed out closed, and their bodies closed (a
|
|
341
|
+
# Body::Pipe cancelled). What a request handed out holds still counts,
|
|
342
|
+
# until #finish. The block gets (stream_id, exception) for a body whose
|
|
343
|
+
# close raised. First, the requests the client reset before their end
|
|
344
|
+
# (#on_reset) get our side of their streams reset too, with
|
|
345
|
+
# H3_REQUEST_INCOMPLETE as RFC 9114 section 4.1 says, so that the
|
|
346
|
+
# streams close; those it reset once they had come in full, waiting to
|
|
347
|
+
# be handed out or handed out and not answered, with
|
|
348
|
+
# H3_REQUEST_CANCELLED.
|
|
90
349
|
def release_closed(&block)
|
|
350
|
+
@incomplete.each { |id| abort_stream(id, Nghttp3::H3_REQUEST_INCOMPLETE) }
|
|
351
|
+
@incomplete.clear
|
|
352
|
+
@cancelled.each { |id| abort_stream(id, Nghttp3::H3_REQUEST_CANCELLED) }
|
|
353
|
+
@cancelled.clear
|
|
91
354
|
take_closed.each { |id| release(id, &block) }
|
|
92
355
|
end
|
|
93
356
|
|
|
94
|
-
# The connection is gone: close every body still held.
|
|
357
|
+
# The connection is gone: close every body still held. First the C part
|
|
358
|
+
# lets go of the nghttp3 connection and, of each body, of what nghttp3
|
|
359
|
+
# has not taken, a file body's descriptor included, rather than when the
|
|
360
|
+
# Session is garbage collected. From then on #submit_response and
|
|
361
|
+
# #stop_reading raise Nghttp3::InvalidStateError, as on a failed
|
|
362
|
+
# connection, and #push_chunk, #push_eof and #attach_file as on an
|
|
363
|
+
# aborted stream. What the requests handed out hold still counts, until
|
|
364
|
+
# #finish, since the caller still has them.
|
|
95
365
|
def release_all(&block)
|
|
366
|
+
release_connection
|
|
96
367
|
(@open.keys | @outgoing.keys).each { |id| release(id, &block) }
|
|
97
368
|
end
|
|
98
369
|
|
|
@@ -100,17 +371,22 @@ module HTTP3
|
|
|
100
371
|
|
|
101
372
|
def on_begin_headers(id)
|
|
102
373
|
@open[id] = true
|
|
103
|
-
@incoming[id] = Incoming.new([], [],
|
|
374
|
+
@incoming[id] = Incoming.new([], [], empty_input, Process.clock_gettime(Process::CLOCK_MONOTONIC), 0)
|
|
104
375
|
end
|
|
105
376
|
|
|
106
377
|
def on_header(id, name, value)
|
|
107
378
|
request = @incoming[id] or return
|
|
379
|
+
return if @rejected[id]
|
|
380
|
+
return reject(id, request, 503) unless hold(id, name.bytesize + value.bytesize + FIELD_OVERHEAD)
|
|
381
|
+
|
|
108
382
|
(name.start_with?(":") ? request.pseudo : request.fields) << [name, value]
|
|
109
383
|
end
|
|
110
384
|
|
|
111
385
|
def on_fields_too_large(id)
|
|
112
386
|
request = @incoming[id] or return
|
|
113
|
-
|
|
387
|
+
return if @rejected[id]
|
|
388
|
+
|
|
389
|
+
reject(id, request, 431)
|
|
114
390
|
end
|
|
115
391
|
|
|
116
392
|
def on_end_headers(id)
|
|
@@ -118,65 +394,331 @@ module HTTP3
|
|
|
118
394
|
|
|
119
395
|
def on_data(id, bytes)
|
|
120
396
|
request = @incoming[id] or return
|
|
121
|
-
return if
|
|
397
|
+
return if @rejected[id]
|
|
122
398
|
|
|
123
|
-
if request.
|
|
124
|
-
|
|
125
|
-
|
|
399
|
+
if request.received + bytes.bytesize > @max_request_body
|
|
400
|
+
reject(id, request, 413)
|
|
401
|
+
elsif @spool_files.key?(id) || request.received + bytes.bytesize > @request_body_buffer_size
|
|
402
|
+
spool(id, request, bytes)
|
|
403
|
+
elsif !hold(id, bytes.bytesize)
|
|
404
|
+
reject(id, request, 503)
|
|
126
405
|
else
|
|
127
|
-
request.body
|
|
406
|
+
request.body.write(bytes)
|
|
407
|
+
request.received += bytes.bytesize
|
|
128
408
|
end
|
|
129
409
|
end
|
|
130
410
|
|
|
131
411
|
def on_end_stream(id)
|
|
132
|
-
|
|
412
|
+
return if @rejected[id]
|
|
413
|
+
|
|
414
|
+
request = @incoming[id] or return
|
|
415
|
+
input = request.body
|
|
416
|
+
begin
|
|
417
|
+
input.flush
|
|
418
|
+
input.rewind
|
|
419
|
+
rescue => e
|
|
420
|
+
@spool_error ||= e
|
|
421
|
+
return reject(id, request, 503)
|
|
422
|
+
end
|
|
423
|
+
@incoming.delete(id)
|
|
133
424
|
headers = request.pseudo + request.fields
|
|
134
|
-
@
|
|
425
|
+
@ready << [Request.new(stream_id: id, headers: headers, input: input),
|
|
426
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)]
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
# The client reset its side of the stream, and the request is no longer
|
|
430
|
+
# #answerable?. A request it had not finished is dropped with what it
|
|
431
|
+
# held, and #release_closed resets our side with H3_REQUEST_INCOMPLETE;
|
|
432
|
+
# one waiting to be handed out likewise, with H3_REQUEST_CANCELLED, so
|
|
433
|
+
# that nobody answers what nobody reads. One handed out and not
|
|
434
|
+
# answered yet has our side reset with H3_REQUEST_CANCELLED too, so that
|
|
435
|
+
# its stream closes without waiting for the application, whose response
|
|
436
|
+
# is dropped. One answered goes on: the client may still read the
|
|
437
|
+
# response. One refused is answered as it would have been. Inside a
|
|
438
|
+
# callback, so it calls no C.
|
|
439
|
+
def on_reset(id)
|
|
440
|
+
@reset[id] = true if @open.key?(id)
|
|
441
|
+
if (waiting = take_waiting(id))
|
|
442
|
+
close_input(waiting.input)
|
|
443
|
+
release_budget(id)
|
|
444
|
+
@cancelled << id
|
|
445
|
+
return
|
|
446
|
+
end
|
|
447
|
+
if @handed.key?(id) && !@answered[id]
|
|
448
|
+
@cancelled << id
|
|
449
|
+
return
|
|
450
|
+
end
|
|
451
|
+
return unless @incoming.key?(id) && !@rejected[id]
|
|
452
|
+
|
|
453
|
+
request = @incoming.delete(id)
|
|
454
|
+
close_input(request.body)
|
|
455
|
+
release_budget(id)
|
|
456
|
+
@incomplete << id
|
|
457
|
+
end
|
|
458
|
+
|
|
459
|
+
# The client asked for no more of the response (STOP_SENDING). The C
|
|
460
|
+
# part has stopped sending it and reading the rest of the request, and
|
|
461
|
+
# the request is no longer #answerable?: one waiting to be handed out, or
|
|
462
|
+
# still coming in, is dropped with what it held, and one refused is not
|
|
463
|
+
# answered. A response being sent stops: a Body::Pipe is cancelled, so
|
|
464
|
+
# that its writer gets StreamClosed. Inside a callback, so it calls no C.
|
|
465
|
+
def on_stop_sending(id)
|
|
466
|
+
return unless @open.key?(id)
|
|
467
|
+
|
|
468
|
+
@stopped[id] = true
|
|
469
|
+
@refused.reject! { |request, _| request.stream_id == id }
|
|
470
|
+
if (out = @outgoing[id])
|
|
471
|
+
out.done = true
|
|
472
|
+
out.body.cancel if out.body.is_a?(Body::Pipe)
|
|
473
|
+
end
|
|
474
|
+
if (waiting = take_waiting(id))
|
|
475
|
+
close_input(waiting.input)
|
|
476
|
+
release_budget(id)
|
|
477
|
+
elsif (request = @incoming[id]) && !@rejected[id]
|
|
478
|
+
@incoming.delete(id)
|
|
479
|
+
close_input(request.body)
|
|
480
|
+
release_budget(id)
|
|
481
|
+
end
|
|
135
482
|
end
|
|
136
483
|
|
|
137
484
|
private
|
|
138
485
|
|
|
139
|
-
def setup(max_request_body)
|
|
486
|
+
def setup(max_request_body, budget, request_timeout, request_body_buffer_size, spool, response_timeout)
|
|
140
487
|
@max_request_body = max_request_body
|
|
488
|
+
@budget = budget
|
|
489
|
+
@request_timeout = request_timeout
|
|
490
|
+
@response_timeout = response_timeout
|
|
491
|
+
@request_body_buffer_size = request_body_buffer_size
|
|
492
|
+
@spool = spool
|
|
493
|
+
@held = {}
|
|
494
|
+
# stream_id => bytes of the request's body counted in @spool.bytes,
|
|
495
|
+
# until its file is closed
|
|
496
|
+
@spooled = {}
|
|
497
|
+
# stream_id => true for a request whose file counts in @spool.files
|
|
498
|
+
@spool_files = {}
|
|
499
|
+
@spool_error = nil
|
|
141
500
|
@incoming = {}
|
|
142
|
-
|
|
501
|
+
# [request, status] of the requests refused, until #take_refusals
|
|
502
|
+
@refused = []
|
|
503
|
+
# [request, when it ended (CLOCK_MONOTONIC)] of the requests received
|
|
504
|
+
# in full, until #next_request
|
|
505
|
+
@ready = []
|
|
506
|
+
# stream_id => true for a request handed out, until #finish
|
|
507
|
+
@handed = {}
|
|
143
508
|
@outgoing = {}
|
|
144
509
|
@open = {}
|
|
510
|
+
# stream_id => true for a stream the client has reset
|
|
511
|
+
@reset = {}
|
|
512
|
+
# stream_id => true for a stream the client asked for no more of
|
|
513
|
+
# (STOP_SENDING)
|
|
514
|
+
@stopped = {}
|
|
515
|
+
@rejected = {}
|
|
516
|
+
@answered = {}
|
|
517
|
+
@incomplete = []
|
|
518
|
+
# The requests the client reset once they had come in full, while they
|
|
519
|
+
# waited to be handed out or before they were answered
|
|
520
|
+
@cancelled = []
|
|
145
521
|
end
|
|
146
522
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
523
|
+
# Count n more bytes for the request on id, if the budget has room.
|
|
524
|
+
def hold(id, n)
|
|
525
|
+
return false unless @budget.reserve(n)
|
|
526
|
+
|
|
527
|
+
@held[id] = @held.fetch(id, 0) + n
|
|
528
|
+
true
|
|
529
|
+
end
|
|
530
|
+
|
|
531
|
+
# The request on id holds nothing any more. Calling it again does
|
|
532
|
+
# nothing.
|
|
533
|
+
def release_held(id)
|
|
534
|
+
n = @held.delete(id) or return
|
|
535
|
+
@budget.release(n)
|
|
536
|
+
end
|
|
537
|
+
|
|
538
|
+
# Write bytes to the temporary file of the request on id, making it
|
|
539
|
+
# first if it has none: what the request has in memory goes to the
|
|
540
|
+
# file, and from the memory budget to the spool's. The request is
|
|
541
|
+
# answered with 503 if the spool limits have no room for it, or if its
|
|
542
|
+
# file cannot be made or written; the exception then waits for
|
|
543
|
+
# #take_spool_error. Inside a callback, so it lets nothing out.
|
|
544
|
+
def spool(id, request, bytes)
|
|
545
|
+
unless @spool_files.key?(id)
|
|
546
|
+
return reject(id, request, 503) unless @spool.files.reserve(1)
|
|
547
|
+
|
|
548
|
+
@spool_files[id] = true
|
|
549
|
+
in_memory = request.received
|
|
550
|
+
return reject(id, request, 503) unless reserve_spooled(id, in_memory)
|
|
551
|
+
|
|
552
|
+
file = open_spool
|
|
150
553
|
begin
|
|
151
|
-
|
|
152
|
-
rescue
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
return
|
|
156
|
-
rescue => e
|
|
157
|
-
out.done = true
|
|
158
|
-
abort_stream(id, Nghttp3::H3_INTERNAL_ERROR)
|
|
159
|
-
yield e
|
|
160
|
-
return
|
|
554
|
+
file.write(request.body.string)
|
|
555
|
+
rescue
|
|
556
|
+
close_input(file)
|
|
557
|
+
raise
|
|
161
558
|
end
|
|
559
|
+
request.body = file
|
|
560
|
+
@held[id] = @held.fetch(id, 0) - in_memory
|
|
561
|
+
@budget.release(in_memory)
|
|
562
|
+
end
|
|
563
|
+
return reject(id, request, 503) unless reserve_spooled(id, bytes.bytesize)
|
|
564
|
+
|
|
565
|
+
request.body.write(bytes)
|
|
566
|
+
request.received += bytes.bytesize
|
|
567
|
+
rescue => e
|
|
568
|
+
@spool_error ||= e
|
|
569
|
+
reject(id, request, 503)
|
|
570
|
+
end
|
|
571
|
+
|
|
572
|
+
# The temporary file for a request body; tests replace it.
|
|
573
|
+
def open_spool
|
|
574
|
+
Session.spool_file
|
|
575
|
+
end
|
|
576
|
+
|
|
577
|
+
# Count n more bytes of the request on id in the spool, if it has room.
|
|
578
|
+
def reserve_spooled(id, n)
|
|
579
|
+
return false unless @spool.bytes.reserve(n)
|
|
580
|
+
|
|
581
|
+
@spooled[id] = @spooled.fetch(id, 0) + n
|
|
582
|
+
true
|
|
583
|
+
end
|
|
584
|
+
|
|
585
|
+
# The request on id holds no bytes in the spool any more. Calling it
|
|
586
|
+
# again does nothing.
|
|
587
|
+
def release_spooled(id)
|
|
588
|
+
n = @spooled.delete(id) or return
|
|
589
|
+
@spool.bytes.release(n)
|
|
590
|
+
end
|
|
591
|
+
|
|
592
|
+
# The temporary file of the request on id is closed: it no longer
|
|
593
|
+
# counts as open. Calling it again does nothing.
|
|
594
|
+
def release_spool_file(id)
|
|
595
|
+
@spool_files.delete(id) or return
|
|
596
|
+
@spool.files.release(1)
|
|
597
|
+
end
|
|
598
|
+
|
|
599
|
+
# Nothing the request on id holds counts any more: bytes in memory, in
|
|
600
|
+
# its temporary file, and the file. Calling it again does nothing.
|
|
601
|
+
def release_budget(id)
|
|
602
|
+
release_held(id)
|
|
603
|
+
release_spooled(id)
|
|
604
|
+
release_spool_file(id)
|
|
605
|
+
end
|
|
606
|
+
|
|
607
|
+
# Take the request on id out of those waiting to be handed out; nil if
|
|
608
|
+
# it is not one of them.
|
|
609
|
+
def take_waiting(id)
|
|
610
|
+
index = @ready.index { |request, _| request.stream_id == id } or return nil
|
|
611
|
+
@ready.delete_at(index).first
|
|
612
|
+
end
|
|
613
|
+
|
|
614
|
+
# Answer a request received in full, but not handed out, with status at
|
|
615
|
+
# once, as #reject does one still coming in.
|
|
616
|
+
def refuse_waiting(request, status)
|
|
617
|
+
id = request.stream_id
|
|
618
|
+
close_input(request.input)
|
|
619
|
+
release_budget(id)
|
|
620
|
+
@rejected[id] = true
|
|
621
|
+
@refused << [Request.new(stream_id: id, headers: [], input: empty_input), status]
|
|
622
|
+
end
|
|
623
|
+
|
|
624
|
+
# Close input unless the application has; a file that cannot be flushed
|
|
625
|
+
# is given up on all the same.
|
|
626
|
+
def close_input(input)
|
|
627
|
+
input.close unless input.closed?
|
|
628
|
+
rescue IOError, SystemCallError
|
|
629
|
+
nil
|
|
630
|
+
end
|
|
631
|
+
|
|
632
|
+
# Answer the request on id with status, at once, instead of passing it
|
|
633
|
+
# on: what has come of it is dropped, with the bytes it held, and
|
|
634
|
+
# #respond stops reading the rest. Its Incoming stays in @incoming,
|
|
635
|
+
# emptied, until the stream closes, and the callbacks still to come for
|
|
636
|
+
# it are ignored. 431 may come before all of the pseudo-headers, so the
|
|
637
|
+
# request goes out without any headers. A refusal is not logged: only
|
|
638
|
+
# failures are, and a flood of refusals would flood the log.
|
|
639
|
+
def reject(id, request, status)
|
|
640
|
+
request.pseudo.clear
|
|
641
|
+
request.fields.clear
|
|
642
|
+
close_input(request.body)
|
|
643
|
+
request.body = empty_input
|
|
644
|
+
request.received = 0
|
|
645
|
+
release_budget(id)
|
|
646
|
+
@rejected[id] = true
|
|
647
|
+
@refused << [Request.new(stream_id: id, headers: [], input: empty_input), status]
|
|
648
|
+
end
|
|
649
|
+
|
|
650
|
+
# Queue chunks of out's body while its stream has less than limit bytes
|
|
651
|
+
# unsent and less than room (nil for no limit) has been queued. Returns
|
|
652
|
+
# the bytes queued.
|
|
653
|
+
def fill_body(out, limit, room)
|
|
654
|
+
queued = 0
|
|
655
|
+
id = out.request.stream_id
|
|
656
|
+
while unsent_bytes(id) < limit && (room.nil? || queued < room)
|
|
657
|
+
chunk = next_chunk(out.body)
|
|
658
|
+
break if chunk.nil?
|
|
659
|
+
|
|
162
660
|
unless chunk.is_a?(String)
|
|
163
661
|
out.done = true
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
662
|
+
if chunk.equal?(END_OF_BODY)
|
|
663
|
+
push_eof(id)
|
|
664
|
+
else
|
|
665
|
+
abort_stream(id, Nghttp3::H3_INTERNAL_ERROR)
|
|
666
|
+
yield chunk
|
|
667
|
+
end
|
|
668
|
+
break
|
|
167
669
|
end
|
|
168
670
|
push_chunk(id, chunk)
|
|
671
|
+
queued += chunk.bytesize
|
|
169
672
|
end
|
|
673
|
+
queued
|
|
170
674
|
rescue Nghttp3::Error
|
|
171
675
|
# The stream went away under the body (the peer reset it); it is
|
|
172
676
|
# released when its close comes through.
|
|
173
677
|
out.done = true
|
|
678
|
+
queued
|
|
679
|
+
end
|
|
680
|
+
|
|
681
|
+
# The next chunk of body, a String; END_OF_BODY at its end; the
|
|
682
|
+
# exception that ends it otherwise; or nil while a Body::Pipe has
|
|
683
|
+
# nothing yet.
|
|
684
|
+
def next_chunk(body)
|
|
685
|
+
if body.is_a?(Body::Pipe)
|
|
686
|
+
chunk = body.take
|
|
687
|
+
return chunk.equal?(:eof) ? END_OF_BODY : chunk
|
|
688
|
+
end
|
|
689
|
+
chunk = body.enum.next
|
|
690
|
+
chunk.is_a?(String) ? chunk : TypeError.new("body chunk must be a String (got #{chunk.class})")
|
|
691
|
+
rescue StopIteration
|
|
692
|
+
END_OF_BODY
|
|
693
|
+
rescue => e
|
|
694
|
+
e
|
|
695
|
+
end
|
|
696
|
+
|
|
697
|
+
# The bodies the Session sends as they are generated, rather than read
|
|
698
|
+
# by the C part (Body::File).
|
|
699
|
+
def generated?(body)
|
|
700
|
+
body.is_a?(Body::Chunks) || body.is_a?(Body::Pipe)
|
|
174
701
|
end
|
|
175
702
|
|
|
703
|
+
# An input with no body, for the requests that keep none.
|
|
704
|
+
def empty_input
|
|
705
|
+
StringIO.new("".b)
|
|
706
|
+
end
|
|
707
|
+
|
|
708
|
+
# The input of a request handed out is the caller's to close, and what
|
|
709
|
+
# it holds counts until #finish.
|
|
176
710
|
def release(id)
|
|
177
711
|
@open.delete(id)
|
|
178
|
-
@incoming.delete(id)
|
|
179
|
-
|
|
712
|
+
incoming = @incoming.delete(id)
|
|
713
|
+
close_input(incoming.body) if incoming
|
|
714
|
+
waiting = take_waiting(id)
|
|
715
|
+
close_input(waiting.input) if waiting
|
|
716
|
+
release_budget(id) unless @handed.key?(id)
|
|
717
|
+
@reset.delete(id)
|
|
718
|
+
@stopped.delete(id)
|
|
719
|
+
@rejected.delete(id)
|
|
720
|
+
@answered.delete(id)
|
|
721
|
+
@refused.reject! { |request, _| request.stream_id == id }
|
|
180
722
|
out = @outgoing.delete(id) or return
|
|
181
723
|
|
|
182
724
|
close_body(out.body)
|
|
@@ -184,10 +726,16 @@ module HTTP3
|
|
|
184
726
|
yield id, e if block_given?
|
|
185
727
|
end
|
|
186
728
|
|
|
729
|
+
# A Body::Pipe is cancelled, so that its writer stops; a Body::File's
|
|
730
|
+
# own IO is closed; a rack_body other than nil is closed.
|
|
187
731
|
def close_body(body)
|
|
188
|
-
|
|
732
|
+
case body
|
|
733
|
+
when Body::Pipe then body.cancel
|
|
734
|
+
when Body::File then body.io.close unless body.io.closed?
|
|
735
|
+
end
|
|
189
736
|
ensure
|
|
190
|
-
body.rack_body
|
|
737
|
+
rack_body = body.rack_body if body.respond_to?(:rack_body)
|
|
738
|
+
rack_body.close if rack_body.respond_to?(:close)
|
|
191
739
|
end
|
|
192
740
|
end
|
|
193
741
|
end
|