raptor 0.4.0 → 0.5.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: a9d82dc0869e270278088d312d03a6ed914e494da5fb6b7bf6e41c03257ddf41
4
- data.tar.gz: c113ab336b5ad9e5e72095e79af4702b5adb0efb46117ba43b6879fe2e4a728c
3
+ metadata.gz: c20a98b3c581180e2b9ea2e9cdd23223f4f43438b94852b0b19b8e1b659c751d
4
+ data.tar.gz: bc98ee07befe42ad9c2e30b4645aeac4a1fb369cc598e360a336b591a56d74b1
5
5
  SHA512:
6
- metadata.gz: 365e09dbfcc7cbb265b8ac6569a66e66a0629ecfb4fd176164cb4c55b6f0223ef05f3e17f3318f7ec6cd58be94396996885b232c496e46620f42049ca9a1fe84
7
- data.tar.gz: d6d2b9b18cd0da790c2f13fce8994f799bac41e94270f9c69f4b57241f6931d98a8ef81573e8257d1766dfde0a63807d2f06cf57fa4a44ef8f754533b733aeff
6
+ metadata.gz: b065827d528422e5476827c2c01b792cd9b4a83ba38484283c33f05459a744a28a7ce88bfbe396c80fe07209893662350f205dd823a469503be208428f0cc784
7
+ data.tar.gz: '02583bea8f88760256318b3714b9f95cb7936b12ec0d9bb54a120108a734f965a9b85d4e556e6b8e73d7c731a43be6ddf96bd4871228f35d7bf2903df255cc84'
data/.mise.toml ADDED
@@ -0,0 +1,2 @@
1
+ [tools]
2
+ ruby = "latest"
data/Brewfile ADDED
@@ -0,0 +1,2 @@
1
+ brew "hey"
2
+ brew "nghttp2"
data/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.5.0] - 2026-05-31
4
+
5
+ - Apply the default `stats_file` in the Rack handler
6
+ - Add `phase` to per-worker stats
7
+ - Force-kill workers that fail to exit within `--worker-shutdown-timeout` after shutdown is signalled
8
+ - Kill workers that fail to check in within `--worker-timeout` or `--worker-boot-timeout`
9
+ - Add `index`, `busy_threads`, and `thread_capacity` to per-worker stats
10
+
3
11
  ## [0.4.0] - 2026-05-29
4
12
 
5
13
  - Load `raptor.rb` or `config/raptor.rb` by default when no config path is supplied
data/README.md CHANGED
@@ -29,23 +29,23 @@ run proc { |_env| [200, { "content-type" => "text/plain" }, ["Hello, World!"]] }
29
29
  ```
30
30
 
31
31
  ```
32
- > bundle exec raptor -t 3 -w 4 hello_world.ru
33
- Raptor Cluster initializing:
34
- ├─ Version: 0.4.0
35
- ├─ Ruby Version: ruby 4.0.5 (2026-05-20 revision 64336ffd0e) +YJIT +PRISM [arm64-darwin23]
36
- ├─ Master PID: 26456
37
- │ └─ 4 worker processes
38
- │ ├─ 1 server thread
39
- │ ├─ 1 reactor thread
40
- │ ├─ 1 pipeline ractor
41
- │ ├─ 1 pipeline collector thread
42
- │ ├─ 3 worker threads
43
- │ └─ 1 stats thread
44
- └─ Listening on 0.0.0.0:9292
45
- [26459] Worker 0 booted
46
- [26460] Worker 1 booted
47
- [26461] Worker 2 booted
48
- [26462] Worker 3 booted
32
+ > bundle exec raptor -w 4 -t 3 hello_world.ru
33
+ [Raptor 91348|main|main] Cluster initializing:
34
+ [Raptor 91348|main|main] ├─ Version: 0.5.0
35
+ [Raptor 91348|main|main] ├─ Ruby Version: ruby 4.0.5 (2026-05-20 revision 64336ffd0e) +YJIT +PRISM [arm64-darwin23]
36
+ [Raptor 91348|main|main] ├─ Master PID: 91348
37
+ [Raptor 91348|main|main] │ └─ 4 worker processes
38
+ [Raptor 91348|main|main] │ ├─ 1 server thread
39
+ [Raptor 91348|main|main] │ ├─ 1 reactor thread
40
+ [Raptor 91348|main|main] │ ├─ 1 pipeline ractor
41
+ [Raptor 91348|main|main] │ ├─ 1 pipeline collector thread
42
+ [Raptor 91348|main|main] │ ├─ 3 worker threads
43
+ [Raptor 91348|main|main] │ └─ 1 stats thread
44
+ [Raptor 91348|main|main] └─ Listening on 0.0.0.0:9292
45
+ [Raptor 91350|main|main] Worker 0 booted
46
+ [Raptor 91351|main|main] Worker 1 booted
47
+ [Raptor 91352|main|main] Worker 2 booted
48
+ [Raptor 91353|main|main] Worker 3 booted
49
49
  ```
50
50
 
51
51
  ```
@@ -62,15 +62,18 @@ Also works with `rackup` and `rails server`:
62
62
 
63
63
  ## (Micro) Benchmarks
64
64
 
65
- Raptor 0.4.0 vs Puma 8.0.1:
65
+ Raptor 0.5.0 vs Puma 8.0.2:
66
66
 
67
- | Protocol | Raptor | Puma |
68
- | --------------------- | ------------ | ------------ |
69
- | HTTP/1.1 | ~20k req/s | ~21k req/s |
70
- | HTTP/1.1 (keep-alive) | ~60k req/s | ~45k req/s |
71
- | HTTP/2 | ~23k req/s | N/A |
67
+ | Protocol | Raptor | Puma |
68
+ | --------------------- | ----------- | ----------- |
69
+ | HTTP/1.1 | 20.1k req/s | 20k req/s |
70
+ | HTTP/1.1 (keep-alive) | 61.4k req/s | 39.1k req/s |
71
+ | HTTP/2 | 22.8k req/s | N/A |
72
72
 
73
- > Ruby 4.0.5 +YJIT, macOS Apple Silicon. 4 workers, 3 threads, 12 concurrent connections.
73
+ > ruby 4.0.5 (2026-05-20 revision 64336ffd0e) +YJIT +PRISM [arm64-darwin23]
74
+ > 4 workers, 3 threads, 12 concurrent connections
75
+
76
+ See [bin/benchmark](bin/benchmark) for more details.
74
77
 
75
78
  ## Development
76
79
 
@@ -42,9 +42,9 @@ module Rackup
42
42
  {
43
43
  "Host=HOST" => "Hostname to listen on (default: #{DEFAULT_OPTIONS[:Host]})",
44
44
  "Port=PORT" => "Port to listen on (default: #{DEFAULT_OPTIONS[:Port]})",
45
- "Threads=NUM" => "Number of threads per worker (default: 3)",
46
- "Ractors=NUM" => "Number of pipeline ractors per worker (default: 1)",
47
45
  "Workers=NUM" => "Number of worker processes (default: nprocessors)",
46
+ "Ractors=NUM" => "Number of pipeline ractors per worker (default: 1)",
47
+ "Threads=NUM" => "Number of threads per worker (default: 3)",
48
48
  "Config=PATH" => "Load additional configuration from PATH"
49
49
  }
50
50
  end
@@ -72,27 +72,25 @@ module Rackup
72
72
  config_path = options[:Config] || ::Raptor::CLI.default_config_path
73
73
  config = config_path ? ::Raptor::CLI.load_config_file(config_path) : {}
74
74
 
75
- binds = if options[:Host] || options[:Port]
76
- host = options[:Host] || defaults[:Host]
77
- port = options[:Port] || defaults[:Port]
78
- ["tcp://#{host}:#{port}"]
79
- else
80
- config[:binds] || ["tcp://#{defaults[:Host]}:#{defaults[:Port]}"]
81
- end
82
-
83
75
  result = {
84
- app: app,
85
- binds: binds,
86
- threads: (options[:Threads] || config[:threads] || cli_defaults[:threads]).to_i,
87
- ractors: (options[:Ractors] || config[:ractors] || cli_defaults[:ractors]).to_i,
76
+ binds: if options[:Host] || options[:Port]
77
+ ["tcp://#{options[:Host] || defaults[:Host]}:#{options[:Port] || defaults[:Port]}"]
78
+ else
79
+ config[:binds] || ["tcp://#{defaults[:Host]}:#{defaults[:Port]}"]
80
+ end,
88
81
  workers: (options[:Workers] || config[:workers] || Etc.nprocessors).to_i,
89
- client: cli_defaults[:client].merge(config[:client] || {})
82
+ ractors: (options[:Ractors] || config[:ractors] || cli_defaults[:ractors]).to_i,
83
+ threads: (options[:Threads] || config[:threads] || cli_defaults[:threads]).to_i,
84
+ app: app
90
85
  }
91
-
92
- [:rackup, :on_error, :stats_file, :pid_file].each do |key|
93
- result[key] = config[key] if config.key?(key)
94
- end
95
-
86
+ result[:rackup] = config[:rackup] if config.key?(:rackup)
87
+ result[:client] = cli_defaults[:client].merge(config[:client] || {})
88
+ result[:worker_timeout] = (config[:worker_timeout] || cli_defaults[:worker_timeout]).to_i
89
+ result[:worker_boot_timeout] = (config[:worker_boot_timeout] || cli_defaults[:worker_boot_timeout]).to_i
90
+ result[:worker_shutdown_timeout] = (config[:worker_shutdown_timeout] || cli_defaults[:worker_shutdown_timeout]).to_i
91
+ result[:stats_file] = config.key?(:stats_file) ? config[:stats_file] : cli_defaults[:stats_file]
92
+ result[:pid_file] = config[:pid_file] if config.key?(:pid_file)
93
+ result[:on_error] = config[:on_error] if config.key?(:on_error)
96
94
  result
97
95
  end
98
96
  private_class_method :build_cluster_options
data/lib/raptor/cli.rb CHANGED
@@ -12,7 +12,7 @@ module Raptor
12
12
  #
13
13
  # CLI parses command-line arguments and starts the server cluster with the
14
14
  # specified configuration options. It supports configuring the number of
15
- # workers, threads, ractors, bind addresses, and various client timeout
15
+ # workers, ractors, threads, bind addresses, and various client timeout
16
16
  # settings.
17
17
  #
18
18
  # @example Basic usage
@@ -28,9 +28,9 @@ module Raptor
28
28
 
29
29
  DEFAULT_OPTIONS = {
30
30
  binds: ["tcp://0.0.0.0:9292"].freeze,
31
- threads: 3,
32
- ractors: 1,
33
31
  workers: DEFAULT_WORKER_COUNT,
32
+ ractors: 1,
33
+ threads: 3,
34
34
  rackup: "config.ru",
35
35
  client: {
36
36
  first_data_timeout: 30,
@@ -39,6 +39,9 @@ module Raptor
39
39
  max_body_size: nil,
40
40
  body_spool_threshold: 1024 * 1024,
41
41
  },
42
+ worker_timeout: 60,
43
+ worker_boot_timeout: 60,
44
+ worker_shutdown_timeout: 30,
42
45
  stats_file: "tmp/raptor.json",
43
46
  pid_file: nil,
44
47
  }.freeze
@@ -143,11 +146,12 @@ module Raptor
143
146
  data = JSON.parse(File.read(stats_file), symbolize_names: true)
144
147
 
145
148
  puts "Master PID: #{data[:master_pid]}"
146
- data[:workers].each_with_index do |worker, index|
149
+ data[:workers].each do |worker|
147
150
  status = worker[:booted] ? "booted" : "starting"
148
151
  last_checkin = Time.at(worker[:last_checkin]).strftime("%H:%M:%S")
149
- puts "Worker #{index}: pid=#{worker[:pid]}, requests=#{worker[:requests]}, " \
150
- "backlog=#{worker[:backlog]}, #{status}, last_checkin=#{last_checkin}"
152
+ puts "Worker #{worker[:index]} (phase #{worker[:phase]}): pid=#{worker[:pid]}, requests=#{worker[:requests]}, " \
153
+ "busy=#{worker[:busy_threads]}/#{worker[:thread_capacity]}, backlog=#{worker[:backlog]}, " \
154
+ "#{status}, last_checkin=#{last_checkin}"
151
155
  end
152
156
  end
153
157
 
@@ -215,16 +219,16 @@ module Raptor
215
219
  end
216
220
  end
217
221
 
218
- opts.on("-t", "--threads NUM", Integer, "Number of threads (default: 3)") do |num|
219
- @options[:threads] = num
222
+ opts.on("-w", "--workers NUM", Integer, "Number of worker processes (default: #{DEFAULT_WORKER_COUNT})") do |num|
223
+ @options[:workers] = num
220
224
  end
221
225
 
222
226
  opts.on("-r", "--ractors NUM", Integer, "Number of ractors (default: 1)") do |num|
223
227
  @options[:ractors] = num
224
228
  end
225
229
 
226
- opts.on("-w", "--workers NUM", Integer, "Number of worker processes (default: #{DEFAULT_WORKER_COUNT})") do |num|
227
- @options[:workers] = num
230
+ opts.on("-t", "--threads NUM", Integer, "Number of threads (default: 3)") do |num|
231
+ @options[:threads] = num
228
232
  end
229
233
 
230
234
  opts.on("--first-data-timeout SECONDS", Integer, "First data timeout in seconds (default: 30)") do |timeout|
@@ -247,6 +251,18 @@ module Raptor
247
251
  @options[:client][:body_spool_threshold] = bytes
248
252
  end
249
253
 
254
+ opts.on("--worker-timeout SECONDS", Integer, "Worker check-in timeout in seconds (default: 60)") do |timeout|
255
+ @options[:worker_timeout] = timeout
256
+ end
257
+
258
+ opts.on("--worker-boot-timeout SECONDS", Integer, "Worker boot timeout in seconds (default: 60)") do |timeout|
259
+ @options[:worker_boot_timeout] = timeout
260
+ end
261
+
262
+ opts.on("--worker-shutdown-timeout SECONDS", Integer, "Worker shutdown timeout in seconds (default: 30)") do |timeout|
263
+ @options[:worker_shutdown_timeout] = timeout
264
+ end
265
+
250
266
  opts.on("--stats-file PATH", String, "Stats file path (default: tmp/raptor.json)") do |path|
251
267
  @options[:stats_file] = path
252
268
  end
@@ -7,6 +7,7 @@ require "atomic-ruby/atomic_thread_pool"
7
7
  require "rack/builder"
8
8
  require "ractor-pool"
9
9
 
10
+ require_relative "log"
10
11
  require_relative "binder"
11
12
  require_relative "server"
12
13
  require_relative "reactor"
@@ -18,14 +19,15 @@ module Raptor
18
19
  # Multi-process web server cluster with advanced concurrency architecture.
19
20
  #
20
21
  # Cluster manages multiple worker processes, each running a complete server
21
- # stack including a reactor thread, server thread, ractor pool for HTTP
22
- # parsing, and thread pool for application processing. It handles process
23
- # forking, signal management, graceful shutdown, and automatic worker
24
- # restart when a worker process unexpectedly exits.
22
+ # stack including a ractor pool for HTTP parsing, a thread pool for
23
+ # application processing, plus dedicated reactor and server threads. It
24
+ # handles process forking, signal management, graceful shutdown, and
25
+ # automatic worker restart when a worker process unexpectedly exits.
25
26
  #
26
27
  # The architecture provides horizontal scaling through processes while
27
- # maintaining efficient I/O and CPU utilization within each process through
28
- # the combination of NIO reactors, ractor-based parsing, and thread pools.
28
+ # maintaining efficient I/O and CPU utilization within each process
29
+ # through the combination of ractor-based parsing and thread pools on
30
+ # top of NIO reactors.
29
31
  #
30
32
  # Flow per worker process:
31
33
  # 1. Server continuously accepts connections but skips acceptance when backlog is high
@@ -36,7 +38,7 @@ module Raptor
36
38
  #
37
39
  # @example Basic usage
38
40
  # options = {
39
- # threads: 8, ractors: 2, workers: 4,
41
+ # workers: 4, ractors: 2, threads: 8,
40
42
  # binds: ["tcp://0.0.0.0:3000"],
41
43
  # rackup: "config.ru",
42
44
  # client: { first_data_timeout: 30, chunk_data_timeout: 10 }
@@ -54,50 +56,61 @@ module Raptor
54
56
  new(options).run
55
57
  end
56
58
 
57
- # @rbs @thread_count: Integer
58
- # @rbs @ractor_count: Integer
59
59
  # @rbs @worker_count: Integer
60
+ # @rbs @ractor_count: Integer
61
+ # @rbs @thread_count: Integer
60
62
  # @rbs @client_options: Hash[Symbol, Integer]
61
- # @rbs @on_error: ^(Hash[String, untyped]?, Exception) -> void | nil
63
+ # @rbs @worker_timeout: Integer
64
+ # @rbs @worker_boot_timeout: Integer
65
+ # @rbs @worker_shutdown_timeout: Integer
62
66
  # @rbs @stats_file: String?
63
67
  # @rbs @pid_file: String?
68
+ # @rbs @on_error: ^(Hash[String, untyped]?, Exception) -> void | nil
64
69
  # @rbs @binder: Binder
65
70
  # @rbs @server_port: Integer
66
71
  # @rbs @app: untyped
67
72
  # @rbs @shutdown: bool
68
73
  # @rbs @workers: Hash[Integer, Integer]
74
+ # @rbs @timed_out: Set[Integer]
69
75
  # @rbs @stats: Stats
76
+ # @rbs @phase: Integer
70
77
  # @rbs @phased_restart_requested: bool
71
78
  # @rbs @phased_restarting: bool
72
79
 
73
80
  # Creates a new Cluster with the specified configuration.
74
81
  #
75
- # Initializes the cluster with thread, ractor, and worker counts,
82
+ # Initializes the cluster with worker, ractor, and thread counts,
76
83
  # sets up network binding, loads the Rack application, and prepares
77
84
  # for multi-process operation.
78
85
  #
79
86
  # @param options [Hash] cluster configuration options
80
- # @option options [Integer] :threads number of threads per worker process
81
- # @option options [Integer] :ractors number of ractors per worker process
82
- # @option options [Integer] :workers number of worker processes
83
87
  # @option options [Array<String>] :binds array of bind URIs
88
+ # @option options [Integer] :workers number of worker processes
89
+ # @option options [Integer] :ractors number of ractors per worker process
90
+ # @option options [Integer] :threads number of threads per worker process
84
91
  # @option options [#call] :app pre-built Rack application
85
92
  # @option options [String] :rackup path to Rack configuration file
86
93
  # @option options [Hash] :client client configuration
87
- # @option options [#call] :on_error callback invoked with (env, exception) when the Rack app raises
94
+ # @option options [Integer] :worker_timeout seconds to wait for a booted worker to check in before killing it
95
+ # @option options [Integer] :worker_boot_timeout seconds to wait for a worker to finish booting before killing it
96
+ # @option options [Integer] :worker_shutdown_timeout seconds to wait for graceful worker exit before force-killing
88
97
  # @option options [String, nil] :stats_file path to write per-worker stats JSON, or nil to disable
89
98
  # @option options [String, nil] :pid_file path to write the master PID to, or nil to disable
99
+ # @option options [#call] :on_error callback invoked with (env, exception) when the Rack app raises
90
100
  # @return [void]
91
101
  #
92
102
  # @rbs (Hash[Symbol, untyped] options) -> void
93
103
  def initialize(options)
94
- @thread_count = options[:threads]
95
- @ractor_count = options[:ractors]
96
104
  @worker_count = options[:workers]
105
+ @ractor_count = options[:ractors]
106
+ @thread_count = options[:threads]
97
107
  @client_options = options[:client]
98
- @on_error = options[:on_error]
108
+ @worker_timeout = options[:worker_timeout]
109
+ @worker_boot_timeout = options[:worker_boot_timeout]
110
+ @worker_shutdown_timeout = options[:worker_shutdown_timeout]
99
111
  @stats_file = options[:stats_file]
100
112
  @pid_file = options[:pid_file]
113
+ @on_error = options[:on_error]
101
114
 
102
115
  @binder = Binder.new(options[:binds])
103
116
  @server_port = @binder.server_port
@@ -106,7 +119,9 @@ module Raptor
106
119
 
107
120
  @shutdown = false
108
121
  @workers = {}
122
+ @timed_out = Set.new
109
123
  @stats = Stats.new(@worker_count)
124
+ @phase = 0
110
125
  @phased_restart_requested = false
111
126
  @phased_restarting = false
112
127
  end
@@ -114,15 +129,15 @@ module Raptor
114
129
  # Starts the multi-process cluster and manages worker processes.
115
130
  #
116
131
  # Forks the configured number of worker processes and monitors them,
117
- # automatically restarting any that exit unexpectedly. Handles graceful
118
- # shutdown via INT or TERM signals, stats logging via USR1, and phased
119
- # restart via USR2.
132
+ # restarting any that exit unexpectedly or stop checking in. Handles
133
+ # graceful shutdown via INT or TERM signals, stats logging via USR1,
134
+ # and phased restart via USR2.
120
135
  #
121
136
  # Each worker process includes:
122
137
  # - 1 server thread (continuously accepts connections with backpressure control)
123
138
  # - 1 reactor thread (I/O multiplexing, timeout handling, backlog monitoring)
124
- # - N ractor workers (parallel HTTP parsing)
125
- # - 1 ractor collector thread (coordinates parsing results)
139
+ # - N pipeline ractors (parallel HTTP parsing)
140
+ # - 1 pipeline collector thread (coordinates parsing results)
126
141
  # - M worker threads (Rack application processing and response writing)
127
142
  # - 1 stats thread (writes per-worker metrics to shared memory every second)
128
143
  #
@@ -151,12 +166,12 @@ module Raptor
151
166
  break if reap_workers == :no_children
152
167
 
153
168
  perform_phased_restart if @phased_restart_requested && !@phased_restarting
169
+ timeout_hung_workers
154
170
 
155
171
  sleep 0.1
156
172
  end
157
173
 
158
- @workers.values.each { |pid| Process.kill("TERM", pid) rescue nil }
159
- @workers.values.each { |pid| Process.wait(pid) rescue nil }
174
+ stop_workers
160
175
  stats_file_thread&.join
161
176
  File.delete(@stats_file) rescue nil if @stats_file
162
177
  File.delete(@pid_file) rescue nil if @pid_file
@@ -176,13 +191,14 @@ module Raptor
176
191
  private
177
192
 
178
193
  # Forks a new worker process and registers it at the given index.
194
+ # The worker inherits the cluster's current phase.
179
195
  #
180
196
  # @param index [Integer] slot index for this worker in the stats region
181
197
  # @return [void]
182
198
  #
183
199
  # @rbs (Integer index) -> void
184
200
  def spawn_worker(index)
185
- pid = fork { run_worker(index) }
201
+ pid = fork { run_worker(index, @phase) }
186
202
  @workers[index] = pid
187
203
  end
188
204
 
@@ -199,9 +215,10 @@ module Raptor
199
215
 
200
216
  index = @workers.key(pid)
201
217
  @workers.delete(index)
218
+ @timed_out.delete(pid)
202
219
 
203
220
  unless @shutdown
204
- warn "[#{Process.pid}] Restarting worker #{index} (#{pid}), #{exit_description(status)}"
221
+ Log.warn "Restarting worker #{index} (#{pid}), #{exit_description(status)}"
205
222
  spawn_worker(index)
206
223
  end
207
224
  end
@@ -209,6 +226,57 @@ module Raptor
209
226
  :no_children
210
227
  end
211
228
 
229
+ # Stops every worker, escalating from TERM to KILL if any fail to
230
+ # exit within `worker_shutdown_timeout`.
231
+ #
232
+ # @return [void]
233
+ #
234
+ # @rbs () -> void
235
+ def stop_workers
236
+ @workers.values.each { |pid| Process.kill("TERM", pid) rescue nil }
237
+
238
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + @worker_shutdown_timeout
239
+ until @workers.empty? || Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
240
+ reap_workers
241
+ sleep 0.05
242
+ end
243
+ return if @workers.empty?
244
+
245
+ Log.warn "Force-killing #{@workers.size} worker(s) after #{@worker_shutdown_timeout}s"
246
+ @workers.values.each { |pid| Process.kill("KILL", pid) rescue nil }
247
+ @workers.values.each { |pid| Process.wait(pid) rescue nil }
248
+ end
249
+
250
+ # Kills workers that have stopped checking in. A booted worker that
251
+ # fails to update its stats slot within `worker_timeout` seconds is
252
+ # assumed to be hung (deadlocked app, runaway loop, blocked syscall);
253
+ # a worker still in startup is held to `worker_boot_timeout`. Killed
254
+ # workers are then restarted by `reap_workers`.
255
+ #
256
+ # @return [void]
257
+ #
258
+ # @rbs () -> void
259
+ def timeout_hung_workers
260
+ now = Process.clock_gettime(Process::CLOCK_REALTIME)
261
+ stats = @stats.all
262
+
263
+ @workers.each do |index, pid|
264
+ next if @timed_out.include?(pid)
265
+
266
+ stat = stats[index]
267
+ next unless stat[:pid] == pid
268
+
269
+ timeout = stat[:booted] ? @worker_timeout : @worker_boot_timeout
270
+ elapsed = now - stat[:last_checkin]
271
+ next if elapsed <= timeout
272
+
273
+ action = stat[:booted] ? "check in" : "boot"
274
+ Log.warn "Killing worker #{index} (#{pid}), failed to #{action} within #{timeout}s"
275
+ Process.kill("KILL", pid) rescue nil
276
+ @timed_out << pid
277
+ end
278
+ end
279
+
212
280
  # Replaces each worker process one at a time, waiting for the new
213
281
  # worker to boot before moving on to the next. Triggered by SIGUSR2.
214
282
  #
@@ -218,7 +286,8 @@ module Raptor
218
286
  def perform_phased_restart
219
287
  @phased_restart_requested = false
220
288
  @phased_restarting = true
221
- puts "[#{Process.pid}] Phased restart starting"
289
+ @phase += 1
290
+ Log.info "Phased restart starting"
222
291
 
223
292
  begin
224
293
  @workers.keys.sort.each do |index|
@@ -240,7 +309,7 @@ module Raptor
240
309
  end
241
310
  end
242
311
 
243
- puts "[#{Process.pid}] Phased restart complete"
312
+ Log.info "Phased restart complete"
244
313
  ensure
245
314
  @phased_restarting = false
246
315
  end
@@ -253,10 +322,11 @@ module Raptor
253
322
  # critical component fails.
254
323
  #
255
324
  # @param index [Integer] slot index for this worker in the stats region
325
+ # @param phase [Integer] the cluster phase this worker was forked at
256
326
  # @return [void]
257
327
  #
258
- # @rbs (Integer index) -> void
259
- def run_worker(index)
328
+ # @rbs (Integer index, Integer phase) -> void
329
+ def run_worker(index, phase)
260
330
  shutdown_requested = false
261
331
  trap("INT") { shutdown_requested = true }
262
332
  trap("TERM") { shutdown_requested = true }
@@ -267,8 +337,11 @@ module Raptor
267
337
  @stats.write(
268
338
  index,
269
339
  pid: Process.pid,
340
+ phase: phase,
270
341
  requests: 0,
271
342
  backlog: 0,
343
+ busy_threads: 0,
344
+ thread_capacity: @thread_count,
272
345
  started_at:,
273
346
  last_checkin: started_at,
274
347
  booted: false
@@ -295,18 +368,17 @@ module Raptor
295
368
  request.handle_parsed_request(parsed_result, reactor, thread_pool)
296
369
  end
297
370
  rescue => error
298
- warn "#{Thread.current.name} rescued:"
299
- warn error.full_message
371
+ Log.rescued_error(error)
300
372
  end
301
373
  end
302
374
 
303
- reactor = Reactor.new(thread_pool, ractor_pool, client_options: @client_options)
375
+ reactor = Reactor.new(ractor_pool, thread_pool, client_options: @client_options)
304
376
  reactor_thread = reactor.run
305
377
 
306
378
  server = Server.new(@binder, reactor, thread_pool, request, client_options: @client_options)
307
379
  server_thread = server.run
308
380
 
309
- puts "[#{Process.pid}] Worker #{index} booted"
381
+ Log.info "Worker #{index} booted"
310
382
 
311
383
  stats_thread = Thread.new do
312
384
  Thread.current.name = "Raptor Stats"
@@ -315,8 +387,11 @@ module Raptor
315
387
  @stats.write(
316
388
  index,
317
389
  pid: Process.pid,
390
+ phase: phase,
318
391
  requests: request_count,
319
392
  backlog: reactor.backlog,
393
+ busy_threads: thread_pool.active_count,
394
+ thread_capacity: @thread_count,
320
395
  started_at:,
321
396
  last_checkin: Process.clock_gettime(Process::CLOCK_REALTIME),
322
397
  booted: true
@@ -370,28 +445,25 @@ module Raptor
370
445
  @shutdown = true
371
446
  end
372
447
 
373
- # Logs cluster initialization details including architecture and bind addresses.
374
- #
375
- # Outputs a hierarchical view of the cluster configuration showing
376
- # the master process, worker processes, and per-process thread/ractor
377
- # allocation along with listening addresses.
448
+ # Prints the cluster's startup banner showing process structure
449
+ # and bind addresses.
378
450
  #
379
451
  # @return [void]
380
452
  #
381
453
  # @rbs () -> void
382
454
  def log_initialization
383
- puts "Raptor Cluster initializing:"
384
- puts "├─ Version: #{VERSION}"
385
- puts "├─ Ruby Version: #{RUBY_DESCRIPTION}"
386
- puts "├─ Master PID: #{Process.pid}"
387
- puts "│ └─ #{@worker_count} worker process#{"es" if @worker_count > 1}"
388
- puts "│ ├─ 1 server thread"
389
- puts "│ ├─ 1 reactor thread"
390
- puts "│ ├─ #{@ractor_count} pipeline ractor#{"s" if @ractor_count > 1}"
391
- puts "│ ├─ 1 pipeline collector thread"
392
- puts "│ ├─ #{@thread_count} worker thread#{"s" if @thread_count > 1}"
393
- puts "│ └─ 1 stats thread"
394
- puts "└─ Listening on #{@binder.addresses.join(", ")}"
455
+ Log.info "Cluster initializing:"
456
+ Log.info "├─ Version: #{VERSION}"
457
+ Log.info "├─ Ruby Version: #{RUBY_DESCRIPTION}"
458
+ Log.info "├─ Master PID: #{Process.pid}"
459
+ Log.info "│ └─ #{@worker_count} worker process#{"es" if @worker_count > 1}"
460
+ Log.info "│ ├─ 1 server thread"
461
+ Log.info "│ ├─ 1 reactor thread"
462
+ Log.info "│ ├─ #{@ractor_count} pipeline ractor#{"s" if @ractor_count > 1}"
463
+ Log.info "│ ├─ 1 pipeline collector thread"
464
+ Log.info "│ ├─ #{@thread_count} worker thread#{"s" if @thread_count > 1}"
465
+ Log.info "│ └─ 1 stats thread"
466
+ Log.info "└─ Listening on #{@binder.addresses.join(", ")}"
395
467
  end
396
468
 
397
469
  # Logs current stats for all workers to stdout.
@@ -402,11 +474,11 @@ module Raptor
402
474
  #
403
475
  # @rbs () -> void
404
476
  def log_stats
405
- @stats.all.each_with_index do |stat, index|
477
+ @stats.all.each do |stat|
406
478
  status = stat[:booted] ? "booted" : "starting"
407
- puts "Worker #{index}: pid=#{stat[:pid]}, requests=#{stat[:requests]}, " \
408
- "backlog=#{stat[:backlog]}, #{status}, " \
409
- "last_checkin=#{Time.at(stat[:last_checkin]).strftime("%H:%M:%S")}"
479
+ Log.info "Worker #{stat[:index]} (phase #{stat[:phase]}): pid=#{stat[:pid]}, requests=#{stat[:requests]}, " \
480
+ "busy=#{stat[:busy_threads]}/#{stat[:thread_capacity]}, backlog=#{stat[:backlog]}, " \
481
+ "#{status}, last_checkin=#{Time.at(stat[:last_checkin]).strftime("%H:%M:%S")}"
410
482
  end
411
483
  end
412
484
 
data/lib/raptor/http2.rb CHANGED
@@ -81,7 +81,8 @@ module Raptor
81
81
  # outbound DATA frames respect RFC 7540 §5.2. Threads dispatching stream
82
82
  # responses call `acquire` to reserve send capacity; threads applying
83
83
  # inbound WINDOW_UPDATE or SETTINGS frames call the mutating methods to
84
- # replenish it. State is held in a single `Atom` so updates use CAS.
84
+ # replenish it. The connection window and per-stream windows live in
85
+ # separate `Atom`s so the common fast path skips per-stream tracking.
85
86
  #
86
87
  class FlowControl
87
88
  ACQUIRE_POLL_INTERVAL = 0.001
@@ -529,7 +530,7 @@ module Raptor
529
530
 
530
531
  result[:completed_requests]&.each do |request|
531
532
  stream_id = request[:stream_id]
532
- remote_addr = result[:remote_addr] || "127.0.0.1"
533
+ remote_addr = result[:remote_addr] || Server::DEFAULT_REMOTE_ADDR
533
534
 
534
535
  thread_pool << proc do
535
536
  dispatch_stream_request(
@@ -749,7 +750,7 @@ module Raptor
749
750
  env[Rack::SERVER_NAME] ||= host
750
751
  env[Rack::SERVER_PORT] ||= port || @server_port.to_s
751
752
  else
752
- env[Rack::SERVER_NAME] ||= "localhost"
753
+ env[Rack::SERVER_NAME] ||= Server::DEFAULT_SERVER_NAME
753
754
  env[Rack::SERVER_PORT] ||= @server_port.to_s
754
755
  end
755
756
  end