raptor 0.7.0 → 0.9.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/http2.rb CHANGED
@@ -6,7 +6,7 @@ require "stringio"
6
6
  require "atomic-ruby/atom"
7
7
  require "rack"
8
8
 
9
- require_relative "request"
9
+ require_relative "http"
10
10
  require_relative "raptor_http2"
11
11
 
12
12
  module Raptor
@@ -27,12 +27,17 @@ module Raptor
27
27
  IDLE = :idle
28
28
 
29
29
  # @rbs @state: Atom
30
+ # @rbs @write_timeout: Integer
30
31
 
31
32
  # Creates a new Writer.
32
33
  #
33
- # @rbs () -> void
34
- def initialize
34
+ # @param write_timeout [Integer] per-write socket timeout passed through to {Http.socket_write}
35
+ # @return [void]
36
+ #
37
+ # @rbs (write_timeout: Integer) -> void
38
+ def initialize(write_timeout:)
35
39
  @state = Atom.new(IDLE)
40
+ @write_timeout = write_timeout
36
41
  end
37
42
 
38
43
  # Writes frames to the socket, coordinating with concurrent writers
@@ -69,7 +74,7 @@ module Raptor
69
74
  break if pending.empty?
70
75
 
71
76
  pending.each do |frame|
72
- Request.socket_write(socket, frame) rescue nil
77
+ Http.socket_write(socket, frame, timeout: @write_timeout) rescue nil
73
78
  end
74
79
  end
75
80
  end
@@ -78,7 +83,7 @@ module Raptor
78
83
  # Per-connection outbound flow-control accounting.
79
84
  #
80
85
  # Tracks the peer's connection-level and per-stream receive windows so
81
- # outbound DATA frames respect RFC 7540 §5.2. Threads dispatching stream
86
+ # outbound DATA frames respect RFC 7540 section 5.2. Threads dispatching stream
82
87
  # responses call `acquire` to reserve send capacity; threads applying
83
88
  # inbound WINDOW_UPDATE or SETTINGS frames call the mutating methods to
84
89
  # replenish it. The connection window and per-stream windows live in
@@ -146,9 +151,9 @@ module Raptor
146
151
  end
147
152
 
148
153
  if granted > 0
149
- @stream_windows.swap do |s|
150
- current = s[stream_id] || initial
151
- s.merge(stream_id => current - granted)
154
+ @stream_windows.swap do |windows|
155
+ current = windows[stream_id] || initial
156
+ windows.merge(stream_id => current - granted)
152
157
  end
153
158
  return granted
154
159
  end
@@ -178,14 +183,14 @@ module Raptor
178
183
  # @rbs (Integer stream_id, Integer increment) -> void
179
184
  def add_stream_window(stream_id, increment)
180
185
  initial = @initial_stream_window.value
181
- @stream_windows.swap do |s|
182
- current = s[stream_id] || initial
183
- s.merge(stream_id => current + increment)
186
+ @stream_windows.swap do |windows|
187
+ current = windows[stream_id] || initial
188
+ windows.merge(stream_id => current + increment)
184
189
  end
185
190
  end
186
191
 
187
192
  # Updates the peer's `SETTINGS_INITIAL_WINDOW_SIZE`. Shifts every
188
- # existing stream window by the delta as required by RFC 7540 §6.9.2.
193
+ # existing stream window by the delta as required by RFC 7540 section 6.9.2.
189
194
  #
190
195
  # @param new_size [Integer] the peer's new initial window size
191
196
  # @return [void]
@@ -197,8 +202,8 @@ module Raptor
197
202
  delta = new_size - old
198
203
  return if delta.zero?
199
204
 
200
- @stream_windows.swap do |s|
201
- s.transform_values { |size| size + delta }
205
+ @stream_windows.swap do |windows|
206
+ windows.transform_values { |size| size + delta }
202
207
  end
203
208
  end
204
209
 
@@ -213,12 +218,12 @@ module Raptor
213
218
  def discard_stream(stream_id)
214
219
  return unless @stream_windows.value.key?(stream_id)
215
220
 
216
- @stream_windows.swap do |s|
217
- next s unless s.key?(stream_id)
221
+ @stream_windows.swap do |windows|
222
+ next windows unless windows.key?(stream_id)
218
223
 
219
- new = s.dup
220
- new.delete(stream_id)
221
- new
224
+ pruned = windows.dup
225
+ pruned.delete(stream_id)
226
+ pruned
222
227
  end
223
228
  end
224
229
  end
@@ -244,34 +249,51 @@ module Raptor
244
249
 
245
250
  # @rbs @app: ^(Hash[String, untyped]) -> [Integer, Hash[String, String | Array[String]], untyped]
246
251
  # @rbs @server_port: Integer
252
+ # @rbs @write_timeout: Integer
253
+ # @rbs @access_log_io: IO?
247
254
  # @rbs @on_error: ^(Hash[String, untyped]?, Exception) -> void | nil
255
+ # @rbs @initial_settings_frame: String
256
+
257
+ # The initial server SETTINGS frame sent on every new HTTP/2 connection.
258
+ #
259
+ # @return [String] the encoded SETTINGS frame
260
+ attr_reader :initial_settings_frame
248
261
 
249
262
  # Creates a new Http2 handler.
250
263
  #
251
264
  # @param app [#call] the Rack application to dispatch requests to
252
265
  # @param server_port [Integer] port number used to populate SERVER_PORT in the Rack env
266
+ # @param connection_options [Hash] per-connection settings shared across protocols
267
+ # @option connection_options [Integer] :write_timeout per-write socket timeout in seconds
268
+ # @param http2_options [Hash] HTTP/2-specific settings
269
+ # @option http2_options [Integer] :max_concurrent_streams maximum HTTP/2 concurrent streams per connection
270
+ # @param access_log_io [IO, nil] IO to write Common Log Format access entries to, or nil to disable
253
271
  # @param on_error [#call, nil] callback invoked with (env, exception) when the Rack app raises
254
272
  # @return [void]
255
273
  #
256
- # @rbs (^(Hash[String, untyped]) -> [Integer, Hash[String, String | Array[String]], untyped] app, Integer server_port, ?on_error: ^(Hash[String, untyped]?, Exception) -> void | nil) -> void
257
- def initialize(app, server_port, on_error: nil)
274
+ # @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
275
+ def initialize(app, server_port, connection_options: {}, http2_options: {}, access_log_io: nil, on_error: nil)
258
276
  @app = app
259
277
  @server_port = server_port
278
+ @write_timeout = connection_options[:write_timeout] || Http::WRITE_TIMEOUT
279
+ @access_log_io = access_log_io
260
280
  @on_error = on_error
261
- end
262
281
 
263
- # Builds the initial server SETTINGS frame to send on connection establishment.
264
- #
265
- # @return [String] the encoded SETTINGS frame
266
- #
267
- # @rbs () -> String
268
- def self.build_server_settings_frame
269
282
  parser = Http2Parser.new
270
283
  settings_payload = parser.build_settings(
271
- max_concurrent_streams: 100,
284
+ max_concurrent_streams: http2_options[:max_concurrent_streams],
272
285
  initial_window_size: DEFAULT_WINDOW_SIZE
273
286
  )
274
- parser.build_frame(:settings, 0, 0, settings_payload)
287
+ @initial_settings_frame = parser.build_frame(:settings, 0, 0, settings_payload).freeze
288
+ end
289
+
290
+ # Creates a per-connection {Writer} configured with the handler's write timeout.
291
+ #
292
+ # @return [Writer] a new per-connection frame writer
293
+ #
294
+ # @rbs () -> Writer
295
+ def create_writer
296
+ Writer.new(write_timeout: @write_timeout)
275
297
  end
276
298
 
277
299
  # Processes HTTP/2 frames from the connection buffer.
@@ -626,7 +648,8 @@ module Raptor
626
648
  env = build_rack_env(headers, body, remote_addr: remote_addr)
627
649
  status, response_headers, response_body = @app.call(env)
628
650
 
629
- write_http2_response(socket, writer, flow_control, stream_id, status, response_headers, response_body)
651
+ response_size = write_http2_response(socket, writer, flow_control, stream_id, status, response_headers, response_body)
652
+ write_access_log(env, status, response_size, remote_addr) if @access_log_io
630
653
  rescue => error
631
654
  write_http2_error_response(socket, writer, stream_id)
632
655
 
@@ -652,9 +675,9 @@ module Raptor
652
675
  # @param status [Integer] HTTP status code
653
676
  # @param headers [Hash] response headers from the Rack application
654
677
  # @param body [Object] response body responding to each
655
- # @return [void]
678
+ # @return [String] the response body size in bytes
656
679
  #
657
- # @rbs (OpenSSL::SSL::SSLSocket socket, Writer writer, FlowControl flow_control, Integer stream_id, Integer status, Hash[String, String | Array[String]] headers, untyped body) -> void
680
+ # @rbs (OpenSSL::SSL::SSLSocket socket, Writer writer, FlowControl flow_control, Integer stream_id, Integer status, Hash[String, String | Array[String]] headers, untyped body) -> String
658
681
  def write_http2_response(socket, writer, flow_control, stream_id, status, headers, body)
659
682
  parser = Http2Parser.new
660
683
 
@@ -673,11 +696,16 @@ module Raptor
673
696
 
674
697
  encoded_headers = parser.encode_headers(header_pairs)
675
698
  body_chunks = []
676
- body.each { |chunk| body_chunks << chunk unless chunk.empty? }
699
+ body_bytes = 0
700
+ body.each do |chunk|
701
+ next if chunk.empty?
702
+ body_chunks << chunk
703
+ body_bytes += chunk.bytesize
704
+ end
677
705
 
678
706
  if body_chunks.empty?
679
707
  writer.write_frames(socket, [parser.build_frame(:headers, FLAG_END_STREAM | FLAG_END_HEADERS, stream_id, encoded_headers)])
680
- return
708
+ return "0"
681
709
  end
682
710
 
683
711
  frames = [parser.build_frame(:headers, FLAG_END_HEADERS, stream_id, encoded_headers)]
@@ -697,6 +725,7 @@ module Raptor
697
725
  end
698
726
 
699
727
  writer.write_frames(socket, frames)
728
+ body_bytes.to_s
700
729
  end
701
730
 
702
731
  # Writes a 500 error response as HTTP/2 frames.
@@ -717,6 +746,20 @@ module Raptor
717
746
  )
718
747
  end
719
748
 
749
+ # Instance-level wrapper around {Http.write_access_log} that routes to
750
+ # the configured `@access_log_io`.
751
+ #
752
+ # @param env [Hash] the Rack environment
753
+ # @param status [Integer] the response status code
754
+ # @param size [String] the response body size in bytes, or `-` if unknown
755
+ # @param remote_addr [String] the client IP address
756
+ # @return [void]
757
+ #
758
+ # @rbs (Hash[String, untyped] env, Integer status, String size, String remote_addr) -> void
759
+ def write_access_log(env, status, size, remote_addr)
760
+ Http.write_access_log(@access_log_io, env, status, size, remote_addr)
761
+ end
762
+
720
763
  # Builds a Rack environment hash from HTTP/2 headers and body.
721
764
  #
722
765
  # Translates HTTP/2 pseudo-headers into Rack-compatible environment keys
@@ -743,9 +786,9 @@ module Raptor
743
786
  when ":authority" then env[Rack::HTTP_HOST] = value
744
787
  end
745
788
  elsif name == "content-type"
746
- env["CONTENT_TYPE"] = value
789
+ env[Http::CONTENT_TYPE] = value
747
790
  elsif name == "content-length"
748
- env["CONTENT_LENGTH"] = value
791
+ env[Http::CONTENT_LENGTH] = value
749
792
  else
750
793
  rack_key = "HTTP_#{name.upcase.tr("-", "_")}"
751
794
  env[rack_key] = value
@@ -763,11 +806,13 @@ module Raptor
763
806
  env[Rack::PATH_INFO] = "" unless env.key?(Rack::PATH_INFO)
764
807
  env[Rack::QUERY_STRING] = "" unless env.key?(Rack::QUERY_STRING)
765
808
 
766
- if body.bytesize.positive? && !env.key?("CONTENT_LENGTH")
767
- env["CONTENT_LENGTH"] = body.bytesize.to_s
809
+ if body.bytesize.positive? && !env.key?(Http::CONTENT_LENGTH)
810
+ env[Http::CONTENT_LENGTH] = body.bytesize.to_s
768
811
  end
769
812
 
770
- env["REMOTE_ADDR"] = remote_addr
813
+ env[Http::REMOTE_ADDR] = remote_addr
814
+ env[Http::SERVER_SOFTWARE] = Http::SERVER_SOFTWARE_VALUE
815
+ env[Http::HTTP_VERSION] = SERVER_PROTOCOL
771
816
 
772
817
  populate_server_name_and_port(env)
773
818
 
@@ -14,10 +14,12 @@ module Raptor
14
14
  # the server uses for backpressure control to prevent overload.
15
15
  #
16
16
  # @example
17
- # reactor = Reactor.new(ractor_pool, thread_pool, client_options: {
18
- # first_data_timeout: 30,
19
- # chunk_data_timeout: 10
20
- # })
17
+ # reactor = Reactor.new(
18
+ # ractor_pool,
19
+ # thread_pool,
20
+ # connection_options: { first_data_timeout: 30, chunk_data_timeout: 10 },
21
+ # http1_options: { persistent_data_timeout: 65 }
22
+ # )
21
23
  # reactor.run
22
24
  # reactor.add(id: client.object_id, socket: client)
23
25
  # # ... later
@@ -68,7 +70,9 @@ module Raptor
68
70
 
69
71
  # @rbs @thread_pool: untyped
70
72
  # @rbs @ractor_pool: untyped
71
- # @rbs @client_options: Hash[Symbol, Integer]
73
+ # @rbs @first_data_timeout: Integer
74
+ # @rbs @chunk_data_timeout: Integer
75
+ # @rbs @persistent_data_timeout: Integer
72
76
  # @rbs @selector: NIO::Selector
73
77
  # @rbs @queue: Queue[TCPSocket]
74
78
  # @rbs @timeouts: RedBlackTree[TimeoutClient]
@@ -82,17 +86,20 @@ module Raptor
82
86
  #
83
87
  # @param ractor_pool [RactorPool] ractor pool for HTTP parsing
84
88
  # @param thread_pool [AtomicThreadPool] thread pool for application processing
85
- # @param client_options [Hash] timeout configuration options
86
- # @option client_options [Integer] :first_data_timeout timeout for initial data
87
- # @option client_options [Integer] :chunk_data_timeout timeout for subsequent chunks
88
- # @option client_options [Integer] :persistent_data_timeout timeout for keep-alive connections
89
+ # @param connection_options [Hash] per-connection timeout configuration
90
+ # @option connection_options [Integer] :first_data_timeout timeout for initial data
91
+ # @option connection_options [Integer] :chunk_data_timeout timeout for subsequent chunks
92
+ # @param http1_options [Hash] HTTP/1.1-specific configuration
93
+ # @option http1_options [Integer] :persistent_data_timeout timeout for keep-alive idle connections
89
94
  # @return [void]
90
95
  #
91
- # @rbs (untyped ractor_pool, untyped thread_pool, client_options: Hash[Symbol, Integer]) -> void
92
- def initialize(ractor_pool, thread_pool, client_options:)
96
+ # @rbs (untyped ractor_pool, untyped thread_pool, connection_options: Hash[Symbol, untyped], http1_options: Hash[Symbol, untyped]) -> void
97
+ def initialize(ractor_pool, thread_pool, connection_options:, http1_options:)
93
98
  @ractor_pool = ractor_pool
94
99
  @thread_pool = thread_pool
95
- @client_options = client_options
100
+ @first_data_timeout = connection_options[:first_data_timeout]
101
+ @chunk_data_timeout = connection_options[:chunk_data_timeout]
102
+ @persistent_data_timeout = http1_options[:persistent_data_timeout]
96
103
 
97
104
  @selector = NIO::Selector.new
98
105
  @queue = Queue.new
@@ -354,11 +361,11 @@ module Raptor
354
361
  state = @socket_to_state[socket]
355
362
  client = TimeoutClient.new(state)
356
363
  timeout = if state[:persisted]
357
- @client_options[:persistent_data_timeout]
364
+ @persistent_data_timeout
358
365
  elsif first_data_received?(state)
359
- @client_options[:chunk_data_timeout]
366
+ @chunk_data_timeout
360
367
  else
361
- @client_options[:first_data_timeout]
368
+ @first_data_timeout
362
369
  end
363
370
  client.timeout_at = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
364
371
  @timeouts << client
@@ -0,0 +1,108 @@
1
+ # rbs_inline: enabled
2
+ # frozen_string_literal: true
3
+
4
+ require "socket"
5
+ require "uri"
6
+
7
+ begin
8
+ require "libbpf-ruby"
9
+ rescue LoadError
10
+ end
11
+
12
+ module Raptor
13
+ # Routes incoming connections across worker processes to the worker with
14
+ # the lowest reactor backlog.
15
+ #
16
+ # Auto-enabled on Linux when the `libbpf-ruby` gem is installed and the
17
+ # BPF object file has been compiled. Falls back silently to standard
18
+ # `SO_REUSEPORT` when either is missing. Raises when the kernel refuses
19
+ # the BPF program. Wraps `tcp://` bindings only; non-tcp bindings are
20
+ # left to the binder's shared listeners.
21
+ #
22
+ module ReuseportBPF
23
+ LIBBPF_LOADED = !!defined?(LibBPFRuby)
24
+ BPF_OBJECT_PATH = File.expand_path("../../ext/raptor_bpf/reuseport_select.bpf.o", __dir__)
25
+
26
+ # Whether the preconditions for BPF-directed reuseport are met on this
27
+ # platform. Does not imply that the kernel will accept the program.
28
+ #
29
+ # @return [Boolean]
30
+ #
31
+ # @rbs () -> bool
32
+ def self.supported?
33
+ LIBBPF_LOADED && File.exist?(BPF_OBJECT_PATH)
34
+ end
35
+
36
+ # Prepares BPF-directed routing for `worker_count` workers.
37
+ #
38
+ # Returns true when BPF-directed dispatch is active. Returns false
39
+ # when the platform is unsupported. Raises when the kernel refuses
40
+ # the program.
41
+ #
42
+ # @param worker_count [Integer] number of worker processes that will bind sockets
43
+ # @return [Boolean]
44
+ #
45
+ # @rbs (Integer worker_count) -> bool
46
+ def self.setup(worker_count)
47
+ return false unless supported?
48
+
49
+ @object = LibBPFRuby::Object.new(BPF_OBJECT_PATH)
50
+ @program_fd = @object.program_fd("select_least_loaded")
51
+ @socks_fd = @object.map_fd("socks")
52
+ @loads_fd = @object.map_fd("loads")
53
+
54
+ LibBPFRuby.map_update(@loads_fd, [0].pack("L"), [worker_count].pack("L"))
55
+ true
56
+ end
57
+
58
+ # Returns tcp listening sockets bound and registered for `worker_index`.
59
+ # Skips non-tcp bind URIs.
60
+ #
61
+ # @param bind_uris [Array<String>] the configured bind URIs
62
+ # @param worker_index [Integer] slot index for this worker in the socks map
63
+ # @param socket_backlog [Integer] kernel listen() queue depth
64
+ # @return [Array<TCPServer>] the tcp listening sockets created for this worker
65
+ #
66
+ # @rbs (Array[String] bind_uris, Integer worker_index, Integer socket_backlog) -> Array[TCPServer]
67
+ def self.create_worker_listeners(bind_uris, worker_index, socket_backlog)
68
+ @load_key = [worker_index + 1].pack("L")
69
+ @load_value = String.new("\x00\x00\x00\x00", encoding: Encoding::ASCII_8BIT)
70
+
71
+ bind_uris.filter_map do |bind_uri|
72
+ uri = URI(bind_uri)
73
+ next unless uri.scheme == "tcp"
74
+
75
+ host = uri.host
76
+ host = host[1..-2] if host&.start_with?("[")
77
+
78
+ addrinfo = Addrinfo.tcp(host, uri.port)
79
+ socket = Socket.new(addrinfo.afamily, Socket::SOCK_STREAM, 0)
80
+ socket.setsockopt(Socket::SOL_SOCKET, Socket::SO_REUSEADDR, true)
81
+ socket.setsockopt(Socket::SOL_SOCKET, Socket::SO_REUSEPORT, true)
82
+ socket.bind(addrinfo)
83
+ socket.listen(socket_backlog)
84
+
85
+ LibBPFRuby.sockmap_update(@socks_fd, [worker_index].pack("L"), socket)
86
+ LibBPFRuby.attach_reuseport(socket, @program_fd)
87
+
88
+ tcp_server = TCPServer.for_fd(socket.fileno)
89
+ socket.autoclose = false
90
+ tcp_server
91
+ end
92
+ end
93
+
94
+ # Publishes this worker's current backlog to the BPF map.
95
+ #
96
+ # @param backlog [Integer] the worker's current reactor backlog
97
+ # @return [void]
98
+ #
99
+ # @rbs (Integer backlog) -> void
100
+ def self.update_load(backlog)
101
+ @load_value.setbyte(0, backlog & 0xff)
102
+ @load_value.setbyte(1, (backlog >> 8) & 0xff)
103
+ @load_value.setbyte(2, (backlog >> 16) & 0xff)
104
+ @load_value.setbyte(3, (backlog >> 24) & 0xff)
105
+ LibBPFRuby.map_update(@loads_fd, @load_key, @load_value)
106
+ end
107
+ end
108
+ end