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/server.rb CHANGED
@@ -5,6 +5,8 @@ require "socket"
5
5
 
6
6
  require "atomic-ruby/atomic_boolean"
7
7
 
8
+ require_relative "reuseport_bpf"
9
+
8
10
  module Raptor
9
11
  # Accepts client connections and dispatches them into the request
10
12
  # pipeline. Skips acceptance when the reactor backlog is high so an
@@ -19,9 +21,10 @@ module Raptor
19
21
  #
20
22
  # @example
21
23
  # binder = Binder.new(["tcp://0.0.0.0:3000"])
22
- # reactor = Reactor.new(ractor_pool, thread_pool, client_options: {})
23
- # request = Request.new(app, 3000)
24
- # server = Server.new(binder, reactor, thread_pool, request, client_options: { first_data_timeout: 30 })
24
+ # reactor = Reactor.new(ractor_pool, thread_pool, connection_options: {}, http1_options: {})
25
+ # http1 = Http1.new(app, 3000)
26
+ # http2 = Http2.new(app, 3000)
27
+ # server = Server.new(binder, reactor, thread_pool, http1, http2, connection_options: { first_data_timeout: 30 }, listeners: binder.listeners)
25
28
  # server.run
26
29
  # # ... later
27
30
  # server.shutdown
@@ -35,13 +38,17 @@ module Raptor
35
38
  DEFAULT_REMOTE_ADDR = "127.0.0.1"
36
39
  DEFAULT_SERVER_NAME = "localhost"
37
40
 
38
- MIN_BACKPRESSURE_THRESHOLD = 64
41
+ MIN_BACKPRESSURE_THRESHOLD = 8
39
42
 
40
43
  # @rbs @binder: Binder
44
+ # @rbs @listeners: Array[TCPServer | UNIXServer | Binder::SslListener]
41
45
  # @rbs @reactor: Reactor
42
46
  # @rbs @thread_pool: AtomicThreadPool
43
- # @rbs @request: Request
44
- # @rbs @client_options: Hash[Symbol, untyped]
47
+ # @rbs @http1: Http1
48
+ # @rbs @http2: Http2
49
+ # @rbs @first_data_timeout: Integer
50
+ # @rbs @drain_accept_queue: bool
51
+ # @rbs @bpf_active: bool
45
52
  # @rbs @running: AtomicBoolean
46
53
 
47
54
  # Creates a new Server instance.
@@ -49,66 +56,109 @@ module Raptor
49
56
  # @param binder [Binder] the binder managing listening sockets
50
57
  # @param reactor [Reactor] the reactor for handling client connections
51
58
  # @param thread_pool [AtomicThreadPool] thread pool for application processing
52
- # @param request [Request] the HTTP/1.1 request handler
53
- # @param client_options [Hash] client timeout configuration, used to bound TLS handshakes
59
+ # @param http1 [Http1] the HTTP/1.1 handler
60
+ # @param http2 [Http2] the HTTP/2 handler (provides the initial SETTINGS frame)
61
+ # @param connection_options [Hash] per-connection timeout configuration, used to bound TLS handshakes
62
+ # @param listeners [Array] the per-worker listeners this server accepts on
63
+ # @param drain_accept_queue [Boolean] whether to drain the kernel accept queue on shutdown
64
+ # @param worker_index [Integer, nil] the slot index for BPF load reporting
54
65
  # @return [void]
55
66
  #
56
- # @rbs (Binder binder, Reactor reactor, AtomicThreadPool thread_pool, Request request, client_options: Hash[Symbol, untyped]) -> void
57
- def initialize(binder, reactor, thread_pool, request, client_options:)
67
+ # @rbs (Binder binder, Reactor reactor, AtomicThreadPool thread_pool, Http1 http1, Http2 http2, connection_options: Hash[Symbol, untyped], listeners: Array[untyped], ?drain_accept_queue: bool, ?worker_index: Integer?) -> void
68
+ def initialize(binder, reactor, thread_pool, http1, http2, connection_options:, listeners:, drain_accept_queue: false, worker_index: nil)
58
69
  @binder = binder
70
+ @listeners = listeners
59
71
  @reactor = reactor
60
72
  @thread_pool = thread_pool
61
- @request = request
62
- @client_options = client_options
73
+ @http1 = http1
74
+ @http2 = http2
75
+ @first_data_timeout = connection_options[:first_data_timeout]
76
+ @drain_accept_queue = drain_accept_queue
77
+ @bpf_active = !!worker_index
63
78
  @running = AtomicBoolean.new(true)
64
79
  end
65
80
 
66
81
  # Starts the server's main accept loop in a new thread.
67
82
  #
68
83
  # The accept loop polls listening sockets for ready connections and accepts
69
- # them when system capacity allows. It checks reactor backlog before accepting
70
- # to prevent overload. This provides natural load balancing across multiple
71
- # worker processes through backpressure control.
84
+ # them when the reactor backlog is under the backpressure threshold. On
85
+ # Linux with BPF-directed dispatch active, a companion thread publishes
86
+ # this worker's backlog to the BPF map.
72
87
  #
73
88
  # @return [Thread] the thread running the accept loop
74
89
  #
75
90
  # @rbs () -> Thread
76
91
  def run
77
- Thread.new(@binder.listeners, @reactor, @running) do |server_sockets, reactor, running|
92
+ spawn_load_reporter if @bpf_active
93
+
94
+ Thread.new do
78
95
  Thread.current.name = "Server"
79
96
 
80
- while running.true?
97
+ backpressure_threshold = [(@thread_pool.size * 1.2).ceil, MIN_BACKPRESSURE_THRESHOLD].max
98
+
99
+ while @running.true?
81
100
  begin
82
- ready_servers, _, _ = IO.select(server_sockets, nil, nil, 1)
101
+ ready_servers, _, _ = IO.select(@listeners, nil, nil, 1)
83
102
  rescue IOError, Errno::EBADF
84
103
  break
85
104
  end
86
105
 
87
106
  next unless ready_servers
88
- next if @reactor.backlog >= [(@thread_pool.size * 1.2).ceil, MIN_BACKPRESSURE_THRESHOLD].max
107
+ next if @reactor.backlog >= backpressure_threshold
89
108
 
90
- ready_servers.each do |listener|
91
- accept_connection(listener, reactor)
92
- end
109
+ ready_servers.each { |listener| accept_connection(listener) }
93
110
  end
94
111
  end
95
112
  end
96
113
 
97
114
  # Gracefully shuts down the server.
98
115
  #
99
- # Stops accepting new connections and closes all listening sockets.
100
- # The server thread will exit after handling any in-flight accept operations.
116
+ # Stops accepting new connections and closes all listening sockets. When
117
+ # `drain_accept_queue` is enabled, dispatches every connection already in
118
+ # the kernel accept queue before closing the listeners.
101
119
  #
102
120
  # @return [void]
103
121
  #
104
122
  # @rbs () -> void
105
123
  def shutdown
106
124
  @running.make_false
107
- @binder.close
125
+ drain_accept_queue if @drain_accept_queue
126
+ @listeners.each(&:close)
108
127
  end
109
128
 
110
129
  private
111
130
 
131
+ # Starts a background thread that publishes this worker's reactor
132
+ # backlog to the BPF map for load-aware dispatch.
133
+ #
134
+ # @return [void]
135
+ #
136
+ # @rbs () -> void
137
+ def spawn_load_reporter
138
+ Thread.new do
139
+ Thread.current.name = "Load Reporter"
140
+
141
+ while @running.true?
142
+ ReuseportBPF.update_load(@reactor.backlog)
143
+ sleep 0.01
144
+ end
145
+ end
146
+ end
147
+
148
+ # Dispatches every connection already in the kernel accept queue for each
149
+ # listener until all are drained.
150
+ #
151
+ # @return [void]
152
+ #
153
+ # @rbs () -> void
154
+ def drain_accept_queue
155
+ loop do
156
+ accepted = false
157
+ @listeners.each { |listener| accepted = true if accept_connection(listener) }
158
+ break unless accepted
159
+ end
160
+ end
161
+
112
162
  # Accepts a connection from the given listener and dispatches it.
113
163
  #
114
164
  # For SSL listeners the TLS handshake is offloaded to the thread pool so
@@ -118,15 +168,14 @@ module Raptor
118
168
  # follow the HTTP/1.1 path.
119
169
  #
120
170
  # @param listener [TCPServer, UNIXServer, Binder::SslListener] the ready listener
121
- # @param reactor [Reactor] the reactor to dispatch connections to
122
- # @return [void]
171
+ # @return [Boolean] true if a connection was accepted, false if the listener had nothing to dispatch
123
172
  #
124
- # @rbs (TCPServer | UNIXServer | Binder::SslListener listener, Reactor reactor) -> void
125
- def accept_connection(listener, reactor)
173
+ # @rbs (TCPServer | UNIXServer | Binder::SslListener listener) -> bool
174
+ def accept_connection(listener)
126
175
  tcp_client = begin
127
176
  listener.is_a?(Binder::SslListener) ? listener.tcp_server.accept_nonblock : listener.accept_nonblock
128
177
  rescue IO::WaitReadable
129
- return
178
+ return false
130
179
  end
131
180
 
132
181
  if tcp_client.is_a?(TCPSocket)
@@ -138,19 +187,20 @@ module Raptor
138
187
 
139
188
  if listener.is_a?(Binder::SslListener)
140
189
  @thread_pool << proc do
141
- dispatch_ssl_connection(listener, tcp_client, remote_addr, reactor)
190
+ dispatch_ssl_connection(listener, tcp_client, remote_addr)
142
191
  end
143
- return
192
+ return true
144
193
  end
145
194
 
146
- @request.eager_accept(
195
+ @http1.eager_accept(
147
196
  tcp_client,
148
197
  tcp_client.object_id,
149
- reactor,
198
+ @reactor,
150
199
  @thread_pool,
151
200
  remote_addr,
152
201
  HTTP_SCHEME
153
202
  )
203
+ true
154
204
  end
155
205
 
156
206
  # Performs the TLS handshake for an accepted SSL connection and dispatches
@@ -160,35 +210,34 @@ module Raptor
160
210
  # @param listener [Binder::SslListener] the SSL listener that accepted the connection
161
211
  # @param tcp_client [TCPSocket] the accepted TCP socket
162
212
  # @param remote_addr [String] the client's IP address
163
- # @param reactor [Reactor] the reactor to dispatch the connection to
164
213
  # @return [void]
165
214
  #
166
- # @rbs (Binder::SslListener listener, TCPSocket tcp_client, String remote_addr, Reactor reactor) -> void
167
- def dispatch_ssl_connection(listener, tcp_client, remote_addr, reactor)
215
+ # @rbs (Binder::SslListener listener, TCPSocket tcp_client, String remote_addr) -> void
216
+ def dispatch_ssl_connection(listener, tcp_client, remote_addr)
168
217
  ssl_socket = OpenSSL::SSL::SSLSocket.new(tcp_client, listener.ssl_context)
169
218
  ssl_socket.sync_close = true
170
219
  return unless perform_ssl_handshake(ssl_socket)
171
220
 
172
221
  if ssl_socket.alpn_protocol == H2_PROTOCOL
173
- ssl_socket.write(Http2.build_server_settings_frame) rescue nil
222
+ ssl_socket.write(@http2.initial_settings_frame) rescue nil
174
223
 
175
- reactor.add(
224
+ @reactor.add(
176
225
  id: ssl_socket.object_id,
177
226
  socket: ssl_socket,
178
227
  remote_addr: remote_addr,
179
228
  url_scheme: HTTPS_SCHEME,
180
229
  protocol: :http2,
181
- writer: Http2::Writer.new,
230
+ writer: @http2.create_writer,
182
231
  flow_control: Http2::FlowControl.new
183
232
  )
184
233
 
185
234
  return
186
235
  end
187
236
 
188
- @request.eager_accept(
237
+ @http1.eager_accept(
189
238
  ssl_socket,
190
239
  ssl_socket.object_id,
191
- reactor,
240
+ @reactor,
192
241
  @thread_pool,
193
242
  remote_addr,
194
243
  HTTPS_SCHEME
@@ -204,7 +253,7 @@ module Raptor
204
253
  #
205
254
  # @rbs (OpenSSL::SSL::SSLSocket ssl_socket) -> bool
206
255
  def perform_ssl_handshake(ssl_socket)
207
- deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + @client_options[:first_data_timeout]
256
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + @first_data_timeout
208
257
 
209
258
  begin
210
259
  ssl_socket.accept_nonblock
data/lib/raptor/stats.rb CHANGED
@@ -60,7 +60,7 @@ module Raptor
60
60
  # @rbs (Integer index, pid: Integer, phase: Integer, requests: Integer, backlog: Integer, busy_threads: Integer, thread_capacity: Integer, started_at: Float, last_checkin: Float, booted: bool) -> void
61
61
  def write(index, pid:, phase:, requests:, backlog:, busy_threads:, thread_capacity:, started_at:, last_checkin:, booted:)
62
62
  data = [pid, index, phase, requests, backlog, busy_threads, thread_capacity, started_at, last_checkin, booted ? 1 : 0].pack(SLOT_FORMAT)
63
- @mmap.semlock { @mmap[index * SLOT_SIZE, SLOT_SIZE] = data }
63
+ @mmap[index * SLOT_SIZE, SLOT_SIZE] = data
64
64
  end
65
65
 
66
66
  # Returns stats for all worker slots.
@@ -0,0 +1,69 @@
1
+ # rbs_inline: enabled
2
+ # frozen_string_literal: true
3
+
4
+ require "socket"
5
+
6
+ module Raptor
7
+ # Integration with systemd's service notification protocol and
8
+ # socket-activation file descriptors.
9
+ #
10
+ module Systemd
11
+ LISTEN_FDS_START = 3
12
+
13
+ LISTEN_FDNAMES_ENV = "LISTEN_FDNAMES"
14
+ LISTEN_FDS_ENV = "LISTEN_FDS"
15
+ LISTEN_PID_ENV = "LISTEN_PID"
16
+ NOTIFY_SOCKET_ENV = "NOTIFY_SOCKET"
17
+
18
+ # Sends `message` to the systemd notification socket, returning true on
19
+ # success and false when the socket is unset or the send fails.
20
+ #
21
+ # @param message [String] notify protocol payload, e.g. "READY=1"
22
+ # @return [Boolean]
23
+ #
24
+ # @rbs (String message) -> bool
25
+ def self.notify(message)
26
+ socket_path = ENV[NOTIFY_SOCKET_ENV]
27
+ return false if socket_path.nil? || socket_path.empty?
28
+
29
+ address = if socket_path.start_with?("@")
30
+ Socket.pack_sockaddr_un("\0#{socket_path[1..]}")
31
+ else
32
+ Socket.pack_sockaddr_un(socket_path)
33
+ end
34
+
35
+ socket = Socket.new(Socket::AF_UNIX, Socket::SOCK_DGRAM, 0)
36
+ socket.send(message, 0, address)
37
+ true
38
+ rescue SystemCallError, IOError
39
+ false
40
+ ensure
41
+ socket&.close
42
+ end
43
+
44
+ # Returns the file descriptors passed in via socket activation, or an
45
+ # empty array when systemd has not exported any.
46
+ #
47
+ # @return [Array<Integer>]
48
+ #
49
+ # @rbs () -> Array[Integer]
50
+ def self.listen_fds
51
+ return [] unless ENV[LISTEN_PID_ENV]&.to_i == Process.pid
52
+
53
+ count = ENV[LISTEN_FDS_ENV]&.to_i || 0
54
+ Array.new(count) { |index| LISTEN_FDS_START + index }
55
+ end
56
+
57
+ # Clears the socket-activation environment variables so children don't
58
+ # act on stale values.
59
+ #
60
+ # @return [void]
61
+ #
62
+ # @rbs () -> void
63
+ def self.clear_listen_env
64
+ ENV.delete(LISTEN_FDNAMES_ENV)
65
+ ENV.delete(LISTEN_FDS_ENV)
66
+ ENV.delete(LISTEN_PID_ENV)
67
+ end
68
+ end
69
+ end
@@ -2,5 +2,5 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  module Raptor
5
- VERSION = "0.7.0"
5
+ VERSION = "0.9.0"
6
6
  end
@@ -51,10 +51,26 @@ module Raptor
51
51
  def members: () -> [ :tcp_server, :ssl_context ]
52
52
  end
53
53
 
54
- @bind_uris: Array[String]
54
+ @uri_listeners: Hash[String, Array[TCPServer | UNIXServer | SslListener]]
55
55
 
56
56
  @listeners: Array[TCPServer | UNIXServer | SslListener]
57
57
 
58
+ @inherited_fds: Hash[String, Array[Integer]]
59
+
60
+ @socket_backlog: Integer
61
+
62
+ @bind_uris: Array[String]
63
+
64
+ # Array of bind URIs.
65
+ #
66
+ # @return [Array<String>] the bind URIs
67
+ attr_reader bind_uris: untyped
68
+
69
+ # Kernel listen() queue depth for TCP/SSL listeners.
70
+ #
71
+ # @return [Integer] the socket backlog
72
+ attr_reader socket_backlog: untyped
73
+
58
74
  # Array of listening sockets.
59
75
  #
60
76
  # @return [Array<TCPServer, UNIXServer, SslListener>] the server sockets
@@ -64,17 +80,21 @@ module Raptor
64
80
  #
65
81
  # Parses the provided bind URIs and creates listening sockets for each one.
66
82
  # Supports tcp://, unix://, and ssl:// schemes. Localhost is expanded to
67
- # all available loopback addresses (both IPv4 and IPv6).
83
+ # all available loopback addresses (both IPv4 and IPv6). When `inherited_fds`
84
+ # supplies file descriptors for a URI, the listener is reconstructed from
85
+ # those FDs instead of binding fresh.
68
86
  #
69
87
  # @param bind_uris [Array<String>] array of URI strings to bind to
88
+ # @param socket_backlog [Integer] kernel listen() queue depth for TCP/SSL listeners
89
+ # @param inherited_fds [Hash{String => Array<Integer>}] inherited listener FDs keyed by bind URI
70
90
  # @return [void]
71
91
  # @raise [UnknownBindSchemeError] if a URI has an unsupported scheme
72
92
  #
73
93
  # @example
74
94
  # binder = Binder.new(["tcp://0.0.0.0:3000", "unix:///tmp/raptor.sock"])
75
95
  #
76
- # @rbs (Array[String] bind_uris) -> void
77
- def initialize: (Array[String] bind_uris) -> void
96
+ # @rbs (Array[String] bind_uris, ?socket_backlog: Integer, ?inherited_fds: Hash[String, Array[Integer]]) -> void
97
+ def initialize: (Array[String] bind_uris, ?socket_backlog: Integer, ?inherited_fds: Hash[String, Array[Integer]]) -> void
78
98
 
79
99
  # Returns the bound addresses as strings.
80
100
  #
@@ -106,9 +126,27 @@ module Raptor
106
126
  # @rbs () -> void
107
127
  def close: () -> void
108
128
 
129
+ # Returns the file descriptors of every listener, grouped by the bind URI
130
+ # they were created from. The result is the payload to hand to a successor
131
+ # process via the `inherited_fds:` constructor argument.
132
+ #
133
+ # @return [Hash{String => Array<Integer>}]
134
+ #
135
+ # @rbs () -> Hash[String, Array[Integer]]
136
+ def inheritable_fds: () -> Hash[String, Array[Integer]]
137
+
138
+ # Clears the close-on-exec flag on every listener so the file descriptors
139
+ # survive `Kernel.exec`.
140
+ #
141
+ # @return [void]
142
+ #
143
+ # @rbs () -> void
144
+ def clear_close_on_exec: () -> void
145
+
109
146
  private
110
147
 
111
- # Parses bind URIs and creates listening sockets.
148
+ # Parses bind URIs and creates listening sockets, reusing inherited file
149
+ # descriptors for URIs supplied in `@inherited_fds`.
112
150
  #
113
151
  # @return [void]
114
152
  # @raise [UnknownBindSchemeError] if a URI scheme is not supported
@@ -116,6 +154,26 @@ module Raptor
116
154
  # @rbs () -> void
117
155
  def parse: () -> void
118
156
 
157
+ # Creates fresh listeners for the given bind URI.
158
+ #
159
+ # @param bind_uri [String] the URI to bind
160
+ # @return [Array<TCPServer, UNIXServer, SslListener>]
161
+ # @raise [UnknownBindSchemeError] if the URI scheme is not supported
162
+ #
163
+ # @rbs (String bind_uri) -> Array[TCPServer | UNIXServer | SslListener]
164
+ def create_listeners: (String bind_uri) -> Array[TCPServer | UNIXServer | SslListener]
165
+
166
+ # Reconstructs listeners for the given bind URI from inherited file
167
+ # descriptors.
168
+ #
169
+ # @param bind_uri [String] the URI the FDs were bound to
170
+ # @param filenos [Array<Integer>] file descriptors to wrap
171
+ # @return [Array<TCPServer, UNIXServer, SslListener>]
172
+ # @raise [UnknownBindSchemeError] if the URI scheme is not supported
173
+ #
174
+ # @rbs (String bind_uri, Array[Integer] filenos) -> Array[TCPServer | UNIXServer | SslListener]
175
+ def restore_listeners: (String bind_uri, Array[Integer] filenos) -> Array[TCPServer | UNIXServer | SslListener]
176
+
119
177
  # Creates TCP server sockets for the given host and port.
120
178
  #
121
179
  # @param host [String, nil] hostname or IP address to bind to
@@ -138,6 +196,16 @@ module Raptor
138
196
  # @rbs (String path) -> Array[UNIXServer]
139
197
  def create_unix_listeners: (String path) -> Array[UNIXServer]
140
198
 
199
+ # Registers an `at_exit` hook that removes the Unix socket file on the
200
+ # owning master's clean exit. Each call records the current process so
201
+ # forked workers won't delete a socket their master still owns.
202
+ #
203
+ # @param path [String] filesystem path of the Unix socket
204
+ # @return [void]
205
+ #
206
+ # @rbs (String path) -> void
207
+ def register_unix_socket_cleanup: (String path) -> void
208
+
141
209
  # Creates SSL server sockets for the given host, port, and SSL parameters.
142
210
  #
143
211
  # Wraps each TCP listener with an SSL context to produce SslListener objects.
@@ -152,6 +220,15 @@ module Raptor
152
220
  # @rbs (String? host, Integer? port, Hash[String, String] ssl_params) -> Array[SslListener]
153
221
  def create_ssl_listeners: (String? host, Integer? port, Hash[String, String] ssl_params) -> Array[SslListener]
154
222
 
223
+ # Builds a frozen `OpenSSL::SSL::SSLContext` configured for HTTP/2 and
224
+ # HTTP/1.1 ALPN negotiation.
225
+ #
226
+ # @param ssl_params [Hash<String, String>] SSL options ("cert" and "key" paths)
227
+ # @return [OpenSSL::SSL::SSLContext]
228
+ #
229
+ # @rbs (Hash[String, String] ssl_params) -> OpenSSL::SSL::SSLContext
230
+ def build_ssl_context: (Hash[String, String] ssl_params) -> OpenSSL::SSL::SSLContext
231
+
155
232
  # Returns all available loopback IP addresses.
156
233
  #
157
234
  # @return [Array<String>] unique loopback addresses (IPv4 and IPv6)
@@ -18,6 +18,8 @@ module Raptor
18
18
  class CLI
19
19
  DEFAULT_WORKER_COUNT: untyped
20
20
 
21
+ NESTED_OPTION_KEYS: untyped
22
+
21
23
  DEFAULT_OPTIONS: untyped
22
24
 
23
25
  DEFAULT_CONFIG_PATHS: untyped
@@ -103,9 +105,6 @@ module Raptor
103
105
 
104
106
  # Loads a config file and merges it into `@options` over the defaults.
105
107
  #
106
- # Top-level keys replace defaults; the nested `:client` hash is merged
107
- # key-by-key so a config file does not need to restate every client option.
108
- #
109
108
  # @param path [String, nil] path to the config file, or nil to no-op
110
109
  # @return [void]
111
110
  #