pitchfork 0.5.0 → 0.7.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ccd444fbdc89d5a253819e7be03e5d097c5aaf8b061980e15d4ec5326d2c54b5
4
- data.tar.gz: f300bd3135e8d8d9e15f14dfe2780c75521ececeb72b8245fef0380e2ba338f5
3
+ metadata.gz: 05372d35dd4784eb22e204b614b1c7add13fd0e298495543c5395e2f03b42073
4
+ data.tar.gz: 14f241efeb95774f6de6e9fc8926815eb7f10c25965a09a0700f77dae75dbb92
5
5
  SHA512:
6
- metadata.gz: b054a33d46f4b60c14e44fbac330bc4318a1b52259e89f2e8a1c16a58d8467f02eef981dbe018906a0c7a027c98db75a8efd483619d7ea5e2ece234b30b3846f
7
- data.tar.gz: b12a622275fe638d49be5d9457d11309421119f9628044dec9898231f83425cab6aa6434b9f42372c5f8954b261f7e979e300a41fa75c39172fb8e1832cb0805
6
+ metadata.gz: f26ebeeb3bc9f7533d25cc41b29a317fb8a80ce108a1859657adfa80b9eb8f88812cfe85dfecf0fbd8093b6e91f45c6ce51ab7b9a15864d96b9d0f5f77835786
7
+ data.tar.gz: 8d1f09bb24226f2c89b2db2d549c28508a9f2ea716a1612fcc3367d27445657b9d9da281e623af696dc803acd649847a724366306caf672dbc523a3e7495579b
@@ -10,7 +10,6 @@ jobs:
10
10
  fail-fast: false
11
11
  matrix:
12
12
  os: ["ubuntu-latest"]
13
- redis: ["6.2"]
14
13
  ruby: ["3.2", "3.1", "3.0", "2.7", "2.6"]
15
14
  runs-on: ubuntu-latest
16
15
  steps:
data/CHANGELOG.md CHANGED
@@ -1,5 +1,24 @@
1
1
  # Unreleased
2
2
 
3
+ # 0.7.0
4
+
5
+ - Set nicer `proctile` to better see the state of the process tree at a glance.
6
+ - Pass the last request env to `after_request_complete` callback.
7
+ - Fix the slow rollout of workers on a new generation.
8
+ - Expose `Pitchfork::Info.fork_safe?` and `Pitchfork::Info.no_longer_fork_safe!`.
9
+
10
+ # 0.6.0
11
+
12
+ - Expose `Pitchfork::Info.workers_count` and `.live_workers_count` to be consumed by application health checks.
13
+ - Implement `before_worker_exit` callback.
14
+ - Make each mold and worker a process group leader.
15
+ - Get rid of `Pitchfork::PrereadInput`.
16
+ - Add `Pitchfork.shutting_down?` to allow health check endpoints to fail sooner on graceful shutdowns.
17
+ - Treat `TERM` as graceful shutdown rather than quick shutdown.
18
+ - Implement `after_worker_hard_timeout` callback.
19
+
20
+ # 0.5.0
21
+
3
22
  - Added a soft timeout in addition to the historical Unicorn hard timeout.
4
23
  On soft timeout, the `after_worker_timeout` callback is invoked.
5
24
  - Implement `after_request_complete` callback.
data/Dockerfile CHANGED
@@ -1,4 +1,4 @@
1
1
  FROM ruby:3.2
2
- RUN apt-get update -y && apt-get install -y ragel socat netcat smem apache2-utils
2
+ RUN apt-get update -y && apt-get install -y ragel socat netcat-traditional smem apache2-utils
3
3
  WORKDIR /app
4
4
  CMD [ "bash" ]
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- pitchfork (0.5.0)
4
+ pitchfork (0.7.0)
5
5
  rack (>= 2.0)
6
6
  raindrops (~> 0.7)
7
7
 
@@ -9,8 +9,8 @@ GEM
9
9
  remote: https://rubygems.org/
10
10
  specs:
11
11
  minitest (5.15.0)
12
- nio4r (2.5.8)
13
- puma (6.1.1)
12
+ nio4r (2.5.9)
13
+ puma (6.3.0)
14
14
  nio4r (~> 2.0)
15
15
  rack (3.0.8)
16
16
  raindrops (0.20.1)
@@ -13,5 +13,5 @@ sleep 3
13
13
  puts "Memory Usage:"
14
14
  puts Net::HTTP.get(URI(app_url))
15
15
 
16
- Process.kill("TERM", pid)
16
+ Process.kill("INT", pid)
17
17
  Process.wait
@@ -336,6 +336,21 @@ really have to, make sure to configure the `cleanup` timeout so that the
336
336
  callback has time to complete before the "hard" timeout triggers.
337
337
  By default the cleanup timeout is 2 seconds.
338
338
 
339
+ ### `after_worker_hard_timeout`
340
+
341
+ Called in the master process when a worker hard timeout is elapsed:
342
+
343
+ ```ruby
344
+ after_worker_timeout do |server, worker|
345
+ $stderr.puts "Worker hard timeout, pid=#{worker.pid}"
346
+ end
347
+ ```
348
+
349
+ Once the callback complete, the worker will be signaled with `SIGKILL`.
350
+
351
+ This callback being called in an indication that something is preventing the
352
+ soft timeout from working.
353
+
339
354
  ### `after_worker_exit`
340
355
 
341
356
  Called in the master process after a worker exits.
@@ -356,7 +371,7 @@ Called in the worker processes after a request has completed.
356
371
  Can be used for out of band work, or to exit unhealthy workers.
357
372
 
358
373
  ```ruby
359
- after_request_complete do |server, worker|
374
+ after_request_complete do |server, worker, env|
360
375
  if something_wrong?
361
376
  exit
362
377
  end
data/docs/FORK_SAFETY.md CHANGED
@@ -76,10 +76,12 @@ impact of discovering such bug.
76
76
 
77
77
  ## Known Incompatible Gems
78
78
 
79
- - [The `grpc` isn't fork safe](https://github.com/grpc/grpc/issues/8798) and doesn't provide any before or after fork callback to re-establish connection.
80
- It can only be used in forking environment if the client is never used in the parent before fork.
81
- If you application uses `grpc`, you shouldn't enable reforking.
82
- But frankly, that gem is such a tire fire, you shouldn't use it regardless.
83
- If you really have to consume a gRPC API, you can consider `grpc_kit` as a replacement.
79
+ - The `grpc` isn't fork safe by default, but starting from version `1.57.0`, it does provide an experimental
80
+ fork safe option that requires setting an environment variable before loading the library, and calling
81
+ `GRPC.prefork`, `GRPC.postfork_parent` and `GRPC.postfork_child` around fork calls.
82
+ (https://github.com/grpc/grpc/pull/33430)
84
83
 
85
- No other gem is known to be incompatible, but if you find one please open an issue to add it to the list.
84
+ - The `ruby-vips` gem binds the `libvips` image processing library that isn't fork safe.
85
+ (https://github.com/libvips/libvips/discussions/3577)
86
+
87
+ No other gem is known to be incompatible for now, but if you find one please open an issue to add it to the list.
data/docs/SIGNALS.md CHANGED
@@ -6,9 +6,9 @@ processes are documented here as well.
6
6
 
7
7
  ### Master Process
8
8
 
9
- * `INT/TERM` - quick shutdown, kills all workers immediately
9
+ * `INT` - quick shutdown, kills all workers immediately
10
10
 
11
- * `QUIT` - graceful shutdown, waits for workers to finish their
11
+ * `QUIT/TERM` - graceful shutdown, waits for workers to finish their
12
12
  current request before finishing.
13
13
 
14
14
  * `USR2` - trigger a manual refork. A worker is promoted as
@@ -29,10 +29,10 @@ Sending signals directly to the worker processes should not normally be
29
29
  needed. If the master process is running, any exited worker will be
30
30
  automatically respawned.
31
31
 
32
- * `INT/TERM` - Quick shutdown, immediately exit.
32
+ * `INT` - Quick shutdown, immediately exit.
33
33
  The master process will respawn a worker to replace this one.
34
34
  Immediate shutdown is still triggered using kill(2) and not the
35
35
  internal pipe as of unicorn 4.8
36
36
 
37
- * `QUIT` - Gracefully exit after finishing the current request.
37
+ * `QUIT/TERM` - Gracefully exit after finishing the current request.
38
38
  The master process will respawn a worker to replace this one.
@@ -26,11 +26,15 @@ module Pitchfork
26
26
  }
27
27
 
28
28
  # Default settings for Pitchfork
29
+ default_logger = Logger.new($stderr)
30
+ default_logger.formatter = Logger::Formatter.new
31
+ default_logger.progname = "[Pitchfork]"
32
+
29
33
  DEFAULTS = {
30
34
  :soft_timeout => 20,
31
35
  :cleanup_timeout => 2,
32
36
  :timeout => 22,
33
- :logger => Logger.new($stderr),
37
+ :logger => default_logger,
34
38
  :worker_processes => 1,
35
39
  :after_worker_fork => lambda { |server, worker|
36
40
  server.logger.info("worker=#{worker.nr} gen=#{worker.generation} pid=#{$$} spawned")
@@ -38,6 +42,7 @@ module Pitchfork
38
42
  :after_mold_fork => lambda { |server, worker|
39
43
  server.logger.info("mold gen=#{worker.generation} pid=#{$$} spawned")
40
44
  },
45
+ :before_worker_exit => nil,
41
46
  :after_worker_exit => lambda { |server, worker, status|
42
47
  m = if worker.nil?
43
48
  "repead unknown process (#{status.inspect})"
@@ -56,6 +61,7 @@ module Pitchfork
56
61
  server.logger.info("worker=#{worker.nr} gen=#{worker.generation} ready")
57
62
  },
58
63
  :after_worker_timeout => nil,
64
+ :after_worker_hard_timeout => nil,
59
65
  :after_request_complete => nil,
60
66
  :early_hints => false,
61
67
  :refork_condition => nil,
@@ -139,12 +145,20 @@ module Pitchfork
139
145
  set_hook(:after_worker_timeout, block_given? ? block : args[0], 3)
140
146
  end
141
147
 
148
+ def after_worker_hard_timeout(*args, &block)
149
+ set_hook(:after_worker_hard_timeout, block_given? ? block : args[0], 2)
150
+ end
151
+
152
+ def before_worker_exit(*args, &block)
153
+ set_hook(:before_worker_exit, block_given? ? block : args[0], 2)
154
+ end
155
+
142
156
  def after_worker_exit(*args, &block)
143
157
  set_hook(:after_worker_exit, block_given? ? block : args[0], 3)
144
158
  end
145
159
 
146
160
  def after_request_complete(*args, &block)
147
- set_hook(:after_request_complete, block_given? ? block : args[0])
161
+ set_hook(:after_request_complete, block_given? ? block : args[0], 3)
148
162
  end
149
163
 
150
164
  def timeout(seconds, cleanup: 2)
@@ -19,6 +19,10 @@ module Pitchfork
19
19
  nil
20
20
  end
21
21
 
22
+ def to_io
23
+ @file
24
+ end
25
+
22
26
  def unlink
23
27
  File.unlink(@file.path)
24
28
  rescue Errno::ENOENT
@@ -2,6 +2,8 @@
2
2
  require 'pitchfork/pitchfork_http'
3
3
  require 'pitchfork/flock'
4
4
  require 'pitchfork/soft_timeout'
5
+ require 'pitchfork/shared_memory'
6
+ require 'pitchfork/info'
5
7
 
6
8
  module Pitchfork
7
9
  # This is the process manager of Pitchfork. This manages worker
@@ -44,14 +46,15 @@ module Pitchfork
44
46
  end
45
47
 
46
48
  def call(original_thread) # :nodoc:
47
- @server.logger.error("worker=#{@worker.nr} pid=#{@worker.pid} timed out, exiting")
48
- if @callback
49
- @callback.call(@server, @worker, Info.new(original_thread, @rack_env))
49
+ begin
50
+ @server.logger.error("worker=#{@worker.nr} pid=#{@worker.pid} timed out, exiting")
51
+ if @callback
52
+ @callback.call(@server, @worker, Info.new(original_thread, @rack_env))
53
+ end
54
+ rescue => error
55
+ Pitchfork.log_error(@server.logger, "after_worker_timeout error", error)
50
56
  end
51
- rescue => error
52
- Pitchfork.log_error(@server.logger, "after_worker_timeout error", error)
53
- ensure
54
- exit
57
+ @server.worker_exit(@worker)
55
58
  end
56
59
 
57
60
  def finished # :nodoc:
@@ -76,8 +79,8 @@ module Pitchfork
76
79
  :listener_opts, :children,
77
80
  :orig_app, :config, :ready_pipe,
78
81
  :default_middleware, :early_hints
79
- attr_writer :after_worker_exit, :after_worker_ready, :after_request_complete, :refork_condition,
80
- :after_worker_timeout
82
+ attr_writer :after_worker_exit, :before_worker_exit, :after_worker_ready, :after_request_complete,
83
+ :refork_condition, :after_worker_timeout, :after_worker_hard_timeout
81
84
 
82
85
  attr_reader :logger
83
86
  include Pitchfork::SocketHelper
@@ -88,7 +91,7 @@ module Pitchfork
88
91
  # in new projects
89
92
  LISTENERS = []
90
93
 
91
- NOOP = '.'
94
+ NOOP = '.'.freeze
92
95
 
93
96
  # :startdoc:
94
97
  # This Hash is considered a stable interface and changing its contents
@@ -126,6 +129,7 @@ module Pitchfork
126
129
  @respawn = false
127
130
  @last_check = Pitchfork.time_now
128
131
  @promotion_lock = Flock.new("pitchfork-promotion")
132
+ Info.keep_io(@promotion_lock)
129
133
 
130
134
  options = options.dup
131
135
  @ready_pipe = options.delete(:ready_pipe)
@@ -134,6 +138,8 @@ module Pitchfork
134
138
  self.config = Pitchfork::Configurator.new(options)
135
139
  self.listener_opts = {}
136
140
 
141
+ proc_name role: 'monitor', status: START_CTX[:argv].join(' ')
142
+
137
143
  # We use @control_socket differently in the master and worker processes:
138
144
  #
139
145
  # * The master process never closes or reinitializes this once
@@ -163,7 +169,9 @@ module Pitchfork
163
169
  # list of signals we care about and trap in master.
164
170
  @queue_sigs = [
165
171
  :QUIT, :INT, :TERM, :USR2, :TTIN, :TTOU ]
166
- Worker.preallocate_drops(worker_processes)
172
+
173
+ Info.workers_count = worker_processes
174
+ SharedMemory.preallocate_drops(worker_processes)
167
175
  end
168
176
 
169
177
  # Runs the thing. Returns self so you can run join on it
@@ -175,6 +183,7 @@ module Pitchfork
175
183
  # It's also used by newly spawned children to send their soft_signal pipe
176
184
  # to the master when they are spawned.
177
185
  @control_socket.replace(Pitchfork.socketpair)
186
+ Info.keep_ios(@control_socket)
178
187
  @master_pid = $$
179
188
 
180
189
  # setup signal handlers before writing pid file in case people get
@@ -259,6 +268,7 @@ module Pitchfork
259
268
  io = server_cast(io)
260
269
  end
261
270
  logger.info "listening on addr=#{sock_name(io)} fd=#{io.fileno}"
271
+ Info.keep_io(io)
262
272
  LISTENERS << io
263
273
  io
264
274
  rescue Errno::EADDRINUSE => err
@@ -282,7 +292,8 @@ module Pitchfork
282
292
  def join
283
293
  @respawn = true
284
294
 
285
- proc_name 'master'
295
+ proc_name role: 'monitor', status: START_CTX[:argv].join(' ')
296
+
286
297
  logger.info "master process ready" # test_exec.rb relies on this message
287
298
  if @ready_pipe
288
299
  begin
@@ -312,7 +323,7 @@ module Pitchfork
312
323
  if REFORKING_AVAILABLE && @respawn && @children.molds.empty?
313
324
  logger.info("No mold alive, shutting down")
314
325
  @exit_status = 1
315
- @sig_queue << :QUIT
326
+ @sig_queue << :TERM
316
327
  @respawn = false
317
328
  end
318
329
 
@@ -332,15 +343,21 @@ module Pitchfork
332
343
  end
333
344
 
334
345
  master_sleep(sleep_time) if sleep
335
- when :QUIT # graceful shutdown
336
- logger.info "QUIT received, starting graceful shutdown"
346
+ when :QUIT, :TERM # graceful shutdown
347
+ SharedMemory.shutting_down!
348
+ logger.info "#{message} received, starting graceful shutdown"
337
349
  return StopIteration
338
- when :TERM, :INT # immediate shutdown
350
+ when :INT # immediate shutdown
351
+ SharedMemory.shutting_down!
339
352
  logger.info "#{message} received, starting immediate shutdown"
340
353
  stop(false)
341
354
  return StopIteration
342
355
  when :USR2 # trigger a promotion
343
- trigger_refork
356
+ if @respawn
357
+ trigger_refork
358
+ else
359
+ logger.error "Can't trigger a refork as the server is shutting down"
360
+ end
344
361
  when :TTIN
345
362
  @respawn = true
346
363
  self.worker_processes += 1
@@ -359,7 +376,7 @@ module Pitchfork
359
376
  logger.info("mold pid=#{new_mold.pid} gen=#{new_mold.generation} ready")
360
377
  old_molds.each do |old_mold|
361
378
  logger.info("Terminating old mold pid=#{old_mold.pid} gen=#{old_mold.generation}")
362
- old_mold.soft_kill(:QUIT)
379
+ old_mold.soft_kill(:TERM)
363
380
  end
364
381
  else
365
382
  logger.error("Unexpected message in sig_queue #{message.inspect}")
@@ -369,15 +386,17 @@ module Pitchfork
369
386
 
370
387
  # Terminates all workers, but does not exit master process
371
388
  def stop(graceful = true)
389
+ proc_name role: 'monitor', status: 'shutting down'
372
390
  @respawn = false
391
+ SharedMemory.shutting_down!
373
392
  wait_for_pending_workers
374
393
  self.listeners = []
375
394
  limit = Pitchfork.time_now + timeout
376
395
  until @children.workers.empty? || Pitchfork.time_now > limit
377
396
  if graceful
378
- soft_kill_each_child(:QUIT)
397
+ soft_kill_each_child(:TERM)
379
398
  else
380
- kill_each_child(:TERM)
399
+ kill_each_child(:INT)
381
400
  end
382
401
  if monitor_loop(false) == StopIteration
383
402
  return StopIteration
@@ -387,6 +406,19 @@ module Pitchfork
387
406
  @promotion_lock.unlink
388
407
  end
389
408
 
409
+ def worker_exit(worker)
410
+ proc_name status: "exiting"
411
+
412
+ if @before_worker_exit
413
+ begin
414
+ @before_worker_exit.call(self, worker)
415
+ rescue => error
416
+ Pitchfork.log_error(logger, "before_worker_exit error", error)
417
+ end
418
+ end
419
+ Process.exit
420
+ end
421
+
390
422
  def rewindable_input
391
423
  Pitchfork::HttpParser.input_class.method_defined?(:rewind)
392
424
  end
@@ -473,10 +505,19 @@ module Pitchfork
473
505
  else # worker is out of time
474
506
  next_sleep = 0
475
507
  if worker.mold?
476
- logger.error "mold pid=#{worker.pid} deadline=#{deadline} timed out, killing"
508
+ logger.error "mold pid=#{worker.pid} timed out, killing"
477
509
  else
478
- logger.error "worker=#{worker.nr} pid=#{worker.pid} deadline=#{deadline} timed out, killing"
510
+ logger.error "worker=#{worker.nr} pid=#{worker.pid} timed out, killing"
511
+ end
512
+
513
+ if @after_worker_hard_timeout
514
+ begin
515
+ @after_worker_hard_timeout.call(self, worker)
516
+ rescue => error
517
+ Pitchfork.log_error(@logger, "after_worker_hard_timeout callback", error)
518
+ end
479
519
  end
520
+
480
521
  kill_worker(:KILL, worker.pid) # take no prisoners for hard timeout violations
481
522
  end
482
523
  end
@@ -486,7 +527,7 @@ module Pitchfork
486
527
 
487
528
  def trigger_refork
488
529
  unless REFORKING_AVAILABLE
489
- logger.error("This system doesn't support PR_SET_CHILD_SUBREAPER, can't promote a worker")
530
+ logger.error("This system doesn't support PR_SET_CHILD_SUBREAPER, can't refork")
490
531
  end
491
532
 
492
533
  unless @children.pending_promotion?
@@ -495,7 +536,6 @@ module Pitchfork
495
536
  else
496
537
  logger.error("No children at all???")
497
538
  end
498
- else
499
539
  end
500
540
  end
501
541
 
@@ -519,6 +559,7 @@ module Pitchfork
519
559
 
520
560
  after_fork_internal
521
561
  worker_loop(worker)
562
+ worker_exit(worker)
522
563
  end
523
564
 
524
565
  worker
@@ -577,7 +618,7 @@ module Pitchfork
577
618
  def maintain_worker_count
578
619
  (off = @children.workers_count - worker_processes) == 0 and return
579
620
  off < 0 and return spawn_missing_workers
580
- @children.each_worker { |w| w.nr >= worker_processes and w.soft_kill(:QUIT) }
621
+ @children.each_worker { |w| w.nr >= worker_processes and w.soft_kill(:TERM) }
581
622
  end
582
623
 
583
624
  def restart_outdated_workers
@@ -593,8 +634,13 @@ module Pitchfork
593
634
  if workers_to_restart > 0
594
635
  outdated_workers = @children.workers.select { |w| !w.exiting? && w.generation < @children.mold.generation }
595
636
  outdated_workers.each do |worker|
596
- logger.info("worker=#{worker.nr} pid=#{worker.pid} gen=#{worker.generation} restarting")
597
- worker.soft_kill(:QUIT)
637
+ if worker.soft_kill(:TERM)
638
+ logger.info("Sent SIGTERM to worker=#{worker.nr} pid=#{worker.pid} gen=#{worker.generation}")
639
+ workers_to_restart -= 1
640
+ else
641
+ logger.info("Failed to send SIGTERM to worker=#{worker.nr} pid=#{worker.pid} gen=#{worker.generation}")
642
+ end
643
+ break if workers_to_restart <= 0
598
644
  end
599
645
  end
600
646
  end
@@ -649,6 +695,9 @@ module Pitchfork
649
695
  env = nil
650
696
  @request = Pitchfork::HttpParser.new
651
697
  env = @request.read(client)
698
+
699
+ proc_name status: "processing: #{env["PATH_INFO"]}"
700
+
652
701
  timeout_handler.rack_env = env
653
702
  env["pitchfork.timeout"] = timeout_handler
654
703
 
@@ -663,12 +712,12 @@ module Pitchfork
663
712
  status, headers, body = @app.call(env)
664
713
 
665
714
  begin
666
- return if @request.hijacked?
715
+ return env if @request.hijacked?
667
716
 
668
717
  if 100 == status.to_i
669
718
  e100_response_write(client, env)
670
719
  status, headers, body = @app.call(env)
671
- return if @request.hijacked?
720
+ return env if @request.hijacked?
672
721
  end
673
722
  @request.headers? or headers = nil
674
723
  http_response_write(client, status, headers, body, @request)
@@ -683,11 +732,14 @@ module Pitchfork
683
732
  end
684
733
  client.close # flush and uncork socket immediately, no keepalive
685
734
  end
735
+ env
686
736
  rescue => e
687
737
  handle_error(client, e)
738
+ env
688
739
  ensure
689
740
  env["rack.after_reply"].each(&:call) if env
690
741
  timeout_handler.finished
742
+ env
691
743
  end
692
744
 
693
745
  def nuke_listeners!(readers)
@@ -702,16 +754,16 @@ module Pitchfork
702
754
  # traps for USR2, and HUP may be set in the after_fork Proc
703
755
  # by the user.
704
756
  def init_worker_process(worker)
757
+ proc_name role: "(gen:#{worker.generation}) worker[#{worker.nr}]", status: "init"
705
758
  worker.reset
706
759
  worker.register_to_master(@control_socket[1])
707
- # we'll re-trap :QUIT later for graceful shutdown iff we accept clients
760
+ # we'll re-trap :QUIT and :TERM later for graceful shutdown iff we accept clients
708
761
  exit_sigs = [ :QUIT, :TERM, :INT ]
709
762
  exit_sigs.each { |sig| trap(sig) { exit!(0) } }
710
763
  exit!(0) if (@sig_queue & exit_sigs)[0]
711
764
  (@queue_sigs - exit_sigs).each { |sig| trap(sig, nil) }
712
765
  trap(:CHLD, 'DEFAULT')
713
766
  @sig_queue.clear
714
- proc_name "(gen:#{worker.generation}) worker[#{worker.nr}]"
715
767
  @children = nil
716
768
 
717
769
  after_worker_fork.call(self, worker) # can drop perms and create listeners
@@ -722,14 +774,16 @@ module Pitchfork
722
774
  readers = LISTENERS.dup
723
775
  readers << worker
724
776
  trap(:QUIT) { nuke_listeners!(readers) }
777
+ trap(:TERM) { nuke_listeners!(readers) }
725
778
  readers
726
779
  end
727
780
 
728
781
  def init_mold_process(mold)
729
- proc_name "(gen: #{mold.generation}) mold"
782
+ proc_name role: "(gen:#{mold.generation}) mold", status: "ready"
730
783
  after_mold_fork.call(self, mold)
731
784
  readers = [mold]
732
785
  trap(:QUIT) { nuke_listeners!(readers) }
786
+ trap(:TERM) { nuke_listeners!(readers) }
733
787
  readers
734
788
  end
735
789
 
@@ -754,6 +808,8 @@ module Pitchfork
754
808
  ready = readers.dup
755
809
  @after_worker_ready.call(self, worker)
756
810
 
811
+ proc_name status: "ready"
812
+
757
813
  while readers[0]
758
814
  begin
759
815
  worker.update_deadline(@timeout)
@@ -765,12 +821,16 @@ module Pitchfork
765
821
  if client
766
822
  case client
767
823
  when Message::PromoteWorker
768
- spawn_mold(worker.generation)
824
+ if Info.fork_safe?
825
+ spawn_mold(worker.generation)
826
+ else
827
+ logger.error("worker=#{worker.nr} gen=#{worker.generation} is no longer fork safe, can't refork")
828
+ end
769
829
  when Message
770
830
  worker.update(client)
771
831
  else
772
- process_client(client, prepare_timeout(worker))
773
- @after_request_complete&.call(self, worker)
832
+ request_env = process_client(client, prepare_timeout(worker))
833
+ @after_request_complete&.call(self, worker, request_env)
774
834
  worker.increment_requests_count
775
835
  end
776
836
  worker.update_deadline(@timeout)
@@ -780,7 +840,7 @@ module Pitchfork
780
840
  # timeout so we can update .deadline and keep parent from SIGKILL-ing us
781
841
  worker.update_deadline(@timeout)
782
842
 
783
- if @refork_condition && !worker.outdated?
843
+ if @refork_condition && Info.fork_safe? && !worker.outdated?
784
844
  if @refork_condition.met?(worker, logger)
785
845
  if spawn_mold(worker.generation)
786
846
  logger.info("Refork condition met, promoting ourselves")
@@ -789,6 +849,7 @@ module Pitchfork
789
849
  end
790
850
  end
791
851
 
852
+ proc_name status: "waiting"
792
853
  waiter.get_readers(ready, readers, @timeout * 500) # to milliseconds, but halved
793
854
  rescue => e
794
855
  Pitchfork.log_error(@logger, "listen loop error", e) if readers[0]
@@ -880,6 +941,8 @@ module Pitchfork
880
941
  def build_app!
881
942
  return unless app.respond_to?(:arity)
882
943
 
944
+ proc_name status: "booting"
945
+
883
946
  self.app = case app.arity
884
947
  when 0
885
948
  app.call
@@ -890,9 +953,11 @@ module Pitchfork
890
953
  end
891
954
  end
892
955
 
893
- def proc_name(tag)
894
- $0 = ([ File.basename(START_CTX[0]), tag
895
- ]).concat(START_CTX[:argv]).join(' ')
956
+ def proc_name(role: nil, status: nil)
957
+ @proctitle_role = role if role
958
+ @proctitle_status = status if status
959
+
960
+ Process.setproctitle("#{File.basename(START_CTX[0])} #{@proctitle_role} - #{@proctitle_status}")
896
961
  end
897
962
 
898
963
  def bind_listeners!
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'pitchfork/shared_memory'
4
+
5
+ module Pitchfork
6
+ module Info
7
+ @workers_count = 0
8
+ @fork_safe = true
9
+ @kept_ios = ObjectSpace::WeakMap.new
10
+
11
+ class << self
12
+ attr_accessor :workers_count
13
+
14
+ def keep_io(io)
15
+ raise ArgumentError, "#{io.inspect} doesn't respond to :to_io" unless io.respond_to?(:to_io)
16
+ @kept_ios[io] = io
17
+ io
18
+ end
19
+
20
+ def keep_ios(ios)
21
+ ios.each { |io| keep_io(io) }
22
+ end
23
+
24
+ def close_all_ios!
25
+ ignored_ios = [$stdin, $stdout, $stderr]
26
+
27
+ @kept_ios.each_value do |io_like|
28
+ ignored_ios << (io_like.is_a?(IO) ? io_like : io_like.to_io)
29
+ end
30
+
31
+ ObjectSpace.each_object(IO) do |io|
32
+ closed = begin
33
+ io.closed?
34
+ rescue IOError
35
+ true
36
+ end
37
+
38
+ if !closed && io.autoclose? && !ignored_ios.include?(io)
39
+ if io.is_a?(TCPSocket)
40
+ # If we inherited a TCP Socket, calling #close directly could send FIN or RST.
41
+ # So we first reopen /dev/null to avoid that.
42
+ io.reopen(File::NULL)
43
+ end
44
+ begin
45
+ io.close
46
+ rescue Errno::EBADF
47
+ end
48
+ end
49
+ end
50
+ end
51
+
52
+ def fork_safe?
53
+ @fork_safe
54
+ end
55
+
56
+ def no_longer_fork_safe!
57
+ @fork_safe = false
58
+ end
59
+
60
+ def live_workers_count
61
+ now = Pitchfork.time_now(true)
62
+ (0...workers_count).count do |nr|
63
+ SharedMemory.worker_deadline(nr).value > now
64
+ end
65
+ end
66
+
67
+ # Returns true if the server is shutting down.
68
+ # This can be useful to implement health check endpoints, so they
69
+ # can fail immediately after TERM/QUIT/INT was received by the master
70
+ # process.
71
+ # Otherwise they may succeed while Pitchfork is draining requests causing
72
+ # more requests to be sent.
73
+ def shutting_down?
74
+ SharedMemory.shutting_down?
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'raindrops'
4
+
5
+ module Pitchfork
6
+ module SharedMemory
7
+ extend self
8
+
9
+ PER_DROP = Raindrops::PAGE_SIZE / Raindrops::SIZE
10
+ CURRENT_GENERATION_OFFSET = 0
11
+ SHUTDOWN_OFFSET = 1
12
+ MOLD_TICK_OFFSET = 2
13
+ WORKER_TICK_OFFSET = 3
14
+
15
+ DROPS = [Raindrops.new(PER_DROP)]
16
+
17
+ def current_generation
18
+ DROPS[0][CURRENT_GENERATION_OFFSET]
19
+ end
20
+
21
+ def current_generation=(value)
22
+ DROPS[0][CURRENT_GENERATION_OFFSET] = value
23
+ end
24
+
25
+ def shutting_down!
26
+ DROPS[0][SHUTDOWN_OFFSET] = 1
27
+ end
28
+
29
+ def shutting_down?
30
+ DROPS[0][SHUTDOWN_OFFSET] > 0
31
+ end
32
+
33
+ class Field
34
+ def initialize(offset)
35
+ @drop = DROPS.fetch(offset / PER_DROP)
36
+ @offset = offset % PER_DROP
37
+ end
38
+
39
+ def value
40
+ @drop[@offset]
41
+ end
42
+
43
+ def value=(value)
44
+ @drop[@offset] = value
45
+ end
46
+ end
47
+
48
+ def mold_deadline
49
+ self[MOLD_TICK_OFFSET]
50
+ end
51
+
52
+ def worker_deadline(worker_nr)
53
+ self[WORKER_TICK_OFFSET + worker_nr]
54
+ end
55
+
56
+ def [](offset)
57
+ Field.new(offset)
58
+ end
59
+
60
+ # Since workers are created from another process, we have to
61
+ # pre-allocate the drops so they are shared between everyone.
62
+ #
63
+ # However this doesn't account for TTIN signals that increase the
64
+ # number of workers, but we should probably remove that feature too.
65
+ def preallocate_drops(workers_count)
66
+ 0.upto(((WORKER_TICK_OFFSET + workers_count) / PER_DROP.to_f).ceil) do |i|
67
+ DROPS[i] ||= Raindrops.new(PER_DROP)
68
+ end
69
+ end
70
+ end
71
+ end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Pitchfork
4
- VERSION = "0.5.0"
4
+ VERSION = "0.7.0"
5
5
  module Const
6
6
  UNICORN_VERSION = '6.1.0'
7
7
  end
@@ -1,5 +1,5 @@
1
1
  # -*- encoding: binary -*-
2
- require "raindrops"
2
+ require 'pitchfork/shared_memory'
3
3
 
4
4
  module Pitchfork
5
5
  # This class and its members can be considered a stable interface
@@ -24,7 +24,8 @@ module Pitchfork
24
24
  @exiting = false
25
25
  @requests_count = 0
26
26
  if nr
27
- build_raindrops(nr)
27
+ @deadline_drop = SharedMemory.worker_deadline(nr)
28
+ self.deadline = 0
28
29
  else
29
30
  promoted!
30
31
  end
@@ -47,7 +48,7 @@ module Pitchfork
47
48
  end
48
49
 
49
50
  def outdated?
50
- CURRENT_GENERATION_DROP[0] > @generation
51
+ SharedMemory.current_generation > @generation
51
52
  end
52
53
 
53
54
  def update(message)
@@ -73,7 +74,7 @@ module Pitchfork
73
74
  def finish_promotion(control_socket)
74
75
  message = Message::MoldReady.new(@nr, @pid, generation)
75
76
  control_socket.sendmsg(message)
76
- CURRENT_GENERATION_DROP[0] = @generation
77
+ SharedMemory.current_generation = @generation
77
78
  end
78
79
 
79
80
  def promote(generation)
@@ -92,8 +93,8 @@ module Pitchfork
92
93
  def promoted!
93
94
  @mold = true
94
95
  @nr = nil
95
- @drop_offset = 0
96
- @deadline_drop = MOLD_DROP
96
+ @deadline_drop = SharedMemory.mold_deadline
97
+ self.deadline = 0
97
98
  self
98
99
  end
99
100
 
@@ -173,20 +174,12 @@ module Pitchfork
173
174
 
174
175
  # called in the worker process
175
176
  def deadline=(value) # :nodoc:
176
- if mold?
177
- MOLD_DROP[0] = value
178
- else
179
- @deadline_drop[@drop_offset] = value
180
- end
177
+ @deadline_drop.value = value
181
178
  end
182
179
 
183
180
  # called in the master process
184
181
  def deadline # :nodoc:
185
- if mold?
186
- MOLD_DROP[0]
187
- else
188
- @deadline_drop[@drop_offset]
189
- end
182
+ @deadline_drop.value
190
183
  end
191
184
 
192
185
  def reset
@@ -199,12 +192,13 @@ module Pitchfork
199
192
 
200
193
  # called in both the master (reaping worker) and worker (SIGQUIT handler)
201
194
  def close # :nodoc:
195
+ self.deadline = 0
202
196
  @master.close if @master
203
197
  @to_io.close if @to_io
204
198
  end
205
199
 
206
200
  def create_socketpair!
207
- @to_io, @master = Pitchfork.socketpair
201
+ @to_io, @master = Info.keep_ios(Pitchfork.socketpair)
208
202
  end
209
203
 
210
204
  def after_fork_in_child
@@ -214,46 +208,24 @@ module Pitchfork
214
208
  private
215
209
 
216
210
  def pipe=(socket)
211
+ raise ArgumentError, "pipe can't be nil" unless socket
212
+ Info.keep_io(socket)
217
213
  @master = MessageSocket.new(socket)
218
214
  end
219
215
 
220
216
  def send_message_nonblock(message)
221
217
  success = false
218
+ return false unless @master
222
219
  begin
223
220
  case @master.sendmsg_nonblock(message, exception: false)
224
221
  when :wait_writable
225
222
  else
226
223
  success = true
227
224
  end
228
- rescue Errno::EPIPE, Errno::ECONNRESET, Errno::ECONNREFUSED
225
+ rescue Errno::EPIPE, Errno::ECONNRESET, Errno::ECONNREFUSED, Errno::ENOTCONN
229
226
  # worker will be reaped soon
230
227
  end
231
228
  success
232
229
  end
233
-
234
- MOLD_DROP = Raindrops.new(1)
235
- CURRENT_GENERATION_DROP = Raindrops.new(1)
236
- PER_DROP = Raindrops::PAGE_SIZE / Raindrops::SIZE
237
- TICK_DROPS = []
238
-
239
- class << self
240
- # Since workers are created from another process, we have to
241
- # pre-allocate the drops so they are shared between everyone.
242
- #
243
- # However this doesn't account for TTIN signals that increase the
244
- # number of workers, but we should probably remove that feature too.
245
- def preallocate_drops(workers_count)
246
- 0.upto(workers_count / PER_DROP) do |i|
247
- TICK_DROPS[i] = Raindrops.new(PER_DROP)
248
- end
249
- end
250
- end
251
-
252
- def build_raindrops(drop_nr)
253
- drop_index = drop_nr / PER_DROP
254
- @drop_offset = drop_nr % PER_DROP
255
- @deadline_drop = TICK_DROPS[drop_index] ||= Raindrops.new(PER_DROP)
256
- @deadline_drop[@drop_offset] = 0
257
- end
258
230
  end
259
231
  end
data/lib/pitchfork.rb CHANGED
@@ -26,9 +26,10 @@ module Pitchfork
26
26
  # application dispatch. This is always raised with an empty backtrace
27
27
  # since there is nothing in the application stack that is responsible
28
28
  # for client shutdowns/disconnects. This exception is visible to Rack
29
- # applications unless PrereadInput middleware is loaded. This
30
- # is a subclass of the standard EOFError class and applications should
31
- # not rescue it explicitly, but rescue EOFError instead.
29
+ # applications. This is a subclass of the standard EOFError class and
30
+ # applications should not rescue it explicitly, but rescue EOFError instead.
31
+ # Such an error is likely an indication that the reverse proxy in front
32
+ # of Pitchfork isn't properly buffering requests.
32
33
  ClientShutdown = Class.new(EOFError)
33
34
 
34
35
  BootFailure = Class.new(StandardError)
@@ -120,8 +121,11 @@ module Pitchfork
120
121
  end
121
122
  end
122
123
 
123
- def self.clean_fork(&block)
124
+ def self.clean_fork(setpgid: true, &block)
124
125
  if pid = Process.fork
126
+ if setpgid
127
+ Process.setpgid(pid, pid) # Make into a group leader
128
+ end
125
129
  return pid
126
130
  end
127
131
 
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.5.0
4
+ version: 0.7.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: 2023-06-21 00:00:00.000000000 Z
11
+ date: 2023-08-18 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: raindrops
@@ -101,12 +101,13 @@ files:
101
101
  - lib/pitchfork/http_parser.rb
102
102
  - lib/pitchfork/http_response.rb
103
103
  - lib/pitchfork/http_server.rb
104
+ - lib/pitchfork/info.rb
104
105
  - lib/pitchfork/launcher.rb
105
106
  - lib/pitchfork/mem_info.rb
106
107
  - lib/pitchfork/message.rb
107
- - lib/pitchfork/preread_input.rb
108
108
  - lib/pitchfork/refork_condition.rb
109
109
  - lib/pitchfork/select_waiter.rb
110
+ - lib/pitchfork/shared_memory.rb
110
111
  - lib/pitchfork/socket_helper.rb
111
112
  - lib/pitchfork/soft_timeout.rb
112
113
  - lib/pitchfork/stream_input.rb
@@ -1,33 +0,0 @@
1
- # -*- encoding: binary -*-
2
-
3
- module Pitchfork
4
- # This middleware is used to ensure input is buffered to memory
5
- # or disk (depending on size) before the application is dispatched
6
- # by entirely consuming it (from TeeInput) beforehand.
7
- #
8
- # Usage (in config.ru):
9
- #
10
- # require 'pitchfork/preread_input'
11
- # if defined?(Pitchfork)
12
- # use Pitchfork::PrereadInput
13
- # end
14
- # run YourApp.new
15
- class PrereadInput
16
-
17
- # :stopdoc:
18
- def initialize(app)
19
- @app = app
20
- end
21
-
22
- def call(env)
23
- buf = ""
24
- input = env["rack.input"]
25
- if input.respond_to?(:rewind)
26
- true while input.read(16384, buf)
27
- input.rewind
28
- end
29
- @app.call(env)
30
- end
31
- # :startdoc:
32
- end
33
- end