raptor 0.11.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: be4ff0cf1729c188fab8261b749c2a279616bd565dbad81d18f5a3e75311b734
4
- data.tar.gz: 0acc2ece57f69207a34696df792e45bf52bfce22bb7617c5ceb3113223c175f5
3
+ metadata.gz: 36b617c828f29b9196e8e5b2ea86e6fdeb5c51fece8d5f2678df486ef77c5bd6
4
+ data.tar.gz: 433b1070b29e3b13d2b337a25ac7f6519c10c5ae8ab757b523ad93f426cf2f5f
5
5
  SHA512:
6
- metadata.gz: 787fabc9128f3c4e16a394dc5634ff8435916b60b68ff3f6d569b0db561f07af0c61ab5d17e7440301c850d87fbbe1f0dc3691274b1c060d1cbbd6f7bc84bfbf
7
- data.tar.gz: 26d154b6c0abbc63986262e1212e526bd9e4e492d06f9a86e723e73fd85afd142550a15324b55ad91b575633db2d0231f262aebb818f198e2de4c96c9afa8666
6
+ metadata.gz: 24e001cc6d45a3cc126f1bfdc1eb332bb0dc2353cb9ebbcaaa7bb0f111dc3c0f61715ac105700e5cf330c2fd9f4e514bea24610ec0242a15a446fd990489f368
7
+ data.tar.gz: e852c7851d772da17e28650b6e9fae59a9fa34619ed4aeba38d506febb0aaa5ff6096e5ffea20841822f81fcc0588c44a2dad30a32407cb37126bfe118e8ebe4
@@ -18,6 +18,25 @@ steps:
18
18
  - bundle install
19
19
  - bundle exec rake ci
20
20
 
21
+ - label: ":ruby: Ruby 4.0 +GC.stress"
22
+ image: "ruby:4.0"
23
+ env:
24
+ RUBY_GC_STRESS: "1"
25
+ commands:
26
+ - ruby -v
27
+ - bundle install
28
+ - bundle exec rake ci
29
+
30
+ - label: ":ruby: Ruby 4.0 +YJIT +GC.stress"
31
+ image: "ruby:4.0"
32
+ env:
33
+ RUBY_YJIT_ENABLE: "1"
34
+ RUBY_GC_STRESS: "1"
35
+ commands:
36
+ - ruby -v
37
+ - bundle install
38
+ - bundle exec rake ci
39
+
21
40
  - label: ":ruby: Ruby head"
22
41
  soft_fail: true
23
42
  image: "rubylang/ruby:master-dev"
@@ -38,6 +57,29 @@ steps:
38
57
  - bundle install
39
58
  - bundle exec rake ci
40
59
 
60
+ - label: ":ruby: Ruby head +GC.stress"
61
+ soft_fail: true
62
+ image: "rubylang/ruby:master-dev"
63
+ env:
64
+ RUBY_GC_STRESS: "1"
65
+ commands:
66
+ - apt-get update && apt-get install -y libyaml-dev
67
+ - ruby -v
68
+ - bundle install
69
+ - bundle exec rake ci
70
+
71
+ - label: ":ruby: Ruby head +YJIT +GC.stress"
72
+ soft_fail: true
73
+ image: "rubylang/ruby:master-dev"
74
+ env:
75
+ RUBY_YJIT_ENABLE: "1"
76
+ RUBY_GC_STRESS: "1"
77
+ commands:
78
+ - apt-get update && apt-get install -y libyaml-dev
79
+ - ruby -v
80
+ - bundle install
81
+ - bundle exec rake ci
82
+
41
83
  - group: ":mac: macOS"
42
84
  key: macos
43
85
  steps:
@@ -65,6 +107,33 @@ steps:
65
107
  - bundle install
66
108
  - bundle exec rake ci
67
109
 
110
+ - label: ":ruby: Ruby 4.0 +GC.stress"
111
+ agents:
112
+ queue: macos
113
+ env:
114
+ RUBY_GC_STRESS: "1"
115
+ commands:
116
+ - brew install --quiet ruby@4.0
117
+ - export PATH="$(brew --prefix ruby@4.0)/bin:$PATH"
118
+ - bundle config set --local path .bundle
119
+ - ruby -v
120
+ - bundle install
121
+ - bundle exec rake ci
122
+
123
+ - label: ":ruby: Ruby 4.0 +YJIT +GC.stress"
124
+ agents:
125
+ queue: macos
126
+ env:
127
+ RUBY_YJIT_ENABLE: "1"
128
+ RUBY_GC_STRESS: "1"
129
+ commands:
130
+ - brew install --quiet ruby@4.0
131
+ - export PATH="$(brew --prefix ruby@4.0)/bin:$PATH"
132
+ - bundle config set --local path .bundle
133
+ - ruby -v
134
+ - bundle install
135
+ - bundle exec rake ci
136
+
68
137
  - label: ":ruby: Ruby head"
69
138
  soft_fail: true
70
139
  agents:
@@ -92,3 +161,34 @@ steps:
92
161
  - ruby -v
93
162
  - bundle install
94
163
  - bundle exec rake ci
164
+
165
+ - label: ":ruby: Ruby head +GC.stress"
166
+ soft_fail: true
167
+ agents:
168
+ queue: macos
169
+ env:
170
+ RUBY_GC_STRESS: "1"
171
+ commands:
172
+ - brew unlink ruby
173
+ - brew install --HEAD --quiet ruby
174
+ - export PATH="$(brew --prefix ruby)/bin:$PATH"
175
+ - bundle config set --local path .bundle
176
+ - ruby -v
177
+ - bundle install
178
+ - bundle exec rake ci
179
+
180
+ - label: ":ruby: Ruby head +YJIT +GC.stress"
181
+ soft_fail: true
182
+ agents:
183
+ queue: macos
184
+ env:
185
+ RUBY_YJIT_ENABLE: "1"
186
+ RUBY_GC_STRESS: "1"
187
+ commands:
188
+ - brew unlink ruby
189
+ - brew install --HEAD --quiet ruby
190
+ - export PATH="$(brew --prefix ruby)/bin:$PATH"
191
+ - bundle config set --local path .bundle
192
+ - ruby -v
193
+ - bundle install
194
+ - bundle exec rake ci
data/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.12.0] - 2026-07-19
4
+
5
+ - Add `refork_after` and `before_refork` for reforking workers off a warmed source
6
+ - Add `before_fork`, `before_worker_boot`, and `before_worker_shutdown` hooks
7
+ - Sync `$stdout` and `$stderr` writes so info-level logs flush immediately
8
+ - Break reuseport routing ties by 4-tuple hash instead of a random draw
9
+
3
10
  ## [0.11.0] - 2026-07-12
4
11
 
5
12
  - Yield subsequent HTTP/1.1 keep-alive requests to the thread pool only under saturation
data/README.md CHANGED
@@ -36,23 +36,23 @@ run proc { |_env| [200, { "content-type" => "text/plain" }, ["Hello, World!"]] }
36
36
 
37
37
  ```
38
38
  > bundle exec raptor -w 4 -t 3 hello_world.ru
39
- [Raptor 76577|Main|Main] Cluster initializing:
40
- [Raptor 76577|Main|Main] ├─ Version: 0.11.0
41
- [Raptor 76577|Main|Main] ├─ Ruby Version: ruby 4.0.5 (2026-05-20 revision 64336ffd0e) +YJIT +PRISM [arm64-darwin23]
42
- [Raptor 76577|Main|Main] ├─ Environment: development
43
- [Raptor 76577|Main|Main] ├─ Master PID: 76577
44
- [Raptor 76577|Main|Main] │ └─ 4 worker processes
45
- [Raptor 76577|Main|Main] │ ├─ 1 server thread
46
- [Raptor 76577|Main|Main] │ ├─ 1 reactor thread
47
- [Raptor 76577|Main|Main] │ ├─ 1 pipeline ractor
48
- [Raptor 76577|Main|Main] │ ├─ 1 pipeline collector thread
49
- [Raptor 76577|Main|Main] │ ├─ 3 worker threads
50
- [Raptor 76577|Main|Main] │ └─ 1 stats thread
51
- [Raptor 76577|Main|Main] └─ Listening on 0.0.0.0:9292
52
- [Raptor 76579|Main|Main] Worker 0 booted
53
- [Raptor 76580|Main|Main] Worker 1 booted
54
- [Raptor 76581|Main|Main] Worker 2 booted
55
- [Raptor 76582|Main|Main] Worker 3 booted
39
+ [Raptor 59013|Main|Main] Cluster initializing:
40
+ [Raptor 59013|Main|Main] ├─ Version: 0.12.0
41
+ [Raptor 59013|Main|Main] ├─ Ruby Version: ruby 4.0.5 (2026-05-20 revision 64336ffd0e) +YJIT +PRISM [arm64-darwin23]
42
+ [Raptor 59013|Main|Main] ├─ Environment: development
43
+ [Raptor 59013|Main|Main] ├─ Master PID: 59013
44
+ [Raptor 59013|Main|Main] │ └─ 4 worker processes
45
+ [Raptor 59013|Main|Main] │ ├─ 1 server thread
46
+ [Raptor 59013|Main|Main] │ ├─ 1 reactor thread
47
+ [Raptor 59013|Main|Main] │ ├─ 1 pipeline ractor
48
+ [Raptor 59013|Main|Main] │ ├─ 1 pipeline collector thread
49
+ [Raptor 59013|Main|Main] │ ├─ 3 worker threads
50
+ [Raptor 59013|Main|Main] │ └─ 1 stats thread
51
+ [Raptor 59013|Main|Main] └─ Listening on 0.0.0.0:9292
52
+ [Raptor 59023|Main|Main] Worker 0 booted
53
+ [Raptor 59024|Main|Main] Worker 1 booted
54
+ [Raptor 59025|Main|Main] Worker 2 booted
55
+ [Raptor 59026|Main|Main] Worker 3 booted
56
56
  ```
57
57
 
58
58
  ```
@@ -79,15 +79,16 @@ The config file is a Ruby file that evaluates to a hash of options. By default R
79
79
  ```ruby
80
80
  # raptor.rb
81
81
 
82
+ # Every key below is set to its default value; only include the ones you want to override.
82
83
  {
83
84
  binds: ["tcp://0.0.0.0:9292"],
84
85
  socket_backlog: 1024,
85
86
  drain_accept_queue: false,
86
- workers: 4,
87
+ workers: 4, # `Etc.nprocessors`
87
88
  ractors: 1,
88
89
  threads: 3,
89
90
  chdir: nil,
90
- environment: nil,
91
+ environment: nil, # falls back to `RAILS_ENV`, then `RACK_ENV`, then `"development"`
91
92
  connection: {
92
93
  first_data_timeout: 30,
93
94
  chunk_data_timeout: 10,
@@ -106,6 +107,11 @@ The config file is a Ruby file that evaluates to a hash of options. By default R
106
107
  worker_timeout: 60,
107
108
  worker_drain_timeout: 25,
108
109
  worker_shutdown_timeout: 30,
110
+ refork_after: 1000, # `nil` on non-Linux
111
+ before_fork: [],
112
+ before_worker_boot: [],
113
+ before_worker_shutdown: [],
114
+ before_refork: [],
109
115
  stats_file: "tmp/raptor.json",
110
116
  pid_file: nil,
111
117
  stdout_file: nil,
@@ -187,20 +193,30 @@ Worker 1 (phase 0): pid=91351, requests=1199, busy=1/3, backlog=0, booted, last_
187
193
 
188
194
  ## (Micro) Benchmarks
189
195
 
190
- Raptor 0.11.0 vs Puma 8.0.2, median of 3 runs across two workload profiles: IO-bound (sleep for a random 1-10ms then
191
- return small JSON) and CPU-bound (serialise a JSON array of 20-200 items).
192
-
193
- | Protocol | Workload | Raptor | Puma | +/- vs Puma |
194
- | --------------------- | -------- | ------------ | ------------ | ----------- |
195
- | HTTP/1.1 | IO | 2.05k req/s | 1.94k req/s | +5.7% |
196
- | HTTP/1.1 | CPU | 13.66k req/s | 9.21k req/s | +48.3% |
197
- | HTTP/1.1 (keep-alive) | IO | 2.08k req/s | 1.95k req/s | +6.6% |
198
- | HTTP/1.1 (keep-alive) | CPU | 27.85k req/s | 27.47k req/s | +1.4% |
199
- | HTTP/2 | IO | 0.96k req/s | N/A | - |
200
- | HTTP/2 | CPU | 28.92k req/s | N/A | - |
196
+ Raptor 0.12.0 vs Puma 8.0.2 vs Falcon 0.55.5 across two workload profiles. **IO-bound** is a GET endpoint that
197
+ interleaves 5-10 short sleeps (total 2.5-15ms) with small CPU work, simulating a read path that makes several DB or
198
+ cache calls. **CPU-bound** is a POST endpoint that accepts a small JSON body, interleaves 3-5 chunks of JSON item
199
+ building (total 450-1500 items) with sub-100µs sleeps, and returns the built array, simulating a write path that does
200
+ most of its work in Ruby with a few near-zero-cost cache hits.
201
+
202
+ Each cell reports the median throughput and median p95 latency independently across 5 runs, so the two numbers in a row
203
+ may come from different runs. Every run starts a fresh server process so the samples are independent of each other;
204
+ state accumulated in a previous run cannot bias the next. Across the whole table, the widest spread
205
+ ((max - min) / 2 / median) between runs of a single cell was ±22.4% for throughput
206
+ and ±31.1% for p95.
207
+
208
+ | Protocol | Workload | Raptor req/s | Raptor p95 | Puma req/s | Puma p95 | vs Puma req/s | vs Puma p95 | Falcon req/s | Falcon p95 | vs Falcon req/s | vs Falcon p95 |
209
+ | --------------------- | -------- | ------------ | ---------- | ----------- | -------- | ------------- | ----------- | ------------ | ---------- | --------------- | ------------- |
210
+ | HTTP/1.1 | IO | 1.36k req/s | 52.90 ms | 0.94k req/s | 66.00 ms | +44.6% | -19.8% | 4.65k req/s | 14.60 ms | -70.8% | +262.3% |
211
+ | HTTP/1.1 | CPU | 3.46k req/s | 28.10 ms | 3.58k req/s | 16.70 ms | -3.3% | +68.3% | 3.57k req/s | 16.30 ms | -3.1% | +72.4% |
212
+ | HTTP/1.1 (keep-alive) | IO | 1.33k req/s | 34.60 ms | 0.94k req/s | 65.40 ms | +41.6% | -47.1% | 4.17k req/s | 16.70 ms | -68.1% | +107.2% |
213
+ | HTTP/1.1 (keep-alive) | CPU | 4.04k req/s | 12.10 ms | 3.73k req/s | 17.50 ms | +8.4% | -30.9% | 3.70k req/s | 17.90 ms | +9.1% | -32.4% |
214
+ | HTTP/2 | IO | 0.69k req/s | 87.49 ms | N/A | N/A | - | - | 4.04k req/s | 17.11 ms | -83.0% | +411.3% |
215
+ | HTTP/2 | CPU | 3.23k req/s | 22.63 ms | N/A | N/A | - | - | 3.85k req/s | 24.38 ms | -16.2% | -7.2% |
201
216
 
202
217
  > ruby 4.0.5 (2026-05-20 revision 64336ffd0e) +YJIT +PRISM [aarch64-linux]
203
- > 4 workers, 3 threads, 48 concurrent connections
218
+ > 4 worker processes; Raptor and Puma run 3 threads per worker, Falcon runs unbounded fibers per worker;
219
+ > 48 concurrent HTTP/1.1 client connections; 16 concurrent HTTP/2 client connections × 3 streams each
204
220
 
205
221
  See [bin/benchmark](bin/benchmark) for more details.
206
222
 
@@ -1,6 +1,6 @@
1
1
  # Raptor vs Puma: A Design Comparison
2
2
 
3
- Raptor is a Ruby web server designed around Ruby 4's Ractors. On CPU-bound HTTP/1.1 workloads it holds a real edge over the latest Puma release on the same hardware; on IO-bound work where the request sleep dominates, both servers land close to parity. It also speaks HTTP/2 natively, which Puma does not. This document is a systems-design walkthrough of how it manages both.
3
+ Raptor is a Ruby web server designed around Ruby 4's Ractors. On CPU-bound HTTP/1.1 with keep-alive it holds a real edge over the latest Puma release on the same hardware; on IO-heavy work fibers still win, and Falcon is the server to compare to there rather than Raptor or Puma. Raptor also speaks HTTP/2 natively, which Puma does not. This document is a systems-design walkthrough of how it manages both.
4
4
 
5
5
  ## Why this document exists
6
6
 
@@ -10,14 +10,26 @@ They did, but the more interesting result was structural. Once you commit to a R
10
10
 
11
11
  This document walks through both servers at systems-design depth. By the end you should be able to describe how Puma and Raptor each move a request from `accept()` to `app.call(env)` and back, why the two architectures make the design decisions they do, and where the performance delta actually comes from. No source code required.
12
12
 
13
+ ## A note on Falcon
14
+
15
+ Falcon is the other next-generation Ruby web server worth naming. It takes a different bet than either Puma or Raptor: fiber-based concurrency via the `async` gem instead of threads, native HTTP/2, per-request lightweight tasks instead of a fixed thread pool. Its strengths show up on workloads with lots of concurrent long-lived connections (WebSockets, SSE, streaming), applications built end-to-end on the `async` ecosystem, and HTTP/2-heavy traffic. On a traditional Rails workload where most of the request budget is a synchronous DB round-trip through ActiveRecord, its advantages over Puma are less pronounced because the fiber scheduler only helps when the underlying I/O is fiber-aware. If your app fits that sweet spot, Falcon belongs in your evaluation.
16
+
17
+ This document focuses on Puma because Puma is the incumbent that any new Ruby web server has to justify itself against; that is the comparison most readers actually need. Falcon appears in the benchmark table in the README as a third data point, but a full design comparison against Falcon would be its own document.
18
+
13
19
  ## The shape of the benchmark
14
20
 
15
- The Raptor README carries the [current head-to-head numbers](../README.md#micro-benchmarks) against the latest Puma release, run on the same hardware with the same Rack app, 4 workers, 3 threads, 48 concurrent connections, on a recent Ruby with YJIT enabled. Two workload profiles are measured: **IO-bound** (each request sleeps for a random 1-10ms then returns a small JSON response) and **CPU-bound** (each request serialises a JSON array of 20-200 items). Rather than pin specific numbers into this document (they drift with Ruby versions and hardware), the shape of the result is what matters.
21
+ Raptor is a research project. It hasn't run production traffic. The numbers in the README come from a repeatable microbenchmark, not from a real deployment. The benchmark measures only the **server** work: accepting connections, parsing, dispatching, and writing responses. It does not measure your application. In a typical Rails app where most of a request's time goes to ActiveRecord and downstream services, the server accounts for maybe 5 to 15 percent of the total, so a +N% number in this table will show up as a much smaller improvement in production. The gap is real, but it isn't what a Rails app against a real database will report.
22
+
23
+ The Raptor README carries the [current head-to-head numbers](../README.md#micro-benchmarks) against the latest Puma and Falcon releases, run on the same hardware with the same Rack app on a recent Ruby with YJIT enabled. All three servers run four worker processes; Raptor and Puma use three threads per worker, and Falcon uses unbounded fibers per worker. Load generators use 48 concurrent HTTP/1.1 client connections, and 16 h2 connections × 3 streams each for the h2 rows.
16
24
 
17
- - On IO-bound work, both servers land near parity across all protocols; throughput is bounded by the per-request sleep and the benchmark client's concurrency, not by anything the server does.
18
- - On CPU-bound HTTP/1.1 without keep-alive, Raptor holds a meaningful lead; per-request parsing and response-writing overhead is a real fraction of the work.
19
- - On CPU-bound HTTP/1.1 with keep-alive, Raptor still leads but by a smaller margin; the app thread's JSON-building work dominates.
20
- - On HTTP/2 there is no comparison, since Puma does not implement it.
25
+ Two workload profiles are measured. **IO-bound** is a GET endpoint that does 5 to 10 short sleeps interleaved with small CPU work per request, simulating a read path that makes several DB or cache calls throughout its lifetime. **CPU-bound** is a POST endpoint with a small JSON body that builds a JSON response in 3 to 5 chunks interleaved with sub-100µs sleeps, simulating a write path that does most of its work in Ruby with a few near-zero-cost cache hits. The workloads are interleaved rather than a single bulk sleep or single bulk serialise so a fiber-per-connection server like Falcon doesn't look artificially good from one-shot IO, and the CPU-bound workload is heavily CPU-dominated by design (roughly 95% CPU / 5% IO by wall time) so it actually measures CPU work rather than smuggling in enough IO for fibers to multiplex.
26
+
27
+ Each cell in the table reports the median throughput and median p95 latency independently across 5 runs, and every run boots a fresh server process so state cannot accumulate across measurements. Rather than pin specific numbers into this document (they drift with Ruby versions and hardware), the shape of the result is what matters.
28
+
29
+ - On IO-bound work, Falcon is roughly 3-4x ahead of both thread-based servers; the fiber-per-connection model can keep every one of the 48 client connections in flight at once, while Raptor and Puma cap out at their 12 threads. Between the two thread-based servers, Raptor holds a clear lead over Puma on both throughput and p95.
30
+ - On CPU-bound HTTP/1.1 without keep-alive, the three servers land close together on throughput. Raptor's p95 is meaningfully worse than Puma's and Falcon's here; the extra hop through the pipeline shows up as tail latency when connection setup and teardown are also on the critical path.
31
+ - On CPU-bound HTTP/1.1 with keep-alive, Raptor pulls ahead of both on throughput and p95. Connection setup is amortised, the eager keep-alive loop keeps requests on the same app thread, and the Ractor-parallel parser earns its cost when many small requests are in flight.
32
+ - On HTTP/2, Raptor and Falcon both implement it; Puma doesn't. On CPU-bound h2 Raptor holds a small edge on throughput and a meaningful one on p95; on IO-bound h2 Falcon dominates for the same reason it dominates h1 IO. This matters if you're terminating h2 at the app server, less so if nginx or another proxy in front is already handling it.
21
33
 
22
34
  The rest of this doc explains why the shape looks like that.
23
35
 
@@ -73,7 +85,7 @@ The server thread is straightforward. It calls `IO.select` on all listener socke
73
85
 
74
86
  What happens next depends on `queue_requests`. `queue_requests` defaults to true and is the mode almost everyone runs in. When it is true, a thread-pool worker picks up the client and calls `client.eagerly_finish`, which does one non-blocking read attempt and one attempt at parsing. If the request is complete after that single read, the worker proceeds to invoke the Rack app inline. If it is not complete, the worker hands the client to the reactor and returns to the pool. When `queue_requests` is false, the worker skips the eagerly_finish attempt and does a blocking `client.finish(first_data_timeout)`. The `queue_requests: false` path is fine for fast trusted clients but does not scale; a slow client will hold a thread indefinitely.
75
87
 
76
- The reactor is a separate thread running `Puma::Reactor`, which wraps an `NIO::Selector` from nio4r. Its job is to babysit two kinds of sockets:
88
+ The reactor is a separate thread running `Puma::Reactor`, which wraps an `NIO::Selector` from `nio4r`. Its job is to babysit two kinds of sockets:
77
89
 
78
90
  1. **Sockets in the middle of a request.** A client sent some headers, was not yet complete, and needs more data before the app can be called.
79
91
  2. **Keep-alive sockets between requests.** The previous request finished, the connection is still alive, and we are waiting for the next request to start.
@@ -184,6 +196,20 @@ Master-to-worker communication does not use pipes. Every worker writes its stats
184
196
 
185
197
  On Linux, each worker pins itself to a distinct CPU via `sched_setaffinity` when the worker count fits within the process's allowed CPU set, so it stays on one core and its L1/L2 caches stay warm. When workers outnumber available CPUs the pin is skipped and the kernel scheduler manages placement.
186
198
 
199
+ ### Refork
200
+
201
+ Long-lived workers accumulate mutations. Every allocation, every YJIT-compiled block, every gem that lazy-loads on first use, every cache that populates over the first few thousand requests, all of it dirties pages that were shared copy-on-write with the master. After a few hours of traffic the CoW savings are mostly gone and each worker's RSS climbs toward its steady-state size. Reforking off a warmed source is the standard answer.
202
+
203
+ Raptor's version is driven by `refork_after`, an `Integer`, `Array<Integer>`, or `nil`. When any worker's request count crosses the next threshold, the master picks the most-experienced booted worker in the current phase and sends it `SIGURG`. That worker breaks out of its main loop, runs any `before_refork` hooks (typically to close database connections and any other file descriptors the fresh workers should not inherit), drains in-flight requests, and then transitions into a seed loop instead of exiting. From that point on the seed's only job is to fork replacement workers on the master's request; it never accepts another connection. The master then rolls the remaining workers through a phased restart, forking each replacement from the seed.
204
+
205
+ Successive thresholds promote fresh seeds. When the next threshold trips, the master picks a new candidate from the currently-serving generation, terminates the previous seed, and starts the same drain-and-promote cycle on the new candidate. Each generation's fork source is therefore a worker that has served enough traffic to warm its VM, its YJIT caches, and its lazy-loaded gems, and the previous generation's seed is retired the moment it is no longer needed. Passing an array to `refork_after` (e.g. `[1000, 5000, 20_000]`) sets successive thresholds; the last one repeats.
206
+
207
+ Master and seed communicate over two pipes opened before the initial fork. The fork pipe (`@fork_r`, `@fork_w`) carries an 8-byte packed `[slot_index, phase]` from master to seed for each replacement to fork. The response pipe (`@resp_r`, `@resp_w`) carries a 4-byte packed pid back the other way, plus a one-shot 4-byte zero the seed writes when it enters its loop so the master knows drain is complete. The master polls the ready marker non-blockingly on the supervision loop; the phased restart that fills the vacated slot and cycles the remaining workers only starts once the marker arrives (or the drain deadline expires, in which case the master clears the seed reference and falls back to direct forks).
208
+
209
+ Workers forked by the seed are not the master's direct children. Before spawning anything the master calls `PR_SET_CHILD_SUBREAPER` (via a small `Raptor::Subreaper` C extension wrapping `prctl`), so seed-forked children reparent to the master immediately and `Process.wait2` sees them like any other child. When a seed is retired on the next promotion its still-running descendants stay attached to the master through the same subreaper bit. Refork depends on this: on platforms without `PR_SET_CHILD_SUBREAPER` — everything except Linux — `refork_after` defaults to `nil` and is ignored with a warning if the user sets it anyway.
210
+
211
+ The design is Pitchfork's, adapted for Raptor's process model. [Pitchfork](https://github.com/Shopify/pitchfork) was the first Ruby server built around `PR_SET_CHILD_SUBREAPER` and multi-generation mould promotion; Puma's shipping `fork_worker` keeps worker 0 serving traffic alongside its fork-server duties, and Instacart's [`mold_worker` PR #3643](https://github.com/puma/puma/pull/3643) is Puma catching up to the same shape Pitchfork established. Raptor's seed matches the Pitchfork shape: pure fork source, promoted from a warm worker, retired when the next generation takes over.
212
+
187
213
  ### Threading model
188
214
 
189
215
  This is where Raptor diverges dramatically. Inside a worker there are five kinds of concurrent activity:
@@ -227,9 +253,11 @@ next if @reactor.backlog >= backpressure_threshold
227
253
 
228
254
  where `@reactor.backlog` is `thread_pool.queue_size + thread_pool.active_count`. If the total load on the thread pool is at 120% of the pool size, this worker stops accepting until it drains. `MIN_BACKPRESSURE_THRESHOLD` is 8, so small pools (say, 3 threads) trip backpressure at 8 concurrent items rather than at 120% of a very small number; the floor keeps saturated workers signaling early so the load-aware dispatcher (below) has time to route around them.
229
255
 
230
- Because Raptor is always in cluster mode and every worker listens in the same `SO_REUSEPORT` group, load balancing across workers happens at the kernel level. On Linux, Raptor attaches a small BPF program to the reuseport group. Each worker binds its own listener registered in a sockmap, and a dedicated reporter thread publishes the worker's current reactor backlog into a loads map at 100 Hz. The BPF program consults the map on every incoming connection and routes it to the least-loaded worker, with a random tie-break when loads are equal. If the `libbpf-ruby` gem is not installed or the BPF object has not been compiled, Raptor falls back silently to the default four-tuple-hash routing; if the kernel refuses the program, startup raises. Either way, if a worker is saturated and stops calling `accept`, other workers pick up the slack.
256
+ Because Raptor is always in cluster mode and every worker listens in the same `SO_REUSEPORT` group, load balancing across workers happens at the kernel level. On Linux, Raptor attaches a small BPF program to the reuseport group. Each worker binds its own listener registered in a sockmap, and a dedicated reporter thread publishes the worker's current reactor backlog into a loads map at 100 Hz. The BPF program consults the map on every incoming connection and routes it to the least-loaded worker; when all workers report equal load (typical of a fresh accept burst), the program falls back to the connection's 4-tuple hash, which spreads accepts more evenly than a random draw across a small worker count. If the `libbpf-ruby` gem is not installed or the BPF object has not been compiled, Raptor falls back silently to the default four-tuple-hash routing; if the kernel refuses the program, startup raises. Either way, if a worker is saturated and stops calling `accept`, other workers pick up the slack.
257
+
258
+ The BPF-based approach was inspired by [a comment](https://github.com/puma/puma/issues/3934#issuecomment-4356462590) by John Hawthorn ([@jhawthorn](https://github.com/jhawthorn)) on a Puma issue about `EPOLLEXCLUSIVE`, where he floated `SO_ATTACH_REUSEPORT_EBPF` as a way to route each connection to the least-busy worker.
231
259
 
232
- The reactor is again an NIO::Selector loop. Two things make it different from Puma's:
260
+ The reactor is again an `NIO::Selector` loop. Two things make it different from Puma's:
233
261
 
234
262
  1. **Read strategy.** When a socket is readable, the reactor does one `read_nonblock(64KB)` right there in the reactor thread, updates the buffered state for that connection, and only then decides what to do. If the request is not yet complete, the state stays in the reactor and awaits more data. If it is complete (headers parsed, body received), the state is pushed to the Ractor pool for parsing (or, for HTTP/2, straight to the parser). The reactor does not try to parse; it does the I/O and hands off the raw buffer.
235
263
 
@@ -290,10 +318,10 @@ From there the shape is similar to HTTP/1.1:
290
318
 
291
319
  1. Reactor reads frames.
292
320
  2. The HTTP/2 parser (native C, with an HPACK decoder using a static Huffman table) parses the frames in the Ractor pool.
293
- 3. Completed requests (once HEADERS+DATA is complete for a stream) go to the thread pool as separate work items. **A single connection can be servicing many streams in parallel across the thread pool.**
321
+ 3. Completed requests (once `HEADERS` and `DATA` are complete for a stream) go to the thread pool as separate work items. **A single connection can be servicing many streams in parallel across the thread pool.**
294
322
  4. Each stream's response is written back through the connection's `Writer`, which serialises frame writes across threads without a mutex.
295
323
 
296
- The Writer is worth a paragraph. Naive per-connection writing would use a mutex: acquire, write, release. Contention grows with concurrent streams. Raptor's Writer stores the "pending frames" queue in an `Atom` whose value is either `:idle` (nobody is writing) or an array of frames waiting to go out. A thread that wants to write does a CAS:
324
+ The `Writer` is worth a paragraph. Naive per-connection writing would use a mutex: acquire, write, release. Contention grows with concurrent streams. Raptor's `Writer` stores the "pending frames" queue in an `Atom` whose value is either `:idle` (nobody is writing) or an array of frames waiting to go out. A thread that wants to write does a CAS:
297
325
 
298
326
  - If current value is `:idle`, the thread claims the writer by CAS-ing to its own array of frames, then loops draining any additional frames other threads have appended.
299
327
  - If current value is an array (someone is already writing), the thread CAS-appends its frames and returns immediately; the current writer will pick them up and flush them.
@@ -399,7 +427,7 @@ At a moderate 100 concurrent connections this is a rounding error. At 1000+ (whi
399
427
 
400
428
  **Puma.** Standard `Mutex` + `ConditionVariable` + Array-as-queue. Every enqueue takes the mutex, wakes a waiter. Every dequeue takes the mutex to check the queue. Under contention, the mutex is a serialisation point.
401
429
 
402
- **Raptor.** CAS on an Atom holding a frozen `{in:, out:, count:}` hash (Banker's queue). Enqueue is a single CAS to prepend to the in-stack. Dequeue is a single CAS to pop from the out-stack (or, if empty, atomically flip in/out). Metrics (queue length, active count) are lock-free reads.
430
+ **Raptor.** CAS on an `Atom` holding a frozen `{in:, out:, count:}` hash (Banker's queue). Enqueue is a single CAS to prepend to the in-stack. Dequeue is a single CAS to pop from the out-stack (or, if empty, atomically flip in/out). Metrics (queue length, active count) are lock-free reads.
403
431
 
404
432
  The condvar is still there for parking idle threads (spinning would burn CPU), but the hot path when the queue has items is lock-free.
405
433
 
@@ -431,10 +459,12 @@ The performance difference here is negligible. Stats writing is one syscall per
431
459
 
432
460
  ### HTTP/2
433
461
 
434
- **Puma.** Not implemented. `alpn_protocols` is not advertised, so h2 clients fall back to h1.
462
+ **Puma.** Not implemented. Puma's [position](https://github.com/puma/puma/issues/2697) is that HTTP/2 belongs at the edge (nginx, Caddy, ALB), which terminates it and speaks HTTP/1.1 to the app server. That's a reasonable call for the deployments Puma is aimed at, and it's where most Rails production actually sits.
435
463
 
436
464
  **Raptor.** Native C parser plus HPACK, per-stream flow control, lock-free frame writer, stream multiplexing over a single connection. Same request path as HTTP/1.1 once a request is complete: it enters the same thread pool. Under HTTP/2, a single client connection can be issuing many concurrent requests, and Raptor services all of them in parallel on the same thread pool.
437
465
 
466
+ Whether that matters depends on your setup. If you terminate TLS at an edge proxy that already speaks HTTP/2, both servers see HTTP/1.1 and it doesn't matter which of them you pick on this axis. If you're building an all-Ruby stack with no proxy in front, running gRPC or similar all the way through, or measuring the app server itself, HTTP/2 support is where Raptor and Puma stop being comparable.
467
+
438
468
  At the throughput numbers the benchmark shows, with only a handful of concurrent connections each multiplexing many streams, Raptor is exercising the HTTP/2 multiplexing hard: many in-flight streams sharing one socket, all writing responses through the same connection. The writer's CAS-based coordination avoids the mutex contention that would otherwise cap throughput.
439
469
 
440
470
  ### Response writing
@@ -517,27 +547,33 @@ Puma has to bounce Request 3 through the reactor and back through the mutex-prot
517
547
 
518
548
  ## Part IV: What Raptor's design buys you
519
549
 
520
- ### IO-bound work, close to parity
550
+ ### IO-bound work, where Falcon wins and Raptor clearly beats Puma
551
+
552
+ On the IO-bound benchmark profile, each request does 5 to 10 short sleeps interleaved with small CPU work, simulating a request that makes several DB or cache calls throughout its lifetime. The bottleneck is how many requests a worker can keep in flight while they wait on IO. Raptor and Puma cap out at their 12 total threads (4 workers × 3), so with 48 client connections the extra 36 sit in the pool queue at any given moment. Falcon spawns a fiber per connection and cooperatively yields on every sleep, so all 48 requests can be in flight simultaneously. That gap shows up as roughly 3-4x throughput and much lower p95 for Falcon.
553
+
554
+ Between the thread-based servers, Raptor holds a clear lead over Puma on both throughput and p95. The eager accept path, writev-batched responses, and lock-free work queue all shave a bit of per-request time, and those savings stack.
555
+
556
+ Real applications that spend most of their time waiting on a database or an upstream service look like this. If your app is IO-heavy and you're free to adopt the `async` ecosystem, Falcon is the interesting comparison there, not Raptor or Puma.
521
557
 
522
- On the IO-bound benchmark profile, each request sleeps for a random 1-10ms then returns a small JSON payload. Throughput is bounded by the per-request sleep and the benchmark client's 48-connection concurrency; both servers push those requests through their pipelines faster than the sleep completes, so what shows up in the numbers is the workload cost itself, not the server cost. Raptor and Puma land within a few percent of each other in both keep-alive modes because there is nothing to differentiate.
558
+ ### CPU-bound HTTP/1.1, where the pool model earns its keep
523
559
 
524
- Real applications that spend most of their time waiting on a database or an upstream service look like this. The choice between Raptor and Puma there is not throughput-driven.
560
+ On the CPU-bound benchmark profile, each POST request accepts a small JSON body and builds a JSON response in 3 to 5 chunks totalling 450 to 1500 items, with sub-100µs sleeps between chunks. It's roughly 95% CPU by wall time, so fibers can't multiplex their way to an advantage. The CPU work happens under a single Ruby VM regardless of concurrency model.
525
561
 
526
- ### CPU-bound HTTP/1.1, where the edge shows up
562
+ **Without keep-alive**, every request opens a fresh TCP connection, gets parsed, dispatched, served, and closes. All three servers land within a few percent of each other on throughput. Raptor's p95 is meaningfully worse than Puma's and Falcon's here; the extra hop through the Ractor pipeline shows up as tail latency when connection setup and teardown are also on the critical path.
527
563
 
528
- On the CPU-bound benchmark profile, each request serialises a JSON array of 20-200 items. Per-request cost is real Ruby work, and the coordination overhead of pushing a request through the server matters.
564
+ **With keep-alive**, Raptor pulls ahead of both Puma and Falcon on throughput and p95. Connection setup is amortised, the eager keep-alive loop keeps subsequent requests on the same app thread, and the Ractor-parallel parser earns its cost when many small requests are in flight. This is the shape of workload Raptor's design was optimised for.
529
565
 
530
- **Without keep-alive**, every request opens a fresh TCP connection, gets parsed, dispatched, served, and closes. Raptor lands materially ahead of Puma. The wins that individually add a few percent (writev-batched response writes, CAS-based work queue, per-worker CPU affinity) stack more prominently here because per-request coordination is a larger fraction of total time than under keep-alive.
566
+ ### HTTP/2, when it matters
531
567
 
532
- **With keep-alive**, Raptor still leads but by a smaller margin. Every connection amortises accept and TCP-setup costs to zero, and the app thread's JSON-building work dominates the per-request budget. The eager keep-alive loop keeps both servers close to their pool ceiling, and Raptor's coordination-overhead advantages become a smaller relative slice.
568
+ Puma doesn't implement HTTP/2, and most Rails production terminates HTTP/2 at nginx or a similar edge proxy before it reaches the app server. If that describes your stack, Raptor's HTTP/2 support isn't going to help you. Both servers see HTTP/1.1 from the proxy and the throughput numbers above are what actually matter. Puma's [position](https://github.com/puma/puma/issues/2697) is that this is where h2 belongs, and it's a reasonable one.
533
569
 
534
- Raptor's eager keep-alive loop is still doing real work here; it keeps the socket on the same app thread for the entire burst, avoiding the reactor round-trip Puma has to make between requests. But when the work per request is large enough, that round-trip is a smaller share of the total.
570
+ Where Raptor's HTTP/2 support does matter is the all-Ruby stack. No proxy in front, TLS terminated at the app, clients speaking h2 all the way through (`curl --http2`, gRPC-Ruby transports, HTTP/2-only SDKs, direct browser connections). In that setup, Puma silently falls back to HTTP/1.1 and you lose multiplexing, header compression, and prioritisation.
535
571
 
536
- ### HTTP/2, a category to itself
572
+ Falcon also speaks HTTP/2 natively, so it's the interesting comparison there rather than Puma. The shape of the h2 result follows the h1 shape. On CPU-bound h2 Raptor holds a small edge on throughput and a meaningful one on p95, for the same reasons it wins CPU-bound h1 keep-alive. Parsing runs in parallel with the app, the lock-free writer serialises frames without a mutex, and per-stream flow-control atoms don't contend. On IO-bound h2 Falcon wins by a wide margin, again because fibers can keep every stream in flight simultaneously while Raptor's thread pool caps concurrency.
537
573
 
538
- Puma does not implement HTTP/2. Raptor does, natively, with a Ractor-safe parser, HPACK, per-stream flow control, and a lock-free frame writer. If your clients default to h2 (most browsers, `curl --http2`, gRPC-flavoured stacks, most modern SDKs), or if you terminate TLS at your app rather than upstream, this alone is decisive: on Puma you silently fall back to HTTP/1.1 and lose multiplexing, header compression, and prioritisation.
574
+ Raptor's h2 IO numbers still show some run-to-run variance where h1 doesn't. With only 16 physical connections spread across four workers, even the BPF program's hash-based tie-break can leave one worker over-subscribed, and the concentrated worker becomes the bottleneck for that whole run. h2 CPU stays tight because once workers report distinguishable load the load-aware routing has enough signal to place new connections cleanly regardless of tie-break behaviour.
539
575
 
540
- Beyond parity, Raptor's HTTP/2 CPU-bound throughput in the benchmark is on the same order as its HTTP/1.1 keep-alive throughput on the same profile, despite each connection multiplexing dozens of concurrent streams into a single socket. That only happens if the per-stream coordination is essentially free. The lock-free `Writer` and flow-control atoms are doing real work here. If they used mutexes, throughput would be capped by lock contention rather than by CPU.
576
+ Beyond that, Raptor's HTTP/2 CPU-bound throughput in the benchmark is on the same order as its HTTP/1.1 keep-alive throughput on the same profile, despite each connection multiplexing dozens of concurrent streams into a single socket. That only happens if the per-stream coordination is essentially free. The lock-free `Writer` and flow-control atoms are doing real work here. If they used mutexes, throughput would be capped by lock contention rather than by CPU.
541
577
 
542
578
  ## Part V: What Raptor gives up
543
579
 
@@ -547,22 +583,4 @@ No design comes free. Two disclosures matter most.
547
583
 
548
584
  **Ruby version.** Raptor requires Ruby 4.0 because it depends on `Ractor::Port` and on Ractor internals having stabilised. Puma works on 3.0 and up. If you need to support older Ruby, Puma wins by default.
549
585
 
550
- A handful of smaller trade-offs are worth naming briefly. Raptor pays some minimum-latency overhead per request because of the Ractor crossing, so at extremely low concurrency Puma's simpler flow can edge ahead; the eager-parse path on the server thread hides most of this in practice. Raptor's core dependencies (`ractor-pool`, `atomic-ruby`, `red-black-tree`, `mmap-ruby`, `libbpf-ruby`) are libraries I wrote specifically to make it work, which is either "purpose-built" or "narrower testing surface" depending on how you look at it. Debugging is harder because control flow crosses Ractor and thread boundaries, so tracing a request end-to-end means stitching several stack traces together. Raptor has no single-process mode; on a single-CPU container it wastes a small amount of coordination overhead. And Puma's `fork_worker: true` refork mode is a genuinely clever answer to CoW decay over time that Raptor has no equivalent for.
551
-
552
- ## Part VI: The design in one line each
553
-
554
- If you had to compress this whole document into a handful of one-line framings, these are the ones that carry the most:
555
-
556
- - **"Puma is a thread pool with a reactor bolted on. Raptor is a pipeline with each stage on its own scheduling primitive."** Puma's core abstraction is the thread pool; the reactor exists to feed it. Raptor's core abstraction is the pipeline; the thread pool is one stage in it.
557
-
558
- - **"The GVL is not the enemy. Serialising parsing behind the GVL is the enemy."** Ruby threads are fine for I/O-bound work; the app thread spends most of its time waiting on the database. What you cannot afford is spending CPU cycles under the GVL doing work that does not need the GVL, like tokenising HTTP headers.
559
-
560
- - **"Ractors are impractical for user code but perfect for protocol code."** The very things that make Ractors annoying for application code (frozen data only, no shared mutable state) are exactly what you want in a parser: a pure function from bytes to headers with no side effects.
561
-
562
- - **"Lock-free is not always faster, but it is always less variable."** A CAS-based queue does not out-perform a mutex in every scenario, but its worst-case latency is much better. Under contention, mutex-based code can suffer thundering herd wakeups and cache-line bouncing that a CAS design avoids.
563
-
564
- - **"The keep-alive edge is not one big win, it is many small wins that stack."** Eager keep-alive, RBT timeouts, CAS work queue, Ractor parsing, lock-free backpressure. Each adds a few percent and stacked they produce a measurable lead.
565
-
566
- - **"HTTP/2 is not a nice-to-have; it changes the arithmetic."** With HTTP/2, one client connection can be doing dozens of requests concurrently. Suddenly every serialisation point in the server becomes a bottleneck. The lock-free primitives Raptor uses for HTTP/1.1 are not optional for HTTP/2; they are structural.
567
-
568
- - **"This is what Ruby 4 lets you do."** `Ractor::Port`, `Ractor.shareable_proc`, `rb_ext_ractor_safe`, native-extension Ractor safety. These are all recent additions. Raptor is essentially a demo of what becomes practical when you have them.
586
+ A handful of smaller trade-offs are worth naming briefly. Raptor pays some minimum-latency overhead per request because of the Ractor crossing, so at extremely low concurrency Puma's simpler flow can edge ahead; the eager-parse path on the server thread hides most of this in practice. Raptor's core dependencies (`ractor-pool`, `atomic-ruby`, `red-black-tree`, `mmap-ruby`, `libbpf-ruby`) are libraries I wrote specifically to make it work, which is either "purpose-built" or "narrower testing surface" depending on how you look at it. Debugging is harder because control flow crosses Ractor and thread boundaries, so tracing a request end-to-end means stitching several stack traces together. Raptor has no single-process mode; on a single-CPU container it wastes a small amount of coordination overhead.
@@ -20,7 +20,8 @@ struct {
20
20
  } loads SEC(".maps");
21
21
 
22
22
  // Routes each incoming connection to the worker with the lowest backlog,
23
- // with a random tie-break when loads are equal.
23
+ // falling back to the connection's 4-tuple hash when loads are equal so
24
+ // bursts of accepts spread across workers instead of clustering by chance.
24
25
  SEC("sk_reuseport")
25
26
  int select_least_loaded(struct sk_reuseport_md *ctx) {
26
27
  __u32 count_key = 0;
@@ -55,7 +56,7 @@ int select_least_loaded(struct sk_reuseport_md *ctx) {
55
56
  }
56
57
  }
57
58
 
58
- __u32 chosen_idx = (min_load == max_load) ? (bpf_get_prandom_u32() % num_workers) : min_idx;
59
+ __u32 chosen_idx = (min_load == max_load) ? (ctx->hash % num_workers) : min_idx;
59
60
  bpf_sk_select_reuseport(ctx, &socks, &chosen_idx, 0);
60
61
  return SK_PASS;
61
62
  }
@@ -10,6 +10,7 @@
10
10
 
11
11
  #ifdef __linux__
12
12
  #include <sched.h>
13
+ #include <sys/prctl.h>
13
14
  #include <unistd.h>
14
15
  #endif
15
16
 
@@ -82,18 +83,31 @@ static VALUE raptor_native_cpu_count(VALUE self) {
82
83
  #endif
83
84
  }
84
85
 
86
+ static VALUE raptor_native_enable_subreaper(VALUE self) {
87
+ (void)self;
88
+ #if defined(__linux__) && defined(PR_SET_CHILD_SUBREAPER)
89
+ if (prctl(PR_SET_CHILD_SUBREAPER, 1, 0, 0, 0) < 0) rb_sys_fail("prctl");
90
+ return Qtrue;
91
+ #else
92
+ return Qfalse;
93
+ #endif
94
+ }
95
+
85
96
  RUBY_FUNC_EXPORTED void Init_raptor_native(void) {
86
97
  rb_ext_ractor_safe(true);
87
98
 
88
99
  VALUE mRaptor = rb_define_module("Raptor");
89
- VALUE mVectorIO = rb_define_module_under(mRaptor, "VectorIO");
90
- VALUE mCPU = rb_define_module_under(mRaptor, "CPU");
91
100
 
101
+ VALUE mVectorIO = rb_define_module_under(mRaptor, "VectorIO");
92
102
  rb_define_singleton_method(mVectorIO, "writev_nonblock", raptor_native_writev_nonblock, 2);
93
103
 
104
+ VALUE mCPU = rb_define_module_under(mRaptor, "CPU");
94
105
  rb_define_singleton_method(mCPU, "pin", raptor_native_pin_to_cpu, 1);
95
106
  rb_define_singleton_method(mCPU, "count", raptor_native_cpu_count, 0);
96
107
 
108
+ VALUE mSubreaper = rb_define_module_under(mRaptor, "Subreaper");
109
+ rb_define_singleton_method(mSubreaper, "enable", raptor_native_enable_subreaper, 0);
110
+
97
111
  eEAGAINWaitWritable = rb_const_get(rb_cIO, rb_intern("EAGAINWaitWritable"));
98
112
  rb_global_variable(&eEAGAINWaitWritable);
99
113
  }
data/lib/raptor/cli.rb CHANGED
@@ -56,6 +56,11 @@ module Raptor
56
56
  worker_timeout: 60,
57
57
  worker_drain_timeout: 25,
58
58
  worker_shutdown_timeout: 30,
59
+ refork_after: (RUBY_PLATFORM.include?("linux") ? 1000 : nil),
60
+ before_fork: [].freeze,
61
+ before_worker_boot: [].freeze,
62
+ before_worker_shutdown: [].freeze,
63
+ before_refork: [].freeze,
59
64
  stats_file: "tmp/raptor.json",
60
65
  pid_file: nil,
61
66
  stdout_file: nil,
@@ -312,6 +317,10 @@ module Raptor
312
317
  @options[:worker_shutdown_timeout] = timeout
313
318
  end
314
319
 
320
+ opts.on("--refork-after NUM", Integer, "Refork workers from a warmed source after any worker crosses NUM requests; 0 disables (default: 1000)") do |num|
321
+ @options[:refork_after] = num
322
+ end
323
+
315
324
  opts.on("--stats-file PATH", String, "Stats file path (default: tmp/raptor.json)") do |path|
316
325
  @options[:stats_file] = path
317
326
  end