puma 6.6.0 → 7.0.1

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/puma/client.rb CHANGED
@@ -1,13 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- class IO
4
- # We need to use this for a jruby work around on both 1.8 and 1.9.
5
- # So this either creates the constant (on 1.8), or harmlessly
6
- # reopens it (on 1.9).
7
- module WaitReadable
8
- end
9
- end
10
-
11
3
  require_relative 'detect'
12
4
  require_relative 'io_buffer'
13
5
  require 'tempfile'
@@ -64,6 +56,11 @@ module Puma
64
56
 
65
57
  TE_ERR_MSG = 'Invalid Transfer-Encoding'
66
58
 
59
+ # See:
60
+ # https://httpwg.org/specs/rfc9110.html#rfc.section.5.6.1.1
61
+ # https://httpwg.org/specs/rfc9112.html#rfc.section.6.1
62
+ STRIP_OWS = /\A[ \t]+|[ \t]+\z/
63
+
67
64
  # The object used for a request with no body. All requests with
68
65
  # no body share this one object since it has no state.
69
66
  EmptyBody = NullIO.new
@@ -111,7 +108,8 @@ module Puma
111
108
  end
112
109
 
113
110
  attr_reader :env, :to_io, :body, :io, :timeout_at, :ready, :hijacked,
114
- :tempfile, :io_buffer, :http_content_length_limit_exceeded
111
+ :tempfile, :io_buffer, :http_content_length_limit_exceeded,
112
+ :requests_served
115
113
 
116
114
  attr_writer :peerip, :http_content_length_limit
117
115
 
@@ -133,9 +131,9 @@ module Puma
133
131
  "#<Puma::Client:0x#{object_id.to_s(16)} @ready=#{@ready.inspect}>"
134
132
  end
135
133
 
136
- # For the hijack protocol (allows us to just put the Client object
137
- # into the env)
138
- def call
134
+ # For the full hijack protocol, `env['rack.hijack']` is set to
135
+ # `client.method :full_hijack`
136
+ def full_hijack
139
137
  @hijacked = true
140
138
  env[HIJACK_IO] ||= @io
141
139
  end
@@ -150,11 +148,12 @@ module Puma
150
148
  end
151
149
 
152
150
  # Number of seconds until the timeout elapses.
151
+ # @!attribute [r] timeout
153
152
  def timeout
154
153
  [@timeout_at - Process.clock_gettime(Process::CLOCK_MONOTONIC), 0].max
155
154
  end
156
155
 
157
- def reset(fast_check=true)
156
+ def reset
158
157
  @parser.reset
159
158
  @io_buffer.reset
160
159
  @read_header = true
@@ -166,11 +165,14 @@ module Puma
166
165
  @peerip = nil if @remote_addr_header
167
166
  @in_last_chunk = false
168
167
  @http_content_length_limit_exceeded = false
168
+ end
169
169
 
170
+ # only used with back-to-back requests contained in the buffer
171
+ def process_back_to_back_requests
170
172
  if @buffer
171
173
  return false unless try_to_parse_proxy_protocol
172
174
 
173
- @parsed_bytes = @parser.execute(@env, @buffer, @parsed_bytes)
175
+ @parsed_bytes = parser_execute
174
176
 
175
177
  if @parser.finished?
176
178
  return setup_body
@@ -178,25 +180,20 @@ module Puma
178
180
  raise HttpParserError,
179
181
  "HEADER is longer than allowed, aborting client early."
180
182
  end
181
-
182
- return false
183
- else
184
- begin
185
- if fast_check && @to_io.wait_readable(FAST_TRACK_KA_TIMEOUT)
186
- return try_to_finish
187
- end
188
- rescue IOError
189
- # swallow it
190
- end
191
183
  end
192
184
  end
193
185
 
186
+ # if a client sends back-to-back requests, the buffer may contain one or more
187
+ # of them.
188
+ def has_back_to_back_requests?
189
+ !(@buffer.nil? || @buffer.empty?)
190
+ end
191
+
194
192
  def close
195
193
  tempfile_close
196
194
  begin
197
195
  @io.close
198
196
  rescue IOError, Errno::EBADF
199
- Puma::Util.purge_interrupt_queue
200
197
  end
201
198
  end
202
199
 
@@ -273,26 +270,28 @@ module Puma
273
270
 
274
271
  return false unless try_to_parse_proxy_protocol
275
272
 
276
- @parsed_bytes = @parser.execute(@env, @buffer, @parsed_bytes)
273
+ @parsed_bytes = parser_execute
277
274
 
278
275
  if @parser.finished? && above_http_content_limit(@parser.body.bytesize)
279
276
  @http_content_length_limit_exceeded = true
280
277
  end
281
278
 
282
279
  if @parser.finished?
283
- return setup_body
280
+ setup_body
284
281
  elsif @parsed_bytes >= MAX_HEADER
285
282
  raise HttpParserError,
286
283
  "HEADER is longer than allowed, aborting client early."
284
+ else
285
+ false
287
286
  end
288
-
289
- false
290
287
  end
291
288
 
292
289
  def eagerly_finish
293
290
  return true if @ready
294
- return false unless @to_io.wait_readable(0)
295
- try_to_finish
291
+ while @to_io.wait_readable(0) # rubocop: disable Style/WhileUntilModifier
292
+ return true if try_to_finish
293
+ end
294
+ false
296
295
  end
297
296
 
298
297
  def finish(timeout)
@@ -300,6 +299,44 @@ module Puma
300
299
  @to_io.wait_readable(timeout) || timeout! until try_to_finish
301
300
  end
302
301
 
302
+ # Wraps `@parser.execute` and adds meaningful error messages
303
+ # @return [Integer] bytes of buffer read by parser
304
+ #
305
+ def parser_execute
306
+ @parser.execute(@env, @buffer, @parsed_bytes)
307
+ rescue => e
308
+ @env[HTTP_CONNECTION] = 'close'
309
+ raise e unless HttpParserError === e && e.message.include?('non-SSL')
310
+
311
+ req, _ = @buffer.split "\r\n\r\n"
312
+ request_line, headers = req.split "\r\n", 2
313
+
314
+ # below checks for request issues and changes error message accordingly
315
+ if !@env.key? REQUEST_METHOD
316
+ if request_line.count(' ') != 2
317
+ # maybe this is an SSL connection ?
318
+ raise e
319
+ else
320
+ method = request_line[/\A[^ ]+/]
321
+ raise e, "Invalid HTTP format, parsing fails. Bad method #{method}"
322
+ end
323
+ elsif !@env.key? REQUEST_PATH
324
+ path = request_line[/\A[^ ]+ +([^ ?\r\n]+)/, 1]
325
+ raise e, "Invalid HTTP format, parsing fails. Bad path #{path}"
326
+ elsif request_line.match?(/\A[^ ]+ +[^ ?\r\n]+\?/) && !@env.key?(QUERY_STRING)
327
+ query = request_line[/\A[^ ]+ +[^? ]+\?([^ ]+)/, 1]
328
+ raise e, "Invalid HTTP format, parsing fails. Bad query #{query}"
329
+ elsif !@env.key? SERVER_PROTOCOL
330
+ # protocol is bad
331
+ text = request_line[/[^ ]*\z/]
332
+ raise HttpParserError, "Invalid HTTP format, parsing fails. Bad protocol #{text}"
333
+ elsif !headers.empty?
334
+ # headers are bad
335
+ hdrs = headers.split("\r\n").map { |h| h.gsub "\n", '\n'}.join "\n"
336
+ raise HttpParserError, "Invalid HTTP format, parsing fails. Bad headers\n#{hdrs}"
337
+ end
338
+ end
339
+
303
340
  def timeout!
304
341
  write_error(408) if in_data_phase
305
342
  raise ConnectionError
@@ -374,17 +411,18 @@ module Puma
374
411
  if te
375
412
  te_lwr = te.downcase
376
413
  if te.include? ','
377
- te_ary = te_lwr.split ','
414
+ te_ary = te_lwr.split(',').each { |te| te.gsub!(STRIP_OWS, "") }
378
415
  te_count = te_ary.count CHUNKED
379
416
  te_valid = te_ary[0..-2].all? { |e| ALLOWED_TRANSFER_ENCODING.include? e }
380
- if te_ary.last == CHUNKED && te_count == 1 && te_valid
381
- @env.delete TRANSFER_ENCODING2
382
- return setup_chunked_body body
383
- elsif te_count >= 1
417
+ if te_count > 1
384
418
  raise HttpParserError , "#{TE_ERR_MSG}, multiple chunked: '#{te}'"
419
+ elsif te_ary.last != CHUNKED
420
+ raise HttpParserError , "#{TE_ERR_MSG}, last value must be chunked: '#{te}'"
385
421
  elsif !te_valid
386
422
  raise HttpParserError501, "#{TE_ERR_MSG}, unknown value: '#{te}'"
387
423
  end
424
+ @env.delete TRANSFER_ENCODING2
425
+ return setup_chunked_body body
388
426
  elsif te_lwr == CHUNKED
389
427
  @env.delete TRANSFER_ENCODING2
390
428
  return setup_chunked_body body
@@ -110,7 +110,6 @@ module Puma
110
110
  begin
111
111
  @worker_write << "#{PIPE_BOOT}#{Process.pid}:#{index}\n"
112
112
  rescue SystemCallError, IOError
113
- Puma::Util.purge_interrupt_queue
114
113
  STDERR.puts "Master seems to have exited, exiting."
115
114
  return
116
115
  end
@@ -128,16 +127,16 @@ module Puma
128
127
 
129
128
  while true
130
129
  begin
131
- b = server.backlog || 0
132
- r = server.running || 0
133
- t = server.pool_capacity || 0
134
- m = server.max_threads || 0
135
- rc = server.requests_count || 0
136
- bt = server.busy_threads || 0
137
- payload = %Q!#{base_payload}{ "backlog":#{b}, "running":#{r}, "pool_capacity":#{t}, "max_threads":#{m}, "requests_count":#{rc}, "busy_threads":#{bt} }\n!
138
- io << payload
130
+ payload = base_payload.dup
131
+
132
+ hsh = server.stats
133
+ hsh.each do |k, v|
134
+ payload << %Q! "#{k}":#{v || 0},!
135
+ end
136
+ # sub call properly adds 'closing' string
137
+ io << payload.sub(/,\z/, " }\n")
138
+ server.reset_max
139
139
  rescue IOError
140
- Puma::Util.purge_interrupt_queue
141
140
  break
142
141
  end
143
142
  sleep @options[:worker_check_interval]
@@ -4,13 +4,15 @@ module Puma
4
4
  class Cluster < Runner
5
5
  #—————————————————————— DO NOT USE — this class is for internal use only ———
6
6
 
7
-
8
7
  # This class represents a worker process from the perspective of the puma
9
8
  # master process. It contains information about the process and its health
10
9
  # and it exposes methods to control the process via IPC. It does not
11
10
  # include the actual logic executed by the worker process itself. For that,
12
11
  # see Puma::Cluster::Worker.
13
12
  class WorkerHandle # :nodoc:
13
+ # array of stat 'max' keys
14
+ WORKER_MAX_KEYS = [:backlog_max, :reactor_max]
15
+
14
16
  def initialize(idx, pid, phase, options)
15
17
  @index = idx
16
18
  @pid = pid
@@ -23,6 +25,7 @@ module Puma
23
25
  @last_checkin = Time.now
24
26
  @last_status = {}
25
27
  @term = false
28
+ @worker_max = Array.new WORKER_MAX_KEYS.length, 0
26
29
  end
27
30
 
28
31
  attr_reader :index, :pid, :phase, :signal, :last_checkin, :last_status, :started_at
@@ -51,12 +54,38 @@ module Puma
51
54
  @term
52
55
  end
53
56
 
54
- STATUS_PATTERN = /{ "backlog":(?<backlog>\d*), "running":(?<running>\d*), "pool_capacity":(?<pool_capacity>\d*), "max_threads":(?<max_threads>\d*), "requests_count":(?<requests_count>\d*), "busy_threads":(?<busy_threads>\d*) }/
55
- private_constant :STATUS_PATTERN
56
-
57
57
  def ping!(status)
58
+ hsh = {}
59
+ k, v = nil, nil
60
+ status.tr('}{"', '').strip.split(", ") do |kv|
61
+ cntr = 0
62
+ kv.split(':') do |t|
63
+ if cntr == 0
64
+ k = t
65
+ cntr = 1
66
+ else
67
+ v = t
68
+ end
69
+ end
70
+ hsh[k.to_sym] = v.to_i
71
+ end
72
+
73
+ # check stat max values, we can't signal workers to reset the max values,
74
+ # so we do so here
75
+ WORKER_MAX_KEYS.each_with_index do |key, idx|
76
+ if hsh[key] < @worker_max[idx]
77
+ hsh[key] = @worker_max[idx]
78
+ else
79
+ @worker_max[idx] = hsh[key]
80
+ end
81
+ end
58
82
  @last_checkin = Time.now
59
- @last_status = status.match(STATUS_PATTERN).named_captures.map { |c_name, c| [c_name.to_sym, c.to_i] }.to_h
83
+ @last_status = hsh
84
+ end
85
+
86
+ # Resets max values to zero. Called whenever `Cluster#stats` is called
87
+ def reset_max
88
+ WORKER_MAX_KEYS.length.times { |idx| @worker_max[idx] = 0 }
60
89
  end
61
90
 
62
91
  # @see Puma::Cluster#check_workers
data/lib/puma/cluster.rb CHANGED
@@ -22,6 +22,7 @@ module Puma
22
22
  @workers = []
23
23
  @next_check = Time.now
24
24
 
25
+ @worker_max = [] # keeps track of 'max' stat values
25
26
  @phased_restart = false
26
27
  end
27
28
 
@@ -44,10 +45,14 @@ module Puma
44
45
  end
45
46
  end
46
47
 
47
- def start_phased_restart
48
- @events.fire_on_restart!
48
+ def start_phased_restart(refork = false)
49
+ @events.fire_before_restart!
49
50
  @phase += 1
50
- log "- Starting phased worker restart, phase: #{@phase}"
51
+ if refork
52
+ log "- Starting worker refork, phase: #{@phase}"
53
+ else
54
+ log "- Starting phased worker restart, phase: #{@phase}"
55
+ end
51
56
 
52
57
  # Be sure to change the directory again before loading
53
58
  # the app. This way we can pick up new code.
@@ -166,7 +171,7 @@ module Puma
166
171
  (@workers.map(&:pid) - idle_timed_out_worker_pids).empty?
167
172
  end
168
173
 
169
- def check_workers
174
+ def check_workers(refork = false)
170
175
  return if @next_check >= Time.now
171
176
 
172
177
  @next_check = Time.now + @options[:worker_check_interval]
@@ -184,7 +189,12 @@ module Puma
184
189
  w = @workers.find { |x| x.phase != @phase }
185
190
 
186
191
  if w
187
- log "- Stopping #{w.pid} for phased upgrade..."
192
+ if refork
193
+ log "- Stopping #{w.pid} for refork..."
194
+ else
195
+ log "- Stopping #{w.pid} for phased upgrade..."
196
+ end
197
+
188
198
  unless w.term?
189
199
  w.term
190
200
  log "- #{w.signal} sent to #{w.pid}..."
@@ -228,7 +238,7 @@ module Puma
228
238
  def phased_restart(refork = false)
229
239
  return false if @options[:preload_app] && !refork
230
240
 
231
- @phased_restart = true
241
+ @phased_restart = refork ? :refork : true
232
242
  wakeup!
233
243
 
234
244
  true
@@ -258,11 +268,14 @@ module Puma
258
268
  end
259
269
 
260
270
  # Inside of a child process, this will return all zeroes, as @workers is only populated in
261
- # the master process.
271
+ # the master process. Calling this also resets stat 'max' values to zero.
262
272
  # @!attribute [r] stats
273
+ # @return [Hash]
274
+
263
275
  def stats
264
276
  old_worker_count = @workers.count { |w| w.phase != @phase }
265
277
  worker_status = @workers.map do |w|
278
+ w.reset_max
266
279
  {
267
280
  started_at: utc_iso8601(w.started_at),
268
281
  pid: w.pid,
@@ -273,7 +286,6 @@ module Puma
273
286
  last_status: w.last_status,
274
287
  }
275
288
  end
276
-
277
289
  {
278
290
  started_at: utc_iso8601(@started_at),
279
291
  workers: @workers.size,
@@ -342,7 +354,7 @@ module Puma
342
354
 
343
355
  stop_workers
344
356
  stop
345
- @events.fire_on_stopped!
357
+ @events.fire_after_stopped!
346
358
  raise(SignalException, "SIGTERM") if @options[:raise_exception_on_sigterm]
347
359
  exit 0 # Clean exit, workers were stopped
348
360
  end
@@ -359,16 +371,12 @@ module Puma
359
371
 
360
372
  if preload?
361
373
  # Threads explicitly marked as fork safe will be ignored. Used in Rails,
362
- # but may be used by anyone. Note that we need to explicit
363
- # Process::Waiter check here because there's a bug in Ruby 2.6 and below
364
- # where calling thread_variable_get on a Process::Waiter will segfault.
365
- # We can drop that clause once those versions of Ruby are no longer
366
- # supported.
367
- fork_safe = ->(t) { !t.is_a?(Process::Waiter) && t.thread_variable_get(:fork_safe) }
374
+ # but may be used by anyone.
375
+ fork_safe = ->(t) { t.thread_variable_get(:fork_safe) }
368
376
 
369
377
  before = Thread.list.reject(&fork_safe)
370
378
 
371
- log "* Restarts: (\u2714) hot (\u2716) phased"
379
+ log "* Restarts: (\u2714) hot (\u2716) phased (#{@options[:fork_worker] ? "\u2714" : "\u2716"}) refork"
372
380
  log "* Preloading application"
373
381
  load_and_bind
374
382
 
@@ -386,7 +394,7 @@ module Puma
386
394
  end
387
395
  end
388
396
  else
389
- log "* Restarts: (\u2714) hot (\u2714) phased"
397
+ log "* Restarts: (\u2714) hot (\u2714) phased (#{@options[:fork_worker] ? "\u2714" : "\u2716"}) refork"
390
398
 
391
399
  unless @config.app_configured?
392
400
  error "No application configured, nothing to run"
@@ -413,6 +421,7 @@ module Puma
413
421
 
414
422
  log "Use Ctrl-C to stop"
415
423
 
424
+ warn_ruby_mn_threads
416
425
  single_worker_warning
417
426
 
418
427
  redirect_io
@@ -448,13 +457,17 @@ module Puma
448
457
  end
449
458
 
450
459
  if @phased_restart
451
- start_phased_restart
460
+ start_phased_restart(@phased_restart == :refork)
461
+
462
+ in_phased_restart = @phased_restart
452
463
  @phased_restart = false
453
- in_phased_restart = true
464
+
454
465
  workers_not_booted = @options[:workers]
466
+ # worker 0 is not restarted on refork
467
+ workers_not_booted -= 1 if in_phased_restart == :refork
455
468
  end
456
469
 
457
- check_workers
470
+ check_workers(in_phased_restart == :refork)
458
471
 
459
472
  if read.wait_readable([0, @next_check - Time.now].max)
460
473
  req = read.read_nonblock(1)
@@ -497,7 +510,7 @@ module Puma
497
510
  end
498
511
 
499
512
  if !booted && @workers.none? {|worker| worker.last_status.empty?}
500
- @events.fire_on_booted!
513
+ @events.fire_after_booted!
501
514
  debug_loaded_extensions("Loaded Extensions - master:") if @log_writer.debug?
502
515
  booted = true
503
516
  end
@@ -514,7 +527,7 @@ module Puma
514
527
  end
515
528
 
516
529
  if in_phased_restart && workers_not_booted.zero?
517
- @events.fire_on_booted!
530
+ @events.fire_after_booted!
518
531
  debug_loaded_extensions("Loaded Extensions - master:") if @log_writer.debug?
519
532
  in_phased_restart = false
520
533
  end
@@ -29,13 +29,13 @@ module Puma
29
29
 
30
30
  CONTENT_LENGTH = 'Content-Length' # should be lower case from app,
31
31
  # Util::HeaderHash allows mixed
32
- HTTP_VERSION = Const::HTTP_VERSION
33
32
  HTTP_X_FORWARDED_FOR = Const::HTTP_X_FORWARDED_FOR
34
33
  PATH_INFO = Const::PATH_INFO
35
34
  QUERY_STRING = Const::QUERY_STRING
36
35
  REMOTE_ADDR = Const::REMOTE_ADDR
37
36
  REMOTE_USER = 'REMOTE_USER'
38
37
  REQUEST_METHOD = Const::REQUEST_METHOD
38
+ SERVER_PROTOCOL = Const::SERVER_PROTOCOL
39
39
 
40
40
  def initialize(app, logger=nil)
41
41
  @app = app
@@ -70,7 +70,7 @@ module Puma
70
70
  env[REQUEST_METHOD],
71
71
  env[PATH_INFO],
72
72
  env[QUERY_STRING].empty? ? "" : "?#{env[QUERY_STRING]}",
73
- env[HTTP_VERSION],
73
+ env[SERVER_PROTOCOL],
74
74
  now - began_at ]
75
75
 
76
76
  write(msg)
@@ -87,7 +87,7 @@ module Puma
87
87
  env[REQUEST_METHOD],
88
88
  env[PATH_INFO],
89
89
  env[QUERY_STRING].empty? ? "" : "?#{env[QUERY_STRING]}",
90
- env[HTTP_VERSION],
90
+ env[SERVER_PROTOCOL],
91
91
  status.to_s[0..3],
92
92
  length,
93
93
  now - began_at ]