raptor 0.19.0 → 0.20.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.
data/lib/raptor/cli.rb CHANGED
@@ -35,6 +35,9 @@ module Raptor
35
35
  workers: DEFAULT_WORKER_COUNT,
36
36
  threads: 3,
37
37
  max_threads: Float::INFINITY,
38
+ cpu_affinity: false,
39
+ clean_thread_locals: true,
40
+ clean_fiber_locals: false,
38
41
  rackup: "config.ru",
39
42
  chdir: nil,
40
43
  environment: nil,
@@ -48,7 +51,7 @@ module Raptor
48
51
  http1: {
49
52
  ractors: nil,
50
53
  persistent_data_timeout: 65,
51
- max_keepalive_requests: 100,
54
+ max_keepalive_requests: 1000,
52
55
  },
53
56
  http2: {
54
57
  ractors: nil,
@@ -64,6 +67,7 @@ module Raptor
64
67
  before_worker_shutdown: [].freeze,
65
68
  before_refork: [].freeze,
66
69
  stats_file: "tmp/raptor.json",
70
+ control_url: nil,
67
71
  pid_file: nil,
68
72
  stdout_file: nil,
69
73
  stderr_file: nil,
@@ -151,6 +155,7 @@ module Raptor
151
155
  end
152
156
 
153
157
  apply_config_file(extract_config_path(argv) || self.class.default_config_path)
158
+ apply_environment
154
159
 
155
160
  @parser = create_parser
156
161
  @parser.parse!(argv)
@@ -232,6 +237,18 @@ module Raptor
232
237
  end
233
238
  end
234
239
 
240
+ # Applies worker and thread settings from the environment. Explicit CLI
241
+ # options are parsed afterwards and take precedence.
242
+ #
243
+ # @return [void]
244
+ #
245
+ # @rbs () -> void
246
+ def apply_environment
247
+ @options[:workers] = Integer(ENV["RAPTOR_WORKERS"], 10) if ENV["RAPTOR_WORKERS"]
248
+ @options[:threads] = Integer(ENV["RAPTOR_THREADS"], 10) if ENV["RAPTOR_THREADS"]
249
+ @options[:max_threads] = ENV["RAPTOR_MAX_THREADS"] if ENV["RAPTOR_MAX_THREADS"]
250
+ end
251
+
235
252
  # Creates the OptionParser instance with all supported command-line options.
236
253
  #
237
254
  # @return [OptionParser] configured option parser
@@ -273,6 +290,18 @@ module Raptor
273
290
  @options[:max_threads] = num
274
291
  end
275
292
 
293
+ opts.on("--[no-]cpu-affinity", "Pin each worker process to a CPU (default: off)") do |bool|
294
+ @options[:cpu_affinity] = bool
295
+ end
296
+
297
+ opts.on("--[no-]clean-thread-locals", "Clear application thread locals after each request (default: on)") do |bool|
298
+ @options[:clean_thread_locals] = bool
299
+ end
300
+
301
+ opts.on("--[no-]clean-fiber-locals", "Process each request in a fresh Fiber (default: off)") do |bool|
302
+ @options[:clean_fiber_locals] = bool
303
+ end
304
+
276
305
  opts.on("-C", "--chdir PATH", String, "Change to PATH before loading the Rack application (default: none)") do |path|
277
306
  @options[:chdir] = path
278
307
  end
@@ -309,7 +338,7 @@ module Raptor
309
338
  @options[:http1][:persistent_data_timeout] = timeout
310
339
  end
311
340
 
312
- opts.on("--http1-max-keepalive-requests NUM", Integer, "Maximum HTTP/1.1 requests per keep-alive connection (default: 100)") do |num|
341
+ opts.on("--http1-max-keepalive-requests NUM", Integer, "Maximum HTTP/1.1 requests per keep-alive connection (default: 1000)") do |num|
313
342
  @options[:http1][:max_keepalive_requests] = num
314
343
  end
315
344
 
@@ -345,6 +374,10 @@ module Raptor
345
374
  @options[:stats_file] = path
346
375
  end
347
376
 
377
+ opts.on("--control-url URI", String, "Serve cluster stats on a unix:// URI (default: off)") do |uri|
378
+ @options[:control_url] = uri
379
+ end
380
+
348
381
  opts.on("--pid-file PATH", String, "PID file path (default: none)") do |path|
349
382
  @options[:pid_file] = path
350
383
  end
@@ -3,6 +3,7 @@
3
3
 
4
4
  require "concurrent/utility/processor_counter"
5
5
  require "json"
6
+ require "time"
6
7
 
7
8
  require "atomic-ruby/atomic_thread_pool"
8
9
  require "rack/builder"
@@ -10,6 +11,7 @@ require "ractor-pool"
10
11
 
11
12
  require_relative "log"
12
13
  require_relative "binder"
14
+ require_relative "control_server"
13
15
  require_relative "reactor"
14
16
  require_relative "server"
15
17
  require_relative "reuseport_bpf"
@@ -74,6 +76,9 @@ module Raptor
74
76
  # @rbs @http2_ractor_count: Integer
75
77
  # @rbs @thread_count: Integer
76
78
  # @rbs @max_thread_count: (Integer | Float)?
79
+ # @rbs @cpu_affinity: bool
80
+ # @rbs @clean_thread_locals: bool
81
+ # @rbs @clean_fiber_locals: bool
77
82
  # @rbs @environment: String
78
83
  # @rbs @connection_options: Hash[Symbol, untyped]
79
84
  # @rbs @http1_options: Hash[Symbol, untyped]
@@ -87,6 +92,7 @@ module Raptor
87
92
  # @rbs @before_worker_shutdown: Array[Proc]
88
93
  # @rbs @before_refork: Array[Proc]
89
94
  # @rbs @stats_file: String?
95
+ # @rbs @control_server: ControlServer?
90
96
  # @rbs @pid_file: String?
91
97
  # @rbs @stdout_file: String?
92
98
  # @rbs @stderr_file: String?
@@ -102,6 +108,7 @@ module Raptor
102
108
  # @rbs @workers: Hash[Integer, Integer]
103
109
  # @rbs @timed_out: Set[Integer]
104
110
  # @rbs @stats: Stats
111
+ # @rbs @started_at: Float
105
112
  # @rbs @phase: Integer
106
113
  # @rbs @phased_restart_requested: bool
107
114
  # @rbs @phased_restarting: bool
@@ -127,6 +134,9 @@ module Raptor
127
134
  # @option options [Integer] :workers number of worker processes
128
135
  # @option options [Integer] :threads number of threads per worker process
129
136
  # @option options [Integer, Float] :max_threads maximum number of threads per worker process, or `Float::INFINITY` for no limit
137
+ # @option options [Boolean] :cpu_affinity whether to pin each worker to a CPU
138
+ # @option options [Boolean] :clean_thread_locals whether to clear application thread locals after each request
139
+ # @option options [Boolean] :clean_fiber_locals whether to process each request in a fresh Fiber
130
140
  # @option options [#call] :app pre-built Rack application
131
141
  # @option options [String] :rackup path to Rack configuration file
132
142
  # @option options [String, nil] :chdir directory to change to before loading the Rack application, or nil to leave the working directory unchanged
@@ -140,10 +150,11 @@ module Raptor
140
150
  # @option options [Integer] :worker_shutdown_timeout seconds to wait for graceful worker exit before force-killing
141
151
  # @option options [Integer, Array<Integer>, nil] :refork_after request-count threshold(s) at which a warm worker is promoted to a fork source for phased refork; nil or 0 disables. Requires `PR_SET_CHILD_SUBREAPER` (Linux)
142
152
  # @option options [Array<Proc>] :before_fork procs called in the master before every worker fork
143
- # @option options [Array<Proc>] :before_worker_boot procs called in each worker before it begins serving
144
- # @option options [Array<Proc>] :before_worker_shutdown procs called in each worker before its graceful shutdown
153
+ # @option options [Array<Proc>] :before_worker_boot procs called with the worker index before it begins serving
154
+ # @option options [Array<Proc>] :before_worker_shutdown procs called with the worker index before its graceful shutdown
145
155
  # @option options [Array<Proc>] :before_refork procs called in a worker before it transitions to a seed
146
156
  # @option options [String, nil] :stats_file path to write per-worker stats JSON, or nil to disable
157
+ # @option options [String, nil] :control_url `unix://` URL serving cluster stats, or nil to disable
147
158
  # @option options [String, nil] :pid_file path to write the master PID to, or nil to disable
148
159
  # @option options [String, nil] :stdout_file path to redirect stdout to, reopened on SIGHUP, or nil to disable
149
160
  # @option options [String, nil] :stderr_file path to redirect stderr to, reopened on SIGHUP, or nil to disable
@@ -161,6 +172,9 @@ module Raptor
161
172
  @http2_ractor_count = options[:http2][:ractors] || self.class.default_http2_ractor_count(@worker_count)
162
173
  @thread_count = options[:threads]
163
174
  @max_thread_count = options[:max_threads]
175
+ @cpu_affinity = options[:cpu_affinity]
176
+ @clean_thread_locals = options[:clean_thread_locals]
177
+ @clean_fiber_locals = options[:clean_fiber_locals]
164
178
  @environment = options[:environment] || ENV["RAILS_ENV"] || ENV["RACK_ENV"] || "development"
165
179
  @connection_options = options[:connection]
166
180
  @http1_options = options[:http1]
@@ -174,6 +188,7 @@ module Raptor
174
188
  @before_worker_shutdown = Array(options[:before_worker_shutdown])
175
189
  @before_refork = Array(options[:before_refork])
176
190
  @stats_file = options[:stats_file]
191
+ @control_server = ControlServer.new(options[:control_url]) { stats_hash } if options[:control_url]
177
192
  @pid_file = options[:pid_file]
178
193
  @stdout_file = options[:stdout_file]
179
194
  @stderr_file = options[:stderr_file]
@@ -202,6 +217,7 @@ module Raptor
202
217
  @workers = {}
203
218
  @timed_out = Set.new
204
219
  @stats = Stats.new(@worker_count)
220
+ @started_at = Process.clock_gettime(Process::CLOCK_REALTIME)
205
221
  @phase = 0
206
222
  @phased_restart_requested = false
207
223
  @phased_restarting = false
@@ -247,7 +263,9 @@ module Raptor
247
263
 
248
264
  @bpf_active = ReuseportBPF.setup(@worker_count)
249
265
 
266
+ @control_server&.bind
250
267
  @worker_count.times { |index| spawn_worker(index) }
268
+ @control_server&.start
251
269
 
252
270
  stats_file_thread = if @stats_file
253
271
  Thread.new do
@@ -274,6 +292,7 @@ module Raptor
274
292
  Systemd.notify("STOPPING=1")
275
293
  stop_workers
276
294
  stats_file_thread&.join
295
+ @control_server&.shutdown
277
296
  File.delete(@stats_file) rescue nil if @stats_file
278
297
  File.delete(@pid_file) rescue nil if @pid_file
279
298
  @stats.unmap
@@ -291,8 +310,57 @@ module Raptor
291
310
  @stats.all
292
311
  end
293
312
 
313
+ # Returns cluster statistics for the control server.
314
+ # For an adaptive pool, `max_threads` is the number of threads currently
315
+ # running so utilization reflects the capacity available at that moment.
316
+ #
317
+ # @return [Hash] cluster statistics
318
+ #
319
+ # @rbs () -> Hash[Symbol, untyped]
320
+ def stats_hash
321
+ worker_stats = @stats.all
322
+ worker_status = @workers.sort.map do |index, pid|
323
+ stat = worker_stats[index]
324
+ stat = {} unless stat && stat[:pid] == pid
325
+ capacity = stat.fetch(:thread_capacity, 0)
326
+ active = stat.fetch(:busy_threads, 0)
327
+ total_work = stat.fetch(:backlog, 0)
328
+
329
+ {
330
+ started_at: timestamp(stat.fetch(:started_at, 0)),
331
+ pid: pid,
332
+ index: index,
333
+ phase: stat.fetch(:phase, @phase),
334
+ booted: stat.fetch(:booted, false),
335
+ last_checkin: timestamp(stat.fetch(:last_checkin, 0)),
336
+ last_status: {
337
+ backlog: [total_work - active, 0].max,
338
+ running: capacity,
339
+ pool_capacity: [capacity - total_work, 0].max,
340
+ busy_threads: active,
341
+ max_threads: capacity,
342
+ requests_count: stat.fetch(:requests, 0),
343
+ },
344
+ }
345
+ end
346
+
347
+ {
348
+ started_at: timestamp(@started_at),
349
+ workers: worker_status.length,
350
+ phase: @phase,
351
+ booted_workers: worker_status.count { |worker| worker[:booted] },
352
+ old_workers: worker_status.count { |worker| worker[:phase] != @phase },
353
+ worker_status: worker_status,
354
+ }
355
+ end
356
+
294
357
  private
295
358
 
359
+ # @rbs (Float timestamp) -> String
360
+ def timestamp(timestamp)
361
+ Time.at(timestamp).utc.iso8601(6)
362
+ end
363
+
296
364
  # Returns the inherited-FDs hash for a systemd socket-activation handoff,
297
365
  # pairing each bind URI with the FD systemd passed at the same index.
298
366
  # Returns an empty hash when the FD count doesn't match the bind count.
@@ -700,6 +768,7 @@ module Raptor
700
768
  stop_workers
701
769
  @binder.clear_close_on_exec
702
770
  ENV[INHERITED_FDS_ENV] = JSON.generate(@binder.inheritable_fds)
771
+ @control_server&.shutdown
703
772
  File.delete(@stats_file) rescue nil if @stats_file
704
773
  File.delete(@pid_file) rescue nil if @pid_file
705
774
  @stats.unmap
@@ -731,7 +800,7 @@ module Raptor
731
800
  trap("USR2", "IGNORE")
732
801
  trap("URG") { promote_to_seed = true } if @fork_r
733
802
 
734
- Raptor::CPU.pin(index) if Raptor::CPU.count >= @worker_count
803
+ Raptor::CPU.pin(index) if @cpu_affinity && Raptor::CPU.count >= @worker_count
735
804
 
736
805
  started_at = Process.clock_gettime(Process::CLOCK_REALTIME)
737
806
  request_count = 0
@@ -762,6 +831,8 @@ module Raptor
762
831
  connection_options: @connection_options,
763
832
  http1_options: @http1_options,
764
833
  access_log_io: @access_log_io,
834
+ clean_thread_locals: @clean_thread_locals,
835
+ clean_fiber_locals: @clean_fiber_locals,
765
836
  on_error: @on_error
766
837
  )
767
838
  http2 = Http2.new(
@@ -770,6 +841,8 @@ module Raptor
770
841
  connection_options: @connection_options,
771
842
  http2_options: @http2_options,
772
843
  access_log_io: @access_log_io,
844
+ clean_thread_locals: @clean_thread_locals,
845
+ clean_fiber_locals: @clean_fiber_locals,
773
846
  on_error: @on_error
774
847
  )
775
848
  http1_ractor_pool = RactorPool.new(
@@ -829,7 +902,7 @@ module Raptor
829
902
  )
830
903
  server_thread = server.run
831
904
 
832
- @before_worker_boot.each(&:call)
905
+ @before_worker_boot.each { |hook| hook.parameters.empty? ? hook.call : hook.call(index) }
833
906
 
834
907
  Log.info "Worker #{index} booted"
835
908
 
@@ -865,7 +938,7 @@ module Raptor
865
938
  @before_refork.each(&:call)
866
939
  server.stop_accepting
867
940
  else
868
- @before_worker_shutdown.each(&:call)
941
+ @before_worker_shutdown.each { |hook| hook.parameters.empty? ? hook.call : hook.call(index) }
869
942
  server.shutdown
870
943
  end
871
944
 
@@ -0,0 +1,164 @@
1
+ # rbs_inline: enabled
2
+ # frozen_string_literal: true
3
+
4
+ require "json"
5
+ require "socket"
6
+ require "uri"
7
+
8
+ require "atomic-ruby/atom"
9
+ require "atomic-ruby/atomic_boolean"
10
+
11
+ module Raptor
12
+ # Serves cluster statistics over a Unix socket.
13
+ #
14
+ class ControlServer
15
+ MAX_REQUEST_SIZE = 16 * 1024
16
+ REQUEST_TIMEOUT = 1
17
+
18
+ # @rbs @path: String
19
+ # @rbs @stats: ^() -> Hash[Symbol, untyped]
20
+ # @rbs @server: UNIXServer?
21
+ # @rbs @client: Atom
22
+ # @rbs @thread: Thread?
23
+ # @rbs @running: AtomicBoolean
24
+
25
+ # Creates a control server for `url` without binding it.
26
+ #
27
+ # @param url [String] `unix://` URL to listen on
28
+ # @yieldreturn [Hash] cluster statistics
29
+ # @return [void]
30
+ # @raise [ArgumentError] if the URL is not a Unix socket
31
+ #
32
+ # @rbs (String url) { () -> Hash[Symbol, untyped] } -> void
33
+ def initialize(url, &stats)
34
+ uri = URI(url)
35
+ raise ArgumentError, "control_url must use unix://" unless uri.scheme == "unix" && !uri.path.empty?
36
+
37
+ @path = uri.path
38
+ @stats = stats
39
+ @server = nil
40
+ @client = Atom.new(nil)
41
+ @thread = nil
42
+ @running = AtomicBoolean.new(false)
43
+ end
44
+
45
+ # Binds the Unix socket.
46
+ #
47
+ # @return [void]
48
+ #
49
+ # @rbs () -> void
50
+ def bind
51
+ remove_stale_socket
52
+ @server = UNIXServer.new(@path)
53
+ end
54
+
55
+ # Starts serving requests in a background thread.
56
+ #
57
+ # @return [void]
58
+ #
59
+ # @rbs () -> void
60
+ def start
61
+ @running.make_true
62
+ owner_pid = Process.pid
63
+ at_exit { File.delete(@path) rescue nil if Process.pid == owner_pid }
64
+
65
+ @thread = Thread.new do
66
+ Thread.current.name = "Control Server"
67
+
68
+ serve
69
+ end
70
+ end
71
+
72
+ # Stops serving and removes the socket.
73
+ #
74
+ # @return [void]
75
+ #
76
+ # @rbs () -> void
77
+ def shutdown
78
+ @running.make_false
79
+ @server&.close
80
+ close_client
81
+ @thread&.join
82
+ File.delete(@path) rescue nil
83
+ end
84
+
85
+ private
86
+
87
+ # @rbs () -> void
88
+ def remove_stale_socket
89
+ return unless File.exist?(@path)
90
+
91
+ begin
92
+ UNIXSocket.new(@path).close
93
+ raise "Socket #{@path.inspect} is already in use"
94
+ rescue Errno::ECONNREFUSED
95
+ File.delete(@path)
96
+ end
97
+ end
98
+
99
+ # @rbs () -> void
100
+ def serve
101
+ while @running.true?
102
+ readable, = IO.select([@server], nil, nil, 1)
103
+ next unless readable
104
+
105
+ client = @server.accept_nonblock(exception: false)
106
+ next unless client.is_a?(UNIXSocket)
107
+
108
+ @client.swap { client }
109
+ unless @running.true?
110
+ close_client
111
+ break
112
+ end
113
+
114
+ handle(client)
115
+ end
116
+ rescue IOError, Errno::EBADF
117
+ end
118
+
119
+ # @rbs (UNIXSocket client) -> void
120
+ def handle(client)
121
+ request = read_request(client)
122
+ return unless request
123
+
124
+ if request.start_with?("GET /stats ")
125
+ body = JSON.generate(@stats.call)
126
+ client.write("HTTP/1.0 200 OK\r\nContent-Type: application/json\r\nContent-Length: #{body.bytesize}\r\n\r\n#{body}")
127
+ else
128
+ client.write("HTTP/1.0 404 Not Found\r\nContent-Length: 0\r\n\r\n")
129
+ end
130
+ rescue IOError, SystemCallError
131
+ ensure
132
+ close_client
133
+ end
134
+
135
+ # @rbs (UNIXSocket client) -> String?
136
+ def read_request(client)
137
+ request = String.new
138
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + REQUEST_TIMEOUT
139
+
140
+ loop do
141
+ return request if request.include?("\r\n\r\n")
142
+ return if request.bytesize >= MAX_REQUEST_SIZE
143
+
144
+ timeout = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
145
+ return if timeout <= 0 || !client.wait_readable(timeout)
146
+
147
+ chunk = client.read_nonblock(MAX_REQUEST_SIZE - request.bytesize, exception: false)
148
+ return unless chunk.is_a?(String)
149
+
150
+ request << chunk
151
+ end
152
+ end
153
+
154
+ # @rbs () -> void
155
+ def close_client
156
+ client = nil
157
+ @client.swap do |current|
158
+ client = current
159
+ nil
160
+ end
161
+ client&.close
162
+ end
163
+ end
164
+ end
data/lib/raptor/http1.rb CHANGED
@@ -10,6 +10,7 @@ require "rack"
10
10
 
11
11
  require_relative "http"
12
12
  require_relative "raptor_http"
13
+ require_relative "thread_locals"
13
14
 
14
15
  module Raptor
15
16
  # Parses HTTP/1.x requests and dispatches them to the Rack
@@ -23,8 +24,7 @@ module Raptor
23
24
  FILE_CHUNK_SIZE = 64 * 1024
24
25
  READ_BUFFER_SIZE = 64 * 1024
25
26
  RESPONSE_BUFFER_CAPACITY = 4 * 1024
26
- KEEPALIVE_READ_TIMEOUT = 0.001
27
- MAX_KEEPALIVE_REQUESTS = 100
27
+ MAX_KEEPALIVE_REQUESTS = 1000
28
28
 
29
29
  HTTP_10 = "HTTP/1.0"
30
30
  HTTP_11 = "HTTP/1.1"
@@ -168,6 +168,8 @@ module Raptor
168
168
  # @rbs @body_spool_threshold: Integer?
169
169
  # @rbs @max_keepalive_requests: Integer
170
170
  # @rbs @access_log_io: IO?
171
+ # @rbs @clean_thread_locals: bool
172
+ # @rbs @clean_fiber_locals: bool
171
173
  # @rbs @on_error: ^(Hash[String, untyped]?, Exception) -> void | nil
172
174
  # @rbs @running: AtomicBoolean
173
175
  # @rbs @env_template: Hash[String, untyped]
@@ -183,11 +185,13 @@ module Raptor
183
185
  # @param http1_options [Hash] HTTP/1.1-specific settings
184
186
  # @option http1_options [Integer] :max_keepalive_requests maximum requests per HTTP/1.1 keep-alive connection
185
187
  # @param access_log_io [IO, nil] IO to write Common Log Format access entries to, or nil to disable
188
+ # @param clean_thread_locals [Boolean] whether to clear application thread locals after each request
189
+ # @param clean_fiber_locals [Boolean] whether to process each request in a fresh Fiber
186
190
  # @param on_error [#call, nil] callback invoked with (env, exception) when the Rack app raises
187
191
  # @return [void]
188
192
  #
189
- # @rbs (^(Hash[String, untyped]) -> [Integer, Hash[String, String | Array[String]], untyped] app, Integer server_port, ?connection_options: Hash[Symbol, untyped], ?http1_options: Hash[Symbol, untyped], ?access_log_io: IO?, ?on_error: ^(Hash[String, untyped]?, Exception) -> void | nil) -> void
190
- def initialize(app, server_port, connection_options: {}, http1_options: {}, access_log_io: nil, on_error: nil)
193
+ # @rbs (^(Hash[String, untyped]) -> [Integer, Hash[String, String | Array[String]], untyped] app, Integer server_port, ?connection_options: Hash[Symbol, untyped], ?http1_options: Hash[Symbol, untyped], ?access_log_io: IO?, ?clean_thread_locals: bool, ?clean_fiber_locals: bool, ?on_error: ^(Hash[String, untyped]?, Exception) -> void | nil) -> void
194
+ def initialize(app, server_port, connection_options: {}, http1_options: {}, access_log_io: nil, clean_thread_locals: true, clean_fiber_locals: false, on_error: nil)
191
195
  @app = app
192
196
  @server_port = server_port
193
197
  @server_port_string = server_port.to_s.freeze
@@ -196,6 +200,8 @@ module Raptor
196
200
  @body_spool_threshold = connection_options[:body_spool_threshold]
197
201
  @max_keepalive_requests = http1_options[:max_keepalive_requests] || MAX_KEEPALIVE_REQUESTS
198
202
  @access_log_io = access_log_io
203
+ @clean_thread_locals = clean_thread_locals
204
+ @clean_fiber_locals = clean_fiber_locals
199
205
  @on_error = on_error
200
206
  @running = AtomicBoolean.new(true)
201
207
  @env_template = {
@@ -373,7 +379,7 @@ module Raptor
373
379
  #
374
380
  # @rbs (TCPSocket socket) -> String
375
381
  def read_into_thread_buffer(socket)
376
- buffer = (Thread.current[:raptor_read_buffer] ||= String.new(capacity: READ_BUFFER_SIZE))
382
+ buffer = ThreadLocals.fetch(:raptor_read_buffer) { String.new(capacity: READ_BUFFER_SIZE) }
377
383
  socket.read_nonblock(READ_BUFFER_SIZE, buffer)
378
384
 
379
385
  while socket.respond_to?(:pending) && socket.pending.positive?
@@ -392,7 +398,7 @@ module Raptor
392
398
  #
393
399
  # @rbs (String buffer) -> [Hash[String, untyped], Hash[Symbol, untyped], Integer, HttpParser]
394
400
  def parse_next_request(buffer)
395
- parser = (Thread.current[:raptor_http_parser] ||= HttpParser.new)
401
+ parser = ThreadLocals.fetch(:raptor_http_parser) { HttpParser.new }
396
402
  parser.reset
397
403
  env = @env_template.dup
398
404
  nread = parser.execute(env, buffer, 0)
@@ -505,6 +511,19 @@ module Raptor
505
511
  #
506
512
  # @rbs (TCPSocket socket, Hash[String, untyped] env, Hash[Symbol, untyped] parse_data, String? body, Integer request_count, String remote_addr, String url_scheme) -> bool
507
513
  def process_request(socket, env, parse_data, body, request_count, remote_addr, url_scheme)
514
+ if @clean_fiber_locals
515
+ Fiber.new { perform_request(socket, env, parse_data, body, request_count, remote_addr, url_scheme) }.resume
516
+ else
517
+ perform_request(socket, env, parse_data, body, request_count, remote_addr, url_scheme)
518
+ end
519
+ ensure
520
+ ThreadLocals.clear if @clean_thread_locals
521
+ end
522
+
523
+ # Calls the Rack app and writes its response for one request.
524
+ #
525
+ # @rbs (TCPSocket socket, Hash[String, untyped] env, Hash[Symbol, untyped] parse_data, String? body, Integer request_count, String remote_addr, String url_scheme) -> bool
526
+ def perform_request(socket, env, parse_data, body, request_count, remote_addr, url_scheme)
508
527
  rack_env = nil
509
528
  status = nil
510
529
  headers = nil
@@ -571,8 +590,9 @@ module Raptor
571
590
  end
572
591
 
573
592
  # Reads and processes subsequent requests inline on a kept-alive
574
- # connection. Falls back to the reactor when no data arrives within the
575
- # timeout, the thread pool is saturated, or the request is incomplete.
593
+ # connection. Returns the connection to the reactor when no data is ready
594
+ # or a request is incomplete, and queues complete requests behind work
595
+ # that is already waiting.
576
596
  #
577
597
  # @param socket [TCPSocket] the client socket
578
598
  # @param id [Integer] unique client identifier
@@ -591,7 +611,7 @@ module Raptor
591
611
  return
592
612
  end
593
613
 
594
- unless socket.wait_readable(KEEPALIVE_READ_TIMEOUT)
614
+ unless socket.wait_readable(0)
595
615
  reactor.persist(socket, id, request_count, remote_addr: remote_addr, url_scheme: url_scheme)
596
616
  return
597
617
  end
@@ -626,7 +646,7 @@ module Raptor
626
646
 
627
647
  request_count += 1
628
648
 
629
- if thread_pool.queue_size >= thread_pool.size
649
+ if thread_pool.queue_size.positive?
630
650
  thread_pool << proc do
631
651
  process_client(
632
652
  socket,
@@ -900,7 +920,7 @@ module Raptor
900
920
 
901
921
  response = build_status_line(http_version, status)
902
922
 
903
- corked = !keep_alive
923
+ corked = !keep_alive && !body.respond_to?(:to_ary) && !no_body && !response_hijack
904
924
  cork_socket(socket) if corked
905
925
 
906
926
  if response_hijack
@@ -983,7 +1003,7 @@ module Raptor
983
1003
  # @rbs (String http_version, Integer status) -> String
984
1004
  def build_status_line(http_version, status)
985
1005
  cache = http_version == HTTP_11 ? STATUS_LINE_CACHE_11 : STATUS_LINE_CACHE_10
986
- response = (Thread.current[:raptor_response_buffer] ||= String.new(capacity: RESPONSE_BUFFER_CAPACITY))
1006
+ response = ThreadLocals.fetch(:raptor_response_buffer) { String.new(capacity: RESPONSE_BUFFER_CAPACITY) }
987
1007
  response.clear
988
1008
  response << cache[status]
989
1009
  response
data/lib/raptor/http2.rb CHANGED
@@ -8,6 +8,7 @@ require "rack"
8
8
 
9
9
  require_relative "http"
10
10
  require_relative "raptor_http2"
11
+ require_relative "thread_locals"
11
12
 
12
13
  module Raptor
13
14
  # Handles HTTP/2 request processing and Rack application integration.
@@ -267,6 +268,8 @@ module Raptor
267
268
  # @rbs @server_port: Integer
268
269
  # @rbs @write_timeout: Integer
269
270
  # @rbs @access_log_io: IO?
271
+ # @rbs @clean_thread_locals: bool
272
+ # @rbs @clean_fiber_locals: bool
270
273
  # @rbs @on_error: ^(Hash[String, untyped]?, Exception) -> void | nil
271
274
  # @rbs @initial_settings_frame: String
272
275
 
@@ -285,15 +288,19 @@ module Raptor
285
288
  # @param http2_options [Hash] HTTP/2-specific settings
286
289
  # @option http2_options [Integer] :max_concurrent_streams maximum HTTP/2 concurrent streams per connection
287
290
  # @param access_log_io [IO, nil] IO to write Common Log Format access entries to, or nil to disable
291
+ # @param clean_thread_locals [Boolean] whether to clear application thread locals after each request
292
+ # @param clean_fiber_locals [Boolean] whether to process each request in a fresh Fiber
288
293
  # @param on_error [#call, nil] callback invoked with (env, exception) when the Rack app raises
289
294
  # @return [void]
290
295
  #
291
- # @rbs (^(Hash[String, untyped]) -> [Integer, Hash[String, String | Array[String]], untyped] app, Integer server_port, ?connection_options: Hash[Symbol, untyped], ?http2_options: Hash[Symbol, untyped], ?access_log_io: IO?, ?on_error: ^(Hash[String, untyped]?, Exception) -> void | nil) -> void
292
- def initialize(app, server_port, connection_options: {}, http2_options: {}, access_log_io: nil, on_error: nil)
296
+ # @rbs (^(Hash[String, untyped]) -> [Integer, Hash[String, String | Array[String]], untyped] app, Integer server_port, ?connection_options: Hash[Symbol, untyped], ?http2_options: Hash[Symbol, untyped], ?access_log_io: IO?, ?clean_thread_locals: bool, ?clean_fiber_locals: bool, ?on_error: ^(Hash[String, untyped]?, Exception) -> void | nil) -> void
297
+ def initialize(app, server_port, connection_options: {}, http2_options: {}, access_log_io: nil, clean_thread_locals: true, clean_fiber_locals: false, on_error: nil)
293
298
  @app = app
294
299
  @server_port = server_port
295
300
  @write_timeout = connection_options[:write_timeout] || Http::WRITE_TIMEOUT
296
301
  @access_log_io = access_log_io
302
+ @clean_thread_locals = clean_thread_locals
303
+ @clean_fiber_locals = clean_fiber_locals
297
304
  @on_error = on_error
298
305
 
299
306
  parser = Http2Parser.new
@@ -730,6 +737,19 @@ module Raptor
730
737
  #
731
738
  # @rbs (OpenSSL::SSL::SSLSocket socket, Writer writer, FlowControl flow_control, Integer stream_id, Array[[String, String]] headers, String body, remote_addr: String) -> void
732
739
  def dispatch_stream_request(socket, writer, flow_control, stream_id, headers, body, remote_addr:)
740
+ if @clean_fiber_locals
741
+ Fiber.new { perform_stream_request(socket, writer, flow_control, stream_id, headers, body, remote_addr:) }.resume
742
+ else
743
+ perform_stream_request(socket, writer, flow_control, stream_id, headers, body, remote_addr:)
744
+ end
745
+ ensure
746
+ ThreadLocals.clear if @clean_thread_locals
747
+ end
748
+
749
+ # Calls the Rack app and writes its response for one stream.
750
+ #
751
+ # @rbs (OpenSSL::SSL::SSLSocket socket, Writer writer, FlowControl flow_control, Integer stream_id, Array[[String, String]] headers, String body, remote_addr: String) -> void
752
+ def perform_stream_request(socket, writer, flow_control, stream_id, headers, body, remote_addr:)
733
753
  env = build_rack_env(headers, body, remote_addr: remote_addr)
734
754
  status, response_headers, response_body = @app.call(env)
735
755