raptor 0.3.0 → 0.4.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.
@@ -5,6 +5,7 @@ require "socket"
5
5
  require "stringio"
6
6
  require "tempfile"
7
7
 
8
+ require "atomic-ruby/atomic_boolean"
8
9
  require "rack"
9
10
 
10
11
  require_relative "raptor_http"
@@ -39,6 +40,7 @@ module Raptor
39
40
  end
40
41
 
41
42
  STATUS_WITH_NO_ENTITY_BODY = Set.new([204, 304, *100..199]).freeze
43
+ BAD_REQUEST_RESPONSE = "HTTP/1.1 400 Bad Request\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
42
44
  INTERNAL_SERVER_ERROR_RESPONSE = "HTTP/1.1 500 Internal Server Error\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
43
45
  CONTENT_TOO_LARGE_RESPONSE = "HTTP/1.1 413 Content Too Large\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
44
46
 
@@ -92,11 +94,39 @@ module Raptor
92
94
  [decoded, :incomplete]
93
95
  end
94
96
 
97
+ # Writes a string to the socket, retrying on partial writes and flow control blocks.
98
+ #
99
+ # Uses write_nonblock with `WRITE_TIMEOUT` to avoid blocking the thread
100
+ # indefinitely on slow clients.
101
+ #
102
+ # @param socket [TCPSocket] the socket to write to
103
+ # @param string [String] the data to write
104
+ # @return [void]
105
+ # @raise [WriteError] if the socket is not writable within the timeout or raises IOError
106
+ #
107
+ # @rbs (TCPSocket socket, String string) -> void
108
+ def self.socket_write(socket, string)
109
+ bytes = 0
110
+ byte_size = string.bytesize
111
+
112
+ while bytes < byte_size
113
+ begin
114
+ bytes += socket.write_nonblock(bytes.zero? ? string : string.byteslice(bytes..-1))
115
+ rescue IO::WaitWritable
116
+ raise WriteError unless socket.wait_writable(WRITE_TIMEOUT)
117
+ retry
118
+ rescue IOError
119
+ raise WriteError
120
+ end
121
+ end
122
+ end
123
+
95
124
  # @rbs @app: ^(Hash[String, untyped]) -> [Integer, Hash[String, String | Array[String]], untyped]
96
125
  # @rbs @server_port: Integer
97
126
  # @rbs @max_body_size: Integer?
98
127
  # @rbs @body_spool_threshold: Integer?
99
128
  # @rbs @on_error: ^(Hash[String, untyped]?, Exception) -> void | nil
129
+ # @rbs @running: AtomicBoolean
100
130
 
101
131
  # Creates a new Request handler.
102
132
  #
@@ -115,6 +145,17 @@ module Raptor
115
145
  @max_body_size = client_options[:max_body_size]
116
146
  @body_spool_threshold = client_options[:body_spool_threshold]
117
147
  @on_error = on_error
148
+ @running = AtomicBoolean.new(true)
149
+ end
150
+
151
+ # Signals eager keep-alive loops to stop processing further requests on
152
+ # their connections. In-flight requests complete normally.
153
+ #
154
+ # @return [void]
155
+ #
156
+ # @rbs () -> void
157
+ def shutdown
158
+ @running.make_false
118
159
  end
119
160
 
120
161
  # Eagerly reads and parses the first request on a freshly accepted
@@ -155,7 +196,12 @@ module Raptor
155
196
 
156
197
  parser = HttpParser.new
157
198
  env = {}
158
- nread = parser.execute(env, buffer, 0)
199
+ nread = begin
200
+ parser.execute(env, buffer, 0)
201
+ rescue HttpParserError
202
+ reject_malformed(socket)
203
+ return
204
+ end
159
205
  parse_data = { parse_count: 1, content_length: parser.content_length }
160
206
 
161
207
  body = nil
@@ -210,7 +256,11 @@ module Raptor
210
256
 
211
257
  parser = Raptor::HttpParser.new
212
258
  env = {}
213
- nread = parser.execute(env, data[:buffer], 0)
259
+ nread = begin
260
+ parser.execute(env, data[:buffer], 0)
261
+ rescue Raptor::HttpParserError
262
+ next Ractor.make_shareable(data.merge(complete: true, malformed: true))
263
+ end
214
264
  parse_data = if data[:parse_data]
215
265
  data[:parse_data].dup
216
266
  else
@@ -269,6 +319,12 @@ module Raptor
269
319
  return
270
320
  end
271
321
 
322
+ if parsed_request[:malformed]
323
+ socket = reactor.remove(parsed_request[:id])
324
+ reject_malformed(socket) if socket
325
+ return
326
+ end
327
+
272
328
  unless parsed_request[:complete]
273
329
  reactor.update_state(parsed_request)
274
330
  else
@@ -394,6 +450,11 @@ module Raptor
394
450
  # @rbs (TCPSocket socket, Integer id, Reactor reactor, AtomicThreadPool thread_pool, Integer request_count, String remote_addr, String url_scheme) -> void
395
451
  def eager_keepalive(socket, id, reactor, thread_pool, request_count, remote_addr, url_scheme)
396
452
  loop do
453
+ unless @running.true?
454
+ socket.close rescue nil
455
+ return
456
+ end
457
+
397
458
  unless socket.wait_readable(KEEPALIVE_READ_TIMEOUT)
398
459
  reactor.persist(socket, id, request_count, remote_addr: remote_addr, url_scheme: url_scheme)
399
460
  return
@@ -418,7 +479,12 @@ module Raptor
418
479
 
419
480
  parser = HttpParser.new
420
481
  env = {}
421
- nread = parser.execute(env, buffer, 0)
482
+ nread = begin
483
+ parser.execute(env, buffer, 0)
484
+ rescue HttpParserError
485
+ reject_malformed(socket)
486
+ return
487
+ end
422
488
  parse_data = { parse_count: 1, content_length: parser.content_length }
423
489
 
424
490
  body = nil
@@ -515,6 +581,18 @@ module Raptor
515
581
  socket.close rescue nil
516
582
  end
517
583
 
584
+ # Writes a 400 response and closes the socket. Used when the HTTP parser
585
+ # rejects the request line or headers.
586
+ #
587
+ # @param socket [TCPSocket] the client socket
588
+ # @return [void]
589
+ #
590
+ # @rbs (TCPSocket socket) -> void
591
+ def reject_malformed(socket)
592
+ socket.write(BAD_REQUEST_RESPONSE) rescue nil
593
+ socket.close rescue nil
594
+ end
595
+
518
596
  # Builds a Rack environment hash from parsed HTTP request data.
519
597
  #
520
598
  # Populates all required Rack env keys including rack.* keys, REMOTE_ADDR,
@@ -644,7 +722,7 @@ module Raptor
644
722
  end
645
723
  response << "\r\n"
646
724
 
647
- socket_write(socket, response)
725
+ Request.socket_write(socket, response)
648
726
  end
649
727
 
650
728
  # Writes a complete HTTP response to the socket.
@@ -777,7 +855,7 @@ module Raptor
777
855
  def write_hijacked_response(socket, response, headers, response_hijack)
778
856
  response << format_headers(headers)
779
857
  response << "\r\n"
780
- socket_write(socket, response)
858
+ Request.socket_write(socket, response)
781
859
  uncork_socket(socket)
782
860
  response_hijack.call(socket)
783
861
  end
@@ -802,7 +880,7 @@ module Raptor
802
880
 
803
881
  response << format_headers(headers)
804
882
  response << "\r\n"
805
- socket_write(socket, response)
883
+ Request.socket_write(socket, response)
806
884
  end
807
885
 
808
886
  # Writes a complete response with a body.
@@ -824,7 +902,7 @@ module Raptor
824
902
  if body.respond_to?(:call)
825
903
  response << format_headers(headers)
826
904
  response << "\r\n"
827
- socket_write(socket, response)
905
+ Request.socket_write(socket, response)
828
906
  uncork_socket(socket)
829
907
  body.call(socket)
830
908
  return
@@ -861,7 +939,7 @@ module Raptor
861
939
  raise TypeError, "body must respond to each, to_ary, or to_path"
862
940
  end
863
941
 
864
- socket_write(socket, "0\r\n\r\n") if use_chunked
942
+ Request.socket_write(socket, "0\r\n\r\n") if use_chunked
865
943
  end
866
944
 
867
945
  # Calculates content length from an array or file body without consuming it.
@@ -901,15 +979,15 @@ module Raptor
901
979
  def write_file_body(socket, response, path, content_length, use_chunked)
902
980
  File.open(path, "rb") do |file|
903
981
  if use_chunked
904
- socket_write(socket, response)
982
+ Request.socket_write(socket, response)
905
983
  while (chunk = file.read(FILE_CHUNK_SIZE))
906
- socket_write(socket, "#{chunk.bytesize.to_s(16)}\r\n#{chunk}\r\n")
984
+ Request.socket_write(socket, "#{chunk.bytesize.to_s(16)}\r\n#{chunk}\r\n")
907
985
  end
908
986
  elsif content_length && content_length < BODY_BUFFER_THRESHOLD
909
987
  response << file.read(content_length)
910
- socket_write(socket, response)
988
+ Request.socket_write(socket, response)
911
989
  else
912
- socket_write(socket, response)
990
+ Request.socket_write(socket, response)
913
991
  IO.copy_stream(file, socket)
914
992
  end
915
993
  end
@@ -952,12 +1030,12 @@ module Raptor
952
1030
 
953
1031
  if use_chunked
954
1032
  response << "#{chunk.bytesize.to_s(16)}\r\n#{chunk}\r\n"
955
- socket_write(socket, response)
1033
+ Request.socket_write(socket, response)
956
1034
  elsif chunk.bytesize < BODY_BUFFER_THRESHOLD
957
- socket_write(socket, response << chunk)
1035
+ Request.socket_write(socket, response << chunk)
958
1036
  else
959
- socket_write(socket, response)
960
- socket_write(socket, chunk)
1037
+ Request.socket_write(socket, response)
1038
+ Request.socket_write(socket, chunk)
961
1039
  end
962
1040
  end
963
1041
 
@@ -973,13 +1051,13 @@ module Raptor
973
1051
  # @rbs (TCPSocket socket, String response, Array[String] body_array, bool use_chunked) -> void
974
1052
  def write_multiple_chunks(socket, response, body_array, use_chunked)
975
1053
  if use_chunked
976
- socket_write(socket, response)
1054
+ Request.socket_write(socket, response)
977
1055
  body_array.each do |chunk|
978
1056
  raise TypeError, "body must yield String values" unless chunk.is_a?(String)
979
1057
 
980
1058
  next if chunk.empty?
981
1059
 
982
- socket_write(socket, "#{chunk.bytesize.to_s(16)}\r\n#{chunk}\r\n")
1060
+ Request.socket_write(socket, "#{chunk.bytesize.to_s(16)}\r\n#{chunk}\r\n")
983
1061
  end
984
1062
  else
985
1063
  body_array.each do |chunk|
@@ -987,7 +1065,7 @@ module Raptor
987
1065
 
988
1066
  response << chunk
989
1067
  end
990
- socket_write(socket, response)
1068
+ Request.socket_write(socket, response)
991
1069
  end
992
1070
  end
993
1071
 
@@ -1003,13 +1081,13 @@ module Raptor
1003
1081
  # @rbs (TCPSocket socket, String response, untyped body, bool use_chunked) -> void
1004
1082
  def write_enumerable_body(socket, response, body, use_chunked)
1005
1083
  if use_chunked
1006
- socket_write(socket, response)
1084
+ Request.socket_write(socket, response)
1007
1085
  body.each do |chunk|
1008
1086
  raise TypeError, "body must yield String values" unless chunk.is_a?(String)
1009
1087
 
1010
1088
  next if chunk.empty?
1011
1089
 
1012
- socket_write(socket, "#{chunk.bytesize.to_s(16)}\r\n#{chunk}\r\n")
1090
+ Request.socket_write(socket, "#{chunk.bytesize.to_s(16)}\r\n#{chunk}\r\n")
1013
1091
  end
1014
1092
  else
1015
1093
  body.each do |chunk|
@@ -1017,7 +1095,7 @@ module Raptor
1017
1095
 
1018
1096
  response << chunk
1019
1097
  end
1020
- socket_write(socket, response)
1098
+ Request.socket_write(socket, response)
1021
1099
  end
1022
1100
  end
1023
1101
 
@@ -1090,33 +1168,6 @@ module Raptor
1090
1168
  end
1091
1169
  end
1092
1170
 
1093
- # Writes a string to the socket, retrying on partial writes and flow control blocks.
1094
- #
1095
- # Uses write_nonblock with a 5-second writable timeout to avoid blocking the
1096
- # thread indefinitely on slow clients.
1097
- #
1098
- # @param socket [TCPSocket] the socket to write to
1099
- # @param string [String] the data to write
1100
- # @return [void]
1101
- # @raise [WriteError] if the socket is not writable within the timeout or raises IOError
1102
- #
1103
- # @rbs (TCPSocket socket, String string) -> void
1104
- def socket_write(socket, string)
1105
- bytes = 0
1106
- byte_size = string.bytesize
1107
-
1108
- while bytes < byte_size
1109
- begin
1110
- bytes += socket.write_nonblock(bytes.zero? ? string : string.byteslice(bytes..-1))
1111
- rescue IO::WaitWritable
1112
- raise WriteError unless socket.wait_writable(WRITE_TIMEOUT)
1113
- retry
1114
- rescue IOError
1115
- raise WriteError
1116
- end
1117
- end
1118
- end
1119
-
1120
1171
  if Socket.const_defined?(:TCP_CORK)
1121
1172
  # Enables TCP_CORK on the socket to batch outgoing packets into fewer segments.
1122
1173
  #
data/lib/raptor/server.rb CHANGED
@@ -14,8 +14,8 @@ module Raptor
14
14
  # providing natural backpressure based on system capacity.
15
15
  #
16
16
  # Supports TCP, Unix domain, and SSL listeners transparently. TCP_NODELAY is
17
- # applied only to TCP sockets, and SSL handshakes are performed synchronously
18
- # before the connection is dispatched.
17
+ # applied only to TCP sockets, and SSL handshakes are offloaded to the thread
18
+ # pool so a slow client cannot block the server thread.
19
19
  #
20
20
  # For HTTP/1.1 connections the first request is parsed inline on the server
21
21
  # thread and dispatched directly to the thread pool, falling back to the
@@ -27,7 +27,7 @@ module Raptor
27
27
  # binder = Binder.new(["tcp://0.0.0.0:3000"])
28
28
  # reactor = Reactor.new(thread_pool, ractor_pool, client_options: {})
29
29
  # request = Request.new(app, 3000)
30
- # server = Server.new(binder, reactor, thread_pool, request)
30
+ # server = Server.new(binder, reactor, thread_pool, request, client_options: { first_data_timeout: 30 })
31
31
  # server.run
32
32
  # # ... later
33
33
  # server.shutdown
@@ -41,6 +41,7 @@ module Raptor
41
41
  # @rbs @reactor: Reactor
42
42
  # @rbs @thread_pool: AtomicThreadPool
43
43
  # @rbs @request: Request
44
+ # @rbs @client_options: Hash[Symbol, untyped]
44
45
  # @rbs @running: AtomicBoolean
45
46
 
46
47
  # Creates a new Server instance.
@@ -49,14 +50,16 @@ module Raptor
49
50
  # @param reactor [Reactor] the reactor for handling client connections
50
51
  # @param thread_pool [AtomicThreadPool] thread pool for application processing
51
52
  # @param request [Request] the HTTP/1.1 request handler
53
+ # @param client_options [Hash] client timeout configuration, used to bound TLS handshakes
52
54
  # @return [void]
53
55
  #
54
- # @rbs (Binder binder, Reactor reactor, AtomicThreadPool thread_pool, Request request) -> void
55
- def initialize(binder, reactor, thread_pool, request)
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:)
56
58
  @binder = binder
57
59
  @reactor = reactor
58
60
  @thread_pool = thread_pool
59
61
  @request = request
62
+ @client_options = client_options
60
63
  @running = AtomicBoolean.new(true)
61
64
  end
62
65
 
@@ -108,9 +111,11 @@ module Raptor
108
111
 
109
112
  # Accepts a connection from the given listener and dispatches it.
110
113
  #
111
- # For SSL connections with h2 negotiated via ALPN, the server sends
112
- # initial SETTINGS and adds the connection to the reactor as an HTTP/2
113
- # connection. All other connections follow the HTTP/1.1 path.
114
+ # For SSL listeners the TLS handshake is offloaded to the thread pool so
115
+ # a slow client cannot block the server thread. For SSL connections with
116
+ # h2 negotiated via ALPN, the server sends initial SETTINGS and adds the
117
+ # connection to the reactor as an HTTP/2 connection. All other connections
118
+ # follow the HTTP/1.1 path.
114
119
  #
115
120
  # @param listener [TCPServer, UNIXServer, Binder::SslListener] the ready listener
116
121
  # @param reactor [Reactor] the reactor to dispatch connections to
@@ -131,46 +136,117 @@ module Raptor
131
136
  remote_addr = "127.0.0.1"
132
137
  end
133
138
 
134
- url_scheme = HTTP_SCHEME
135
- client = tcp_client
136
-
137
139
  if listener.is_a?(Binder::SslListener)
138
- url_scheme = HTTPS_SCHEME
139
- begin
140
- ssl_socket = OpenSSL::SSL::SSLSocket.new(tcp_client, listener.ssl_context)
141
- ssl_socket.sync_close = true
142
- ssl_socket.accept
143
- client = ssl_socket
144
- rescue OpenSSL::SSL::SSLError => error
145
- warn "SSL handshake failed: #{error.message}"
146
- tcp_client.close rescue nil
147
- return
140
+ @thread_pool << proc do
141
+ dispatch_ssl_connection(listener, tcp_client, remote_addr, reactor)
148
142
  end
143
+ return
144
+ end
149
145
 
150
- if ssl_socket.alpn_protocol == H2_PROTOCOL
151
- ssl_socket.write(Http2.build_server_settings_frame) rescue nil
146
+ @request.eager_accept(
147
+ tcp_client,
148
+ tcp_client.object_id,
149
+ reactor,
150
+ @thread_pool,
151
+ remote_addr,
152
+ HTTP_SCHEME
153
+ )
154
+ end
152
155
 
153
- reactor.add(
154
- id: ssl_socket.object_id,
155
- socket: ssl_socket,
156
- remote_addr: remote_addr,
157
- url_scheme: HTTPS_SCHEME,
158
- protocol: :http2,
159
- writer: Http2::Writer.new
160
- )
156
+ # Performs the TLS handshake for an accepted SSL connection and dispatches
157
+ # it through the HTTP/2 or HTTP/1.1 path. The handshake is bounded by
158
+ # `:first_data_timeout` so a slow client cannot pin a worker thread.
159
+ #
160
+ # @param listener [Binder::SslListener] the SSL listener that accepted the connection
161
+ # @param tcp_client [TCPSocket] the accepted TCP socket
162
+ # @param remote_addr [String] the client's IP address
163
+ # @param reactor [Reactor] the reactor to dispatch the connection to
164
+ # @return [void]
165
+ #
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)
168
+ ssl_socket = OpenSSL::SSL::SSLSocket.new(tcp_client, listener.ssl_context)
169
+ ssl_socket.sync_close = true
170
+ return unless perform_ssl_handshake(ssl_socket)
171
+
172
+ if ssl_socket.alpn_protocol == H2_PROTOCOL
173
+ ssl_socket.write(Http2.build_server_settings_frame) rescue nil
174
+
175
+ reactor.add(
176
+ id: ssl_socket.object_id,
177
+ socket: ssl_socket,
178
+ remote_addr: remote_addr,
179
+ url_scheme: HTTPS_SCHEME,
180
+ protocol: :http2,
181
+ writer: Http2::Writer.new,
182
+ flow_control: Http2::FlowControl.new
183
+ )
161
184
 
162
- return
163
- end
185
+ return
164
186
  end
165
187
 
166
188
  @request.eager_accept(
167
- client,
168
- client.object_id,
189
+ ssl_socket,
190
+ ssl_socket.object_id,
169
191
  reactor,
170
192
  @thread_pool,
171
193
  remote_addr,
172
- url_scheme
194
+ HTTPS_SCHEME
173
195
  )
174
196
  end
197
+
198
+ # Drives a non-blocking SSL handshake to completion, bounded by the
199
+ # configured first-data timeout. Returns true on success, false on
200
+ # timeout or SSL error.
201
+ #
202
+ # @param ssl_socket [OpenSSL::SSL::SSLSocket] the SSL socket to hand-shake
203
+ # @return [Boolean] true if the handshake completed
204
+ #
205
+ # @rbs (OpenSSL::SSL::SSLSocket ssl_socket) -> bool
206
+ def perform_ssl_handshake(ssl_socket)
207
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + @client_options[:first_data_timeout]
208
+
209
+ begin
210
+ ssl_socket.accept_nonblock
211
+ true
212
+ rescue IO::WaitReadable
213
+ return false unless wait_for_handshake(ssl_socket, deadline, :read)
214
+
215
+ retry
216
+ rescue IO::WaitWritable
217
+ return false unless wait_for_handshake(ssl_socket, deadline, :write)
218
+
219
+ retry
220
+ rescue OpenSSL::SSL::SSLError => error
221
+ warn "SSL handshake failed: #{error.message}"
222
+ ssl_socket.close rescue nil
223
+ false
224
+ end
225
+ end
226
+
227
+ # Waits up to `deadline` for the socket to become ready for the next step
228
+ # of the SSL handshake. Closes the socket and returns false on timeout.
229
+ #
230
+ # @param ssl_socket [OpenSSL::SSL::SSLSocket] the SSL socket
231
+ # @param deadline [Float] absolute monotonic deadline
232
+ # @param direction [Symbol] either `:read` or `:write`
233
+ # @return [Boolean] true if the socket became ready before the deadline
234
+ #
235
+ # @rbs (OpenSSL::SSL::SSLSocket ssl_socket, Float deadline, Symbol direction) -> bool
236
+ def wait_for_handshake(ssl_socket, deadline, direction)
237
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
238
+ ready = if remaining <= 0
239
+ false
240
+ elsif direction == :read
241
+ ssl_socket.wait_readable(remaining)
242
+ else
243
+ ssl_socket.wait_writable(remaining)
244
+ end
245
+ return true if ready
246
+
247
+ warn "SSL handshake timed out"
248
+ ssl_socket.close rescue nil
249
+ false
250
+ end
175
251
  end
176
252
  end
@@ -2,5 +2,5 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  module Raptor
5
- VERSION = "0.3.0"
5
+ VERSION = "0.4.0"
6
6
  end
@@ -20,6 +20,8 @@ module Raptor
20
20
 
21
21
  DEFAULT_OPTIONS: untyped
22
22
 
23
+ DEFAULT_CONFIG_PATHS: untyped
24
+
23
25
  # Loads a configuration file and returns the hash it evaluates to.
24
26
  #
25
27
  # The file is evaluated at the top level so constants like `Raptor::*` resolve
@@ -33,6 +35,18 @@ module Raptor
33
35
  # @rbs (String path) -> Hash[Symbol, untyped]
34
36
  def self.load_config_file: (String path) -> Hash[Symbol, untyped]
35
37
 
38
+ # Returns the first existing path in {DEFAULT_CONFIG_PATHS} resolved
39
+ # against `root`, or nil if none exist.
40
+ #
41
+ # Used to pick up a project-local config file when no `-c`/`--config`
42
+ # flag was supplied.
43
+ #
44
+ # @param root [String] directory to resolve the default paths against
45
+ # @return [String, nil] the config path, or nil if no default file exists
46
+ #
47
+ # @rbs (?String root) -> String?
48
+ def self.default_config_path: (?String root) -> String?
49
+
36
50
  @command: Symbol
37
51
 
38
52
  @options: Hash[Symbol, untyped]
@@ -53,7 +53,7 @@ module Raptor
53
53
 
54
54
  @binder: Binder
55
55
 
56
- @pidfile: String?
56
+ @pid_file: String?
57
57
 
58
58
  @stats_file: String?
59
59
 
@@ -83,7 +83,7 @@ module Raptor
83
83
  # @option options [Hash] :client client configuration
84
84
  # @option options [#call] :on_error callback invoked with (env, exception) when the Rack app raises
85
85
  # @option options [String, nil] :stats_file path to write per-worker stats JSON, or nil to disable
86
- # @option options [String, nil] :pidfile path to write the master PID to, or nil to disable
86
+ # @option options [String, nil] :pid_file path to write the master PID to, or nil to disable
87
87
  # @return [void]
88
88
  #
89
89
  # @rbs (Hash[Symbol, untyped] options) -> void