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.
data/lib/http3/server.rb CHANGED
@@ -20,13 +20,28 @@ module HTTP3
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
22
  # strings and header values never appear in the log. Those answered
23
- # without the application (400, 408, 413, 431, 501, 503) are counted
24
- # instead: refusal_log_interval seconds after the first of them, and when
25
- # #run ends, a warn line per status tells how many there were since,
26
- # such as "requests_refused status=503 count=1234 seconds=60".
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.
27
35
  class Server
28
36
  ALPN = ["h3"].freeze
29
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
+
30
45
  # What the signal handlers write to the self-pipe.
31
46
  SIGNALS = {"TERM" => "T", "INT" => "I", "HUP" => "H"}.freeze
32
47
 
@@ -46,30 +61,54 @@ module HTTP3
46
61
  # certificate_path is a PEM chain, leaf first; private_key_path a PEM key
47
62
  # without passphrase. qlog_dir writes one qlog file per connection.
48
63
  # max_request_body and max_field_section_size (bytes) bound a request's
49
- # body (413 beyond) and header section (431 beyond).
50
- # max_connection_request_buffer bounds the bytes the requests of a
51
- # connection hold before they reach the application, and
52
- # max_total_request_buffer those of every connection; a request that
53
- # would pass either gets 503. They have to leave room for a request at
54
- # the other two limits, or ArgumentError is raised. send_buffer_size
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
55
77
  # bounds the bytes of a response body queued but unsent per stream,
56
- # max_connection_send_buffer those of the array and each bodies of a
57
- # connection, and max_total_send_buffer those of every connection; a
58
- # body waits for room. Each has to be at least the one before. A request
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
59
81
  # not received in full within request_timeout seconds of the start of
60
- # its headers gets 408; nil is no limit. refusal_log_interval is the
61
- # seconds between the requests_refused lines (see above).
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.
62
91
  def initialize(app:, bind:, certificate_path:, private_key_path:, transport_params: nil, settings: nil,
63
- qlog_dir: nil, max_connections: 1024, max_request_body: 1_048_576, max_field_section_size: 65_536,
92
+ qlog_dir: nil, max_connections: 1024, threads: 3, max_request_body: 1_048_576, max_field_section_size: 65_536,
64
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,
65
97
  send_buffer_size: 65_536, max_connection_send_buffer: 1_048_576, max_total_send_buffer: 67_108_864,
66
- request_timeout: 30, refusal_log_interval: 60, shutdown_timeout: 30, logger: Logger.new($stderr))
67
- check_request_limits(max_request_body, max_field_section_size, max_connection_request_buffer,
68
- max_total_request_buffer)
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
69
105
  check_send_limits(send_buffer_size, max_connection_send_buffer, max_total_send_buffer)
106
+ check_threads(threads)
70
107
  check_request_timeout(request_timeout)
108
+ check_response_timeout(response_timeout, request_timeout)
71
109
  check_refusal_log_interval(refusal_log_interval)
72
- @adapter = RackAdapter.new(app)
110
+ @app = app
111
+ @threads = threads
73
112
  @bind_address = parse_bind(bind)
74
113
  @certificate_path = certificate_path
75
114
  @private_key_path = private_key_path
@@ -84,10 +123,16 @@ module HTTP3
84
123
  @max_field_section_size = max_field_section_size
85
124
  @max_connection_request_buffer = max_connection_request_buffer
86
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
87
131
  @send_buffer_size = send_buffer_size
88
132
  @max_connection_send_buffer = max_connection_send_buffer
89
133
  @max_total_send_buffer = max_total_send_buffer
90
134
  @request_timeout = request_timeout
135
+ @response_timeout = response_timeout
91
136
  @refusal_log_interval = refusal_log_interval
92
137
  @shutdown_timeout = shutdown_timeout
93
138
  @logger = logger
@@ -96,6 +141,17 @@ module HTTP3
96
141
  # Serve until told to stop (see the class comment). Returns nil.
97
142
  def run
98
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))
99
155
  @sock = UDPSocket.new(@bind_address.afamily)
100
156
  @sock.bind(@bind_address.ip_address, @bind_address.ip_port)
101
157
  @local_address = @sock.local_address
@@ -105,24 +161,35 @@ module HTTP3
105
161
  )
106
162
  @sessions = {}
107
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
108
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
109
171
  @blocked_send = nil
110
172
  @phase = :serving
111
173
  @drain_deadline = nil
112
174
  # {status => count} of the requests answered without the
113
175
  # application, since @refusals_since.
114
176
  @refusals = Hash.new(0)
177
+ # The responses aborted after response_timeout, since @refusals_since.
178
+ @responses_timed_out = 0
115
179
  @refusals_since = nil
116
180
  old_traps = install_traps(sig_w)
181
+ @pool = WorkerPool.new(@threads).start
117
182
 
118
183
  turn(sig_r) until finished?
119
184
  close_connections
185
+ stop_workers
120
186
  log_refusals
121
187
  nil
122
188
  ensure
123
189
  restore_traps(old_traps) if old_traps
190
+ stop_workers if @pool && !@workers_stopped
124
191
  @sessions&.each_value { |session| release_session(session) }
125
- [@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? }
126
193
  end
127
194
 
128
195
  private
@@ -134,11 +201,16 @@ module HTTP3
134
201
  def turn(sig_r)
135
202
  room = send_room
136
203
  writers = @blocked_send ? [@sock] : nil
137
- readable, writable, = IO.select([@sock, sig_r], writers, nil, select_timeout(room))
204
+ readable, writable, = IO.select([@sock, sig_r, @wake_r], writers, nil, select_timeout(room))
138
205
  handle_signals(sig_r) if readable&.include?(sig_r)
139
206
  flush if writable&.include?(@sock)
140
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
141
212
  expire_requests
213
+ expire_responses
142
214
  serve_requests
143
215
  log_refusals if refusals_due?
144
216
  fill_bodies(room)
@@ -150,14 +222,17 @@ module HTTP3
150
222
  def finished?
151
223
  case @phase
152
224
  when :serving then false
153
- 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?))
154
226
  else true
155
227
  end
156
228
  end
157
229
 
158
- # Until the next timer, the next request to run out of time, the next
159
- # requests_refused lines or the end of the drain, but no wait at all
160
- # while a body could take more within the send buffers.
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.
161
236
  def select_timeout(room)
162
237
  wants_fill = @sessions.each_value.any? { |session|
163
238
  session.wants_fill?(@send_buffer_size, connection_limit: @max_connection_send_buffer, room: room)
@@ -169,6 +244,8 @@ module HTTP3
169
244
  timeouts << [(expiry - now_ns) / 1e9, 0.0].max if expiry
170
245
  deadline = @sessions.each_value.filter_map(&:next_request_deadline).min
171
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
172
249
  timeouts << [refusal_log_at - now, 0.0].max if refusal_log_at
173
250
  timeouts << [@drain_deadline - now, 0.0].max if @drain_deadline
174
251
  timeouts.min
@@ -199,7 +276,10 @@ module HTTP3
199
276
  @sessions[conn] = Session.attach(
200
277
  conn, max_request_body: @max_request_body, max_field_section_size: @max_field_section_size,
201
278
  max_connection_request_buffer: @max_connection_request_buffer, shared_buffer: @request_buffer,
202
- request_timeout: @request_timeout
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
203
283
  )
204
284
  @remotes[conn] = sender
205
285
  rescue => e
@@ -218,27 +298,183 @@ module HTTP3
218
298
  @sessions.each_value { |session| session.expire_requests(at) }
219
299
  end
220
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).
221
311
  def serve_requests
222
312
  @sessions.each do |conn, session|
223
- session.take_requests.each do |request, status|
224
- count_refusal(status) if status
225
- response = status ? Response.new(status: status, headers: [], body: nil) : call_app(conn, request)
226
- begin
227
- session.respond(request, response) { |error| log_body_error(conn, request, error) }
228
- rescue Nghttp3::Error
229
- # The stream went away while the application ran.
230
- discard_body(response.body)
231
- 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))
232
316
  end
233
317
  end
318
+ while @jobs.size < @threads
319
+ conn, request = next_request
320
+ break if request.nil?
321
+
322
+ dispatch(conn, request)
323
+ end
234
324
  @adapter.take_refusals.each { |status, count| count_refusal(status, count) }
235
325
  end
236
326
 
237
- def call_app(conn, request)
238
- @adapter.call(request, remote: @remotes[conn])
239
- rescue => e
240
- log_app_error(conn, request, e)
241
- Response.new(status: 500, headers: [], body: nil)
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)
356
+ end
357
+
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
242
478
  end
243
479
 
244
480
  def count_refusal(status, count = 1)
@@ -246,8 +482,14 @@ module HTTP3
246
482
  @refusals[status] += count
247
483
  end
248
484
 
249
- # When the refusals counted are due to be logged; nil while there are
250
- # none.
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.
251
493
  def refusal_log_at
252
494
  @refusals_since && @refusals_since + @refusal_log_interval
253
495
  end
@@ -257,8 +499,8 @@ module HTTP3
257
499
  !at.nil? && at <= now
258
500
  end
259
501
 
260
- # One line per status, in ascending order, with the seconds since the
261
- # first of them; the count starts over.
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.
262
504
  def log_refusals
263
505
  return if @refusals_since.nil?
264
506
 
@@ -266,7 +508,11 @@ module HTTP3
266
508
  @refusals.sort.each do |status, count|
267
509
  log(:warn, "requests_refused", status: status, count: count, seconds: seconds)
268
510
  end
511
+ if @responses_timed_out.positive?
512
+ log(:warn, "responses_timed_out", count: @responses_timed_out, seconds: seconds)
513
+ end
269
514
  @refusals.clear
515
+ @responses_timed_out = 0
270
516
  @refusals_since = nil
271
517
  end
272
518
 
@@ -275,11 +521,12 @@ module HTTP3
275
521
  def fill_bodies(room)
276
522
  @sessions.each do |conn, session|
277
523
  room -= session.fill_bodies(@send_buffer_size, connection_limit: @max_connection_send_buffer,
278
- room: room) { |request, error| log_app_error(conn, request, error) }
524
+ room: room) { |request, error| log_app_error(remote_of(conn), request, error) }
279
525
  end
280
526
  end
281
527
 
282
- # What max_total_send_buffer has left for array and each bodies.
528
+ # What max_total_send_buffer has left for array, each and streaming
529
+ # bodies.
283
530
  def send_room
284
531
  @max_total_send_buffer - @sessions.each_value.sum(&:unsent_total)
285
532
  end
@@ -312,6 +559,9 @@ module HTTP3
312
559
  if (error = session.take_error)
313
560
  log(:error, "session_error", remote: remote_of(conn), **error_fields(error))
314
561
  end
562
+ if (error = session.take_spool_error)
563
+ log_spool_failure(conn, error)
564
+ end
315
565
  session.release_closed { |stream_id, e| log_body_close_error(stream_id, e) }
316
566
  end
317
567
  @endpoint.take_closed.each do |conn|
@@ -354,6 +604,38 @@ module HTTP3
354
604
  error: e.class, message: e.message)
355
605
  end
356
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
+
357
639
  # Close every connection and send the CONNECTION_CLOSEs, without
358
640
  # waiting out the closing period.
359
641
  def close_connections
@@ -391,11 +673,14 @@ module HTTP3
391
673
  end
392
674
 
393
675
  # Raise ArgumentError for the first request limit found wrong: each has
394
- # to be an Integer in range, and the buffers have to let a request at
676
+ # to be an Integer in range, and the limits have to let a request at
395
677
  # max_request_body and max_field_section_size through on a server with
396
- # nothing else under way.
397
- def check_request_limits(max_request_body, max_field_section_size, max_connection_request_buffer,
398
- max_total_request_buffer)
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:)
399
684
  unless max_request_body.is_a?(Integer) && max_request_body >= 0
400
685
  raise ArgumentError, "max_request_body must be an Integer of 0 or more (got #{max_request_body.inspect})"
401
686
  end
@@ -404,18 +689,38 @@ module HTTP3
404
689
  unless max_field_section_size.is_a?(Integer) && (1...2**62).cover?(max_field_section_size)
405
690
  raise ArgumentError, "max_field_section_size must be 1..2**62-1 (got #{max_field_section_size.inspect})"
406
691
  end
407
- {max_connection_request_buffer:, max_total_request_buffer:}.each do |name, value|
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|
408
695
  raise ArgumentError, "#{name} must be a positive Integer (got #{value.inspect})" unless value.is_a?(Integer) && value.positive?
409
696
  end
410
- request = max_request_body + max_field_section_size
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
411
704
  if max_connection_request_buffer < request
412
705
  raise ArgumentError, "max_connection_request_buffer (#{max_connection_request_buffer}) must be at least " \
413
- "max_request_body + max_field_section_size (#{request})"
706
+ "#{terms} (#{request})"
414
707
  end
415
708
  if max_total_request_buffer < max_connection_request_buffer
416
709
  raise ArgumentError, "max_total_request_buffer (#{max_total_request_buffer}) must be at least " \
417
710
  "max_connection_request_buffer (#{max_connection_request_buffer})"
418
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
419
724
  end
420
725
 
421
726
  # Raise ArgumentError for the first send limit found wrong: each has to
@@ -434,6 +739,10 @@ module HTTP3
434
739
  end
435
740
  end
436
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
+
437
746
  def check_request_timeout(request_timeout)
438
747
  return if request_timeout.nil?
439
748
  return if (request_timeout.is_a?(Integer) || request_timeout.is_a?(Float)) && request_timeout.positive?
@@ -441,6 +750,19 @@ module HTTP3
441
750
  raise ArgumentError, "request_timeout must be nil or a positive number of seconds (got #{request_timeout.inspect})"
442
751
  end
443
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
+
444
766
  def check_refusal_log_interval(refusal_log_interval)
445
767
  return if (refusal_log_interval.is_a?(Integer) || refusal_log_interval.is_a?(Float)) && refusal_log_interval.positive?
446
768
 
@@ -459,22 +781,37 @@ module HTTP3
459
781
  address
460
782
  end
461
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.
462
787
  def discard_body(body)
463
- return if body.nil?
464
-
465
- body.io.close if body.is_a?(Body::File) && !body.io.closed?
466
- body.rack_body.close if body.rack_body.respond_to?(:close)
788
+ case body
789
+ when Body::Pipe then body.cancel
790
+ when Body::File then body.io.close unless body.io.closed?
791
+ end
467
792
  rescue => e
468
793
  log(:error, "body_close_error", **error_fields(e))
469
794
  end
470
795
 
471
- def log_app_error(conn, request, error)
796
+ def log_app_error(remote, request, error)
472
797
  pseudo = request.headers.select { |name, _| name.start_with?(":") }.to_h
473
798
  path = pseudo.fetch(":path", "")[/\A[^?]*/]
474
- log(:error, "app_error", remote: remote_of(conn), stream: request.stream_id, method: pseudo[":method"],
799
+ log(:error, "app_error", remote: remote, stream: request.stream_id, method: pseudo[":method"],
475
800
  path: path, **error_fields(error))
476
801
  end
477
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
+
478
815
  def log_body_error(conn, request, error)
479
816
  log(:error, "body_error", remote: remote_of(conn), stream: request.stream_id, error: error.class,
480
817
  message: error.message)
@@ -503,8 +840,11 @@ module HTTP3
503
840
  end
504
841
 
505
842
  def remote_of(conn)
506
- address = @remotes[conn] or return "-"
507
- format_remote(address)
843
+ remote_label(@remotes[conn])
844
+ end
845
+
846
+ def remote_label(address)
847
+ address ? format_remote(address) : "-"
508
848
  end
509
849
 
510
850
  def format_remote(address)