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
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
|
|
@@ -11,8 +15,10 @@ module HTTP3
|
|
|
11
15
|
# the streams no longer need.
|
|
12
16
|
class Session
|
|
13
17
|
# A request still coming in; started is when its headers began
|
|
14
|
-
# (CLOCK_MONOTONIC).
|
|
15
|
-
|
|
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)
|
|
@@ -21,6 +27,10 @@ module HTTP3
|
|
|
21
27
|
# section 4.2.2 counts it, and as the C part does for 431.
|
|
22
28
|
FIELD_OVERHEAD = 32
|
|
23
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
|
+
|
|
24
34
|
# Bytes the requests coming in may hold: a connection's, and through
|
|
25
35
|
# parent the server's. Only the server's I/O loop, one thread, calls
|
|
26
36
|
# it, so it takes no lock.
|
|
@@ -50,25 +60,54 @@ module HTTP3
|
|
|
50
60
|
end
|
|
51
61
|
private_constant :Budget
|
|
52
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
|
+
|
|
53
69
|
# Take over quic_conn's streams. A request body over max_request_body
|
|
54
70
|
# bytes is answered with 413, a header section over
|
|
55
71
|
# max_field_section_size bytes (RFC 9114 section 4.2.2) with 431, as
|
|
56
72
|
# soon as the request passes the limit; the rest of it is not read.
|
|
57
73
|
#
|
|
58
|
-
#
|
|
59
|
-
#
|
|
60
|
-
#
|
|
61
|
-
#
|
|
62
|
-
#
|
|
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.
|
|
63
90
|
#
|
|
64
91
|
# A request not received in full within request_timeout seconds of the
|
|
65
|
-
# start of its headers is answered with 408
|
|
66
|
-
#
|
|
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.
|
|
67
102
|
def self.attach(quic_conn, max_request_body:, max_field_section_size:,
|
|
68
|
-
max_connection_request_buffer: 4_194_304, shared_buffer: nil, request_timeout: nil
|
|
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)
|
|
69
106
|
session = _attach(quic_conn, max_field_section_size)
|
|
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))
|
|
70
109
|
session.send(:setup, max_request_body, Budget.new(max_connection_request_buffer, parent: shared_buffer),
|
|
71
|
-
request_timeout)
|
|
110
|
+
request_timeout, request_body_buffer_size, spool, response_timeout)
|
|
72
111
|
session
|
|
73
112
|
end
|
|
74
113
|
|
|
@@ -78,21 +117,87 @@ module HTTP3
|
|
|
78
117
|
Budget.new(limit)
|
|
79
118
|
end
|
|
80
119
|
|
|
81
|
-
#
|
|
82
|
-
#
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
|
92
195
|
end
|
|
93
196
|
|
|
94
197
|
# Refuse with 408 the requests still coming in whose request_timeout
|
|
95
|
-
# has passed at now (CLOCK_MONOTONIC)
|
|
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.
|
|
96
201
|
def expire_requests(now)
|
|
97
202
|
return if @request_timeout.nil?
|
|
98
203
|
|
|
@@ -101,15 +206,50 @@ module HTTP3
|
|
|
101
206
|
|
|
102
207
|
reject(id, request, 408)
|
|
103
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
|
|
104
214
|
end
|
|
105
215
|
|
|
106
|
-
# When the next request still coming in
|
|
107
|
-
# (CLOCK_MONOTONIC), or nil.
|
|
216
|
+
# When the next request still coming in, or waiting for #next_request,
|
|
217
|
+
# runs out of time (CLOCK_MONOTONIC), or nil.
|
|
108
218
|
def next_request_deadline
|
|
109
219
|
return nil if @request_timeout.nil?
|
|
110
220
|
|
|
111
221
|
started = @incoming.filter_map { |id, request| request.started unless @rejected[id] }.min
|
|
112
|
-
started
|
|
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)
|
|
113
253
|
end
|
|
114
254
|
|
|
115
255
|
# Send response to request. Its body, if any, is kept until the stream
|
|
@@ -123,7 +263,8 @@ module HTTP3
|
|
|
123
263
|
id = request.stream_id
|
|
124
264
|
raise Nghttp3::InvalidStateError, "stream #{id} has been answered" if @answered[id]
|
|
125
265
|
|
|
126
|
-
|
|
266
|
+
# Refused while it was still coming in (#reject keeps its Incoming).
|
|
267
|
+
stop_reading(id) if @rejected[id] && @incoming.key?(id)
|
|
127
268
|
headers = [[":status", response.status.to_s], *response.headers]
|
|
128
269
|
body = response.body
|
|
129
270
|
submit_response(id, headers, !body.nil?)
|
|
@@ -133,7 +274,7 @@ module HTTP3
|
|
|
133
274
|
# Only the pseudo-headers stay, for the log, while the body is sent:
|
|
134
275
|
# the request body would otherwise be held all along, uncounted.
|
|
135
276
|
pseudo = request.headers.select { |name, _| name.start_with?(":") }
|
|
136
|
-
@outgoing[id] = Outgoing.new(Request.new(stream_id: id, headers: pseudo,
|
|
277
|
+
@outgoing[id] = Outgoing.new(Request.new(stream_id: id, headers: pseudo, input: empty_input), body, false)
|
|
137
278
|
return unless body.is_a?(Body::File)
|
|
138
279
|
|
|
139
280
|
begin
|
|
@@ -146,21 +287,21 @@ module HTTP3
|
|
|
146
287
|
end
|
|
147
288
|
end
|
|
148
289
|
|
|
149
|
-
# Queue more of each Body::Chunks body until limit bytes
|
|
150
|
-
# its stream, connection_limit on the whole connection
|
|
151
|
-
# and room more bytes have been queued; nil is no
|
|
152
|
-
# take any of them past its limit, by less than its
|
|
153
|
-
# bytes queued.
|
|
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.
|
|
154
295
|
#
|
|
155
|
-
# A body whose enum raises
|
|
156
|
-
# went out: the stream is aborted with
|
|
157
|
-
# gets (request, exception). That
|
|
158
|
-
# and no body.
|
|
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.
|
|
159
300
|
def fill_bodies(limit, connection_limit: nil, room: nil)
|
|
160
301
|
left = [room, connection_limit && connection_limit - unsent_total].compact.min
|
|
161
302
|
queued = 0
|
|
162
303
|
@outgoing.each_value do |out|
|
|
163
|
-
next if out.done || !out.body
|
|
304
|
+
next if out.done || !generated?(out.body)
|
|
164
305
|
break if left && queued >= left
|
|
165
306
|
|
|
166
307
|
queued += fill_body(out, limit, left && left - queued) { |error| yield out.request, error if block_given? }
|
|
@@ -168,43 +309,61 @@ module HTTP3
|
|
|
168
309
|
queued
|
|
169
310
|
end
|
|
170
311
|
|
|
171
|
-
# True when a Body::Chunks body
|
|
172
|
-
# limits of #fill_bodies.
|
|
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.
|
|
173
314
|
def wants_fill?(limit, connection_limit: nil, room: nil)
|
|
174
315
|
return false if room && room <= 0
|
|
175
316
|
return false if connection_limit && unsent_total >= connection_limit
|
|
176
317
|
|
|
177
318
|
@outgoing.any? do |id, out|
|
|
178
|
-
|
|
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
|
|
179
323
|
end
|
|
180
324
|
end
|
|
181
325
|
|
|
182
|
-
# Bytes of the Body::Chunks bodies queued but not taken
|
|
183
|
-
# on every stream; a body that has ended may still have
|
|
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.
|
|
184
329
|
def unsent_total
|
|
185
|
-
@outgoing.sum { |id, out| out.body
|
|
330
|
+
@outgoing.sum { |id, out| generated?(out.body) ? unsent_bytes(id) : 0 }
|
|
186
331
|
end
|
|
187
332
|
|
|
188
|
-
# True while a request stream is open: received in part, waiting
|
|
189
|
-
#
|
|
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.
|
|
190
335
|
def busy?
|
|
191
|
-
!@open.empty?
|
|
336
|
+
!@open.empty? || !@ready.empty?
|
|
192
337
|
end
|
|
193
338
|
|
|
194
|
-
# Let go of the streams nghttp3 closed: their requests are dropped
|
|
195
|
-
#
|
|
196
|
-
#
|
|
197
|
-
#
|
|
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
|
|
198
345
|
# H3_REQUEST_INCOMPLETE as RFC 9114 section 4.1 says, so that the
|
|
199
|
-
# streams close
|
|
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.
|
|
200
349
|
def release_closed(&block)
|
|
201
350
|
@incomplete.each { |id| abort_stream(id, Nghttp3::H3_REQUEST_INCOMPLETE) }
|
|
202
351
|
@incomplete.clear
|
|
352
|
+
@cancelled.each { |id| abort_stream(id, Nghttp3::H3_REQUEST_CANCELLED) }
|
|
353
|
+
@cancelled.clear
|
|
203
354
|
take_closed.each { |id| release(id, &block) }
|
|
204
355
|
end
|
|
205
356
|
|
|
206
|
-
# 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.
|
|
207
365
|
def release_all(&block)
|
|
366
|
+
release_connection
|
|
208
367
|
(@open.keys | @outgoing.keys).each { |id| release(id, &block) }
|
|
209
368
|
end
|
|
210
369
|
|
|
@@ -212,7 +371,7 @@ module HTTP3
|
|
|
212
371
|
|
|
213
372
|
def on_begin_headers(id)
|
|
214
373
|
@open[id] = true
|
|
215
|
-
@incoming[id] = Incoming.new([], [],
|
|
374
|
+
@incoming[id] = Incoming.new([], [], empty_input, Process.clock_gettime(Process::CLOCK_MONOTONIC), 0)
|
|
216
375
|
end
|
|
217
376
|
|
|
218
377
|
def on_header(id, name, value)
|
|
@@ -237,49 +396,128 @@ module HTTP3
|
|
|
237
396
|
request = @incoming[id] or return
|
|
238
397
|
return if @rejected[id]
|
|
239
398
|
|
|
240
|
-
if request.
|
|
399
|
+
if request.received + bytes.bytesize > @max_request_body
|
|
241
400
|
reject(id, request, 413)
|
|
401
|
+
elsif @spool_files.key?(id) || request.received + bytes.bytesize > @request_body_buffer_size
|
|
402
|
+
spool(id, request, bytes)
|
|
242
403
|
elsif !hold(id, bytes.bytesize)
|
|
243
404
|
reject(id, request, 503)
|
|
244
405
|
else
|
|
245
|
-
request.body
|
|
406
|
+
request.body.write(bytes)
|
|
407
|
+
request.received += bytes.bytesize
|
|
246
408
|
end
|
|
247
409
|
end
|
|
248
410
|
|
|
249
411
|
def on_end_stream(id)
|
|
250
412
|
return if @rejected[id]
|
|
251
413
|
|
|
252
|
-
request = @incoming
|
|
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)
|
|
253
424
|
headers = request.pseudo + request.fields
|
|
254
|
-
@
|
|
425
|
+
@ready << [Request.new(stream_id: id, headers: headers, input: input),
|
|
426
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)]
|
|
255
427
|
end
|
|
256
428
|
|
|
257
|
-
# The client reset its side of the stream
|
|
258
|
-
# finished is dropped with
|
|
259
|
-
# resets our side
|
|
260
|
-
#
|
|
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.
|
|
261
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
|
|
262
451
|
return unless @incoming.key?(id) && !@rejected[id]
|
|
263
452
|
|
|
264
|
-
@incoming.delete(id)
|
|
265
|
-
|
|
453
|
+
request = @incoming.delete(id)
|
|
454
|
+
close_input(request.body)
|
|
455
|
+
release_budget(id)
|
|
266
456
|
@incomplete << id
|
|
267
457
|
end
|
|
268
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
|
|
482
|
+
end
|
|
483
|
+
|
|
269
484
|
private
|
|
270
485
|
|
|
271
|
-
def setup(max_request_body, budget, request_timeout)
|
|
486
|
+
def setup(max_request_body, budget, request_timeout, request_body_buffer_size, spool, response_timeout)
|
|
272
487
|
@max_request_body = max_request_body
|
|
273
488
|
@budget = budget
|
|
274
489
|
@request_timeout = request_timeout
|
|
490
|
+
@response_timeout = response_timeout
|
|
491
|
+
@request_body_buffer_size = request_body_buffer_size
|
|
492
|
+
@spool = spool
|
|
275
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
|
|
276
500
|
@incoming = {}
|
|
277
|
-
|
|
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 = {}
|
|
278
508
|
@outgoing = {}
|
|
279
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 = {}
|
|
280
515
|
@rejected = {}
|
|
281
516
|
@answered = {}
|
|
282
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 = []
|
|
283
521
|
end
|
|
284
522
|
|
|
285
523
|
# Count n more bytes for the request on id, if the budget has room.
|
|
@@ -297,6 +535,100 @@ module HTTP3
|
|
|
297
535
|
@budget.release(n)
|
|
298
536
|
end
|
|
299
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
|
|
553
|
+
begin
|
|
554
|
+
file.write(request.body.string)
|
|
555
|
+
rescue
|
|
556
|
+
close_input(file)
|
|
557
|
+
raise
|
|
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
|
+
|
|
300
632
|
# Answer the request on id with status, at once, instead of passing it
|
|
301
633
|
# on: what has come of it is dropped, with the bytes it held, and
|
|
302
634
|
# #respond stops reading the rest. Its Incoming stays in @incoming,
|
|
@@ -307,10 +639,12 @@ module HTTP3
|
|
|
307
639
|
def reject(id, request, status)
|
|
308
640
|
request.pseudo.clear
|
|
309
641
|
request.fields.clear
|
|
310
|
-
request.body
|
|
311
|
-
|
|
642
|
+
close_input(request.body)
|
|
643
|
+
request.body = empty_input
|
|
644
|
+
request.received = 0
|
|
645
|
+
release_budget(id)
|
|
312
646
|
@rejected[id] = true
|
|
313
|
-
@
|
|
647
|
+
@refused << [Request.new(stream_id: id, headers: [], input: empty_input), status]
|
|
314
648
|
end
|
|
315
649
|
|
|
316
650
|
# Queue chunks of out's body while its stream has less than limit bytes
|
|
@@ -320,23 +654,18 @@ module HTTP3
|
|
|
320
654
|
queued = 0
|
|
321
655
|
id = out.request.stream_id
|
|
322
656
|
while unsent_bytes(id) < limit && (room.nil? || queued < room)
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
out.done = true
|
|
327
|
-
push_eof(id)
|
|
328
|
-
return queued
|
|
329
|
-
rescue => e
|
|
330
|
-
out.done = true
|
|
331
|
-
abort_stream(id, Nghttp3::H3_INTERNAL_ERROR)
|
|
332
|
-
yield e
|
|
333
|
-
return queued
|
|
334
|
-
end
|
|
657
|
+
chunk = next_chunk(out.body)
|
|
658
|
+
break if chunk.nil?
|
|
659
|
+
|
|
335
660
|
unless chunk.is_a?(String)
|
|
336
661
|
out.done = true
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
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
|
|
340
669
|
end
|
|
341
670
|
push_chunk(id, chunk)
|
|
342
671
|
queued += chunk.bytesize
|
|
@@ -349,13 +678,47 @@ module HTTP3
|
|
|
349
678
|
queued
|
|
350
679
|
end
|
|
351
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)
|
|
701
|
+
end
|
|
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.
|
|
352
710
|
def release(id)
|
|
353
711
|
@open.delete(id)
|
|
354
|
-
@incoming.delete(id)
|
|
355
|
-
|
|
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)
|
|
356
719
|
@rejected.delete(id)
|
|
357
720
|
@answered.delete(id)
|
|
358
|
-
@
|
|
721
|
+
@refused.reject! { |request, _| request.stream_id == id }
|
|
359
722
|
out = @outgoing.delete(id) or return
|
|
360
723
|
|
|
361
724
|
close_body(out.body)
|
|
@@ -363,10 +726,16 @@ module HTTP3
|
|
|
363
726
|
yield id, e if block_given?
|
|
364
727
|
end
|
|
365
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.
|
|
366
731
|
def close_body(body)
|
|
367
|
-
|
|
732
|
+
case body
|
|
733
|
+
when Body::Pipe then body.cancel
|
|
734
|
+
when Body::File then body.io.close unless body.io.closed?
|
|
735
|
+
end
|
|
368
736
|
ensure
|
|
369
|
-
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)
|
|
370
739
|
end
|
|
371
740
|
end
|
|
372
741
|
end
|