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.
@@ -9,11 +9,13 @@ require "ractor-pool"
9
9
 
10
10
  require_relative "log"
11
11
  require_relative "binder"
12
- require_relative "server"
13
12
  require_relative "reactor"
14
- require_relative "request"
13
+ require_relative "server"
14
+ require_relative "reuseport_bpf"
15
+ require_relative "http1"
15
16
  require_relative "http2"
16
17
  require_relative "stats"
18
+ require_relative "systemd"
17
19
 
18
20
  module Raptor
19
21
  # Multi-process web server cluster with advanced concurrency architecture.
@@ -41,11 +43,13 @@ module Raptor
41
43
  # workers: 4, ractors: 2, threads: 8,
42
44
  # binds: ["tcp://0.0.0.0:3000"],
43
45
  # rackup: "config.ru",
44
- # client: { first_data_timeout: 30, chunk_data_timeout: 10 }
46
+ # connection: { first_data_timeout: 30, chunk_data_timeout: 10 }
45
47
  # }
46
48
  # Cluster.run(options)
47
49
  #
48
50
  class Cluster
51
+ INHERITED_FDS_ENV = "RAPTOR_INHERITED_FDS"
52
+
49
53
  # Convenience method to create and run a cluster with the given options.
50
54
  #
51
55
  # @param options [Hash] cluster configuration options
@@ -56,15 +60,26 @@ module Raptor
56
60
  new(options).run
57
61
  end
58
62
 
63
+ # @rbs @drain_accept_queue: bool
59
64
  # @rbs @worker_count: Integer
60
65
  # @rbs @ractor_count: Integer
61
66
  # @rbs @thread_count: Integer
62
- # @rbs @client_options: Hash[Symbol, Integer]
63
- # @rbs @worker_timeout: Integer
67
+ # @rbs @environment: String
68
+ # @rbs @connection_options: Hash[Symbol, untyped]
69
+ # @rbs @http1_options: Hash[Symbol, untyped]
70
+ # @rbs @http2_options: Hash[Symbol, untyped]
64
71
  # @rbs @worker_boot_timeout: Integer
72
+ # @rbs @worker_timeout: Integer
73
+ # @rbs @worker_drain_timeout: Integer
65
74
  # @rbs @worker_shutdown_timeout: Integer
66
75
  # @rbs @stats_file: String?
67
76
  # @rbs @pid_file: String?
77
+ # @rbs @stdout_file: String?
78
+ # @rbs @stderr_file: String?
79
+ # @rbs @access_log_file: String?
80
+ # @rbs @access_log_io: IO?
81
+ # @rbs @launch_command: String?
82
+ # @rbs @launch_argv: Array[String]?
68
83
  # @rbs @on_error: ^(Hash[String, untyped]?, Exception) -> void | nil
69
84
  # @rbs @binder: Binder
70
85
  # @rbs @server_port: Integer
@@ -76,6 +91,8 @@ module Raptor
76
91
  # @rbs @phase: Integer
77
92
  # @rbs @phased_restart_requested: bool
78
93
  # @rbs @phased_restarting: bool
94
+ # @rbs @hot_restart_requested: bool
95
+ # @rbs @bpf_active: bool
79
96
 
80
97
  # Creates a new Cluster with the specified configuration.
81
98
  #
@@ -85,34 +102,67 @@ module Raptor
85
102
  #
86
103
  # @param options [Hash] cluster configuration options
87
104
  # @option options [Array<String>] :binds array of bind URIs
105
+ # @option options [Integer] :socket_backlog kernel listen() queue depth for TCP/SSL listeners
106
+ # @option options [Boolean] :drain_accept_queue whether to drain the kernel accept queue on shutdown
88
107
  # @option options [Integer] :workers number of worker processes
89
108
  # @option options [Integer] :ractors number of ractors per worker process
90
109
  # @option options [Integer] :threads number of threads per worker process
91
110
  # @option options [#call] :app pre-built Rack application
92
111
  # @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
112
+ # @option options [String, nil] :chdir directory to change to before loading the Rack application, or nil to leave the working directory unchanged
113
+ # @option options [String, nil] :environment Raptor's application environment label; falls back to `$RAILS_ENV`, then `$RACK_ENV`, then `"development"`
114
+ # @option options [Hash] :connection per-connection settings shared across protocols
115
+ # @option options [Hash] :http1 HTTP/1.1-specific settings
116
+ # @option options [Hash] :http2 HTTP/2-specific settings
95
117
  # @option options [Integer] :worker_boot_timeout seconds to wait for a worker to finish booting before killing it
118
+ # @option options [Integer] :worker_timeout seconds to wait for a booted worker to check in before killing it
119
+ # @option options [Integer] :worker_drain_timeout seconds a worker waits for in-flight requests during shutdown before force-killing app threads
96
120
  # @option options [Integer] :worker_shutdown_timeout seconds to wait for graceful worker exit before force-killing
97
121
  # @option options [String, nil] :stats_file path to write per-worker stats JSON, or nil to disable
98
122
  # @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
123
+ # @option options [String, nil] :stdout_file path to redirect stdout to, reopened on SIGHUP, or nil to disable
124
+ # @option options [String, nil] :stderr_file path to redirect stderr to, reopened on SIGHUP, or nil to disable
125
+ # @option options [String, nil] :access_log_file path to write Common Log Format access logs to, reopened on SIGHUP, or nil to disable
126
+ # @option options [String, nil] :launch_command path of the program to re-exec on hot restart, or nil to disable
127
+ # @option options [Array<String>, nil] :launch_argv command-line arguments for the hot-restart exec, or nil to disable
128
+ # @option options [#call, nil] :on_error callback invoked with (env, exception) when the Rack app raises
100
129
  # @return [void]
101
130
  #
102
131
  # @rbs (Hash[Symbol, untyped] options) -> void
103
132
  def initialize(options)
133
+ @drain_accept_queue = options[:drain_accept_queue]
104
134
  @worker_count = options[:workers]
105
135
  @ractor_count = options[:ractors]
106
136
  @thread_count = options[:threads]
107
- @client_options = options[:client]
108
- @worker_timeout = options[:worker_timeout]
137
+ @environment = options[:environment] || ENV["RAILS_ENV"] || ENV["RACK_ENV"] || "development"
138
+ @connection_options = options[:connection]
139
+ @http1_options = options[:http1]
140
+ @http2_options = options[:http2]
109
141
  @worker_boot_timeout = options[:worker_boot_timeout]
142
+ @worker_timeout = options[:worker_timeout]
143
+ @worker_drain_timeout = options[:worker_drain_timeout]
110
144
  @worker_shutdown_timeout = options[:worker_shutdown_timeout]
111
145
  @stats_file = options[:stats_file]
112
146
  @pid_file = options[:pid_file]
147
+ @stdout_file = options[:stdout_file]
148
+ @stderr_file = options[:stderr_file]
149
+ @access_log_file = options[:access_log_file]
150
+ @access_log_io = nil
151
+ @launch_command = options[:launch_command]
152
+ @launch_argv = options[:launch_argv]
113
153
  @on_error = options[:on_error]
114
154
 
115
- @binder = Binder.new(options[:binds])
155
+ Dir.chdir(options[:chdir]) if options[:chdir]
156
+
157
+ inherited_fds = if raw = ENV.delete(INHERITED_FDS_ENV)
158
+ JSON.parse(raw)
159
+ elsif (systemd_fds = Systemd.listen_fds).any?
160
+ Systemd.clear_listen_env
161
+ pair_systemd_fds(options[:binds], systemd_fds)
162
+ else
163
+ {}
164
+ end
165
+ @binder = Binder.new(options[:binds], socket_backlog: options[:socket_backlog], inherited_fds: inherited_fds)
116
166
  @server_port = @binder.server_port
117
167
  @app = options[:app] || Rack::Builder.parse_file(options[:rackup])
118
168
  log_initialization
@@ -124,14 +174,15 @@ module Raptor
124
174
  @phase = 0
125
175
  @phased_restart_requested = false
126
176
  @phased_restarting = false
177
+ @hot_restart_requested = false
127
178
  end
128
179
 
129
180
  # Starts the multi-process cluster and manages worker processes.
130
181
  #
131
182
  # Forks the configured number of worker processes and monitors them,
132
183
  # 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.
184
+ # graceful shutdown via INT or TERM signals, phased restart via USR1,
185
+ # and hot restart via USR2.
135
186
  #
136
187
  # Each worker process includes:
137
188
  # - 1 server thread (continuously accepts connections with backpressure control)
@@ -145,13 +196,18 @@ module Raptor
145
196
  #
146
197
  # @rbs () -> void
147
198
  def run
199
+ reopen_logs
200
+
148
201
  trap("INT") { shutdown }
149
202
  trap("TERM") { shutdown }
150
- trap("USR1") { log_stats }
151
- trap("USR2") { @phased_restart_requested = true }
203
+ trap("HUP") { reopen_logs_and_signal_workers }
204
+ trap("USR1") { @phased_restart_requested = true }
205
+ trap("USR2") { @hot_restart_requested = true }
152
206
 
153
207
  File.open(@pid_file, File::CREAT | File::EXCL | File::WRONLY) { |file| file.write(Process.pid.to_s) } if @pid_file
154
208
 
209
+ @bpf_active = ReuseportBPF.setup(@worker_count)
210
+
155
211
  @worker_count.times { |index| spawn_worker(index) }
156
212
 
157
213
  stats_file_thread = if @stats_file
@@ -162,20 +218,25 @@ module Raptor
162
218
  end
163
219
  end
164
220
 
221
+ Systemd.notify("READY=1\nMAINPID=#{Process.pid}")
222
+
165
223
  until @shutdown
166
224
  break if reap_workers == :no_children
167
225
 
226
+ perform_hot_restart if @hot_restart_requested
168
227
  perform_phased_restart if @phased_restart_requested && !@phased_restarting
169
228
  timeout_hung_workers
170
229
 
171
230
  sleep 0.1
172
231
  end
173
232
 
233
+ Systemd.notify("STOPPING=1")
174
234
  stop_workers
175
235
  stats_file_thread&.join
176
236
  File.delete(@stats_file) rescue nil if @stats_file
177
237
  File.delete(@pid_file) rescue nil if @pid_file
178
238
  @stats.unmap
239
+ @binder.close
179
240
  end
180
241
 
181
242
  # Returns stats for all worker processes.
@@ -190,6 +251,25 @@ module Raptor
190
251
 
191
252
  private
192
253
 
254
+ # Returns the inherited-FDs hash for a systemd socket-activation handoff,
255
+ # pairing each bind URI with the FD systemd passed at the same index.
256
+ # The activation is skipped (with a warning) when the FD count doesn't
257
+ # match the number of bind URIs.
258
+ #
259
+ # @param bind_uris [Array<String>] the configured bind URIs
260
+ # @param filenos [Array<Integer>] file descriptors passed by systemd
261
+ # @return [Hash{String => Array<Integer>}]
262
+ #
263
+ # @rbs (Array[String] bind_uris, Array[Integer] filenos) -> Hash[String, Array[Integer]]
264
+ def pair_systemd_fds(bind_uris, filenos)
265
+ if bind_uris.length != filenos.length
266
+ Log.warn "Ignoring socket activation: #{filenos.length} fd(s) from systemd, #{bind_uris.length} bind(s) configured"
267
+ return {}
268
+ end
269
+
270
+ bind_uris.zip(filenos).to_h { |bind_uri, fileno| [bind_uri, [fileno]] }
271
+ end
272
+
193
273
  # Forks a new worker process and registers it at the given index.
194
274
  # The worker inherits the cluster's current phase.
195
275
  #
@@ -278,7 +358,7 @@ module Raptor
278
358
  end
279
359
 
280
360
  # 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.
361
+ # worker to boot before moving on to the next.
282
362
  #
283
363
  # @return [void]
284
364
  #
@@ -315,6 +395,36 @@ module Raptor
315
395
  end
316
396
  end
317
397
 
398
+ # Re-execs the master process with a fresh boot of the same Raptor
399
+ # invocation, handing the new master its listening sockets so accepted
400
+ # connections continue to be served across the swap.
401
+ #
402
+ # @return [void]
403
+ #
404
+ # @rbs () -> void
405
+ def perform_hot_restart
406
+ @hot_restart_requested = false
407
+
408
+ unless @launch_command && @launch_argv
409
+ Log.warn "Hot restart unavailable: launch command not captured"
410
+ return
411
+ end
412
+
413
+ Log.info "Hot restart starting"
414
+ monotonic_usec = (Process.clock_gettime(Process::CLOCK_MONOTONIC) * 1_000_000).to_i
415
+ Systemd.notify("RELOADING=1\nMONOTONIC_USEC=#{monotonic_usec}")
416
+ @shutdown = true
417
+ stop_workers
418
+ @binder.clear_close_on_exec
419
+ ENV[INHERITED_FDS_ENV] = JSON.generate(@binder.inheritable_fds)
420
+ File.delete(@stats_file) rescue nil if @stats_file
421
+ File.delete(@pid_file) rescue nil if @pid_file
422
+ @stats.unmap
423
+ $stdout.flush
424
+ $stderr.flush
425
+ exec(@launch_command, *@launch_argv)
426
+ end
427
+
318
428
  # Runs the full server stack inside a worker process.
319
429
  #
320
430
  # Sets up and coordinates the reactor, server, ractor pool, thread pool,
@@ -330,6 +440,11 @@ module Raptor
330
440
  shutdown_requested = false
331
441
  trap("INT") { shutdown_requested = true }
332
442
  trap("TERM") { shutdown_requested = true }
443
+ trap("HUP") { reopen_logs }
444
+ trap("USR1", "IGNORE")
445
+ trap("USR2", "IGNORE")
446
+
447
+ Raptor::CPU.pin(index) if Raptor::CPU.count >= @worker_count
333
448
 
334
449
  started_at = Process.clock_gettime(Process::CLOCK_REALTIME)
335
450
  request_count = 0
@@ -354,27 +469,64 @@ module Raptor
354
469
  @app.call(env)
355
470
  }
356
471
  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)
472
+ http1 = Http1.new(
473
+ counting_app,
474
+ @server_port,
475
+ connection_options: @connection_options,
476
+ http1_options: @http1_options,
477
+ access_log_io: @access_log_io,
478
+ on_error: @on_error
479
+ )
480
+ http2 = Http2.new(
481
+ counting_app,
482
+ @server_port,
483
+ connection_options: @connection_options,
484
+ http2_options: @http2_options,
485
+ access_log_io: @access_log_io,
486
+ on_error: @on_error
487
+ )
359
488
  ractor_pool = RactorPool.new(
360
489
  size: @ractor_count,
361
- worker: request.http_parser_worker
490
+ worker: http1.http_parser_worker
362
491
  ) do |parsed_result|
363
492
  begin
364
493
  if parsed_result[:protocol] == :http2
365
494
  http2.handle_parsed_request(parsed_result, reactor, thread_pool)
366
495
  else
367
- request.handle_parsed_request(parsed_result, reactor, thread_pool)
496
+ http1.handle_parsed_request(parsed_result, reactor, thread_pool)
368
497
  end
369
498
  rescue => error
370
499
  Log.rescued_error(error)
371
500
  end
372
501
  end
373
502
 
374
- reactor = Reactor.new(ractor_pool, thread_pool, client_options: @client_options)
503
+ reactor = Reactor.new(
504
+ ractor_pool,
505
+ thread_pool,
506
+ connection_options: @connection_options,
507
+ http1_options: @http1_options
508
+ )
375
509
  reactor_thread = reactor.run
376
510
 
377
- server = Server.new(@binder, reactor, thread_pool, request, client_options: @client_options)
511
+ worker_listeners = if @bpf_active
512
+ bpf_listeners = ReuseportBPF.create_worker_listeners(@binder.bind_uris, index, @binder.socket_backlog)
513
+ non_tcp_listeners = @binder.listeners.reject { |listener| listener.is_a?(TCPServer) }
514
+ bpf_listeners + non_tcp_listeners
515
+ else
516
+ @binder.listeners
517
+ end
518
+
519
+ server = Server.new(
520
+ @binder,
521
+ reactor,
522
+ thread_pool,
523
+ http1,
524
+ http2,
525
+ connection_options: @connection_options,
526
+ drain_accept_queue: @drain_accept_queue,
527
+ listeners: worker_listeners,
528
+ worker_index: (index if @bpf_active)
529
+ )
378
530
  server_thread = server.run
379
531
 
380
532
  Log.info "Worker #{index} booted"
@@ -412,11 +564,28 @@ module Raptor
412
564
  reactor.shutdown
413
565
  reactor_thread.join
414
566
  ractor_pool.shutdown
415
- request.shutdown
416
- thread_pool.shutdown
567
+ http1.shutdown
568
+ drain_thread_pool(thread_pool)
417
569
  stats_thread.join
418
570
  end
419
571
 
572
+ # Shuts down the worker's application thread pool, force-killing the
573
+ # underlying threads if in-flight requests have not finished within
574
+ # `worker_drain_timeout` seconds.
575
+ #
576
+ # @param thread_pool [AtomicThreadPool] the worker's thread pool
577
+ # @return [void]
578
+ #
579
+ # @rbs (AtomicThreadPool thread_pool) -> void
580
+ def drain_thread_pool(thread_pool)
581
+ drain = Thread.new { thread_pool.shutdown }
582
+ return if drain.join(@worker_drain_timeout)
583
+
584
+ Log.warn "Force-killing in-flight app threads after #{@worker_drain_timeout}s drain timeout"
585
+ thread_pool.instance_variable_get(:@threads).each(&:kill)
586
+ drain.join
587
+ end
588
+
420
589
  # Returns a human-readable description of how a process exited.
421
590
  #
422
591
  # @param status [Process::Status] the exit status of the process
@@ -454,6 +623,7 @@ module Raptor
454
623
  Log.info "Cluster initializing:"
455
624
  Log.info "├─ Version: #{VERSION}"
456
625
  Log.info "├─ Ruby Version: #{RUBY_DESCRIPTION}"
626
+ Log.info "├─ Environment: #{@environment}"
457
627
  Log.info "├─ Master PID: #{Process.pid}"
458
628
  Log.info "│ └─ #{@worker_count} worker process#{"es" if @worker_count > 1}"
459
629
  Log.info "│ ├─ 1 server thread"
@@ -465,20 +635,31 @@ module Raptor
465
635
  Log.info "└─ Listening on #{@binder.addresses.join(", ")}"
466
636
  end
467
637
 
468
- # Logs current stats for all workers to stdout.
638
+ # Redirects `$stdout`, `$stderr`, and the access log to their configured
639
+ # paths. No-op for any stream whose target path is nil.
469
640
  #
470
- # Triggered by SIGUSR1 in the master process.
641
+ # @return [void]
642
+ #
643
+ # @rbs () -> void
644
+ def reopen_logs
645
+ $stdout.reopen(@stdout_file, "a").sync = true if @stdout_file
646
+ $stderr.reopen(@stderr_file, "a").sync = true if @stderr_file
647
+ return unless @access_log_file
648
+
649
+ @access_log_io ||= File.open(@access_log_file, "a")
650
+ @access_log_io.reopen(@access_log_file, "a")
651
+ @access_log_io.sync = true
652
+ end
653
+
654
+ # Reopens the master's log files and forwards SIGHUP to each worker so
655
+ # they reopen their own inherited file descriptors.
471
656
  #
472
657
  # @return [void]
473
658
  #
474
659
  # @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
660
+ def reopen_logs_and_signal_workers
661
+ reopen_logs
662
+ @workers.values.each { |pid| Process.kill("HUP", pid) rescue nil }
482
663
  end
483
664
 
484
665
  # Writes the stats file on a 1-second interval until shutdown.
@@ -0,0 +1,116 @@
1
+ # rbs_inline: enabled
2
+ # frozen_string_literal: true
3
+
4
+ require "rack"
5
+
6
+ require_relative "version"
7
+ require_relative "raptor_native"
8
+
9
+ module Raptor
10
+ # Shared HTTP utilities used by both the HTTP/1.x and HTTP/2 handlers:
11
+ # Rack env keys that aren't provided by Rack itself, low-level socket
12
+ # writing, and Common Log Format access-log formatting.
13
+ #
14
+ module Http
15
+ WRITE_TIMEOUT = 5
16
+
17
+ CONTENT_LENGTH = "CONTENT_LENGTH"
18
+ CONTENT_TYPE = "CONTENT_TYPE"
19
+ HTTP_VERSION = "HTTP_VERSION"
20
+ REMOTE_ADDR = "REMOTE_ADDR"
21
+ SERVER_SOFTWARE = "SERVER_SOFTWARE"
22
+ SERVER_SOFTWARE_VALUE = "Raptor/#{Raptor::VERSION}".freeze
23
+
24
+ class WriteError < StandardError
25
+ # @rbs () -> String
26
+ def message = "could not write response"
27
+ end
28
+
29
+ # Writes `string` in full, retrying on partial writes. Bounded by
30
+ # `timeout` so a slow client can't pin the writing thread.
31
+ #
32
+ # @param socket [TCPSocket] the socket to write to
33
+ # @param string [String] the data to write
34
+ # @param timeout [Integer] seconds to wait for the socket to become writable on each partial write
35
+ # @return [void]
36
+ # @raise [WriteError] if the socket is not writable within the timeout or raises IOError
37
+ #
38
+ # @rbs (TCPSocket socket, String string, ?timeout: Integer) -> void
39
+ def self.socket_write(socket, string, timeout: WRITE_TIMEOUT)
40
+ bytes = 0
41
+ byte_size = string.bytesize
42
+
43
+ while bytes < byte_size
44
+ begin
45
+ bytes += socket.write_nonblock(bytes.zero? ? string : string.byteslice(bytes..-1))
46
+ rescue IO::WaitWritable
47
+ raise WriteError unless socket.wait_writable(timeout)
48
+ retry
49
+ rescue IOError
50
+ raise WriteError
51
+ end
52
+ end
53
+ end
54
+
55
+ # Writes `strings` in full via a single `writev(2)` syscall when possible,
56
+ # falling back to per-string writes on partial results. Bounded by
57
+ # `timeout` so a slow client can't pin the writing thread.
58
+ #
59
+ # @param socket [TCPSocket] the socket to write to
60
+ # @param strings [Array<String>] the buffers to write in order
61
+ # @param timeout [Integer] seconds to wait for the socket to become writable on each retry
62
+ # @return [void]
63
+ # @raise [WriteError] if the socket is not writable within the timeout or raises IOError
64
+ #
65
+ # @rbs (TCPSocket socket, Array[String] strings, ?timeout: Integer) -> void
66
+ def self.socket_writev(socket, strings, timeout: WRITE_TIMEOUT)
67
+ total = strings.sum(&:bytesize)
68
+ return if total.zero?
69
+
70
+ begin
71
+ written = Raptor::VectorIO.writev_nonblock(socket, strings)
72
+ rescue IO::WaitWritable
73
+ raise WriteError unless socket.wait_writable(timeout)
74
+ retry
75
+ rescue IOError
76
+ raise WriteError
77
+ end
78
+
79
+ return if written == total
80
+
81
+ offset = 0
82
+ strings.each do |string|
83
+ size = string.bytesize
84
+ if written >= offset + size
85
+ offset += size
86
+ else
87
+ start = written - offset
88
+ socket_write(socket, start.zero? ? string : string.byteslice(start..-1), timeout: timeout)
89
+ offset += size
90
+ written = offset
91
+ end
92
+ end
93
+ end
94
+
95
+ # Writes a Common Log Format entry to `io`. Write failures are silently
96
+ # ignored.
97
+ #
98
+ # @param io [IO] the destination IO
99
+ # @param env [Hash] the Rack environment
100
+ # @param status [Integer] the response status code
101
+ # @param size [String] the response body size in bytes, or `-` if unknown
102
+ # @param remote_addr [String] the client IP address
103
+ # @return [void]
104
+ #
105
+ # @rbs (IO io, Hash[String, untyped] env, Integer status, String size, String remote_addr) -> void
106
+ def self.write_access_log(io, env, status, size, remote_addr)
107
+ timestamp = Time.now.strftime("%d/%b/%Y:%H:%M:%S %z")
108
+ method = env[Rack::REQUEST_METHOD]
109
+ query = env[Rack::QUERY_STRING]
110
+ path = query.empty? ? env[Rack::PATH_INFO] : "#{env[Rack::PATH_INFO]}?#{query}"
111
+ protocol = env[Rack::SERVER_PROTOCOL]
112
+
113
+ io.puts(%(#{remote_addr} - - [#{timestamp}] "#{method} #{path} #{protocol}" #{status} #{size})) rescue nil
114
+ end
115
+ end
116
+ end