pitchfork 0.4.1 → 0.6.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: c0b28e12be38614fe1056d54a89d58882d59100d2982fac86d844574c0c3c196
4
- data.tar.gz: 64bf5dfc0d7fc843add452d7737137c96d466f550a95cfd50abe2d0e12b0caad
3
+ metadata.gz: b3a7c87f91b13b474f5c204a251c380cd091190f3ecfca8ac3107d697e883f12
4
+ data.tar.gz: '00116558f20d20e4aad0cda6a1866fc7104e3712df27b1668470c52346812258'
5
5
  SHA512:
6
- metadata.gz: 6bf6a9ffeed660e5cbacf4a3d86c536f28472f9842cf79ad15cd1a1ac2fc7bab351c19040c775e080a2ab4fd0c83607c455b691701d30c308ac9050c968b9ebf
7
- data.tar.gz: 789e03e4b35df56c3eb91eb7836aa865f6948e12e959ea196c0fbd757dc6b69be802f005547a6e52ae6ca1932f8a4b0c9cba74a495297d9e138e915ebdc3937f
6
+ metadata.gz: 2b51ac98fdc2fc2d83a72c6a55c5e26e42996b43bb6e33815e8958c6f5c729f11b17676b9b8bdb098d75b53ebac5bc98114f2bfa864550b3111162824ae6597f
7
+ data.tar.gz: 5bbf64cf105b20930fd6627001abfb610c9f87ad8510b35a7ab1fcb5c5ccd5437eec011eb6253aefdf87f7d7bc353ac2abbaeb30ac6afad91ddd48fbe81f475c
data/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # Unreleased
2
2
 
3
+ # 0.6.0
4
+
5
+ - Expose `Pitchfork::Info.workers_count` and `.live_workers_count` to be consumed by application health checks.
6
+ - Implement `before_worker_exit` callback.
7
+ - Make each mold and worker a process group leader.
8
+ - Get rid of `Pitchfork::PrereadInput`.
9
+ - Add `Pitchfork.shutting_down?` to allow health check endpoints to fail sooner on graceful shutdowns.
10
+ - Treat `TERM` as graceful shutdown rather than quick shutdown.
11
+ - Implement `after_worker_hard_timeout` callback.
12
+
13
+ # 0.5.0
14
+
15
+ - Added a soft timeout in addition to the historical Unicorn hard timeout.
16
+ On soft timeout, the `after_worker_timeout` callback is invoked.
17
+ - Implement `after_request_complete` callback.
18
+
3
19
  # 0.4.1
4
20
 
5
21
  - Avoid a Rack 3 deprecation warning.
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- pitchfork (0.4.1)
4
+ pitchfork (0.6.0)
5
5
  rack (>= 2.0)
6
6
  raindrops (~> 0.7)
7
7
 
@@ -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
@@ -70,5 +70,5 @@ handle network/server failures.
70
70
  The `timeout` mechanism in pitchfork is an extreme solution that should
71
71
  be avoided whenever possible.
72
72
  It will help preserve the platform if your application or a dependency
73
- has a bug that cause it to either get stuck or two slow, but it is not a
73
+ has a bug that cause it to either get stuck or be too slow, but it is not a
74
74
  solution to such bugs, merely a mitigation.
@@ -173,33 +173,55 @@ The following options may be specified (but are generally not needed):
173
173
  ### `timeout`
174
174
 
175
175
  ```ruby
176
- timeout 10
176
+ timeout 10, cleanup: 3
177
177
  ```
178
178
 
179
- Sets the timeout of worker processes to a number of seconds.
180
- Workers handling the request/app.call/response cycle taking longer than
181
- this time period will be forcibly killed (via `SIGKILL`).
179
+ Sets the timeout for worker processes to a number of seconds.
182
180
 
183
- This timeout mecanism shouldn't be routinely relying on, and should
181
+ Note that Pitchfork has two layers of timeout.
182
+
183
+ A first "soft" timeout will invoke the `after_worker_timeout` from
184
+ within the worker (but from a background thread) and then call `exit`
185
+ to terminate the worker cleanly.
186
+
187
+ The second "hard" timeout, is the sum of `timeout` and `cleanup`.
188
+ Workers taking longer than this time period to be ready to handle a new
189
+ request will be forcibly killed (via `SIGKILL`).
190
+
191
+ Neither of these timeout mecanisms should be routinely relied on, and should
184
192
  instead be considered as a last line of defense in case you application
185
193
  is impacted by bugs causing unexpectedly slow response time, or fully stuck
186
194
  processes.
187
195
 
196
+ If some of the application endpoints require an unreasonably large timeout,
197
+ rather than to increase the global application timeout, it is possible to
198
+ adjust it on a per request basis via the rack request environment:
199
+
200
+ ```ruby
201
+ class MyMiddleware
202
+ def call(env)
203
+ if slow_endpoint?(env)
204
+ # Give 10 more seconds
205
+ env["pitchfork.timeout"]&.extend_deadline(10)
206
+ end
207
+ @app.call(env)
208
+ end
209
+ end
210
+ ```
211
+
188
212
  Make sure to read the guide on [application timeouts](Application_Timeouts.md).
189
213
 
190
- This configuration defaults to a (too) generous 20 seconds, it is
191
- highly recommended to set a stricter one based on your application
192
- profile.
214
+ This configuration defaults to a (too) generous 20 seconds for the soft timeout
215
+ and an extra 2 seconds for the hard timeout. It is highly recommended to set a
216
+ stricter one based on your application profile.
193
217
 
194
- This timeout is enforced by the master process itself and not subject
195
- to the scheduling limitations by the worker process.
196
218
  Due the low-complexity, low-overhead implementation, timeouts of less
197
219
  than 3.0 seconds can be considered inaccurate and unsafe.
198
220
 
199
221
  For running Pitchfork behind nginx, it is recommended to set
200
222
  "fail_timeout=0" for in your nginx configuration like this
201
223
  to have nginx always retry backends that may have had workers
202
- SIGKILL-ed due to timeouts.
224
+ exit or be SIGKILL-ed due to timeouts.
203
225
 
204
226
  ```
205
227
  upstream pitchfork_backend {
@@ -284,6 +306,51 @@ after_worker_ready do |server, worker|
284
306
  end
285
307
  ```
286
308
 
309
+ ### `after_worker_timeout`
310
+
311
+ Called by the worker process when the request timeout is elapsed:
312
+
313
+ ```ruby
314
+ after_worker_timeout do |server, worker, timeout_info|
315
+ timeout_info.copy_thread_variables!
316
+ timeout_info.thread.kill
317
+ server.logger.error("Request timed out: #{timeout_info.rack_env.inspect}")
318
+ $stderr.puts timeout_info.thread.backtrace
319
+ end
320
+ ```
321
+
322
+ Note that this callback is invoked from a different thread. You can access the
323
+ main thread via `timeout_info.thread`, as well as the rack environment via `timeout_info.rack_env`.
324
+
325
+ If you need to invoke cleanup code that rely on thread local state, you can copy
326
+ that state with `timeout_info.copy_thread_variables!`, but it's best avoided as the
327
+ thread local state could contain thread unsafe objects.
328
+
329
+ Also note that at this stage, the thread is still alive, if your callback does
330
+ substantial work, you may want to kill the thread.
331
+
332
+ After the callback is executed the worker will exit with status `0`.
333
+
334
+ It is recommended not to do slow operations in this callback, but if you
335
+ really have to, make sure to configure the `cleanup` timeout so that the
336
+ callback has time to complete before the "hard" timeout triggers.
337
+ By default the cleanup timeout is 2 seconds.
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
+
287
354
  ### `after_worker_exit`
288
355
 
289
356
  Called in the master process after a worker exits.
@@ -297,6 +364,20 @@ after_worker_exit do |server, worker, status|
297
364
  end
298
365
  ```
299
366
 
367
+ ### `after_request_complete`
368
+
369
+ Called in the worker processes after a request has completed.
370
+
371
+ Can be used for out of band work, or to exit unhealthy workers.
372
+
373
+ ```ruby
374
+ after_request_complete do |server, worker|
375
+ if something_wrong?
376
+ exit
377
+ end
378
+ end
379
+ ```
380
+
300
381
  ## Reforking
301
382
 
302
383
  ### `refork_after`
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,9 +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
- :timeout => 20,
31
- :logger => Logger.new($stderr),
34
+ :soft_timeout => 20,
35
+ :cleanup_timeout => 2,
36
+ :timeout => 22,
37
+ :logger => default_logger,
32
38
  :worker_processes => 1,
33
39
  :after_worker_fork => lambda { |server, worker|
34
40
  server.logger.info("worker=#{worker.nr} gen=#{worker.generation} pid=#{$$} spawned")
@@ -36,6 +42,7 @@ module Pitchfork
36
42
  :after_mold_fork => lambda { |server, worker|
37
43
  server.logger.info("mold gen=#{worker.generation} pid=#{$$} spawned")
38
44
  },
45
+ :before_worker_exit => nil,
39
46
  :after_worker_exit => lambda { |server, worker, status|
40
47
  m = if worker.nil?
41
48
  "repead unknown process (#{status.inspect})"
@@ -53,6 +60,9 @@ module Pitchfork
53
60
  :after_worker_ready => lambda { |server, worker|
54
61
  server.logger.info("worker=#{worker.nr} gen=#{worker.generation} ready")
55
62
  },
63
+ :after_worker_timeout => nil,
64
+ :after_worker_hard_timeout => nil,
65
+ :after_request_complete => nil,
56
66
  :early_hints => false,
57
67
  :refork_condition => nil,
58
68
  :check_client_connection => false,
@@ -131,15 +141,30 @@ module Pitchfork
131
141
  set_hook(:after_worker_ready, block_given? ? block : args[0])
132
142
  end
133
143
 
144
+ def after_worker_timeout(*args, &block)
145
+ set_hook(:after_worker_timeout, block_given? ? block : args[0], 3)
146
+ end
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
+
134
156
  def after_worker_exit(*args, &block)
135
157
  set_hook(:after_worker_exit, block_given? ? block : args[0], 3)
136
158
  end
137
159
 
138
- def timeout(seconds)
139
- set_int(:timeout, seconds, 3)
140
- # POSIX says 31 days is the smallest allowed maximum timeout for select()
141
- max = 30 * 60 * 60 * 24
142
- set[:timeout] = seconds > max ? max : seconds
160
+ def after_request_complete(*args, &block)
161
+ set_hook(:after_request_complete, block_given? ? block : args[0])
162
+ end
163
+
164
+ def timeout(seconds, cleanup: 2)
165
+ soft_timeout = set_int(:soft_timeout, seconds, 3)
166
+ cleanup_timeout = set_int(:cleanup_timeout, cleanup, 2)
167
+ set_int(:timeout, soft_timeout + cleanup_timeout, 5)
143
168
  end
144
169
 
145
170
  def worker_processes(nr)
@@ -1,6 +1,9 @@
1
1
  # -*- encoding: binary -*-
2
2
  require 'pitchfork/pitchfork_http'
3
3
  require 'pitchfork/flock'
4
+ require 'pitchfork/soft_timeout'
5
+ require 'pitchfork/shared_memory'
6
+ require 'pitchfork/info'
4
7
 
5
8
  module Pitchfork
6
9
  # This is the process manager of Pitchfork. This manages worker
@@ -8,13 +11,76 @@ module Pitchfork
8
11
  # Listener sockets are started in the master process and shared with
9
12
  # forked worker children.
10
13
  class HttpServer
14
+ class TimeoutHandler
15
+ class Info
16
+ attr_reader :thread, :rack_env
17
+
18
+ def initialize(thread, rack_env)
19
+ @thread = thread
20
+ @rack_env = rack_env
21
+ end
22
+
23
+ def copy_thread_variables!
24
+ current_thread = Thread.current
25
+ @thread.keys.each do |key|
26
+ current_thread[key] = @thread[key]
27
+ end
28
+ @thread.thread_variables.each do |variable|
29
+ current_thread.thread_variable_set(variable, @thread.thread_variable_get(variable))
30
+ end
31
+ end
32
+ end
33
+
34
+ attr_writer :rack_env, :timeout_request # :nodoc:
35
+
36
+ def initialize(server, worker, callback) # :nodoc:
37
+ @server = server
38
+ @worker = worker
39
+ @callback = callback
40
+ @rack_env = nil
41
+ @timeout_request = nil
42
+ end
43
+
44
+ def inspect
45
+ "#<Pitchfork::HttpServer::TimeoutHandler##{object_id}>"
46
+ end
47
+
48
+ def call(original_thread) # :nodoc:
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)
56
+ end
57
+ @server.worker_exit(@worker)
58
+ end
59
+
60
+ def finished # :nodoc:
61
+ @timeout_request.finished
62
+ end
63
+
64
+ def deadline
65
+ @timeout_request.deadline
66
+ end
67
+
68
+ def extend_deadline(extra_time)
69
+ extra_time = Integer(extra_time)
70
+ @worker.deadline += extra_time
71
+ @timeout_request.extend_deadline(extra_time)
72
+ self
73
+ end
74
+ end
75
+
11
76
  # :stopdoc:
12
- attr_accessor :app, :timeout, :worker_processes,
77
+ attr_accessor :app, :timeout, :soft_timeout, :cleanup_timeout, :worker_processes,
13
78
  :after_worker_fork, :after_mold_fork,
14
79
  :listener_opts, :children,
15
80
  :orig_app, :config, :ready_pipe,
16
81
  :default_middleware, :early_hints
17
- attr_writer :after_worker_exit, :after_worker_ready, :refork_condition
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
18
84
 
19
85
  attr_reader :logger
20
86
  include Pitchfork::SocketHelper
@@ -100,7 +166,9 @@ module Pitchfork
100
166
  # list of signals we care about and trap in master.
101
167
  @queue_sigs = [
102
168
  :QUIT, :INT, :TERM, :USR2, :TTIN, :TTOU ]
103
- Worker.preallocate_drops(worker_processes)
169
+
170
+ Info.workers_count = worker_processes
171
+ SharedMemory.preallocate_drops(worker_processes)
104
172
  end
105
173
 
106
174
  # Runs the thing. Returns self so you can run join on it
@@ -249,7 +317,7 @@ module Pitchfork
249
317
  if REFORKING_AVAILABLE && @respawn && @children.molds.empty?
250
318
  logger.info("No mold alive, shutting down")
251
319
  @exit_status = 1
252
- @sig_queue << :QUIT
320
+ @sig_queue << :TERM
253
321
  @respawn = false
254
322
  end
255
323
 
@@ -269,10 +337,12 @@ module Pitchfork
269
337
  end
270
338
 
271
339
  master_sleep(sleep_time) if sleep
272
- when :QUIT # graceful shutdown
273
- logger.info "QUIT received, starting graceful shutdown"
340
+ when :QUIT, :TERM # graceful shutdown
341
+ SharedMemory.shutting_down!
342
+ logger.info "#{message} received, starting graceful shutdown"
274
343
  return StopIteration
275
- when :TERM, :INT # immediate shutdown
344
+ when :INT # immediate shutdown
345
+ SharedMemory.shutting_down!
276
346
  logger.info "#{message} received, starting immediate shutdown"
277
347
  stop(false)
278
348
  return StopIteration
@@ -296,7 +366,7 @@ module Pitchfork
296
366
  logger.info("mold pid=#{new_mold.pid} gen=#{new_mold.generation} ready")
297
367
  old_molds.each do |old_mold|
298
368
  logger.info("Terminating old mold pid=#{old_mold.pid} gen=#{old_mold.generation}")
299
- old_mold.soft_kill(:QUIT)
369
+ old_mold.soft_kill(:TERM)
300
370
  end
301
371
  else
302
372
  logger.error("Unexpected message in sig_queue #{message.inspect}")
@@ -307,14 +377,15 @@ module Pitchfork
307
377
  # Terminates all workers, but does not exit master process
308
378
  def stop(graceful = true)
309
379
  @respawn = false
380
+ SharedMemory.shutting_down!
310
381
  wait_for_pending_workers
311
382
  self.listeners = []
312
383
  limit = Pitchfork.time_now + timeout
313
384
  until @children.workers.empty? || Pitchfork.time_now > limit
314
385
  if graceful
315
- soft_kill_each_child(:QUIT)
386
+ soft_kill_each_child(:TERM)
316
387
  else
317
- kill_each_child(:TERM)
388
+ kill_each_child(:INT)
318
389
  end
319
390
  if monitor_loop(false) == StopIteration
320
391
  return StopIteration
@@ -324,6 +395,17 @@ module Pitchfork
324
395
  @promotion_lock.unlink
325
396
  end
326
397
 
398
+ def worker_exit(worker)
399
+ if @before_worker_exit
400
+ begin
401
+ @before_worker_exit.call(self, worker)
402
+ rescue => error
403
+ Pitchfork.log_error(logger, "before_worker_exit error", error)
404
+ end
405
+ end
406
+ Process.exit
407
+ end
408
+
327
409
  def rewindable_input
328
410
  Pitchfork::HttpParser.input_class.method_defined?(:rewind)
329
411
  end
@@ -394,25 +476,39 @@ module Pitchfork
394
476
 
395
477
  # forcibly terminate all workers that haven't checked in in timeout seconds. The timeout is implemented using an unlinked File
396
478
  def murder_lazy_workers
397
- next_sleep = @timeout - 1
398
479
  now = Pitchfork.time_now(true)
480
+ next_sleep = @timeout - 1
481
+
399
482
  @children.workers.each do |worker|
400
- tick = worker.tick
401
- 0 == tick and next # skip workers that haven't processed any clients
402
- diff = now - tick
403
- tmp = @timeout - diff
404
- if tmp >= 0
405
- next_sleep > tmp and next_sleep = tmp
483
+ deadline = worker.deadline
484
+ if 0 == deadline # worker is idle
406
485
  next
486
+ elsif deadline > now # worker still has time
487
+ time_left = deadline - now
488
+ if time_left < next_sleep
489
+ next_sleep = time_left
490
+ end
491
+ next
492
+ else # worker is out of time
493
+ next_sleep = 0
494
+ if worker.mold?
495
+ logger.error "mold pid=#{worker.pid} timed out, killing"
496
+ else
497
+ logger.error "worker=#{worker.nr} pid=#{worker.pid} timed out, killing"
498
+ end
499
+
500
+ if @after_worker_hard_timeout
501
+ begin
502
+ @after_worker_hard_timeout.call(self, worker)
503
+ rescue => error
504
+ Pitchfork.log_error(@logger, "after_worker_hard_timeout callback", error)
505
+ end
506
+ end
507
+
508
+ kill_worker(:KILL, worker.pid) # take no prisoners for hard timeout violations
407
509
  end
408
- next_sleep = 0
409
- if worker.mold?
410
- logger.error "mold pid=#{worker.pid} timeout (#{diff}s > #{@timeout}s), killing"
411
- else
412
- logger.error "worker=#{worker.nr} pid=#{worker.pid} timeout (#{diff}s > #{@timeout}s), killing"
413
- end
414
- kill_worker(:KILL, worker.pid) # take no prisoners for timeout violations
415
510
  end
511
+
416
512
  next_sleep <= 0 ? 1 : next_sleep
417
513
  end
418
514
 
@@ -451,6 +547,7 @@ module Pitchfork
451
547
 
452
548
  after_fork_internal
453
549
  worker_loop(worker)
550
+ worker_exit(worker)
454
551
  end
455
552
 
456
553
  worker
@@ -509,7 +606,7 @@ module Pitchfork
509
606
  def maintain_worker_count
510
607
  (off = @children.workers_count - worker_processes) == 0 and return
511
608
  off < 0 and return spawn_missing_workers
512
- @children.each_worker { |w| w.nr >= worker_processes and w.soft_kill(:QUIT) }
609
+ @children.each_worker { |w| w.nr >= worker_processes and w.soft_kill(:TERM) }
513
610
  end
514
611
 
515
612
  def restart_outdated_workers
@@ -526,7 +623,7 @@ module Pitchfork
526
623
  outdated_workers = @children.workers.select { |w| !w.exiting? && w.generation < @children.mold.generation }
527
624
  outdated_workers.each do |worker|
528
625
  logger.info("worker=#{worker.nr} pid=#{worker.pid} gen=#{worker.generation} restarting")
529
- worker.soft_kill(:QUIT)
626
+ worker.soft_kill(:TERM)
530
627
  end
531
628
  end
532
629
  end
@@ -577,9 +674,12 @@ module Pitchfork
577
674
 
578
675
  # once a client is accepted, it is processed in its entirety here
579
676
  # in 3 easy steps: read request, call app, write app response
580
- def process_client(client)
677
+ def process_client(client, timeout_handler)
678
+ env = nil
581
679
  @request = Pitchfork::HttpParser.new
582
680
  env = @request.read(client)
681
+ timeout_handler.rack_env = env
682
+ env["pitchfork.timeout"] = timeout_handler
583
683
 
584
684
  if early_hints
585
685
  env["rack.early_hints"] = lambda do |headers|
@@ -616,6 +716,7 @@ module Pitchfork
616
716
  handle_error(client, e)
617
717
  ensure
618
718
  env["rack.after_reply"].each(&:call) if env
719
+ timeout_handler.finished
619
720
  end
620
721
 
621
722
  def nuke_listeners!(readers)
@@ -632,7 +733,7 @@ module Pitchfork
632
733
  def init_worker_process(worker)
633
734
  worker.reset
634
735
  worker.register_to_master(@control_socket[1])
635
- # we'll re-trap :QUIT later for graceful shutdown iff we accept clients
736
+ # we'll re-trap :QUIT and :TERM later for graceful shutdown iff we accept clients
636
737
  exit_sigs = [ :QUIT, :TERM, :INT ]
637
738
  exit_sigs.each { |sig| trap(sig) { exit!(0) } }
638
739
  exit!(0) if (@sig_queue & exit_sigs)[0]
@@ -650,6 +751,7 @@ module Pitchfork
650
751
  readers = LISTENERS.dup
651
752
  readers << worker
652
753
  trap(:QUIT) { nuke_listeners!(readers) }
754
+ trap(:TERM) { nuke_listeners!(readers) }
653
755
  readers
654
756
  end
655
757
 
@@ -658,6 +760,7 @@ module Pitchfork
658
760
  after_mold_fork.call(self, mold)
659
761
  readers = [mold]
660
762
  trap(:QUIT) { nuke_listeners!(readers) }
763
+ trap(:TERM) { nuke_listeners!(readers) }
661
764
  readers
662
765
  end
663
766
 
@@ -684,7 +787,7 @@ module Pitchfork
684
787
 
685
788
  while readers[0]
686
789
  begin
687
- worker.tick = Pitchfork.time_now(true)
790
+ worker.update_deadline(@timeout)
688
791
  while sock = ready.shift
689
792
  # Pitchfork::Worker#accept_nonblock is not like accept(2) at all,
690
793
  # but that will return false
@@ -697,15 +800,16 @@ module Pitchfork
697
800
  when Message
698
801
  worker.update(client)
699
802
  else
700
- process_client(client)
803
+ process_client(client, prepare_timeout(worker))
804
+ @after_request_complete&.call(self, worker)
701
805
  worker.increment_requests_count
702
806
  end
703
- worker.tick = Pitchfork.time_now(true)
807
+ worker.update_deadline(@timeout)
704
808
  end
705
809
  end
706
810
 
707
- # timeout so we can .tick and keep parent from SIGKILL-ing us
708
- worker.tick = Pitchfork.time_now(true)
811
+ # timeout so we can update .deadline and keep parent from SIGKILL-ing us
812
+ worker.update_deadline(@timeout)
709
813
 
710
814
  if @refork_condition && !worker.outdated?
711
815
  if @refork_condition.met?(worker, logger)
@@ -754,7 +858,7 @@ module Pitchfork
754
858
 
755
859
  while readers[0]
756
860
  begin
757
- mold.tick = Pitchfork.time_now(true)
861
+ mold.update_deadline(@timeout)
758
862
  while sock = ready.shift
759
863
  # Pitchfork::Worker#accept_nonblock is not like accept(2) at all,
760
864
  # but that will return false
@@ -774,7 +878,7 @@ module Pitchfork
774
878
  end
775
879
 
776
880
  # timeout so we can .tick and keep parent from SIGKILL-ing us
777
- mold.tick = Pitchfork.time_now(true)
881
+ mold.update_deadline(@timeout)
778
882
  waiter.get_readers(ready, readers, @timeout * 500) # to milliseconds, but halved
779
883
  rescue => e
780
884
  Pitchfork.log_error(@logger, "mold loop error", e) if readers[0]
@@ -832,5 +936,11 @@ module Pitchfork
832
936
  listeners.each { |addr| listen(addr) }
833
937
  raise ArgumentError, "no listeners" if LISTENERS.empty?
834
938
  end
939
+
940
+ def prepare_timeout(worker)
941
+ handler = TimeoutHandler.new(self, worker, @after_worker_timeout)
942
+ handler.timeout_request = SoftTimeout.request(@soft_timeout, handler)
943
+ handler
944
+ end
835
945
  end
836
946
  end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'pitchfork/shared_memory'
4
+
5
+ module Pitchfork
6
+ module Info
7
+ @workers_count = 0
8
+
9
+ class << self
10
+ attr_accessor :workers_count
11
+
12
+ def live_workers_count
13
+ now = Pitchfork.time_now(true)
14
+ (0...workers_count).count do |nr|
15
+ SharedMemory.worker_deadline(nr).value > now
16
+ end
17
+ end
18
+
19
+ # Returns true if the server is shutting down.
20
+ # This can be useful to implement health check endpoints, so they
21
+ # can fail immediately after TERM/QUIT/INT was received by the master
22
+ # process.
23
+ # Otherwise they may succeed while Pitchfork is draining requests causing
24
+ # more requests to be sent.
25
+ def shutting_down?
26
+ SharedMemory.shutting_down?
27
+ end
28
+ end
29
+ end
30
+ 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
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pitchfork
4
+ # :stopdoc:
5
+ module SoftTimeout
6
+ extend self
7
+
8
+ CONDVAR = ConditionVariable.new
9
+ QUEUE = Queue.new
10
+ QUEUE_MUTEX = Mutex.new
11
+ TIMEOUT_THREAD_MUTEX = Mutex.new
12
+ @timeout_thread = nil
13
+ private_constant :CONDVAR, :QUEUE, :QUEUE_MUTEX, :TIMEOUT_THREAD_MUTEX
14
+
15
+ class Request
16
+ attr_reader :deadline, :thread
17
+
18
+ def initialize(thread, timeout, block)
19
+ @thread = thread
20
+ @deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
21
+ @block = block
22
+
23
+ @mutex = Mutex.new
24
+ @done = false # protected by @mutex
25
+ end
26
+
27
+ def extend_deadline(timeout)
28
+ @deadline += timeout
29
+ QUEUE_MUTEX.synchronize do
30
+ CONDVAR.signal
31
+ end
32
+ self
33
+ end
34
+
35
+ def done?
36
+ @mutex.synchronize do
37
+ @done
38
+ end
39
+ end
40
+
41
+ def expired?(now)
42
+ now >= @deadline
43
+ end
44
+
45
+ def interrupt
46
+ @mutex.synchronize do
47
+ unless @done
48
+ begin
49
+ @block.call(@thread)
50
+ ensure
51
+ @done = true
52
+ end
53
+ end
54
+ end
55
+ end
56
+
57
+ def finished
58
+ @mutex.synchronize do
59
+ @done = true
60
+ end
61
+ end
62
+ end
63
+
64
+ def request(sec, callback)
65
+ ensure_timeout_thread_created
66
+ request = Request.new(Thread.current, sec, callback)
67
+ QUEUE_MUTEX.synchronize do
68
+ QUEUE << request
69
+ CONDVAR.signal
70
+ end
71
+ request
72
+ end
73
+
74
+ private
75
+
76
+ def create_timeout_thread
77
+ watcher = Thread.new do
78
+ requests = []
79
+ while true
80
+ until QUEUE.empty? and !requests.empty? # wait to have at least one request
81
+ req = QUEUE.pop
82
+ requests << req unless req.done?
83
+ end
84
+ closest_deadline = requests.min_by(&:deadline).deadline
85
+
86
+ now = 0.0
87
+ QUEUE_MUTEX.synchronize do
88
+ while (now = Process.clock_gettime(Process::CLOCK_MONOTONIC)) < closest_deadline and QUEUE.empty?
89
+ CONDVAR.wait(QUEUE_MUTEX, closest_deadline - now)
90
+ end
91
+ end
92
+
93
+ requests.each do |req|
94
+ req.interrupt if req.expired?(now)
95
+ end
96
+ requests.reject!(&:done?)
97
+ end
98
+ end
99
+ watcher.name = "Pitchfork::Timeout"
100
+ watcher
101
+ end
102
+
103
+ def ensure_timeout_thread_created
104
+ unless @timeout_thread and @timeout_thread.alive?
105
+ TIMEOUT_THREAD_MUTEX.synchronize do
106
+ unless @timeout_thread and @timeout_thread.alive?
107
+ @timeout_thread = create_timeout_thread
108
+ end
109
+ end
110
+ end
111
+ end
112
+ end
113
+ end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Pitchfork
4
- VERSION = "0.4.1"
4
+ VERSION = "0.6.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
- @tick_drop = MOLD_DROP
96
+ @deadline_drop = SharedMemory.mold_deadline
97
+ self.deadline = 0
97
98
  self
98
99
  end
99
100
 
@@ -167,22 +168,18 @@ module Pitchfork
167
168
  super || (!@nr.nil? && @nr == other)
168
169
  end
169
170
 
171
+ def update_deadline(timeout)
172
+ self.deadline = Pitchfork.time_now(true) + timeout
173
+ end
174
+
170
175
  # called in the worker process
171
- def tick=(value) # :nodoc:
172
- if mold?
173
- MOLD_DROP[0] = value
174
- else
175
- @tick_drop[@drop_offset] = value
176
- end
176
+ def deadline=(value) # :nodoc:
177
+ @deadline_drop.value = value
177
178
  end
178
179
 
179
180
  # called in the master process
180
- def tick # :nodoc:
181
- if mold?
182
- MOLD_DROP[0]
183
- else
184
- @tick_drop[@drop_offset]
185
- end
181
+ def deadline # :nodoc:
182
+ @deadline_drop.value
186
183
  end
187
184
 
188
185
  def reset
@@ -195,6 +192,7 @@ module Pitchfork
195
192
 
196
193
  # called in both the master (reaping worker) and worker (SIGQUIT handler)
197
194
  def close # :nodoc:
195
+ self.deadline = 0
198
196
  @master.close if @master
199
197
  @to_io.close if @to_io
200
198
  end
@@ -221,35 +219,10 @@ module Pitchfork
221
219
  else
222
220
  success = true
223
221
  end
224
- rescue Errno::EPIPE, Errno::ECONNRESET
222
+ rescue Errno::EPIPE, Errno::ECONNRESET, Errno::ECONNREFUSED, Errno::ENOTCONN
225
223
  # worker will be reaped soon
226
224
  end
227
225
  success
228
226
  end
229
-
230
- MOLD_DROP = Raindrops.new(1)
231
- CURRENT_GENERATION_DROP = Raindrops.new(1)
232
- PER_DROP = Raindrops::PAGE_SIZE / Raindrops::SIZE
233
- TICK_DROPS = []
234
-
235
- class << self
236
- # Since workers are created from another process, we have to
237
- # pre-allocate the drops so they are shared between everyone.
238
- #
239
- # However this doesn't account for TTIN signals that increase the
240
- # number of workers, but we should probably remove that feature too.
241
- def preallocate_drops(workers_count)
242
- 0.upto(workers_count / PER_DROP) do |i|
243
- TICK_DROPS[i] = Raindrops.new(PER_DROP)
244
- end
245
- end
246
- end
247
-
248
- def build_raindrops(drop_nr)
249
- drop_index = drop_nr / PER_DROP
250
- @drop_offset = drop_nr % PER_DROP
251
- @tick_drop = TICK_DROPS[drop_index] ||= Raindrops.new(PER_DROP)
252
- @tick_drop[@drop_offset] = 0
253
- end
254
227
  end
255
228
  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.4.1
4
+ version: 0.6.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-19 00:00:00.000000000 Z
11
+ date: 2023-07-18 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: raindrops
@@ -101,13 +101,15 @@ 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
112
+ - lib/pitchfork/soft_timeout.rb
111
113
  - lib/pitchfork/stream_input.rb
112
114
  - lib/pitchfork/tee_input.rb
113
115
  - lib/pitchfork/tmpio.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