raptor 0.19.0 → 0.20.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,131 @@
1
+ # rbs_inline: enabled
2
+ # frozen_string_literal: true
3
+
4
+ require "json"
5
+ require "socket"
6
+ require "uri"
7
+
8
+ module Raptor
9
+ # Serves cluster statistics over a Unix socket.
10
+ #
11
+ class ControlServer
12
+ # @rbs @path: String
13
+ # @rbs @stats: ^() -> Hash[Symbol, untyped]
14
+ # @rbs @server: UNIXServer?
15
+ # @rbs @client: UNIXSocket?
16
+ # @rbs @thread: Thread?
17
+ # @rbs @running: bool
18
+ # @rbs @mutex: Mutex
19
+
20
+ # Creates a control server for `url` without binding it.
21
+ #
22
+ # @param url [String] `unix://` URL to listen on
23
+ # @yieldreturn [Hash] cluster statistics
24
+ # @return [void]
25
+ # @raise [ArgumentError] if the URL is not a Unix socket
26
+ #
27
+ # @rbs (String url) { () -> Hash[Symbol, untyped] } -> void
28
+ def initialize(url, &stats)
29
+ uri = URI(url)
30
+ raise ArgumentError, "control_url must use unix://" unless uri.scheme == "unix" && !uri.path.empty?
31
+
32
+ @path = uri.path
33
+ @stats = stats
34
+ @server = nil
35
+ @client = nil
36
+ @thread = nil
37
+ @running = false
38
+ @mutex = Mutex.new
39
+ end
40
+
41
+ # Binds the Unix socket.
42
+ #
43
+ # @return [void]
44
+ #
45
+ # @rbs () -> void
46
+ def bind
47
+ remove_stale_socket
48
+ @server = UNIXServer.new(@path)
49
+ end
50
+
51
+ # Starts serving requests in a background thread.
52
+ #
53
+ # @return [void]
54
+ #
55
+ # @rbs () -> void
56
+ def start
57
+ @running = true
58
+ owner_pid = Process.pid
59
+ at_exit { File.delete(@path) rescue nil if Process.pid == owner_pid }
60
+
61
+ @thread = Thread.new do
62
+ Thread.current.name = "Control Server"
63
+
64
+ serve
65
+ end
66
+ end
67
+
68
+ # Stops serving and removes the socket.
69
+ #
70
+ # @return [void]
71
+ #
72
+ # @rbs () -> void
73
+ def shutdown
74
+ @running = false
75
+ @mutex.synchronize do
76
+ @server&.close
77
+ @client&.close
78
+ end
79
+ @thread&.join
80
+ File.delete(@path) rescue nil
81
+ end
82
+
83
+ private
84
+
85
+ # @rbs () -> void
86
+ def remove_stale_socket
87
+ return unless File.exist?(@path)
88
+
89
+ begin
90
+ UNIXSocket.new(@path).close
91
+ raise "Socket #{@path.inspect} is already in use"
92
+ rescue Errno::ECONNREFUSED
93
+ File.delete(@path)
94
+ end
95
+ end
96
+
97
+ # @rbs () -> void
98
+ def serve
99
+ while @running
100
+ readable, = IO.select([@server], nil, nil, 1)
101
+ next unless readable
102
+
103
+ @mutex.synchronize do
104
+ client = @server.accept_nonblock(exception: false)
105
+ @client = client if client.is_a?(UNIXSocket)
106
+ end
107
+ handle(@client) if @client
108
+ end
109
+ rescue IOError, Errno::EBADF
110
+ end
111
+
112
+ # @rbs (UNIXSocket client) -> void
113
+ def handle(client)
114
+ request_line = client.gets
115
+ while line = client.gets
116
+ break if line == "\r\n"
117
+ end
118
+
119
+ if request_line&.start_with?("GET /stats ")
120
+ body = JSON.generate(@stats.call)
121
+ client.write("HTTP/1.0 200 OK\r\nContent-Type: application/json\r\nContent-Length: #{body.bytesize}\r\n\r\n#{body}")
122
+ else
123
+ client.write("HTTP/1.0 404 Not Found\r\nContent-Length: 0\r\n\r\n")
124
+ end
125
+ rescue IOError, SystemCallError
126
+ ensure
127
+ client.close rescue nil
128
+ @mutex.synchronize { @client = nil }
129
+ end
130
+ end
131
+ 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
 
@@ -0,0 +1,38 @@
1
+ # rbs_inline: enabled
2
+ # frozen_string_literal: true
3
+
4
+ module Raptor
5
+ # Owns Raptor's reusable per-thread values and clears application thread
6
+ # locals after each request.
7
+ #
8
+ module ThreadLocals
9
+ PRESERVED_KEYS = [
10
+ :raptor_http_parser,
11
+ :raptor_read_buffer,
12
+ :raptor_response_buffer,
13
+ ].freeze
14
+
15
+ # Returns a reusable value stored on the current thread.
16
+ #
17
+ # @return [Object]
18
+ #
19
+ # @rbs (Symbol key) { () -> untyped } -> untyped
20
+ def self.fetch(key)
21
+ thread = Thread.current
22
+ thread.thread_variable_get(key) || thread.thread_variable_set(key, yield)
23
+ end
24
+
25
+ # Clears application-owned true thread locals while retaining Raptor's
26
+ # reusable per-thread values.
27
+ #
28
+ # @return [void]
29
+ #
30
+ # @rbs () -> void
31
+ def self.clear
32
+ thread = Thread.current
33
+ thread.thread_variables.each do |key|
34
+ thread.thread_variable_set(key, nil) unless PRESERVED_KEYS.include?(key)
35
+ end
36
+ end
37
+ end
38
+ end
@@ -2,5 +2,5 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  module Raptor
5
- VERSION = "0.19.0"
5
+ VERSION = "0.20.1"
6
6
  end
@@ -106,6 +106,14 @@ module Raptor
106
106
  # @rbs (String? path) -> void
107
107
  def apply_config_file: (String? path) -> void
108
108
 
109
+ # Applies worker and thread settings from the environment. Explicit CLI
110
+ # options are parsed afterwards and take precedence.
111
+ #
112
+ # @return [void]
113
+ #
114
+ # @rbs () -> void
115
+ def apply_environment: () -> void
116
+
109
117
  # Creates the OptionParser instance with all supported command-line options.
110
118
  #
111
119
  # @return [OptionParser] configured option parser