raptor 0.22.3 → 0.23.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 (42) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +30 -0
  3. data/Dockerfile +3 -1
  4. data/README.md +38 -31
  5. data/docs/raptor-vs-puma.md +44 -38
  6. data/docs/talk.md +1295 -0
  7. data/ext/raptor_bpf/reuseport_select.bpf.c +18 -9
  8. data/ext/raptor_http/extconf.rb +2 -0
  9. data/ext/raptor_http/raptor_http.c +91 -119
  10. data/ext/raptor_http2/raptor_http2.c +272 -223
  11. data/ext/raptor_native/raptor_native.c +38 -20
  12. data/lib/rackup/handler/raptor.rb +12 -11
  13. data/lib/raptor/binder.rb +12 -12
  14. data/lib/raptor/cli.rb +18 -18
  15. data/lib/raptor/cluster.rb +44 -44
  16. data/lib/raptor/control_server.rb +3 -2
  17. data/lib/raptor/detached_body.rb +6 -2
  18. data/lib/raptor/http.rb +7 -5
  19. data/lib/raptor/http1.rb +232 -207
  20. data/lib/raptor/http2.rb +312 -265
  21. data/lib/raptor/reactor.rb +138 -148
  22. data/lib/raptor/reuseport_bpf.rb +62 -38
  23. data/lib/raptor/server.rb +45 -34
  24. data/lib/raptor/stats.rb +15 -3
  25. data/lib/raptor/thread_locals.rb +4 -3
  26. data/lib/raptor/version.rb +1 -1
  27. data/sig/generated/rackup/handler/raptor.rbs +2 -1
  28. data/sig/generated/raptor/binder.rbs +9 -9
  29. data/sig/generated/raptor/cli.rbs +6 -6
  30. data/sig/generated/raptor/cluster.rbs +9 -9
  31. data/sig/generated/raptor/control_server.rbs +2 -1
  32. data/sig/generated/raptor/detached_body.rbs +5 -1
  33. data/sig/generated/raptor/http.rbs +3 -3
  34. data/sig/generated/raptor/http1.rbs +100 -91
  35. data/sig/generated/raptor/http2.rbs +200 -189
  36. data/sig/generated/raptor/reactor.rbs +107 -118
  37. data/sig/generated/raptor/reuseport_bpf.rbs +54 -17
  38. data/sig/generated/raptor/server.rbs +26 -26
  39. data/sig/generated/raptor/stats.rbs +3 -2
  40. data/sig/generated/raptor/thread_locals.rbs +4 -3
  41. metadata +2 -2
  42. 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,32 @@ 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
+ CHUNKED_TERMINATOR = "0\r\n\r\n"
45
47
 
46
48
  CONNECTION_CLOSE = "close"
47
49
  CONNECTION_KEEPALIVE = "keep-alive"
48
50
  EXPECT_100_CONTINUE = "100-continue"
49
51
  TRANSFER_ENCODING_CHUNKED = "chunked"
50
- CHUNKED_TERMINATOR = "0\r\n\r\n"
51
52
 
52
53
  HTTP_CONNECTION = "HTTP_CONNECTION"
53
54
  HTTP_EXPECT = "HTTP_EXPECT"
54
55
  HTTP_TRANSFER_ENCODING = "HTTP_TRANSFER_ENCODING"
56
+ HTTP_X_FORWARDED_PROTO = "HTTP_X_FORWARDED_PROTO"
57
+ HTTP_X_FORWARDED_SCHEME = "HTTP_X_FORWARDED_SCHEME"
58
+ HTTP_X_FORWARDED_SSL = "HTTP_X_FORWARDED_SSL"
55
59
  RACK_HEADER_PREFIX = "rack."
56
60
  RACK_HIJACKED = "rack.hijacked"
57
61
  RACK_HIJACK_IO = "rack.hijack_io"
@@ -98,6 +102,17 @@ module Raptor
98
102
  false
99
103
  end
100
104
 
105
+ # Returns any pipelined bytes following the request, or nil if none remain.
106
+ #
107
+ # @param buffer [String] the raw request bytes
108
+ # @param offset [Integer] the byte offset where the request ends
109
+ # @return [String, nil]
110
+ #
111
+ # @rbs (String buffer, Integer offset) -> String?
112
+ def self.pipelined_bytes(buffer, offset)
113
+ buffer.byteslice(offset..-1) if offset < buffer.bytesize
114
+ end
115
+
101
116
  # Encodes one HTTP/1.1 response chunk.
102
117
  #
103
118
  # @param chunk [String] response body bytes
@@ -128,7 +143,8 @@ module Raptor
128
143
 
129
144
  # Advances an HTTP/1.x request parse from the state hash's buffered
130
145
  # bytes. A complete well-formed request returns with `:complete` set
131
- # plus populated `:env` and `:body`; malformed or oversized input flips
146
+ # plus populated `:env` and `:body`, and records any bytes after it in
147
+ # the parse data's `:pipelined`; malformed or oversized input flips
132
148
  # `:malformed` or `:too_large`; incomplete input returns the state
133
149
  # so the reactor can wait for more.
134
150
  #
@@ -139,11 +155,11 @@ module Raptor
139
155
  #
140
156
  # @rbs (Hash[Symbol, untyped] data, Hash[String, untyped] env_template, Integer? max_body_size) -> Hash[Symbol, untyped]
141
157
  def self.parse(data, env_template, max_body_size)
142
- parser = Raptor::HttpParser.new
158
+ parser = HttpParser.new
143
159
  env = env_template.dup
144
160
  nread = begin
145
161
  parser.execute(env, data[:buffer], 0)
146
- rescue Raptor::HttpParserError
162
+ rescue HttpParserError
147
163
  return Ractor.make_shareable(data.merge(complete: true, malformed: true))
148
164
  end
149
165
  parse_data = if data[:parse_data]
@@ -162,11 +178,12 @@ module Raptor
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)
@@ -370,10 +354,7 @@ module Raptor
370
354
  return
371
355
  end
372
356
 
373
- unless parsed_request[:complete]
374
- parsed_request = send_continue_if_expected(parsed_request, reactor)
375
- reactor.update_state(parsed_request)
376
- else
357
+ if parsed_request[:complete]
377
358
  socket = reactor.remove(parsed_request[:id])
378
359
  request_count = (parsed_request[:request_count] || 0) + 1
379
360
  remote_addr = parsed_request[:remote_addr] || Server::DEFAULT_REMOTE_ADDR
@@ -393,6 +374,112 @@ module Raptor
393
374
  url_scheme
394
375
  )
395
376
  end
377
+ else
378
+ parsed_request = send_continue_if_expected(parsed_request, reactor)
379
+ reactor.update_state(parsed_request)
380
+ end
381
+ end
382
+
383
+ # Processes kept-alive requests on application threads, yielding to queued
384
+ # work and returning idle or incomplete connections to the reactor.
385
+ #
386
+ # @param socket [TCPSocket] the client socket
387
+ # @param id [Integer] unique connection identifier
388
+ # @param reactor [Reactor] the reactor for fallback registration
389
+ # @param thread_pool [AtomicThreadPool] thread pool for application processing
390
+ # @param request_count [Integer] number of requests handled on this connection
391
+ # @param remote_addr [String] the client IP address
392
+ # @param url_scheme [String] "http" or "https"
393
+ # @param resumed [Boolean] whether the reactor queued the connection once its next request became readable
394
+ # @param pipelined [String, nil] bytes the client sent after the previous request
395
+ # @return [void]
396
+ #
397
+ # @rbs (TCPSocket socket, Integer id, Reactor reactor, AtomicThreadPool thread_pool, Integer request_count, String remote_addr, String url_scheme, ?resumed: bool, ?pipelined: String?) -> void
398
+ def eager_keepalive(socket, id, reactor, thread_pool, request_count, remote_addr, url_scheme, resumed: false, pipelined: nil)
399
+ loop do
400
+ unless @running.true?
401
+ socket.close rescue nil
402
+ return
403
+ end
404
+
405
+ if pipelined
406
+ buffer = pipelined
407
+ else
408
+ unless resumed || socket.wait_readable(0)
409
+ reactor.persist(socket, id, request_count, remote_addr: remote_addr, url_scheme: url_scheme)
410
+ return
411
+ end
412
+
413
+ begin
414
+ buffer = read_into_thread_buffer(socket)
415
+ rescue IO::WaitReadable
416
+ reactor.persist(socket, id, request_count, remote_addr: remote_addr, url_scheme: url_scheme)
417
+ return
418
+ rescue EOFError, IOError, SystemCallError
419
+ socket.close rescue nil
420
+ return
421
+ end
422
+ end
423
+
424
+ env, parse_data, nread, parser = begin
425
+ parse_next_request(buffer)
426
+ rescue HttpParserError
427
+ reject_malformed(socket)
428
+ return
429
+ end
430
+
431
+ unless parser.finished?
432
+ fallback_to_reactor(socket, id, buffer, env, parse_data, reactor, request_count, remote_addr, url_scheme)
433
+ return
434
+ end
435
+
436
+ body = extract_body(buffer, env, parse_data, parser, nread)
437
+ case body
438
+ when :incomplete
439
+ fallback_to_reactor(socket, id, buffer, env, parse_data, reactor, request_count, remote_addr, url_scheme)
440
+ return
441
+ when :too_large
442
+ reject_oversized(socket)
443
+ return
444
+ when :malformed
445
+ reject_malformed(socket)
446
+ return
447
+ end
448
+
449
+ request_count += 1
450
+
451
+ if !resumed && thread_pool.queue_size.positive?
452
+ thread_pool << proc do
453
+ process_client(
454
+ socket,
455
+ id,
456
+ env,
457
+ parse_data,
458
+ body,
459
+ reactor,
460
+ thread_pool,
461
+ request_count,
462
+ remote_addr,
463
+ url_scheme
464
+ )
465
+ end
466
+ return
467
+ end
468
+
469
+ resumed = false
470
+ pipelined = parse_data[:pipelined]
471
+ keep_alive = process_request(
472
+ socket,
473
+ id,
474
+ env,
475
+ parse_data,
476
+ body,
477
+ reactor,
478
+ request_count,
479
+ remote_addr,
480
+ url_scheme
481
+ )
482
+ return unless keep_alive
396
483
  end
397
484
  end
398
485
 
@@ -400,11 +487,12 @@ module Raptor
400
487
 
401
488
  # Reads pending bytes off `socket` into the thread-local read buffer,
402
489
  # 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.
490
+ # return.
405
491
  #
406
492
  # @param socket [TCPSocket] the socket to read from
407
493
  # @return [String] the thread-local buffer, freshly populated
494
+ # @raise [IO::WaitReadable] if no bytes are ready to read
495
+ # @raise [EOFError, IOError, SystemCallError] if the connection is closed or fails
408
496
  #
409
497
  # @rbs (TCPSocket socket) -> String
410
498
  def read_into_thread_buffer(socket)
@@ -418,12 +506,11 @@ module Raptor
418
506
  buffer
419
507
  end
420
508
 
421
- # Runs a fresh HTTP/1.x parse against `buffer`, returning
422
- # `[env, parse_data, nread, parser]`. Raises `HttpParserError` on
423
- # malformed input.
509
+ # Runs a fresh HTTP/1.x parse against `buffer`.
424
510
  #
425
511
  # @param buffer [String] the raw request bytes
426
- # @return [Array(Hash, Hash, Integer, HttpParser)]
512
+ # @return [Array(Hash, Hash, Integer, HttpParser)] the env, parse data, bytes read, and parser
513
+ # @raise [HttpParserError] if the request is malformed
427
514
  #
428
515
  # @rbs (String buffer) -> [Hash[String, untyped], Hash[Symbol, untyped], Integer, HttpParser]
429
516
  def parse_next_request(buffer)
@@ -435,51 +522,53 @@ module Raptor
435
522
  [env, parse_data, nread, parser]
436
523
  end
437
524
 
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.
525
+ # Validates request framing and extracts the body, preserving pipelined bytes.
442
526
  #
443
527
  # @param buffer [String] the raw request bytes
444
528
  # @param env [Hash] the Rack environment being built
529
+ # @param parse_data [Hash] metadata from the parsing pass
445
530
  # @param parser [HttpParser] the parser holding the finished parse state
446
531
  # @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]
532
+ # @return [String, nil, Symbol] body, nil when absent, or `:incomplete`,
533
+ # `:too_large`, or `:malformed`
449
534
  #
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?
535
+ # @rbs (String buffer, Hash[String, untyped] env, Hash[Symbol, untyped] parse_data, HttpParser parser, Integer nread) -> (String | Symbol)?
536
+ def extract_body(buffer, env, parse_data, parser, nread)
537
+ return :malformed if Http1.invalid_host?(env) || Http1.request_smuggling?(env)
453
538
 
454
- body = buffer.byteslice(nread..-1) || ""
539
+ unless parser.has_body?
540
+ parse_data[:pipelined] = Http1.pipelined_bytes(buffer, nread)
541
+ return
542
+ end
543
+
544
+ return :too_large if @max_body_size && parser.content_length > @max_body_size
455
545
 
456
546
  if parser.chunked?
457
- return :incomplete unless decode_chunked
547
+ body, chunked_state, consumed = HttpParser.decode_chunked(buffer.byteslice(nread..-1), @max_body_size)
548
+ return chunked_state unless chunked_state == :complete
458
549
 
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
550
+ env.delete(HTTP_TRANSFER_ENCODING)
551
+ parse_data[:pipelined] = Http1.pipelined_bytes(buffer, nread + consumed)
552
+ body
553
+ elsif nread + parser.content_length > buffer.bytesize
468
554
  :incomplete
469
555
  else
470
- body
556
+ parse_data[:pipelined] = Http1.pipelined_bytes(buffer, nread + parser.content_length)
557
+ buffer.byteslice(nread, parser.content_length)
471
558
  end
472
559
  end
473
560
 
474
- # Returns true if the request expects a 100 Continue response per
475
- # RFC 7231 section 5.1.1.
561
+ # Returns true when the request expects a 100 Continue response per
562
+ # RFC 9110 section 10.1.1.
476
563
  #
477
564
  # @param env [Hash] the parsed Rack environment (possibly incomplete)
478
565
  # @return [Boolean]
479
566
  #
480
567
  # @rbs (Hash[String, untyped] env) -> bool
481
568
  def expects_100_continue?(env)
482
- (env[Rack::SERVER_PROTOCOL] == HTTP_11) && env[HTTP_EXPECT]&.casecmp?(EXPECT_100_CONTINUE)
569
+ return false unless env[Rack::SERVER_PROTOCOL] == HTTP_11
570
+
571
+ env[HTTP_EXPECT]&.casecmp?(EXPECT_100_CONTINUE) || false
483
572
  end
484
573
 
485
574
  # Sends an HTTP 100 Continue response when the client requested
@@ -508,35 +597,35 @@ module Raptor
508
597
  # if keep-alive, eagerly reading subsequent requests inline.
509
598
  #
510
599
  # @param socket [TCPSocket] the client socket
511
- # @param id [Integer] unique client identifier
600
+ # @param id [Integer] unique connection identifier
512
601
  # @param env [Hash] partial env hash from the HTTP parser
513
602
  # @param parse_data [Hash] metadata from the parsing pass
514
603
  # @param body [String, nil] decoded request body
515
604
  # @param reactor [Reactor] the reactor managing the client connection
516
605
  # @param thread_pool [AtomicThreadPool] thread pool for application processing
517
606
  # @param request_count [Integer] number of requests handled on this connection
518
- # @param remote_addr [String] client IP address
607
+ # @param remote_addr [String] the client IP address
519
608
  # @param url_scheme [String] "http" or "https"
520
609
  # @return [void]
521
610
  #
522
611
  # @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
612
  def process_client(socket, id, env, parse_data, body, reactor, thread_pool, request_count, remote_addr, url_scheme)
524
613
  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
614
+ eager_keepalive(socket, id, reactor, thread_pool, request_count, remote_addr, url_scheme, pipelined: parse_data[:pipelined]) if keep_alive
526
615
  end
527
616
 
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.
617
+ # Processes a single request, isolating fiber and thread locals as
618
+ # configured, and returns whether the connection stays open for another
619
+ # request.
531
620
  #
532
621
  # @param socket [TCPSocket] the client socket
533
- # @param id [Integer] unique client identifier
622
+ # @param id [Integer] unique connection identifier
534
623
  # @param env [Hash] partial env hash from the HTTP parser
535
624
  # @param parse_data [Hash] metadata from the parsing pass
536
625
  # @param body [String, nil] decoded request body
537
626
  # @param reactor [Reactor] the reactor managing the client connection
538
627
  # @param request_count [Integer] number of requests handled on this connection
539
- # @param remote_addr [String] client IP address
628
+ # @param remote_addr [String] the client IP address
540
629
  # @param url_scheme [String] "http" or "https"
541
630
  # @return [Boolean] true if the connection should be kept alive
542
631
  #
@@ -554,13 +643,13 @@ module Raptor
554
643
  # Calls the Rack app and writes its response for one request.
555
644
  #
556
645
  # @param socket [TCPSocket] the client socket
557
- # @param id [Integer] unique client identifier
646
+ # @param id [Integer] unique connection identifier
558
647
  # @param env [Hash] partial env hash from the HTTP parser
559
648
  # @param parse_data [Hash] metadata from the parsing pass
560
649
  # @param body [String, nil] decoded request body
561
650
  # @param reactor [Reactor] the reactor managing the client connection
562
651
  # @param request_count [Integer] number of requests handled on this connection
563
- # @param remote_addr [String] client IP address
652
+ # @param remote_addr [String] the client IP address
564
653
  # @param url_scheme [String] "http" or "https"
565
654
  # @return [Boolean] true if the connection should be kept alive
566
655
  #
@@ -599,7 +688,14 @@ module Raptor
599
688
  socket,
600
689
  id,
601
690
  body,
602
- { chunked: chunked, keep_alive: keep_alive, request_count: request_count, remote_addr: remote_addr, url_scheme: url_scheme },
691
+ {
692
+ chunked: chunked,
693
+ keep_alive: keep_alive,
694
+ request_count: request_count,
695
+ remote_addr: remote_addr,
696
+ url_scheme: url_scheme,
697
+ pipelined: parse_data[:pipelined]
698
+ },
603
699
  finished
604
700
  )
605
701
  else
@@ -637,14 +733,14 @@ module Raptor
637
733
  #
638
734
  # @param socket [TCPSocket] the client socket
639
735
  # @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
736
+ # @param status [Object, nil] the status returned by the app, if any
641
737
  # @param headers [Hash, nil] the headers returned by the app, if any
642
738
  # @param error [Exception] the exception raised
643
739
  # @param response_started [Boolean] whether any response bytes have been written
644
740
  # @param hijacked [Boolean] whether the app took over the socket
645
741
  # @return [void]
646
742
  #
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
743
+ # @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
744
  def handle_app_error(socket, rack_env, status, headers, error, response_started:, hijacked:)
649
745
  Http.call_response_finished(rack_env, status, headers, error)
650
746
  socket.write(INTERNAL_SERVER_ERROR_RESPONSE) rescue nil unless response_started || hijacked
@@ -656,112 +752,22 @@ module Raptor
656
752
  end
657
753
  end
658
754
 
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
755
  # Re-registers a socket with the reactor for further processing when
750
756
  # an incomplete request is received on the fast path.
751
757
  #
752
758
  # @param socket [TCPSocket] the client socket
753
- # @param id [Integer] unique client identifier
759
+ # @param id [Integer] unique connection identifier
754
760
  # @param buffer [String] the partial request data already read
755
761
  # @param env [Hash] partial env hash from the HTTP parser
756
762
  # @param parse_data [Hash] metadata from the parsing pass
757
763
  # @param reactor [Reactor] the reactor to re-register with
758
764
  # @param request_count [Integer] number of requests handled on this connection
759
- # @param remote_addr [String] client IP address
765
+ # @param remote_addr [String] the client IP address
760
766
  # @param url_scheme [String] "http" or "https"
761
767
  # @param persisted [Boolean] whether the connection has already completed at least one request
762
768
  # @return [void]
763
769
  #
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
770
+ # @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
771
  def fallback_to_reactor(socket, id, buffer, env, parse_data, reactor, request_count, remote_addr, url_scheme, persisted: true)
766
772
  continued = expects_100_continue?(env)
767
773
  socket_write(socket, CONTINUE_RESPONSE) rescue nil if continued
@@ -809,7 +815,7 @@ module Raptor
809
815
  # @param parse_data [Hash] metadata from the parsing pass, including content_length
810
816
  # @param body [String, nil] decoded request body, or nil if no body
811
817
  # @param socket [TCPSocket] the client socket, used for hijack support
812
- # @param remote_addr [String] client IP address
818
+ # @param remote_addr [String] the client IP address
813
819
  # @param url_scheme [String] "http" or "https"
814
820
  # @return [Hash] fully populated Rack environment hash
815
821
  #
@@ -833,7 +839,7 @@ module Raptor
833
839
  if scheme_end
834
840
  authority_end = request_uri.index("/", scheme_end + 3) || request_uri.bytesize
835
841
  path_and_query = request_uri.byteslice(authority_end..-1) || ""
836
- if query_delim = path_and_query.index("?")
842
+ if (query_delim = path_and_query.index("?"))
837
843
  env[Rack::PATH_INFO] = query_delim.zero? ? "/" : path_and_query.byteslice(0, query_delim)
838
844
  env[Rack::QUERY_STRING] = path_and_query.byteslice(query_delim + 1..-1)
839
845
  else
@@ -911,10 +917,10 @@ module Raptor
911
917
  #
912
918
  # @rbs (Hash[String, untyped] env) -> bool
913
919
  def forwarded_https?(env)
914
- proto = env["HTTP_X_FORWARDED_PROTO"] || env["HTTP_X_FORWARDED_SCHEME"]
920
+ proto = env[HTTP_X_FORWARDED_PROTO] || env[HTTP_X_FORWARDED_SCHEME]
915
921
  return true if proto && proto.split(",").first&.strip&.casecmp?(Server::HTTPS_SCHEME)
916
922
 
917
- env["HTTP_X_FORWARDED_SSL"]&.casecmp?("on") || false
923
+ env[HTTP_X_FORWARDED_SSL]&.casecmp?("on") || false
918
924
  end
919
925
 
920
926
  # Returns true when the connection should be kept alive after the
@@ -1037,14 +1043,13 @@ module Raptor
1037
1043
  # @raise [TypeError] if status is not an Integer
1038
1044
  # @raise [ArgumentError] if status is less than 100
1039
1045
  #
1040
- # @rbs (Integer status) -> void
1046
+ # @rbs (untyped status) -> void
1041
1047
  def validate_status(status)
1042
1048
  raise TypeError, "status must be an Integer" unless status.is_a?(Integer)
1043
-
1044
1049
  raise ArgumentError, "status must be >= 100" unless status >= 100
1045
1050
  end
1046
1051
 
1047
- # Returns a normalised copy of the response headers with lowercased
1052
+ # Returns a normalized copy of the response headers with lowercased
1048
1053
  # keys and illegal/`rack.*`/`status` entries dropped.
1049
1054
  #
1050
1055
  # @param headers [Hash] raw headers from the Rack application
@@ -1083,7 +1088,6 @@ module Raptor
1083
1088
  def validate_headers(headers, status, no_entity_body)
1084
1089
  if no_entity_body
1085
1090
  raise ArgumentError, "content-type must not be present for status #{status}" if headers.key?(Rack::CONTENT_TYPE)
1086
-
1087
1091
  raise ArgumentError, "content-length must not be present for status #{status}" if headers.key?(Rack::CONTENT_LENGTH)
1088
1092
  end
1089
1093
  end
@@ -1103,8 +1107,8 @@ module Raptor
1103
1107
  response
1104
1108
  end
1105
1109
 
1106
- # Writes the response headers, uncorks the socket, and hands the raw
1107
- # socket to the hijack callback.
1110
+ # Writes the response headers and hands the raw socket to the hijack
1111
+ # callback.
1108
1112
  #
1109
1113
  # @param socket [TCPSocket] the client socket
1110
1114
  # @param response [String] the status line accumulated so far
@@ -1117,7 +1121,6 @@ module Raptor
1117
1121
  HttpParser.format_headers(response, headers)
1118
1122
  response << "\r\n"
1119
1123
  socket_write(socket, response)
1120
- uncork_socket(socket)
1121
1124
  response_hijack.call(socket)
1122
1125
  end
1123
1126
 
@@ -1152,6 +1155,7 @@ module Raptor
1152
1155
  # @param body [Object] the response body
1153
1156
  # @param http_version [String] "HTTP/1.1" or "HTTP/1.0"
1154
1157
  # @return [void]
1158
+ # @raise [TypeError] if the body responds to none of `each`, `to_ary`, or `to_path`
1155
1159
  #
1156
1160
  # @rbs (TCPSocket socket, String response, Hash[String, String | Array[String]] headers, untyped body, String http_version) -> void
1157
1161
  def write_full_response(socket, response, headers, body, http_version)
@@ -1211,8 +1215,6 @@ module Raptor
1211
1215
  array.sum { |chunk| chunk.is_a?(String) ? chunk.bytesize : 0 }
1212
1216
  elsif body.respond_to?(:to_path) && (path = body.to_path) && File.readable?(path)
1213
1217
  File.size(path)
1214
- else
1215
- nil
1216
1218
  end
1217
1219
  end
1218
1220
 
@@ -1352,8 +1354,31 @@ module Raptor
1352
1354
  end
1353
1355
  end
1354
1356
 
1355
- # Instance-level wrapper around {Http.write_access_log} that routes to
1356
- # the configured `@access_log_io`.
1357
+ # Writes response bytes within the configured write timeout.
1358
+ #
1359
+ # @param socket [TCPSocket] the socket to write to
1360
+ # @param string [String] the data to write
1361
+ # @return [void]
1362
+ # @raise [Http::WriteError] if the socket is not writable within the timeout or raises IOError
1363
+ #
1364
+ # @rbs (TCPSocket socket, String string) -> void
1365
+ def socket_write(socket, string)
1366
+ Http.socket_write(socket, string, timeout: @write_timeout)
1367
+ end
1368
+
1369
+ # Writes response buffers in order within the configured write timeout.
1370
+ #
1371
+ # @param socket [TCPSocket] the socket to write to
1372
+ # @param strings [Array<String>] the buffers to write in order
1373
+ # @return [void]
1374
+ # @raise [Http::WriteError] if the socket is not writable within the timeout or raises IOError
1375
+ #
1376
+ # @rbs (TCPSocket socket, Array[String] strings) -> void
1377
+ def socket_writev(socket, strings)
1378
+ Http.socket_writev(socket, strings, timeout: @write_timeout)
1379
+ end
1380
+
1381
+ # Writes a completed request to the access log.
1357
1382
  #
1358
1383
  # @param env [Hash] the Rack environment
1359
1384
  # @param status [Integer] the response status code