pitchfork 0.17.0 → 0.18.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b22167ab418be7e6f7ad8a8c66177582b80e611423531f7f96f132688f684c8d
4
- data.tar.gz: 036fbed35ccdece6c17b26f0f4011b78efcc33e4344e6fbe94fcb5ee170d52d3
3
+ metadata.gz: 735c4d6910ec05b65ae12e6e13f306110d55102d4ec41d93be14033dbda00f6f
4
+ data.tar.gz: 1452376664a49ff6c81c4c29e04345ef93b891f68d4d87d53e40473c6ed5e67b
5
5
  SHA512:
6
- metadata.gz: cd7bd9b5ef42730eb970b368d18fc188834e65a8a7104666cf320d6ef71e629196dbab2ef081a11c988b5232de9084d7debe732ee306524dc503d7464f33823f
7
- data.tar.gz: 53db8ad62bfdd0e21d8e01734427a9cc59a3594438b61616a2c0151dc563d5d2bc84521b1085ba797821598a8d35c9f95db12d50c3a909dfe03bc7834404c49b
6
+ metadata.gz: 4d9d99a1092c508806c2b7877930637c54c7a0e79aeb3cb9d74b22f9fc2a25caa65b26a5d8f73c9a78ecdc072e7da285c1f1fd1b1d025b096acb652bcf46db90
7
+ data.tar.gz: 05b55878eb9fe2c7d8af70429789f31aaa4ec5c30e025fd5fb4a6d6e3934256fe9c66dce75fbc98f2e0d596f56874a5ccc06578923c9d15f7811e768a02ecf4c
data/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Unreleased
2
2
 
3
+ # 0.18.0
4
+
5
+ - Fix a regression introduced in `0.17.0` causing `before_worker_exit` to often not be called.
6
+ - Implemented the `max_consecutive_spawn_errors` configuration. Purely opt-in for now.
7
+ - Properly handle magic comments in `config.ru`.
8
+ - Execute `rack.response_finished` callbacks in reverse order of registration
9
+
3
10
  # 0.17.0
4
11
 
5
12
  - Improve `Pitchfork::Info#live_workers_count` to be more accurate.
@@ -542,6 +542,37 @@ By default automatic reforking isn't enabled.
542
542
 
543
543
  Make sure to read the [fork safety guide](FORK_SAFETY.md) before enabling reforking.
544
544
 
545
+ ### `refork_max_unavailable`
546
+
547
+ ```ruby
548
+ refork_max_unavailable 5
549
+ ```
550
+
551
+ Sets the number of workers than can be restarted at a given time.
552
+
553
+ When a new generation is created, workers from the old generation are rolled out progressively
554
+ and replaced by fresh workers from the new generation.
555
+
556
+ For instance `refork_max_unavailable 5` means 5 workers may be unavailable during the rollout phase.
557
+
558
+ The default is `(workers_processes * 0.1).ceil`, or `10%` rounded up.
559
+
560
+ ### `max_consecutive_spawn_errors`
561
+
562
+ ```ruby
563
+ max_consecutive_spawn_errors 5
564
+ ```
565
+
566
+ Sets the number of consecutive failures of spawning new workers that trigger a shutdown.
567
+
568
+ Whenever a newly spawned worker fail to acheive readiness, either because it crashes or because
569
+ it times out before becoming ready, a counter is incremented. Whenever a worker successfully
570
+ reach readiness, the counter is reset.
571
+
572
+ This can be useful to better handle issues with slow or broken `after_worker_fork` callbacks.
573
+
574
+ The default is `nil`, which means the behavior is disabled.
575
+
545
576
  ## Rack Features
546
577
 
547
578
  ### `early_hints`
@@ -75,7 +75,7 @@ module Pitchfork
75
75
  :client_body_buffer_size => Pitchfork::Const::MAX_BODY,
76
76
  :before_service_worker_ready => nil,
77
77
  :before_service_worker_exit => nil,
78
- :setpgid => false,
78
+ :setpgid => true,
79
79
  }
80
80
  #:startdoc:
81
81
 
@@ -216,6 +216,14 @@ module Pitchfork
216
216
  set_int(:worker_processes, nr, 1)
217
217
  end
218
218
 
219
+ def refork_max_unavailable(max)
220
+ set_int(:refork_max_unavailable, max, 1)
221
+ end
222
+
223
+ def max_consecutive_spawn_errors(max)
224
+ set_int(:max_consecutive_spawn_errors, max, 1)
225
+ end
226
+
219
227
  def early_hints(bool)
220
228
  set_bool(:early_hints, bool)
221
229
  end
@@ -82,7 +82,8 @@ module Pitchfork
82
82
  :listener_opts, :children,
83
83
  :orig_app, :config, :ready_pipe, :early_hints, :setpgid
84
84
  attr_writer :after_worker_exit, :before_worker_exit, :after_worker_ready, :after_request_complete,
85
- :refork_condition, :after_worker_timeout, :after_worker_hard_timeout, :after_monitor_ready
85
+ :refork_condition, :after_worker_timeout, :after_worker_hard_timeout, :after_monitor_ready, :refork_max_unavailable,
86
+ :max_consecutive_spawn_errors
86
87
 
87
88
  attr_reader :logger
88
89
  include Pitchfork::SocketHelper
@@ -103,6 +104,9 @@ module Pitchfork
103
104
  @exit_status = 0
104
105
  @app = app
105
106
  @respawn = false
107
+ @refork_max_unavailable = nil
108
+ @consecutive_spawn_errors = 0
109
+ @max_consecutive_spawn_errors = nil
106
110
  @last_check = Pitchfork.time_now
107
111
  @promotion_lock = Flock.new("pitchfork-promotion")
108
112
  Info.keep_io(@promotion_lock)
@@ -330,6 +334,13 @@ module Pitchfork
330
334
  # machine) comes out of suspend/hibernation
331
335
  if (@last_check + @timeout) >= (@last_check = Pitchfork.time_now)
332
336
  sleep_time = murder_lazy_workers
337
+ if @max_consecutive_spawn_errors && @consecutive_spawn_errors > @max_consecutive_spawn_errors && !SharedMemory.shutting_down?
338
+ logger.fatal("#{@consecutive_spawn_errors} consecutive failures to spawn children, aborting - broken after_worker_fork callback?")
339
+ @exit_status = 1
340
+ SharedMemory.shutting_down!
341
+ stop(false)
342
+ return StopIteration
343
+ end
333
344
  else
334
345
  sleep_time = @timeout/2.0 + 1
335
346
  @logger.debug("waiting #{sleep_time}s after suspend/hibernation")
@@ -371,6 +382,7 @@ module Pitchfork
371
382
  new_service = @children.update(message)
372
383
  logger.info("#{new_service.to_log} spawned")
373
384
  when Message::MoldReady
385
+ @consecutive_spawn_errors = 0
374
386
  old_molds = @children.molds
375
387
  new_mold = @children.update(message)
376
388
  logger.info("#{new_mold.to_log} ready")
@@ -378,6 +390,8 @@ module Pitchfork
378
390
  logger.info("Terminating old #{old_mold.to_log}")
379
391
  old_mold.soft_kill(:TERM)
380
392
  end
393
+ when Message::WorkerReady, Message::ServiceReady
394
+ @consecutive_spawn_errors = 0
381
395
  else
382
396
  logger.error("Unexpected message in sig_queue #{message.inspect}")
383
397
  logger.error(@sig_queue.inspect)
@@ -467,6 +481,10 @@ module Pitchfork
467
481
 
468
482
  private
469
483
 
484
+ def refork_max_unavailable
485
+ @refork_max_unavailable ||= (worker_processes * 0.1).ceil
486
+ end
487
+
470
488
  # wait for a signal handler to wake us up and then consume the pipe
471
489
  def monitor_sleep(sec)
472
490
  @control_socket[0].wait(sec) or return
@@ -490,6 +508,9 @@ module Pitchfork
490
508
  wpid or return
491
509
  worker = @children.reap(wpid) and worker.close rescue nil
492
510
  if worker
511
+ unless worker.ready?
512
+ @consecutive_spawn_errors += 1
513
+ end
493
514
  @after_worker_exit.call(self, worker, status)
494
515
  else
495
516
  logger.info("reaped unknown subprocess #{status.inspect}")
@@ -549,6 +570,7 @@ module Pitchfork
549
570
  end
550
571
 
551
572
  logger.error "#{child.to_log} timed out, killing"
573
+ @consecutive_spawn_errors += 1 unless child.ready?
552
574
  @children.hard_kill(@timeout_signal.call(child.pid), child) # take no prisoners for hard timeout violations
553
575
  end
554
576
 
@@ -613,6 +635,7 @@ module Pitchfork
613
635
  end
614
636
  end
615
637
 
638
+ service.notify_ready(@control_socket[1])
616
639
  proc_name status: "ready"
617
640
 
618
641
  while readers[0]
@@ -750,8 +773,7 @@ module Pitchfork
750
773
  # We don't shutdown any outdated worker if any worker is already being
751
774
  # spawned or a worker is exiting. Only 10% of workers can be reforked at
752
775
  # once to minimize the impact on capacity.
753
- max_pending_workers = (worker_processes * 0.1).ceil
754
- workers_to_restart = max_pending_workers - @children.restarting_workers_count
776
+ workers_to_restart = refork_max_unavailable - @children.restarting_workers_count
755
777
 
756
778
  if service = @children.service
757
779
  if service.outdated?
@@ -871,7 +893,7 @@ module Pitchfork
871
893
  env
872
894
  ensure
873
895
  if env
874
- env["rack.response_finished"].each do |callback|
896
+ env["rack.response_finished"].reverse_each do |callback|
875
897
  if callback.arity == 0
876
898
  callback.call
877
899
  else
@@ -926,14 +948,13 @@ module Pitchfork
926
948
  end
927
949
 
928
950
  def init_service_process(service)
929
- proc_name role: "(gen:#{service.generation}) mold", status: "init"
951
+ proc_name role: "(gen:#{service.generation}) service", status: "init"
930
952
  LISTENERS.each(&:close).clear # Don't appear as listening to incoming requests
931
953
  service.register_to_monitor(@control_socket[1])
932
954
  readers = [service]
933
955
  trap(:QUIT) { nuke_listeners!(readers) }
934
956
  trap(:TERM) { nuke_listeners!(readers) }
935
957
  trap(:INT) { nuke_listeners!(readers); exit!(0) }
936
- proc_name role: "(gen:#{service.generation}) service", status: "ready"
937
958
  readers
938
959
  end
939
960
 
@@ -944,7 +965,6 @@ module Pitchfork
944
965
  trap(:QUIT) { nuke_listeners!(readers) }
945
966
  trap(:TERM) { nuke_listeners!(readers) }
946
967
  trap(:INT) { nuke_listeners!(readers); exit!(0) }
947
- proc_name role: "(gen:#{mold.generation}) mold", status: "ready"
948
968
  readers
949
969
  end
950
970
 
@@ -969,10 +989,9 @@ module Pitchfork
969
989
  ready = readers.dup
970
990
  @after_worker_ready.call(self, worker)
971
991
 
992
+ worker.notify_ready(@control_socket[1])
972
993
  proc_name status: "ready"
973
994
 
974
- worker.ready = true
975
-
976
995
  while readers[0]
977
996
  begin
978
997
  worker.update_deadline(@timeout)
@@ -1054,6 +1073,8 @@ module Pitchfork
1054
1073
  ready = readers.dup
1055
1074
 
1056
1075
  mold.finish_promotion(@control_socket[1])
1076
+ mold.ready = true
1077
+ proc_name status: "ready"
1057
1078
 
1058
1079
  while readers[0]
1059
1080
  begin
@@ -1165,8 +1186,6 @@ module Pitchfork
1165
1186
  FORK_TIMEOUT = 5
1166
1187
 
1167
1188
  def fork_sibling(role, &block)
1168
- reset_signal_handlers
1169
-
1170
1189
  if REFORKING_AVAILABLE
1171
1190
  r, w = Pitchfork::Info.keep_ios(IO.pipe)
1172
1191
  # We double fork so that the new worker is re-attached back
@@ -1190,6 +1209,8 @@ module Pitchfork
1190
1209
  raise ForkFailure, "fork_sibling didn't succeed in #{FORK_TIMEOUT} seconds"
1191
1210
  end
1192
1211
  else # first child
1212
+ reset_signal_handlers
1213
+
1193
1214
  r.close
1194
1215
  Process.setproctitle("<pitchfork fork_sibling(#{role})>")
1195
1216
  pid = Pitchfork.clean_fork(setpgid: setpgid) do
@@ -124,6 +124,7 @@ module Pitchfork
124
124
  class Message
125
125
  SpawnWorker = new(:nr)
126
126
  WorkerSpawned = new(:nr, :pid, :generation, :pipe)
127
+ WorkerReady = new(:nr, :pid, :generation)
127
128
  PromoteWorker = new(:generation)
128
129
 
129
130
  MoldSpawned = new(:nr, :pid, :generation, :pipe)
@@ -131,6 +132,7 @@ module Pitchfork
131
132
 
132
133
  SpawnService = new(:_) # Struct.new requires at least 1 member on Ruby < 3.3
133
134
  ServiceSpawned = new(:pid, :generation, :pipe)
135
+ ServiceReady = new(:pid, :generation)
134
136
 
135
137
  SoftKill = new(:signum)
136
138
  end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Pitchfork
4
- VERSION = "0.17.0"
4
+ VERSION = "0.18.0"
5
5
  module Const
6
6
  UNICORN_VERSION = '6.1.0'
7
7
  end
@@ -67,12 +67,25 @@ module Pitchfork
67
67
  end
68
68
 
69
69
  def finish_promotion(control_socket)
70
+ SharedMemory.current_generation = @generation
70
71
  message = Message::MoldReady.new(@nr, @pid, generation)
71
72
  control_socket.sendmsg(message)
72
- SharedMemory.current_generation = @generation
73
73
  @state_drop = SharedMemory.mold_state
74
74
  end
75
75
 
76
+ def notify_ready(control_socket)
77
+ self.ready = true
78
+ message = if worker?
79
+ Message::WorkerReady.new(@nr, @pid, @generation)
80
+ elsif service?
81
+ Message::ServiceReady.new(@pid, @generation)
82
+ else
83
+ raise "Unexpected child type"
84
+ end
85
+
86
+ control_socket.sendmsg(message)
87
+ end
88
+
76
89
  def promote(generation)
77
90
  send_message_nonblock(Message::PromoteWorker.new(generation))
78
91
  end
@@ -106,6 +119,10 @@ module Pitchfork
106
119
  false
107
120
  end
108
121
 
122
+ def worker?
123
+ !mold? && !service?
124
+ end
125
+
109
126
  def to_io # IO.select-compatible
110
127
  @to_io.to_io
111
128
  end
data/lib/pitchfork.rb CHANGED
@@ -87,7 +87,12 @@ module Pitchfork
87
87
  when /\.ru$/
88
88
  raw = File.read(ru)
89
89
  raw.sub!(/^__END__\n.*/, '')
90
- eval("Rack::Builder.new {(\n#{raw}\n)}.to_app", TOPLEVEL_BINDING, ru)
90
+ lines = raw.lines
91
+ trailing_comments_index = lines.index { |line| !line.start_with?('#') }
92
+ prelude = lines[0...trailing_comments_index].join
93
+ raw = lines[trailing_comments_index..-1].join
94
+
95
+ eval("#{prelude}\nRack::Builder.new do\n#{raw}\nend.to_app\n", TOPLEVEL_BINDING, ru)
91
96
  else
92
97
  require ru
93
98
  Object.const_get(File.basename(ru, '.rb').capitalize)
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: pitchfork
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.17.0
4
+ version: 0.18.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jean Boussier
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2025-03-24 00:00:00.000000000 Z
11
+ date: 2025-07-17 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rack