puma 6.6.1 → 7.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/lib/puma/cli.rb CHANGED
@@ -39,10 +39,8 @@ module Puma
39
39
  @control_url = nil
40
40
  @control_options = {}
41
41
 
42
- setup_options env
43
-
44
42
  begin
45
- @parser.parse! @argv
43
+ setup_options env
46
44
 
47
45
  if file = @argv.shift
48
46
  @conf.configure do |user_config, file_config|
@@ -93,7 +91,7 @@ module Puma
93
91
  #
94
92
 
95
93
  def setup_options(env = ENV)
96
- @conf = Configuration.new({}, {events: @events}, env) do |user_config, file_config|
94
+ @conf = Configuration.new({}, { events: @events }, env) do |user_config, file_config|
97
95
  @parser = OptionParser.new do |o|
98
96
  o.on "-b", "--bind URI", "URI to bind to (tcp://, unix://, ssl://)" do |arg|
99
97
  user_config.bind arg
@@ -240,7 +238,7 @@ module Puma
240
238
  $stdout.puts o
241
239
  exit 0
242
240
  end
243
- end
241
+ end.parse! @argv
244
242
  end
245
243
  end
246
244
  end
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,7 +165,10 @@ 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
 
@@ -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
 
@@ -291,8 +288,10 @@ module Puma
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)
@@ -412,17 +411,18 @@ module Puma
412
411
  if te
413
412
  te_lwr = te.downcase
414
413
  if te.include? ','
415
- te_ary = te_lwr.split ','
414
+ te_ary = te_lwr.split(',').each { |te| te.gsub!(STRIP_OWS, "") }
416
415
  te_count = te_ary.count CHUNKED
417
416
  te_valid = te_ary[0..-2].all? { |e| ALLOWED_TRANSFER_ENCODING.include? e }
418
- if te_ary.last == CHUNKED && te_count == 1 && te_valid
419
- @env.delete TRANSFER_ENCODING2
420
- return setup_chunked_body body
421
- elsif te_count >= 1
417
+ if te_count > 1
422
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}'"
423
421
  elsif !te_valid
424
422
  raise HttpParserError501, "#{TE_ERR_MSG}, unknown value: '#{te}'"
425
423
  end
424
+ @env.delete TRANSFER_ENCODING2
425
+ return setup_chunked_body body
426
426
  elsif te_lwr == CHUNKED
427
427
  @env.delete TRANSFER_ENCODING2
428
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,40 @@ 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
+ next unless hsh[key]
77
+
78
+ if hsh[key] < @worker_max[idx]
79
+ hsh[key] = @worker_max[idx]
80
+ else
81
+ @worker_max[idx] = hsh[key]
82
+ end
83
+ end
58
84
  @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
85
+ @last_status = hsh
86
+ end
87
+
88
+ # Resets max values to zero. Called whenever `Cluster#stats` is called
89
+ def reset_max
90
+ WORKER_MAX_KEYS.length.times { |idx| @worker_max[idx] = 0 }
60
91
  end
61
92
 
62
93
  # @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
 
@@ -45,8 +46,7 @@ module Puma
45
46
  end
46
47
 
47
48
  def start_phased_restart(refork = false)
48
- @events.fire_on_restart!
49
-
49
+ @events.fire_before_restart!
50
50
  @phase += 1
51
51
  if refork
52
52
  log "- Starting worker refork, phase: #{@phase}"
@@ -268,11 +268,14 @@ module Puma
268
268
  end
269
269
 
270
270
  # Inside of a child process, this will return all zeroes, as @workers is only populated in
271
- # the master process.
271
+ # the master process. Calling this also resets stat 'max' values to zero.
272
272
  # @!attribute [r] stats
273
+ # @return [Hash]
274
+
273
275
  def stats
274
276
  old_worker_count = @workers.count { |w| w.phase != @phase }
275
277
  worker_status = @workers.map do |w|
278
+ w.reset_max
276
279
  {
277
280
  started_at: utc_iso8601(w.started_at),
278
281
  pid: w.pid,
@@ -283,7 +286,6 @@ module Puma
283
286
  last_status: w.last_status,
284
287
  }
285
288
  end
286
-
287
289
  {
288
290
  started_at: utc_iso8601(@started_at),
289
291
  workers: @workers.size,
@@ -352,7 +354,7 @@ module Puma
352
354
 
353
355
  stop_workers
354
356
  stop
355
- @events.fire_on_stopped!
357
+ @events.fire_after_stopped!
356
358
  raise(SignalException, "SIGTERM") if @options[:raise_exception_on_sigterm]
357
359
  exit 0 # Clean exit, workers were stopped
358
360
  end
@@ -369,12 +371,8 @@ module Puma
369
371
 
370
372
  if preload?
371
373
  # Threads explicitly marked as fork safe will be ignored. Used in Rails,
372
- # but may be used by anyone. Note that we need to explicit
373
- # Process::Waiter check here because there's a bug in Ruby 2.6 and below
374
- # where calling thread_variable_get on a Process::Waiter will segfault.
375
- # We can drop that clause once those versions of Ruby are no longer
376
- # supported.
377
- 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) }
378
376
 
379
377
  before = Thread.list.reject(&fork_safe)
380
378
 
@@ -423,6 +421,7 @@ module Puma
423
421
 
424
422
  log "Use Ctrl-C to stop"
425
423
 
424
+ warn_ruby_mn_threads
426
425
  single_worker_warning
427
426
 
428
427
  redirect_io
@@ -511,7 +510,7 @@ module Puma
511
510
  end
512
511
 
513
512
  if !booted && @workers.none? {|worker| worker.last_status.empty?}
514
- @events.fire_on_booted!
513
+ @events.fire_after_booted!
515
514
  debug_loaded_extensions("Loaded Extensions - master:") if @log_writer.debug?
516
515
  booted = true
517
516
  end
@@ -528,7 +527,7 @@ module Puma
528
527
  end
529
528
 
530
529
  if in_phased_restart && workers_not_booted.zero?
531
- @events.fire_on_booted!
530
+ @events.fire_after_booted!
532
531
  debug_loaded_extensions("Loaded Extensions - master:") if @log_writer.debug?
533
532
  in_phased_restart = false
534
533
  end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Puma
4
+ # Calculate a delay value for sleeping when running in clustered mode
5
+ #
6
+ # The main reason this is a class is so it can be unit tested independently.
7
+ # This makes modification easier in the future if we can encode properties of the
8
+ # delay into a test instead of relying on end-to-end testing only.
9
+ #
10
+ # This is an imprecise mechanism to address specific goals:
11
+ #
12
+ # - Evenly distribute requests across all workers at start
13
+ # - Evenly distribute CPU resources across all workers
14
+ #
15
+ # ## Goal: Distribute requests across workers at start
16
+ #
17
+ # There was a perf bug in Puma where one worker would wake up slightly before the rest and accept
18
+ # all the requests on the socket even though it didn't have enough resources to process all of them.
19
+ # This was originally fixed by never calling accept when a worker had more requests than threads
20
+ # already https://github.com/puma/puma/pull/3678/files/2736ebddb3fc8528e5150b5913fba251c37a8bf7#diff-a95f46e7ce116caddc9b9a9aa81004246d5210d5da5f4df90a818c780630166bL251-L291
21
+ #
22
+ # With the introduction of true keepalive support, there are two ways a request can come in:
23
+ # - A new request from a new client comes into the socket and it must be "accept"-d
24
+ # - A keepalive request is served and the connection is retained. Another request is then accepted
25
+ #
26
+ # Ideally the server handles requests in the order they come in, and ideally it doesn't accept more requests than it can handle.
27
+ # These goals are contradictory, because when the server is at maximum capacity due to keepalive connections, it could mean we
28
+ # block all new requests, even if those came in before the new request on the older keepalive connection.
29
+ #
30
+ # ## Distribute CPU resources across all workers
31
+ #
32
+ # - This issue was opened https://github.com/puma/puma/issues/2078
33
+ #
34
+ # There are several entangled issues and it's not exactly clear the root cause, but the observable outcome
35
+ # was that performance was better with a small sleep, and that eventually became the default.
36
+ #
37
+ # An attempt to describe why this works is here: https://github.com/puma/puma/issues/2078#issuecomment-3287032470.
38
+ #
39
+ # Summarizing: The delay is for tuning the rate at which "accept" is called on the socket.
40
+ # Puma works by calling "accept" nonblock on the socket in a loop. When there are multiple workers,
41
+ # (processes) then they will "race" to accept a request at roughly the same rate. However if one
42
+ # worker has all threads busy processing requests, then accepting a new request might "steal" it from
43
+ # a less busy worker. If a worker has no work to do, it should loop as fast as possible.
44
+ #
45
+ # ## Solution(s): Distribute requests across workers at start
46
+ #
47
+ # For now, both goals are framed as "load balancing" across workers (processes) and achieved through
48
+ # the same mechanism of sleeping longer to delay busier workers. Rather than the prior Puma 6.x
49
+ # and earlier behavior of using a binary on/off sleep value, we increase it an amound proportional
50
+ # to the load the server is under. Capping the maximum delay to the scenario where all threads are busy
51
+ # and the todo list has reached a multiplier of the maximum number of threads.
52
+ #
53
+ # Private: API may change unexpectedly
54
+ class ClusterAcceptLoopDelay
55
+ attr_reader :max_threads, :max_delay
56
+
57
+ # Initialize happens once, `call` happens often. Push global calculations here
58
+ def initialize(
59
+ # Number of workers in the cluster
60
+ workers: ,
61
+ # Maximum delay in seconds i.e. 0.005 is 5 microseconds
62
+ max_delay: # In seconds i.e. 0.005 is 5 microseconds
63
+
64
+ )
65
+ @on = max_delay > 0 && workers >= 2
66
+ @max_delay = max_delay.to_f
67
+
68
+ # Reach maximum delay when `max_threads * overload_multiplier` is reached in the system
69
+ @overload_multiplier = 25.0
70
+ end
71
+
72
+ def on?
73
+ @on
74
+ end
75
+
76
+ # We want the extreme values of this delay to be known (minimum and maximum) as well as
77
+ # a predictable curve between the two. i.e. no step functions or hard cliffs.
78
+ #
79
+ # Return value is always numeric. Returns 0 if there should be no delay
80
+ def calculate(
81
+ # Number of threads working right now, plus number of requests in the todo list
82
+ busy_threads_plus_todo:,
83
+ # Maximum number of threads in the pool, note that the busy threads (alone) may go over this value at times
84
+ # if the pool needs to be reaped. The busy thread plus todo count may go over this value by a large amount
85
+ max_threads:
86
+ )
87
+ max_value = @overload_multiplier * max_threads
88
+ # Approaches max delay when `busy_threads_plus_todo` approaches `max_value`
89
+ return max_delay * busy_threads_plus_todo.clamp(0, max_value) / max_value
90
+ end
91
+ end
92
+ 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 ]