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.
@@ -8,6 +8,7 @@ require "tempfile"
8
8
  require "atomic-ruby/atomic_boolean"
9
9
  require "rack"
10
10
 
11
+ require_relative "http"
11
12
  require_relative "raptor_http"
12
13
 
13
14
  module Raptor
@@ -16,11 +17,12 @@ module Raptor
16
17
  # with the reactor for requests that need more data before they
17
18
  # can be handled.
18
19
  #
19
- class Request
20
+ class Http1
20
21
  BODY_BUFFER_THRESHOLD = 256 * 1024
21
22
  FILE_CHUNK_SIZE = 64 * 1024
23
+ MAX_CHUNK_OVERHEAD = 16 * 1024
22
24
  READ_BUFFER_SIZE = 64 * 1024
23
- WRITE_TIMEOUT = 5
25
+ RESPONSE_BUFFER_CAPACITY = 4 * 1024
24
26
  KEEPALIVE_READ_TIMEOUT = 0.001
25
27
  MAX_KEEPALIVE_REQUESTS = 100
26
28
 
@@ -36,15 +38,18 @@ module Raptor
36
38
  end
37
39
 
38
40
  STATUS_WITH_NO_ENTITY_BODY = [204, 304, *100..199].freeze
41
+ CONTINUE_RESPONSE = "HTTP/1.1 100 Continue\r\n\r\n"
39
42
  BAD_REQUEST_RESPONSE = "HTTP/1.1 400 Bad Request\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
40
- INTERNAL_SERVER_ERROR_RESPONSE = "HTTP/1.1 500 Internal Server Error\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
41
43
  CONTENT_TOO_LARGE_RESPONSE = "HTTP/1.1 413 Content Too Large\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
44
+ INTERNAL_SERVER_ERROR_RESPONSE = "HTTP/1.1 500 Internal Server Error\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
42
45
 
43
46
  CONNECTION_CLOSE = "close"
44
47
  CONNECTION_KEEPALIVE = "keep-alive"
48
+ EXPECT_100_CONTINUE = "100-continue"
45
49
  TRANSFER_ENCODING_CHUNKED = "chunked"
46
50
 
47
51
  HTTP_CONNECTION = "HTTP_CONNECTION"
52
+ HTTP_EXPECT = "HTTP_EXPECT"
48
53
  HTTP_TRANSFER_ENCODING = "HTTP_TRANSFER_ENCODING"
49
54
  RACK_HEADER_PREFIX = "rack."
50
55
  RACK_HIJACKED = "rack.hijacked"
@@ -53,17 +58,39 @@ module Raptor
53
58
  ILLEGAL_HEADER_KEY_REGEX = /[\x00-\x20\(\)<>@,;:\\"\/\[\]\?=\{\}\x7F]/
54
59
  ILLEGAL_HEADER_VALUE_REGEX = /[\x00-\x08\x0A-\x1F]/
55
60
 
56
- class Error < StandardError; end
57
- class WriteError < Error
58
- # @rbs () -> String
59
- def message = "could not write response"
61
+ # Returns true when the message framing shows a request-smuggling vector
62
+ # per RFC 9112 section 6.3: a `Transfer-Encoding` where `chunked` is
63
+ # missing, not the final encoding, or duplicated; a `Transfer-Encoding`
64
+ # paired with a `Content-Length`; or a `Content-Length` containing any
65
+ # non-digit character.
66
+ #
67
+ # @param env [Hash] the Rack environment after header parsing
68
+ # @return [Boolean]
69
+ #
70
+ # @rbs (Hash[String, untyped] env) -> bool
71
+ def self.request_smuggling?(env)
72
+ transfer_encoding = env[HTTP_TRANSFER_ENCODING]
73
+ content_length = env[Http::CONTENT_LENGTH]
74
+
75
+ if transfer_encoding
76
+ return true if content_length
77
+
78
+ encodings = transfer_encoding.downcase.split(",").map(&:strip)
79
+ return true if encodings.last != TRANSFER_ENCODING_CHUNKED
80
+ return true if encodings.count(TRANSFER_ENCODING_CHUNKED) > 1
81
+ elsif content_length
82
+ return true if content_length.match?(/[^\d]/)
83
+ end
84
+
85
+ false
60
86
  end
61
87
 
62
88
  # Decodes a chunked transfer-encoded body buffer.
63
89
  #
64
90
  # Returns the decoded bytes and a state symbol: `:complete` when the
65
91
  # terminating zero-length chunk was found, `:too_large` when the decoded
66
- # size would exceed `max_size`, or `:incomplete` otherwise.
92
+ # size would exceed `max_size`, `:malformed` when chunk framing overhead
93
+ # exceeds `MAX_CHUNK_OVERHEAD`, or `:incomplete` otherwise.
67
94
  #
68
95
  # @param buffer [String] the raw body buffer to decode
69
96
  # @param max_size [Integer, nil] maximum decoded body size, or nil for unlimited
@@ -73,6 +100,7 @@ module Raptor
73
100
  def self.decode_chunked(buffer, max_size = nil)
74
101
  decoded = String.new
75
102
  offset = 0
103
+ overhead = 0
76
104
 
77
105
  while offset < buffer.bytesize
78
106
  crlf = buffer.index("\r\n", offset)
@@ -80,7 +108,10 @@ module Raptor
80
108
 
81
109
  chunk_size = buffer.byteslice(offset, crlf - offset).to_i(16)
82
110
  return [decoded, :complete] if chunk_size == 0
83
- return [decoded, :too_large] if max_size && decoded.bytesize + chunk_size > max_size
111
+ return [decoded, :too_large] if max_size && (decoded.bytesize + chunk_size) > max_size
112
+
113
+ overhead += (crlf - offset) + 4
114
+ return [decoded, :malformed] if overhead > (decoded.bytesize + chunk_size + MAX_CHUNK_OVERHEAD)
84
115
 
85
116
  offset = crlf + 2
86
117
  decoded << buffer.byteslice(offset, chunk_size)
@@ -90,58 +121,69 @@ module Raptor
90
121
  [decoded, :incomplete]
91
122
  end
92
123
 
93
- # Writes `string` in full, retrying on partial writes. Bounded by
94
- # `WRITE_TIMEOUT` so a slow client can't pin the writing thread.
95
- #
96
- # @param socket [TCPSocket] the socket to write to
97
- # @param string [String] the data to write
98
- # @return [void]
99
- # @raise [WriteError] if the socket is not writable within the timeout or raises IOError
100
- #
101
- # @rbs (TCPSocket socket, String string) -> void
102
- def self.socket_write(socket, string)
103
- bytes = 0
104
- byte_size = string.bytesize
105
-
106
- while bytes < byte_size
107
- begin
108
- bytes += socket.write_nonblock(bytes.zero? ? string : string.byteslice(bytes..-1))
109
- rescue IO::WaitWritable
110
- raise WriteError unless socket.wait_writable(WRITE_TIMEOUT)
111
- retry
112
- rescue IOError
113
- raise WriteError
114
- end
115
- end
116
- end
117
-
118
124
  # @rbs @app: ^(Hash[String, untyped]) -> [Integer, Hash[String, String | Array[String]], untyped]
119
125
  # @rbs @server_port: Integer
126
+ # @rbs @write_timeout: Integer
120
127
  # @rbs @max_body_size: Integer?
121
128
  # @rbs @body_spool_threshold: Integer?
129
+ # @rbs @max_keepalive_requests: Integer
130
+ # @rbs @access_log_io: IO?
122
131
  # @rbs @on_error: ^(Hash[String, untyped]?, Exception) -> void | nil
123
132
  # @rbs @running: AtomicBoolean
124
133
 
125
- # Creates a new Request handler.
134
+ # Creates a new Http1 handler.
126
135
  #
127
136
  # @param app [#call] the Rack application to dispatch complete requests to
128
137
  # @param server_port [Integer] port number used to populate SERVER_PORT in the Rack env
129
- # @param client_options [Hash] client limits configuration
130
- # @option client_options [Integer, nil] :max_body_size maximum request body size in bytes
131
- # @option client_options [Integer, nil] :body_spool_threshold spool bodies larger than this to a tempfile
138
+ # @param connection_options [Hash] per-connection settings shared across protocols
139
+ # @option connection_options [Integer] :write_timeout per-write socket timeout in seconds
140
+ # @option connection_options [Integer, nil] :max_body_size maximum request body size in bytes
141
+ # @option connection_options [Integer, nil] :body_spool_threshold spool bodies larger than this to a tempfile
142
+ # @param http1_options [Hash] HTTP/1.1-specific settings
143
+ # @option http1_options [Integer] :max_keepalive_requests maximum requests per HTTP/1.1 keep-alive connection
144
+ # @param access_log_io [IO, nil] IO to write Common Log Format access entries to, or nil to disable
132
145
  # @param on_error [#call, nil] callback invoked with (env, exception) when the Rack app raises
133
146
  # @return [void]
134
147
  #
135
- # @rbs (^(Hash[String, untyped]) -> [Integer, Hash[String, String | Array[String]], untyped] app, Integer server_port, ?client_options: Hash[Symbol, untyped], ?on_error: ^(Hash[String, untyped]?, Exception) -> void | nil) -> void
136
- def initialize(app, server_port, client_options: {}, on_error: nil)
148
+ # @rbs (^(Hash[String, untyped]) -> [Integer, Hash[String, String | Array[String]], untyped] app, Integer server_port, ?connection_options: Hash[Symbol, untyped], ?http1_options: Hash[Symbol, untyped], ?access_log_io: IO?, ?on_error: ^(Hash[String, untyped]?, Exception) -> void | nil) -> void
149
+ def initialize(app, server_port, connection_options: {}, http1_options: {}, access_log_io: nil, on_error: nil)
137
150
  @app = app
138
151
  @server_port = server_port
139
- @max_body_size = client_options[:max_body_size]
140
- @body_spool_threshold = client_options[:body_spool_threshold]
152
+ @write_timeout = connection_options[:write_timeout] || Http::WRITE_TIMEOUT
153
+ @max_body_size = connection_options[:max_body_size]
154
+ @body_spool_threshold = connection_options[:body_spool_threshold]
155
+ @max_keepalive_requests = http1_options[:max_keepalive_requests] || MAX_KEEPALIVE_REQUESTS
156
+ @access_log_io = access_log_io
141
157
  @on_error = on_error
142
158
  @running = AtomicBoolean.new(true)
143
159
  end
144
160
 
161
+ # Instance-level wrapper around {Http.socket_write} that applies the
162
+ # configured `write_timeout`.
163
+ #
164
+ # @param socket [TCPSocket] the socket to write to
165
+ # @param string [String] the data to write
166
+ # @return [void]
167
+ # @raise [Http::WriteError] if the socket is not writable within the timeout or raises IOError
168
+ #
169
+ # @rbs (TCPSocket socket, String string) -> void
170
+ def socket_write(socket, string)
171
+ Http.socket_write(socket, string, timeout: @write_timeout)
172
+ end
173
+
174
+ # Instance-level wrapper around {Http.socket_writev} that applies the
175
+ # configured `write_timeout`.
176
+ #
177
+ # @param socket [TCPSocket] the socket to write to
178
+ # @param strings [Array<String>] the buffers to write in order
179
+ # @return [void]
180
+ # @raise [Http::WriteError] if the socket is not writable within the timeout or raises IOError
181
+ #
182
+ # @rbs (TCPSocket socket, Array[String] strings) -> void
183
+ def socket_writev(socket, strings)
184
+ Http.socket_writev(socket, strings, timeout: @write_timeout)
185
+ end
186
+
145
187
  # Signals eager keep-alive loops to stop processing further requests on
146
188
  # their connections. In-flight requests complete normally.
147
189
  #
@@ -166,8 +208,10 @@ module Raptor
166
208
  #
167
209
  # @rbs (TCPSocket socket, Integer id, Reactor reactor, AtomicThreadPool thread_pool, String remote_addr, String url_scheme) -> void
168
210
  def eager_accept(socket, id, reactor, thread_pool, remote_addr, url_scheme)
169
- data = begin
170
- socket.read_nonblock(READ_BUFFER_SIZE)
211
+ buffer = (Thread.current[:raptor_read_buffer] ||= String.new(capacity: READ_BUFFER_SIZE))
212
+
213
+ begin
214
+ socket.read_nonblock(READ_BUFFER_SIZE, buffer)
171
215
  rescue IO::WaitReadable
172
216
  reactor.add(
173
217
  id: id,
@@ -181,9 +225,6 @@ module Raptor
181
225
  return
182
226
  end
183
227
 
184
- buffer = String.new
185
- buffer << data
186
-
187
228
  while socket.respond_to?(:pending) && socket.pending > 0
188
229
  buffer << socket.read_nonblock(socket.pending)
189
230
  end
@@ -202,6 +243,9 @@ module Raptor
202
243
  if !parser.finished?
203
244
  fallback_to_reactor(socket, id, buffer, env, parse_data, reactor, 0, remote_addr, url_scheme, persisted: false)
204
245
  return
246
+ elsif Http1.request_smuggling?(env)
247
+ reject_malformed(socket)
248
+ return
205
249
  elsif parser.has_body?
206
250
  if @max_body_size && parser.content_length > @max_body_size
207
251
  reject_oversized(socket)
@@ -211,13 +255,16 @@ module Raptor
211
255
  body = buffer.byteslice(nread..-1) || ""
212
256
 
213
257
  if env[HTTP_TRANSFER_ENCODING]&.include?(TRANSFER_ENCODING_CHUNKED)
214
- body, chunked_state = Request.decode_chunked(body, @max_body_size)
258
+ body, chunked_state = Http1.decode_chunked(body, @max_body_size)
215
259
  case chunked_state
216
260
  when :complete
217
261
  env.delete(HTTP_TRANSFER_ENCODING)
218
262
  when :too_large
219
263
  reject_oversized(socket)
220
264
  return
265
+ when :malformed
266
+ reject_malformed(socket)
267
+ return
221
268
  else
222
269
  fallback_to_reactor(socket, id, buffer, env, parse_data, reactor, 0, remote_addr, url_scheme, persisted: false)
223
270
  return
@@ -263,13 +310,15 @@ module Raptor
263
310
  parse_data[:parse_count] += 1
264
311
 
265
312
  message = if parser.finished?
266
- if parser.has_body?
313
+ if Raptor::Http1.request_smuggling?(env)
314
+ data.merge(env: env, body: nil, parse_data: parse_data, complete: true, malformed: true)
315
+ elsif parser.has_body?
267
316
  body_buffer = data[:buffer].byteslice(nread..-1) || ""
268
317
 
269
318
  if max_body_size && parser.content_length > max_body_size
270
319
  data.merge(env: env, body: nil, parse_data: parse_data, complete: true, too_large: true)
271
320
  elsif env[HTTP_TRANSFER_ENCODING]&.include?(TRANSFER_ENCODING_CHUNKED)
272
- decoded_body, chunked_state = Raptor::Request.decode_chunked(body_buffer, max_body_size)
321
+ decoded_body, chunked_state = Raptor::Http1.decode_chunked(body_buffer, max_body_size)
273
322
 
274
323
  case chunked_state
275
324
  when :complete
@@ -277,6 +326,8 @@ module Raptor
277
326
  data.merge(env: env, body: decoded_body, parse_data: parse_data, complete: true)
278
327
  when :too_large
279
328
  data.merge(env: env, body: nil, parse_data: parse_data, complete: true, too_large: true)
329
+ when :malformed
330
+ data.merge(env: env, body: nil, parse_data: parse_data, complete: true, malformed: true)
280
331
  else
281
332
  data.merge(env: env, parse_data: parse_data)
282
333
  end
@@ -320,6 +371,7 @@ module Raptor
320
371
  end
321
372
 
322
373
  unless parsed_request[:complete]
374
+ parsed_request = send_continue_if_expected(parsed_request, reactor)
323
375
  reactor.update_state(parsed_request)
324
376
  else
325
377
  socket = reactor.remove(parsed_request[:id])
@@ -346,6 +398,41 @@ module Raptor
346
398
 
347
399
  private
348
400
 
401
+ # Returns true if the request expects a 100 Continue response per
402
+ # RFC 7231 section 5.1.1.
403
+ #
404
+ # @param env [Hash] the parsed Rack environment (possibly incomplete)
405
+ # @return [Boolean]
406
+ #
407
+ # @rbs (Hash[String, untyped] env) -> bool
408
+ def expects_100_continue?(env)
409
+ (env[Rack::SERVER_PROTOCOL] == HTTP_11) && env[HTTP_EXPECT]&.casecmp?(EXPECT_100_CONTINUE)
410
+ end
411
+
412
+ # Sends an HTTP 100 Continue response when an HTTP/1.1 client requested
413
+ # `Expect: 100-continue` and the request body has not yet been received.
414
+ #
415
+ # Returns the state hash with `:continued` set when the response has been
416
+ # written. A write failure is silently ignored.
417
+ #
418
+ # @param state [Hash] the partially-parsed connection state
419
+ # @param reactor [Reactor] the reactor holding the connection's socket
420
+ # @return [Hash] the state, with `:continued` set if 100 was written
421
+ #
422
+ # @rbs (Hash[Symbol, untyped] state, Reactor reactor) -> Hash[Symbol, untyped]
423
+ def send_continue_if_expected(state, reactor)
424
+ return state if state[:continued]
425
+
426
+ env = state[:env]
427
+ return state unless env && expects_100_continue?(env)
428
+
429
+ socket = reactor.socket_for(state[:id])
430
+ return state unless socket
431
+
432
+ socket_write(socket, CONTINUE_RESPONSE) rescue nil
433
+ state.merge(continued: true)
434
+ end
435
+
349
436
  # Processes a client connection by handling the current request and,
350
437
  # if keep-alive, eagerly reading subsequent requests inline.
351
438
  #
@@ -400,10 +487,12 @@ module Raptor
400
487
  hijacked = headers.is_a?(Hash) && !!headers[Rack::RACK_HIJACK]
401
488
  streaming = body.respond_to?(:call) && !body.respond_to?(:each)
402
489
  keep_alive = (hijacked || streaming) ? false : keep_alive?(rack_env, request_count)
490
+ response_size = response_size(headers, body) unless hijacked
403
491
  response_started = true
404
492
  write_response(socket, rack_env, status, headers, body, keep_alive: keep_alive)
405
493
  end
406
494
 
495
+ write_access_log(rack_env, status, response_size, remote_addr) if @access_log_io && !hijacked
407
496
  call_response_finished(rack_env, status, headers, nil)
408
497
  keep_alive && !hijacked
409
498
  rescue => error
@@ -454,8 +543,10 @@ module Raptor
454
543
  return
455
544
  end
456
545
 
457
- data = begin
458
- socket.read_nonblock(READ_BUFFER_SIZE)
546
+ buffer = (Thread.current[:raptor_read_buffer] ||= String.new(capacity: READ_BUFFER_SIZE))
547
+
548
+ begin
549
+ socket.read_nonblock(READ_BUFFER_SIZE, buffer)
459
550
  rescue IO::WaitReadable
460
551
  reactor.persist(socket, id, request_count, remote_addr: remote_addr, url_scheme: url_scheme)
461
552
  return
@@ -464,9 +555,6 @@ module Raptor
464
555
  return
465
556
  end
466
557
 
467
- buffer = String.new
468
- buffer << data
469
-
470
558
  while socket.respond_to?(:pending) && socket.pending > 0
471
559
  buffer << socket.read_nonblock(socket.pending)
472
560
  end
@@ -549,10 +637,13 @@ module Raptor
549
637
  #
550
638
  # @rbs (TCPSocket socket, Integer id, String buffer, Hash[String, untyped] env, Hash[Symbol, untyped] parse_data, Reactor reactor, Integer request_count, String remote_addr, String url_scheme, persisted: bool) -> void
551
639
  def fallback_to_reactor(socket, id, buffer, env, parse_data, reactor, request_count, remote_addr, url_scheme, persisted: true)
640
+ continued = expects_100_continue?(env)
641
+ socket_write(socket, CONTINUE_RESPONSE) rescue nil if continued
642
+
552
643
  reactor.persist(socket, id, request_count, remote_addr: remote_addr, url_scheme: url_scheme)
553
644
  state = {
554
645
  id: id,
555
- buffer: buffer,
646
+ buffer: buffer.dup,
556
647
  env: env,
557
648
  request_count: request_count,
558
649
  parse_data: parse_data,
@@ -560,6 +651,7 @@ module Raptor
560
651
  url_scheme: url_scheme
561
652
  }
562
653
  state[:persisted] = true if persisted
654
+ state[:continued] = true if continued
563
655
  reactor.update_state(Ractor.make_shareable(state))
564
656
  end
565
657
 
@@ -603,7 +695,6 @@ module Raptor
603
695
  # @rbs (Hash[String, untyped] env, Hash[Symbol, untyped] parse_data, String? body, TCPSocket socket, ?remote_addr: String, ?url_scheme: String) -> Hash[String, untyped]
604
696
  def build_rack_env(env, parse_data, body, socket, remote_addr: Server::DEFAULT_REMOTE_ADDR, url_scheme: Server::HTTP_SCHEME)
605
697
  env[Rack::RACK_VERSION] = Rack::VERSION
606
- env[Rack::RACK_URL_SCHEME] = url_scheme
607
698
  env[Rack::RACK_INPUT] = build_rack_input(body)
608
699
  env[Rack::RACK_ERRORS] = $stderr
609
700
  env[Rack::RACK_RESPONSE_FINISHED] = []
@@ -625,10 +716,16 @@ module Raptor
625
716
  env[Rack::QUERY_STRING] = "" unless env.key?(Rack::QUERY_STRING)
626
717
 
627
718
  if (content_length = parse_data[:content_length]).positive?
628
- env["CONTENT_LENGTH"] = content_length.to_s
719
+ env[Http::CONTENT_LENGTH] = content_length.to_s
629
720
  end
630
721
 
631
- env["REMOTE_ADDR"] = remote_addr
722
+ env[Http::REMOTE_ADDR] = remote_addr
723
+ env[Http::SERVER_SOFTWARE] = Http::SERVER_SOFTWARE_VALUE
724
+ env[Http::HTTP_VERSION] = env[Rack::SERVER_PROTOCOL]
725
+
726
+ behind_tls_proxy = (url_scheme == Server::HTTP_SCHEME) && forwarded_https?(env)
727
+ env[Rack::RACK_URL_SCHEME] = behind_tls_proxy ? Server::HTTPS_SCHEME : url_scheme
728
+ default_port = behind_tls_proxy ? "443" : @server_port.to_s
632
729
 
633
730
  http_host = env[Rack::HTTP_HOST]
634
731
  if http_host
@@ -639,10 +736,10 @@ module Raptor
639
736
  host, port = http_host.split(":", 2)
640
737
  end
641
738
  env[Rack::SERVER_NAME] ||= host
642
- env[Rack::SERVER_PORT] ||= port || @server_port.to_s
739
+ env[Rack::SERVER_PORT] ||= port || default_port
643
740
  else
644
741
  env[Rack::SERVER_NAME] ||= Server::DEFAULT_SERVER_NAME
645
- env[Rack::SERVER_PORT] ||= @server_port.to_s
742
+ env[Rack::SERVER_PORT] ||= default_port
646
743
  end
647
744
 
648
745
  env
@@ -668,6 +765,21 @@ module Raptor
668
765
  end
669
766
  end
670
767
 
768
+ # Returns true when an upstream proxy signals that it terminated TLS for
769
+ # this request via `X-Forwarded-Proto`, `X-Forwarded-Scheme`, or
770
+ # `X-Forwarded-Ssl`. Only the first comma-separated value is consulted.
771
+ #
772
+ # @param env [Hash] the Rack environment
773
+ # @return [Boolean]
774
+ #
775
+ # @rbs (Hash[String, untyped] env) -> bool
776
+ def forwarded_https?(env)
777
+ proto = env["HTTP_X_FORWARDED_PROTO"] || env["HTTP_X_FORWARDED_SCHEME"]
778
+ return true if proto && proto.split(",").first&.strip&.casecmp?(Server::HTTPS_SCHEME)
779
+
780
+ env["HTTP_X_FORWARDED_SSL"]&.casecmp?("on") || false
781
+ end
782
+
671
783
  # Determines whether the connection should be kept alive after the response.
672
784
  #
673
785
  # Returns false if the request limit has been reached. For HTTP/1.1, keep-alive
@@ -680,7 +792,7 @@ module Raptor
680
792
  #
681
793
  # @rbs (Hash[String, untyped] env, Integer request_count) -> bool
682
794
  def keep_alive?(env, request_count)
683
- return false if request_count >= MAX_KEEPALIVE_REQUESTS
795
+ return false if request_count >= @max_keepalive_requests
684
796
 
685
797
  connection_header = env[HTTP_CONNECTION]
686
798
 
@@ -716,7 +828,7 @@ module Raptor
716
828
  end
717
829
  response << "\r\n"
718
830
 
719
- Request.socket_write(socket, response)
831
+ socket_write(socket, response)
720
832
  end
721
833
 
722
834
  # Writes a complete HTTP response to the socket.
@@ -822,7 +934,8 @@ module Raptor
822
934
  end
823
935
  end
824
936
 
825
- # Builds the HTTP status line string.
937
+ # Returns the HTTP status line for `status`. Callers must not retain the
938
+ # returned string across further calls on the same thread.
826
939
  #
827
940
  # @param http_version [String] "HTTP/1.1" or "HTTP/1.0"
828
941
  # @param status [Integer] HTTP status code
@@ -831,7 +944,10 @@ module Raptor
831
944
  # @rbs (String http_version, Integer status) -> String
832
945
  def build_status_line(http_version, status)
833
946
  cache = http_version == HTTP_11 ? STATUS_LINE_CACHE_11 : STATUS_LINE_CACHE_10
834
- cache[status].dup
947
+ response = (Thread.current[:raptor_response_buffer] ||= String.new(capacity: RESPONSE_BUFFER_CAPACITY))
948
+ response.clear
949
+ response << cache[status]
950
+ response
835
951
  end
836
952
 
837
953
  # Writes response headers and delegates body writing to the hijack callback.
@@ -849,7 +965,7 @@ module Raptor
849
965
  def write_hijacked_response(socket, response, headers, response_hijack)
850
966
  response << format_headers(headers)
851
967
  response << "\r\n"
852
- Request.socket_write(socket, response)
968
+ socket_write(socket, response)
853
969
  uncork_socket(socket)
854
970
  response_hijack.call(socket)
855
971
  end
@@ -874,7 +990,7 @@ module Raptor
874
990
 
875
991
  response << format_headers(headers)
876
992
  response << "\r\n"
877
- Request.socket_write(socket, response)
993
+ socket_write(socket, response)
878
994
  end
879
995
 
880
996
  # Writes a complete response with a body.
@@ -896,7 +1012,7 @@ module Raptor
896
1012
  if body.respond_to?(:call)
897
1013
  response << format_headers(headers)
898
1014
  response << "\r\n"
899
- Request.socket_write(socket, response)
1015
+ socket_write(socket, response)
900
1016
  uncork_socket(socket)
901
1017
  body.call(socket)
902
1018
  return
@@ -933,7 +1049,7 @@ module Raptor
933
1049
  raise TypeError, "body must respond to each, to_ary, or to_path"
934
1050
  end
935
1051
 
936
- Request.socket_write(socket, "0\r\n\r\n") if use_chunked
1052
+ socket_write(socket, "0\r\n\r\n") if use_chunked
937
1053
  end
938
1054
 
939
1055
  # Calculates content length from an array or file body without consuming it.
@@ -973,15 +1089,15 @@ module Raptor
973
1089
  def write_file_body(socket, response, path, content_length, use_chunked)
974
1090
  File.open(path, "rb") do |file|
975
1091
  if use_chunked
976
- Request.socket_write(socket, response)
1092
+ socket_write(socket, response)
977
1093
  while (chunk = file.read(FILE_CHUNK_SIZE))
978
- Request.socket_write(socket, "#{chunk.bytesize.to_s(16)}\r\n#{chunk}\r\n")
1094
+ socket_write(socket, "#{chunk.bytesize.to_s(16)}\r\n#{chunk}\r\n")
979
1095
  end
980
1096
  elsif content_length && content_length < BODY_BUFFER_THRESHOLD
981
1097
  response << file.read(content_length)
982
- Request.socket_write(socket, response)
1098
+ socket_write(socket, response)
983
1099
  else
984
- Request.socket_write(socket, response)
1100
+ socket_write(socket, response)
985
1101
  IO.copy_stream(file, socket)
986
1102
  end
987
1103
  end
@@ -1006,10 +1122,7 @@ module Raptor
1006
1122
  end
1007
1123
  end
1008
1124
 
1009
- # Writes a single-element array body, optionally buffering it with the headers.
1010
- #
1011
- # Small bodies are concatenated with the headers into one write to reduce
1012
- # system call overhead.
1125
+ # Writes a single-element array body to the socket.
1013
1126
  #
1014
1127
  # @param socket [TCPSocket] the client socket
1015
1128
  # @param response [String] headers already serialized, to be written before the body
@@ -1024,12 +1137,9 @@ module Raptor
1024
1137
 
1025
1138
  if use_chunked
1026
1139
  response << "#{chunk.bytesize.to_s(16)}\r\n#{chunk}\r\n"
1027
- Request.socket_write(socket, response)
1028
- elsif chunk.bytesize < BODY_BUFFER_THRESHOLD
1029
- Request.socket_write(socket, response << chunk)
1140
+ socket_write(socket, response)
1030
1141
  else
1031
- Request.socket_write(socket, response)
1032
- Request.socket_write(socket, chunk)
1142
+ socket_writev(socket, [response, chunk])
1033
1143
  end
1034
1144
  end
1035
1145
 
@@ -1045,21 +1155,19 @@ module Raptor
1045
1155
  # @rbs (TCPSocket socket, String response, Array[String] body_array, bool use_chunked) -> void
1046
1156
  def write_multiple_chunks(socket, response, body_array, use_chunked)
1047
1157
  if use_chunked
1048
- Request.socket_write(socket, response)
1158
+ socket_write(socket, response)
1049
1159
  body_array.each do |chunk|
1050
1160
  raise TypeError, "body must yield String values" unless chunk.is_a?(String)
1051
1161
 
1052
1162
  next if chunk.empty?
1053
1163
 
1054
- Request.socket_write(socket, "#{chunk.bytesize.to_s(16)}\r\n#{chunk}\r\n")
1164
+ socket_write(socket, "#{chunk.bytesize.to_s(16)}\r\n#{chunk}\r\n")
1055
1165
  end
1056
1166
  else
1057
1167
  body_array.each do |chunk|
1058
1168
  raise TypeError, "body must yield String values" unless chunk.is_a?(String)
1059
-
1060
- response << chunk
1061
1169
  end
1062
- Request.socket_write(socket, response)
1170
+ socket_writev(socket, [response, *body_array])
1063
1171
  end
1064
1172
  end
1065
1173
 
@@ -1075,13 +1183,13 @@ module Raptor
1075
1183
  # @rbs (TCPSocket socket, String response, untyped body, bool use_chunked) -> void
1076
1184
  def write_enumerable_body(socket, response, body, use_chunked)
1077
1185
  if use_chunked
1078
- Request.socket_write(socket, response)
1186
+ socket_write(socket, response)
1079
1187
  body.each do |chunk|
1080
1188
  raise TypeError, "body must yield String values" unless chunk.is_a?(String)
1081
1189
 
1082
1190
  next if chunk.empty?
1083
1191
 
1084
- Request.socket_write(socket, "#{chunk.bytesize.to_s(16)}\r\n#{chunk}\r\n")
1192
+ socket_write(socket, "#{chunk.bytesize.to_s(16)}\r\n#{chunk}\r\n")
1085
1193
  end
1086
1194
  else
1087
1195
  body.each do |chunk|
@@ -1089,7 +1197,7 @@ module Raptor
1089
1197
 
1090
1198
  response << chunk
1091
1199
  end
1092
- Request.socket_write(socket, response)
1200
+ socket_write(socket, response)
1093
1201
  end
1094
1202
  end
1095
1203
 
@@ -1127,16 +1235,10 @@ module Raptor
1127
1235
  headers.each do |name, value|
1128
1236
  next if illegal_header_key?(name)
1129
1237
 
1130
- if value.is_a?(Array)
1131
- value.each do |header_value|
1132
- next if illegal_header_value?(header_value.to_s)
1238
+ Array(value).flat_map { |entry| entry.to_s.split("\n") }.each do |header_value|
1239
+ next if illegal_header_value?(header_value)
1133
1240
 
1134
- result << "#{name}: #{header_value}\r\n"
1135
- end
1136
- else
1137
- next if illegal_header_value?(value.to_s)
1138
-
1139
- result << "#{name}: #{value}\r\n"
1241
+ result << "#{name}: #{header_value}\r\n"
1140
1242
  end
1141
1243
  end
1142
1244
  result
@@ -1162,6 +1264,33 @@ module Raptor
1162
1264
  end
1163
1265
  end
1164
1266
 
1267
+ # Instance-level wrapper around {Http.write_access_log} that routes to
1268
+ # the configured `@access_log_io`.
1269
+ #
1270
+ # @param env [Hash] the Rack environment
1271
+ # @param status [Integer] the response status code
1272
+ # @param size [String] the response body size in bytes, or `-` if unknown
1273
+ # @param remote_addr [String] the client IP address
1274
+ # @return [void]
1275
+ #
1276
+ # @rbs (Hash[String, untyped] env, Integer status, String size, String remote_addr) -> void
1277
+ def write_access_log(env, status, size, remote_addr)
1278
+ Http.write_access_log(@access_log_io, env, status, size, remote_addr)
1279
+ end
1280
+
1281
+ # Returns the response body size as a String for the access log, taken
1282
+ # from the `content-length` header when set, computed from the body
1283
+ # otherwise, or `-` when the size cannot be determined upfront.
1284
+ #
1285
+ # @param headers [Hash] the response headers
1286
+ # @param body [Object] the response body
1287
+ # @return [String]
1288
+ #
1289
+ # @rbs (Hash[String, String | Array[String]] headers, untyped body) -> String
1290
+ def response_size(headers, body)
1291
+ headers[Rack::CONTENT_LENGTH] || calculate_content_length(body)&.to_s || "-"
1292
+ end
1293
+
1165
1294
  if Socket.const_defined?(:TCP_CORK)
1166
1295
  # Enables TCP_CORK on the socket to batch outgoing packets into fewer segments.
1167
1296
  #