pitchfork 0.10.0 → 0.11.1

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: 0f23c677bd64eb8ca7a040cb2d41dab4047e05ae849da16af8e537748bfb6dbe
4
- data.tar.gz: 890bac5b67c23d6f0aacf0d9130999cf69ac0bc5a2f1ea06bdd142d67c0606ac
3
+ metadata.gz: 5dc129b2e6e6e940be0f9e388400a376614a65a125a64b68c73b919744c433b3
4
+ data.tar.gz: b12e1d5f360edc7159567060c095b40510e8e89c54dae058882742c29c830f78
5
5
  SHA512:
6
- metadata.gz: 39c33bbbe865ba22bebc8b6b1e823f71d0ba6b2787f06af26f0ffe3d4251c781850826fcbcdf1dc13b1734919770e35dc57815d2de057f18d97ba354f247a3e7
7
- data.tar.gz: 94e5ae39e129af1fd950c9a77e094bac5bda586f7358115fd8cf25bb3c6ee2079bb4654ebd4e6ed5e9ff498b461559da07d7b44acc0147fcac5c9a4695b7c62c
6
+ metadata.gz: 4a43b0204dc0e77a4d28007dbfd89e9645724a4198ea6ec7ffa0895aefaf075bd42d4303c37d0f7e61afb550b665b78ab6b22ca9f934ef2d3721883acf0a68cd
7
+ data.tar.gz: 7eb41807d6971ac948ee264b1f70fd2e16afcf8f289655073c876b752a53a405b9705e97509f5be82c519ee12694ecf1910b18a20cce8aad3ce9a4ffd3af9ac6
@@ -10,7 +10,7 @@ jobs:
10
10
  fail-fast: false
11
11
  matrix:
12
12
  os: ["ubuntu-latest"]
13
- ruby: ["3.2", "3.1", "3.0", "2.7", "2.6"]
13
+ ruby: ["ruby-head", "3.3", "3.2", "3.1", "3.0", "2.7", "2.6"]
14
14
  runs-on: ubuntu-latest
15
15
  steps:
16
16
  - name: Check out code
data/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Unreleased
2
2
 
3
+ # 0.11.1
4
+
5
+ - Fix Ruby 3.4-dev compatibility.
6
+
7
+ # 0.11.0
8
+
9
+ - Drop invalid response headers.
10
+ - Enforce `spawn_timeout` for molds too.
11
+ - Gracefully shutdown the server if the mold appear to be corrupted (#79).
12
+ - Add more information in proctitle when forking a new sibbling.
13
+ - Add a `before_fork` callback called before forking new molds and new workers.
14
+
3
15
  # 0.10.0
4
16
 
5
17
  - Include requests count in workers proctitle.
data/Gemfile.lock CHANGED
@@ -1,18 +1,18 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- pitchfork (0.10.0)
4
+ pitchfork (0.11.1)
5
5
  rack (>= 2.0)
6
6
  raindrops (~> 0.7)
7
7
 
8
8
  GEM
9
9
  remote: https://rubygems.org/
10
10
  specs:
11
- minitest (5.15.0)
12
- nio4r (2.5.9)
13
- puma (6.3.1)
11
+ minitest (5.22.2)
12
+ nio4r (2.7.0)
13
+ puma (6.4.2)
14
14
  nio4r (~> 2.0)
15
- rack (3.0.8)
15
+ rack (3.0.9.1)
16
16
  raindrops (0.20.1)
17
17
  rake (13.0.6)
18
18
  rake-compiler (1.2.1)
@@ -20,8 +20,7 @@ GEM
20
20
 
21
21
  PLATFORMS
22
22
  aarch64-linux
23
- arm64-darwin-21
24
- arm64-darwin-22
23
+ arm64-darwin
25
24
  x86_64-linux
26
25
 
27
26
  DEPENDENCIES
data/Rakefile CHANGED
@@ -42,7 +42,6 @@ namespace :test do
42
42
  # preferable to edit the test suite as little as possible.
43
43
  task legacy_integration: :compile do
44
44
  File.write("test/integration/random_blob", File.read("/dev/random", 1_000_000))
45
- lib = File.expand_path("lib", __dir__)
46
45
  path = "#{File.expand_path("exe", __dir__)}:#{ENV["PATH"]}"
47
46
  old_path = ENV["PATH"]
48
47
  ENV["PATH"] = "#{path}:#{old_path}"
@@ -241,10 +241,10 @@ for more details on nginx upstream configuration.
241
241
  ### `spawn_timeout`
242
242
 
243
243
  ```ruby
244
- timeout 5
244
+ spawn_timeout 5
245
245
  ```
246
246
 
247
- Sets the timeout for a newly spawned worker to be ready after being spawned.
247
+ Sets the timeout for a newly spawned worker or mold to be ready after being spawned.
248
248
 
249
249
  This timeout is a safeguard against various low-level fork safety bugs that could cause
250
250
  a process to dead-lock.
@@ -284,6 +284,16 @@ after_monitor_ready do |server|
284
284
  end
285
285
  ```
286
286
 
287
+ ### `before_fork`
288
+
289
+ Called by the mold before forking a new workers, and by workers before they spawn a new mold.
290
+
291
+ ```ruby
292
+ before_fork do |server|
293
+ server.logger.info("About to fork, closing connections!")
294
+ end
295
+ ```
296
+
287
297
  ### `after_mold_fork`
288
298
 
289
299
  ```ruby
@@ -307,6 +317,11 @@ That is the case for instance of many SQL databases protocols.
307
317
  This is also the callback in which memory optimizations, such as
308
318
  heap compaction should be done.
309
319
 
320
+ This callback is also a good place to check for potential corruption
321
+ issues caused by forking. If you detect something wrong, you can
322
+ call `Process.exit`, and this mold won't be used, another one will be
323
+ spawned later. e.g. you can check `Socket.getaddrinfo` still works, etc.
324
+
310
325
  ### `after_worker_fork`
311
326
 
312
327
  ```ruby
data/docs/DESIGN.md CHANGED
@@ -49,7 +49,7 @@
49
49
  nothing to accept().
50
50
 
51
51
  * Since non-blocking accept() is used, there can be a thundering
52
- herd when an occasional client connects when application
52
+ herd when an occasional client connects when the application
53
53
  *is not busy*. The thundering herd problem should not affect
54
54
  applications that are running all the time since worker processes
55
55
  will only select()/accept() outside of the application dispatch.
data/docs/FORK_SAFETY.md CHANGED
@@ -3,11 +3,11 @@
3
3
  Because `pitchfork` is a preforking server, your application code and libraries
4
4
  must be fork safe.
5
5
 
6
- Generally code might be fork-unsafe for one of two reasons
6
+ Generally, code might be fork-unsafe for one of two reasons.
7
7
 
8
8
  ## Inherited Connection
9
9
 
10
- When a process is forked, any open file descriptor (sockets, files, pipes, etc)
10
+ When a process is forked, any open file descriptors (sockets, files, pipes, etc)
11
11
  end up shared between the parent and child process. This is never what you
12
12
  want, so any code keeping persistent connections should close them either
13
13
  before or after the fork happens.
@@ -22,20 +22,24 @@ reopen connections and restart threads:
22
22
  ```ruby
23
23
  # pitchfork.conf.rb
24
24
 
25
- after_mold_fork do
25
+ before_fork do
26
26
  Sequel::DATABASES.each(&:disconnect)
27
27
  end
28
28
 
29
- after_worker_fork do
29
+ after_mold_fork do
30
30
  SomeLibary.connection.close
31
31
  end
32
+
33
+ after_worker_fork do
34
+ SomeOtherLibary.connection.close
35
+ end
32
36
  ```
33
37
 
34
38
  The documentation of any database client or network library you use should be
35
39
  read with care to figure out how to disconnect it, and whether it is best to
36
40
  do it before or after fork.
37
41
 
38
- Since the most common Ruby application servers `Puma`, `Unicorn` and `Passenger`
42
+ Since the most common Ruby application servers like `Puma`, `Unicorn` and `Passenger`
39
43
  have forking at least as an option, the requirements are generally well documented.
40
44
 
41
45
  However what is novel with `Pitchfork`, is that processes can be forked more than once.
@@ -57,7 +61,7 @@ So any libraries that spawn a background thread for periodical work may need to
57
61
  that a fork happened and that it should restart its thread.
58
62
 
59
63
  Just like with connections, some libraries take on them to automatically restart their background
60
- thread when they detect a fork happened.
64
+ thread when they detect that a fork happened.
61
65
 
62
66
  # Refork Safety
63
67
 
@@ -67,7 +71,7 @@ but not work in Pitchfork when reforking is enabled.
67
71
  This is because it is not uncommon for network connections or background threads to only be
68
72
  initialized upon the first request. As such they're not inherited on the first fork.
69
73
 
70
- However when reforking is enabled, new processes as forked out of warmed up process, as such
74
+ However when reforking is enabled, new processes are forked out of a warmed up process, as such
71
75
  any lazily created connection is much more likely to have been created.
72
76
 
73
77
  As such, if you enable reforking for the first time, it is heavily recommended to first do it
@@ -84,4 +88,6 @@ impact of discovering such bug.
84
88
  - The `ruby-vips` gem binds the `libvips` image processing library that isn't fork safe.
85
89
  (https://github.com/libvips/libvips/discussions/3577)
86
90
 
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.
91
+ - Any gem binding with `libgobject`, such as the `gda` gem, likely aren't fork safe.
92
+
93
+ 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/PHILOSOPHY.md CHANGED
@@ -80,8 +80,8 @@ Suitable options include `nginx`, `caddy` and likely several others.
80
80
  One of the main advantages of threaded servers over preforking servers is their
81
81
  lower memory usage.
82
82
 
83
- However `pitchfork` solves this with its reforking feature. If enabled and properly configured
84
- it very significantly increase Copy-on-Write performance, closing the gap with threaded servers.
83
+ However `pitchfork` solves this with its reforking feature. If enabled and properly configured,
84
+ it can very significantly increase Copy-on-Write performance, closing the gap with threaded servers.
85
85
 
86
86
  ## Assume Modern Deployment Methods
87
87
 
data/docs/REFORKING.md CHANGED
@@ -21,7 +21,7 @@ forked processes are essentially free.
21
21
  So in theory, preforking servers shouldn't use more memory than threaded servers.
22
22
 
23
23
  However, in a Ruby process, there is generally a lot of memory regions that are lazily initialized.
24
- This include the Ruby Virtual Machine inline caches, JITed code if you use YJIT, and also
24
+ This includes the Ruby Virtual Machine inline caches, JITed code if you use YJIT, and also
25
25
  some common patterns in applications, such as memoization:
26
26
 
27
27
  ```ruby
@@ -35,13 +35,13 @@ end
35
35
  However, since workers are forked right after boot, most codepaths have never been executed,
36
36
  so most of these caches are not yet initialized.
37
37
 
38
- As more code get executed, more an more memory pages get invalidated. If you were to graph the ratio
39
- of shared memory of a ruby process over time, you'd likely see a logarithmic curve, with a quick degradation
38
+ As more code gets executed, more and more memory pages get invalidated. If you were to graph the ratio
39
+ of shared memory of a Ruby process over time, you'd likely see a logarithmic curve, with a quick degradation
40
40
  during the first few processed request as the most common code paths get warmed up, and then a stabilization.
41
41
 
42
42
  ### Reforking
43
43
 
44
- That is where reforking helps. Since move of these invalidations only happens when a codepath is executed for the
44
+ That is where reforking helps. Since most of these invalidations only happen when a code path is executed for the
45
45
  first time, if you take a warmed up worker out of rotation, and use it to fork new workers, warmed up pages will
46
46
  be shared again, and most of them won't be invalidated anymore.
47
47
 
@@ -123,9 +123,9 @@ PID COMMAND
123
123
  ```
124
124
 
125
125
  However the `pitchfork` master process registers itself as a "child subreaper" via [`PR_SET_CHILD_SUBREAPER`](https://man7.org/linux/man-pages/man2/prctl.2.html).
126
- This means any descendant process that is orphaned will be re-parented as a child of the master rather than a child of the init process (pid 1).
126
+ This means that any descendant process that is orphaned will be re-parented as a child of the master process rather than a child of the init process (pid 1).
127
127
 
128
- With this in mind, the mold forks twice to create an orphaned process that will get re-attached to the master,
128
+ With this in mind, the mold forks twice to create an orphaned process that will get re-attached to the master process,
129
129
  effectively forking a sibling rather than a child. Similarly, workers do the same when forking new molds.
130
130
  This technique eases killing previous generations of molds and workers.
131
131
 
data/docs/SIGNALS.md CHANGED
@@ -1,6 +1,6 @@
1
1
  ## Signal handling
2
2
 
3
- In general, signals need only be sent to the master process. However,
3
+ In general, signals need to only be sent to the master process. However,
4
4
  the signals Pitchfork uses internally to communicate with the worker
5
5
  processes are documented here as well.
6
6
 
@@ -22,8 +22,8 @@ processes are documented here as well.
22
22
  ### Worker Processes
23
23
 
24
24
  Note: the master uses a pipe to signal workers
25
- instead of `kill(2)` for most cases. Using signals still (and works and
26
- remains supported for external tools/libraries), however.
25
+ instead of `kill(2)` for most cases. Using signals still works and
26
+ remains supported for external tools/libraries, however.
27
27
 
28
28
  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
@@ -21,6 +21,16 @@
21
21
  # define USE_EPOLL (0)
22
22
  #endif
23
23
 
24
+ #ifndef HAVE_RB_IO_DESCRIPTOR /* Ruby < 3.1 */
25
+ static int rb_io_descriptor(VALUE io)
26
+ {
27
+ rb_io_t *fptr;
28
+ GetOpenFile(io, fptr);
29
+ rb_io_check_closed(fptr);
30
+ return fptr->fd;
31
+ }
32
+ #endif
33
+
24
34
  #if USE_EPOLL
25
35
  /*
26
36
  * :nodoc:
@@ -54,8 +64,7 @@ static VALUE prep_readers(VALUE cls, VALUE readers)
54
64
  */
55
65
  e.events = EPOLLEXCLUSIVE | EPOLLIN;
56
66
  io = rb_io_get_io(io);
57
- GetOpenFile(io, fptr);
58
- rc = epoll_ctl(epfd, EPOLL_CTL_ADD, fptr->fd, &e);
67
+ rc = epoll_ctl(epfd, EPOLL_CTL_ADD, rb_io_descriptor(io), &e);
59
68
  if (rc < 0) rb_sys_fail("epoll_ctl");
60
69
  }
61
70
  return epio;
@@ -65,7 +74,7 @@ static VALUE prep_readers(VALUE cls, VALUE readers)
65
74
  #if USE_EPOLL
66
75
  struct ep_wait {
67
76
  struct epoll_event event;
68
- rb_io_t *fptr;
77
+ VALUE io;
69
78
  int timeout_msec;
70
79
  };
71
80
 
@@ -79,7 +88,7 @@ static void *do_wait(void *ptr) /* runs w/o GVL */
79
88
  * at-a-time (c.f. fs/eventpoll.c in linux.git, it's quite
80
89
  * easy-to-understand for anybody familiar with Ruby C).
81
90
  */
82
- return (void *)(long)epoll_wait(epw->fptr->fd, &epw->event, 1,
91
+ return (void *)(long)epoll_wait(rb_io_descriptor(epw->io), &epw->event, 1,
83
92
  epw->timeout_msec);
84
93
  }
85
94
 
@@ -93,11 +102,10 @@ get_readers(VALUE epio, VALUE ready, VALUE readers, VALUE timeout_msec)
93
102
 
94
103
  Check_Type(ready, T_ARRAY);
95
104
  Check_Type(readers, T_ARRAY);
96
- epio = rb_io_get_io(epio);
97
- GetOpenFile(epio, epw.fptr);
98
-
105
+ epw.io = rb_io_get_io(epio);
99
106
  epw.timeout_msec = NUM2INT(timeout_msec);
100
107
  n = (long)rb_thread_call_without_gvl(do_wait, &epw, RUBY_UBF_IO, NULL);
108
+ RB_GC_GUARD(epw.io);
101
109
  if (n < 0) {
102
110
  if (errno != EINTR) rb_sys_fail("epoll_wait");
103
111
  } else if (n > 0) { /* maxevents is hardcoded to 1 */
@@ -3,6 +3,8 @@ require 'mkmf'
3
3
 
4
4
  have_const("PR_SET_CHILD_SUBREAPER", "sys/prctl.h")
5
5
  have_func("rb_enc_interned_str", "ruby.h") # Ruby 3.0+
6
+ have_func("rb_io_descriptor", "ruby.h") # Ruby 3.1+
7
+
6
8
  if RUBY_VERSION.start_with?('3.0.')
7
9
  # https://bugs.ruby-lang.org/issues/18772
8
10
  $CFLAGS << ' -DRB_ENC_INTERNED_STR_NULL_CHECK=1 '
@@ -66,6 +66,11 @@ module Pitchfork
66
66
  @workers.key?(nr)
67
67
  end
68
68
 
69
+ def abandon(worker)
70
+ @workers.delete(worker.nr)
71
+ @pending_workers.delete(worker.nr)
72
+ end
73
+
69
74
  def reap(pid)
70
75
  if child = @children.delete(pid)
71
76
  @pending_workers.delete(child.nr)
@@ -73,6 +78,9 @@ module Pitchfork
73
78
  @molds.delete(child.pid)
74
79
  @workers.delete(child.nr)
75
80
  if @mold == child
81
+ @pending_workers.reject! do |nr, worker|
82
+ worker.generation == @mold.generation
83
+ end
76
84
  @mold = nil
77
85
  end
78
86
  end
@@ -99,6 +107,10 @@ module Pitchfork
99
107
  @molds.values
100
108
  end
101
109
 
110
+ def empty?
111
+ @children.empty?
112
+ end
113
+
102
114
  def each(&block)
103
115
  @children.each_value(&block)
104
116
  end
@@ -126,14 +138,6 @@ module Pitchfork
126
138
  end
127
139
  end
128
140
 
129
- def hard_timeout(child)
130
- child.hard_timeout!
131
- rescue Errno::ESRCH
132
- reap(child.pid)
133
- child.close
134
- true
135
- end
136
-
137
141
  def workers
138
142
  @workers.values
139
143
  end
@@ -34,9 +34,11 @@ module Pitchfork
34
34
  :soft_timeout => 20,
35
35
  :cleanup_timeout => 2,
36
36
  :spawn_timeout => 10,
37
+ :timeout_signal => -> (_pid) { :KILL },
37
38
  :timeout => 22,
38
39
  :logger => default_logger,
39
40
  :worker_processes => 1,
41
+ :before_fork => nil,
40
42
  :after_worker_fork => lambda { |server, worker|
41
43
  server.logger.info("worker=#{worker.nr} gen=#{worker.generation} pid=#{$$} spawned")
42
44
  },
@@ -133,6 +135,10 @@ module Pitchfork
133
135
  set[:logger] = obj
134
136
  end
135
137
 
138
+ def before_fork(*args, &block)
139
+ set_hook(:before_fork, block_given? ? block : args[0], 1)
140
+ end
141
+
136
142
  def after_worker_fork(*args, &block)
137
143
  set_hook(:after_worker_fork, block_given? ? block : args[0])
138
144
  end
@@ -175,6 +181,19 @@ module Pitchfork
175
181
  set_int(:timeout, soft_timeout + cleanup_timeout, 5)
176
182
  end
177
183
 
184
+ def timeout_signal(*args, &block)
185
+ if block_given?
186
+ set_hook(:timeout_signal, block, 1)
187
+ elsif args.first.respond_to?(:call)
188
+ set_hook(:timeout_signal, args.first, 1)
189
+ elsif args.first.is_a?(Symbol)
190
+ signal = args.first
191
+ set_hook(:timeout_signal, ->(_pid) { signal }, 1)
192
+ else
193
+ raise ArgumentError, "timeout_signal must be a symbol or a proc"
194
+ end
195
+ end
196
+
178
197
  def spawn_timeout(seconds)
179
198
  set_int(:spawn_timeout, seconds, 1)
180
199
  end
@@ -193,7 +193,12 @@ module Pitchfork
193
193
 
194
194
  # called by ext/pitchfork_http/pitchfork_http.rl via rb_funcall
195
195
  def self.is_chunked?(v) # :nodoc:
196
- vals = v.split(/[ \t]*,[ \t]*/).map!(&:downcase)
196
+ vals = v.split(',')
197
+ vals.each do |val|
198
+ val.strip!
199
+ val.downcase!
200
+ end
201
+
197
202
  if vals.pop == 'chunked'.freeze
198
203
  return true unless vals.include?('chunked'.freeze)
199
204
  raise Pitchfork::HttpParserError, 'double chunked', []
@@ -14,6 +14,8 @@ module Pitchfork
14
14
  STATUS_CODES = defined?(Rack::Utils::HTTP_STATUS_CODES) ?
15
15
  Rack::Utils::HTTP_STATUS_CODES : {}
16
16
 
17
+ ILLEGAL_HEADER_VALUE = /[\x00-\x08\x0A-\x1F]/
18
+
17
19
  # internal API, code will always be common-enough-for-even-old-Rack
18
20
  def err_response(code, response_start_sent)
19
21
  "#{response_start_sent ? '' : 'HTTP/1.1 '}" \
@@ -23,10 +25,16 @@ module Pitchfork
23
25
  def append_header(buf, key, value)
24
26
  case value
25
27
  when Array # Rack 3
26
- value.each { |v| buf << "#{key}: #{v}\r\n" }
28
+ value.each do |v|
29
+ next if ILLEGAL_HEADER_VALUE.match?(v)
30
+ buf << "#{key}: #{v}\r\n"
31
+ end
27
32
  when /\n/ # Rack 2
28
33
  # avoiding blank, key-only cookies with /\n+/
29
- value.split(/\n+/).each { |v| buf << "#{key}: #{v}\r\n" }
34
+ value.split(/\n+/).each do |v|
35
+ next if ILLEGAL_HEADER_VALUE.match?(v)
36
+ buf << "#{key}: #{v}\r\n"
37
+ end
30
38
  else
31
39
  buf << "#{key}: #{value}\r\n"
32
40
  end
@@ -74,8 +74,8 @@ module Pitchfork
74
74
  end
75
75
 
76
76
  # :stopdoc:
77
- attr_accessor :app, :timeout, :soft_timeout, :cleanup_timeout, :spawn_timeout, :worker_processes,
78
- :after_worker_fork, :after_mold_fork,
77
+ attr_accessor :app, :timeout, :timeout_signal, :soft_timeout, :cleanup_timeout, :spawn_timeout, :worker_processes,
78
+ :before_fork, :after_worker_fork, :after_mold_fork,
79
79
  :listener_opts, :children,
80
80
  :orig_app, :config, :ready_pipe,
81
81
  :default_middleware, :early_hints
@@ -201,7 +201,7 @@ module Pitchfork
201
201
  else
202
202
  build_app!
203
203
  bind_listeners!
204
- after_mold_fork.call(self, Worker.new(nil, pid: $$).promoted!)
204
+ after_mold_fork.call(self, Worker.new(nil, pid: $$).promoted!(@spawn_timeout))
205
205
  end
206
206
 
207
207
  if sync
@@ -315,7 +315,7 @@ module Pitchfork
315
315
  end
316
316
  end
317
317
  stop # gracefully shutdown all workers on our way out
318
- logger.info "master complete"
318
+ logger.info "master complete status=#{@exit_status}"
319
319
  @exit_status
320
320
  end
321
321
 
@@ -368,7 +368,7 @@ module Pitchfork
368
368
  when Message::WorkerSpawned
369
369
  worker = @children.update(message)
370
370
  # TODO: should we send a message to the worker to acknowledge?
371
- logger.info "worker=#{worker.nr} pid=#{worker.pid} registered"
371
+ logger.info "worker=#{worker.nr} pid=#{worker.pid} gen=#{worker.generation} registered"
372
372
  when Message::MoldSpawned
373
373
  new_mold = @children.update(message)
374
374
  logger.info("mold pid=#{new_mold.pid} gen=#{new_mold.generation} spawned")
@@ -394,7 +394,7 @@ module Pitchfork
394
394
  wait_for_pending_workers
395
395
  self.listeners = []
396
396
  limit = Pitchfork.time_now + timeout
397
- until @children.workers.empty? || Pitchfork.time_now > limit
397
+ until @children.empty? || Pitchfork.time_now > limit
398
398
  if graceful
399
399
  @children.soft_kill_all(:TERM)
400
400
  else
@@ -404,11 +404,17 @@ module Pitchfork
404
404
  return StopIteration
405
405
  end
406
406
  end
407
- @children.hard_kill_all(:KILL)
407
+
408
+ @children.each do |child|
409
+ if child.pid
410
+ @children.hard_kill(@timeout_signal.call(child.pid), child)
411
+ end
412
+ end
408
413
  @promotion_lock.unlink
409
414
  end
410
415
 
411
416
  def worker_exit(worker)
417
+ logger.info "worker=#{worker.nr} pid=#{worker.pid} gen=#{worker.generation} exiting"
412
418
  proc_name status: "exiting"
413
419
 
414
420
  if @before_worker_exit
@@ -494,8 +500,8 @@ module Pitchfork
494
500
  now = Pitchfork.time_now(true)
495
501
  next_sleep = @timeout - 1
496
502
 
497
- @children.workers.each do |worker|
498
- deadline = worker.deadline
503
+ @children.each do |child|
504
+ deadline = child.deadline
499
505
  if 0 == deadline # worker is idle
500
506
  next
501
507
  elsif deadline > now # worker still has time
@@ -506,28 +512,34 @@ module Pitchfork
506
512
  next
507
513
  else # worker is out of time
508
514
  next_sleep = 0
509
- hard_timeout(worker)
515
+ hard_timeout(child)
510
516
  end
511
517
  end
512
518
 
513
519
  next_sleep <= 0 ? 1 : next_sleep
514
520
  end
515
521
 
516
- def hard_timeout(worker)
517
- if @after_worker_hard_timeout
522
+ def hard_timeout(child)
523
+ if child.pid.nil? # Not yet registered, likely never spawned
524
+ logger.error "worker=#{child.nr} timed out during spawn, abandoning"
525
+ @children.abandon(worker)
526
+ return
527
+ end
528
+
529
+ if @after_worker_hard_timeout && !child.mold?
518
530
  begin
519
- @after_worker_hard_timeout.call(self, worker)
531
+ @after_worker_hard_timeout.call(self, child)
520
532
  rescue => error
521
533
  Pitchfork.log_error(@logger, "after_worker_hard_timeout callback", error)
522
534
  end
523
535
  end
524
536
 
525
- if worker.mold?
526
- logger.error "mold pid=#{worker.pid} timed out, killing"
537
+ if child.mold?
538
+ logger.error "mold pid=#{child.pid} gen=#{child.generation} timed out, killing"
527
539
  else
528
- logger.error "worker=#{worker.nr} pid=#{worker.pid} timed out, killing"
540
+ logger.error "worker=#{child.nr} pid=#{child.pid} gen=#{child.generation} timed out, killing"
529
541
  end
530
- @children.hard_timeout(worker) # take no prisoners for hard timeout violations
542
+ @children.hard_kill(@timeout_signal.call(child.pid), child) # take no prisoners for hard timeout violations
531
543
  end
532
544
 
533
545
  def trigger_refork
@@ -563,7 +575,8 @@ module Pitchfork
563
575
  # reason it gets stuck before reaching the worker loop,
564
576
  # the monitor process will kill it.
565
577
  worker.update_deadline(@spawn_timeout)
566
- Pitchfork.fork_sibling do
578
+ @before_fork&.call(self)
579
+ fork_sibling("spawn_worker") do
567
580
  worker.pid = Process.pid
568
581
 
569
582
  after_fork_internal
@@ -598,6 +611,8 @@ module Pitchfork
598
611
  worker = Pitchfork::Worker.new(worker_nr)
599
612
 
600
613
  if REFORKING_AVAILABLE
614
+ worker.generation = @children.mold&.generation || 0
615
+
601
616
  unless @children.mold&.spawn_worker(worker)
602
617
  @logger.error("Failed to send a spawn_worker command")
603
618
  end
@@ -707,6 +722,7 @@ module Pitchfork
707
722
 
708
723
  proc_name status: "requests: #{worker.requests_count}, processing: #{env["PATH_INFO"]}"
709
724
 
725
+ env["pitchfork.worker"] = worker
710
726
  timeout_handler.rack_env = env
711
727
  env["pitchfork.timeout"] = timeout_handler
712
728
 
@@ -784,15 +800,18 @@ module Pitchfork
784
800
  readers << worker
785
801
  trap(:QUIT) { nuke_listeners!(readers) }
786
802
  trap(:TERM) { nuke_listeners!(readers) }
803
+ trap(:INT) { nuke_listeners!(readers); exit!(0) }
787
804
  readers
788
805
  end
789
806
 
790
807
  def init_mold_process(mold)
791
- proc_name role: "(gen:#{mold.generation}) mold", status: "ready"
808
+ proc_name role: "(gen:#{mold.generation}) mold", status: "init"
792
809
  after_mold_fork.call(self, mold)
793
810
  readers = [mold]
794
811
  trap(:QUIT) { nuke_listeners!(readers) }
795
812
  trap(:TERM) { nuke_listeners!(readers) }
813
+ trap(:INT) { nuke_listeners!(readers); exit!(0) }
814
+ proc_name role: "(gen:#{mold.generation}) mold", status: "ready"
796
815
  readers
797
816
  end
798
817
 
@@ -831,7 +850,7 @@ module Pitchfork
831
850
  case client
832
851
  when Message::PromoteWorker
833
852
  if Info.fork_safe?
834
- spawn_mold(worker.generation)
853
+ spawn_mold(worker)
835
854
  else
836
855
  logger.error("worker=#{worker.nr} gen=#{worker.generation} is no longer fork safe, can't refork")
837
856
  end
@@ -852,8 +871,8 @@ module Pitchfork
852
871
  if @refork_condition && Info.fork_safe? && !worker.outdated?
853
872
  if @refork_condition.met?(worker, logger)
854
873
  proc_name status: "requests: #{worker.requests_count}, spawning mold"
855
- if spawn_mold(worker.generation)
856
- logger.info("Refork condition met, promoting ourselves")
874
+ if spawn_mold(worker)
875
+ logger.info("worker=#{worker.nr} gen=#{worker.generation} Refork condition met, promoting ourselves")
857
876
  end
858
877
  @refork_condition.backoff!
859
878
  end
@@ -867,13 +886,17 @@ module Pitchfork
867
886
  end
868
887
  end
869
888
 
870
- def spawn_mold(current_generation)
889
+ def spawn_mold(worker)
871
890
  return false unless @promotion_lock.try_lock
872
891
 
892
+ worker.update_deadline(@spawn_timeout)
893
+
894
+ @before_fork&.call(self)
895
+
873
896
  begin
874
- Pitchfork.fork_sibling do
875
- mold = Worker.new(nil, pid: Process.pid, generation: current_generation)
876
- mold.promote!
897
+ fork_sibling("spawn_mold") do
898
+ mold = Worker.new(nil, pid: Process.pid, generation: worker.generation)
899
+ mold.promote!(@spawn_timeout)
877
900
  mold.start_promotion(@control_socket[1])
878
901
  mold_loop(mold)
879
902
  end
@@ -907,8 +930,18 @@ module Pitchfork
907
930
  when false
908
931
  # no message, keep looping
909
932
  when Message::SpawnWorker
933
+ retries = 1
910
934
  begin
911
935
  spawn_worker(Worker.new(message.nr, generation: mold.generation), detach: true)
936
+ rescue ForkFailure
937
+ if retries > 0
938
+ @logger.fatal("mold pid=#{mold.pid} gen=#{mold.generation} Failed to spawn a worker. Retrying.")
939
+ retries -= 1
940
+ retry
941
+ else
942
+ @logger.fatal("mold pid=#{mold.pid} gen=#{mold.generation} Failed to spawn a worker twice in a row. Corrupted mold process?")
943
+ Process.exit(1)
944
+ end
912
945
  rescue => error
913
946
  raise BootFailure, error.message
914
947
  end
@@ -977,5 +1010,61 @@ module Pitchfork
977
1010
  handler.timeout_request = SoftTimeout.request(@soft_timeout, handler)
978
1011
  handler
979
1012
  end
1013
+
1014
+ FORK_TIMEOUT = 5
1015
+
1016
+ def fork_sibling(role, &block)
1017
+ if REFORKING_AVAILABLE
1018
+ r, w = Pitchfork::Info.keep_ios(IO.pipe)
1019
+ # We double fork so that the new worker is re-attached back
1020
+ # to the master.
1021
+ # This requires either PR_SET_CHILD_SUBREAPER which is exclusive to Linux 3.4
1022
+ # or the master to be PID 1.
1023
+ if middle_pid = FORK_LOCK.synchronize { Process.fork } # parent
1024
+ w.close
1025
+ # We need to wait(2) so that the middle process doesn't end up a zombie.
1026
+ # The process only call fork again an exit so it should be pretty fast.
1027
+ # However it might need to execute some `Process._fork` or `at_exit` callbacks,
1028
+ # as well as Ruby's cleanup procedure to run finalizers etc, and there is a risk
1029
+ # of deadlock.
1030
+ # So in case it takes more than 5 seconds to exit, we kill it.
1031
+ # TODO: rather than to busy loop here, we handle it in the worker/mold loop
1032
+ process_wait_with_timeout(middle_pid, FORK_TIMEOUT)
1033
+ pid_str = r.gets
1034
+ r.close
1035
+ if pid_str
1036
+ Integer(pid_str)
1037
+ else
1038
+ raise ForkFailure, "fork_sibling didn't succeed in #{FORK_TIMEOUT} seconds"
1039
+ end
1040
+ else # first child
1041
+ r.close
1042
+ Process.setproctitle("<pitchfork fork_sibling(#{role})>")
1043
+ pid = Pitchfork.clean_fork do
1044
+ # detach into a grand child
1045
+ w.close
1046
+ yield
1047
+ end
1048
+ w.puts(pid)
1049
+ w.close
1050
+ exit
1051
+ end
1052
+ else
1053
+ Pitchfork.clean_fork(&block)
1054
+ end
1055
+ end
1056
+
1057
+ def process_wait_with_timeout(pid, timeout)
1058
+ (timeout * 50).times do
1059
+ _, status = Process.waitpid2(pid, Process::WNOHANG)
1060
+ return status if status
1061
+ sleep 0.02 # 50 * 20ms => 1s
1062
+ end
1063
+
1064
+ # The process didn't exit in the allotted time, so we kill it.
1065
+ Process.kill(@timeout_signal.call(pid), pid)
1066
+ _, status = Process.waitpid2(pid)
1067
+ status
1068
+ end
980
1069
  end
981
1070
  end
@@ -26,7 +26,7 @@ module Pitchfork
26
26
  @map.each_key(&block)
27
27
  end
28
28
  end
29
-
29
+
30
30
  @kept_ios = WeakSet.new
31
31
 
32
32
  class << self
@@ -2,6 +2,12 @@
2
2
 
3
3
  module Pitchfork
4
4
  module ReforkCondition
5
+ @backoff_delay = 10.0
6
+
7
+ class << self
8
+ attr_accessor :backoff_delay
9
+ end
10
+
5
11
  class RequestsCount
6
12
  def initialize(request_counts)
7
13
  @limits = request_counts
@@ -31,7 +37,7 @@ module Pitchfork
31
37
  end
32
38
  end
33
39
 
34
- def backoff!(delay = 10.0)
40
+ def backoff!(delay = ReforkCondition.backoff_delay)
35
41
  @backoff_until = Pitchfork.time_now + delay
36
42
  end
37
43
  end
@@ -10,7 +10,8 @@ module Pitchfork
10
10
  CURRENT_GENERATION_OFFSET = 0
11
11
  SHUTDOWN_OFFSET = 1
12
12
  MOLD_TICK_OFFSET = 2
13
- WORKER_TICK_OFFSET = 3
13
+ MOLD_PROMOTION_TICK_OFFSET = 3
14
+ WORKER_TICK_OFFSET = 4
14
15
 
15
16
  DROPS = [Raindrops.new(PER_DROP)]
16
17
 
@@ -49,6 +50,10 @@ module Pitchfork
49
50
  self[MOLD_TICK_OFFSET]
50
51
  end
51
52
 
53
+ def mold_promotion_deadline
54
+ self[MOLD_PROMOTION_TICK_OFFSET]
55
+ end
56
+
52
57
  def worker_deadline(worker_nr)
53
58
  self[WORKER_TICK_OFFSET + worker_nr]
54
59
  end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Pitchfork
4
- VERSION = "0.10.0"
4
+ VERSION = "0.11.1"
5
5
  module Const
6
6
  UNICORN_VERSION = '6.1.0'
7
7
  end
@@ -27,7 +27,7 @@ module Pitchfork
27
27
  @deadline_drop = SharedMemory.worker_deadline(nr)
28
28
  self.deadline = 0
29
29
  else
30
- promoted!
30
+ promoted!(nil)
31
31
  end
32
32
  end
33
33
 
@@ -55,6 +55,13 @@ module Pitchfork
55
55
  message.class.members.each do |member|
56
56
  send("#{member}=", message.public_send(member))
57
57
  end
58
+
59
+ case message
60
+ when Message::MoldSpawned
61
+ @deadline_drop = SharedMemory.mold_promotion_deadline
62
+ when Message::MoldReady
63
+ @deadline_drop = SharedMemory.mold_deadline
64
+ end
58
65
  end
59
66
 
60
67
  def register_to_master(control_socket)
@@ -75,6 +82,7 @@ module Pitchfork
75
82
  message = Message::MoldReady.new(@nr, @pid, generation)
76
83
  control_socket.sendmsg(message)
77
84
  SharedMemory.current_generation = @generation
85
+ @deadline_drop = SharedMemory.mold_deadline
78
86
  end
79
87
 
80
88
  def promote(generation)
@@ -85,16 +93,16 @@ module Pitchfork
85
93
  send_message_nonblock(Message::SpawnWorker.new(new_worker.nr))
86
94
  end
87
95
 
88
- def promote!
96
+ def promote!(timeout)
89
97
  @generation += 1
90
- promoted!
98
+ promoted!(timeout)
91
99
  end
92
100
 
93
- def promoted!
101
+ def promoted!(timeout)
94
102
  @mold = true
95
103
  @nr = nil
96
- @deadline_drop = SharedMemory.mold_deadline
97
- self.deadline = 0
104
+ @deadline_drop = SharedMemory.mold_promotion_deadline
105
+ update_deadline(timeout) if timeout
98
106
  self
99
107
  end
100
108
 
@@ -143,10 +151,6 @@ module Pitchfork
143
151
  Process.kill(sig, pid)
144
152
  end
145
153
 
146
- def hard_timeout!
147
- hard_kill(:KILL)
148
- end
149
-
150
154
  # this only runs when the Rack app.call is not running
151
155
  # act like a listener
152
156
  def accept_nonblock(exception: nil) # :nodoc:
data/lib/pitchfork.rb CHANGED
@@ -33,6 +33,7 @@ module Pitchfork
33
33
  ClientShutdown = Class.new(EOFError)
34
34
 
35
35
  BootFailure = Class.new(StandardError)
36
+ ForkFailure = Class.new(StandardError)
36
37
 
37
38
  # :stopdoc:
38
39
 
@@ -196,43 +197,6 @@ module Pitchfork
196
197
  end
197
198
  end
198
199
 
199
- def fork_sibling(&block)
200
- if REFORKING_AVAILABLE
201
- # We double fork so that the new worker is re-attached back
202
- # to the master.
203
- # This requires either PR_SET_CHILD_SUBREAPER which is exclusive to Linux 3.4
204
- # or the master to be PID 1.
205
- if middle_pid = FORK_LOCK.synchronize { Process.fork } # parent
206
- # We need to wait(2) so that the middle process doesn't end up a zombie.
207
- # The process only call fork again an exit so it should be pretty fast.
208
- # However it might need to execute some `Process._fork` or `at_exit` callbacks,
209
- # so it case it takes more than 5 seconds to exit, we kill it with SIGBUS
210
- # to produce a crash report, as this is indicative of a nasty bug.
211
- process_wait_with_timeout(middle_pid, 5, :BUS)
212
- else # first child
213
- Process.setproctitle("<pitchfork fork_sibling>")
214
- clean_fork(&block) # detach into a grand child
215
- exit
216
- end
217
- else
218
- clean_fork(&block)
219
- end
220
-
221
- nil # it's tricky to return the PID
222
- end
223
-
224
- def process_wait_with_timeout(pid, timeout, timeout_signal = :KILL)
225
- (timeout * 200).times do
226
- status = Process.wait(pid, Process::WNOHANG)
227
- return status if status
228
- sleep 0.005 # 200 * 5ms => 1s
229
- end
230
-
231
- # The process didn't exit in the allotted time, so we kill it.
232
- Process.kill(timeout_signal, pid)
233
- Process.wait(pid)
234
- end
235
-
236
200
  def time_now(int = false)
237
201
  Process.clock_gettime(Process::CLOCK_MONOTONIC, int ? :second : :float_second)
238
202
  end
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.10.0
4
+ version: 0.11.1
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-11-02 00:00:00.000000000 Z
11
+ date: 2024-03-06 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: raindrops
@@ -138,7 +138,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
138
138
  - !ruby/object:Gem::Version
139
139
  version: '0'
140
140
  requirements: []
141
- rubygems_version: 3.4.10
141
+ rubygems_version: 3.5.5
142
142
  signing_key:
143
143
  specification_version: 4
144
144
  summary: Rack HTTP server for fast clients and Unix