puma 6.4.0 → 6.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. checksums.yaml +4 -4
  2. data/History.md +235 -18
  3. data/README.md +87 -26
  4. data/docs/fork_worker.md +11 -1
  5. data/docs/java_options.md +54 -0
  6. data/docs/kubernetes.md +1 -1
  7. data/docs/plugins.md +4 -0
  8. data/docs/restart.md +1 -0
  9. data/docs/signals.md +2 -2
  10. data/docs/stats.md +8 -3
  11. data/docs/systemd.md +13 -6
  12. data/ext/puma_http11/extconf.rb +21 -14
  13. data/ext/puma_http11/mini_ssl.c +16 -2
  14. data/ext/puma_http11/org/jruby/puma/Http11.java +31 -8
  15. data/ext/puma_http11/org/jruby/puma/MiniSSL.java +2 -1
  16. data/ext/puma_http11/puma_http11.c +4 -1
  17. data/lib/puma/app/status.rb +1 -1
  18. data/lib/puma/binder.rb +12 -4
  19. data/lib/puma/cli.rb +9 -5
  20. data/lib/puma/client.rb +104 -16
  21. data/lib/puma/cluster/worker.rb +9 -6
  22. data/lib/puma/cluster/worker_handle.rb +4 -5
  23. data/lib/puma/cluster.rb +112 -27
  24. data/lib/puma/configuration.rb +37 -21
  25. data/lib/puma/const.rb +22 -3
  26. data/lib/puma/control_cli.rb +4 -4
  27. data/lib/puma/detect.rb +3 -4
  28. data/lib/puma/dsl.rb +303 -58
  29. data/lib/puma/error_logger.rb +4 -4
  30. data/lib/puma/jruby_restart.rb +0 -16
  31. data/lib/puma/launcher.rb +21 -8
  32. data/lib/puma/log_writer.rb +9 -9
  33. data/lib/puma/minissl/context_builder.rb +3 -0
  34. data/lib/puma/minissl.rb +6 -0
  35. data/lib/puma/null_io.rb +42 -2
  36. data/lib/puma/request.rb +35 -14
  37. data/lib/puma/runner.rb +10 -3
  38. data/lib/puma/sd_notify.rb +1 -4
  39. data/lib/puma/server.rb +53 -21
  40. data/lib/puma/single.rb +1 -1
  41. data/lib/puma/state_file.rb +2 -2
  42. data/lib/puma/thread_pool.rb +14 -2
  43. data/lib/puma/util.rb +1 -1
  44. data/lib/rack/handler/puma.rb +8 -5
  45. data/tools/Dockerfile +5 -3
  46. metadata +10 -12
data/lib/puma/client.rb CHANGED
@@ -51,6 +51,14 @@ module Puma
51
51
  CHUNK_VALID_ENDING = Const::LINE_END
52
52
  CHUNK_VALID_ENDING_SIZE = CHUNK_VALID_ENDING.bytesize
53
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
61
+
54
62
  # Content-Length header value validation
55
63
  CONTENT_LENGTH_VALUE_INVALID = /[^\d]/.freeze
56
64
 
@@ -152,8 +160,6 @@ module Puma
152
160
  @read_header = true
153
161
  @read_proxy = !!@expect_proxy_proto
154
162
  @env = @proto_env.dup
155
- @body = nil
156
- @tempfile = nil
157
163
  @parsed_bytes = 0
158
164
  @ready = false
159
165
  @body_remain = 0
@@ -164,7 +170,7 @@ module Puma
164
170
  if @buffer
165
171
  return false unless try_to_parse_proxy_protocol
166
172
 
167
- @parsed_bytes = @parser.execute(@env, @buffer, @parsed_bytes)
173
+ @parsed_bytes = parser_execute
168
174
 
169
175
  if @parser.finished?
170
176
  return setup_body
@@ -182,11 +188,11 @@ module Puma
182
188
  rescue IOError
183
189
  # swallow it
184
190
  end
185
-
186
191
  end
187
192
  end
188
193
 
189
194
  def close
195
+ tempfile_close
190
196
  begin
191
197
  @io.close
192
198
  rescue IOError, Errno::EBADF
@@ -194,6 +200,15 @@ module Puma
194
200
  end
195
201
  end
196
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
+
197
212
  # If necessary, read the PROXY protocol from the buffer. Returns
198
213
  # false if more data is needed.
199
214
  def try_to_parse_proxy_protocol
@@ -232,6 +247,7 @@ module Puma
232
247
 
233
248
  return read_body if in_data_phase
234
249
 
250
+ data = nil
235
251
  begin
236
252
  data = @io.read_nonblock(CHUNK_SIZE)
237
253
  rescue IO::WaitReadable
@@ -257,20 +273,20 @@ module Puma
257
273
 
258
274
  return false unless try_to_parse_proxy_protocol
259
275
 
260
- @parsed_bytes = @parser.execute(@env, @buffer, @parsed_bytes)
276
+ @parsed_bytes = parser_execute
261
277
 
262
278
  if @parser.finished? && above_http_content_limit(@parser.body.bytesize)
263
279
  @http_content_length_limit_exceeded = true
264
280
  end
265
281
 
266
282
  if @parser.finished?
267
- return setup_body
283
+ setup_body
268
284
  elsif @parsed_bytes >= MAX_HEADER
269
285
  raise HttpParserError,
270
286
  "HEADER is longer than allowed, aborting client early."
287
+ else
288
+ false
271
289
  end
272
-
273
- false
274
290
  end
275
291
 
276
292
  def eagerly_finish
@@ -284,6 +300,44 @@ module Puma
284
300
  @to_io.wait_readable(timeout) || timeout! until try_to_finish
285
301
  end
286
302
 
303
+ # Wraps `@parser.execute` and adds meaningful error messages
304
+ # @return [Integer] bytes of buffer read by parser
305
+ #
306
+ def parser_execute
307
+ @parser.execute(@env, @buffer, @parsed_bytes)
308
+ rescue => e
309
+ @env[HTTP_CONNECTION] = 'close'
310
+ raise e unless HttpParserError === e && e.message.include?('non-SSL')
311
+
312
+ req, _ = @buffer.split "\r\n\r\n"
313
+ request_line, headers = req.split "\r\n", 2
314
+
315
+ # below checks for request issues and changes error message accordingly
316
+ if !@env.key? REQUEST_METHOD
317
+ if request_line.count(' ') != 2
318
+ # maybe this is an SSL connection ?
319
+ raise e
320
+ else
321
+ method = request_line[/\A[^ ]+/]
322
+ raise e, "Invalid HTTP format, parsing fails. Bad method #{method}"
323
+ end
324
+ elsif !@env.key? REQUEST_PATH
325
+ path = request_line[/\A[^ ]+ +([^ ?\r\n]+)/, 1]
326
+ raise e, "Invalid HTTP format, parsing fails. Bad path #{path}"
327
+ elsif request_line.match?(/\A[^ ]+ +[^ ?\r\n]+\?/) && !@env.key?(QUERY_STRING)
328
+ query = request_line[/\A[^ ]+ +[^? ]+\?([^ ]+)/, 1]
329
+ raise e, "Invalid HTTP format, parsing fails. Bad query #{query}"
330
+ elsif !@env.key? SERVER_PROTOCOL
331
+ # protocol is bad
332
+ text = request_line[/[^ ]*\z/]
333
+ raise HttpParserError, "Invalid HTTP format, parsing fails. Bad protocol #{text}"
334
+ elsif !headers.empty?
335
+ # headers are bad
336
+ hdrs = headers.split("\r\n").map { |h| h.gsub "\n", '\n'}.join "\n"
337
+ raise HttpParserError, "Invalid HTTP format, parsing fails. Bad headers\n#{hdrs}"
338
+ end
339
+ end
340
+
287
341
  def timeout!
288
342
  write_error(408) if in_data_phase
289
343
  raise ConnectionError
@@ -395,18 +449,33 @@ module Puma
395
449
  return true
396
450
  end
397
451
 
398
- remain = cl.to_i - body.bytesize
452
+ content_length = cl.to_i
453
+
454
+ remain = content_length - body.bytesize
399
455
 
400
456
  if remain <= 0
401
- @body = StringIO.new(body)
402
- @buffer = nil
457
+ # Part of the body is a pipelined request OR garbage. We'll deal with that later.
458
+ if content_length == 0
459
+ @body = EmptyBody
460
+ if body.empty?
461
+ @buffer = nil
462
+ else
463
+ @buffer = body
464
+ end
465
+ elsif remain == 0
466
+ @body = StringIO.new body
467
+ @buffer = nil
468
+ else
469
+ @body = StringIO.new(body[0,content_length])
470
+ @buffer = body[content_length..-1]
471
+ end
403
472
  set_ready
404
473
  return true
405
474
  end
406
475
 
407
476
  if remain > MAX_BODY
408
- @body = Tempfile.new(Const::PUMA_TMP_BASE)
409
- @body.unlink
477
+ @body = Tempfile.create(Const::PUMA_TMP_BASE)
478
+ File.unlink @body.path unless IS_WINDOWS
410
479
  @body.binmode
411
480
  @tempfile = @body
412
481
  else
@@ -470,7 +539,7 @@ module Puma
470
539
  def read_chunked_body
471
540
  while true
472
541
  begin
473
- chunk = @io.read_nonblock(4096, @read_buffer)
542
+ chunk = @io.read_nonblock(CHUNK_SIZE, @read_buffer)
474
543
  rescue IO::WaitReadable
475
544
  return false
476
545
  rescue SystemCallError, IOError
@@ -496,9 +565,10 @@ module Puma
496
565
  @chunked_body = true
497
566
  @partial_part_left = 0
498
567
  @prev_chunk = ""
568
+ @excess_cr = 0
499
569
 
500
- @body = Tempfile.new(Const::PUMA_TMP_BASE)
501
- @body.unlink
570
+ @body = Tempfile.create(Const::PUMA_TMP_BASE)
571
+ File.unlink @body.path unless IS_WINDOWS
502
572
  @body.binmode
503
573
  @tempfile = @body
504
574
  @chunked_content_length = 0
@@ -577,6 +647,20 @@ module Puma
577
647
  end
578
648
  end
579
649
 
650
+ # Track the excess as a function of the size of the
651
+ # header vs the size of the actual data. Excess can
652
+ # go negative (and is expected to) when the body is
653
+ # significant.
654
+ # The additional of chunk_hex.size and 2 compensates
655
+ # for a client sending 1 byte in a chunked body over
656
+ # a long period of time, making sure that that client
657
+ # isn't accidentally eventually punished.
658
+ @excess_cr += (line.size - len - chunk_hex.size - 2)
659
+
660
+ if @excess_cr >= MAX_CHUNK_EXCESS
661
+ raise HttpParserError, "Maximum chunk excess detected"
662
+ end
663
+
580
664
  len += 2
581
665
 
582
666
  part = io.read(len)
@@ -604,6 +688,10 @@ module Puma
604
688
  @partial_part_left = len - part.size
605
689
  end
606
690
  else
691
+ if @prev_chunk.size + line.size >= MAX_CHUNK_HEADER_SIZE
692
+ raise HttpParserError, "maximum size of chunk header exceeded"
693
+ end
694
+
607
695
  @prev_chunk = line
608
696
  return false
609
697
  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."
@@ -122,7 +124,7 @@ module Puma
122
124
 
123
125
  stat_thread ||= Thread.new(@worker_write) do |io|
124
126
  Puma.set_thread_name "stat pld"
125
- base_payload = "p#{Process.pid}"
127
+ base_payload = "#{PIPE_PING}#{Process.pid}"
126
128
 
127
129
  while true
128
130
  begin
@@ -131,7 +133,8 @@ module Puma
131
133
  t = server.pool_capacity || 0
132
134
  m = server.max_threads || 0
133
135
  rc = server.requests_count || 0
134
- 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!
135
138
  io << payload
136
139
  rescue IOError
137
140
  Puma::Util.purge_interrupt_queue
@@ -147,7 +150,7 @@ module Puma
147
150
  # exiting until any background operations are completed
148
151
  @config.run_hooks(:before_worker_shutdown, index, @log_writer, @hook_data)
149
152
  ensure
150
- @worker_write << "t#{Process.pid}\n" rescue nil
153
+ @worker_write << "#{PIPE_TERM}#{Process.pid}\n" rescue nil
151
154
  @worker_write.close
152
155
  end
153
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
@@ -44,10 +44,15 @@ module Puma
44
44
  end
45
45
  end
46
46
 
47
- def start_phased_restart
47
+ def start_phased_restart(refork = false)
48
48
  @events.fire_on_restart!
49
+
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.
@@ -85,10 +90,12 @@ module Puma
85
90
  @workers << WorkerHandle.new(idx, pid, @phase, @options)
86
91
  end
87
92
 
88
- if @options[:fork_worker] &&
89
- @workers.all? {|x| x.phase == @phase}
90
-
93
+ if @options[:fork_worker] && all_workers_in_phase?
91
94
  @fork_writer << "0\n"
95
+
96
+ if worker_at(0).phase > 0
97
+ @fork_writer << "-2\n"
98
+ end
92
99
  end
93
100
  end
94
101
 
@@ -148,11 +155,23 @@ module Puma
148
155
  idx
149
156
  end
150
157
 
158
+ def worker_at(idx)
159
+ @workers.find { |w| w.index == idx }
160
+ end
161
+
151
162
  def all_workers_booted?
152
163
  @workers.count { |w| !w.booted? } == 0
153
164
  end
154
165
 
155
- def check_workers
166
+ def all_workers_in_phase?
167
+ @workers.all? { |w| w.phase == @phase }
168
+ end
169
+
170
+ def all_workers_idle_timed_out?
171
+ (@workers.map(&:pid) - idle_timed_out_worker_pids).empty?
172
+ end
173
+
174
+ def check_workers(refork = false)
156
175
  return if @next_check >= Time.now
157
176
 
158
177
  @next_check = Time.now + @options[:worker_check_interval]
@@ -170,7 +189,12 @@ module Puma
170
189
  w = @workers.find { |x| x.phase != @phase }
171
190
 
172
191
  if w
173
- 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
+
174
198
  unless w.term?
175
199
  w.term
176
200
  log "- #{w.signal} sent to #{w.pid}..."
@@ -214,7 +238,7 @@ module Puma
214
238
  def phased_restart(refork = false)
215
239
  return false if @options[:preload_app] && !refork
216
240
 
217
- @phased_restart = true
241
+ @phased_restart = refork ? :refork : true
218
242
  wakeup!
219
243
 
220
244
  true
@@ -276,7 +300,7 @@ module Puma
276
300
 
277
301
  # @version 5.0.0
278
302
  def fork_worker!
279
- if (worker = @workers.find { |w| w.index == 0 })
303
+ if (worker = worker_at 0)
280
304
  worker.phase += 1
281
305
  end
282
306
  phased_restart(true)
@@ -354,7 +378,7 @@ module Puma
354
378
 
355
379
  before = Thread.list.reject(&fork_safe)
356
380
 
357
- log "* Restarts: (\u2714) hot (\u2716) phased"
381
+ log "* Restarts: (\u2714) hot (\u2716) phased (#{@options[:fork_worker] ? "\u2714" : "\u2716"}) refork"
358
382
  log "* Preloading application"
359
383
  load_and_bind
360
384
 
@@ -372,7 +396,7 @@ module Puma
372
396
  end
373
397
  end
374
398
  else
375
- log "* Restarts: (\u2714) hot (\u2714) phased"
399
+ log "* Restarts: (\u2714) hot (\u2714) phased (#{@options[:fork_worker] ? "\u2714" : "\u2716"}) refork"
376
400
 
377
401
  unless @config.app_configured?
378
402
  error "No application configured, nothing to run"
@@ -411,6 +435,8 @@ module Puma
411
435
 
412
436
  @master_read, @worker_write = read, @wakeup
413
437
 
438
+ @options[:worker_write] = @worker_write
439
+
414
440
  @config.run_hooks(:before_fork, nil, @log_writer)
415
441
 
416
442
  spawn_workers
@@ -426,61 +452,86 @@ module Puma
426
452
 
427
453
  while @status == :run
428
454
  begin
455
+ if @options[:idle_timeout] && all_workers_idle_timed_out?
456
+ log "- All workers reached idle timeout"
457
+ break
458
+ end
459
+
429
460
  if @phased_restart
430
- start_phased_restart
461
+ start_phased_restart(@phased_restart == :refork)
462
+
463
+ in_phased_restart = @phased_restart
431
464
  @phased_restart = false
432
- in_phased_restart = true
465
+
433
466
  workers_not_booted = @options[:workers]
467
+ # worker 0 is not restarted on refork
468
+ workers_not_booted -= 1 if in_phased_restart == :refork
434
469
  end
435
470
 
436
- check_workers
471
+ check_workers(in_phased_restart == :refork)
437
472
 
438
473
  if read.wait_readable([0, @next_check - Time.now].max)
439
474
  req = read.read_nonblock(1)
475
+ next unless req
440
476
 
441
- @next_check = Time.now if req == "!"
442
- next if !req || req == "!"
477
+ if req == PIPE_WAKEUP
478
+ @next_check = Time.now
479
+ next
480
+ end
443
481
 
444
482
  result = read.gets
445
483
  pid = result.to_i
446
484
 
447
- if req == "b" || req == "f"
485
+ if req == PIPE_BOOT || req == PIPE_FORK
448
486
  pid, idx = result.split(':').map(&:to_i)
449
- w = @workers.find {|x| x.index == idx}
487
+ w = worker_at idx
450
488
  w.pid = pid if w.pid.nil?
451
489
  end
452
490
 
453
491
  if w = @workers.find { |x| x.pid == pid }
454
492
  case req
455
- when "b"
493
+ when PIPE_BOOT
456
494
  w.boot!
457
495
  log "- Worker #{w.index} (PID: #{pid}) booted in #{w.uptime.round(2)}s, phase: #{w.phase}"
458
496
  @next_check = Time.now
459
497
  workers_not_booted -= 1
460
- when "e"
498
+ when PIPE_EXTERNAL_TERM
461
499
  # external term, see worker method, Signal.trap "SIGTERM"
462
500
  w.term!
463
- when "t"
501
+ when PIPE_TERM
464
502
  w.term unless w.term?
465
- when "p"
466
- w.ping!(result.sub(/^\d+/,'').chomp)
503
+ when PIPE_PING
504
+ status = result.sub(/^\d+/,'').chomp
505
+ w.ping!(status)
467
506
  @events.fire(:ping!, w)
507
+
508
+ if in_phased_restart && @options[:fork_worker] && workers_not_booted.positive? && w0 = worker_at(0)
509
+ w0.ping!(status)
510
+ @events.fire(:ping!, w0)
511
+ end
512
+
468
513
  if !booted && @workers.none? {|worker| worker.last_status.empty?}
469
514
  @events.fire_on_booted!
470
515
  debug_loaded_extensions("Loaded Extensions - master:") if @log_writer.debug?
471
516
  booted = true
472
517
  end
518
+ when PIPE_IDLE
519
+ if idle_workers[pid]
520
+ idle_workers.delete pid
521
+ else
522
+ idle_workers[pid] = true
523
+ end
473
524
  end
474
525
  else
475
526
  log "! Out-of-sync worker list, no #{pid} worker"
476
527
  end
477
528
  end
529
+
478
530
  if in_phased_restart && workers_not_booted.zero?
479
531
  @events.fire_on_booted!
480
532
  debug_loaded_extensions("Loaded Extensions - master:") if @log_writer.debug?
481
533
  in_phased_restart = false
482
534
  end
483
-
484
535
  rescue Interrupt
485
536
  @status = :stop
486
537
  end
@@ -509,10 +560,31 @@ module Puma
509
560
  # loops thru @workers, removing workers that exited, and calling
510
561
  # `#term` if needed
511
562
  def wait_workers
563
+ # Reap all children, known workers or otherwise.
564
+ # If puma has PID 1, as it's common in containerized environments,
565
+ # then it's responsible for reaping orphaned processes, so we must reap
566
+ # all our dead children, regardless of whether they are workers we spawned
567
+ # or some reattached processes.
568
+ reaped_children = {}
569
+ loop do
570
+ begin
571
+ pid, status = Process.wait2(-1, Process::WNOHANG)
572
+ break unless pid
573
+ reaped_children[pid] = status
574
+ rescue Errno::ECHILD
575
+ break
576
+ end
577
+ end
578
+
512
579
  @workers.reject! do |w|
513
580
  next false if w.pid.nil?
514
581
  begin
515
- if Process.wait(w.pid, Process::WNOHANG)
582
+ # We may need to check the PID individually because:
583
+ # 1. From Ruby versions 2.6 to 3.2, `Process.detach` can prevent or delay
584
+ # `Process.wait2(-1)` from detecting a terminated process: https://bugs.ruby-lang.org/issues/19837.
585
+ # 2. When `fork_worker` is enabled, some worker may not be direct children,
586
+ # but grand children. Because of this they won't be reaped by `Process.wait2(-1)`.
587
+ if reaped_children.delete(w.pid) || Process.wait(w.pid, Process::WNOHANG)
516
588
  true
517
589
  else
518
590
  w.term if w.term?
@@ -529,6 +601,11 @@ module Puma
529
601
  end
530
602
  end
531
603
  end
604
+
605
+ # Log unknown children
606
+ reaped_children.each do |pid, status|
607
+ log "! reaped unknown child process pid=#{pid} status=#{status}"
608
+ end
532
609
  end
533
610
 
534
611
  # @version 5.0.0
@@ -536,14 +613,22 @@ module Puma
536
613
  @workers.each do |w|
537
614
  if !w.term? && w.ping_timeout <= Time.now
538
615
  details = if w.booted?
539
- "(worker failed to check in within #{@options[:worker_timeout]} seconds)"
616
+ "(Worker #{w.index} failed to check in within #{@options[:worker_timeout]} seconds)"
540
617
  else
541
- "(worker failed to boot within #{@options[:worker_boot_timeout]} seconds)"
618
+ "(Worker #{w.index} failed to boot within #{@options[:worker_boot_timeout]} seconds)"
542
619
  end
543
620
  log "! Terminating timed out worker #{details}: #{w.pid}"
544
621
  w.kill
545
622
  end
546
623
  end
547
624
  end
625
+
626
+ def idle_timed_out_worker_pids
627
+ idle_workers.keys
628
+ end
629
+
630
+ def idle_workers
631
+ @idle_workers ||= {}
632
+ end
548
633
  end
549
634
  end