http3 0.0.3 → 0.0.4

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
@@ -19,7 +19,11 @@ 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) 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
27
  class Server
24
28
  ALPN = ["h3"].freeze
25
29
 
@@ -42,11 +46,29 @@ module HTTP3
42
46
  # certificate_path is a PEM chain, leaf first; private_key_path a PEM key
43
47
  # without passphrase. qlog_dir writes one qlog file per connection.
44
48
  # max_request_body and max_field_section_size (bytes) bound a request's
45
- # body (413 beyond) and header section (431 beyond); send_buffer_size
46
- # bounds the bytes of a response body queued but unsent per stream.
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
55
+ # 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
59
+ # 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).
47
62
  def initialize(app:, bind:, certificate_path:, private_key_path:, transport_params: nil, settings: nil,
48
63
  qlog_dir: nil, max_connections: 1024, max_request_body: 1_048_576, max_field_section_size: 65_536,
49
- send_buffer_size: 65_536, shutdown_timeout: 30, logger: Logger.new($stderr))
64
+ max_connection_request_buffer: 4_194_304, max_total_request_buffer: 67_108_864,
65
+ 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)
69
+ check_send_limits(send_buffer_size, max_connection_send_buffer, max_total_send_buffer)
70
+ check_request_timeout(request_timeout)
71
+ check_refusal_log_interval(refusal_log_interval)
50
72
  @adapter = RackAdapter.new(app)
51
73
  @bind_address = parse_bind(bind)
52
74
  @certificate_path = certificate_path
@@ -60,7 +82,13 @@ module HTTP3
60
82
  @max_connections = max_connections
61
83
  @max_request_body = max_request_body
62
84
  @max_field_section_size = max_field_section_size
85
+ @max_connection_request_buffer = max_connection_request_buffer
86
+ @max_total_request_buffer = max_total_request_buffer
63
87
  @send_buffer_size = send_buffer_size
88
+ @max_connection_send_buffer = max_connection_send_buffer
89
+ @max_total_send_buffer = max_total_send_buffer
90
+ @request_timeout = request_timeout
91
+ @refusal_log_interval = refusal_log_interval
64
92
  @shutdown_timeout = shutdown_timeout
65
93
  @logger = logger
66
94
  end
@@ -77,13 +105,19 @@ module HTTP3
77
105
  )
78
106
  @sessions = {}
79
107
  @remotes = {}
108
+ @request_buffer = Session.shared_buffer(@max_total_request_buffer)
80
109
  @blocked_send = nil
81
110
  @phase = :serving
82
111
  @drain_deadline = nil
112
+ # {status => count} of the requests answered without the
113
+ # application, since @refusals_since.
114
+ @refusals = Hash.new(0)
115
+ @refusals_since = nil
83
116
  old_traps = install_traps(sig_w)
84
117
 
85
118
  turn(sig_r) until finished?
86
119
  close_connections
120
+ log_refusals
87
121
  nil
88
122
  ensure
89
123
  restore_traps(old_traps) if old_traps
@@ -93,15 +127,21 @@ module HTTP3
93
127
 
94
128
  private
95
129
 
96
- # One turn of the loop.
130
+ # One turn of the loop. The room left in max_total_send_buffer is
131
+ # counted once, at the start. Only fill_bodies queues more, so until
132
+ # then the room can only have grown (nghttp3 taking what flush sends),
133
+ # and filling by the room counted queues no more than the limit allows.
97
134
  def turn(sig_r)
135
+ room = send_room
98
136
  writers = @blocked_send ? [@sock] : nil
99
- readable, writable, = IO.select([@sock, sig_r], writers, nil, select_timeout)
137
+ readable, writable, = IO.select([@sock, sig_r], writers, nil, select_timeout(room))
100
138
  handle_signals(sig_r) if readable&.include?(sig_r)
101
139
  flush if writable&.include?(@sock)
102
140
  receive if readable&.include?(@sock)
141
+ expire_requests
103
142
  serve_requests
104
- fill_bodies
143
+ log_refusals if refusals_due?
144
+ fill_bodies(room)
105
145
  @endpoint.handle_expiry if due?(@endpoint.expiry)
106
146
  flush
107
147
  reap
@@ -115,14 +155,21 @@ module HTTP3
115
155
  end
116
156
  end
117
157
 
118
- # Until the next timer or the end of the drain, but no wait at all while
119
- # a body could take more.
120
- def select_timeout
121
- return 0 if @sessions.each_value.any? { |session| session.wants_fill?(@send_buffer_size) }
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.
161
+ def select_timeout(room)
162
+ wants_fill = @sessions.each_value.any? { |session|
163
+ session.wants_fill?(@send_buffer_size, connection_limit: @max_connection_send_buffer, room: room)
164
+ }
165
+ return 0 if wants_fill
122
166
 
123
167
  timeouts = []
124
168
  expiry = @endpoint.expiry
125
169
  timeouts << [(expiry - now_ns) / 1e9, 0.0].max if expiry
170
+ deadline = @sessions.each_value.filter_map(&:next_request_deadline).min
171
+ timeouts << [deadline - now, 0.0].max if deadline
172
+ timeouts << [refusal_log_at - now, 0.0].max if refusal_log_at
126
173
  timeouts << [@drain_deadline - now, 0.0].max if @drain_deadline
127
174
  timeouts.min
128
175
  end
@@ -142,16 +189,39 @@ module HTTP3
142
189
  conn = @endpoint.read_pkt(data, remote: sender.to_sockaddr)
143
190
  next if conn.nil? || @sessions.key?(conn)
144
191
 
145
- @sessions[conn] = Session.attach(
146
- conn, max_request_body: @max_request_body, max_field_section_size: @max_field_section_size
147
- )
148
- @remotes[conn] = sender
192
+ attach_session(conn, sender)
149
193
  end
150
194
  end
151
195
 
196
+ # Serve a new connection. One whose Session cannot be set up is logged
197
+ # and closed, and the loop goes on with the others.
198
+ def attach_session(conn, sender)
199
+ @sessions[conn] = Session.attach(
200
+ conn, max_request_body: @max_request_body, max_field_section_size: @max_field_section_size,
201
+ max_connection_request_buffer: @max_connection_request_buffer, shared_buffer: @request_buffer,
202
+ request_timeout: @request_timeout
203
+ )
204
+ @remotes[conn] = sender
205
+ rescue => e
206
+ log(:error, "session_error", remote: format_remote(sender), **error_fields(e))
207
+ begin
208
+ conn.close(error_code: Nghttp3::H3_INTERNAL_ERROR)
209
+ rescue QUIC::Error
210
+ nil
211
+ end
212
+ end
213
+
214
+ # Refuse with 408 the requests that ran out of time; serve_requests
215
+ # answers them.
216
+ def expire_requests
217
+ at = now
218
+ @sessions.each_value { |session| session.expire_requests(at) }
219
+ end
220
+
152
221
  def serve_requests
153
222
  @sessions.each do |conn, session|
154
223
  session.take_requests.each do |request, status|
224
+ count_refusal(status) if status
155
225
  response = status ? Response.new(status: status, headers: [], body: nil) : call_app(conn, request)
156
226
  begin
157
227
  session.respond(request, response) { |error| log_body_error(conn, request, error) }
@@ -161,6 +231,7 @@ module HTTP3
161
231
  end
162
232
  end
163
233
  end
234
+ @adapter.take_refusals.each { |status, count| count_refusal(status, count) }
164
235
  end
165
236
 
166
237
  def call_app(conn, request)
@@ -170,12 +241,49 @@ module HTTP3
170
241
  Response.new(status: 500, headers: [], body: nil)
171
242
  end
172
243
 
173
- def fill_bodies
244
+ def count_refusal(status, count = 1)
245
+ @refusals_since ||= now
246
+ @refusals[status] += count
247
+ end
248
+
249
+ # When the refusals counted are due to be logged; nil while there are
250
+ # none.
251
+ def refusal_log_at
252
+ @refusals_since && @refusals_since + @refusal_log_interval
253
+ end
254
+
255
+ def refusals_due?
256
+ at = refusal_log_at
257
+ !at.nil? && at <= now
258
+ end
259
+
260
+ # One line per status, in ascending order, with the seconds since the
261
+ # first of them; the count starts over.
262
+ def log_refusals
263
+ return if @refusals_since.nil?
264
+
265
+ seconds = (now - @refusals_since).round
266
+ @refusals.sort.each do |status, count|
267
+ log(:warn, "requests_refused", status: status, count: count, seconds: seconds)
268
+ end
269
+ @refusals.clear
270
+ @refusals_since = nil
271
+ end
272
+
273
+ # The connections are filled in the order they came, so that while the
274
+ # total is short the older ones get its room first.
275
+ def fill_bodies(room)
174
276
  @sessions.each do |conn, session|
175
- session.fill_bodies(@send_buffer_size) { |request, error| log_app_error(conn, request, error) }
277
+ room -= session.fill_bodies(@send_buffer_size, connection_limit: @max_connection_send_buffer,
278
+ room: room) { |request, error| log_app_error(conn, request, error) }
176
279
  end
177
280
  end
178
281
 
282
+ # What max_total_send_buffer has left for array and each bodies.
283
+ def send_room
284
+ @max_total_send_buffer - @sessions.each_value.sum(&:unsent_total)
285
+ end
286
+
179
287
  # Send what the Endpoint has, until the socket would block.
180
288
  def flush
181
289
  loop do
@@ -282,6 +390,64 @@ module HTTP3
282
390
  previous.each { |name, handler| Signal.trap(name, handler || "DEFAULT") }
283
391
  end
284
392
 
393
+ # 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
395
+ # 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)
399
+ unless max_request_body.is_a?(Integer) && max_request_body >= 0
400
+ raise ArgumentError, "max_request_body must be an Integer of 0 or more (got #{max_request_body.inspect})"
401
+ end
402
+ # The range Session._attach takes, which would otherwise raise only
403
+ # on the first connection.
404
+ unless max_field_section_size.is_a?(Integer) && (1...2**62).cover?(max_field_section_size)
405
+ raise ArgumentError, "max_field_section_size must be 1..2**62-1 (got #{max_field_section_size.inspect})"
406
+ end
407
+ {max_connection_request_buffer:, max_total_request_buffer:}.each do |name, value|
408
+ raise ArgumentError, "#{name} must be a positive Integer (got #{value.inspect})" unless value.is_a?(Integer) && value.positive?
409
+ end
410
+ request = max_request_body + max_field_section_size
411
+ if max_connection_request_buffer < request
412
+ raise ArgumentError, "max_connection_request_buffer (#{max_connection_request_buffer}) must be at least " \
413
+ "max_request_body + max_field_section_size (#{request})"
414
+ end
415
+ if max_total_request_buffer < max_connection_request_buffer
416
+ raise ArgumentError, "max_total_request_buffer (#{max_total_request_buffer}) must be at least " \
417
+ "max_connection_request_buffer (#{max_connection_request_buffer})"
418
+ end
419
+ end
420
+
421
+ # Raise ArgumentError for the first send limit found wrong: each has to
422
+ # be a positive Integer, at least the one before.
423
+ def check_send_limits(send_buffer_size, max_connection_send_buffer, max_total_send_buffer)
424
+ {send_buffer_size:, max_connection_send_buffer:, max_total_send_buffer:}.each do |name, value|
425
+ raise ArgumentError, "#{name} must be a positive Integer (got #{value.inspect})" unless value.is_a?(Integer) && value.positive?
426
+ end
427
+ if max_connection_send_buffer < send_buffer_size
428
+ raise ArgumentError, "max_connection_send_buffer (#{max_connection_send_buffer}) must be at least " \
429
+ "send_buffer_size (#{send_buffer_size})"
430
+ end
431
+ if max_total_send_buffer < max_connection_send_buffer
432
+ raise ArgumentError, "max_total_send_buffer (#{max_total_send_buffer}) must be at least " \
433
+ "max_connection_send_buffer (#{max_connection_send_buffer})"
434
+ end
435
+ end
436
+
437
+ def check_request_timeout(request_timeout)
438
+ return if request_timeout.nil?
439
+ return if (request_timeout.is_a?(Integer) || request_timeout.is_a?(Float)) && request_timeout.positive?
440
+
441
+ raise ArgumentError, "request_timeout must be nil or a positive number of seconds (got #{request_timeout.inspect})"
442
+ end
443
+
444
+ def check_refusal_log_interval(refusal_log_interval)
445
+ return if (refusal_log_interval.is_a?(Integer) || refusal_log_interval.is_a?(Float)) && refusal_log_interval.positive?
446
+
447
+ raise ArgumentError,
448
+ "refusal_log_interval must be a positive number of seconds (got #{refusal_log_interval.inspect})"
449
+ end
450
+
285
451
  def parse_bind(bind)
286
452
  match = bind.match(/\A\[(?<host>[^\]]+)\]:(?<port>\d+)\z/) || bind.match(/\A(?<host>[^:\[\]]+):(?<port>\d+)\z/)
287
453
  raise ArgumentError, "bind must be \"host:port\" or \"[ipv6]:port\" (got #{bind.inspect})" unless match
@@ -338,6 +504,10 @@ module HTTP3
338
504
 
339
505
  def remote_of(conn)
340
506
  address = @remotes[conn] or return "-"
507
+ format_remote(address)
508
+ end
509
+
510
+ def format_remote(address)
341
511
  host = address.ipv6? ? "[#{address.ip_address}]" : address.ip_address
342
512
  "#{host}:#{address.ip_port}"
343
513
  end
data/lib/http3/session.rb CHANGED
@@ -10,49 +10,136 @@ module HTTP3
10
10
  # requests, answers them, keeps the bodies flowing and lets go of what
11
11
  # the streams no longer need.
12
12
  class Session
13
- # A request still coming in. reject is the status to answer with
14
- # instead of calling the application (413, 431), or nil.
15
- Incoming = Struct.new(:pseudo, :fields, :body, :reject)
13
+ # A request still coming in; started is when its headers began
14
+ # (CLOCK_MONOTONIC).
15
+ Incoming = Struct.new(:pseudo, :fields, :body, :started)
16
16
 
17
17
  # A response whose body is being sent.
18
18
  Outgoing = Struct.new(:request, :body, :done)
19
19
 
20
+ # What a field counts for besides its name and value, as RFC 9114
21
+ # section 4.2.2 counts it, and as the C part does for 431.
22
+ FIELD_OVERHEAD = 32
23
+
24
+ # Bytes the requests coming in may hold: a connection's, and through
25
+ # parent the server's. Only the server's I/O loop, one thread, calls
26
+ # it, so it takes no lock.
27
+ class Budget
28
+ attr_reader :limit, :used
29
+
30
+ def initialize(limit, parent: nil)
31
+ @limit = limit
32
+ @used = 0
33
+ @parent = parent
34
+ end
35
+
36
+ # Count n bytes and return true if they fit both here and in the
37
+ # parent; count nothing and return false if not.
38
+ def reserve(n)
39
+ return false if @used + n > @limit
40
+ return false if @parent && !@parent.reserve(n)
41
+
42
+ @used += n
43
+ true
44
+ end
45
+
46
+ def release(n)
47
+ @used -= n
48
+ @parent&.release(n)
49
+ end
50
+ end
51
+ private_constant :Budget
52
+
20
53
  # Take over quic_conn's streams. A request body over max_request_body
21
54
  # bytes is answered with 413, a header section over
22
- # max_field_section_size bytes (RFC 9114 section 4.2.2) with 431.
23
- def self.attach(quic_conn, max_request_body:, max_field_section_size:)
55
+ # max_field_section_size bytes (RFC 9114 section 4.2.2) with 431, as
56
+ # soon as the request passes the limit; the rest of it is not read.
57
+ #
58
+ # The requests not yet handed out (#take_requests) may hold
59
+ # max_connection_request_buffer bytes in all, header fields counted as
60
+ # for 431 and bodies by their bytes, and those of every Session given
61
+ # the same shared_buffer (Session.shared_buffer) its limit; a request
62
+ # that would pass either is answered with 503.
63
+ #
64
+ # A request not received in full within request_timeout seconds of the
65
+ # start of its headers is answered with 408 (#expire_requests); nil is
66
+ # no limit.
67
+ 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)
24
69
  session = _attach(quic_conn, max_field_section_size)
25
- session.send(:setup, max_request_body)
70
+ session.send(:setup, max_request_body, Budget.new(max_connection_request_buffer, parent: shared_buffer),
71
+ request_timeout)
26
72
  session
27
73
  end
28
74
 
75
+ # A limit of limit bytes for the requests coming in on every Session
76
+ # given it as attach's shared_buffer.
77
+ def self.shared_buffer(limit)
78
+ Budget.new(limit)
79
+ end
80
+
29
81
  # The requests received in full since the last call, each as
30
82
  # [request, status]: status is nil for the application to answer the
31
- # request, or the status to answer it with directly.
83
+ # request, or the status to answer it with directly. A request answered
84
+ # directly comes out as soon as it is refused, before the rest of it
85
+ # has arrived, with neither headers nor body. The bytes they held no
86
+ # longer count for the limits.
32
87
  def take_requests
33
88
  done = @done
34
89
  @done = []
90
+ done.each { |request, _| release_held(request.stream_id) }
35
91
  done
36
92
  end
37
93
 
94
+ # Refuse with 408 the requests still coming in whose request_timeout
95
+ # has passed at now (CLOCK_MONOTONIC); #take_requests hands them out.
96
+ def expire_requests(now)
97
+ return if @request_timeout.nil?
98
+
99
+ @incoming.each do |id, request|
100
+ next if @rejected[id] || request.started + @request_timeout > now
101
+
102
+ reject(id, request, 408)
103
+ end
104
+ end
105
+
106
+ # When the next request still coming in runs out of time
107
+ # (CLOCK_MONOTONIC), or nil.
108
+ def next_request_deadline
109
+ return nil if @request_timeout.nil?
110
+
111
+ started = @incoming.filter_map { |id, request| request.started unless @rejected[id] }.min
112
+ started && started + @request_timeout
113
+ end
114
+
38
115
  # Send response to request. Its body, if any, is kept until the stream
39
116
  # closes, but the IO of a Body::File is closed at once: the C part reads
40
117
  # the file through a descriptor of its own. When that descriptor cannot
41
118
  # be had (EMFILE, say), the stream is aborted with H3_INTERNAL_ERROR and
42
- # the block gets the exception.
119
+ # the block gets the exception. A request is answered once: nghttp3
120
+ # would send a second response after the first, so it raises
121
+ # Nghttp3::InvalidStateError.
43
122
  def respond(request, response)
123
+ id = request.stream_id
124
+ raise Nghttp3::InvalidStateError, "stream #{id} has been answered" if @answered[id]
125
+
126
+ stop_reading(id) if @rejected[id]
44
127
  headers = [[":status", response.status.to_s], *response.headers]
45
128
  body = response.body
46
- submit_response(request.stream_id, headers, !body.nil?)
129
+ submit_response(id, headers, !body.nil?)
130
+ @answered[id] = true
47
131
  return if body.nil?
48
132
 
49
- @outgoing[request.stream_id] = Outgoing.new(request, body, false)
133
+ # Only the pseudo-headers stay, for the log, while the body is sent:
134
+ # the request body would otherwise be held all along, uncounted.
135
+ pseudo = request.headers.select { |name, _| name.start_with?(":") }
136
+ @outgoing[id] = Outgoing.new(Request.new(stream_id: id, headers: pseudo, body: "".b), body, false)
50
137
  return unless body.is_a?(Body::File)
51
138
 
52
139
  begin
53
- attach_file(request.stream_id, body.io, body.offset, body.length)
140
+ attach_file(id, body.io, body.offset, body.length)
54
141
  rescue SystemCallError => e
55
- abort_stream(request.stream_id, Nghttp3::H3_INTERNAL_ERROR)
142
+ abort_stream(id, Nghttp3::H3_INTERNAL_ERROR)
56
143
  yield e if block_given?
57
144
  ensure
58
145
  body.io.close
@@ -60,24 +147,44 @@ module HTTP3
60
147
  end
61
148
 
62
149
  # Queue more of each Body::Chunks body until limit bytes wait unsent on
63
- # its stream. A body whose enum raises is the application failing after
64
- # its headers went out: the stream is aborted with H3_INTERNAL_ERROR and
65
- # the block gets (request, exception).
66
- def fill_bodies(limit)
150
+ # its stream, connection_limit on the whole connection (#unsent_total),
151
+ # and room more bytes have been queued; nil is no limit. A chunk may
152
+ # take any of them past its limit, by less than its size. Returns the
153
+ # bytes queued.
154
+ #
155
+ # A body whose enum raises is the application failing after its headers
156
+ # went out: the stream is aborted with H3_INTERNAL_ERROR and the block
157
+ # gets (request, exception). That request has only the pseudo-headers,
158
+ # and no body.
159
+ def fill_bodies(limit, connection_limit: nil, room: nil)
160
+ left = [room, connection_limit && connection_limit - unsent_total].compact.min
161
+ queued = 0
67
162
  @outgoing.each_value do |out|
68
163
  next if out.done || !out.body.is_a?(Body::Chunks)
164
+ break if left && queued >= left
69
165
 
70
- fill_body(out, limit) { |error| yield out.request, error if block_given? }
166
+ queued += fill_body(out, limit, left && left - queued) { |error| yield out.request, error if block_given? }
71
167
  end
168
+ queued
72
169
  end
73
170
 
74
- # True when a Body::Chunks body could take more right now.
75
- def wants_fill?(limit)
171
+ # True when a Body::Chunks body could take more right now, with the
172
+ # limits of #fill_bodies.
173
+ def wants_fill?(limit, connection_limit: nil, room: nil)
174
+ return false if room && room <= 0
175
+ return false if connection_limit && unsent_total >= connection_limit
176
+
76
177
  @outgoing.any? do |id, out|
77
178
  !out.done && out.body.is_a?(Body::Chunks) && unsent_bytes(id) < limit
78
179
  end
79
180
  end
80
181
 
182
+ # Bytes of the Body::Chunks bodies queued but not taken by nghttp3 yet,
183
+ # on every stream; a body that has ended may still have some.
184
+ def unsent_total
185
+ @outgoing.sum { |id, out| out.body.is_a?(Body::Chunks) ? unsent_bytes(id) : 0 }
186
+ end
187
+
81
188
  # True while a request stream is open: received in part, waiting for
82
189
  # its answer, or with a response still being sent.
83
190
  def busy?
@@ -86,8 +193,13 @@ module HTTP3
86
193
 
87
194
  # Let go of the streams nghttp3 closed: their requests are dropped and
88
195
  # their bodies closed. The block gets (stream_id, exception) for a body
89
- # whose close raised.
196
+ # whose close raised. First, the requests the client reset before their
197
+ # end (#on_reset) get our side of their streams reset too, with
198
+ # H3_REQUEST_INCOMPLETE as RFC 9114 section 4.1 says, so that the
199
+ # streams close.
90
200
  def release_closed(&block)
201
+ @incomplete.each { |id| abort_stream(id, Nghttp3::H3_REQUEST_INCOMPLETE) }
202
+ @incomplete.clear
91
203
  take_closed.each { |id| release(id, &block) }
92
204
  end
93
205
 
@@ -100,17 +212,22 @@ module HTTP3
100
212
 
101
213
  def on_begin_headers(id)
102
214
  @open[id] = true
103
- @incoming[id] = Incoming.new([], [], "".b, nil)
215
+ @incoming[id] = Incoming.new([], [], "".b, Process.clock_gettime(Process::CLOCK_MONOTONIC))
104
216
  end
105
217
 
106
218
  def on_header(id, name, value)
107
219
  request = @incoming[id] or return
220
+ return if @rejected[id]
221
+ return reject(id, request, 503) unless hold(id, name.bytesize + value.bytesize + FIELD_OVERHEAD)
222
+
108
223
  (name.start_with?(":") ? request.pseudo : request.fields) << [name, value]
109
224
  end
110
225
 
111
226
  def on_fields_too_large(id)
112
227
  request = @incoming[id] or return
113
- request.reject = 431
228
+ return if @rejected[id]
229
+
230
+ reject(id, request, 431)
114
231
  end
115
232
 
116
233
  def on_end_headers(id)
@@ -118,64 +235,126 @@ module HTTP3
118
235
 
119
236
  def on_data(id, bytes)
120
237
  request = @incoming[id] or return
121
- return if request.reject
238
+ return if @rejected[id]
122
239
 
123
240
  if request.body.bytesize + bytes.bytesize > @max_request_body
124
- request.reject = 413
125
- request.body = "".b
241
+ reject(id, request, 413)
242
+ elsif !hold(id, bytes.bytesize)
243
+ reject(id, request, 503)
126
244
  else
127
245
  request.body << bytes
128
246
  end
129
247
  end
130
248
 
131
249
  def on_end_stream(id)
250
+ return if @rejected[id]
251
+
132
252
  request = @incoming.delete(id) or return
133
253
  headers = request.pseudo + request.fields
134
- @done << [Request.new(stream_id: id, headers: headers, body: request.body), request.reject]
254
+ @done << [Request.new(stream_id: id, headers: headers, body: request.body), nil]
255
+ end
256
+
257
+ # The client reset its side of the stream. A request it had not
258
+ # finished is dropped with the bytes it held, and #release_closed
259
+ # resets our side; one received in full, or refused, is answered as it
260
+ # would have been. Inside a callback, so it calls no C.
261
+ def on_reset(id)
262
+ return unless @incoming.key?(id) && !@rejected[id]
263
+
264
+ @incoming.delete(id)
265
+ release_held(id)
266
+ @incomplete << id
135
267
  end
136
268
 
137
269
  private
138
270
 
139
- def setup(max_request_body)
271
+ def setup(max_request_body, budget, request_timeout)
140
272
  @max_request_body = max_request_body
273
+ @budget = budget
274
+ @request_timeout = request_timeout
275
+ @held = {}
141
276
  @incoming = {}
142
277
  @done = []
143
278
  @outgoing = {}
144
279
  @open = {}
280
+ @rejected = {}
281
+ @answered = {}
282
+ @incomplete = []
283
+ end
284
+
285
+ # Count n more bytes for the request on id, if the budget has room.
286
+ def hold(id, n)
287
+ return false unless @budget.reserve(n)
288
+
289
+ @held[id] = @held.fetch(id, 0) + n
290
+ true
291
+ end
292
+
293
+ # The request on id holds nothing any more. Calling it again does
294
+ # nothing.
295
+ def release_held(id)
296
+ n = @held.delete(id) or return
297
+ @budget.release(n)
298
+ end
299
+
300
+ # Answer the request on id with status, at once, instead of passing it
301
+ # on: what has come of it is dropped, with the bytes it held, and
302
+ # #respond stops reading the rest. Its Incoming stays in @incoming,
303
+ # emptied, until the stream closes, and the callbacks still to come for
304
+ # it are ignored. 431 may come before all of the pseudo-headers, so the
305
+ # request goes out without any headers. A refusal is not logged: only
306
+ # failures are, and a flood of refusals would flood the log.
307
+ def reject(id, request, status)
308
+ request.pseudo.clear
309
+ request.fields.clear
310
+ request.body = "".b
311
+ release_held(id)
312
+ @rejected[id] = true
313
+ @done << [Request.new(stream_id: id, headers: [], body: "".b), status]
145
314
  end
146
315
 
147
- def fill_body(out, limit)
316
+ # Queue chunks of out's body while its stream has less than limit bytes
317
+ # unsent and less than room (nil for no limit) has been queued. Returns
318
+ # the bytes queued.
319
+ def fill_body(out, limit, room)
320
+ queued = 0
148
321
  id = out.request.stream_id
149
- while unsent_bytes(id) < limit
322
+ while unsent_bytes(id) < limit && (room.nil? || queued < room)
150
323
  begin
151
324
  chunk = out.body.enum.next
152
325
  rescue StopIteration
153
326
  out.done = true
154
327
  push_eof(id)
155
- return
328
+ return queued
156
329
  rescue => e
157
330
  out.done = true
158
331
  abort_stream(id, Nghttp3::H3_INTERNAL_ERROR)
159
332
  yield e
160
- return
333
+ return queued
161
334
  end
162
335
  unless chunk.is_a?(String)
163
336
  out.done = true
164
337
  abort_stream(id, Nghttp3::H3_INTERNAL_ERROR)
165
338
  yield TypeError.new("body chunk must be a String (got #{chunk.class})")
166
- return
339
+ return queued
167
340
  end
168
341
  push_chunk(id, chunk)
342
+ queued += chunk.bytesize
169
343
  end
344
+ queued
170
345
  rescue Nghttp3::Error
171
346
  # The stream went away under the body (the peer reset it); it is
172
347
  # released when its close comes through.
173
348
  out.done = true
349
+ queued
174
350
  end
175
351
 
176
352
  def release(id)
177
353
  @open.delete(id)
178
354
  @incoming.delete(id)
355
+ release_held(id)
356
+ @rejected.delete(id)
357
+ @answered.delete(id)
179
358
  @done.reject! { |request, _| request.stream_id == id }
180
359
  out = @outgoing.delete(id) or return
181
360