puma 6.0.0 → 6.6.0

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 (85) hide show
  1. checksums.yaml +4 -4
  2. data/History.md +392 -13
  3. data/LICENSE +0 -0
  4. data/README.md +135 -29
  5. data/bin/puma-wild +0 -0
  6. data/docs/architecture.md +0 -0
  7. data/docs/compile_options.md +0 -0
  8. data/docs/deployment.md +0 -0
  9. data/docs/fork_worker.md +11 -1
  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/java_options.md +54 -0
  14. data/docs/jungle/README.md +0 -0
  15. data/docs/jungle/rc.d/README.md +0 -0
  16. data/docs/jungle/rc.d/puma.conf +0 -0
  17. data/docs/kubernetes.md +12 -0
  18. data/docs/nginx.md +1 -1
  19. data/docs/plugins.md +4 -0
  20. data/docs/rails_dev_mode.md +0 -0
  21. data/docs/restart.md +1 -0
  22. data/docs/signals.md +2 -2
  23. data/docs/stats.md +8 -3
  24. data/docs/systemd.md +13 -7
  25. data/docs/testing_benchmarks_local_files.md +0 -0
  26. data/docs/testing_test_rackup_ci_files.md +0 -0
  27. data/ext/puma_http11/PumaHttp11Service.java +0 -0
  28. data/ext/puma_http11/ext_help.h +0 -0
  29. data/ext/puma_http11/extconf.rb +21 -14
  30. data/ext/puma_http11/http11_parser.c +0 -0
  31. data/ext/puma_http11/http11_parser.h +0 -0
  32. data/ext/puma_http11/http11_parser.java.rl +0 -0
  33. data/ext/puma_http11/http11_parser.rl +0 -0
  34. data/ext/puma_http11/http11_parser_common.rl +0 -0
  35. data/ext/puma_http11/mini_ssl.c +107 -10
  36. data/ext/puma_http11/no_ssl/PumaHttp11Service.java +0 -0
  37. data/ext/puma_http11/org/jruby/puma/Http11.java +30 -7
  38. data/ext/puma_http11/org/jruby/puma/Http11Parser.java +0 -0
  39. data/ext/puma_http11/org/jruby/puma/MiniSSL.java +2 -1
  40. data/ext/puma_http11/puma_http11.c +4 -1
  41. data/lib/puma/app/status.rb +1 -1
  42. data/lib/puma/binder.rb +26 -15
  43. data/lib/puma/cli.rb +13 -5
  44. data/lib/puma/client.rb +113 -26
  45. data/lib/puma/cluster/worker.rb +14 -6
  46. data/lib/puma/cluster/worker_handle.rb +4 -5
  47. data/lib/puma/cluster.rb +93 -22
  48. data/lib/puma/commonlogger.rb +21 -14
  49. data/lib/puma/configuration.rb +42 -22
  50. data/lib/puma/const.rb +149 -89
  51. data/lib/puma/control_cli.rb +16 -9
  52. data/lib/puma/detect.rb +5 -4
  53. data/lib/puma/dsl.rb +432 -40
  54. data/lib/puma/error_logger.rb +6 -5
  55. data/lib/puma/events.rb +0 -0
  56. data/lib/puma/io_buffer.rb +10 -0
  57. data/lib/puma/jruby_restart.rb +0 -16
  58. data/lib/puma/json_serialization.rb +0 -0
  59. data/lib/puma/launcher/bundle_pruner.rb +0 -0
  60. data/lib/puma/launcher.rb +29 -29
  61. data/lib/puma/log_writer.rb +23 -13
  62. data/lib/puma/minissl/context_builder.rb +4 -0
  63. data/lib/puma/minissl.rb +23 -0
  64. data/lib/puma/null_io.rb +42 -2
  65. data/lib/puma/plugin/systemd.rb +90 -0
  66. data/lib/puma/plugin/tmp_restart.rb +0 -0
  67. data/lib/puma/plugin.rb +0 -0
  68. data/lib/puma/rack/builder.rb +2 -2
  69. data/lib/puma/rack/urlmap.rb +1 -1
  70. data/lib/puma/rack_default.rb +18 -3
  71. data/lib/puma/reactor.rb +17 -8
  72. data/lib/puma/request.rb +207 -126
  73. data/lib/puma/runner.rb +26 -4
  74. data/lib/puma/sd_notify.rb +146 -0
  75. data/lib/puma/server.rb +121 -49
  76. data/lib/puma/single.rb +3 -1
  77. data/lib/puma/state_file.rb +2 -2
  78. data/lib/puma/thread_pool.rb +56 -9
  79. data/lib/puma/util.rb +1 -1
  80. data/lib/puma.rb +1 -3
  81. data/lib/rack/handler/puma.rb +116 -86
  82. data/tools/Dockerfile +2 -2
  83. data/tools/trickletest.rb +0 -0
  84. metadata +12 -13
  85. data/lib/puma/systemd.rb +0 -47
data/lib/puma/client.rb CHANGED
@@ -9,8 +9,8 @@ class IO
9
9
  end
10
10
 
11
11
  require_relative 'detect'
12
+ require_relative 'io_buffer'
12
13
  require 'tempfile'
13
- require 'forwardable'
14
14
 
15
15
  if Puma::IS_JRUBY
16
16
  # We have to work around some OpenSSL buffer/io-readiness bugs
@@ -48,7 +48,16 @@ module Puma
48
48
 
49
49
  # chunked body validation
50
50
  CHUNK_SIZE_INVALID = /[^\h]/.freeze
51
- CHUNK_VALID_ENDING = "\r\n".freeze
51
+ CHUNK_VALID_ENDING = Const::LINE_END
52
+ CHUNK_VALID_ENDING_SIZE = CHUNK_VALID_ENDING.bytesize
53
+
54
+ # The maximum number of bytes we'll buffer looking for a valid
55
+ # chunk header.
56
+ MAX_CHUNK_HEADER_SIZE = 4096
57
+
58
+ # The maximum amount of excess data the client sends
59
+ # using chunk size extensions before we abort the connection.
60
+ MAX_CHUNK_EXCESS = 16 * 1024
52
61
 
53
62
  # Content-Length header value validation
54
63
  CONTENT_LENGTH_VALUE_INVALID = /[^\d]/.freeze
@@ -60,13 +69,13 @@ module Puma
60
69
  EmptyBody = NullIO.new
61
70
 
62
71
  include Puma::Const
63
- extend Forwardable
64
72
 
65
73
  def initialize(io, env=nil)
66
74
  @io = io
67
75
  @to_io = io.to_io
76
+ @io_buffer = IOBuffer.new
68
77
  @proto_env = env
69
- @env = env ? env.dup : nil
78
+ @env = env&.dup
70
79
 
71
80
  @parser = HttpParser.new
72
81
  @parsed_bytes = 0
@@ -84,6 +93,9 @@ module Puma
84
93
  @requests_served = 0
85
94
  @hijacked = false
86
95
 
96
+ @http_content_length_limit = nil
97
+ @http_content_length_limit_exceeded = false
98
+
87
99
  @peerip = nil
88
100
  @peer_family = nil
89
101
  @listener = nil
@@ -93,16 +105,22 @@ module Puma
93
105
  @body_remain = 0
94
106
 
95
107
  @in_last_chunk = false
108
+
109
+ # need unfrozen ASCII-8BIT, +'' is UTF-8
110
+ @read_buffer = String.new # rubocop: disable Performance/UnfreezeString
96
111
  end
97
112
 
98
113
  attr_reader :env, :to_io, :body, :io, :timeout_at, :ready, :hijacked,
99
- :tempfile
114
+ :tempfile, :io_buffer, :http_content_length_limit_exceeded
100
115
 
101
- attr_writer :peerip
116
+ attr_writer :peerip, :http_content_length_limit
102
117
 
103
118
  attr_accessor :remote_addr_header, :listener
104
119
 
105
- def_delegators :@io, :closed?
120
+ # Remove in Puma 7?
121
+ def closed?
122
+ @to_io.closed?
123
+ end
106
124
 
107
125
  # Test to see if io meets a bare minimum of functioning, @to_io needs to be
108
126
  # used for MiniSSL::Socket
@@ -138,16 +156,16 @@ module Puma
138
156
 
139
157
  def reset(fast_check=true)
140
158
  @parser.reset
159
+ @io_buffer.reset
141
160
  @read_header = true
142
161
  @read_proxy = !!@expect_proxy_proto
143
162
  @env = @proto_env.dup
144
- @body = nil
145
- @tempfile = nil
146
163
  @parsed_bytes = 0
147
164
  @ready = false
148
165
  @body_remain = 0
149
166
  @peerip = nil if @remote_addr_header
150
167
  @in_last_chunk = false
168
+ @http_content_length_limit_exceeded = false
151
169
 
152
170
  if @buffer
153
171
  return false unless try_to_parse_proxy_protocol
@@ -170,11 +188,11 @@ module Puma
170
188
  rescue IOError
171
189
  # swallow it
172
190
  end
173
-
174
191
  end
175
192
  end
176
193
 
177
194
  def close
195
+ tempfile_close
178
196
  begin
179
197
  @io.close
180
198
  rescue IOError, Errno::EBADF
@@ -182,6 +200,15 @@ module Puma
182
200
  end
183
201
  end
184
202
 
203
+ def tempfile_close
204
+ tf_path = @tempfile&.path
205
+ @tempfile&.close
206
+ File.unlink(tf_path) if tf_path
207
+ @tempfile = nil
208
+ @body = nil
209
+ rescue Errno::ENOENT, IOError
210
+ end
211
+
185
212
  # If necessary, read the PROXY protocol from the buffer. Returns
186
213
  # false if more data is needed.
187
214
  def try_to_parse_proxy_protocol
@@ -207,8 +234,20 @@ module Puma
207
234
  end
208
235
 
209
236
  def try_to_finish
237
+ if env[CONTENT_LENGTH] && above_http_content_limit(env[CONTENT_LENGTH].to_i)
238
+ @http_content_length_limit_exceeded = true
239
+ end
240
+
241
+ if @http_content_length_limit_exceeded
242
+ @buffer = nil
243
+ @body = EmptyBody
244
+ set_ready
245
+ return true
246
+ end
247
+
210
248
  return read_body if in_data_phase
211
249
 
250
+ data = nil
212
251
  begin
213
252
  data = @io.read_nonblock(CHUNK_SIZE)
214
253
  rescue IO::WaitReadable
@@ -236,6 +275,10 @@ module Puma
236
275
 
237
276
  @parsed_bytes = @parser.execute(@env, @buffer, @parsed_bytes)
238
277
 
278
+ if @parser.finished? && above_http_content_limit(@parser.body.bytesize)
279
+ @http_content_length_limit_exceeded = true
280
+ end
281
+
239
282
  if @parser.finished?
240
283
  return setup_body
241
284
  elsif @parsed_bytes >= MAX_HEADER
@@ -357,8 +400,8 @@ module Puma
357
400
  cl = @env[CONTENT_LENGTH]
358
401
 
359
402
  if cl
360
- # cannot contain characters that are not \d
361
- if CONTENT_LENGTH_VALUE_INVALID.match? cl
403
+ # cannot contain characters that are not \d, or be empty
404
+ if CONTENT_LENGTH_VALUE_INVALID.match?(cl) || cl.empty?
362
405
  raise HttpParserError, "Invalid Content-Length: #{cl.inspect}"
363
406
  end
364
407
  else
@@ -368,18 +411,33 @@ module Puma
368
411
  return true
369
412
  end
370
413
 
371
- remain = cl.to_i - body.bytesize
414
+ content_length = cl.to_i
415
+
416
+ remain = content_length - body.bytesize
372
417
 
373
418
  if remain <= 0
374
- @body = StringIO.new(body)
375
- @buffer = nil
419
+ # Part of the body is a pipelined request OR garbage. We'll deal with that later.
420
+ if content_length == 0
421
+ @body = EmptyBody
422
+ if body.empty?
423
+ @buffer = nil
424
+ else
425
+ @buffer = body
426
+ end
427
+ elsif remain == 0
428
+ @body = StringIO.new body
429
+ @buffer = nil
430
+ else
431
+ @body = StringIO.new(body[0,content_length])
432
+ @buffer = body[content_length..-1]
433
+ end
376
434
  set_ready
377
435
  return true
378
436
  end
379
437
 
380
438
  if remain > MAX_BODY
381
- @body = Tempfile.new(Const::PUMA_TMP_BASE)
382
- @body.unlink
439
+ @body = Tempfile.create(Const::PUMA_TMP_BASE)
440
+ File.unlink @body.path unless IS_WINDOWS
383
441
  @body.binmode
384
442
  @tempfile = @body
385
443
  else
@@ -411,7 +469,7 @@ module Puma
411
469
  end
412
470
 
413
471
  begin
414
- chunk = @io.read_nonblock(want)
472
+ chunk = @io.read_nonblock(want, @read_buffer)
415
473
  rescue IO::WaitReadable
416
474
  return false
417
475
  rescue SystemCallError, IOError
@@ -443,7 +501,7 @@ module Puma
443
501
  def read_chunked_body
444
502
  while true
445
503
  begin
446
- chunk = @io.read_nonblock(4096)
504
+ chunk = @io.read_nonblock(CHUNK_SIZE, @read_buffer)
447
505
  rescue IO::WaitReadable
448
506
  return false
449
507
  rescue SystemCallError, IOError
@@ -469,9 +527,10 @@ module Puma
469
527
  @chunked_body = true
470
528
  @partial_part_left = 0
471
529
  @prev_chunk = ""
530
+ @excess_cr = 0
472
531
 
473
- @body = Tempfile.new(Const::PUMA_TMP_BASE)
474
- @body.unlink
532
+ @body = Tempfile.create(Const::PUMA_TMP_BASE)
533
+ File.unlink @body.path unless IS_WINDOWS
475
534
  @body.binmode
476
535
  @tempfile = @body
477
536
  @chunked_content_length = 0
@@ -519,7 +578,7 @@ module Puma
519
578
 
520
579
  while !io.eof?
521
580
  line = io.gets
522
- if line.end_with?("\r\n")
581
+ if line.end_with?(CHUNK_VALID_ENDING)
523
582
  # Puma doesn't process chunk extensions, but should parse if they're
524
583
  # present, which is the reason for the semicolon regex
525
584
  chunk_hex = line.strip[/\A[^;]+/]
@@ -531,19 +590,39 @@ module Puma
531
590
  @in_last_chunk = true
532
591
  @body.rewind
533
592
  rest = io.read
534
- last_crlf_size = "\r\n".bytesize
535
- if rest.bytesize < last_crlf_size
593
+ if rest.bytesize < CHUNK_VALID_ENDING_SIZE
536
594
  @buffer = nil
537
- @partial_part_left = last_crlf_size - rest.bytesize
595
+ @partial_part_left = CHUNK_VALID_ENDING_SIZE - rest.bytesize
538
596
  return false
539
597
  else
540
- @buffer = rest[last_crlf_size..-1]
598
+ # if the next character is a CRLF, set buffer to everything after that CRLF
599
+ start_of_rest = if rest.start_with?(CHUNK_VALID_ENDING)
600
+ CHUNK_VALID_ENDING_SIZE
601
+ else # we have started a trailer section, which we do not support. skip it!
602
+ rest.index(CHUNK_VALID_ENDING*2) + CHUNK_VALID_ENDING_SIZE*2
603
+ end
604
+
605
+ @buffer = rest[start_of_rest..-1]
541
606
  @buffer = nil if @buffer.empty?
542
607
  set_ready
543
608
  return true
544
609
  end
545
610
  end
546
611
 
612
+ # Track the excess as a function of the size of the
613
+ # header vs the size of the actual data. Excess can
614
+ # go negative (and is expected to) when the body is
615
+ # significant.
616
+ # The additional of chunk_hex.size and 2 compensates
617
+ # for a client sending 1 byte in a chunked body over
618
+ # a long period of time, making sure that that client
619
+ # isn't accidentally eventually punished.
620
+ @excess_cr += (line.size - len - chunk_hex.size - 2)
621
+
622
+ if @excess_cr >= MAX_CHUNK_EXCESS
623
+ raise HttpParserError, "Maximum chunk excess detected"
624
+ end
625
+
547
626
  len += 2
548
627
 
549
628
  part = io.read(len)
@@ -571,6 +650,10 @@ module Puma
571
650
  @partial_part_left = len - part.size
572
651
  end
573
652
  else
653
+ if @prev_chunk.size + line.size >= MAX_CHUNK_HEADER_SIZE
654
+ raise HttpParserError, "maximum size of chunk header exceeded"
655
+ end
656
+
574
657
  @prev_chunk = line
575
658
  return false
576
659
  end
@@ -591,5 +674,9 @@ module Puma
591
674
  @requests_served += 1
592
675
  @ready = true
593
676
  end
677
+
678
+ def above_http_content_limit(value)
679
+ @http_content_length_limit&.< value
680
+ end
594
681
  end
595
682
  end
@@ -88,25 +88,27 @@ module Puma
88
88
  server.begin_restart(true)
89
89
  @config.run_hooks(:before_refork, nil, @log_writer, @hook_data)
90
90
  end
91
+ elsif idx == -2 # refork cycle is done
92
+ @config.run_hooks(:after_refork, nil, @log_writer, @hook_data)
91
93
  elsif idx == 0 # restart server
92
94
  restart_server << true << false
93
95
  else # fork worker
94
96
  worker_pids << pid = spawn_worker(idx)
95
- @worker_write << "f#{pid}:#{idx}\n" rescue nil
97
+ @worker_write << "#{PIPE_FORK}#{pid}:#{idx}\n" rescue nil
96
98
  end
97
99
  end
98
100
  end
99
101
  end
100
102
 
101
103
  Signal.trap "SIGTERM" do
102
- @worker_write << "e#{Process.pid}\n" rescue nil
104
+ @worker_write << "#{PIPE_EXTERNAL_TERM}#{Process.pid}\n" rescue nil
103
105
  restart_server.clear
104
106
  server.stop
105
107
  restart_server << false
106
108
  end
107
109
 
108
110
  begin
109
- @worker_write << "b#{Process.pid}:#{index}\n"
111
+ @worker_write << "#{PIPE_BOOT}#{Process.pid}:#{index}\n"
110
112
  rescue SystemCallError, IOError
111
113
  Puma::Util.purge_interrupt_queue
112
114
  STDERR.puts "Master seems to have exited, exiting."
@@ -115,9 +117,14 @@ module Puma
115
117
 
116
118
  while restart_server.pop
117
119
  server_thread = server.run
120
+
121
+ if @log_writer.debug? && index == 0
122
+ debug_loaded_extensions "Loaded Extensions - worker 0:"
123
+ end
124
+
118
125
  stat_thread ||= Thread.new(@worker_write) do |io|
119
126
  Puma.set_thread_name "stat pld"
120
- base_payload = "p#{Process.pid}"
127
+ base_payload = "#{PIPE_PING}#{Process.pid}"
121
128
 
122
129
  while true
123
130
  begin
@@ -126,7 +133,8 @@ module Puma
126
133
  t = server.pool_capacity || 0
127
134
  m = server.max_threads || 0
128
135
  rc = server.requests_count || 0
129
- payload = %Q!#{base_payload}{ "backlog":#{b}, "running":#{r}, "pool_capacity":#{t}, "max_threads": #{m}, "requests_count": #{rc} }\n!
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!
130
138
  io << payload
131
139
  rescue IOError
132
140
  Puma::Util.purge_interrupt_queue
@@ -142,7 +150,7 @@ module Puma
142
150
  # exiting until any background operations are completed
143
151
  @config.run_hooks(:before_worker_shutdown, index, @log_writer, @hook_data)
144
152
  ensure
145
- @worker_write << "t#{Process.pid}\n" rescue nil
153
+ @worker_write << "#{PIPE_TERM}#{Process.pid}\n" rescue nil
146
154
  @worker_write.close
147
155
  end
148
156
 
@@ -51,13 +51,12 @@ module Puma
51
51
  @term
52
52
  end
53
53
 
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
+
54
57
  def ping!(status)
55
58
  @last_checkin = Time.now
56
- captures = status.match(/{ "backlog":(?<backlog>\d*), "running":(?<running>\d*), "pool_capacity":(?<pool_capacity>\d*), "max_threads": (?<max_threads>\d*), "requests_count": (?<requests_count>\d*) }/)
57
- @last_status = captures.names.inject({}) do |hash, key|
58
- hash[key.to_sym] = captures[key].to_i
59
- hash
60
- end
59
+ @last_status = status.match(STATUS_PATTERN).named_captures.map { |c_name, c| [c_name.to_sym, c.to_i] }.to_h
61
60
  end
62
61
 
63
62
  # @see Puma::Cluster#check_workers
data/lib/puma/cluster.rb CHANGED
@@ -6,8 +6,6 @@ require_relative 'plugin'
6
6
  require_relative 'cluster/worker_handle'
7
7
  require_relative 'cluster/worker'
8
8
 
9
- require 'time'
10
-
11
9
  module Puma
12
10
  # This class is instantiated by the `Puma::Launcher` and used
13
11
  # to boot and serve a Ruby application when puma "workers" are needed
@@ -87,10 +85,12 @@ module Puma
87
85
  @workers << WorkerHandle.new(idx, pid, @phase, @options)
88
86
  end
89
87
 
90
- if @options[:fork_worker] &&
91
- @workers.all? {|x| x.phase == @phase}
92
-
88
+ if @options[:fork_worker] && all_workers_in_phase?
93
89
  @fork_writer << "0\n"
90
+
91
+ if worker_at(0).phase > 0
92
+ @fork_writer << "-2\n"
93
+ end
94
94
  end
95
95
  end
96
96
 
@@ -150,10 +150,22 @@ module Puma
150
150
  idx
151
151
  end
152
152
 
153
+ def worker_at(idx)
154
+ @workers.find { |w| w.index == idx }
155
+ end
156
+
153
157
  def all_workers_booted?
154
158
  @workers.count { |w| !w.booted? } == 0
155
159
  end
156
160
 
161
+ def all_workers_in_phase?
162
+ @workers.all? { |w| w.phase == @phase }
163
+ end
164
+
165
+ def all_workers_idle_timed_out?
166
+ (@workers.map(&:pid) - idle_timed_out_worker_pids).empty?
167
+ end
168
+
157
169
  def check_workers
158
170
  return if @next_check >= Time.now
159
171
 
@@ -252,18 +264,18 @@ module Puma
252
264
  old_worker_count = @workers.count { |w| w.phase != @phase }
253
265
  worker_status = @workers.map do |w|
254
266
  {
255
- started_at: w.started_at.utc.iso8601,
267
+ started_at: utc_iso8601(w.started_at),
256
268
  pid: w.pid,
257
269
  index: w.index,
258
270
  phase: w.phase,
259
271
  booted: w.booted?,
260
- last_checkin: w.last_checkin.utc.iso8601,
272
+ last_checkin: utc_iso8601(w.last_checkin),
261
273
  last_status: w.last_status,
262
274
  }
263
275
  end
264
276
 
265
277
  {
266
- started_at: @started_at.utc.iso8601,
278
+ started_at: utc_iso8601(@started_at),
267
279
  workers: @workers.size,
268
280
  phase: @phase,
269
281
  booted_workers: worker_status.count { |w| w[:booted] },
@@ -278,7 +290,7 @@ module Puma
278
290
 
279
291
  # @version 5.0.0
280
292
  def fork_worker!
281
- if (worker = @workers.find { |w| w.index == 0 })
293
+ if (worker = worker_at 0)
282
294
  worker.phase += 1
283
295
  end
284
296
  phased_restart(true)
@@ -413,6 +425,8 @@ module Puma
413
425
 
414
426
  @master_read, @worker_write = read, @wakeup
415
427
 
428
+ @options[:worker_write] = @worker_write
429
+
416
430
  @config.run_hooks(:before_fork, nil, @log_writer)
417
431
 
418
432
  spawn_workers
@@ -428,6 +442,11 @@ module Puma
428
442
 
429
443
  while @status == :run
430
444
  begin
445
+ if @options[:idle_timeout] && all_workers_idle_timed_out?
446
+ log "- All workers reached idle timeout"
447
+ break
448
+ end
449
+
431
450
  if @phased_restart
432
451
  start_phased_restart
433
452
  @phased_restart = false
@@ -439,48 +458,66 @@ module Puma
439
458
 
440
459
  if read.wait_readable([0, @next_check - Time.now].max)
441
460
  req = read.read_nonblock(1)
461
+ next unless req
442
462
 
443
- @next_check = Time.now if req == "!"
444
- next if !req || req == "!"
463
+ if req == PIPE_WAKEUP
464
+ @next_check = Time.now
465
+ next
466
+ end
445
467
 
446
468
  result = read.gets
447
469
  pid = result.to_i
448
470
 
449
- if req == "b" || req == "f"
471
+ if req == PIPE_BOOT || req == PIPE_FORK
450
472
  pid, idx = result.split(':').map(&:to_i)
451
- w = @workers.find {|x| x.index == idx}
473
+ w = worker_at idx
452
474
  w.pid = pid if w.pid.nil?
453
475
  end
454
476
 
455
477
  if w = @workers.find { |x| x.pid == pid }
456
478
  case req
457
- when "b"
479
+ when PIPE_BOOT
458
480
  w.boot!
459
481
  log "- Worker #{w.index} (PID: #{pid}) booted in #{w.uptime.round(2)}s, phase: #{w.phase}"
460
482
  @next_check = Time.now
461
483
  workers_not_booted -= 1
462
- when "e"
484
+ when PIPE_EXTERNAL_TERM
463
485
  # external term, see worker method, Signal.trap "SIGTERM"
464
486
  w.term!
465
- when "t"
487
+ when PIPE_TERM
466
488
  w.term unless w.term?
467
- when "p"
468
- w.ping!(result.sub(/^\d+/,'').chomp)
489
+ when PIPE_PING
490
+ status = result.sub(/^\d+/,'').chomp
491
+ w.ping!(status)
469
492
  @events.fire(:ping!, w)
493
+
494
+ if in_phased_restart && @options[:fork_worker] && workers_not_booted.positive? && w0 = worker_at(0)
495
+ w0.ping!(status)
496
+ @events.fire(:ping!, w0)
497
+ end
498
+
470
499
  if !booted && @workers.none? {|worker| worker.last_status.empty?}
471
500
  @events.fire_on_booted!
501
+ debug_loaded_extensions("Loaded Extensions - master:") if @log_writer.debug?
472
502
  booted = true
473
503
  end
504
+ when PIPE_IDLE
505
+ if idle_workers[pid]
506
+ idle_workers.delete pid
507
+ else
508
+ idle_workers[pid] = true
509
+ end
474
510
  end
475
511
  else
476
512
  log "! Out-of-sync worker list, no #{pid} worker"
477
513
  end
478
514
  end
515
+
479
516
  if in_phased_restart && workers_not_booted.zero?
480
517
  @events.fire_on_booted!
518
+ debug_loaded_extensions("Loaded Extensions - master:") if @log_writer.debug?
481
519
  in_phased_restart = false
482
520
  end
483
-
484
521
  rescue Interrupt
485
522
  @status = :stop
486
523
  end
@@ -509,10 +546,31 @@ module Puma
509
546
  # loops thru @workers, removing workers that exited, and calling
510
547
  # `#term` if needed
511
548
  def wait_workers
549
+ # Reap all children, known workers or otherwise.
550
+ # If puma has PID 1, as it's common in containerized environments,
551
+ # then it's responsible for reaping orphaned processes, so we must reap
552
+ # all our dead children, regardless of whether they are workers we spawned
553
+ # or some reattached processes.
554
+ reaped_children = {}
555
+ loop do
556
+ begin
557
+ pid, status = Process.wait2(-1, Process::WNOHANG)
558
+ break unless pid
559
+ reaped_children[pid] = status
560
+ rescue Errno::ECHILD
561
+ break
562
+ end
563
+ end
564
+
512
565
  @workers.reject! do |w|
513
566
  next false if w.pid.nil?
514
567
  begin
515
- if Process.wait(w.pid, Process::WNOHANG)
568
+ # We may need to check the PID individually because:
569
+ # 1. From Ruby versions 2.6 to 3.2, `Process.detach` can prevent or delay
570
+ # `Process.wait2(-1)` from detecting a terminated process: https://bugs.ruby-lang.org/issues/19837.
571
+ # 2. When `fork_worker` is enabled, some worker may not be direct children,
572
+ # 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)
516
574
  true
517
575
  else
518
576
  w.term if w.term?
@@ -529,6 +587,11 @@ module Puma
529
587
  end
530
588
  end
531
589
  end
590
+
591
+ # Log unknown children
592
+ reaped_children.each do |pid, status|
593
+ log "! reaped unknown child process pid=#{pid} status=#{status}"
594
+ end
532
595
  end
533
596
 
534
597
  # @version 5.0.0
@@ -536,14 +599,22 @@ module Puma
536
599
  @workers.each do |w|
537
600
  if !w.term? && w.ping_timeout <= Time.now
538
601
  details = if w.booted?
539
- "(worker failed to check in within #{@options[:worker_timeout]} seconds)"
602
+ "(Worker #{w.index} failed to check in within #{@options[:worker_timeout]} seconds)"
540
603
  else
541
- "(worker failed to boot within #{@options[:worker_boot_timeout]} seconds)"
604
+ "(Worker #{w.index} failed to boot within #{@options[:worker_boot_timeout]} seconds)"
542
605
  end
543
606
  log "! Terminating timed out worker #{details}: #{w.pid}"
544
607
  w.kill
545
608
  end
546
609
  end
547
610
  end
611
+
612
+ def idle_timed_out_worker_pids
613
+ idle_workers.keys
614
+ end
615
+
616
+ def idle_workers
617
+ @idle_workers ||= {}
618
+ end
548
619
  end
549
620
  end