puma 6.6.0 → 8.0.2

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.
Files changed (65) hide show
  1. checksums.yaml +4 -4
  2. data/History.md +309 -5
  3. data/README.md +41 -42
  4. data/docs/5.0-Upgrade.md +98 -0
  5. data/docs/6.0-Upgrade.md +56 -0
  6. data/docs/7.0-Upgrade.md +52 -0
  7. data/docs/8.0-Upgrade.md +100 -0
  8. data/docs/deployment.md +58 -23
  9. data/docs/fork_worker.md +5 -5
  10. data/docs/grpc.md +62 -0
  11. data/docs/images/favicon.svg +1 -0
  12. data/docs/images/running-puma.svg +1 -0
  13. data/docs/images/standard-logo.svg +1 -0
  14. data/docs/jungle/README.md +1 -1
  15. data/docs/kubernetes.md +11 -16
  16. data/docs/plugins.md +2 -2
  17. data/docs/restart.md +2 -2
  18. data/docs/signals.md +21 -21
  19. data/docs/stats.md +4 -3
  20. data/docs/systemd.md +4 -4
  21. data/ext/puma_http11/extconf.rb +2 -17
  22. data/ext/puma_http11/http11_parser.java.rl +51 -65
  23. data/ext/puma_http11/mini_ssl.c +18 -8
  24. data/ext/puma_http11/org/jruby/puma/EnvKey.java +241 -0
  25. data/ext/puma_http11/org/jruby/puma/Http11.java +174 -102
  26. data/ext/puma_http11/org/jruby/puma/Http11Parser.java +71 -85
  27. data/ext/puma_http11/puma_http11.c +122 -118
  28. data/lib/puma/app/status.rb +10 -2
  29. data/lib/puma/binder.rb +12 -10
  30. data/lib/puma/cli.rb +4 -6
  31. data/lib/puma/client.rb +205 -131
  32. data/lib/puma/client_env.rb +171 -0
  33. data/lib/puma/cluster/worker.rb +17 -17
  34. data/lib/puma/cluster/worker_handle.rb +38 -7
  35. data/lib/puma/cluster.rb +44 -30
  36. data/lib/puma/cluster_accept_loop_delay.rb +91 -0
  37. data/lib/puma/commonlogger.rb +3 -3
  38. data/lib/puma/configuration.rb +173 -58
  39. data/lib/puma/const.rb +11 -11
  40. data/lib/puma/control_cli.rb +7 -3
  41. data/lib/puma/detect.rb +13 -0
  42. data/lib/puma/dsl.rb +225 -108
  43. data/lib/puma/error_logger.rb +3 -1
  44. data/lib/puma/events.rb +25 -10
  45. data/lib/puma/io_buffer.rb +8 -4
  46. data/lib/puma/launcher/bundle_pruner.rb +3 -5
  47. data/lib/puma/launcher.rb +57 -53
  48. data/lib/puma/log_writer.rb +8 -2
  49. data/lib/puma/minissl.rb +0 -1
  50. data/lib/puma/plugin/systemd.rb +3 -3
  51. data/lib/puma/rack/urlmap.rb +1 -1
  52. data/lib/puma/reactor.rb +19 -13
  53. data/lib/puma/{request.rb → response.rb} +56 -212
  54. data/lib/puma/runner.rb +9 -18
  55. data/lib/puma/server.rb +182 -97
  56. data/lib/puma/server_plugin_control.rb +32 -0
  57. data/lib/puma/single.rb +7 -4
  58. data/lib/puma/state_file.rb +3 -2
  59. data/lib/puma/thread_pool.rb +170 -99
  60. data/lib/puma/util.rb +0 -7
  61. data/lib/puma.rb +10 -0
  62. data/lib/rack/handler/puma.rb +3 -3
  63. data/tools/Dockerfile +15 -5
  64. metadata +19 -7
  65. data/ext/puma_http11/ext_help.h +0 -15
@@ -0,0 +1,171 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Puma
4
+
5
+ #———————————————————————— DO NOT USE — this class is for internal use only ———
6
+
7
+
8
+ # This module is included in `Client`. It contains code to process the `env`
9
+ # before it is passed to the app.
10
+ #
11
+ module ClientEnv # :nodoc:
12
+
13
+ include Puma::Const
14
+
15
+ # Given a Hash +env+ for the request read from +client+, add
16
+ # and fixup keys to comply with Rack's env guidelines.
17
+ # @param env [Hash] see Puma::Client#env, from request
18
+ # @param client [Puma::Client] only needed for Client#peerip
19
+ #
20
+ def normalize_env
21
+ if host = @env[HTTP_HOST]
22
+ # host can be a hostname, ipv4 or bracketed ipv6. Followed by an optional port.
23
+ if colon = host.rindex("]:") # IPV6 with port
24
+ @env[SERVER_NAME] = host[0, colon+1]
25
+ @env[SERVER_PORT] = host[colon+2, host.bytesize]
26
+ elsif !host.start_with?("[") && colon = host.index(":") # not hostname or IPV4 with port
27
+ @env[SERVER_NAME] = host[0, colon]
28
+ @env[SERVER_PORT] = host[colon+1, host.bytesize]
29
+ else
30
+ @env[SERVER_NAME] = host
31
+ @env[SERVER_PORT] = default_server_port
32
+ end
33
+ else
34
+ @env[SERVER_NAME] = LOCALHOST
35
+ @env[SERVER_PORT] = default_server_port
36
+ end
37
+
38
+ unless @env[REQUEST_PATH]
39
+ # it might be a dumbass full host request header
40
+ uri = begin
41
+ URI.parse(@env[REQUEST_URI])
42
+ rescue URI::InvalidURIError
43
+ raise Puma::HttpParserError
44
+ end
45
+ @env[REQUEST_PATH] = uri.path
46
+
47
+ # A nil env value will cause a LintError (and fatal errors elsewhere),
48
+ # so only set the env value if there actually is a value.
49
+ @env[QUERY_STRING] = uri.query if uri.query
50
+ end
51
+
52
+ @env[PATH_INFO] = @env[REQUEST_PATH].to_s # #to_s in case it's nil
53
+
54
+ # From https://www.ietf.org/rfc/rfc3875 :
55
+ # "Script authors should be aware that the REMOTE_ADDR and
56
+ # REMOTE_HOST meta-variables (see sections 4.1.8 and 4.1.9)
57
+ # may not identify the ultimate source of the request.
58
+ # They identify the client for the immediate request to the
59
+ # server; that client may be a proxy, gateway, or other
60
+ # intermediary acting on behalf of the actual source client."
61
+ #
62
+
63
+ unless @env.key?(REMOTE_ADDR)
64
+ begin
65
+ addr = peerip
66
+ rescue Errno::ENOTCONN
67
+ # Client disconnects can result in an inability to get the
68
+ # peeraddr from the socket; default to unspec.
69
+ if peer_family == Socket::AF_INET6
70
+ addr = UNSPECIFIED_IPV6
71
+ else
72
+ addr = UNSPECIFIED_IPV4
73
+ end
74
+ end
75
+
76
+ # Set unix socket addrs to localhost
77
+ if addr.empty?
78
+ addr = peer_family == Socket::AF_INET6 ? LOCALHOST_IPV6 : LOCALHOST_IPV4
79
+ end
80
+
81
+ @env[REMOTE_ADDR] = addr
82
+ end
83
+
84
+ # The legacy HTTP_VERSION header can be sent as a client header.
85
+ # Rack v4 may remove using HTTP_VERSION. If so, remove this line.
86
+ @env[HTTP_VERSION] = @env[SERVER_PROTOCOL] if @env_set_http_version
87
+
88
+ @env[PUMA_SOCKET] = @io
89
+
90
+ if @env[HTTPS_KEY] && @io.peercert
91
+ @env[PUMA_PEERCERT] = @io.peercert
92
+ end
93
+
94
+ @env[HIJACK_P] = true
95
+ @env[HIJACK] = method(:full_hijack).to_proc
96
+
97
+ @env[RACK_INPUT] = @body || EmptyBody
98
+ @env[RACK_URL_SCHEME] ||= default_server_port == PORT_443 ? HTTPS : HTTP
99
+ end
100
+
101
+ # Fixup any headers with `,` in the name to have `_` now. We emit
102
+ # headers with `,` in them during the parse phase to avoid ambiguity
103
+ # with the `-` to `_` conversion for critical headers. But here for
104
+ # compatibility, we'll convert them back. This code is written to
105
+ # avoid allocation in the common case (ie there are no headers
106
+ # with `,` in their names), that's why it has the extra conditionals.
107
+ #
108
+ # @note If a normalized version of a `,` header already exists, we ignore
109
+ # the `,` version. This prevents clobbering headers managed by proxies
110
+ # but not by clients (Like X-Forwarded-For).
111
+ #
112
+ # @param env [Hash] see Puma::Client#env, from request, modifies in place
113
+ # @version 5.0.3
114
+ #
115
+ def req_env_post_parse
116
+ to_delete = nil
117
+ to_add = nil
118
+
119
+ @env.each do |k,v|
120
+ if k.start_with?("HTTP_") && k.include?(",") && !UNMASKABLE_HEADERS.key?(k)
121
+ if to_delete
122
+ to_delete << k
123
+ else
124
+ to_delete = [k]
125
+ end
126
+
127
+ new_k = k.tr(",", "_")
128
+ if @env.key?(new_k)
129
+ next
130
+ end
131
+
132
+ unless to_add
133
+ to_add = {}
134
+ end
135
+
136
+ to_add[new_k] = v
137
+ end
138
+ end
139
+
140
+ if to_delete # rubocop:disable Style/SafeNavigation
141
+ to_delete.each { |k| env.delete(k) }
142
+ end
143
+
144
+ if to_add
145
+ @env.merge! to_add
146
+ end
147
+
148
+ # A rack extension. If the app writes #call'ables to this
149
+ # array, we will invoke them when the request is done.
150
+ #
151
+ env[RACK_AFTER_REPLY] ||= []
152
+ env[RACK_RESPONSE_FINISHED] ||= []
153
+ end
154
+
155
+ HTTP_ON_VALUES = { "on" => true, HTTPS => true }
156
+ private_constant :HTTP_ON_VALUES
157
+
158
+ # @return [Puma::Const::PORT_443,Puma::Const::PORT_80]
159
+ #
160
+ def default_server_port
161
+ if HTTP_ON_VALUES[@env[HTTPS_KEY]] ||
162
+ @env[HTTP_X_FORWARDED_PROTO]&.start_with?(HTTPS) ||
163
+ @env[HTTP_X_FORWARDED_SCHEME] == HTTPS ||
164
+ @env[HTTP_X_FORWARDED_SSL] == "on"
165
+ PORT_443
166
+ else
167
+ PORT_80
168
+ end
169
+ end
170
+ end
171
+ end
@@ -14,7 +14,7 @@ module Puma
14
14
  class Worker < Puma::Runner # :nodoc:
15
15
  attr_reader :index, :master
16
16
 
17
- def initialize(index:, master:, launcher:, pipes:, server: nil)
17
+ def initialize(index:, master:, launcher:, pipes:, app: nil)
18
18
  super(launcher)
19
19
 
20
20
  @index = index
@@ -23,7 +23,8 @@ module Puma
23
23
  @worker_write = pipes[:worker_write]
24
24
  @fork_pipe = pipes[:fork_pipe]
25
25
  @wakeup = pipes[:wakeup]
26
- @server = server
26
+ @app = app
27
+ @server = nil
27
28
  @hook_data = {}
28
29
  end
29
30
 
@@ -57,7 +58,7 @@ module Puma
57
58
  @config.run_hooks(:before_worker_boot, index, @log_writer, @hook_data)
58
59
 
59
60
  begin
60
- server = @server ||= start_server
61
+ @server = start_server
61
62
  rescue Exception => e
62
63
  log "! Unable to start worker"
63
64
  log e
@@ -85,7 +86,7 @@ module Puma
85
86
  if idx == -1 # stop server
86
87
  if restart_server.length > 0
87
88
  restart_server.clear
88
- server.begin_restart(true)
89
+ @server.begin_restart(true)
89
90
  @config.run_hooks(:before_refork, nil, @log_writer, @hook_data)
90
91
  end
91
92
  elsif idx == -2 # refork cycle is done
@@ -103,20 +104,19 @@ module Puma
103
104
  Signal.trap "SIGTERM" do
104
105
  @worker_write << "#{PIPE_EXTERNAL_TERM}#{Process.pid}\n" rescue nil
105
106
  restart_server.clear
106
- server.stop
107
+ @server.stop
107
108
  restart_server << false
108
109
  end
109
110
 
110
111
  begin
111
112
  @worker_write << "#{PIPE_BOOT}#{Process.pid}:#{index}\n"
112
113
  rescue SystemCallError, IOError
113
- Puma::Util.purge_interrupt_queue
114
114
  STDERR.puts "Master seems to have exited, exiting."
115
115
  return
116
116
  end
117
117
 
118
118
  while restart_server.pop
119
- server_thread = server.run
119
+ server_thread = @server.run
120
120
 
121
121
  if @log_writer.debug? && index == 0
122
122
  debug_loaded_extensions "Loaded Extensions - worker 0:"
@@ -128,16 +128,16 @@ module Puma
128
128
 
129
129
  while true
130
130
  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
131
+ payload = base_payload.dup
132
+
133
+ hsh = @server.stats
134
+ hsh.each do |k, v|
135
+ payload << %Q! "#{k}":#{v || 0},!
136
+ end
137
+ # sub call properly adds 'closing' string
138
+ io << payload.sub(/,\z/, " }\n")
139
+ @server.reset_max
139
140
  rescue IOError
140
- Puma::Util.purge_interrupt_queue
141
141
  break
142
142
  end
143
143
  sleep @options[:worker_check_interval]
@@ -165,7 +165,7 @@ module Puma
165
165
  launcher: @launcher,
166
166
  pipes: { check_pipe: @check_pipe,
167
167
  worker_write: @worker_write },
168
- server: @server
168
+ app: @app
169
169
  new_worker.run
170
170
  end
171
171
 
@@ -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,12 +25,13 @@ 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
- attr_reader :index, :pid, :phase, :signal, :last_checkin, :last_status, :started_at
31
+ attr_reader :index, :pid, :phase, :signal, :last_checkin, :last_status, :started_at, :process_status
29
32
 
30
33
  # @version 5.0.0
31
- attr_writer :pid, :phase
34
+ attr_writer :pid, :phase, :process_status
32
35
 
33
36
  def booted?
34
37
  @stage == :booted
@@ -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,7 +22,8 @@ module Puma
22
22
  @workers = []
23
23
  @next_check = Time.now
24
24
 
25
- @phased_restart = false
25
+ @worker_max = [] # keeps track of 'max' stat values
26
+ @pending_phased_restart = false
26
27
  end
27
28
 
28
29
  # Returns the list of cluster worker handles.
@@ -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.
@@ -82,7 +87,7 @@ module Puma
82
87
  end
83
88
 
84
89
  debug "Spawned worker: #{pid}"
85
- @workers << WorkerHandle.new(idx, pid, @phase, @options)
90
+ @workers.insert(idx, WorkerHandle.new(idx, pid, @phase, @options))
86
91
  end
87
92
 
88
93
  if @options[:fork_worker] && all_workers_in_phase?
@@ -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]
@@ -181,10 +186,15 @@ module Puma
181
186
  # we need to phase any workers out (which will restart
182
187
  # in the right phase).
183
188
  #
184
- w = @workers.find { |x| x.phase != @phase }
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}..."
@@ -211,12 +221,11 @@ module Puma
211
221
  pipes[:wakeup] = @wakeup
212
222
  end
213
223
 
214
- server = start_server if preload?
215
224
  new_worker = Worker.new index: index,
216
225
  master: master,
217
226
  launcher: @launcher,
218
227
  pipes: pipes,
219
- server: server
228
+ app: (app if preload?)
220
229
  new_worker.run
221
230
  end
222
231
 
@@ -228,7 +237,7 @@ module Puma
228
237
  def phased_restart(refork = false)
229
238
  return false if @options[:preload_app] && !refork
230
239
 
231
- @phased_restart = true
240
+ @pending_phased_restart = refork ? :refork : true
232
241
  wakeup!
233
242
 
234
243
  true
@@ -258,11 +267,14 @@ module Puma
258
267
  end
259
268
 
260
269
  # Inside of a child process, this will return all zeroes, as @workers is only populated in
261
- # the master process.
270
+ # the master process. Calling this also resets stat 'max' values to zero.
262
271
  # @!attribute [r] stats
272
+ # @return [Hash]
273
+
263
274
  def stats
264
275
  old_worker_count = @workers.count { |w| w.phase != @phase }
265
276
  worker_status = @workers.map do |w|
277
+ w.reset_max
266
278
  {
267
279
  started_at: utc_iso8601(w.started_at),
268
280
  pid: w.pid,
@@ -273,7 +285,6 @@ module Puma
273
285
  last_status: w.last_status,
274
286
  }
275
287
  end
276
-
277
288
  {
278
289
  started_at: utc_iso8601(@started_at),
279
290
  workers: @workers.size,
@@ -342,7 +353,7 @@ module Puma
342
353
 
343
354
  stop_workers
344
355
  stop
345
- @events.fire_on_stopped!
356
+ @events.fire_after_stopped!
346
357
  raise(SignalException, "SIGTERM") if @options[:raise_exception_on_sigterm]
347
358
  exit 0 # Clean exit, workers were stopped
348
359
  end
@@ -359,16 +370,12 @@ module Puma
359
370
 
360
371
  if preload?
361
372
  # 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) }
373
+ # but may be used by anyone.
374
+ fork_safe = ->(t) { t.thread_variable_get(:fork_safe) }
368
375
 
369
376
  before = Thread.list.reject(&fork_safe)
370
377
 
371
- log "* Restarts: (\u2714) hot (\u2716) phased"
378
+ log "* Restarts: (\u2714) hot (\u2716) phased (#{@options[:fork_worker] ? "\u2714" : "\u2716"}) refork"
372
379
  log "* Preloading application"
373
380
  load_and_bind
374
381
 
@@ -386,7 +393,7 @@ module Puma
386
393
  end
387
394
  end
388
395
  else
389
- log "* Restarts: (\u2714) hot (\u2714) phased"
396
+ log "* Restarts: (\u2714) hot (\u2714) phased (#{@options[:fork_worker] ? "\u2714" : "\u2716"}) refork"
390
397
 
391
398
  unless @config.app_configured?
392
399
  error "No application configured, nothing to run"
@@ -413,6 +420,7 @@ module Puma
413
420
 
414
421
  log "Use Ctrl-C to stop"
415
422
 
423
+ warn_ruby_mn_threads
416
424
  single_worker_warning
417
425
 
418
426
  redirect_io
@@ -447,14 +455,18 @@ module Puma
447
455
  break
448
456
  end
449
457
 
450
- if @phased_restart
451
- start_phased_restart
452
- @phased_restart = false
453
- in_phased_restart = true
458
+ if @pending_phased_restart
459
+ start_phased_restart(@pending_phased_restart == :refork)
460
+
461
+ in_phased_restart = @pending_phased_restart
462
+ @pending_phased_restart = false
463
+
454
464
  workers_not_booted = @options[:workers]
465
+ # worker 0 is not restarted on refork
466
+ workers_not_booted -= 1 if in_phased_restart == :refork
455
467
  end
456
468
 
457
- check_workers
469
+ check_workers(in_phased_restart == :refork)
458
470
 
459
471
  if read.wait_readable([0, @next_check - Time.now].max)
460
472
  req = read.read_nonblock(1)
@@ -497,7 +509,7 @@ module Puma
497
509
  end
498
510
 
499
511
  if !booted && @workers.none? {|worker| worker.last_status.empty?}
500
- @events.fire_on_booted!
512
+ @events.fire_after_booted!
501
513
  debug_loaded_extensions("Loaded Extensions - master:") if @log_writer.debug?
502
514
  booted = true
503
515
  end
@@ -514,7 +526,7 @@ module Puma
514
526
  end
515
527
 
516
528
  if in_phased_restart && workers_not_booted.zero?
517
- @events.fire_on_booted!
529
+ @events.fire_after_booted!
518
530
  debug_loaded_extensions("Loaded Extensions - master:") if @log_writer.debug?
519
531
  in_phased_restart = false
520
532
  end
@@ -570,7 +582,9 @@ module Puma
570
582
  # `Process.wait2(-1)` from detecting a terminated process: https://bugs.ruby-lang.org/issues/19837.
571
583
  # 2. When `fork_worker` is enabled, some worker may not be direct children,
572
584
  # but grand children. Because of this they won't be reaped by `Process.wait2(-1)`.
573
- if reaped_children.delete(w.pid) || Process.wait(w.pid, Process::WNOHANG)
585
+ if (status = reaped_children.delete(w.pid) || Process.wait2(w.pid, Process::WNOHANG)&.last)
586
+ w.process_status = status
587
+ @config.run_hooks(:after_worker_shutdown, w, @log_writer)
574
588
  true
575
589
  else
576
590
  w.term if w.term?
@@ -0,0 +1,91 @@
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"-ed
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
+ # ## Goal: 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 what the root cause is, 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), 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: 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 amount 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_delay
56
+
57
+ # Initialize happens once, `call` happens often. Perform 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 milliseconds
62
+ max_delay:
63
+ )
64
+ @on = max_delay > 0 && workers >= 2
65
+ @max_delay = max_delay.to_f
66
+
67
+ # Reach maximum delay when `max_threads * overload_multiplier` is reached in the system
68
+ @overload_multiplier = 25.0
69
+ end
70
+
71
+ def on?
72
+ @on
73
+ end
74
+
75
+ # We want the extreme values of this delay to be known (minimum and maximum) as well as
76
+ # a predictable curve between the two. i.e. no step functions or hard cliffs.
77
+ #
78
+ # Return value is always numeric. Returns 0 if there should be no delay.
79
+ def calculate(
80
+ # Number of threads working right now, plus number of requests in the todo list
81
+ busy_threads_plus_todo:,
82
+ # Maximum number of threads in the pool, note that the busy threads (alone) may go over this value at times
83
+ # if the pool needs to be reaped. The busy thread plus todo count may go over this value by a large amount.
84
+ max_threads:
85
+ )
86
+ max_value = @overload_multiplier * max_threads
87
+ # Approaches max delay when `busy_threads_plus_todo` approaches `max_value`
88
+ return max_delay * busy_threads_plus_todo.clamp(0, max_value) / max_value
89
+ end
90
+ end
91
+ 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 ]