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