raptor 0.6.0 → 0.8.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
@@ -26,24 +26,41 @@ module Raptor
26
26
  class CLI
27
27
  DEFAULT_WORKER_COUNT = Etc.nprocessors
28
28
 
29
+ NESTED_OPTION_KEYS = [:connection, :http1, :http2].freeze
30
+
29
31
  DEFAULT_OPTIONS = {
30
32
  binds: ["tcp://0.0.0.0:9292"].freeze,
33
+ socket_backlog: 1024,
34
+ drain_accept_queue: false,
31
35
  workers: DEFAULT_WORKER_COUNT,
32
36
  ractors: 1,
33
37
  threads: 3,
34
38
  rackup: "config.ru",
35
- client: {
39
+ chdir: nil,
40
+ environment: nil,
41
+ connection: {
36
42
  first_data_timeout: 30,
37
43
  chunk_data_timeout: 10,
38
- persistent_data_timeout: 65,
44
+ write_timeout: 5,
39
45
  max_body_size: nil,
40
46
  body_spool_threshold: 1024 * 1024,
41
47
  },
42
- worker_timeout: 60,
48
+ http1: {
49
+ persistent_data_timeout: 65,
50
+ max_keepalive_requests: 100,
51
+ },
52
+ http2: {
53
+ max_concurrent_streams: 100,
54
+ },
43
55
  worker_boot_timeout: 60,
56
+ worker_timeout: 60,
57
+ worker_drain_timeout: 25,
44
58
  worker_shutdown_timeout: 30,
45
59
  stats_file: "tmp/raptor.json",
46
60
  pid_file: nil,
61
+ stdout_file: nil,
62
+ stderr_file: nil,
63
+ access_log_file: nil,
47
64
  }.freeze
48
65
 
49
66
  DEFAULT_CONFIG_PATHS = ["raptor.rb", "config/raptor.rb"].freeze
@@ -102,14 +119,17 @@ module Raptor
102
119
  #
103
120
  # @rbs (Array[String] argv) -> void
104
121
  def initialize(argv)
122
+ @options = DEFAULT_OPTIONS.dup
123
+ NESTED_OPTION_KEYS.each { |key| @options[key] = @options[key].dup }
124
+ @options[:launch_command] = $PROGRAM_NAME
125
+ @options[:launch_argv] = argv.dup
126
+
105
127
  if argv.first == "stats"
106
128
  argv.shift
107
129
  @command = :stats
108
130
  else
109
131
  @command = :server
110
132
  end
111
- @options = DEFAULT_OPTIONS.dup
112
- @options[:client] = @options[:client].dup
113
133
 
114
134
  apply_config_file(extract_config_path(argv) || self.class.default_config_path)
115
135
 
@@ -178,9 +198,6 @@ module Raptor
178
198
 
179
199
  # Loads a config file and merges it into `@options` over the defaults.
180
200
  #
181
- # Top-level keys replace defaults; the nested `:client` hash is merged
182
- # key-by-key so a config file does not need to restate every client option.
183
- #
184
201
  # @param path [String, nil] path to the config file, or nil to no-op
185
202
  # @return [void]
186
203
  #
@@ -190,8 +207,8 @@ module Raptor
190
207
 
191
208
  config = self.class.load_config_file(path)
192
209
  config.each do |key, value|
193
- if key == :client && value.is_a?(Hash)
194
- @options[:client] = @options[:client].merge(value)
210
+ if NESTED_OPTION_KEYS.include?(key) && value.is_a?(Hash)
211
+ @options[key] = @options[key].merge(value)
195
212
  else
196
213
  @options[key] = value
197
214
  end
@@ -219,46 +236,78 @@ module Raptor
219
236
  end
220
237
  end
221
238
 
239
+ opts.on("--socket-backlog NUM", Integer, "Socket listen backlog (default: 1024)") do |num|
240
+ @options[:socket_backlog] = num
241
+ end
242
+
243
+ opts.on("--[no-]drain-accept-queue", "Drain the kernel accept queue on shutdown (default: off)") do |bool|
244
+ @options[:drain_accept_queue] = bool
245
+ end
246
+
222
247
  opts.on("-w", "--workers NUM", Integer, "Number of worker processes (default: #{DEFAULT_WORKER_COUNT})") do |num|
223
248
  @options[:workers] = num
224
249
  end
225
250
 
226
- opts.on("-r", "--ractors NUM", Integer, "Number of ractors (default: 1)") do |num|
251
+ opts.on("-r", "--ractors NUM", Integer, "Number of pipeline ractors per worker (default: 1)") do |num|
227
252
  @options[:ractors] = num
228
253
  end
229
254
 
230
- opts.on("-t", "--threads NUM", Integer, "Number of threads (default: 3)") do |num|
255
+ opts.on("-t", "--threads NUM", Integer, "Number of application threads per worker (default: 3)") do |num|
231
256
  @options[:threads] = num
232
257
  end
233
258
 
259
+ opts.on("-C", "--chdir PATH", String, "Change to PATH before loading the Rack application (default: none)") do |path|
260
+ @options[:chdir] = path
261
+ end
262
+
263
+ opts.on("-e", "--environment ENV", String, "Application environment label; falls back to $RAILS_ENV, then $RACK_ENV, then development") do |env|
264
+ @options[:environment] = env
265
+ end
266
+
234
267
  opts.on("--first-data-timeout SECONDS", Integer, "First data timeout in seconds (default: 30)") do |timeout|
235
- @options[:client][:first_data_timeout] = timeout
268
+ @options[:connection][:first_data_timeout] = timeout
236
269
  end
237
270
 
238
271
  opts.on("--chunk-data-timeout SECONDS", Integer, "Chunk data timeout in seconds (default: 10)") do |timeout|
239
- @options[:client][:chunk_data_timeout] = timeout
272
+ @options[:connection][:chunk_data_timeout] = timeout
240
273
  end
241
274
 
242
- opts.on("--persistent-data-timeout SECONDS", Integer, "Persistent data timeout in seconds (default: 65)") do |timeout|
243
- @options[:client][:persistent_data_timeout] = timeout
275
+ opts.on("--write-timeout SECONDS", Integer, "Per-write socket timeout in seconds (default: 5)") do |timeout|
276
+ @options[:connection][:write_timeout] = timeout
244
277
  end
245
278
 
246
279
  opts.on("--max-body-size BYTES", Integer, "Maximum request body size in bytes (default: unlimited)") do |bytes|
247
- @options[:client][:max_body_size] = bytes
280
+ @options[:connection][:max_body_size] = bytes
248
281
  end
249
282
 
250
- opts.on("--body-spool-threshold BYTES", Integer, "Spool request bodies larger than this to a tempfile (default: #{1024 * 1024})") do |bytes|
251
- @options[:client][:body_spool_threshold] = bytes
283
+ opts.on("--body-spool-threshold BYTES", Integer, "Request body spool threshold in bytes (default: #{1024 * 1024})") do |bytes|
284
+ @options[:connection][:body_spool_threshold] = bytes
252
285
  end
253
286
 
254
- opts.on("--worker-timeout SECONDS", Integer, "Worker check-in timeout in seconds (default: 60)") do |timeout|
255
- @options[:worker_timeout] = timeout
287
+ opts.on("--http1-persistent-data-timeout SECONDS", Integer, "HTTP/1.1 keep-alive idle timeout in seconds (default: 65)") do |timeout|
288
+ @options[:http1][:persistent_data_timeout] = timeout
289
+ end
290
+
291
+ opts.on("--http1-max-keepalive-requests NUM", Integer, "Maximum HTTP/1.1 requests per keep-alive connection (default: 100)") do |num|
292
+ @options[:http1][:max_keepalive_requests] = num
293
+ end
294
+
295
+ opts.on("--http2-max-concurrent-streams NUM", Integer, "Maximum HTTP/2 concurrent streams per connection (default: 100)") do |num|
296
+ @options[:http2][:max_concurrent_streams] = num
256
297
  end
257
298
 
258
299
  opts.on("--worker-boot-timeout SECONDS", Integer, "Worker boot timeout in seconds (default: 60)") do |timeout|
259
300
  @options[:worker_boot_timeout] = timeout
260
301
  end
261
302
 
303
+ opts.on("--worker-timeout SECONDS", Integer, "Worker check-in timeout in seconds (default: 60)") do |timeout|
304
+ @options[:worker_timeout] = timeout
305
+ end
306
+
307
+ opts.on("--worker-drain-timeout SECONDS", Integer, "Worker request-drain timeout in seconds (default: 25)") do |timeout|
308
+ @options[:worker_drain_timeout] = timeout
309
+ end
310
+
262
311
  opts.on("--worker-shutdown-timeout SECONDS", Integer, "Worker shutdown timeout in seconds (default: 30)") do |timeout|
263
312
  @options[:worker_shutdown_timeout] = timeout
264
313
  end
@@ -271,6 +320,18 @@ module Raptor
271
320
  @options[:pid_file] = path
272
321
  end
273
322
 
323
+ opts.on("--stdout-file PATH", String, "Redirect stdout to PATH; reopened on SIGHUP (default: none)") do |path|
324
+ @options[:stdout_file] = path
325
+ end
326
+
327
+ opts.on("--stderr-file PATH", String, "Redirect stderr to PATH; reopened on SIGHUP (default: none)") do |path|
328
+ @options[:stderr_file] = path
329
+ end
330
+
331
+ opts.on("--access-log-file PATH", String, "Write Common Log Format access logs to PATH; reopened on SIGHUP (default: none)") do |path|
332
+ @options[:access_log_file] = path
333
+ end
334
+
274
335
  opts.on("--help", "Show this help") do
275
336
  puts opts
276
337
  exit
@@ -11,9 +11,10 @@ require_relative "log"
11
11
  require_relative "binder"
12
12
  require_relative "server"
13
13
  require_relative "reactor"
14
- require_relative "request"
14
+ require_relative "http1"
15
15
  require_relative "http2"
16
16
  require_relative "stats"
17
+ require_relative "systemd"
17
18
 
18
19
  module Raptor
19
20
  # Multi-process web server cluster with advanced concurrency architecture.
@@ -41,11 +42,13 @@ module Raptor
41
42
  # workers: 4, ractors: 2, threads: 8,
42
43
  # binds: ["tcp://0.0.0.0:3000"],
43
44
  # rackup: "config.ru",
44
- # client: { first_data_timeout: 30, chunk_data_timeout: 10 }
45
+ # connection: { first_data_timeout: 30, chunk_data_timeout: 10 }
45
46
  # }
46
47
  # Cluster.run(options)
47
48
  #
48
49
  class Cluster
50
+ INHERITED_FDS_ENV = "RAPTOR_INHERITED_FDS"
51
+
49
52
  # Convenience method to create and run a cluster with the given options.
50
53
  #
51
54
  # @param options [Hash] cluster configuration options
@@ -56,15 +59,26 @@ module Raptor
56
59
  new(options).run
57
60
  end
58
61
 
62
+ # @rbs @drain_accept_queue: bool
59
63
  # @rbs @worker_count: Integer
60
64
  # @rbs @ractor_count: Integer
61
65
  # @rbs @thread_count: Integer
62
- # @rbs @client_options: Hash[Symbol, Integer]
63
- # @rbs @worker_timeout: Integer
66
+ # @rbs @environment: String
67
+ # @rbs @connection_options: Hash[Symbol, untyped]
68
+ # @rbs @http1_options: Hash[Symbol, untyped]
69
+ # @rbs @http2_options: Hash[Symbol, untyped]
64
70
  # @rbs @worker_boot_timeout: Integer
71
+ # @rbs @worker_timeout: Integer
72
+ # @rbs @worker_drain_timeout: Integer
65
73
  # @rbs @worker_shutdown_timeout: Integer
66
74
  # @rbs @stats_file: String?
67
75
  # @rbs @pid_file: String?
76
+ # @rbs @stdout_file: String?
77
+ # @rbs @stderr_file: String?
78
+ # @rbs @access_log_file: String?
79
+ # @rbs @access_log_io: IO?
80
+ # @rbs @launch_command: String?
81
+ # @rbs @launch_argv: Array[String]?
68
82
  # @rbs @on_error: ^(Hash[String, untyped]?, Exception) -> void | nil
69
83
  # @rbs @binder: Binder
70
84
  # @rbs @server_port: Integer
@@ -76,6 +90,7 @@ module Raptor
76
90
  # @rbs @phase: Integer
77
91
  # @rbs @phased_restart_requested: bool
78
92
  # @rbs @phased_restarting: bool
93
+ # @rbs @hot_restart_requested: bool
79
94
 
80
95
  # Creates a new Cluster with the specified configuration.
81
96
  #
@@ -85,34 +100,67 @@ module Raptor
85
100
  #
86
101
  # @param options [Hash] cluster configuration options
87
102
  # @option options [Array<String>] :binds array of bind URIs
103
+ # @option options [Integer] :socket_backlog kernel listen() queue depth for TCP/SSL listeners
104
+ # @option options [Boolean] :drain_accept_queue whether to drain the kernel accept queue on shutdown
88
105
  # @option options [Integer] :workers number of worker processes
89
106
  # @option options [Integer] :ractors number of ractors per worker process
90
107
  # @option options [Integer] :threads number of threads per worker process
91
108
  # @option options [#call] :app pre-built Rack application
92
109
  # @option options [String] :rackup path to Rack configuration file
93
- # @option options [Hash] :client client configuration
94
- # @option options [Integer] :worker_timeout seconds to wait for a booted worker to check in before killing it
110
+ # @option options [String, nil] :chdir directory to change to before loading the Rack application, or nil to leave the working directory unchanged
111
+ # @option options [String, nil] :environment Raptor's application environment label; falls back to `$RAILS_ENV`, then `$RACK_ENV`, then `"development"`
112
+ # @option options [Hash] :connection per-connection settings shared across protocols
113
+ # @option options [Hash] :http1 HTTP/1.1-specific settings
114
+ # @option options [Hash] :http2 HTTP/2-specific settings
95
115
  # @option options [Integer] :worker_boot_timeout seconds to wait for a worker to finish booting before killing it
116
+ # @option options [Integer] :worker_timeout seconds to wait for a booted worker to check in before killing it
117
+ # @option options [Integer] :worker_drain_timeout seconds a worker waits for in-flight requests during shutdown before force-killing app threads
96
118
  # @option options [Integer] :worker_shutdown_timeout seconds to wait for graceful worker exit before force-killing
97
119
  # @option options [String, nil] :stats_file path to write per-worker stats JSON, or nil to disable
98
120
  # @option options [String, nil] :pid_file path to write the master PID to, or nil to disable
99
- # @option options [#call] :on_error callback invoked with (env, exception) when the Rack app raises
121
+ # @option options [String, nil] :stdout_file path to redirect stdout to, reopened on SIGHUP, or nil to disable
122
+ # @option options [String, nil] :stderr_file path to redirect stderr to, reopened on SIGHUP, or nil to disable
123
+ # @option options [String, nil] :access_log_file path to write Common Log Format access logs to, reopened on SIGHUP, or nil to disable
124
+ # @option options [String, nil] :launch_command path of the program to re-exec on hot restart, or nil to disable
125
+ # @option options [Array<String>, nil] :launch_argv command-line arguments for the hot-restart exec, or nil to disable
126
+ # @option options [#call, nil] :on_error callback invoked with (env, exception) when the Rack app raises
100
127
  # @return [void]
101
128
  #
102
129
  # @rbs (Hash[Symbol, untyped] options) -> void
103
130
  def initialize(options)
131
+ @drain_accept_queue = options[:drain_accept_queue]
104
132
  @worker_count = options[:workers]
105
133
  @ractor_count = options[:ractors]
106
134
  @thread_count = options[:threads]
107
- @client_options = options[:client]
108
- @worker_timeout = options[:worker_timeout]
135
+ @environment = options[:environment] || ENV["RAILS_ENV"] || ENV["RACK_ENV"] || "development"
136
+ @connection_options = options[:connection]
137
+ @http1_options = options[:http1]
138
+ @http2_options = options[:http2]
109
139
  @worker_boot_timeout = options[:worker_boot_timeout]
140
+ @worker_timeout = options[:worker_timeout]
141
+ @worker_drain_timeout = options[:worker_drain_timeout]
110
142
  @worker_shutdown_timeout = options[:worker_shutdown_timeout]
111
143
  @stats_file = options[:stats_file]
112
144
  @pid_file = options[:pid_file]
145
+ @stdout_file = options[:stdout_file]
146
+ @stderr_file = options[:stderr_file]
147
+ @access_log_file = options[:access_log_file]
148
+ @access_log_io = nil
149
+ @launch_command = options[:launch_command]
150
+ @launch_argv = options[:launch_argv]
113
151
  @on_error = options[:on_error]
114
152
 
115
- @binder = Binder.new(options[:binds])
153
+ Dir.chdir(options[:chdir]) if options[:chdir]
154
+
155
+ inherited_fds = if raw = ENV.delete(INHERITED_FDS_ENV)
156
+ JSON.parse(raw)
157
+ elsif (systemd_fds = Systemd.listen_fds).any?
158
+ Systemd.clear_listen_env
159
+ pair_systemd_fds(options[:binds], systemd_fds)
160
+ else
161
+ {}
162
+ end
163
+ @binder = Binder.new(options[:binds], socket_backlog: options[:socket_backlog], inherited_fds: inherited_fds)
116
164
  @server_port = @binder.server_port
117
165
  @app = options[:app] || Rack::Builder.parse_file(options[:rackup])
118
166
  log_initialization
@@ -124,14 +172,15 @@ module Raptor
124
172
  @phase = 0
125
173
  @phased_restart_requested = false
126
174
  @phased_restarting = false
175
+ @hot_restart_requested = false
127
176
  end
128
177
 
129
178
  # Starts the multi-process cluster and manages worker processes.
130
179
  #
131
180
  # Forks the configured number of worker processes and monitors them,
132
181
  # restarting any that exit unexpectedly or stop checking in. Handles
133
- # graceful shutdown via INT or TERM signals, stats logging via USR1,
134
- # and phased restart via USR2.
182
+ # graceful shutdown via INT or TERM signals, phased restart via USR1,
183
+ # and hot restart via USR2.
135
184
  #
136
185
  # Each worker process includes:
137
186
  # - 1 server thread (continuously accepts connections with backpressure control)
@@ -145,10 +194,13 @@ module Raptor
145
194
  #
146
195
  # @rbs () -> void
147
196
  def run
197
+ reopen_logs
198
+
148
199
  trap("INT") { shutdown }
149
200
  trap("TERM") { shutdown }
150
- trap("USR1") { log_stats }
151
- trap("USR2") { @phased_restart_requested = true }
201
+ trap("HUP") { reopen_logs_and_signal_workers }
202
+ trap("USR1") { @phased_restart_requested = true }
203
+ trap("USR2") { @hot_restart_requested = true }
152
204
 
153
205
  File.open(@pid_file, File::CREAT | File::EXCL | File::WRONLY) { |file| file.write(Process.pid.to_s) } if @pid_file
154
206
 
@@ -162,15 +214,19 @@ module Raptor
162
214
  end
163
215
  end
164
216
 
217
+ Systemd.notify("READY=1\nMAINPID=#{Process.pid}")
218
+
165
219
  until @shutdown
166
220
  break if reap_workers == :no_children
167
221
 
222
+ perform_hot_restart if @hot_restart_requested
168
223
  perform_phased_restart if @phased_restart_requested && !@phased_restarting
169
224
  timeout_hung_workers
170
225
 
171
226
  sleep 0.1
172
227
  end
173
228
 
229
+ Systemd.notify("STOPPING=1")
174
230
  stop_workers
175
231
  stats_file_thread&.join
176
232
  File.delete(@stats_file) rescue nil if @stats_file
@@ -190,6 +246,25 @@ module Raptor
190
246
 
191
247
  private
192
248
 
249
+ # Returns the inherited-FDs hash for a systemd socket-activation handoff,
250
+ # pairing each bind URI with the FD systemd passed at the same index.
251
+ # The activation is skipped (with a warning) when the FD count doesn't
252
+ # match the number of bind URIs.
253
+ #
254
+ # @param bind_uris [Array<String>] the configured bind URIs
255
+ # @param filenos [Array<Integer>] file descriptors passed by systemd
256
+ # @return [Hash{String => Array<Integer>}]
257
+ #
258
+ # @rbs (Array[String] bind_uris, Array[Integer] filenos) -> Hash[String, Array[Integer]]
259
+ def pair_systemd_fds(bind_uris, filenos)
260
+ if bind_uris.length != filenos.length
261
+ Log.warn "Ignoring socket activation: #{filenos.length} fd(s) from systemd, #{bind_uris.length} bind(s) configured"
262
+ return {}
263
+ end
264
+
265
+ bind_uris.zip(filenos).to_h { |bind_uri, fileno| [bind_uri, [fileno]] }
266
+ end
267
+
193
268
  # Forks a new worker process and registers it at the given index.
194
269
  # The worker inherits the cluster's current phase.
195
270
  #
@@ -278,7 +353,7 @@ module Raptor
278
353
  end
279
354
 
280
355
  # Replaces each worker process one at a time, waiting for the new
281
- # worker to boot before moving on to the next. Triggered by SIGUSR2.
356
+ # worker to boot before moving on to the next.
282
357
  #
283
358
  # @return [void]
284
359
  #
@@ -315,6 +390,36 @@ module Raptor
315
390
  end
316
391
  end
317
392
 
393
+ # Re-execs the master process with a fresh boot of the same Raptor
394
+ # invocation, handing the new master its listening sockets so accepted
395
+ # connections continue to be served across the swap.
396
+ #
397
+ # @return [void]
398
+ #
399
+ # @rbs () -> void
400
+ def perform_hot_restart
401
+ @hot_restart_requested = false
402
+
403
+ unless @launch_command && @launch_argv
404
+ Log.warn "Hot restart unavailable: launch command not captured"
405
+ return
406
+ end
407
+
408
+ Log.info "Hot restart starting"
409
+ monotonic_usec = (Process.clock_gettime(Process::CLOCK_MONOTONIC) * 1_000_000).to_i
410
+ Systemd.notify("RELOADING=1\nMONOTONIC_USEC=#{monotonic_usec}")
411
+ @shutdown = true
412
+ stop_workers
413
+ @binder.clear_close_on_exec
414
+ ENV[INHERITED_FDS_ENV] = JSON.generate(@binder.inheritable_fds)
415
+ File.delete(@stats_file) rescue nil if @stats_file
416
+ File.delete(@pid_file) rescue nil if @pid_file
417
+ @stats.unmap
418
+ $stdout.flush
419
+ $stderr.flush
420
+ exec(@launch_command, *@launch_argv)
421
+ end
422
+
318
423
  # Runs the full server stack inside a worker process.
319
424
  #
320
425
  # Sets up and coordinates the reactor, server, ractor pool, thread pool,
@@ -330,6 +435,9 @@ module Raptor
330
435
  shutdown_requested = false
331
436
  trap("INT") { shutdown_requested = true }
332
437
  trap("TERM") { shutdown_requested = true }
438
+ trap("HUP") { reopen_logs }
439
+ trap("USR1", "IGNORE")
440
+ trap("USR2", "IGNORE")
333
441
 
334
442
  started_at = Process.clock_gettime(Process::CLOCK_REALTIME)
335
443
  request_count = 0
@@ -354,27 +462,46 @@ module Raptor
354
462
  @app.call(env)
355
463
  }
356
464
  thread_pool = AtomicThreadPool.new(size: @thread_count)
357
- request = Request.new(counting_app, @server_port, client_options: @client_options, on_error: @on_error)
358
- http2 = Http2.new(counting_app, @server_port, on_error: @on_error)
465
+ http1 = Http1.new(
466
+ counting_app,
467
+ @server_port,
468
+ connection_options: @connection_options,
469
+ http1_options: @http1_options,
470
+ access_log_io: @access_log_io,
471
+ on_error: @on_error
472
+ )
473
+ http2 = Http2.new(
474
+ counting_app,
475
+ @server_port,
476
+ connection_options: @connection_options,
477
+ http2_options: @http2_options,
478
+ access_log_io: @access_log_io,
479
+ on_error: @on_error
480
+ )
359
481
  ractor_pool = RactorPool.new(
360
482
  size: @ractor_count,
361
- worker: request.http_parser_worker
483
+ worker: http1.http_parser_worker
362
484
  ) do |parsed_result|
363
485
  begin
364
486
  if parsed_result[:protocol] == :http2
365
487
  http2.handle_parsed_request(parsed_result, reactor, thread_pool)
366
488
  else
367
- request.handle_parsed_request(parsed_result, reactor, thread_pool)
489
+ http1.handle_parsed_request(parsed_result, reactor, thread_pool)
368
490
  end
369
491
  rescue => error
370
492
  Log.rescued_error(error)
371
493
  end
372
494
  end
373
495
 
374
- reactor = Reactor.new(ractor_pool, thread_pool, client_options: @client_options)
496
+ reactor = Reactor.new(
497
+ ractor_pool,
498
+ thread_pool,
499
+ connection_options: @connection_options,
500
+ http1_options: @http1_options
501
+ )
375
502
  reactor_thread = reactor.run
376
503
 
377
- server = Server.new(@binder, reactor, thread_pool, request, client_options: @client_options)
504
+ server = Server.new(@binder, reactor, thread_pool, http1, http2, connection_options: @connection_options, drain_accept_queue: @drain_accept_queue)
378
505
  server_thread = server.run
379
506
 
380
507
  Log.info "Worker #{index} booted"
@@ -412,11 +539,28 @@ module Raptor
412
539
  reactor.shutdown
413
540
  reactor_thread.join
414
541
  ractor_pool.shutdown
415
- request.shutdown
416
- thread_pool.shutdown
542
+ http1.shutdown
543
+ drain_thread_pool(thread_pool)
417
544
  stats_thread.join
418
545
  end
419
546
 
547
+ # Shuts down the worker's application thread pool, force-killing the
548
+ # underlying threads if in-flight requests have not finished within
549
+ # `worker_drain_timeout` seconds.
550
+ #
551
+ # @param thread_pool [AtomicThreadPool] the worker's thread pool
552
+ # @return [void]
553
+ #
554
+ # @rbs (AtomicThreadPool thread_pool) -> void
555
+ def drain_thread_pool(thread_pool)
556
+ drain = Thread.new { thread_pool.shutdown }
557
+ return if drain.join(@worker_drain_timeout)
558
+
559
+ Log.warn "Force-killing in-flight app threads after #{@worker_drain_timeout}s drain timeout"
560
+ thread_pool.instance_variable_get(:@threads).each(&:kill)
561
+ drain.join
562
+ end
563
+
420
564
  # Returns a human-readable description of how a process exited.
421
565
  #
422
566
  # @param status [Process::Status] the exit status of the process
@@ -454,6 +598,7 @@ module Raptor
454
598
  Log.info "Cluster initializing:"
455
599
  Log.info "├─ Version: #{VERSION}"
456
600
  Log.info "├─ Ruby Version: #{RUBY_DESCRIPTION}"
601
+ Log.info "├─ Environment: #{@environment}"
457
602
  Log.info "├─ Master PID: #{Process.pid}"
458
603
  Log.info "│ └─ #{@worker_count} worker process#{"es" if @worker_count > 1}"
459
604
  Log.info "│ ├─ 1 server thread"
@@ -465,20 +610,31 @@ module Raptor
465
610
  Log.info "└─ Listening on #{@binder.addresses.join(", ")}"
466
611
  end
467
612
 
468
- # Logs current stats for all workers to stdout.
613
+ # Redirects `$stdout`, `$stderr`, and the access log to their configured
614
+ # paths. No-op for any stream whose target path is nil.
469
615
  #
470
- # Triggered by SIGUSR1 in the master process.
616
+ # @return [void]
617
+ #
618
+ # @rbs () -> void
619
+ def reopen_logs
620
+ $stdout.reopen(@stdout_file, "a").sync = true if @stdout_file
621
+ $stderr.reopen(@stderr_file, "a").sync = true if @stderr_file
622
+ return unless @access_log_file
623
+
624
+ @access_log_io ||= File.open(@access_log_file, "a")
625
+ @access_log_io.reopen(@access_log_file, "a")
626
+ @access_log_io.sync = true
627
+ end
628
+
629
+ # Reopens the master's log files and forwards SIGHUP to each worker so
630
+ # they reopen their own inherited file descriptors.
471
631
  #
472
632
  # @return [void]
473
633
  #
474
634
  # @rbs () -> void
475
- def log_stats
476
- @stats.all.each do |stat|
477
- status = stat[:booted] ? "booted" : "starting"
478
- Log.info "Worker #{stat[:index]} (phase #{stat[:phase]}): pid=#{stat[:pid]}, requests=#{stat[:requests]}, " \
479
- "busy=#{stat[:busy_threads]}/#{stat[:thread_capacity]}, backlog=#{stat[:backlog]}, " \
480
- "#{status}, last_checkin=#{Time.at(stat[:last_checkin]).strftime("%H:%M:%S")}"
481
- end
635
+ def reopen_logs_and_signal_workers
636
+ reopen_logs
637
+ @workers.values.each { |pid| Process.kill("HUP", pid) rescue nil }
482
638
  end
483
639
 
484
640
  # Writes the stats file on a 1-second interval until shutdown.
@@ -0,0 +1,75 @@
1
+ # rbs_inline: enabled
2
+ # frozen_string_literal: true
3
+
4
+ require "rack"
5
+
6
+ require_relative "version"
7
+
8
+ module Raptor
9
+ # Shared HTTP utilities used by both the HTTP/1.x and HTTP/2 handlers:
10
+ # Rack env keys that aren't provided by Rack itself, low-level socket
11
+ # writing, and Common Log Format access-log formatting.
12
+ #
13
+ module Http
14
+ WRITE_TIMEOUT = 5
15
+
16
+ CONTENT_LENGTH = "CONTENT_LENGTH"
17
+ CONTENT_TYPE = "CONTENT_TYPE"
18
+ HTTP_VERSION = "HTTP_VERSION"
19
+ REMOTE_ADDR = "REMOTE_ADDR"
20
+ SERVER_SOFTWARE = "SERVER_SOFTWARE"
21
+ SERVER_SOFTWARE_VALUE = "Raptor/#{Raptor::VERSION}".freeze
22
+
23
+ class WriteError < StandardError
24
+ # @rbs () -> String
25
+ def message = "could not write response"
26
+ end
27
+
28
+ # Writes `string` in full, retrying on partial writes. Bounded by
29
+ # `timeout` so a slow client can't pin the writing thread.
30
+ #
31
+ # @param socket [TCPSocket] the socket to write to
32
+ # @param string [String] the data to write
33
+ # @param timeout [Integer] seconds to wait for the socket to become writable on each partial write
34
+ # @return [void]
35
+ # @raise [WriteError] if the socket is not writable within the timeout or raises IOError
36
+ #
37
+ # @rbs (TCPSocket socket, String string, ?timeout: Integer) -> void
38
+ def self.socket_write(socket, string, timeout: WRITE_TIMEOUT)
39
+ bytes = 0
40
+ byte_size = string.bytesize
41
+
42
+ while bytes < byte_size
43
+ begin
44
+ bytes += socket.write_nonblock(bytes.zero? ? string : string.byteslice(bytes..-1))
45
+ rescue IO::WaitWritable
46
+ raise WriteError unless socket.wait_writable(timeout)
47
+ retry
48
+ rescue IOError
49
+ raise WriteError
50
+ end
51
+ end
52
+ end
53
+
54
+ # Writes a Common Log Format entry to `io`. Write failures are silently
55
+ # ignored.
56
+ #
57
+ # @param io [IO] the destination IO
58
+ # @param env [Hash] the Rack environment
59
+ # @param status [Integer] the response status code
60
+ # @param size [String] the response body size in bytes, or `-` if unknown
61
+ # @param remote_addr [String] the client IP address
62
+ # @return [void]
63
+ #
64
+ # @rbs (IO io, Hash[String, untyped] env, Integer status, String size, String remote_addr) -> void
65
+ def self.write_access_log(io, env, status, size, remote_addr)
66
+ timestamp = Time.now.strftime("%d/%b/%Y:%H:%M:%S %z")
67
+ method = env[Rack::REQUEST_METHOD]
68
+ query = env[Rack::QUERY_STRING]
69
+ path = query.empty? ? env[Rack::PATH_INFO] : "#{env[Rack::PATH_INFO]}?#{query}"
70
+ protocol = env[Rack::SERVER_PROTOCOL]
71
+
72
+ io.puts(%(#{remote_addr} - - [#{timestamp}] "#{method} #{path} #{protocol}" #{status} #{size})) rescue nil
73
+ end
74
+ end
75
+ end