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/server.rb
CHANGED
|
@@ -19,10 +19,29 @@ module HTTP3
|
|
|
19
19
|
#
|
|
20
20
|
# Only failures are logged, one line each: the event, then key=value
|
|
21
21
|
# pairs. Requests are not logged; use Rack::CommonLogger for that. Query
|
|
22
|
-
# strings and header values never appear in the log.
|
|
22
|
+
# strings and header values never appear in the log. Those answered
|
|
23
|
+
# without the application (400, 408, 413, 431, 501, 503), and the
|
|
24
|
+
# responses aborted after response_timeout, are counted instead:
|
|
25
|
+
# refusal_log_interval seconds after the first of them, and when #run
|
|
26
|
+
# ends, a warn line per status tells how many there were since, such as
|
|
27
|
+
# "requests_refused status=503 count=1234 seconds=60", followed by
|
|
28
|
+
# "responses_timed_out count=56 seconds=60" if any were aborted.
|
|
29
|
+
#
|
|
30
|
+
# One thread drives every connection; the application runs on worker
|
|
31
|
+
# threads (WorkerPool), and only there: its call, the each of its body
|
|
32
|
+
# and the close of its body all run on the worker that took the request.
|
|
33
|
+
# The workers hand their responses to that thread through a queue, and
|
|
34
|
+
# wake it through a pipe.
|
|
23
35
|
class Server
|
|
24
36
|
ALPN = ["h3"].freeze
|
|
25
37
|
|
|
38
|
+
# A request handed to a worker. remote is the client's address, taken
|
|
39
|
+
# when the request was handed out, for the log; pipe is the response's
|
|
40
|
+
# Body::Pipe, once the worker has one; started is true once a worker
|
|
41
|
+
# has taken the job.
|
|
42
|
+
Job = Struct.new(:conn, :session, :request, :remote, :pipe, :started)
|
|
43
|
+
private_constant :Job
|
|
44
|
+
|
|
26
45
|
# What the signal handlers write to the self-pipe.
|
|
27
46
|
SIGNALS = {"TERM" => "T", "INT" => "I", "HUP" => "H"}.freeze
|
|
28
47
|
|
|
@@ -42,12 +61,54 @@ module HTTP3
|
|
|
42
61
|
# certificate_path is a PEM chain, leaf first; private_key_path a PEM key
|
|
43
62
|
# without passphrase. qlog_dir writes one qlog file per connection.
|
|
44
63
|
# max_request_body and max_field_section_size (bytes) bound a request's
|
|
45
|
-
# body (413 beyond) and header section (431 beyond)
|
|
46
|
-
#
|
|
64
|
+
# body (413 beyond) and header section (431 beyond). A body over
|
|
65
|
+
# request_body_buffer_size bytes is written to a temporary file in
|
|
66
|
+
# Dir.tmpdir. max_connection_request_buffer bounds the bytes of memory
|
|
67
|
+
# the requests of a connection hold until the application is done with
|
|
68
|
+
# them, and max_total_request_buffer those of every connection;
|
|
69
|
+
# max_connection_request_spool and max_total_request_spool bound the
|
|
70
|
+
# bytes in temporary files, and max_connection_request_spool_files and
|
|
71
|
+
# max_total_request_spool_files the files open at once. A request that
|
|
72
|
+
# would pass any of them, or whose file cannot be made or written, gets
|
|
73
|
+
# 503. They have to leave room for a request at the limits on a server
|
|
74
|
+
# with nothing else under way, or ArgumentError is raised; and when
|
|
75
|
+
# bodies may go to files, Dir.tmpdir has to take one, or
|
|
76
|
+
# SystemCallError is. send_buffer_size
|
|
77
|
+
# bounds the bytes of a response body queued but unsent per stream,
|
|
78
|
+
# max_connection_send_buffer those of the array, each and streaming
|
|
79
|
+
# bodies of a connection, and max_total_send_buffer those of every
|
|
80
|
+
# connection; a body waits for room. Each has to be at least the one before. A request
|
|
81
|
+
# not received in full within request_timeout seconds of the start of
|
|
82
|
+
# its headers gets 408, and one that waits that long for a worker
|
|
83
|
+
# afterwards 503; nil is no limit. A response that stalls, waiting on
|
|
84
|
+
# its client with nothing of it acknowledged, for response_timeout
|
|
85
|
+
# seconds is aborted with H3_REQUEST_CANCELLED, and a worker writing it
|
|
86
|
+
# gets StreamClosed; it bounds a stall, not the whole response. nil is
|
|
87
|
+
# no limit, and unless either is nil, it has to be less than
|
|
88
|
+
# request_timeout. refusal_log_interval is the seconds between the
|
|
89
|
+
# requests_refused and responses_timed_out lines (see above). threads
|
|
90
|
+
# is the number of worker threads.
|
|
47
91
|
def initialize(app:, bind:, certificate_path:, private_key_path:, transport_params: nil, settings: nil,
|
|
48
|
-
qlog_dir: nil, max_connections: 1024, max_request_body: 1_048_576, max_field_section_size: 65_536,
|
|
49
|
-
|
|
50
|
-
|
|
92
|
+
qlog_dir: nil, max_connections: 1024, threads: 3, max_request_body: 1_048_576, max_field_section_size: 65_536,
|
|
93
|
+
max_connection_request_buffer: 4_194_304, max_total_request_buffer: 67_108_864,
|
|
94
|
+
request_body_buffer_size: 65_536, max_connection_request_spool: 16_777_216,
|
|
95
|
+
max_total_request_spool: 1_073_741_824, max_connection_request_spool_files: 16,
|
|
96
|
+
max_total_request_spool_files: 256,
|
|
97
|
+
send_buffer_size: 65_536, max_connection_send_buffer: 1_048_576, max_total_send_buffer: 67_108_864,
|
|
98
|
+
request_timeout: 30, response_timeout: 10, refusal_log_interval: 60, shutdown_timeout: 30,
|
|
99
|
+
logger: Logger.new($stderr))
|
|
100
|
+
check_request_limits(max_request_body:, max_field_section_size:, max_connection_request_buffer:,
|
|
101
|
+
max_total_request_buffer:, request_body_buffer_size:, max_connection_request_spool:,
|
|
102
|
+
max_total_request_spool:, max_connection_request_spool_files:, max_total_request_spool_files:)
|
|
103
|
+
# Found out now rather than by the first large request.
|
|
104
|
+
Session.spool_file.close if max_request_body > request_body_buffer_size
|
|
105
|
+
check_send_limits(send_buffer_size, max_connection_send_buffer, max_total_send_buffer)
|
|
106
|
+
check_threads(threads)
|
|
107
|
+
check_request_timeout(request_timeout)
|
|
108
|
+
check_response_timeout(response_timeout, request_timeout)
|
|
109
|
+
check_refusal_log_interval(refusal_log_interval)
|
|
110
|
+
@app = app
|
|
111
|
+
@threads = threads
|
|
51
112
|
@bind_address = parse_bind(bind)
|
|
52
113
|
@certificate_path = certificate_path
|
|
53
114
|
@private_key_path = private_key_path
|
|
@@ -60,7 +121,19 @@ module HTTP3
|
|
|
60
121
|
@max_connections = max_connections
|
|
61
122
|
@max_request_body = max_request_body
|
|
62
123
|
@max_field_section_size = max_field_section_size
|
|
124
|
+
@max_connection_request_buffer = max_connection_request_buffer
|
|
125
|
+
@max_total_request_buffer = max_total_request_buffer
|
|
126
|
+
@request_body_buffer_size = request_body_buffer_size
|
|
127
|
+
@max_connection_request_spool = max_connection_request_spool
|
|
128
|
+
@max_total_request_spool = max_total_request_spool
|
|
129
|
+
@max_connection_request_spool_files = max_connection_request_spool_files
|
|
130
|
+
@max_total_request_spool_files = max_total_request_spool_files
|
|
63
131
|
@send_buffer_size = send_buffer_size
|
|
132
|
+
@max_connection_send_buffer = max_connection_send_buffer
|
|
133
|
+
@max_total_send_buffer = max_total_send_buffer
|
|
134
|
+
@request_timeout = request_timeout
|
|
135
|
+
@response_timeout = response_timeout
|
|
136
|
+
@refusal_log_interval = refusal_log_interval
|
|
64
137
|
@shutdown_timeout = shutdown_timeout
|
|
65
138
|
@logger = logger
|
|
66
139
|
end
|
|
@@ -68,6 +141,17 @@ module HTTP3
|
|
|
68
141
|
# Serve until told to stop (see the class comment). Returns nil.
|
|
69
142
|
def run
|
|
70
143
|
sig_r, sig_w = IO.pipe
|
|
144
|
+
# The workers write to it when they have a result for the loop, or a
|
|
145
|
+
# Body::Pipe has something to take.
|
|
146
|
+
@wake_r, @wake_w = IO.pipe
|
|
147
|
+
@results = Thread::Queue.new
|
|
148
|
+
# The jobs handed to a worker and not done yet, and whether the
|
|
149
|
+
# workers have been told to stop; the lock is for the pipes of the
|
|
150
|
+
# jobs, which workers set.
|
|
151
|
+
@jobs = {}.compare_by_identity
|
|
152
|
+
@jobs_lock = Thread::Mutex.new
|
|
153
|
+
@workers_stopped = false
|
|
154
|
+
@adapter = RackAdapter.new(@app, pipe_capacity: @send_buffer_size, wake: method(:wake))
|
|
71
155
|
@sock = UDPSocket.new(@bind_address.afamily)
|
|
72
156
|
@sock.bind(@bind_address.ip_address, @bind_address.ip_port)
|
|
73
157
|
@local_address = @sock.local_address
|
|
@@ -77,31 +161,59 @@ module HTTP3
|
|
|
77
161
|
)
|
|
78
162
|
@sessions = {}
|
|
79
163
|
@remotes = {}
|
|
164
|
+
# The index in @sessions of the connection whose turn it is to hand a
|
|
165
|
+
# request to the application (#next_request).
|
|
166
|
+
@next_turn = 0
|
|
167
|
+
@request_buffer = Session.shared_buffer(@max_total_request_buffer)
|
|
168
|
+
@request_spool = Session.shared_spool(@max_total_request_spool, files: @max_total_request_spool_files)
|
|
169
|
+
# When request_spool_failed was last logged; nil until then.
|
|
170
|
+
@spool_failure_logged_at = nil
|
|
80
171
|
@blocked_send = nil
|
|
81
172
|
@phase = :serving
|
|
82
173
|
@drain_deadline = nil
|
|
174
|
+
# {status => count} of the requests answered without the
|
|
175
|
+
# application, since @refusals_since.
|
|
176
|
+
@refusals = Hash.new(0)
|
|
177
|
+
# The responses aborted after response_timeout, since @refusals_since.
|
|
178
|
+
@responses_timed_out = 0
|
|
179
|
+
@refusals_since = nil
|
|
83
180
|
old_traps = install_traps(sig_w)
|
|
181
|
+
@pool = WorkerPool.new(@threads).start
|
|
84
182
|
|
|
85
183
|
turn(sig_r) until finished?
|
|
86
184
|
close_connections
|
|
185
|
+
stop_workers
|
|
186
|
+
log_refusals
|
|
87
187
|
nil
|
|
88
188
|
ensure
|
|
89
189
|
restore_traps(old_traps) if old_traps
|
|
190
|
+
stop_workers if @pool && !@workers_stopped
|
|
90
191
|
@sessions&.each_value { |session| release_session(session) }
|
|
91
|
-
[@sock, sig_r, sig_w].each { |io| io.close if io && !io.closed? }
|
|
192
|
+
[@sock, sig_r, sig_w, @wake_r, @wake_w].each { |io| io.close if io && !io.closed? }
|
|
92
193
|
end
|
|
93
194
|
|
|
94
195
|
private
|
|
95
196
|
|
|
96
|
-
# One turn of the loop.
|
|
197
|
+
# One turn of the loop. The room left in max_total_send_buffer is
|
|
198
|
+
# counted once, at the start. Only fill_bodies queues more, so until
|
|
199
|
+
# then the room can only have grown (nghttp3 taking what flush sends),
|
|
200
|
+
# and filling by the room counted queues no more than the limit allows.
|
|
97
201
|
def turn(sig_r)
|
|
202
|
+
room = send_room
|
|
98
203
|
writers = @blocked_send ? [@sock] : nil
|
|
99
|
-
readable, writable, = IO.select([@sock, sig_r], writers, nil, select_timeout)
|
|
204
|
+
readable, writable, = IO.select([@sock, sig_r, @wake_r], writers, nil, select_timeout(room))
|
|
100
205
|
handle_signals(sig_r) if readable&.include?(sig_r)
|
|
101
206
|
flush if writable&.include?(@sock)
|
|
102
207
|
receive if readable&.include?(@sock)
|
|
208
|
+
# Emptied before the results are taken: a result put after that
|
|
209
|
+
# leaves a byte for the next select.
|
|
210
|
+
drain_wake
|
|
211
|
+
take_results
|
|
212
|
+
expire_requests
|
|
213
|
+
expire_responses
|
|
103
214
|
serve_requests
|
|
104
|
-
|
|
215
|
+
log_refusals if refusals_due?
|
|
216
|
+
fill_bodies(room)
|
|
105
217
|
@endpoint.handle_expiry if due?(@endpoint.expiry)
|
|
106
218
|
flush
|
|
107
219
|
reap
|
|
@@ -110,19 +222,31 @@ module HTTP3
|
|
|
110
222
|
def finished?
|
|
111
223
|
case @phase
|
|
112
224
|
when :serving then false
|
|
113
|
-
when :draining then now >= @drain_deadline || @sessions.each_value.none?(&:busy?)
|
|
225
|
+
when :draining then now >= @drain_deadline || (@jobs.empty? && @sessions.each_value.none?(&:busy?))
|
|
114
226
|
else true
|
|
115
227
|
end
|
|
116
228
|
end
|
|
117
229
|
|
|
118
|
-
# Until the next timer
|
|
119
|
-
#
|
|
120
|
-
|
|
121
|
-
|
|
230
|
+
# Until the next timer, the next request (coming in, or waiting for a
|
|
231
|
+
# worker) or response (waiting on its client) to run out of time, the
|
|
232
|
+
# next requests_refused and responses_timed_out lines or the end of the
|
|
233
|
+
# drain, but no wait at all while a body could take more within the
|
|
234
|
+
# send buffers: a Body::Pipe only once its worker has written to it. A
|
|
235
|
+
# worker with a result wakes the loop through @wake_r.
|
|
236
|
+
def select_timeout(room)
|
|
237
|
+
wants_fill = @sessions.each_value.any? { |session|
|
|
238
|
+
session.wants_fill?(@send_buffer_size, connection_limit: @max_connection_send_buffer, room: room)
|
|
239
|
+
}
|
|
240
|
+
return 0 if wants_fill
|
|
122
241
|
|
|
123
242
|
timeouts = []
|
|
124
243
|
expiry = @endpoint.expiry
|
|
125
244
|
timeouts << [(expiry - now_ns) / 1e9, 0.0].max if expiry
|
|
245
|
+
deadline = @sessions.each_value.filter_map(&:next_request_deadline).min
|
|
246
|
+
timeouts << [deadline - now, 0.0].max if deadline
|
|
247
|
+
deadline = @sessions.each_value.filter_map(&:next_response_deadline).min
|
|
248
|
+
timeouts << [deadline - now, 0.0].max if deadline
|
|
249
|
+
timeouts << [refusal_log_at - now, 0.0].max if refusal_log_at
|
|
126
250
|
timeouts << [@drain_deadline - now, 0.0].max if @drain_deadline
|
|
127
251
|
timeouts.min
|
|
128
252
|
end
|
|
@@ -142,40 +266,271 @@ module HTTP3
|
|
|
142
266
|
conn = @endpoint.read_pkt(data, remote: sender.to_sockaddr)
|
|
143
267
|
next if conn.nil? || @sessions.key?(conn)
|
|
144
268
|
|
|
145
|
-
|
|
146
|
-
conn, max_request_body: @max_request_body, max_field_section_size: @max_field_section_size
|
|
147
|
-
)
|
|
148
|
-
@remotes[conn] = sender
|
|
269
|
+
attach_session(conn, sender)
|
|
149
270
|
end
|
|
150
271
|
end
|
|
151
272
|
|
|
273
|
+
# Serve a new connection. One whose Session cannot be set up is logged
|
|
274
|
+
# and closed, and the loop goes on with the others.
|
|
275
|
+
def attach_session(conn, sender)
|
|
276
|
+
@sessions[conn] = Session.attach(
|
|
277
|
+
conn, max_request_body: @max_request_body, max_field_section_size: @max_field_section_size,
|
|
278
|
+
max_connection_request_buffer: @max_connection_request_buffer, shared_buffer: @request_buffer,
|
|
279
|
+
request_timeout: @request_timeout, request_body_buffer_size: @request_body_buffer_size,
|
|
280
|
+
max_connection_request_spool: @max_connection_request_spool,
|
|
281
|
+
max_connection_request_spool_files: @max_connection_request_spool_files, shared_spool: @request_spool,
|
|
282
|
+
response_timeout: @response_timeout
|
|
283
|
+
)
|
|
284
|
+
@remotes[conn] = sender
|
|
285
|
+
rescue => e
|
|
286
|
+
log(:error, "session_error", remote: format_remote(sender), **error_fields(e))
|
|
287
|
+
begin
|
|
288
|
+
conn.close(error_code: Nghttp3::H3_INTERNAL_ERROR)
|
|
289
|
+
rescue QUIC::Error
|
|
290
|
+
nil
|
|
291
|
+
end
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
# Refuse with 408 the requests that ran out of time; serve_requests
|
|
295
|
+
# answers them.
|
|
296
|
+
def expire_requests
|
|
297
|
+
at = now
|
|
298
|
+
@sessions.each_value { |session| session.expire_requests(at) }
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
# Abort the responses that have waited on their clients
|
|
302
|
+
# response_timeout, and count them for the log.
|
|
303
|
+
def expire_responses
|
|
304
|
+
at = now
|
|
305
|
+
count = @sessions.each_value.sum { |session| session.expire_responses(at) }
|
|
306
|
+
count_timeouts(count) if count.positive?
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
# Answer the refused requests at once, then hand those received in full
|
|
310
|
+
# to the free workers, one at a time (#next_request).
|
|
152
311
|
def serve_requests
|
|
153
312
|
@sessions.each do |conn, session|
|
|
154
|
-
session.
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
session.respond(request, response) { |error| log_body_error(conn, request, error) }
|
|
158
|
-
rescue Nghttp3::Error
|
|
159
|
-
# The stream went away while the application ran.
|
|
160
|
-
discard_body(response.body)
|
|
161
|
-
end
|
|
313
|
+
session.take_refusals.each do |request, status|
|
|
314
|
+
count_refusal(status)
|
|
315
|
+
respond(conn, session, request, Response.new(status: status, headers: [], body: nil))
|
|
162
316
|
end
|
|
163
317
|
end
|
|
318
|
+
while @jobs.size < @threads
|
|
319
|
+
conn, request = next_request
|
|
320
|
+
break if request.nil?
|
|
321
|
+
|
|
322
|
+
dispatch(conn, request)
|
|
323
|
+
end
|
|
324
|
+
@adapter.take_refusals.each { |status, count| count_refusal(status, count) }
|
|
164
325
|
end
|
|
165
326
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
327
|
+
# A method of its own, so that each block posted has a job of its own:
|
|
328
|
+
# a while loop's body shares its variables between rounds.
|
|
329
|
+
def dispatch(conn, request)
|
|
330
|
+
job = Job.new(conn, @sessions[conn], request, @remotes[conn], nil, false)
|
|
331
|
+
@jobs[job] = true
|
|
332
|
+
@pool.post { work(job) }
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
# The next request for the application and its connection, or nil. The
|
|
336
|
+
# connections take turns, one request each, and the turns go on from
|
|
337
|
+
# one call to the next, across turns of the loop: counted from the
|
|
338
|
+
# first connection every time, the oldest ones would always go first.
|
|
339
|
+
def next_request
|
|
340
|
+
conns = @sessions.keys
|
|
341
|
+
conns.size.times do
|
|
342
|
+
@next_turn = 0 if @next_turn >= conns.size
|
|
343
|
+
conn = conns[@next_turn]
|
|
344
|
+
@next_turn += 1
|
|
345
|
+
request = @sessions[conn].next_request
|
|
346
|
+
return [conn, request] if request
|
|
347
|
+
end
|
|
348
|
+
nil
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
def respond(conn, session, request, response)
|
|
352
|
+
session.respond(request, response) { |error| log_body_error(conn, request, error) }
|
|
353
|
+
rescue Nghttp3::Error
|
|
354
|
+
# The stream went away while the application ran.
|
|
355
|
+
discard_body(response.body)
|
|
171
356
|
end
|
|
172
357
|
|
|
173
|
-
|
|
358
|
+
# On a worker, unless stop_workers has let go of the job before a
|
|
359
|
+
# worker took it.
|
|
360
|
+
def work(job)
|
|
361
|
+
@jobs_lock.synchronize do
|
|
362
|
+
return if @workers_stopped
|
|
363
|
+
|
|
364
|
+
job.started = true
|
|
365
|
+
end
|
|
366
|
+
run_job(job)
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
# On a worker: call the application for job's request, hand the
|
|
370
|
+
# response to the loop, write its body, if it is read with each, and
|
|
371
|
+
# tell the loop the job is done, once the request's input is closed.
|
|
372
|
+
# The loop does the logging.
|
|
373
|
+
def run_job(job)
|
|
374
|
+
begin
|
|
375
|
+
response, fill = @adapter.call(job.request, remote: job.remote)
|
|
376
|
+
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
377
|
+
put_result(:error, job, e)
|
|
378
|
+
return
|
|
379
|
+
end
|
|
380
|
+
body = response.body
|
|
381
|
+
# With the lock, so that stop_workers, which takes no results after
|
|
382
|
+
# it, either cancels the pipe and takes this response, or finds the
|
|
383
|
+
# workers stopped here; then the body goes nowhere.
|
|
384
|
+
sent = @jobs_lock.synchronize do
|
|
385
|
+
job.pipe = body if body.is_a?(Body::Pipe)
|
|
386
|
+
put_result(:response, job, response) unless @workers_stopped
|
|
387
|
+
!@workers_stopped
|
|
388
|
+
end
|
|
389
|
+
drop_body(body) unless sent
|
|
390
|
+
begin
|
|
391
|
+
late_error = fill&.call
|
|
392
|
+
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
393
|
+
# fill fails the pipe itself; what gets out of it is the close of
|
|
394
|
+
# the Rack body, after the body has ended.
|
|
395
|
+
body.fail(e)
|
|
396
|
+
close_error = e
|
|
397
|
+
end
|
|
398
|
+
# What the body raised once its stream could no longer be aborted
|
|
399
|
+
# (after it closed the stream, say): the response has been handed
|
|
400
|
+
# over, so it is only logged.
|
|
401
|
+
put_result(:error, job, late_error) if late_error
|
|
402
|
+
ensure
|
|
403
|
+
close_input(job.request.input)
|
|
404
|
+
put_result(:done, job, close_error)
|
|
405
|
+
end
|
|
406
|
+
|
|
407
|
+
# From a worker: kind is :response (value the Response), :error (value
|
|
408
|
+
# what the application raised, or what its body raised after its
|
|
409
|
+
# response was handed over) or :done (value an exception from the close
|
|
410
|
+
# of the Rack body, or nil).
|
|
411
|
+
def put_result(kind, job, value)
|
|
412
|
+
@results << [kind, job, value]
|
|
413
|
+
wake
|
|
414
|
+
end
|
|
415
|
+
|
|
416
|
+
# Wake the loop, from any thread. A full pipe wakes it already.
|
|
417
|
+
def wake
|
|
418
|
+
@wake_w.write_nonblock(".", exception: false)
|
|
419
|
+
rescue IOError, SystemCallError
|
|
420
|
+
# #run has ended, and closed the pipe.
|
|
421
|
+
nil
|
|
422
|
+
end
|
|
423
|
+
|
|
424
|
+
# A body that goes nowhere, without a word: a Body::Pipe is cancelled,
|
|
425
|
+
# so that the worker's next write stops it, and a Body::File's own IO
|
|
426
|
+
# closed.
|
|
427
|
+
def drop_body(body)
|
|
428
|
+
case body
|
|
429
|
+
when Body::Pipe then body.cancel
|
|
430
|
+
when Body::File then body.io.close unless body.io.closed?
|
|
431
|
+
end
|
|
432
|
+
rescue IOError, SystemCallError
|
|
433
|
+
nil
|
|
434
|
+
end
|
|
435
|
+
|
|
436
|
+
def close_input(input)
|
|
437
|
+
input.close unless input.closed?
|
|
438
|
+
rescue IOError, SystemCallError
|
|
439
|
+
nil
|
|
440
|
+
end
|
|
441
|
+
|
|
442
|
+
def drain_wake
|
|
443
|
+
loop do
|
|
444
|
+
break unless @wake_r.read_nonblock(4096, exception: false).is_a?(String)
|
|
445
|
+
end
|
|
446
|
+
end
|
|
447
|
+
|
|
448
|
+
# What the workers put, in the order they put it.
|
|
449
|
+
def take_results
|
|
450
|
+
until @results.empty?
|
|
451
|
+
kind, job, value = @results.pop
|
|
452
|
+
case kind
|
|
453
|
+
when :response
|
|
454
|
+
deliver(job, value)
|
|
455
|
+
when :error
|
|
456
|
+
log_app_error(remote_label(job.remote), job.request, value)
|
|
457
|
+
# A request whose response came before finds it answered, and gets
|
|
458
|
+
# no 500.
|
|
459
|
+
deliver(job, Response.new(status: 500, headers: [], body: nil))
|
|
460
|
+
when :done
|
|
461
|
+
@jobs.delete(job)
|
|
462
|
+
log_body_close_error(job.request.stream_id, value) if value
|
|
463
|
+
job.session.finish(job.request.stream_id)
|
|
464
|
+
end
|
|
465
|
+
end
|
|
466
|
+
end
|
|
467
|
+
|
|
468
|
+
# Send response for job, unless its request can no longer be answered:
|
|
469
|
+
# the client reset it or sent STOP_SENDING, or its stream or connection
|
|
470
|
+
# is gone.
|
|
471
|
+
def deliver(job, response)
|
|
472
|
+
session = job.session
|
|
473
|
+
if @sessions[job.conn].equal?(session) && session.answerable?(job.request.stream_id)
|
|
474
|
+
respond(job.conn, session, job.request, response)
|
|
475
|
+
else
|
|
476
|
+
discard_body(response.body)
|
|
477
|
+
end
|
|
478
|
+
end
|
|
479
|
+
|
|
480
|
+
def count_refusal(status, count = 1)
|
|
481
|
+
@refusals_since ||= now
|
|
482
|
+
@refusals[status] += count
|
|
483
|
+
end
|
|
484
|
+
|
|
485
|
+
# Counted with the refusals, and logged with them.
|
|
486
|
+
def count_timeouts(count)
|
|
487
|
+
@refusals_since ||= now
|
|
488
|
+
@responses_timed_out += count
|
|
489
|
+
end
|
|
490
|
+
|
|
491
|
+
# When the refusals and timeouts counted are due to be logged; nil while
|
|
492
|
+
# there are none.
|
|
493
|
+
def refusal_log_at
|
|
494
|
+
@refusals_since && @refusals_since + @refusal_log_interval
|
|
495
|
+
end
|
|
496
|
+
|
|
497
|
+
def refusals_due?
|
|
498
|
+
at = refusal_log_at
|
|
499
|
+
!at.nil? && at <= now
|
|
500
|
+
end
|
|
501
|
+
|
|
502
|
+
# One line per status, in ascending order, then one for the timeouts, if
|
|
503
|
+
# any, with the seconds since the first of them; the count starts over.
|
|
504
|
+
def log_refusals
|
|
505
|
+
return if @refusals_since.nil?
|
|
506
|
+
|
|
507
|
+
seconds = (now - @refusals_since).round
|
|
508
|
+
@refusals.sort.each do |status, count|
|
|
509
|
+
log(:warn, "requests_refused", status: status, count: count, seconds: seconds)
|
|
510
|
+
end
|
|
511
|
+
if @responses_timed_out.positive?
|
|
512
|
+
log(:warn, "responses_timed_out", count: @responses_timed_out, seconds: seconds)
|
|
513
|
+
end
|
|
514
|
+
@refusals.clear
|
|
515
|
+
@responses_timed_out = 0
|
|
516
|
+
@refusals_since = nil
|
|
517
|
+
end
|
|
518
|
+
|
|
519
|
+
# The connections are filled in the order they came, so that while the
|
|
520
|
+
# total is short the older ones get its room first.
|
|
521
|
+
def fill_bodies(room)
|
|
174
522
|
@sessions.each do |conn, session|
|
|
175
|
-
session.fill_bodies(@send_buffer_size
|
|
523
|
+
room -= session.fill_bodies(@send_buffer_size, connection_limit: @max_connection_send_buffer,
|
|
524
|
+
room: room) { |request, error| log_app_error(remote_of(conn), request, error) }
|
|
176
525
|
end
|
|
177
526
|
end
|
|
178
527
|
|
|
528
|
+
# What max_total_send_buffer has left for array, each and streaming
|
|
529
|
+
# bodies.
|
|
530
|
+
def send_room
|
|
531
|
+
@max_total_send_buffer - @sessions.each_value.sum(&:unsent_total)
|
|
532
|
+
end
|
|
533
|
+
|
|
179
534
|
# Send what the Endpoint has, until the socket would block.
|
|
180
535
|
def flush
|
|
181
536
|
loop do
|
|
@@ -204,6 +559,9 @@ module HTTP3
|
|
|
204
559
|
if (error = session.take_error)
|
|
205
560
|
log(:error, "session_error", remote: remote_of(conn), **error_fields(error))
|
|
206
561
|
end
|
|
562
|
+
if (error = session.take_spool_error)
|
|
563
|
+
log_spool_failure(conn, error)
|
|
564
|
+
end
|
|
207
565
|
session.release_closed { |stream_id, e| log_body_close_error(stream_id, e) }
|
|
208
566
|
end
|
|
209
567
|
@endpoint.take_closed.each do |conn|
|
|
@@ -246,6 +604,38 @@ module HTTP3
|
|
|
246
604
|
error: e.class, message: e.message)
|
|
247
605
|
end
|
|
248
606
|
|
|
607
|
+
# Cancel the Body::Pipes of the jobs under way, so that the workers
|
|
608
|
+
# writing to them stop, let go of the jobs no worker has taken, which
|
|
609
|
+
# the pool drops, and wait a second for the workers, as
|
|
610
|
+
# close_connections waits for the CONNECTION_CLOSEs to go. A worker
|
|
611
|
+
# still in the application then is left to it, and counted in a warn
|
|
612
|
+
# line. What the workers have put and the loop not taken is dropped.
|
|
613
|
+
def stop_workers
|
|
614
|
+
waiting = @jobs_lock.synchronize do
|
|
615
|
+
@workers_stopped = true
|
|
616
|
+
@jobs.each_key { |job| job.pipe&.cancel }
|
|
617
|
+
@jobs.keys.reject(&:started)
|
|
618
|
+
end
|
|
619
|
+
waiting.each { |job| end_job(job) }
|
|
620
|
+
running = @pool.stop(1)
|
|
621
|
+
until @results.empty?
|
|
622
|
+
kind, job, value = @results.pop
|
|
623
|
+
case kind
|
|
624
|
+
when :response then drop_body(value.body)
|
|
625
|
+
when :done then end_job(job)
|
|
626
|
+
end
|
|
627
|
+
end
|
|
628
|
+
log(:warn, "workers_still_running", count: running) if running.positive?
|
|
629
|
+
end
|
|
630
|
+
|
|
631
|
+
# A job no longer to be heard from: its input is closed, and what its
|
|
632
|
+
# request holds no longer counts.
|
|
633
|
+
def end_job(job)
|
|
634
|
+
@jobs.delete(job)
|
|
635
|
+
close_input(job.request.input)
|
|
636
|
+
job.session.finish(job.request.stream_id)
|
|
637
|
+
end
|
|
638
|
+
|
|
249
639
|
# Close every connection and send the CONNECTION_CLOSEs, without
|
|
250
640
|
# waiting out the closing period.
|
|
251
641
|
def close_connections
|
|
@@ -282,6 +672,104 @@ module HTTP3
|
|
|
282
672
|
previous.each { |name, handler| Signal.trap(name, handler || "DEFAULT") }
|
|
283
673
|
end
|
|
284
674
|
|
|
675
|
+
# Raise ArgumentError for the first request limit found wrong: each has
|
|
676
|
+
# to be an Integer in range, and the limits have to let a request at
|
|
677
|
+
# max_request_body and max_field_section_size through on a server with
|
|
678
|
+
# nothing else under way. A body over request_body_buffer_size goes to a
|
|
679
|
+
# temporary file, so then memory has to hold only that much of it, and
|
|
680
|
+
# the spool all of it.
|
|
681
|
+
def check_request_limits(max_request_body:, max_field_section_size:, max_connection_request_buffer:,
|
|
682
|
+
max_total_request_buffer:, request_body_buffer_size:, max_connection_request_spool:,
|
|
683
|
+
max_total_request_spool:, max_connection_request_spool_files:, max_total_request_spool_files:)
|
|
684
|
+
unless max_request_body.is_a?(Integer) && max_request_body >= 0
|
|
685
|
+
raise ArgumentError, "max_request_body must be an Integer of 0 or more (got #{max_request_body.inspect})"
|
|
686
|
+
end
|
|
687
|
+
# The range Session._attach takes, which would otherwise raise only
|
|
688
|
+
# on the first connection.
|
|
689
|
+
unless max_field_section_size.is_a?(Integer) && (1...2**62).cover?(max_field_section_size)
|
|
690
|
+
raise ArgumentError, "max_field_section_size must be 1..2**62-1 (got #{max_field_section_size.inspect})"
|
|
691
|
+
end
|
|
692
|
+
{max_connection_request_buffer:, max_total_request_buffer:, request_body_buffer_size:,
|
|
693
|
+
max_connection_request_spool:, max_total_request_spool:, max_connection_request_spool_files:,
|
|
694
|
+
max_total_request_spool_files:}.each do |name, value|
|
|
695
|
+
raise ArgumentError, "#{name} must be a positive Integer (got #{value.inspect})" unless value.is_a?(Integer) && value.positive?
|
|
696
|
+
end
|
|
697
|
+
spooled = max_request_body > request_body_buffer_size
|
|
698
|
+
in_memory, terms = if spooled
|
|
699
|
+
[request_body_buffer_size, "request_body_buffer_size + max_field_section_size"]
|
|
700
|
+
else
|
|
701
|
+
[max_request_body, "max_request_body + max_field_section_size"]
|
|
702
|
+
end
|
|
703
|
+
request = in_memory + max_field_section_size
|
|
704
|
+
if max_connection_request_buffer < request
|
|
705
|
+
raise ArgumentError, "max_connection_request_buffer (#{max_connection_request_buffer}) must be at least " \
|
|
706
|
+
"#{terms} (#{request})"
|
|
707
|
+
end
|
|
708
|
+
if max_total_request_buffer < max_connection_request_buffer
|
|
709
|
+
raise ArgumentError, "max_total_request_buffer (#{max_total_request_buffer}) must be at least " \
|
|
710
|
+
"max_connection_request_buffer (#{max_connection_request_buffer})"
|
|
711
|
+
end
|
|
712
|
+
if spooled && max_connection_request_spool < max_request_body
|
|
713
|
+
raise ArgumentError, "max_connection_request_spool (#{max_connection_request_spool}) must be at least " \
|
|
714
|
+
"max_request_body (#{max_request_body})"
|
|
715
|
+
end
|
|
716
|
+
if max_total_request_spool < max_connection_request_spool
|
|
717
|
+
raise ArgumentError, "max_total_request_spool (#{max_total_request_spool}) must be at least " \
|
|
718
|
+
"max_connection_request_spool (#{max_connection_request_spool})"
|
|
719
|
+
end
|
|
720
|
+
if max_total_request_spool_files < max_connection_request_spool_files
|
|
721
|
+
raise ArgumentError, "max_total_request_spool_files (#{max_total_request_spool_files}) must be at least " \
|
|
722
|
+
"max_connection_request_spool_files (#{max_connection_request_spool_files})"
|
|
723
|
+
end
|
|
724
|
+
end
|
|
725
|
+
|
|
726
|
+
# Raise ArgumentError for the first send limit found wrong: each has to
|
|
727
|
+
# be a positive Integer, at least the one before.
|
|
728
|
+
def check_send_limits(send_buffer_size, max_connection_send_buffer, max_total_send_buffer)
|
|
729
|
+
{send_buffer_size:, max_connection_send_buffer:, max_total_send_buffer:}.each do |name, value|
|
|
730
|
+
raise ArgumentError, "#{name} must be a positive Integer (got #{value.inspect})" unless value.is_a?(Integer) && value.positive?
|
|
731
|
+
end
|
|
732
|
+
if max_connection_send_buffer < send_buffer_size
|
|
733
|
+
raise ArgumentError, "max_connection_send_buffer (#{max_connection_send_buffer}) must be at least " \
|
|
734
|
+
"send_buffer_size (#{send_buffer_size})"
|
|
735
|
+
end
|
|
736
|
+
if max_total_send_buffer < max_connection_send_buffer
|
|
737
|
+
raise ArgumentError, "max_total_send_buffer (#{max_total_send_buffer}) must be at least " \
|
|
738
|
+
"max_connection_send_buffer (#{max_connection_send_buffer})"
|
|
739
|
+
end
|
|
740
|
+
end
|
|
741
|
+
|
|
742
|
+
def check_threads(threads)
|
|
743
|
+
raise ArgumentError, "threads must be a positive Integer (got #{threads.inspect})" unless threads.is_a?(Integer) && threads.positive?
|
|
744
|
+
end
|
|
745
|
+
|
|
746
|
+
def check_request_timeout(request_timeout)
|
|
747
|
+
return if request_timeout.nil?
|
|
748
|
+
return if (request_timeout.is_a?(Integer) || request_timeout.is_a?(Float)) && request_timeout.positive?
|
|
749
|
+
|
|
750
|
+
raise ArgumentError, "request_timeout must be nil or a positive number of seconds (got #{request_timeout.inspect})"
|
|
751
|
+
end
|
|
752
|
+
|
|
753
|
+
# response_timeout has to be less than request_timeout, so that the
|
|
754
|
+
# workers clients that stop reading keep are freed before the requests
|
|
755
|
+
# waiting for them get 503.
|
|
756
|
+
def check_response_timeout(response_timeout, request_timeout)
|
|
757
|
+
return if response_timeout.nil?
|
|
758
|
+
|
|
759
|
+
seconds = (response_timeout.is_a?(Integer) || response_timeout.is_a?(Float)) && response_timeout.positive?
|
|
760
|
+
raise ArgumentError, "response_timeout must be nil or a positive number of seconds (got #{response_timeout.inspect})" unless seconds
|
|
761
|
+
return if request_timeout.nil? || response_timeout < request_timeout
|
|
762
|
+
|
|
763
|
+
raise ArgumentError, "response_timeout (#{response_timeout}) must be less than request_timeout (#{request_timeout})"
|
|
764
|
+
end
|
|
765
|
+
|
|
766
|
+
def check_refusal_log_interval(refusal_log_interval)
|
|
767
|
+
return if (refusal_log_interval.is_a?(Integer) || refusal_log_interval.is_a?(Float)) && refusal_log_interval.positive?
|
|
768
|
+
|
|
769
|
+
raise ArgumentError,
|
|
770
|
+
"refusal_log_interval must be a positive number of seconds (got #{refusal_log_interval.inspect})"
|
|
771
|
+
end
|
|
772
|
+
|
|
285
773
|
def parse_bind(bind)
|
|
286
774
|
match = bind.match(/\A\[(?<host>[^\]]+)\]:(?<port>\d+)\z/) || bind.match(/\A(?<host>[^:\[\]]+):(?<port>\d+)\z/)
|
|
287
775
|
raise ArgumentError, "bind must be \"host:port\" or \"[ipv6]:port\" (got #{bind.inspect})" unless match
|
|
@@ -293,22 +781,37 @@ module HTTP3
|
|
|
293
781
|
address
|
|
294
782
|
end
|
|
295
783
|
|
|
784
|
+
# A body that will not be sent: a Body::Pipe is cancelled, so that its
|
|
785
|
+
# worker stops writing, and a Body::File's own IO closed. The Rack body
|
|
786
|
+
# is the worker's to close.
|
|
296
787
|
def discard_body(body)
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
body.io.close
|
|
300
|
-
|
|
788
|
+
case body
|
|
789
|
+
when Body::Pipe then body.cancel
|
|
790
|
+
when Body::File then body.io.close unless body.io.closed?
|
|
791
|
+
end
|
|
301
792
|
rescue => e
|
|
302
793
|
log(:error, "body_close_error", **error_fields(e))
|
|
303
794
|
end
|
|
304
795
|
|
|
305
|
-
def log_app_error(
|
|
796
|
+
def log_app_error(remote, request, error)
|
|
306
797
|
pseudo = request.headers.select { |name, _| name.start_with?(":") }.to_h
|
|
307
798
|
path = pseudo.fetch(":path", "")[/\A[^?]*/]
|
|
308
|
-
log(:error, "app_error", remote:
|
|
799
|
+
log(:error, "app_error", remote: remote, stream: request.stream_id, method: pseudo[":method"],
|
|
309
800
|
path: path, **error_fields(error))
|
|
310
801
|
end
|
|
311
802
|
|
|
803
|
+
# A temporary file for a request body could not be made or written
|
|
804
|
+
# (the request got 503). While the disk is full every large request
|
|
805
|
+
# fails, so after one line, those within refusal_log_interval seconds
|
|
806
|
+
# are not logged.
|
|
807
|
+
def log_spool_failure(conn, error)
|
|
808
|
+
at = now
|
|
809
|
+
return if @spool_failure_logged_at && at < @spool_failure_logged_at + @refusal_log_interval
|
|
810
|
+
|
|
811
|
+
@spool_failure_logged_at = at
|
|
812
|
+
log(:error, "request_spool_failed", remote: remote_of(conn), error: error.class, message: error.message)
|
|
813
|
+
end
|
|
814
|
+
|
|
312
815
|
def log_body_error(conn, request, error)
|
|
313
816
|
log(:error, "body_error", remote: remote_of(conn), stream: request.stream_id, error: error.class,
|
|
314
817
|
message: error.message)
|
|
@@ -337,7 +840,14 @@ module HTTP3
|
|
|
337
840
|
end
|
|
338
841
|
|
|
339
842
|
def remote_of(conn)
|
|
340
|
-
|
|
843
|
+
remote_label(@remotes[conn])
|
|
844
|
+
end
|
|
845
|
+
|
|
846
|
+
def remote_label(address)
|
|
847
|
+
address ? format_remote(address) : "-"
|
|
848
|
+
end
|
|
849
|
+
|
|
850
|
+
def format_remote(address)
|
|
341
851
|
host = address.ipv6? ? "[#{address.ip_address}]" : address.ip_address
|
|
342
852
|
"#{host}:#{address.ip_port}"
|
|
343
853
|
end
|