pitchfork 0.10.0 → 0.11.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yml +1 -1
- data/CHANGELOG.md +12 -0
- data/Gemfile.lock +6 -7
- data/Rakefile +0 -1
- data/docs/CONFIGURATION.md +17 -2
- data/docs/DESIGN.md +1 -1
- data/docs/FORK_SAFETY.md +14 -8
- data/docs/PHILOSOPHY.md +2 -2
- data/docs/REFORKING.md +6 -6
- data/docs/SIGNALS.md +3 -3
- data/ext/pitchfork_http/epollexclusive.h +15 -7
- data/ext/pitchfork_http/extconf.rb +2 -0
- data/lib/pitchfork/children.rb +12 -8
- data/lib/pitchfork/configurator.rb +19 -0
- data/lib/pitchfork/http_parser.rb +6 -1
- data/lib/pitchfork/http_response.rb +10 -2
- data/lib/pitchfork/http_server.rb +115 -26
- data/lib/pitchfork/info.rb +1 -1
- data/lib/pitchfork/refork_condition.rb +7 -1
- data/lib/pitchfork/shared_memory.rb +6 -1
- data/lib/pitchfork/version.rb +1 -1
- data/lib/pitchfork/worker.rb +14 -10
- data/lib/pitchfork.rb +1 -37
- metadata +3 -3
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 5dc129b2e6e6e940be0f9e388400a376614a65a125a64b68c73b919744c433b3
|
4
|
+
data.tar.gz: b12e1d5f360edc7159567060c095b40510e8e89c54dae058882742c29c830f78
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 4a43b0204dc0e77a4d28007dbfd89e9645724a4198ea6ec7ffa0895aefaf075bd42d4303c37d0f7e61afb550b665b78ab6b22ca9f934ef2d3721883acf0a68cd
|
7
|
+
data.tar.gz: 7eb41807d6971ac948ee264b1f70fd2e16afcf8f289655073c876b752a53a405b9705e97509f5be82c519ee12694ecf1910b18a20cce8aad3ce9a4ffd3af9ac6
|
data/.github/workflows/ci.yml
CHANGED
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.
|
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.
|
12
|
-
nio4r (2.
|
13
|
-
puma (6.
|
11
|
+
minitest (5.22.2)
|
12
|
+
nio4r (2.7.0)
|
13
|
+
puma (6.4.2)
|
14
14
|
nio4r (~> 2.0)
|
15
|
-
rack (3.0.
|
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
|
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}"
|
data/docs/CONFIGURATION.md
CHANGED
@@ -241,10 +241,10 @@ for more details on nginx upstream configuration.
|
|
241
241
|
### `spawn_timeout`
|
242
242
|
|
243
243
|
```ruby
|
244
|
-
|
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
|
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
|
-
|
25
|
+
before_fork do
|
26
26
|
Sequel::DATABASES.each(&:disconnect)
|
27
27
|
end
|
28
28
|
|
29
|
-
|
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
|
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
|
-
|
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
|
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
|
39
|
-
of shared memory of a
|
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
|
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
|
26
|
-
remains supported for external tools/libraries
|
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
|
-
|
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
|
-
|
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->
|
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
|
-
|
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 '
|
data/lib/pitchfork/children.rb
CHANGED
@@ -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(
|
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
|
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
|
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.
|
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
|
-
|
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.
|
498
|
-
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(
|
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(
|
517
|
-
if
|
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,
|
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
|
526
|
-
logger.error "mold pid=#{
|
537
|
+
if child.mold?
|
538
|
+
logger.error "mold pid=#{child.pid} gen=#{child.generation} timed out, killing"
|
527
539
|
else
|
528
|
-
logger.error "worker=#{
|
540
|
+
logger.error "worker=#{child.nr} pid=#{child.pid} gen=#{child.generation} timed out, killing"
|
529
541
|
end
|
530
|
-
@children.
|
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
|
-
|
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: "
|
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
|
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
|
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(
|
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
|
-
|
875
|
-
mold = Worker.new(nil, pid: Process.pid, 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
|
data/lib/pitchfork/info.rb
CHANGED
@@ -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 =
|
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
|
-
|
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
|
data/lib/pitchfork/version.rb
CHANGED
data/lib/pitchfork/worker.rb
CHANGED
@@ -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.
|
97
|
-
|
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.
|
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:
|
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.
|
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
|