raptor 0.21.0 → 0.22.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.
data/lib/raptor/http1.rb CHANGED
@@ -8,6 +8,7 @@ require "tempfile"
8
8
  require "atomic-ruby/atomic_boolean"
9
9
  require "rack"
10
10
 
11
+ require_relative "detached_body"
11
12
  require_relative "http"
12
13
  require_relative "raptor_http"
13
14
  require_relative "thread_locals"
@@ -97,6 +98,28 @@ module Raptor
97
98
  false
98
99
  end
99
100
 
101
+ # Encodes one HTTP/1.1 response chunk.
102
+ #
103
+ # @rbs (String chunk) -> String
104
+ def self.encode_chunk(chunk)
105
+ HttpParser.chunked_encode(String.new, chunk)
106
+ end
107
+
108
+ # Encodes the final HTTP/1.1 response chunk and its trailers.
109
+ #
110
+ # @rbs (Hash[String, String | Array[String]] trailers) -> String
111
+ def self.encode_trailers(trailers)
112
+ normalized = trailers.each_with_object({}) do |(key, value), headers|
113
+ name = key.match?(/[A-Z]/) ? key.downcase : key
114
+ next if name.start_with?(RACK_HEADER_PREFIX) || name == "status"
115
+
116
+ headers[name] = value
117
+ end
118
+ response = +"0\r\n"
119
+ HttpParser.format_headers(response, normalized)
120
+ response << "\r\n"
121
+ end
122
+
100
123
  # Advances an HTTP/1.x request parse from the state hash's buffered
101
124
  # bytes. A complete well-formed request returns with `:complete` set
102
125
  # plus populated `:env` and `:body`; malformed or oversized input flips
@@ -492,7 +515,7 @@ module Raptor
492
515
  #
493
516
  # @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
494
517
  def process_client(socket, id, env, parse_data, body, reactor, thread_pool, request_count, remote_addr, url_scheme)
495
- keep_alive = process_request(socket, env, parse_data, body, request_count, remote_addr, url_scheme)
518
+ keep_alive = process_request(socket, id, env, parse_data, body, reactor, request_count, remote_addr, url_scheme)
496
519
  eager_keepalive(socket, id, reactor, thread_pool, request_count, remote_addr, url_scheme) if keep_alive
497
520
  end
498
521
 
@@ -501,20 +524,22 @@ module Raptor
501
524
  # for another request.
502
525
  #
503
526
  # @param socket [TCPSocket] the client socket
527
+ # @param id [Integer] unique client identifier
504
528
  # @param env [Hash] partial env hash from the HTTP parser
505
529
  # @param parse_data [Hash] metadata from the parsing pass
506
530
  # @param body [String, nil] decoded request body
531
+ # @param reactor [Reactor] the reactor managing the client connection
507
532
  # @param request_count [Integer] number of requests handled on this connection
508
533
  # @param remote_addr [String] client IP address
509
534
  # @param url_scheme [String] "http" or "https"
510
535
  # @return [Boolean] true if the connection should be kept alive
511
536
  #
512
- # @rbs (TCPSocket socket, Hash[String, untyped] env, Hash[Symbol, untyped] parse_data, String? body, Integer request_count, String remote_addr, String url_scheme) -> bool
513
- def process_request(socket, env, parse_data, body, request_count, remote_addr, url_scheme)
537
+ # @rbs (TCPSocket socket, Integer id, Hash[String, untyped] env, Hash[Symbol, untyped] parse_data, String? body, Reactor reactor, Integer request_count, String remote_addr, String url_scheme) -> bool
538
+ def process_request(socket, id, env, parse_data, body, reactor, request_count, remote_addr, url_scheme)
514
539
  if @clean_fiber_locals
515
- Fiber.new { perform_request(socket, env, parse_data, body, request_count, remote_addr, url_scheme) }.resume
540
+ Fiber.new { perform_request(socket, id, env, parse_data, body, reactor, request_count, remote_addr, url_scheme) }.resume
516
541
  else
517
- perform_request(socket, env, parse_data, body, request_count, remote_addr, url_scheme)
542
+ perform_request(socket, id, env, parse_data, body, reactor, request_count, remote_addr, url_scheme)
518
543
  end
519
544
  ensure
520
545
  ThreadLocals.clear if @clean_thread_locals
@@ -522,13 +547,14 @@ module Raptor
522
547
 
523
548
  # Calls the Rack app and writes its response for one request.
524
549
  #
525
- # @rbs (TCPSocket socket, Hash[String, untyped] env, Hash[Symbol, untyped] parse_data, String? body, Integer request_count, String remote_addr, String url_scheme) -> bool
526
- def perform_request(socket, env, parse_data, body, request_count, remote_addr, url_scheme)
550
+ # @rbs (TCPSocket socket, Integer id, Hash[String, untyped] env, Hash[Symbol, untyped] parse_data, String? body, Reactor reactor, Integer request_count, String remote_addr, String url_scheme) -> bool
551
+ def perform_request(socket, id, env, parse_data, body, reactor, request_count, remote_addr, url_scheme)
527
552
  rack_env = nil
528
553
  status = nil
529
554
  headers = nil
530
555
  hijacked = false
531
556
  keep_alive = false
557
+ detached = false
532
558
  response_started = false
533
559
 
534
560
  begin
@@ -540,16 +566,40 @@ module Raptor
540
566
  body.close if body.respond_to?(:close)
541
567
  else
542
568
  hijacked = headers.is_a?(Hash) && !!headers[Rack::RACK_HIJACK]
543
- streaming = body.respond_to?(:call) && !body.respond_to?(:each)
544
- keep_alive = (hijacked || streaming) ? false : keep_alive?(rack_env, request_count)
545
- response_size = response_size(headers, body) if @access_log_io && !hijacked
546
- response_started = true
547
- write_response(socket, rack_env, status, headers, body, keep_alive: keep_alive)
569
+ no_body = rack_env[Rack::REQUEST_METHOD] == "HEAD" || (status >= 100 && status < 200) || status == 204 || status == 304
570
+ detached = body.is_a?(DetachedBody) && !no_body
571
+ if detached
572
+ chunked = rack_env[Rack::SERVER_PROTOCOL] == HTTP_11
573
+ keep_alive = chunked && keep_alive?(rack_env, request_count)
574
+ response_started = true
575
+ write_detached_response(socket, status, headers, chunked: chunked, keep_alive: keep_alive)
576
+ finished = proc do |reason|
577
+ error = IOError.new("detached response closed") unless reason == :closed
578
+ write_access_log(rack_env, status, "-", remote_addr) if @access_log_io
579
+ Http.call_response_finished(rack_env, status, headers, error)
580
+ end
581
+ reactor.attach_http1_body(
582
+ socket,
583
+ id,
584
+ body,
585
+ {chunked: chunked, keep_alive: keep_alive, request_count: request_count, remote_addr: remote_addr, url_scheme: url_scheme},
586
+ finished
587
+ )
588
+ else
589
+ streaming = body.respond_to?(:call) && !body.respond_to?(:each)
590
+ keep_alive = (hijacked || streaming) ? false : keep_alive?(rack_env, request_count)
591
+ response_size = response_size(headers, body) if @access_log_io && !hijacked
592
+ response_started = true
593
+ write_response(socket, rack_env, status, headers, body, keep_alive: keep_alive)
594
+ body.finish(:closed) if body.is_a?(DetachedBody)
595
+ end
548
596
  end
549
597
 
550
- write_access_log(rack_env, status, response_size, remote_addr) if @access_log_io && !hijacked
551
- Http.call_response_finished(rack_env, status, headers, nil)
552
- keep_alive && !hijacked
598
+ unless detached
599
+ write_access_log(rack_env, status, response_size, remote_addr) if @access_log_io && !hijacked
600
+ Http.call_response_finished(rack_env, status, headers, nil)
601
+ end
602
+ keep_alive && !hijacked && !detached
553
603
  rescue => error
554
604
  keep_alive = false
555
605
  handle_app_error(socket, rack_env, status, headers, error, response_started: response_started, hijacked: hijacked)
@@ -557,7 +607,7 @@ module Raptor
557
607
  rack_input = rack_env && rack_env[Rack::RACK_INPUT]
558
608
  rack_input.close! rescue nil if rack_input.respond_to?(:close!)
559
609
 
560
- unless hijacked || keep_alive
610
+ unless hijacked || keep_alive || detached
561
611
  socket.close rescue nil
562
612
  end
563
613
  end
@@ -666,9 +716,11 @@ module Raptor
666
716
 
667
717
  keep_alive = process_request(
668
718
  socket,
719
+ id,
669
720
  env,
670
721
  parse_data,
671
722
  body,
723
+ reactor,
672
724
  request_count,
673
725
  remote_addr,
674
726
  url_scheme
@@ -895,6 +947,24 @@ module Raptor
895
947
  socket_write(socket, response)
896
948
  end
897
949
 
950
+ # Starts a detached response, chunked on HTTP/1.1 and ended by closing
951
+ # the connection on HTTP/1.0.
952
+ #
953
+ # @rbs (TCPSocket socket, Integer status, Hash[String, String | Array[String]] headers, chunked: bool, keep_alive: bool) -> void
954
+ def write_detached_response(socket, status, headers, chunked:, keep_alive:)
955
+ validate_status(status)
956
+ headers = normalize_headers(headers)
957
+ validate_headers(headers, status, false)
958
+ headers.delete(Rack::CONTENT_LENGTH)
959
+ headers[Rack::TRANSFER_ENCODING] = TRANSFER_ENCODING_CHUNKED if chunked
960
+ headers["connection"] = keep_alive ? CONNECTION_KEEPALIVE : CONNECTION_CLOSE
961
+
962
+ response = build_status_line(chunked ? HTTP_11 : HTTP_10, status)
963
+ HttpParser.format_headers(response, headers)
964
+ response << "\r\n"
965
+ socket_write(socket, response)
966
+ end
967
+
898
968
  # Writes a complete HTTP response to the socket.
899
969
  #
900
970
  # @param socket [TCPSocket] the client socket to write to
data/lib/raptor/http2.rb CHANGED
@@ -8,6 +8,7 @@ require "atomic-ruby/atomic_boolean"
8
8
  require "atomic-ruby/atomic_condition_variable"
9
9
  require "rack"
10
10
 
11
+ require_relative "detached_body"
11
12
  require_relative "http"
12
13
  require_relative "raptor_http2"
13
14
  require_relative "thread_locals"
@@ -18,13 +19,12 @@ module Raptor
18
19
  class Http2
19
20
  RESPONSE_TRAILERS = "raptor.response_trailers"
20
21
 
21
- # Serialises concurrent frame writes on a single HTTP/2 connection so
22
- # exactly one thread is writing at any moment.
22
+ # Queues concurrent frame writes while the reactor remains the sole
23
+ # owner of the connection socket.
23
24
  #
24
25
  class Writer
25
- IDLE = :idle
26
-
27
- # @rbs @state: Atom
26
+ # @rbs @reactor: Reactor?
27
+ # @rbs @connection_id: Integer?
28
28
  # @rbs @write_timeout: Integer
29
29
 
30
30
  # Creates a new Writer.
@@ -34,45 +34,46 @@ module Raptor
34
34
  #
35
35
  # @rbs (write_timeout: Integer) -> void
36
36
  def initialize(write_timeout:)
37
- @state = Atom.new(IDLE)
37
+ @reactor = nil
38
+ @connection_id = nil
38
39
  @write_timeout = write_timeout
39
40
  end
40
41
 
41
- # Writes frames to the socket, coordinating with concurrent writers
42
- # so that exactly one thread is actively writing at any time.
42
+ # Routes subsequent frame writes through the connection's reactor.
43
+ #
44
+ # @param reactor [Reactor] the reactor that owns the connection
45
+ # @param connection_id [Integer] unique connection identifier
46
+ # @return [void]
47
+ #
48
+ # @rbs (Reactor reactor, Integer connection_id) -> void
49
+ def attach(reactor, connection_id)
50
+ @reactor = reactor
51
+ @connection_id = connection_id
52
+ end
53
+
54
+ # Queues frames for the reactor to write. Before attachment, writes
55
+ # directly so the writer remains usable during connection setup.
43
56
  #
44
57
  # @param socket [OpenSSL::SSL::SSLSocket] the connection socket
45
- # @param frames [Array<String>] frame bytes to write in order
58
+ # @param frames [Array<String>, nil] frame bytes to write in order
46
59
  # @return [void]
47
60
  #
48
- # @rbs (OpenSSL::SSL::SSLSocket socket, Array[String] frames) -> void
61
+ # @rbs (OpenSSL::SSL::SSLSocket socket, Array[String]? frames) -> void
49
62
  def write_frames(socket, frames)
50
63
  return if !frames || frames.empty?
51
64
 
52
- claimed = false
53
- @state.swap do |current|
54
- if current.equal?(IDLE)
55
- claimed = true
56
- frames
57
- else
58
- claimed = false
59
- current + frames
60
- end
65
+ if @reactor
66
+ @reactor.write_http2_frames(@connection_id, frames)
67
+ else
68
+ Http.socket_write(socket, frames.join, timeout: @write_timeout) rescue nil
61
69
  end
70
+ end
62
71
 
63
- return unless claimed
64
-
65
- loop do
66
- pending = nil
67
- @state.swap do |current|
68
- pending = current
69
- current.empty? ? IDLE : []
70
- end
71
-
72
- break if pending.empty?
73
-
74
- Http.socket_write(socket, pending.join, timeout: @write_timeout) rescue nil
75
- end
72
+ # Attaches a response body that outlives its application thread.
73
+ #
74
+ # @rbs (Integer stream_id, DetachedBody body, ^(Symbol) -> void finished) -> bool
75
+ def attach_body(stream_id, body, finished)
76
+ @reactor.attach_http2_body(@connection_id, stream_id, body, finished)
76
77
  end
77
78
  end
78
79
 
@@ -133,6 +134,30 @@ module Raptor
133
134
  end
134
135
  end
135
136
 
137
+ # Reserves outbound capacity without waiting for a window update.
138
+ #
139
+ # @param stream_id [Integer] the HTTP/2 stream identifier
140
+ # @param max_bytes [Integer] the largest size the caller would like to send
141
+ # @return [Integer] available bytes, or 0 when flow control is exhausted
142
+ #
143
+ # @rbs (Integer stream_id, Integer max_bytes) -> Integer
144
+ def try_acquire(stream_id, max_bytes)
145
+ check(stream_id)
146
+ initial = @initial_stream_window.value
147
+ stream_window = @stream_windows.value[stream_id] || initial
148
+ capped = [max_bytes, MAX_FRAME_SIZE, stream_window].min
149
+ return 0 unless capped.positive?
150
+
151
+ granted = reserve_connection(capped)
152
+ if granted.positive?
153
+ @stream_windows.swap do |windows|
154
+ current = windows[stream_id] || initial
155
+ windows.merge(stream_id => current - granted)
156
+ end
157
+ end
158
+ granted
159
+ end
160
+
136
161
  # Increments the connection-level send window by `increment` bytes.
137
162
  #
138
163
  # @param increment [Integer] the byte count to add
@@ -900,10 +925,14 @@ module Raptor
900
925
  rounds = 0
901
926
  loop do
902
927
  reactor.acknowledge_http2_ping(result[:id], result[:ping_acknowledgements])
903
- result[:cancelled_streams]&.each { |stream_id| flow_control.cancel_stream(stream_id) }
928
+ result[:cancelled_streams]&.each do |stream_id|
929
+ flow_control.cancel_stream(stream_id)
930
+ reactor.cancel_http2_body(result[:id], stream_id)
931
+ end
904
932
 
905
- if flow_control && (result[:window_updates] || result[:peer_initial_window_size])
933
+ if flow_control && (result[:window_updates]&.any? || result[:peer_initial_window_size])
906
934
  apply_flow_control_updates(flow_control, result)
935
+ reactor.resume_http2_bodies(result[:id])
907
936
  end
908
937
 
909
938
  writer.write_frames(socket, result[:outgoing_frames])
@@ -1025,11 +1054,26 @@ module Raptor
1025
1054
  status = nil
1026
1055
  response_headers = nil
1027
1056
  response_started = false
1057
+ detached = false
1028
1058
 
1029
1059
  flow_control.check(stream_id)
1030
1060
  env = build_rack_env(headers, body, socket, writer, flow_control, stream_id, remote_addr: remote_addr)
1031
1061
  status, response_headers, response_body = @app.call(env)
1032
1062
 
1063
+ no_body = env[Rack::REQUEST_METHOD] == "HEAD" || (status >= 100 && status < 200) || status == 204 || status == 304
1064
+ if response_body.is_a?(DetachedBody) && !no_body
1065
+ finished = proc do |reason|
1066
+ error = StreamClosedError.new unless reason == :closed
1067
+ write_access_log(env, status, "-", remote_addr) if @access_log_io
1068
+ Http.call_response_finished(env, status, response_headers, error)
1069
+ flow_control.discard_stream(stream_id)
1070
+ end
1071
+ detached = write_http2_detached_response(socket, writer, flow_control, stream_id, status, response_headers, response_body, finished) do
1072
+ response_started = true
1073
+ end
1074
+ return
1075
+ end
1076
+
1033
1077
  response_size = write_http2_response(
1034
1078
  socket,
1035
1079
  writer,
@@ -1041,7 +1085,11 @@ module Raptor
1041
1085
  trailers: env[RESPONSE_TRAILERS],
1042
1086
  request_method: env[Rack::REQUEST_METHOD]
1043
1087
  ) { response_started = true }
1044
- response_body.close if response_body.respond_to?(:close)
1088
+ if response_body.is_a?(DetachedBody)
1089
+ response_body.finish(:closed)
1090
+ elsif response_body.respond_to?(:close)
1091
+ response_body.close
1092
+ end
1045
1093
  write_access_log(env, status, response_size, remote_addr) if @access_log_io
1046
1094
  Http.call_response_finished(env, status, response_headers, nil)
1047
1095
  rescue StreamClosedError => error
@@ -1060,7 +1108,21 @@ module Raptor
1060
1108
  raise
1061
1109
  end
1062
1110
  ensure
1063
- flow_control.discard_stream(stream_id) if flow_control
1111
+ flow_control.discard_stream(stream_id) if flow_control && !detached
1112
+ end
1113
+
1114
+ # Starts a detached HTTP/2 response after writing its response headers.
1115
+ #
1116
+ # @rbs (OpenSSL::SSL::SSLSocket socket, Writer writer, FlowControl flow_control, Integer stream_id, Integer status, Hash[String, String | Array[String]] headers, DetachedBody body, ^(Symbol) -> void finished) { () -> void } -> bool
1117
+ def write_http2_detached_response(socket, writer, flow_control, stream_id, status, headers, body, finished)
1118
+ flow_control.check(stream_id)
1119
+ parser = Http2Parser.new
1120
+ encoded = parser.encode_response_headers(status, headers)
1121
+ writer.write_frames(socket, [parser.build_frame(:headers, FLAG_END_HEADERS, stream_id, encoded)])
1122
+ yield
1123
+ attached = writer.attach_body(stream_id, body, finished)
1124
+ write_http2_reset_stream(socket, writer, stream_id, ERROR_REFUSED_STREAM) unless attached
1125
+ true
1064
1126
  end
1065
1127
 
1066
1128
  # Writes a Rack response as HTTP/2 frames to the socket, partitioning