puma 3.8.2 → 4.3.12

Sign up to get free protection for your applications and to get access to all the features.

Potentially problematic release.


This version of puma might be problematic. Click here for more details.

Files changed (91) hide show
  1. checksums.yaml +5 -5
  2. data/History.md +305 -0
  3. data/LICENSE +0 -0
  4. data/README.md +162 -224
  5. data/bin/puma-wild +0 -0
  6. data/docs/architecture.md +37 -0
  7. data/{DEPLOYMENT.md → docs/deployment.md} +24 -4
  8. data/docs/images/puma-connection-flow-no-reactor.png +0 -0
  9. data/docs/images/puma-connection-flow.png +0 -0
  10. data/docs/images/puma-general-arch.png +0 -0
  11. data/docs/nginx.md +0 -0
  12. data/docs/plugins.md +38 -0
  13. data/docs/restart.md +41 -0
  14. data/docs/signals.md +56 -3
  15. data/docs/systemd.md +130 -37
  16. data/docs/tcp_mode.md +96 -0
  17. data/ext/puma_http11/PumaHttp11Service.java +2 -0
  18. data/ext/puma_http11/ext_help.h +0 -0
  19. data/ext/puma_http11/extconf.rb +21 -0
  20. data/ext/puma_http11/http11_parser.c +134 -144
  21. data/ext/puma_http11/http11_parser.h +0 -0
  22. data/ext/puma_http11/http11_parser.java.rl +21 -37
  23. data/ext/puma_http11/http11_parser.rl +12 -10
  24. data/ext/puma_http11/http11_parser_common.rl +4 -4
  25. data/ext/puma_http11/io_buffer.c +0 -0
  26. data/ext/puma_http11/mini_ssl.c +165 -34
  27. data/ext/puma_http11/org/jruby/puma/Http11.java +106 -114
  28. data/ext/puma_http11/org/jruby/puma/Http11Parser.java +85 -101
  29. data/ext/puma_http11/org/jruby/puma/IOBuffer.java +72 -0
  30. data/ext/puma_http11/org/jruby/puma/MiniSSL.java +30 -6
  31. data/ext/puma_http11/puma_http11.c +3 -0
  32. data/lib/puma/accept_nonblock.rb +7 -1
  33. data/lib/puma/app/status.rb +42 -26
  34. data/lib/puma/binder.rb +57 -74
  35. data/lib/puma/cli.rb +26 -7
  36. data/lib/puma/client.rb +307 -191
  37. data/lib/puma/cluster.rb +78 -34
  38. data/lib/puma/commonlogger.rb +2 -0
  39. data/lib/puma/configuration.rb +24 -16
  40. data/lib/puma/const.rb +41 -20
  41. data/lib/puma/control_cli.rb +46 -19
  42. data/lib/puma/detect.rb +2 -0
  43. data/lib/puma/dsl.rb +329 -68
  44. data/lib/puma/events.rb +6 -2
  45. data/lib/puma/io_buffer.rb +3 -6
  46. data/lib/puma/jruby_restart.rb +2 -1
  47. data/lib/puma/launcher.rb +125 -61
  48. data/lib/puma/minissl/context_builder.rb +76 -0
  49. data/lib/puma/minissl.rb +85 -28
  50. data/lib/puma/null_io.rb +2 -0
  51. data/lib/puma/plugin/tmp_restart.rb +2 -1
  52. data/lib/puma/plugin.rb +7 -2
  53. data/lib/puma/rack/builder.rb +4 -1
  54. data/lib/puma/rack/urlmap.rb +2 -0
  55. data/lib/puma/rack_default.rb +2 -0
  56. data/lib/puma/reactor.rb +224 -34
  57. data/lib/puma/runner.rb +27 -6
  58. data/lib/puma/server.rb +212 -68
  59. data/lib/puma/single.rb +16 -5
  60. data/lib/puma/state_file.rb +2 -0
  61. data/lib/puma/tcp_logger.rb +2 -0
  62. data/lib/puma/thread_pool.rb +67 -36
  63. data/lib/puma/util.rb +2 -6
  64. data/lib/puma.rb +16 -0
  65. data/lib/rack/handler/puma.rb +16 -5
  66. data/tools/docker/Dockerfile +16 -0
  67. data/tools/jungle/README.md +12 -2
  68. data/tools/jungle/init.d/README.md +2 -0
  69. data/tools/jungle/init.d/puma +8 -8
  70. data/tools/jungle/init.d/run-puma +1 -1
  71. data/tools/jungle/rc.d/README.md +74 -0
  72. data/tools/jungle/rc.d/puma +61 -0
  73. data/tools/jungle/rc.d/puma.conf +10 -0
  74. data/tools/jungle/upstart/README.md +0 -0
  75. data/tools/jungle/upstart/puma-manager.conf +0 -0
  76. data/tools/jungle/upstart/puma.conf +0 -0
  77. data/tools/trickletest.rb +1 -2
  78. metadata +32 -93
  79. data/.github/issue_template.md +0 -20
  80. data/Gemfile +0 -12
  81. data/Manifest.txt +0 -78
  82. data/Rakefile +0 -158
  83. data/Release.md +0 -9
  84. data/gemfiles/2.1-Gemfile +0 -12
  85. data/lib/puma/compat.rb +0 -14
  86. data/lib/puma/convenient.rb +0 -23
  87. data/lib/puma/daemon_ext.rb +0 -31
  88. data/lib/puma/delegation.rb +0 -11
  89. data/lib/puma/java_io_buffer.rb +0 -45
  90. data/lib/puma/rack/backports/uri/common_193.rb +0 -33
  91. data/puma.gemspec +0 -52
data/lib/puma/client.rb CHANGED
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  class IO
2
4
  # We need to use this for a jruby work around on both 1.8 and 1.9.
3
5
  # So this either creates the constant (on 1.8), or harmlessly
@@ -7,8 +9,8 @@ class IO
7
9
  end
8
10
 
9
11
  require 'puma/detect'
10
- require 'puma/delegation'
11
12
  require 'tempfile'
13
+ require 'forwardable'
12
14
 
13
15
  if Puma::IS_JRUBY
14
16
  # We have to work around some OpenSSL buffer/io-readiness bugs
@@ -21,9 +23,41 @@ module Puma
21
23
 
22
24
  class ConnectionError < RuntimeError; end
23
25
 
26
+ class HttpParserError501 < IOError; end
27
+
28
+ # An instance of this class represents a unique request from a client.
29
+ # For example, this could be a web request from a browser or from CURL.
30
+ #
31
+ # An instance of `Puma::Client` can be used as if it were an IO object
32
+ # by the reactor. The reactor is expected to call `#to_io`
33
+ # on any non-IO objects it polls. For example, nio4r internally calls
34
+ # `IO::try_convert` (which may call `#to_io`) when a new socket is
35
+ # registered.
36
+ #
37
+ # Instances of this class are responsible for knowing if
38
+ # the header and body are fully buffered via the `try_to_finish` method.
39
+ # They can be used to "time out" a response via the `timeout_at` reader.
40
+ #
24
41
  class Client
42
+
43
+ # this tests all values but the last, which must be chunked
44
+ ALLOWED_TRANSFER_ENCODING = %w[compress deflate gzip].freeze
45
+
46
+ # chunked body validation
47
+ CHUNK_SIZE_INVALID = /[^\h]/.freeze
48
+ CHUNK_VALID_ENDING = "\r\n".freeze
49
+
50
+ # Content-Length header value validation
51
+ CONTENT_LENGTH_VALUE_INVALID = /[^\d]/.freeze
52
+
53
+ TE_ERR_MSG = 'Invalid Transfer-Encoding'
54
+
55
+ # The object used for a request with no body. All requests with
56
+ # no body share this one object since it has no state.
57
+ EmptyBody = NullIO.new
58
+
25
59
  include Puma::Const
26
- extend Puma::Delegation
60
+ extend Forwardable
27
61
 
28
62
  def initialize(io, env=nil)
29
63
  @io = io
@@ -41,6 +75,7 @@ module Puma
41
75
  @ready = false
42
76
 
43
77
  @body = nil
78
+ @body_read_start = nil
44
79
  @buffer = nil
45
80
  @tempfile = nil
46
81
 
@@ -51,6 +86,10 @@ module Puma
51
86
 
52
87
  @peerip = nil
53
88
  @remote_addr_header = nil
89
+
90
+ @body_remain = 0
91
+
92
+ @in_last_chunk = false
54
93
  end
55
94
 
56
95
  attr_reader :env, :to_io, :body, :io, :timeout_at, :ready, :hijacked,
@@ -60,7 +99,7 @@ module Puma
60
99
 
61
100
  attr_accessor :remote_addr_header
62
101
 
63
- forward :closed?, :@io
102
+ def_delegators :@io, :closed?
64
103
 
65
104
  def inspect
66
105
  "#<Puma::Client:0x#{object_id.to_s(16)} @ready=#{@ready.inspect}>"
@@ -89,6 +128,9 @@ module Puma
89
128
  @tempfile = nil
90
129
  @parsed_bytes = 0
91
130
  @ready = false
131
+ @body_remain = 0
132
+ @peerip = nil
133
+ @in_last_chunk = false
92
134
 
93
135
  if @buffer
94
136
  @parsed_bytes = @parser.execute(@env, @buffer, @parsed_bytes)
@@ -101,175 +143,25 @@ module Puma
101
143
  end
102
144
 
103
145
  return false
104
- elsif fast_check &&
105
- IO.select([@to_io], nil, nil, FAST_TRACK_KA_TIMEOUT)
106
- return try_to_finish
107
- end
108
- end
109
-
110
- def close
111
- begin
112
- @io.close
113
- rescue IOError
114
- end
115
- end
116
-
117
- # The object used for a request with no body. All requests with
118
- # no body share this one object since it has no state.
119
- EmptyBody = NullIO.new
120
-
121
- def setup_chunked_body(body)
122
- @chunked_body = true
123
- @partial_part_left = 0
124
- @prev_chunk = ""
125
-
126
- @body = Tempfile.new(Const::PUMA_TMP_BASE)
127
- @body.binmode
128
- @tempfile = @body
129
-
130
- return decode_chunk(body)
131
- end
132
-
133
- def decode_chunk(chunk)
134
- if @partial_part_left > 0
135
- if @partial_part_left <= chunk.size
136
- @body << chunk[0..(@partial_part_left-3)] # skip the \r\n
137
- chunk = chunk[@partial_part_left..-1]
138
- else
139
- @body << chunk
140
- @partial_part_left -= chunk.size
141
- return false
142
- end
143
- end
144
-
145
- if @prev_chunk.empty?
146
- io = StringIO.new(chunk)
147
146
  else
148
- io = StringIO.new(@prev_chunk+chunk)
149
- @prev_chunk = ""
150
- end
151
-
152
- while !io.eof?
153
- line = io.gets
154
- if line.end_with?("\r\n")
155
- len = line.strip.to_i(16)
156
- if len == 0
157
- @body.rewind
158
- rest = io.read
159
- @buffer = rest.empty? ? nil : rest
160
- @requests_served += 1
161
- @ready = true
162
- return true
163
- end
164
-
165
- len += 2
166
-
167
- part = io.read(len)
168
-
169
- unless part
170
- @partial_part_left = len
171
- next
172
- end
173
-
174
- got = part.size
175
-
176
- case
177
- when got == len
178
- @body << part[0..-3] # to skip the ending \r\n
179
- when got <= len - 2
180
- @body << part
181
- @partial_part_left = len - part.size
182
- when got == len - 1 # edge where we get just \r but not \n
183
- @body << part[0..-2]
184
- @partial_part_left = len - part.size
185
- end
186
- else
187
- @prev_chunk = line
188
- return false
189
- end
190
- end
191
-
192
- return false
193
- end
194
-
195
- def read_chunked_body
196
- while true
197
147
  begin
198
- chunk = @io.read_nonblock(4096)
199
- rescue Errno::EAGAIN
200
- return false
201
- rescue SystemCallError, IOError
202
- raise ConnectionError, "Connection error detected during read"
203
- end
204
-
205
- # No chunk means a closed socket
206
- unless chunk
207
- @body.close
208
- @buffer = nil
209
- @requests_served += 1
210
- @ready = true
211
- raise EOFError
148
+ if fast_check &&
149
+ IO.select([@to_io], nil, nil, FAST_TRACK_KA_TIMEOUT)
150
+ return try_to_finish
151
+ end
152
+ rescue IOError
153
+ # swallow it
212
154
  end
213
155
 
214
- return true if decode_chunk(chunk)
215
156
  end
216
157
  end
217
158
 
218
- def setup_body
219
- if @env[HTTP_EXPECT] == CONTINUE
220
- # TODO allow a hook here to check the headers before
221
- # going forward
222
- @io << HTTP_11_100
223
- @io.flush
224
- end
225
-
226
- @read_header = false
227
-
228
- body = @parser.body
229
-
230
- te = @env[TRANSFER_ENCODING2]
231
-
232
- if te && CHUNKED.casecmp(te) == 0
233
- return setup_chunked_body(body)
234
- end
235
-
236
- @chunked_body = false
237
-
238
- cl = @env[CONTENT_LENGTH]
239
-
240
- unless cl
241
- @buffer = body.empty? ? nil : body
242
- @body = EmptyBody
243
- @requests_served += 1
244
- @ready = true
245
- return true
246
- end
247
-
248
- remain = cl.to_i - body.bytesize
249
-
250
- if remain <= 0
251
- @body = StringIO.new(body)
252
- @buffer = nil
253
- @requests_served += 1
254
- @ready = true
255
- return true
256
- end
257
-
258
- if remain > MAX_BODY
259
- @body = Tempfile.new(Const::PUMA_TMP_BASE)
260
- @body.binmode
261
- @tempfile = @body
262
- else
263
- # The body[0,0] trick is to get an empty string in the same
264
- # encoding as body.
265
- @body = StringIO.new body[0,0]
159
+ def close
160
+ begin
161
+ @io.close
162
+ rescue IOError
163
+ Thread.current.purge_interrupt_queue if Thread.current.respond_to? :purge_interrupt_queue
266
164
  end
267
-
268
- @body.write body
269
-
270
- @body_remain = remain
271
-
272
- return false
273
165
  end
274
166
 
275
167
  def try_to_finish
@@ -277,12 +169,19 @@ module Puma
277
169
 
278
170
  begin
279
171
  data = @io.read_nonblock(CHUNK_SIZE)
280
- rescue Errno::EAGAIN
172
+ rescue IO::WaitReadable
281
173
  return false
282
- rescue SystemCallError, IOError
174
+ rescue SystemCallError, IOError, EOFError
283
175
  raise ConnectionError, "Connection error detected during read"
284
176
  end
285
177
 
178
+ # No data means a closed socket
179
+ unless data
180
+ @buffer = nil
181
+ set_ready
182
+ raise EOFError
183
+ end
184
+
286
185
  if @buffer
287
186
  @buffer << data
288
187
  else
@@ -312,6 +211,13 @@ module Puma
312
211
  raise e
313
212
  end
314
213
 
214
+ # No data means a closed socket
215
+ unless data
216
+ @buffer = nil
217
+ set_ready
218
+ raise EOFError
219
+ end
220
+
315
221
  if @buffer
316
222
  @buffer << data
317
223
  else
@@ -358,6 +264,108 @@ module Puma
358
264
  true
359
265
  end
360
266
 
267
+ def write_error(status_code)
268
+ begin
269
+ @io << ERROR_RESPONSE[status_code]
270
+ rescue StandardError
271
+ end
272
+ end
273
+
274
+ def peerip
275
+ return @peerip if @peerip
276
+
277
+ if @remote_addr_header
278
+ hdr = (@env[@remote_addr_header] || LOCALHOST_ADDR).split(/[\s,]/).first
279
+ @peerip = hdr
280
+ return hdr
281
+ end
282
+
283
+ @peerip ||= @io.peeraddr.last
284
+ end
285
+
286
+ private
287
+
288
+ def setup_body
289
+ @body_read_start = Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond)
290
+
291
+ if @env[HTTP_EXPECT] == CONTINUE
292
+ # TODO allow a hook here to check the headers before
293
+ # going forward
294
+ @io << HTTP_11_100
295
+ @io.flush
296
+ end
297
+
298
+ @read_header = false
299
+
300
+ body = @parser.body
301
+
302
+ te = @env[TRANSFER_ENCODING2]
303
+ if te
304
+ te_lwr = te.downcase
305
+ if te.include? ','
306
+ te_ary = te_lwr.split ','
307
+ te_count = te_ary.count CHUNKED
308
+ te_valid = te_ary[0..-2].all? { |e| ALLOWED_TRANSFER_ENCODING.include? e }
309
+ if te_ary.last == CHUNKED && te_count == 1 && te_valid
310
+ @env.delete TRANSFER_ENCODING2
311
+ return setup_chunked_body body
312
+ elsif te_count >= 1
313
+ raise HttpParserError , "#{TE_ERR_MSG}, multiple chunked: '#{te}'"
314
+ elsif !te_valid
315
+ raise HttpParserError501, "#{TE_ERR_MSG}, unknown value: '#{te}'"
316
+ end
317
+ elsif te_lwr == CHUNKED
318
+ @env.delete TRANSFER_ENCODING2
319
+ return setup_chunked_body body
320
+ elsif ALLOWED_TRANSFER_ENCODING.include? te_lwr
321
+ raise HttpParserError , "#{TE_ERR_MSG}, single value must be chunked: '#{te}'"
322
+ else
323
+ raise HttpParserError501 , "#{TE_ERR_MSG}, unknown value: '#{te}'"
324
+ end
325
+ end
326
+
327
+ @chunked_body = false
328
+
329
+ cl = @env[CONTENT_LENGTH]
330
+
331
+ if cl
332
+ # cannot contain characters that are not \d
333
+ if cl =~ CONTENT_LENGTH_VALUE_INVALID
334
+ raise HttpParserError, "Invalid Content-Length: #{cl.inspect}"
335
+ end
336
+ else
337
+ @buffer = body.empty? ? nil : body
338
+ @body = EmptyBody
339
+ set_ready
340
+ return true
341
+ end
342
+
343
+ remain = cl.to_i - body.bytesize
344
+
345
+ if remain <= 0
346
+ @body = StringIO.new(body)
347
+ @buffer = nil
348
+ set_ready
349
+ return true
350
+ end
351
+
352
+ if remain > MAX_BODY
353
+ @body = Tempfile.new(Const::PUMA_TMP_BASE)
354
+ @body.binmode
355
+ @tempfile = @body
356
+ else
357
+ # The body[0,0] trick is to get an empty string in the same
358
+ # encoding as body.
359
+ @body = StringIO.new body[0,0]
360
+ end
361
+
362
+ @body.write body
363
+
364
+ @body_remain = remain
365
+
366
+ return false
367
+ end
368
+
361
369
  def read_body
362
370
  if @chunked_body
363
371
  return read_chunked_body
@@ -375,7 +383,7 @@ module Puma
375
383
 
376
384
  begin
377
385
  chunk = @io.read_nonblock(want)
378
- rescue Errno::EAGAIN
386
+ rescue IO::WaitReadable
379
387
  return false
380
388
  rescue SystemCallError, IOError
381
389
  raise ConnectionError, "Connection error detected during read"
@@ -385,8 +393,7 @@ module Puma
385
393
  unless chunk
386
394
  @body.close
387
395
  @buffer = nil
388
- @requests_served += 1
389
- @ready = true
396
+ set_ready
390
397
  raise EOFError
391
398
  end
392
399
 
@@ -395,8 +402,7 @@ module Puma
395
402
  if remain <= 0
396
403
  @body.rewind
397
404
  @buffer = nil
398
- @requests_served += 1
399
- @ready = true
405
+ set_ready
400
406
  return true
401
407
  end
402
408
 
@@ -405,37 +411,147 @@ module Puma
405
411
  false
406
412
  end
407
413
 
408
- def write_400
409
- begin
410
- @io << ERROR_400_RESPONSE
411
- rescue StandardError
414
+ def read_chunked_body
415
+ while true
416
+ begin
417
+ chunk = @io.read_nonblock(4096)
418
+ rescue IO::WaitReadable
419
+ return false
420
+ rescue SystemCallError, IOError
421
+ raise ConnectionError, "Connection error detected during read"
422
+ end
423
+
424
+ # No chunk means a closed socket
425
+ unless chunk
426
+ @body.close
427
+ @buffer = nil
428
+ set_ready
429
+ raise EOFError
430
+ end
431
+
432
+ if decode_chunk(chunk)
433
+ @env[CONTENT_LENGTH] = @chunked_content_length
434
+ return true
435
+ end
412
436
  end
413
437
  end
414
438
 
415
- def write_408
416
- begin
417
- @io << ERROR_408_RESPONSE
418
- rescue StandardError
439
+ def setup_chunked_body(body)
440
+ @chunked_body = true
441
+ @partial_part_left = 0
442
+ @prev_chunk = ""
443
+
444
+ @body = Tempfile.new(Const::PUMA_TMP_BASE)
445
+ @body.binmode
446
+ @tempfile = @body
447
+
448
+ @chunked_content_length = 0
449
+
450
+ if decode_chunk(body)
451
+ @env[CONTENT_LENGTH] = @chunked_content_length
452
+ return true
419
453
  end
420
454
  end
421
455
 
422
- def write_500
423
- begin
424
- @io << ERROR_500_RESPONSE
425
- rescue StandardError
426
- end
456
+ def write_chunk(str)
457
+ @chunked_content_length += @body.write(str)
427
458
  end
428
459
 
429
- def peerip
430
- return @peerip if @peerip
460
+ def decode_chunk(chunk)
461
+ if @partial_part_left > 0
462
+ if @partial_part_left <= chunk.size
463
+ if @partial_part_left > 2
464
+ write_chunk(chunk[0..(@partial_part_left-3)]) # skip the \r\n
465
+ end
466
+ chunk = chunk[@partial_part_left..-1]
467
+ @partial_part_left = 0
468
+ else
469
+ write_chunk(chunk) if @partial_part_left > 2 # don't include the last \r\n
470
+ @partial_part_left -= chunk.size
471
+ return false
472
+ end
473
+ end
431
474
 
432
- if @remote_addr_header
433
- hdr = (@env[@remote_addr_header] || LOCALHOST_ADDR).split(/[\s,]/).first
434
- @peerip = hdr
435
- return hdr
475
+ if @prev_chunk.empty?
476
+ io = StringIO.new(chunk)
477
+ else
478
+ io = StringIO.new(@prev_chunk+chunk)
479
+ @prev_chunk = ""
436
480
  end
437
481
 
438
- @peerip ||= @io.peeraddr.last
482
+ while !io.eof?
483
+ line = io.gets
484
+ if line.end_with?("\r\n")
485
+ # Puma doesn't process chunk extensions, but should parse if they're
486
+ # present, which is the reason for the semicolon regex
487
+ chunk_hex = line.strip[/\A[^;]+/]
488
+ if chunk_hex =~ CHUNK_SIZE_INVALID
489
+ raise HttpParserError, "Invalid chunk size: '#{chunk_hex}'"
490
+ end
491
+ len = chunk_hex.to_i(16)
492
+ if len == 0
493
+ @in_last_chunk = true
494
+ @body.rewind
495
+ rest = io.read
496
+ last_crlf_size = "\r\n".bytesize
497
+ if rest.bytesize < last_crlf_size
498
+ @buffer = nil
499
+ @partial_part_left = last_crlf_size - rest.bytesize
500
+ return false
501
+ else
502
+ @buffer = rest[last_crlf_size..-1]
503
+ @buffer = nil if @buffer.empty?
504
+ set_ready
505
+ return true
506
+ end
507
+ end
508
+
509
+ len += 2
510
+
511
+ part = io.read(len)
512
+
513
+ unless part
514
+ @partial_part_left = len
515
+ next
516
+ end
517
+
518
+ got = part.size
519
+
520
+ case
521
+ when got == len
522
+ # proper chunked segment must end with "\r\n"
523
+ if part.end_with? CHUNK_VALID_ENDING
524
+ write_chunk(part[0..-3]) # to skip the ending \r\n
525
+ else
526
+ raise HttpParserError, "Chunk size mismatch"
527
+ end
528
+ when got <= len - 2
529
+ write_chunk(part)
530
+ @partial_part_left = len - part.size
531
+ when got == len - 1 # edge where we get just \r but not \n
532
+ write_chunk(part[0..-2])
533
+ @partial_part_left = len - part.size
534
+ end
535
+ else
536
+ @prev_chunk = line
537
+ return false
538
+ end
539
+ end
540
+
541
+ if @in_last_chunk
542
+ set_ready
543
+ true
544
+ else
545
+ false
546
+ end
547
+ end
548
+
549
+ def set_ready
550
+ if @body_read_start
551
+ @env['puma.request_body_wait'] = Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond) - @body_read_start
552
+ end
553
+ @requests_served += 1
554
+ @ready = true
439
555
  end
440
556
  end
441
557
  end