raptor 0.22.3 → 0.24.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.
Files changed (46) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +46 -0
  3. data/Dockerfile +10 -2
  4. data/README.md +183 -61
  5. data/docs/assets/profile_picture.png +0 -0
  6. data/docs/assets/startup_output.svg +37 -0
  7. data/docs/raptor-vs-puma.md +104 -424
  8. data/docs/talk.md +1388 -0
  9. data/ext/raptor_bpf/reuseport_select.bpf.c +18 -9
  10. data/ext/raptor_http/extconf.rb +2 -0
  11. data/ext/raptor_http/raptor_http.c +165 -234
  12. data/ext/raptor_http2/raptor_http2.c +272 -223
  13. data/ext/raptor_native/raptor_native.c +38 -20
  14. data/lib/rackup/handler/raptor.rb +14 -12
  15. data/lib/raptor/binder.rb +12 -12
  16. data/lib/raptor/cli.rb +19 -18
  17. data/lib/raptor/cluster.rb +120 -74
  18. data/lib/raptor/control_server.rb +5 -2
  19. data/lib/raptor/detached_body.rb +6 -2
  20. data/lib/raptor/http.rb +7 -6
  21. data/lib/raptor/http1.rb +281 -234
  22. data/lib/raptor/http2.rb +356 -276
  23. data/lib/raptor/plugin.rb +98 -0
  24. data/lib/raptor/reactor.rb +138 -148
  25. data/lib/raptor/reuseport_bpf.rb +62 -38
  26. data/lib/raptor/server.rb +45 -34
  27. data/lib/raptor/stats.rb +15 -3
  28. data/lib/raptor/thread_locals.rb +4 -3
  29. data/lib/raptor/version.rb +1 -1
  30. data/sig/generated/rackup/handler/raptor.rbs +2 -1
  31. data/sig/generated/raptor/binder.rbs +9 -9
  32. data/sig/generated/raptor/cli.rbs +6 -6
  33. data/sig/generated/raptor/cluster.rbs +18 -5
  34. data/sig/generated/raptor/control_server.rbs +2 -1
  35. data/sig/generated/raptor/detached_body.rbs +5 -1
  36. data/sig/generated/raptor/http.rbs +3 -5
  37. data/sig/generated/raptor/http1.rbs +119 -102
  38. data/sig/generated/raptor/http2.rbs +204 -190
  39. data/sig/generated/raptor/plugin.rbs +52 -0
  40. data/sig/generated/raptor/reactor.rbs +107 -118
  41. data/sig/generated/raptor/reuseport_bpf.rbs +54 -17
  42. data/sig/generated/raptor/server.rbs +26 -26
  43. data/sig/generated/raptor/stats.rbs +3 -2
  44. data/sig/generated/raptor/thread_locals.rbs +4 -3
  45. metadata +6 -2
  46. data/docs/brisrails-talk.md +0 -2398
data/lib/raptor/http1.rb CHANGED
@@ -11,6 +11,7 @@ require "rack"
11
11
  require_relative "detached_body"
12
12
  require_relative "http"
13
13
  require_relative "raptor_http"
14
+ require_relative "server"
14
15
  require_relative "thread_locals"
15
16
 
16
17
  module Raptor
@@ -29,29 +30,33 @@ module Raptor
29
30
 
30
31
  HTTP_10 = "HTTP/1.0"
31
32
  HTTP_11 = "HTTP/1.1"
32
- STATUS_LINE_CACHE_10 = Hash.new do |h, status|
33
+ STATUS_LINE_CACHE_10 = Hash.new do |cache, status|
33
34
  reason = Rack::Utils::HTTP_STATUS_CODES[status]
34
- h[status] = "HTTP/1.0 #{status}#{reason ? " #{reason}" : ""}\r\n".freeze
35
+ cache[status] = "#{HTTP_10} #{status}#{reason ? " #{reason}" : ""}\r\n".freeze
35
36
  end
36
- STATUS_LINE_CACHE_11 = Hash.new do |h, status|
37
+ STATUS_LINE_CACHE_11 = Hash.new do |cache, status|
37
38
  reason = Rack::Utils::HTTP_STATUS_CODES[status]
38
- h[status] = "HTTP/1.1 #{status}#{reason ? " #{reason}" : ""}\r\n".freeze
39
+ cache[status] = "#{HTTP_11} #{status}#{reason ? " #{reason}" : ""}\r\n".freeze
39
40
  end
40
41
 
41
42
  CONTINUE_RESPONSE = "HTTP/1.1 100 Continue\r\n\r\n"
42
43
  BAD_REQUEST_RESPONSE = "HTTP/1.1 400 Bad Request\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
43
44
  CONTENT_TOO_LARGE_RESPONSE = "HTTP/1.1 413 Content Too Large\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
44
45
  INTERNAL_SERVER_ERROR_RESPONSE = "HTTP/1.1 500 Internal Server Error\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
46
+ NOT_IMPLEMENTED_RESPONSE = "HTTP/1.1 501 Not Implemented\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
47
+ CHUNKED_TERMINATOR = "0\r\n\r\n"
45
48
 
46
49
  CONNECTION_CLOSE = "close"
47
50
  CONNECTION_KEEPALIVE = "keep-alive"
48
51
  EXPECT_100_CONTINUE = "100-continue"
49
52
  TRANSFER_ENCODING_CHUNKED = "chunked"
50
- CHUNKED_TERMINATOR = "0\r\n\r\n"
51
53
 
52
54
  HTTP_CONNECTION = "HTTP_CONNECTION"
53
55
  HTTP_EXPECT = "HTTP_EXPECT"
54
56
  HTTP_TRANSFER_ENCODING = "HTTP_TRANSFER_ENCODING"
57
+ HTTP_X_FORWARDED_PROTO = "HTTP_X_FORWARDED_PROTO"
58
+ HTTP_X_FORWARDED_SCHEME = "HTTP_X_FORWARDED_SCHEME"
59
+ HTTP_X_FORWARDED_SSL = "HTTP_X_FORWARDED_SSL"
55
60
  RACK_HEADER_PREFIX = "rack."
56
61
  RACK_HIJACKED = "rack.hijacked"
57
62
  RACK_HIJACK_IO = "rack.hijack_io"
@@ -71,31 +76,40 @@ module Raptor
71
76
  !http_host || http_host.empty? || http_host.include?(",")
72
77
  end
73
78
 
74
- # Returns true when the message framing shows a request-smuggling vector
75
- # per RFC 9112 section 6.3: a `Transfer-Encoding` where `chunked` is
76
- # missing, not the final encoding, or duplicated; a `Transfer-Encoding`
77
- # paired with a `Content-Length`; or a `Content-Length` containing any
78
- # non-digit character.
79
+ # Rejects ambiguous message framing per RFC 9112 section 6.3 and
80
+ # transfer codings other than a single `chunked`.
79
81
  #
80
82
  # @param env [Hash] the Rack environment after header parsing
81
- # @return [Boolean]
83
+ # @return [Symbol, nil] `:malformed`, `:unsupported_encoding`, or nil
82
84
  #
83
- # @rbs (Hash[String, untyped] env) -> bool
84
- def self.request_smuggling?(env)
85
+ # @rbs (Hash[String, untyped] env) -> Symbol?
86
+ def self.framing_error(env)
85
87
  transfer_encoding = env[HTTP_TRANSFER_ENCODING]
86
88
  content_length = env[Http::CONTENT_LENGTH]
87
89
 
88
90
  if transfer_encoding
89
- return true if content_length
91
+ return :malformed if content_length
90
92
 
91
- encodings = transfer_encoding.downcase.split(",").map(&:strip)
92
- return true if encodings.last != TRANSFER_ENCODING_CHUNKED
93
- return true if encodings.count(TRANSFER_ENCODING_CHUNKED) > 1
93
+ encodings = transfer_encoding.downcase.split(",", -1).map(&:strip)
94
+ return :malformed if encodings.any?(&:empty?) || encodings.last != TRANSFER_ENCODING_CHUNKED
95
+ return :malformed if encodings.count(TRANSFER_ENCODING_CHUNKED) > 1
96
+ return :unsupported_encoding if encodings.size > 1
94
97
  elsif content_length
95
- return true if content_length.match?(/[^\d]/)
98
+ return :malformed unless content_length.match?(/\A[0-9]+\z/)
96
99
  end
97
100
 
98
- false
101
+ nil
102
+ end
103
+
104
+ # Returns any pipelined bytes following the request, or nil if none remain.
105
+ #
106
+ # @param buffer [String] the raw request bytes
107
+ # @param offset [Integer] the byte offset where the request ends
108
+ # @return [String, nil]
109
+ #
110
+ # @rbs (String buffer, Integer offset) -> String?
111
+ def self.pipelined_bytes(buffer, offset)
112
+ buffer.byteslice(offset..-1) if offset < buffer.bytesize
99
113
  end
100
114
 
101
115
  # Encodes one HTTP/1.1 response chunk.
@@ -128,9 +142,10 @@ module Raptor
128
142
 
129
143
  # Advances an HTTP/1.x request parse from the state hash's buffered
130
144
  # bytes. A complete well-formed request returns with `:complete` set
131
- # plus populated `:env` and `:body`; malformed or oversized input flips
132
- # `:malformed` or `:too_large`; incomplete input returns the state
133
- # so the reactor can wait for more.
145
+ # plus populated `:env` and `:body`, and records any bytes after it in
146
+ # the parse data's `:pipelined`. Rejected input sets `:malformed`,
147
+ # `:too_large`, or `:unsupported_encoding`; incomplete input returns
148
+ # the state so the reactor can wait for more.
134
149
  #
135
150
  # @param data [Hash] the current parse state
136
151
  # @param env_template [Hash] the Rack env template to seed the request with
@@ -139,11 +154,11 @@ module Raptor
139
154
  #
140
155
  # @rbs (Hash[Symbol, untyped] data, Hash[String, untyped] env_template, Integer? max_body_size) -> Hash[Symbol, untyped]
141
156
  def self.parse(data, env_template, max_body_size)
142
- parser = Raptor::HttpParser.new
157
+ parser = HttpParser.new
143
158
  env = env_template.dup
144
159
  nread = begin
145
160
  parser.execute(env, data[:buffer], 0)
146
- rescue Raptor::HttpParserError
161
+ rescue HttpParserError
147
162
  return Ractor.make_shareable(data.merge(complete: true, malformed: true))
148
163
  end
149
164
  parse_data = if data[:parse_data]
@@ -154,19 +169,21 @@ module Raptor
154
169
  parse_data[:parse_count] += 1
155
170
 
156
171
  message = if parser.finished?
157
- if invalid_host?(env) || request_smuggling?(env)
158
- data.merge(env: env, body: nil, parse_data: parse_data, complete: true, malformed: true)
172
+ error = invalid_host?(env) ? :malformed : framing_error(env)
173
+ if error
174
+ data.merge(env: env, body: nil, parse_data: parse_data, complete: true, error => true)
159
175
  elsif parser.has_body?
160
176
  body_buffer = data[:buffer].byteslice(nread..-1) || ""
161
177
 
162
178
  if max_body_size && parser.content_length > max_body_size
163
179
  data.merge(env: env, body: nil, parse_data: parse_data, complete: true, too_large: true)
164
180
  elsif parser.chunked?
165
- decoded_body, chunked_state = HttpParser.decode_chunked(body_buffer, max_body_size)
181
+ decoded_body, chunked_state, consumed = HttpParser.decode_chunked(body_buffer, max_body_size)
166
182
 
167
183
  case chunked_state
168
184
  when :complete
169
185
  env.delete(HTTP_TRANSFER_ENCODING)
186
+ parse_data[:pipelined] = pipelined_bytes(body_buffer, consumed)
170
187
  data.merge(env: env, body: decoded_body, parse_data: parse_data, complete: true)
171
188
  when :too_large
172
189
  data.merge(env: env, body: nil, parse_data: parse_data, complete: true, too_large: true)
@@ -178,9 +195,11 @@ module Raptor
178
195
  elsif parser.content_length > body_buffer.bytesize
179
196
  data.merge(env: env, parse_data: parse_data)
180
197
  else
181
- data.merge(env: env, body: body_buffer, parse_data: parse_data, complete: true)
198
+ parse_data[:pipelined] = pipelined_bytes(body_buffer, parser.content_length)
199
+ data.merge(env: env, body: body_buffer.byteslice(0, parser.content_length), parse_data: parse_data, complete: true)
182
200
  end
183
201
  else
202
+ parse_data[:pipelined] = pipelined_bytes(data[:buffer], nread)
184
203
  data.merge(env: env, body: nil, parse_data: parse_data, complete: true)
185
204
  end
186
205
  else
@@ -190,7 +209,6 @@ module Raptor
190
209
  end
191
210
 
192
211
  # @rbs @app: ^(Hash[String, untyped]) -> [Integer, Hash[String, String | Array[String]], untyped]
193
- # @rbs @server_port: Integer
194
212
  # @rbs @server_port_string: String
195
213
  # @rbs @write_timeout: Integer
196
214
  # @rbs @max_body_size: Integer?
@@ -199,7 +217,7 @@ module Raptor
199
217
  # @rbs @access_log_io: IO?
200
218
  # @rbs @clean_thread_locals: bool
201
219
  # @rbs @clean_fiber_locals: bool
202
- # @rbs @on_error: ^(Hash[String, untyped]?, Exception) -> void | nil
220
+ # @rbs @on_error: (^(Hash[String, untyped]?, Exception) -> void)?
203
221
  # @rbs @running: AtomicBoolean
204
222
  # @rbs @env_template: Hash[String, untyped]
205
223
 
@@ -219,10 +237,9 @@ module Raptor
219
237
  # @param on_error [#call, nil] callback invoked with (env, exception) when the Rack app raises
220
238
  # @return [void]
221
239
  #
222
- # @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?, ?clean_thread_locals: bool, ?clean_fiber_locals: bool, ?on_error: ^(Hash[String, untyped]?, Exception) -> void | nil) -> void
240
+ # @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?, ?clean_thread_locals: bool, ?clean_fiber_locals: bool, ?on_error: (^(Hash[String, untyped]?, Exception) -> void)?) -> void
223
241
  def initialize(app, server_port, connection_options: {}, http1_options: {}, access_log_io: nil, clean_thread_locals: true, clean_fiber_locals: false, on_error: nil)
224
242
  @app = app
225
- @server_port = server_port
226
243
  @server_port_string = server_port.to_s.freeze
227
244
  @write_timeout = connection_options[:write_timeout] || Http::WRITE_TIMEOUT
228
245
  @max_body_size = connection_options[:max_body_size]
@@ -242,8 +259,7 @@ module Raptor
242
259
  }.freeze
243
260
  end
244
261
 
245
- # Instance-level wrapper around {Http.parser_worker} that binds this
246
- # handler's env template and body-size limit into the worker proc.
262
+ # Returns a Ractor-safe proc that parses HTTP/1.x requests.
247
263
  #
248
264
  # @return [Proc]
249
265
  #
@@ -252,32 +268,6 @@ module Raptor
252
268
  Http.parser_worker(@env_template, @max_body_size)
253
269
  end
254
270
 
255
- # Instance-level wrapper around {Http.socket_write} that applies the
256
- # configured `write_timeout`.
257
- #
258
- # @param socket [TCPSocket] the socket to write to
259
- # @param string [String] the data to write
260
- # @return [void]
261
- # @raise [Http::WriteError] if the socket is not writable within the timeout or raises IOError
262
- #
263
- # @rbs (TCPSocket socket, String string) -> void
264
- def socket_write(socket, string)
265
- Http.socket_write(socket, string, timeout: @write_timeout)
266
- end
267
-
268
- # Instance-level wrapper around {Http.socket_writev} that applies the
269
- # configured `write_timeout`.
270
- #
271
- # @param socket [TCPSocket] the socket to write to
272
- # @param strings [Array<String>] the buffers to write in order
273
- # @return [void]
274
- # @raise [Http::WriteError] if the socket is not writable within the timeout or raises IOError
275
- #
276
- # @rbs (TCPSocket socket, Array[String] strings) -> void
277
- def socket_writev(socket, strings)
278
- Http.socket_writev(socket, strings, timeout: @write_timeout)
279
- end
280
-
281
271
  # Signals eager keep-alive loops to stop processing further requests on
282
272
  # their connections. In-flight requests complete normally.
283
273
  #
@@ -293,10 +283,10 @@ module Raptor
293
283
  # when complete. Falls back to the reactor when more data is needed.
294
284
  #
295
285
  # @param socket [TCPSocket] the freshly accepted client socket
296
- # @param id [Integer] unique client identifier
286
+ # @param id [Integer] unique connection identifier
297
287
  # @param reactor [Reactor] the reactor for fallback registration
298
288
  # @param thread_pool [AtomicThreadPool] thread pool for application processing
299
- # @param remote_addr [String] client IP address
289
+ # @param remote_addr [String] the client IP address
300
290
  # @param url_scheme [String] "http" or "https"
301
291
  # @return [void]
302
292
  #
@@ -319,18 +309,12 @@ module Raptor
319
309
  return
320
310
  end
321
311
 
322
- if !parser.finished?
312
+ unless parser.finished?
323
313
  fallback_to_reactor(socket, id, buffer, env, parse_data, reactor, 0, remote_addr, url_scheme, persisted: false)
324
314
  return
325
- elsif Http1.invalid_host?(env) || Http1.request_smuggling?(env)
326
- reject_malformed(socket)
327
- return
328
- elsif parser.has_body? && @max_body_size && parser.content_length > @max_body_size
329
- reject_oversized(socket)
330
- return
331
315
  end
332
316
 
333
- body = extract_body(buffer, env, parser, nread, decode_chunked: true)
317
+ body = extract_body(buffer, env, parse_data, parser, nread)
334
318
  case body
335
319
  when :incomplete
336
320
  fallback_to_reactor(socket, id, buffer, env, parse_data, reactor, 0, remote_addr, url_scheme, persisted: false)
@@ -341,6 +325,9 @@ module Raptor
341
325
  when :malformed
342
326
  reject_malformed(socket)
343
327
  return
328
+ when :unsupported_encoding
329
+ reject_unsupported_encoding(socket)
330
+ return
344
331
  end
345
332
 
346
333
  thread_pool << proc do
@@ -370,10 +357,13 @@ module Raptor
370
357
  return
371
358
  end
372
359
 
373
- unless parsed_request[:complete]
374
- parsed_request = send_continue_if_expected(parsed_request, reactor)
375
- reactor.update_state(parsed_request)
376
- else
360
+ if parsed_request[:unsupported_encoding]
361
+ socket = reactor.remove(parsed_request[:id])
362
+ reject_unsupported_encoding(socket) if socket
363
+ return
364
+ end
365
+
366
+ if parsed_request[:complete]
377
367
  socket = reactor.remove(parsed_request[:id])
378
368
  request_count = (parsed_request[:request_count] || 0) + 1
379
369
  remote_addr = parsed_request[:remote_addr] || Server::DEFAULT_REMOTE_ADDR
@@ -393,6 +383,115 @@ module Raptor
393
383
  url_scheme
394
384
  )
395
385
  end
386
+ else
387
+ parsed_request = send_continue_if_expected(parsed_request, reactor)
388
+ reactor.update_state(parsed_request)
389
+ end
390
+ end
391
+
392
+ # Processes kept-alive requests on application threads, yielding to queued
393
+ # work and returning idle or incomplete connections to the reactor.
394
+ #
395
+ # @param socket [TCPSocket] the client socket
396
+ # @param id [Integer] unique connection identifier
397
+ # @param reactor [Reactor] the reactor for fallback registration
398
+ # @param thread_pool [AtomicThreadPool] thread pool for application processing
399
+ # @param request_count [Integer] number of requests handled on this connection
400
+ # @param remote_addr [String] the client IP address
401
+ # @param url_scheme [String] "http" or "https"
402
+ # @param resumed [Boolean] whether the reactor queued the connection once its next request became readable
403
+ # @param pipelined [String, nil] bytes the client sent after the previous request
404
+ # @return [void]
405
+ #
406
+ # @rbs (TCPSocket socket, Integer id, Reactor reactor, AtomicThreadPool thread_pool, Integer request_count, String remote_addr, String url_scheme, ?resumed: bool, ?pipelined: String?) -> void
407
+ def eager_keepalive(socket, id, reactor, thread_pool, request_count, remote_addr, url_scheme, resumed: false, pipelined: nil)
408
+ loop do
409
+ unless @running.true?
410
+ socket.close rescue nil
411
+ return
412
+ end
413
+
414
+ if pipelined
415
+ buffer = pipelined
416
+ else
417
+ unless resumed || socket.wait_readable(0)
418
+ reactor.persist(socket, id, request_count, remote_addr: remote_addr, url_scheme: url_scheme)
419
+ return
420
+ end
421
+
422
+ begin
423
+ buffer = read_into_thread_buffer(socket)
424
+ rescue IO::WaitReadable
425
+ reactor.persist(socket, id, request_count, remote_addr: remote_addr, url_scheme: url_scheme)
426
+ return
427
+ rescue EOFError, IOError, SystemCallError
428
+ socket.close rescue nil
429
+ return
430
+ end
431
+ end
432
+
433
+ env, parse_data, nread, parser = begin
434
+ parse_next_request(buffer)
435
+ rescue HttpParserError
436
+ reject_malformed(socket)
437
+ return
438
+ end
439
+
440
+ unless parser.finished?
441
+ fallback_to_reactor(socket, id, buffer, env, parse_data, reactor, request_count, remote_addr, url_scheme)
442
+ return
443
+ end
444
+
445
+ body = extract_body(buffer, env, parse_data, parser, nread)
446
+ case body
447
+ when :incomplete
448
+ fallback_to_reactor(socket, id, buffer, env, parse_data, reactor, request_count, remote_addr, url_scheme)
449
+ return
450
+ when :too_large
451
+ reject_oversized(socket)
452
+ return
453
+ when :malformed
454
+ reject_malformed(socket)
455
+ return
456
+ when :unsupported_encoding
457
+ reject_unsupported_encoding(socket)
458
+ return
459
+ end
460
+
461
+ request_count += 1
462
+
463
+ if !resumed && thread_pool.queue_size.positive?
464
+ thread_pool << proc do
465
+ process_client(
466
+ socket,
467
+ id,
468
+ env,
469
+ parse_data,
470
+ body,
471
+ reactor,
472
+ thread_pool,
473
+ request_count,
474
+ remote_addr,
475
+ url_scheme
476
+ )
477
+ end
478
+ return
479
+ end
480
+
481
+ resumed = false
482
+ pipelined = parse_data[:pipelined]
483
+ keep_alive = process_request(
484
+ socket,
485
+ id,
486
+ env,
487
+ parse_data,
488
+ body,
489
+ reactor,
490
+ request_count,
491
+ remote_addr,
492
+ url_scheme
493
+ )
494
+ return unless keep_alive
396
495
  end
397
496
  end
398
497
 
@@ -400,11 +499,12 @@ module Raptor
400
499
 
401
500
  # Reads pending bytes off `socket` into the thread-local read buffer,
402
501
  # draining any additional SSL-buffered bytes so `pending` is empty on
403
- # return. Raises `IO::WaitReadable`, `EOFError`, or `IOError` like the
404
- # underlying `read_nonblock` does.
502
+ # return.
405
503
  #
406
504
  # @param socket [TCPSocket] the socket to read from
407
505
  # @return [String] the thread-local buffer, freshly populated
506
+ # @raise [IO::WaitReadable] if no bytes are ready to read
507
+ # @raise [EOFError, IOError, SystemCallError] if the connection is closed or fails
408
508
  #
409
509
  # @rbs (TCPSocket socket) -> String
410
510
  def read_into_thread_buffer(socket)
@@ -418,12 +518,11 @@ module Raptor
418
518
  buffer
419
519
  end
420
520
 
421
- # Runs a fresh HTTP/1.x parse against `buffer`, returning
422
- # `[env, parse_data, nread, parser]`. Raises `HttpParserError` on
423
- # malformed input.
521
+ # Runs a fresh HTTP/1.x parse against `buffer`.
424
522
  #
425
523
  # @param buffer [String] the raw request bytes
426
- # @return [Array(Hash, Hash, Integer, HttpParser)]
524
+ # @return [Array(Hash, Hash, Integer, HttpParser)] the env, parse data, bytes read, and parser
525
+ # @raise [HttpParserError] if the request is malformed
427
526
  #
428
527
  # @rbs (String buffer) -> [Hash[String, untyped], Hash[Symbol, untyped], Integer, HttpParser]
429
528
  def parse_next_request(buffer)
@@ -435,51 +534,55 @@ module Raptor
435
534
  [env, parse_data, nread, parser]
436
535
  end
437
536
 
438
- # Resolves the request body for a finished parse. Returns the body
439
- # `String` (or `nil` when the request has no body), or one of
440
- # `:incomplete`, `:too_large`, `:malformed` when the caller must
441
- # fall back or reject.
537
+ # Validates request framing and extracts the body, preserving pipelined bytes.
442
538
  #
443
539
  # @param buffer [String] the raw request bytes
444
540
  # @param env [Hash] the Rack environment being built
541
+ # @param parse_data [Hash] metadata from the parsing pass
445
542
  # @param parser [HttpParser] the parser holding the finished parse state
446
543
  # @param nread [Integer] the byte offset where the body begins in `buffer`
447
- # @param decode_chunked [Boolean] whether to decode chunked bodies inline; when false chunked bodies signal `:incomplete`
448
- # @return [String, nil, Symbol]
544
+ # @return [String, nil, Symbol] body, nil when absent, or `:incomplete`,
545
+ # `:too_large`, `:malformed`, or `:unsupported_encoding`
449
546
  #
450
- # @rbs (String buffer, Hash[String, untyped] env, HttpParser parser, Integer nread, decode_chunked: bool) -> (String | Symbol)?
451
- def extract_body(buffer, env, parser, nread, decode_chunked:)
452
- return unless parser.has_body?
547
+ # @rbs (String buffer, Hash[String, untyped] env, Hash[Symbol, untyped] parse_data, HttpParser parser, Integer nread) -> (String | Symbol)?
548
+ def extract_body(buffer, env, parse_data, parser, nread)
549
+ return :malformed if Http1.invalid_host?(env)
550
+ error = Http1.framing_error(env)
551
+ return error if error
552
+
553
+ unless parser.has_body?
554
+ parse_data[:pipelined] = Http1.pipelined_bytes(buffer, nread)
555
+ return
556
+ end
453
557
 
454
- body = buffer.byteslice(nread..-1) || ""
558
+ return :too_large if @max_body_size && parser.content_length > @max_body_size
455
559
 
456
560
  if parser.chunked?
457
- return :incomplete unless decode_chunked
561
+ body, chunked_state, consumed = HttpParser.decode_chunked(buffer.byteslice(nread..-1), @max_body_size)
562
+ return chunked_state unless chunked_state == :complete
458
563
 
459
- body, chunked_state = HttpParser.decode_chunked(body, @max_body_size)
460
- case chunked_state
461
- when :complete
462
- env.delete(HTTP_TRANSFER_ENCODING)
463
- body
464
- else
465
- chunked_state
466
- end
467
- elsif parser.content_length > body.bytesize
564
+ env.delete(HTTP_TRANSFER_ENCODING)
565
+ parse_data[:pipelined] = Http1.pipelined_bytes(buffer, nread + consumed)
566
+ body
567
+ elsif nread + parser.content_length > buffer.bytesize
468
568
  :incomplete
469
569
  else
470
- body
570
+ parse_data[:pipelined] = Http1.pipelined_bytes(buffer, nread + parser.content_length)
571
+ buffer.byteslice(nread, parser.content_length)
471
572
  end
472
573
  end
473
574
 
474
- # Returns true if the request expects a 100 Continue response per
475
- # RFC 7231 section 5.1.1.
575
+ # Returns true when the request expects a 100 Continue response per
576
+ # RFC 9110 section 10.1.1.
476
577
  #
477
578
  # @param env [Hash] the parsed Rack environment (possibly incomplete)
478
579
  # @return [Boolean]
479
580
  #
480
581
  # @rbs (Hash[String, untyped] env) -> bool
481
582
  def expects_100_continue?(env)
482
- (env[Rack::SERVER_PROTOCOL] == HTTP_11) && env[HTTP_EXPECT]&.casecmp?(EXPECT_100_CONTINUE)
583
+ return false unless env[Rack::SERVER_PROTOCOL] == HTTP_11
584
+
585
+ env[HTTP_EXPECT]&.casecmp?(EXPECT_100_CONTINUE) || false
483
586
  end
484
587
 
485
588
  # Sends an HTTP 100 Continue response when the client requested
@@ -508,35 +611,35 @@ module Raptor
508
611
  # if keep-alive, eagerly reading subsequent requests inline.
509
612
  #
510
613
  # @param socket [TCPSocket] the client socket
511
- # @param id [Integer] unique client identifier
614
+ # @param id [Integer] unique connection identifier
512
615
  # @param env [Hash] partial env hash from the HTTP parser
513
616
  # @param parse_data [Hash] metadata from the parsing pass
514
617
  # @param body [String, nil] decoded request body
515
618
  # @param reactor [Reactor] the reactor managing the client connection
516
619
  # @param thread_pool [AtomicThreadPool] thread pool for application processing
517
620
  # @param request_count [Integer] number of requests handled on this connection
518
- # @param remote_addr [String] client IP address
621
+ # @param remote_addr [String] the client IP address
519
622
  # @param url_scheme [String] "http" or "https"
520
623
  # @return [void]
521
624
  #
522
625
  # @rbs (TCPSocket socket, Integer id, Hash[String, untyped] env, Hash[Symbol, untyped] parse_data, String? body, Reactor reactor, AtomicThreadPool thread_pool, Integer request_count, String remote_addr, String url_scheme) -> void
523
626
  def process_client(socket, id, env, parse_data, body, reactor, thread_pool, request_count, remote_addr, url_scheme)
524
627
  keep_alive = process_request(socket, id, env, parse_data, body, reactor, request_count, remote_addr, url_scheme)
525
- eager_keepalive(socket, id, reactor, thread_pool, request_count, remote_addr, url_scheme) if keep_alive
628
+ eager_keepalive(socket, id, reactor, thread_pool, request_count, remote_addr, url_scheme, pipelined: parse_data[:pipelined]) if keep_alive
526
629
  end
527
630
 
528
- # Processes a single request. Builds the Rack env, calls the app,
529
- # writes the response, and returns whether the connection stays open
530
- # for another request.
631
+ # Processes a single request, isolating fiber and thread locals as
632
+ # configured, and returns whether the connection stays open for another
633
+ # request.
531
634
  #
532
635
  # @param socket [TCPSocket] the client socket
533
- # @param id [Integer] unique client identifier
636
+ # @param id [Integer] unique connection identifier
534
637
  # @param env [Hash] partial env hash from the HTTP parser
535
638
  # @param parse_data [Hash] metadata from the parsing pass
536
639
  # @param body [String, nil] decoded request body
537
640
  # @param reactor [Reactor] the reactor managing the client connection
538
641
  # @param request_count [Integer] number of requests handled on this connection
539
- # @param remote_addr [String] client IP address
642
+ # @param remote_addr [String] the client IP address
540
643
  # @param url_scheme [String] "http" or "https"
541
644
  # @return [Boolean] true if the connection should be kept alive
542
645
  #
@@ -554,13 +657,13 @@ module Raptor
554
657
  # Calls the Rack app and writes its response for one request.
555
658
  #
556
659
  # @param socket [TCPSocket] the client socket
557
- # @param id [Integer] unique client identifier
660
+ # @param id [Integer] unique connection identifier
558
661
  # @param env [Hash] partial env hash from the HTTP parser
559
662
  # @param parse_data [Hash] metadata from the parsing pass
560
663
  # @param body [String, nil] decoded request body
561
664
  # @param reactor [Reactor] the reactor managing the client connection
562
665
  # @param request_count [Integer] number of requests handled on this connection
563
- # @param remote_addr [String] client IP address
666
+ # @param remote_addr [String] the client IP address
564
667
  # @param url_scheme [String] "http" or "https"
565
668
  # @return [Boolean] true if the connection should be kept alive
566
669
  #
@@ -599,7 +702,14 @@ module Raptor
599
702
  socket,
600
703
  id,
601
704
  body,
602
- { chunked: chunked, keep_alive: keep_alive, request_count: request_count, remote_addr: remote_addr, url_scheme: url_scheme },
705
+ {
706
+ chunked: chunked,
707
+ keep_alive: keep_alive,
708
+ request_count: request_count,
709
+ remote_addr: remote_addr,
710
+ url_scheme: url_scheme,
711
+ pipelined: parse_data[:pipelined]
712
+ },
603
713
  finished
604
714
  )
605
715
  else
@@ -637,14 +747,14 @@ module Raptor
637
747
  #
638
748
  # @param socket [TCPSocket] the client socket
639
749
  # @param rack_env [Hash, nil] the Rack environment, if it was built
640
- # @param status [Integer, nil] the status returned by the app, if any
750
+ # @param status [Object, nil] the status returned by the app, if any
641
751
  # @param headers [Hash, nil] the headers returned by the app, if any
642
752
  # @param error [Exception] the exception raised
643
753
  # @param response_started [Boolean] whether any response bytes have been written
644
754
  # @param hijacked [Boolean] whether the app took over the socket
645
755
  # @return [void]
646
756
  #
647
- # @rbs (TCPSocket socket, Hash[String, untyped]? rack_env, Integer? status, Hash[String, String | Array[String]]? headers, Exception error, response_started: bool, hijacked: bool) -> void
757
+ # @rbs (TCPSocket socket, Hash[String, untyped]? rack_env, untyped status, Hash[String, String | Array[String]]? headers, Exception error, response_started: bool, hijacked: bool) -> void
648
758
  def handle_app_error(socket, rack_env, status, headers, error, response_started:, hijacked:)
649
759
  Http.call_response_finished(rack_env, status, headers, error)
650
760
  socket.write(INTERNAL_SERVER_ERROR_RESPONSE) rescue nil unless response_started || hijacked
@@ -656,112 +766,22 @@ module Raptor
656
766
  end
657
767
  end
658
768
 
659
- # Reads and processes subsequent requests inline on a kept-alive
660
- # connection. Returns the connection to the reactor when no data is ready
661
- # or a request is incomplete, and queues complete requests behind work
662
- # that is already waiting.
663
- #
664
- # @param socket [TCPSocket] the client socket
665
- # @param id [Integer] unique client identifier
666
- # @param reactor [Reactor] the reactor for fallback registration
667
- # @param thread_pool [AtomicThreadPool] thread pool for deprioritization
668
- # @param request_count [Integer] number of requests handled on this connection
669
- # @param remote_addr [String] client IP address
670
- # @param url_scheme [String] "http" or "https"
671
- # @return [void]
672
- #
673
- # @rbs (TCPSocket socket, Integer id, Reactor reactor, AtomicThreadPool thread_pool, Integer request_count, String remote_addr, String url_scheme) -> void
674
- def eager_keepalive(socket, id, reactor, thread_pool, request_count, remote_addr, url_scheme)
675
- loop do
676
- unless @running.true?
677
- socket.close rescue nil
678
- return
679
- end
680
-
681
- unless socket.wait_readable(0)
682
- reactor.persist(socket, id, request_count, remote_addr: remote_addr, url_scheme: url_scheme)
683
- return
684
- end
685
-
686
- begin
687
- buffer = read_into_thread_buffer(socket)
688
- rescue IO::WaitReadable
689
- reactor.persist(socket, id, request_count, remote_addr: remote_addr, url_scheme: url_scheme)
690
- return
691
- rescue EOFError, IOError, SystemCallError
692
- socket.close rescue nil
693
- return
694
- end
695
-
696
- env, parse_data, nread, parser = begin
697
- parse_next_request(buffer)
698
- rescue HttpParserError
699
- reject_malformed(socket)
700
- return
701
- end
702
-
703
- if !parser.finished?
704
- fallback_to_reactor(socket, id, buffer, env, parse_data, reactor, request_count, remote_addr, url_scheme)
705
- return
706
- end
707
-
708
- body = extract_body(buffer, env, parser, nread, decode_chunked: false)
709
- if body == :incomplete
710
- fallback_to_reactor(socket, id, buffer, env, parse_data, reactor, request_count, remote_addr, url_scheme)
711
- return
712
- end
713
-
714
- request_count += 1
715
-
716
- if thread_pool.queue_size.positive?
717
- thread_pool << proc do
718
- process_client(
719
- socket,
720
- id,
721
- env,
722
- parse_data,
723
- body,
724
- reactor,
725
- thread_pool,
726
- request_count,
727
- remote_addr,
728
- url_scheme
729
- )
730
- end
731
- return
732
- end
733
-
734
- keep_alive = process_request(
735
- socket,
736
- id,
737
- env,
738
- parse_data,
739
- body,
740
- reactor,
741
- request_count,
742
- remote_addr,
743
- url_scheme
744
- )
745
- return unless keep_alive
746
- end
747
- end
748
-
749
769
  # Re-registers a socket with the reactor for further processing when
750
770
  # an incomplete request is received on the fast path.
751
771
  #
752
772
  # @param socket [TCPSocket] the client socket
753
- # @param id [Integer] unique client identifier
773
+ # @param id [Integer] unique connection identifier
754
774
  # @param buffer [String] the partial request data already read
755
775
  # @param env [Hash] partial env hash from the HTTP parser
756
776
  # @param parse_data [Hash] metadata from the parsing pass
757
777
  # @param reactor [Reactor] the reactor to re-register with
758
778
  # @param request_count [Integer] number of requests handled on this connection
759
- # @param remote_addr [String] client IP address
779
+ # @param remote_addr [String] the client IP address
760
780
  # @param url_scheme [String] "http" or "https"
761
781
  # @param persisted [Boolean] whether the connection has already completed at least one request
762
782
  # @return [void]
763
783
  #
764
- # @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
784
+ # @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
765
785
  def fallback_to_reactor(socket, id, buffer, env, parse_data, reactor, request_count, remote_addr, url_scheme, persisted: true)
766
786
  continued = expects_100_continue?(env)
767
787
  socket_write(socket, CONTINUE_RESPONSE) rescue nil if continued
@@ -803,13 +823,24 @@ module Raptor
803
823
  socket.close rescue nil
804
824
  end
805
825
 
826
+ # Writes a 501 response and closes the socket.
827
+ #
828
+ # @param socket [TCPSocket] the client socket
829
+ # @return [void]
830
+ #
831
+ # @rbs (TCPSocket socket) -> void
832
+ def reject_unsupported_encoding(socket)
833
+ socket.write(NOT_IMPLEMENTED_RESPONSE) rescue nil
834
+ socket.close rescue nil
835
+ end
836
+
806
837
  # Builds a Rack environment hash from parsed HTTP request data.
807
838
  #
808
839
  # @param env [Hash] partial env hash from the HTTP parser
809
840
  # @param parse_data [Hash] metadata from the parsing pass, including content_length
810
841
  # @param body [String, nil] decoded request body, or nil if no body
811
842
  # @param socket [TCPSocket] the client socket, used for hijack support
812
- # @param remote_addr [String] client IP address
843
+ # @param remote_addr [String] the client IP address
813
844
  # @param url_scheme [String] "http" or "https"
814
845
  # @return [Hash] fully populated Rack environment hash
815
846
  #
@@ -833,7 +864,7 @@ module Raptor
833
864
  if scheme_end
834
865
  authority_end = request_uri.index("/", scheme_end + 3) || request_uri.bytesize
835
866
  path_and_query = request_uri.byteslice(authority_end..-1) || ""
836
- if query_delim = path_and_query.index("?")
867
+ if (query_delim = path_and_query.index("?"))
837
868
  env[Rack::PATH_INFO] = query_delim.zero? ? "/" : path_and_query.byteslice(0, query_delim)
838
869
  env[Rack::QUERY_STRING] = path_and_query.byteslice(query_delim + 1..-1)
839
870
  else
@@ -849,7 +880,6 @@ module Raptor
849
880
  end
850
881
 
851
882
  env[Http::REMOTE_ADDR] = remote_addr
852
- env[Http::HTTP_VERSION] = env[Rack::SERVER_PROTOCOL]
853
883
 
854
884
  behind_tls_proxy = (url_scheme == Server::HTTP_SCHEME) && forwarded_https?(env)
855
885
  env[Rack::RACK_URL_SCHEME] = behind_tls_proxy ? Server::HTTPS_SCHEME : url_scheme
@@ -884,7 +914,8 @@ module Raptor
884
914
 
885
915
  # Builds the `rack.input` IO object for the request body. Returns an
886
916
  # in-memory StringIO for bodies up to the spool threshold, or a Tempfile
887
- # for larger bodies to bound per-worker memory.
917
+ # for larger bodies. The complete body is already buffered at this point,
918
+ # so spooling does not bound peak receive memory.
888
919
  #
889
920
  # @param body [String, nil] decoded request body
890
921
  # @return [IO] an IO-like object positioned at the start of the body
@@ -911,10 +942,10 @@ module Raptor
911
942
  #
912
943
  # @rbs (Hash[String, untyped] env) -> bool
913
944
  def forwarded_https?(env)
914
- proto = env["HTTP_X_FORWARDED_PROTO"] || env["HTTP_X_FORWARDED_SCHEME"]
945
+ proto = env[HTTP_X_FORWARDED_PROTO] || env[HTTP_X_FORWARDED_SCHEME]
915
946
  return true if proto && proto.split(",").first&.strip&.casecmp?(Server::HTTPS_SCHEME)
916
947
 
917
- env["HTTP_X_FORWARDED_SSL"]&.casecmp?("on") || false
948
+ env[HTTP_X_FORWARDED_SSL]&.casecmp?("on") || false
918
949
  end
919
950
 
920
951
  # Returns true when the connection should be kept alive after the
@@ -928,13 +959,10 @@ module Raptor
928
959
  def keep_alive?(env, request_count)
929
960
  return false if request_count >= @max_keepalive_requests
930
961
 
931
- connection_header = env[HTTP_CONNECTION]
962
+ connection_tokens = env[HTTP_CONNECTION].to_s.downcase.split(",").map(&:strip)
963
+ return false if connection_tokens.include?(CONNECTION_CLOSE)
932
964
 
933
- if env[Rack::SERVER_PROTOCOL] == HTTP_11
934
- !connection_header&.casecmp?(CONNECTION_CLOSE)
935
- else
936
- connection_header&.casecmp?(CONNECTION_KEEPALIVE) || false
937
- end
965
+ env[Rack::SERVER_PROTOCOL] == HTTP_11 || connection_tokens.include?(CONNECTION_KEEPALIVE)
938
966
  end
939
967
 
940
968
  # Sends an HTTP 103 Early Hints response, skipping any entries with
@@ -1037,14 +1065,13 @@ module Raptor
1037
1065
  # @raise [TypeError] if status is not an Integer
1038
1066
  # @raise [ArgumentError] if status is less than 100
1039
1067
  #
1040
- # @rbs (Integer status) -> void
1068
+ # @rbs (untyped status) -> void
1041
1069
  def validate_status(status)
1042
1070
  raise TypeError, "status must be an Integer" unless status.is_a?(Integer)
1043
-
1044
1071
  raise ArgumentError, "status must be >= 100" unless status >= 100
1045
1072
  end
1046
1073
 
1047
- # Returns a normalised copy of the response headers with lowercased
1074
+ # Returns a normalized copy of the response headers with lowercased
1048
1075
  # keys and illegal/`rack.*`/`status` entries dropped.
1049
1076
  #
1050
1077
  # @param headers [Hash] raw headers from the Rack application
@@ -1083,7 +1110,6 @@ module Raptor
1083
1110
  def validate_headers(headers, status, no_entity_body)
1084
1111
  if no_entity_body
1085
1112
  raise ArgumentError, "content-type must not be present for status #{status}" if headers.key?(Rack::CONTENT_TYPE)
1086
-
1087
1113
  raise ArgumentError, "content-length must not be present for status #{status}" if headers.key?(Rack::CONTENT_LENGTH)
1088
1114
  end
1089
1115
  end
@@ -1103,8 +1129,8 @@ module Raptor
1103
1129
  response
1104
1130
  end
1105
1131
 
1106
- # Writes the response headers, uncorks the socket, and hands the raw
1107
- # socket to the hijack callback.
1132
+ # Writes the response headers and hands the raw socket to the hijack
1133
+ # callback.
1108
1134
  #
1109
1135
  # @param socket [TCPSocket] the client socket
1110
1136
  # @param response [String] the status line accumulated so far
@@ -1117,7 +1143,6 @@ module Raptor
1117
1143
  HttpParser.format_headers(response, headers)
1118
1144
  response << "\r\n"
1119
1145
  socket_write(socket, response)
1120
- uncork_socket(socket)
1121
1146
  response_hijack.call(socket)
1122
1147
  end
1123
1148
 
@@ -1152,10 +1177,11 @@ module Raptor
1152
1177
  # @param body [Object] the response body
1153
1178
  # @param http_version [String] "HTTP/1.1" or "HTTP/1.0"
1154
1179
  # @return [void]
1180
+ # @raise [TypeError] if the body responds to none of `each`, `to_ary`, or `to_path`
1155
1181
  #
1156
1182
  # @rbs (TCPSocket socket, String response, Hash[String, String | Array[String]] headers, untyped body, String http_version) -> void
1157
1183
  def write_full_response(socket, response, headers, body, http_version)
1158
- if body.respond_to?(:call)
1184
+ if body.respond_to?(:call) && !body.respond_to?(:each)
1159
1185
  HttpParser.format_headers(response, headers)
1160
1186
  response << "\r\n"
1161
1187
  socket_write(socket, response)
@@ -1211,8 +1237,6 @@ module Raptor
1211
1237
  array.sum { |chunk| chunk.is_a?(String) ? chunk.bytesize : 0 }
1212
1238
  elsif body.respond_to?(:to_path) && (path = body.to_path) && File.readable?(path)
1213
1239
  File.size(path)
1214
- else
1215
- nil
1216
1240
  end
1217
1241
  end
1218
1242
 
@@ -1352,8 +1376,31 @@ module Raptor
1352
1376
  end
1353
1377
  end
1354
1378
 
1355
- # Instance-level wrapper around {Http.write_access_log} that routes to
1356
- # the configured `@access_log_io`.
1379
+ # Writes response bytes within the configured write timeout.
1380
+ #
1381
+ # @param socket [TCPSocket] the socket to write to
1382
+ # @param string [String] the data to write
1383
+ # @return [void]
1384
+ # @raise [Http::WriteError] if the socket is not writable within the timeout or raises IOError
1385
+ #
1386
+ # @rbs (TCPSocket socket, String string) -> void
1387
+ def socket_write(socket, string)
1388
+ Http.socket_write(socket, string, timeout: @write_timeout)
1389
+ end
1390
+
1391
+ # Writes response buffers in order within the configured write timeout.
1392
+ #
1393
+ # @param socket [TCPSocket] the socket to write to
1394
+ # @param strings [Array<String>] the buffers to write in order
1395
+ # @return [void]
1396
+ # @raise [Http::WriteError] if the socket is not writable within the timeout or raises IOError
1397
+ #
1398
+ # @rbs (TCPSocket socket, Array[String] strings) -> void
1399
+ def socket_writev(socket, strings)
1400
+ Http.socket_writev(socket, strings, timeout: @write_timeout)
1401
+ end
1402
+
1403
+ # Writes a completed request to the access log.
1357
1404
  #
1358
1405
  # @param env [Hash] the Rack environment
1359
1406
  # @param status [Integer] the response status code