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