puma 5.0.4 → 5.6.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.

Potentially problematic release.


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

Files changed (79) hide show
  1. checksums.yaml +4 -4
  2. data/History.md +322 -48
  3. data/LICENSE +0 -0
  4. data/README.md +95 -24
  5. data/bin/puma-wild +0 -0
  6. data/docs/architecture.md +57 -20
  7. data/docs/compile_options.md +21 -0
  8. data/docs/deployment.md +53 -67
  9. data/docs/fork_worker.md +2 -0
  10. data/docs/images/puma-connection-flow-no-reactor.png +0 -0
  11. data/docs/images/puma-connection-flow.png +0 -0
  12. data/docs/images/puma-general-arch.png +0 -0
  13. data/docs/jungle/README.md +0 -0
  14. data/docs/jungle/rc.d/README.md +1 -1
  15. data/docs/jungle/rc.d/puma.conf +0 -0
  16. data/docs/kubernetes.md +66 -0
  17. data/docs/nginx.md +0 -0
  18. data/docs/plugins.md +15 -15
  19. data/docs/rails_dev_mode.md +28 -0
  20. data/docs/restart.md +7 -7
  21. data/docs/signals.md +11 -10
  22. data/docs/stats.md +142 -0
  23. data/docs/systemd.md +85 -66
  24. data/ext/puma_http11/PumaHttp11Service.java +0 -0
  25. data/ext/puma_http11/ext_help.h +0 -0
  26. data/ext/puma_http11/extconf.rb +42 -6
  27. data/ext/puma_http11/http11_parser.c +68 -57
  28. data/ext/puma_http11/http11_parser.h +1 -1
  29. data/ext/puma_http11/http11_parser.java.rl +1 -1
  30. data/ext/puma_http11/http11_parser.rl +1 -1
  31. data/ext/puma_http11/http11_parser_common.rl +1 -1
  32. data/ext/puma_http11/mini_ssl.c +226 -88
  33. data/ext/puma_http11/no_ssl/PumaHttp11Service.java +0 -0
  34. data/ext/puma_http11/org/jruby/puma/Http11.java +0 -0
  35. data/ext/puma_http11/org/jruby/puma/Http11Parser.java +51 -51
  36. data/ext/puma_http11/org/jruby/puma/MiniSSL.java +28 -43
  37. data/ext/puma_http11/puma_http11.c +9 -3
  38. data/lib/puma/app/status.rb +4 -7
  39. data/lib/puma/binder.rb +138 -49
  40. data/lib/puma/cli.rb +18 -4
  41. data/lib/puma/client.rb +113 -31
  42. data/lib/puma/cluster/worker.rb +22 -19
  43. data/lib/puma/cluster/worker_handle.rb +13 -2
  44. data/lib/puma/cluster.rb +75 -33
  45. data/lib/puma/commonlogger.rb +0 -0
  46. data/lib/puma/configuration.rb +21 -2
  47. data/lib/puma/const.rb +17 -8
  48. data/lib/puma/control_cli.rb +76 -71
  49. data/lib/puma/detect.rb +19 -9
  50. data/lib/puma/dsl.rb +225 -31
  51. data/lib/puma/error_logger.rb +12 -5
  52. data/lib/puma/events.rb +18 -3
  53. data/lib/puma/io_buffer.rb +0 -0
  54. data/lib/puma/jruby_restart.rb +0 -0
  55. data/lib/puma/json_serialization.rb +96 -0
  56. data/lib/puma/launcher.rb +56 -7
  57. data/lib/puma/minissl/context_builder.rb +14 -6
  58. data/lib/puma/minissl.rb +72 -40
  59. data/lib/puma/null_io.rb +12 -0
  60. data/lib/puma/plugin/tmp_restart.rb +0 -0
  61. data/lib/puma/plugin.rb +2 -2
  62. data/lib/puma/queue_close.rb +7 -7
  63. data/lib/puma/rack/builder.rb +1 -1
  64. data/lib/puma/rack/urlmap.rb +0 -0
  65. data/lib/puma/rack_default.rb +0 -0
  66. data/lib/puma/reactor.rb +19 -12
  67. data/lib/puma/request.rb +55 -21
  68. data/lib/puma/runner.rb +39 -13
  69. data/lib/puma/server.rb +78 -142
  70. data/lib/puma/single.rb +0 -0
  71. data/lib/puma/state_file.rb +45 -9
  72. data/lib/puma/systemd.rb +46 -0
  73. data/lib/puma/thread_pool.rb +11 -8
  74. data/lib/puma/util.rb +8 -1
  75. data/lib/puma.rb +36 -10
  76. data/lib/rack/handler/puma.rb +1 -0
  77. data/tools/Dockerfile +1 -1
  78. data/tools/trickletest.rb +0 -0
  79. metadata +15 -9
data/lib/puma/client.rb CHANGED
@@ -23,6 +23,8 @@ module Puma
23
23
 
24
24
  class ConnectionError < RuntimeError; end
25
25
 
26
+ class HttpParserError501 < IOError; end
27
+
26
28
  # An instance of this class represents a unique request from a client.
27
29
  # For example, this could be a web request from a browser or from CURL.
28
30
  #
@@ -35,7 +37,21 @@ module Puma
35
37
  # Instances of this class are responsible for knowing if
36
38
  # the header and body are fully buffered via the `try_to_finish` method.
37
39
  # They can be used to "time out" a response via the `timeout_at` reader.
40
+ #
38
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
+
39
55
  # The object used for a request with no body. All requests with
40
56
  # no body share this one object since it has no state.
41
57
  EmptyBody = NullIO.new
@@ -56,6 +72,7 @@ module Puma
56
72
  @parser = HttpParser.new
57
73
  @parsed_bytes = 0
58
74
  @read_header = true
75
+ @read_proxy = false
59
76
  @ready = false
60
77
 
61
78
  @body = nil
@@ -69,7 +86,9 @@ module Puma
69
86
  @hijacked = false
70
87
 
71
88
  @peerip = nil
89
+ @listener = nil
72
90
  @remote_addr_header = nil
91
+ @expect_proxy_proto = false
73
92
 
74
93
  @body_remain = 0
75
94
 
@@ -81,7 +100,7 @@ module Puma
81
100
 
82
101
  attr_writer :peerip
83
102
 
84
- attr_accessor :remote_addr_header
103
+ attr_accessor :remote_addr_header, :listener
85
104
 
86
105
  def_delegators :@io, :closed?
87
106
 
@@ -105,7 +124,7 @@ module Puma
105
124
 
106
125
  # @!attribute [r] in_data_phase
107
126
  def in_data_phase
108
- !@read_header
127
+ !(@read_header || @read_proxy)
109
128
  end
110
129
 
111
130
  def set_timeout(val)
@@ -120,16 +139,19 @@ module Puma
120
139
  def reset(fast_check=true)
121
140
  @parser.reset
122
141
  @read_header = true
142
+ @read_proxy = !!@expect_proxy_proto
123
143
  @env = @proto_env.dup
124
144
  @body = nil
125
145
  @tempfile = nil
126
146
  @parsed_bytes = 0
127
147
  @ready = false
128
148
  @body_remain = 0
129
- @peerip = nil
149
+ @peerip = nil if @remote_addr_header
130
150
  @in_last_chunk = false
131
151
 
132
152
  if @buffer
153
+ return false unless try_to_parse_proxy_protocol
154
+
133
155
  @parsed_bytes = @parser.execute(@env, @buffer, @parsed_bytes)
134
156
 
135
157
  if @parser.finished?
@@ -142,8 +164,7 @@ module Puma
142
164
  return false
143
165
  else
144
166
  begin
145
- if fast_check &&
146
- IO.select([@to_io], nil, nil, FAST_TRACK_KA_TIMEOUT)
167
+ if fast_check && @to_io.wait_readable(FAST_TRACK_KA_TIMEOUT)
147
168
  return try_to_finish
148
169
  end
149
170
  rescue IOError
@@ -156,13 +177,37 @@ module Puma
156
177
  def close
157
178
  begin
158
179
  @io.close
159
- rescue IOError
160
- Thread.current.purge_interrupt_queue if Thread.current.respond_to? :purge_interrupt_queue
180
+ rescue IOError, Errno::EBADF
181
+ Puma::Util.purge_interrupt_queue
182
+ end
183
+ end
184
+
185
+ # If necessary, read the PROXY protocol from the buffer. Returns
186
+ # false if more data is needed.
187
+ def try_to_parse_proxy_protocol
188
+ if @read_proxy
189
+ if @expect_proxy_proto == :v1
190
+ if @buffer.include? "\r\n"
191
+ if md = PROXY_PROTOCOL_V1_REGEX.match(@buffer)
192
+ if md[1]
193
+ @peerip = md[1].split(" ")[0]
194
+ end
195
+ @buffer = md.post_match
196
+ end
197
+ # if the buffer has a \r\n but doesn't have a PROXY protocol
198
+ # request, this is just HTTP from a non-PROXY client; move on
199
+ @read_proxy = false
200
+ return @buffer.size > 0
201
+ else
202
+ return false
203
+ end
204
+ end
161
205
  end
206
+ true
162
207
  end
163
208
 
164
209
  def try_to_finish
165
- return read_body unless @read_header
210
+ return read_body if in_data_phase
166
211
 
167
212
  begin
168
213
  data = @io.read_nonblock(CHUNK_SIZE)
@@ -187,6 +232,8 @@ module Puma
187
232
  @buffer = data
188
233
  end
189
234
 
235
+ return false unless try_to_parse_proxy_protocol
236
+
190
237
  @parsed_bytes = @parser.execute(@env, @buffer, @parsed_bytes)
191
238
 
192
239
  if @parser.finished?
@@ -201,13 +248,13 @@ module Puma
201
248
 
202
249
  def eagerly_finish
203
250
  return true if @ready
204
- return false unless IO.select([@to_io], nil, nil, 0)
251
+ return false unless @to_io.wait_readable(0)
205
252
  try_to_finish
206
253
  end
207
254
 
208
255
  def finish(timeout)
209
256
  return if @ready
210
- IO.select([@to_io], nil, nil, timeout) || timeout! until try_to_finish
257
+ @to_io.wait_readable(timeout) || timeout! until try_to_finish
211
258
  end
212
259
 
213
260
  def timeout!
@@ -239,13 +286,19 @@ module Puma
239
286
  # @version 5.0.0
240
287
  #
241
288
  def can_close?
242
- # Allow connection to close if it's received at least one full request
243
- # and hasn't received any data for a future request.
244
- #
245
- # From RFC 2616 section 8.1.4:
246
- # Servers SHOULD always respond to at least one request per connection,
247
- # if at all possible.
248
- @requests_served > 0 && @parsed_bytes == 0
289
+ # Allow connection to close if we're not in the middle of parsing a request.
290
+ @parsed_bytes == 0
291
+ end
292
+
293
+ def expect_proxy_proto=(val)
294
+ if val
295
+ if @read_header
296
+ @read_proxy = true
297
+ end
298
+ else
299
+ @read_proxy = false
300
+ end
301
+ @expect_proxy_proto = val
249
302
  end
250
303
 
251
304
  private
@@ -265,16 +318,27 @@ module Puma
265
318
  body = @parser.body
266
319
 
267
320
  te = @env[TRANSFER_ENCODING2]
268
-
269
321
  if te
270
- if te.include?(",")
271
- te.split(",").each do |part|
272
- if CHUNKED.casecmp(part.strip) == 0
273
- return setup_chunked_body(body)
274
- end
322
+ te_lwr = te.downcase
323
+ if te.include? ','
324
+ te_ary = te_lwr.split ','
325
+ te_count = te_ary.count CHUNKED
326
+ te_valid = te_ary[0..-2].all? { |e| ALLOWED_TRANSFER_ENCODING.include? e }
327
+ if te_ary.last == CHUNKED && te_count == 1 && te_valid
328
+ @env.delete TRANSFER_ENCODING2
329
+ return setup_chunked_body body
330
+ elsif te_count >= 1
331
+ raise HttpParserError , "#{TE_ERR_MSG}, multiple chunked: '#{te}'"
332
+ elsif !te_valid
333
+ raise HttpParserError501, "#{TE_ERR_MSG}, unknown value: '#{te}'"
275
334
  end
276
- elsif CHUNKED.casecmp(te) == 0
277
- return setup_chunked_body(body)
335
+ elsif te_lwr == CHUNKED
336
+ @env.delete TRANSFER_ENCODING2
337
+ return setup_chunked_body body
338
+ elsif ALLOWED_TRANSFER_ENCODING.include? te_lwr
339
+ raise HttpParserError , "#{TE_ERR_MSG}, single value must be chunked: '#{te}'"
340
+ else
341
+ raise HttpParserError501 , "#{TE_ERR_MSG}, unknown value: '#{te}'"
278
342
  end
279
343
  end
280
344
 
@@ -282,7 +346,12 @@ module Puma
282
346
 
283
347
  cl = @env[CONTENT_LENGTH]
284
348
 
285
- unless cl
349
+ if cl
350
+ # cannot contain characters that are not \d
351
+ if cl =~ CONTENT_LENGTH_VALUE_INVALID
352
+ raise HttpParserError, "Invalid Content-Length: #{cl.inspect}"
353
+ end
354
+ else
286
355
  @buffer = body.empty? ? nil : body
287
356
  @body = EmptyBody
288
357
  set_ready
@@ -300,6 +369,7 @@ module Puma
300
369
 
301
370
  if remain > MAX_BODY
302
371
  @body = Tempfile.new(Const::PUMA_TMP_BASE)
372
+ @body.unlink
303
373
  @body.binmode
304
374
  @tempfile = @body
305
375
  else
@@ -312,7 +382,7 @@ module Puma
312
382
 
313
383
  @body_remain = remain
314
384
 
315
- return false
385
+ false
316
386
  end
317
387
 
318
388
  def read_body
@@ -379,7 +449,7 @@ module Puma
379
449
  end
380
450
 
381
451
  if decode_chunk(chunk)
382
- @env[CONTENT_LENGTH] = @chunked_content_length
452
+ @env[CONTENT_LENGTH] = @chunked_content_length.to_s
383
453
  return true
384
454
  end
385
455
  end
@@ -391,12 +461,13 @@ module Puma
391
461
  @prev_chunk = ""
392
462
 
393
463
  @body = Tempfile.new(Const::PUMA_TMP_BASE)
464
+ @body.unlink
394
465
  @body.binmode
395
466
  @tempfile = @body
396
467
  @chunked_content_length = 0
397
468
 
398
469
  if decode_chunk(body)
399
- @env[CONTENT_LENGTH] = @chunked_content_length
470
+ @env[CONTENT_LENGTH] = @chunked_content_length.to_s
400
471
  return true
401
472
  end
402
473
  end
@@ -439,7 +510,13 @@ module Puma
439
510
  while !io.eof?
440
511
  line = io.gets
441
512
  if line.end_with?("\r\n")
442
- len = line.strip.to_i(16)
513
+ # Puma doesn't process chunk extensions, but should parse if they're
514
+ # present, which is the reason for the semicolon regex
515
+ chunk_hex = line.strip[/\A[^;]+/]
516
+ if chunk_hex =~ CHUNK_SIZE_INVALID
517
+ raise HttpParserError, "Invalid chunk size: '#{chunk_hex}'"
518
+ end
519
+ len = chunk_hex.to_i(16)
443
520
  if len == 0
444
521
  @in_last_chunk = true
445
522
  @body.rewind
@@ -470,7 +547,12 @@ module Puma
470
547
 
471
548
  case
472
549
  when got == len
473
- write_chunk(part[0..-3]) # to skip the ending \r\n
550
+ # proper chunked segment must end with "\r\n"
551
+ if part.end_with? CHUNK_VALID_ENDING
552
+ write_chunk(part[0..-3]) # to skip the ending \r\n
553
+ else
554
+ raise HttpParserError, "Chunk size mismatch"
555
+ end
474
556
  when got <= len - 2
475
557
  write_chunk(part)
476
558
  @partial_part_left = len - part.size
@@ -34,8 +34,8 @@ module Puma
34
34
  Signal.trap "SIGCHLD", "DEFAULT"
35
35
 
36
36
  Thread.new do
37
- Puma.set_thread_name "worker check pipe"
38
- IO.select [@check_pipe]
37
+ Puma.set_thread_name "wrkr check"
38
+ @check_pipe.wait_readable
39
39
  log "! Detected parent died, dying"
40
40
  exit! 1
41
41
  end
@@ -54,7 +54,14 @@ module Puma
54
54
  # things in shape before booting the app.
55
55
  @launcher.config.run_hooks :before_worker_boot, index, @launcher.events
56
56
 
57
+ begin
57
58
  server = @server ||= start_server
59
+ rescue Exception => e
60
+ log "! Unable to start worker"
61
+ log e.backtrace[0]
62
+ exit 1
63
+ end
64
+
58
65
  restart_server = Queue.new << true << false
59
66
 
60
67
  fork_worker = @options[:fork_worker] && index == 0
@@ -69,7 +76,7 @@ module Puma
69
76
  end
70
77
 
71
78
  Thread.new do
72
- Puma.set_thread_name "worker fork pipe"
79
+ Puma.set_thread_name "wrkr fork"
73
80
  while (idx = @fork_pipe.gets)
74
81
  idx = idx.to_i
75
82
  if idx == -1 # stop server
@@ -99,7 +106,7 @@ module Puma
99
106
  begin
100
107
  @worker_write << "b#{Process.pid}:#{index}\n"
101
108
  rescue SystemCallError, IOError
102
- Thread.current.purge_interrupt_queue if Thread.current.respond_to? :purge_interrupt_queue
109
+ Puma::Util.purge_interrupt_queue
103
110
  STDERR.puts "Master seems to have exited, exiting."
104
111
  return
105
112
  end
@@ -107,17 +114,23 @@ module Puma
107
114
  while restart_server.pop
108
115
  server_thread = server.run
109
116
  stat_thread ||= Thread.new(@worker_write) do |io|
110
- Puma.set_thread_name "stat payload"
117
+ Puma.set_thread_name "stat pld"
118
+ base_payload = "p#{Process.pid}"
111
119
 
112
120
  while true
113
121
  begin
114
- require 'json'
115
- io << "p#{Process.pid}#{server.stats.to_json}\n"
122
+ b = server.backlog || 0
123
+ r = server.running || 0
124
+ t = server.pool_capacity || 0
125
+ m = server.max_threads || 0
126
+ rc = server.requests_count || 0
127
+ payload = %Q!#{base_payload}{ "backlog":#{b}, "running":#{r}, "pool_capacity":#{t}, "max_threads": #{m}, "requests_count": #{rc} }\n!
128
+ io << payload
116
129
  rescue IOError
117
- Thread.current.purge_interrupt_queue if Thread.current.respond_to? :purge_interrupt_queue
130
+ Puma::Util.purge_interrupt_queue
118
131
  break
119
132
  end
120
- sleep Const::WORKER_CHECK_INTERVAL
133
+ sleep @options[:worker_check_interval]
121
134
  end
122
135
  end
123
136
  server_thread.join
@@ -155,16 +168,6 @@ module Puma
155
168
  @launcher.config.run_hooks :after_worker_fork, idx, @launcher.events
156
169
  pid
157
170
  end
158
-
159
- def wakeup!
160
- return unless @wakeup
161
-
162
- begin
163
- @wakeup.write "!" unless @wakeup.closed?
164
- rescue SystemCallError, IOError
165
- Thread.current.purge_interrupt_queue if Thread.current.respond_to? :purge_interrupt_queue
166
- end
167
- end
168
171
  end
169
172
  end
170
173
  end
@@ -31,19 +31,30 @@ module Puma
31
31
  @stage == :booted
32
32
  end
33
33
 
34
+ def uptime
35
+ Time.now - started_at
36
+ end
37
+
34
38
  def boot!
35
39
  @last_checkin = Time.now
36
40
  @stage = :booted
37
41
  end
38
42
 
43
+ def term!
44
+ @term = true
45
+ end
46
+
39
47
  def term?
40
48
  @term
41
49
  end
42
50
 
43
51
  def ping!(status)
44
52
  @last_checkin = Time.now
45
- require 'json'
46
- @last_status = JSON.parse(status, symbolize_names: true)
53
+ captures = status.match(/{ "backlog":(?<backlog>\d*), "running":(?<running>\d*), "pool_capacity":(?<pool_capacity>\d*), "max_threads": (?<max_threads>\d*), "requests_count": (?<requests_count>\d*) }/)
54
+ @last_status = captures.names.inject({}) do |hash, key|
55
+ hash[key.to_sym] = captures[key].to_i
56
+ hash
57
+ end
47
58
  end
48
59
 
49
60
  # @see Puma::Cluster#check_workers
data/lib/puma/cluster.rb CHANGED
@@ -43,6 +43,7 @@ module Puma
43
43
  end
44
44
 
45
45
  def start_phased_restart
46
+ @events.fire_on_restart!
46
47
  @phase += 1
47
48
  log "- Starting phased worker restart, phase: #{@phase}"
48
49
 
@@ -107,24 +108,42 @@ module Puma
107
108
  def cull_workers
108
109
  diff = @workers.size - @options[:workers]
109
110
  return if diff < 1
111
+ debug "Culling #{diff} workers"
110
112
 
111
- debug "Culling #{diff.inspect} workers"
113
+ workers = workers_to_cull(diff)
114
+ debug "Workers to cull: #{workers.inspect}"
112
115
 
113
- workers_to_cull = @workers[-diff,diff]
114
- debug "Workers to cull: #{workers_to_cull.inspect}"
115
-
116
- workers_to_cull.each do |worker|
117
- log "- Worker #{worker.index} (pid: #{worker.pid}) terminating"
116
+ workers.each do |worker|
117
+ log "- Worker #{worker.index} (PID: #{worker.pid}) terminating"
118
118
  worker.term
119
119
  end
120
120
  end
121
121
 
122
+ def workers_to_cull(diff)
123
+ workers = @workers.sort_by(&:started_at)
124
+
125
+ # In fork_worker mode, worker 0 acts as our master process.
126
+ # We should avoid culling it to preserve copy-on-write memory gains.
127
+ workers.reject! { |w| w.index == 0 } if @options[:fork_worker]
128
+
129
+ workers[cull_start_index(diff), diff]
130
+ end
131
+
132
+ def cull_start_index(diff)
133
+ case @options[:worker_culling_strategy]
134
+ when :oldest
135
+ 0
136
+ else # :youngest
137
+ -diff
138
+ end
139
+ end
140
+
122
141
  # @!attribute [r] next_worker_index
123
142
  def next_worker_index
124
- all_positions = 0...@options[:workers]
125
- occupied_positions = @workers.map { |w| w.index }
126
- available_positions = all_positions.to_a - occupied_positions
127
- available_positions.first
143
+ occupied_positions = @workers.map(&:index)
144
+ idx = 0
145
+ idx += 1 until !occupied_positions.include?(idx)
146
+ idx
128
147
  end
129
148
 
130
149
  def all_workers_booted?
@@ -134,7 +153,7 @@ module Puma
134
153
  def check_workers
135
154
  return if @next_check >= Time.now
136
155
 
137
- @next_check = Time.now + Const::WORKER_CHECK_INTERVAL
156
+ @next_check = Time.now + @options[:worker_check_interval]
138
157
 
139
158
  timeout_workers
140
159
  wait_workers
@@ -163,16 +182,6 @@ module Puma
163
182
  ].compact.min
164
183
  end
165
184
 
166
- def wakeup!
167
- return unless @wakeup
168
-
169
- begin
170
- @wakeup.write "!" unless @wakeup.closed?
171
- rescue SystemCallError, IOError
172
- Thread.current.purge_interrupt_queue if Thread.current.respond_to? :purge_interrupt_queue
173
- end
174
- end
175
-
176
185
  def worker(index, master)
177
186
  @workers = []
178
187
 
@@ -317,7 +326,7 @@ module Puma
317
326
 
318
327
  stop_workers
319
328
  stop
320
-
329
+ @events.fire_on_stopped!
321
330
  raise(SignalException, "SIGTERM") if @options[:raise_exception_on_sigterm]
322
331
  exit 0 # Clean exit, workers were stopped
323
332
  end
@@ -329,15 +338,25 @@ module Puma
329
338
 
330
339
  output_header "cluster"
331
340
 
332
- log "* Process workers: #{@options[:workers]}"
333
-
334
- before = Thread.list
341
+ # This is aligned with the output from Runner, see Runner#output_header
342
+ log "* Workers: #{@options[:workers]}"
335
343
 
336
344
  if preload?
345
+ # Threads explicitly marked as fork safe will be ignored. Used in Rails,
346
+ # but may be used by anyone. Note that we need to explicit
347
+ # Process::Waiter check here because there's a bug in Ruby 2.6 and below
348
+ # where calling thread_variable_get on a Process::Waiter will segfault.
349
+ # We can drop that clause once those versions of Ruby are no longer
350
+ # supported.
351
+ fork_safe = ->(t) { !t.is_a?(Process::Waiter) && t.thread_variable_get(:fork_safe) }
352
+
353
+ before = Thread.list.reject(&fork_safe)
354
+
355
+ log "* Restarts: (\u2714) hot (\u2716) phased"
337
356
  log "* Preloading application"
338
357
  load_and_bind
339
358
 
340
- after = Thread.list
359
+ after = Thread.list.reject(&fork_safe)
341
360
 
342
361
  if after.size > before.size
343
362
  threads = (after - before)
@@ -351,7 +370,7 @@ module Puma
351
370
  end
352
371
  end
353
372
  else
354
- log "* Phased restart available"
373
+ log "* Restarts: (\u2714) hot (\u2714) phased"
355
374
 
356
375
  unless @launcher.config.app_configured?
357
376
  error "No application configured, nothing to run"
@@ -378,6 +397,8 @@ module Puma
378
397
 
379
398
  log "Use Ctrl-C to stop"
380
399
 
400
+ single_worker_warning
401
+
381
402
  redirect_io
382
403
 
383
404
  Plugins.fire_background
@@ -399,19 +420,21 @@ module Puma
399
420
 
400
421
  begin
401
422
  booted = false
423
+ in_phased_restart = false
424
+ workers_not_booted = @options[:workers]
402
425
 
403
426
  while @status == :run
404
427
  begin
405
428
  if @phased_restart
406
429
  start_phased_restart
407
430
  @phased_restart = false
431
+ in_phased_restart = true
432
+ workers_not_booted = @options[:workers]
408
433
  end
409
434
 
410
435
  check_workers
411
436
 
412
- res = IO.select([read], nil, nil, [0, @next_check - Time.now].max)
413
-
414
- if res
437
+ if read.wait_readable([0, @next_check - Time.now].max)
415
438
  req = read.read_nonblock(1)
416
439
 
417
440
  @next_check = Time.now if req == "!"
@@ -430,11 +453,12 @@ module Puma
430
453
  case req
431
454
  when "b"
432
455
  w.boot!
433
- log "- Worker #{w.index} (pid: #{pid}) booted, phase: #{w.phase}"
456
+ log "- Worker #{w.index} (PID: #{pid}) booted in #{w.uptime.round(2)}s, phase: #{w.phase}"
434
457
  @next_check = Time.now
458
+ workers_not_booted -= 1
435
459
  when "e"
436
460
  # external term, see worker method, Signal.trap "SIGTERM"
437
- w.instance_variable_set :@term, true
461
+ w.term!
438
462
  when "t"
439
463
  w.term unless w.term?
440
464
  when "p"
@@ -449,6 +473,10 @@ module Puma
449
473
  log "! Out-of-sync worker list, no #{pid} worker"
450
474
  end
451
475
  end
476
+ if in_phased_restart && workers_not_booted.zero?
477
+ @events.fire_on_booted!
478
+ in_phased_restart = false
479
+ end
452
480
 
453
481
  rescue Interrupt
454
482
  @status = :stop
@@ -466,6 +494,15 @@ module Puma
466
494
 
467
495
  private
468
496
 
497
+ def single_worker_warning
498
+ return if @options[:workers] != 1 || @options[:silence_single_worker_warning]
499
+
500
+ log "! WARNING: Detected running cluster mode with 1 worker."
501
+ log "! Running Puma in cluster mode with a single worker is often a misconfiguration."
502
+ log "! Consider running Puma in single-mode (workers = 0) in order to reduce memory overhead."
503
+ log "! Set the `silence_single_worker_warning` option to silence this warning message."
504
+ end
505
+
469
506
  # loops thru @workers, removing workers that exited, and calling
470
507
  # `#term` if needed
471
508
  def wait_workers
@@ -495,7 +532,12 @@ module Puma
495
532
  def timeout_workers
496
533
  @workers.each do |w|
497
534
  if !w.term? && w.ping_timeout <= Time.now
498
- log "! Terminating timed out worker: #{w.pid}"
535
+ details = if w.booted?
536
+ "(worker failed to check in within #{@options[:worker_timeout]} seconds)"
537
+ else
538
+ "(worker failed to boot within #{@options[:worker_boot_timeout]} seconds)"
539
+ end
540
+ log "! Terminating timed out worker #{details}: #{w.pid}"
499
541
  w.kill
500
542
  end
501
543
  end
File without changes