raptor 0.20.2 → 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.
@@ -0,0 +1,293 @@
1
+ # rbs_inline: enabled
2
+ # frozen_string_literal: true
3
+
4
+ require "atomic-ruby/atom"
5
+
6
+ module Raptor
7
+ # A bounded response body that may be written after its application thread
8
+ # has returned.
9
+ #
10
+ class DetachedBody
11
+ DEFAULT_MAX_BUFFER_SIZE = 64 * 1024
12
+
13
+ # Tracks buffered bytes shared by a group of detached bodies.
14
+ #
15
+ class Budget
16
+ # @rbs @max_size: Integer
17
+ # @rbs @size: Atom
18
+
19
+ # @rbs (Integer max_size) -> void
20
+ def initialize(max_size)
21
+ @max_size = max_size
22
+ @size = Atom.new(0)
23
+ end
24
+
25
+ # @rbs (Integer bytes) -> bool
26
+ def reserve(bytes)
27
+ reserved = false
28
+ @size.swap do |size|
29
+ reserved = size + bytes <= @max_size
30
+ reserved ? size + bytes : size
31
+ end
32
+ reserved
33
+ end
34
+
35
+ # @rbs (Integer bytes) -> void
36
+ def release(bytes)
37
+ @size.swap { |size| size - bytes }
38
+ end
39
+ end
40
+
41
+ # @rbs @max_buffer_size: Integer
42
+ # @rbs @state: Atom
43
+ # @rbs @budgets: Array[Budget]
44
+ # @rbs @wake: ^() -> void
45
+ # @rbs @dispatch: ^(Proc) -> void
46
+ # @rbs @finished: ^(Symbol) -> void
47
+ # @rbs @on_open: (^(DetachedBody) -> void)?
48
+ # @rbs @on_close: (^(Symbol) -> void)?
49
+
50
+ # Creates a detached body with a bounded byte buffer.
51
+ #
52
+ # @param max_buffer_size [Integer] maximum body bytes waiting to be written
53
+ # @return [void]
54
+ #
55
+ # @rbs (?max_buffer_size: Integer) -> void
56
+ def initialize(max_buffer_size: DEFAULT_MAX_BUFFER_SIZE)
57
+ raise ArgumentError, "max_buffer_size must be positive" unless max_buffer_size.positive?
58
+
59
+ @max_buffer_size = max_buffer_size
60
+ @state = Atom.new({chunks: [], size: 0, closing: false, closed: false, notified: false, trailers: {}})
61
+ @budgets = []
62
+ @wake = proc {}
63
+ @dispatch = proc { |callback| callback.call }
64
+ @finished = proc {}
65
+ @on_open = nil
66
+ @on_close = nil
67
+ end
68
+
69
+ # Registers a callback for when the server accepts the response stream.
70
+ #
71
+ # @yieldparam stream [DetachedBody] the opened response stream
72
+ # @return [DetachedBody]
73
+ #
74
+ # @rbs () { (DetachedBody stream) -> void } -> DetachedBody
75
+ def on_open(&block)
76
+ @on_open = block
77
+ self
78
+ end
79
+
80
+ # Registers a callback for when the response stream closes.
81
+ #
82
+ # @yieldparam reason [Symbol] why the stream closed
83
+ # @return [DetachedBody]
84
+ #
85
+ # @rbs () { (Symbol reason) -> void } -> DetachedBody
86
+ def on_close(&block)
87
+ @on_close = block
88
+ self
89
+ end
90
+
91
+ # Adds body bytes without waiting for socket or flow-control capacity.
92
+ #
93
+ # @param chunk [String] response body bytes
94
+ # @return [Symbol] `:accepted`, `:full`, or `:closed`
95
+ #
96
+ # @rbs (String chunk) -> Symbol
97
+ def try_write(chunk)
98
+ raise TypeError, "body must yield String values" unless chunk.is_a?(String)
99
+
100
+ chunk = chunk.dup.freeze
101
+ if chunk.empty?
102
+ state = @state.value
103
+ return state[:closed] || state[:closing] ? :closed : :accepted
104
+ end
105
+ return :full unless reserve(chunk.bytesize)
106
+
107
+ result = :closed
108
+ wake = false
109
+ @state.swap do |state|
110
+ if state[:closed] || state[:closing]
111
+ result = :closed
112
+ wake = false
113
+ state
114
+ elsif state[:size] + chunk.bytesize > @max_buffer_size
115
+ result = :full
116
+ wake = false
117
+ state
118
+ else
119
+ result = :accepted
120
+ wake = !state[:notified]
121
+ state.merge(
122
+ chunks: state[:chunks] + [chunk],
123
+ size: state[:size] + chunk.bytesize,
124
+ notified: true
125
+ )
126
+ end
127
+ end
128
+
129
+ if result == :accepted
130
+ @wake.call if wake
131
+ else
132
+ release(chunk.bytesize)
133
+ end
134
+ result
135
+ end
136
+
137
+ # Finishes the body after all accepted bytes have been written.
138
+ #
139
+ # @param trailers [Hash] trailing response headers
140
+ # @return [void]
141
+ #
142
+ # @rbs (?trailers: Hash[String, String | Array[String]]) -> void
143
+ def close(trailers: {})
144
+ trailers = trailers.to_h do |name, value|
145
+ value = value.is_a?(Array) ? value.map { _1.dup.freeze }.freeze : value.dup.freeze
146
+ [name.dup.freeze, value]
147
+ end.freeze
148
+ wake = false
149
+ @state.swap do |state|
150
+ if state[:closed] || state[:closing]
151
+ wake = false
152
+ next state
153
+ end
154
+
155
+ wake = !state[:notified]
156
+ state.merge(closing: true, notified: true, trailers: trailers)
157
+ end
158
+ @wake.call if wake
159
+ end
160
+
161
+ # Returns whether the stream has closed.
162
+ #
163
+ # @return [Boolean]
164
+ #
165
+ # @rbs () -> bool
166
+ def closed?
167
+ @state.value[:closed]
168
+ end
169
+
170
+ # Connects the body to its reactor-owned response stream.
171
+ #
172
+ # @rbs (Array[Budget] budgets, ^() -> void wake, ^(Proc) -> void dispatch, ^(Symbol) -> void finished) -> bool
173
+ def attach(budgets, wake, dispatch, finished)
174
+ @wake = wake
175
+ @dispatch = dispatch
176
+ @finished = finished
177
+ size = @state.value[:size]
178
+ reserved = []
179
+ budgets.each do |budget|
180
+ unless budget.reserve(size)
181
+ reserved.each { |held| held.release(size) }
182
+ finish(:full)
183
+ return false
184
+ end
185
+ reserved << budget
186
+ end
187
+
188
+ @budgets = budgets
189
+ true
190
+ end
191
+
192
+ # Notifies the application that its response stream is ready.
193
+ #
194
+ # @rbs () -> void
195
+ def open
196
+ callback = @on_open
197
+ @dispatch.call(proc { callback.call(self) }) if callback
198
+ @wake.call if @state.value[:notified]
199
+ end
200
+
201
+ # Returns the size of the next buffered chunk, 0 when closing, or nil
202
+ # while waiting for more data.
203
+ #
204
+ # @rbs () -> Integer?
205
+ def next_size
206
+ state = @state.value
207
+ state[:chunks].first&.bytesize || (0 if state[:closing])
208
+ end
209
+
210
+ # Removes up to `max_bytes` from the next buffered chunk.
211
+ #
212
+ # @rbs (Integer max_bytes) -> String?
213
+ def shift(max_bytes)
214
+ chunk = nil
215
+ @state.swap do |state|
216
+ chunk = nil
217
+ first = state[:chunks].first
218
+ next state unless first
219
+
220
+ size = max_bytes < first.bytesize ? max_bytes : first.bytesize
221
+ chunk = first.byteslice(0, size)
222
+ chunks = if size == first.bytesize
223
+ state[:chunks].drop(1)
224
+ else
225
+ [first.byteslice(size..-1).freeze] + state[:chunks].drop(1)
226
+ end
227
+ state.merge(
228
+ chunks: chunks,
229
+ size: state[:size] - size,
230
+ notified: !chunks.empty? || state[:closing]
231
+ )
232
+ end
233
+ release(chunk.bytesize) if chunk
234
+ chunk
235
+ end
236
+
237
+ # Returns the response trailers supplied when the body closed.
238
+ #
239
+ # @rbs () -> Hash[String, String | Array[String]]
240
+ def trailers
241
+ @state.value[:trailers]
242
+ end
243
+
244
+ # Closes the stream and invokes its callback exactly once.
245
+ #
246
+ # @rbs (Symbol reason) -> void
247
+ def finish(reason)
248
+ callback = false
249
+ remaining = 0
250
+ @state.swap do |state|
251
+ if state[:closed]
252
+ callback = false
253
+ remaining = 0
254
+ state
255
+ else
256
+ callback = true
257
+ remaining = state[:size]
258
+ state.merge(chunks: [], size: 0, closed: true, notified: false)
259
+ end
260
+ end
261
+ return unless callback
262
+
263
+ release(remaining)
264
+ @dispatch.call(proc do
265
+ begin
266
+ @on_close&.call(reason)
267
+ ensure
268
+ @finished.call(reason)
269
+ end
270
+ end)
271
+ end
272
+
273
+ private
274
+
275
+ # @rbs (Integer bytes) -> bool
276
+ def reserve(bytes)
277
+ reserved = []
278
+ @budgets.each do |budget|
279
+ unless budget.reserve(bytes)
280
+ reserved.each { |held| held.release(bytes) }
281
+ return false
282
+ end
283
+ reserved << budget
284
+ end
285
+ true
286
+ end
287
+
288
+ # @rbs (Integer bytes) -> void
289
+ def release(bytes)
290
+ @budgets.each { |budget| budget.release(bytes) }
291
+ end
292
+ end
293
+ end
data/lib/raptor/http.rb CHANGED
@@ -107,6 +107,24 @@ module Raptor
107
107
  end
108
108
  end
109
109
 
110
+ # Calls every `rack.response_finished` callback in reverse
111
+ # registration order, rescuing any that raise.
112
+ #
113
+ # @param env [Hash, nil] the Rack environment
114
+ # @param status [Integer, nil] the response status code
115
+ # @param headers [Hash, nil] the response headers
116
+ # @param error [Exception, nil] any error raised during processing, or nil on success
117
+ # @return [void]
118
+ #
119
+ # @rbs (Hash[String, untyped]? env, Integer? status, Hash[String, String | Array[String]]? headers, Exception? error) -> void
120
+ def self.call_response_finished(env, status, headers, error)
121
+ return unless env && env[Rack::RACK_RESPONSE_FINISHED].is_a?(Array)
122
+
123
+ env[Rack::RACK_RESPONSE_FINISHED].reverse_each do |callable|
124
+ callable.call(env, status, headers, error) rescue nil
125
+ end
126
+ end
127
+
110
128
  # Writes a Common Log Format entry to `io`. Write failures are silently
111
129
  # ignored.
112
130
  #
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
- 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
@@ -579,7 +629,7 @@ module Raptor
579
629
  #
580
630
  # @rbs (TCPSocket socket, Hash[String, untyped]? rack_env, Integer? status, Hash[String, String | Array[String]]? headers, Exception error, response_started: bool, hijacked: bool) -> void
581
631
  def handle_app_error(socket, rack_env, status, headers, error, response_started:, hijacked:)
582
- call_response_finished(rack_env, status, headers, error) if rack_env
632
+ Http.call_response_finished(rack_env, status, headers, error)
583
633
  socket.write(INTERNAL_SERVER_ERROR_RESPONSE) rescue nil unless response_started || hijacked
584
634
 
585
635
  if @on_error
@@ -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
@@ -1258,24 +1328,6 @@ module Raptor
1258
1328
  end
1259
1329
  end
1260
1330
 
1261
- # Calls every `rack.response_finished` callback in reverse
1262
- # registration order, rescuing any that raise.
1263
- #
1264
- # @param env [Hash, nil] the Rack environment
1265
- # @param status [Integer, nil] the response status code
1266
- # @param headers [Hash, nil] the response headers
1267
- # @param error [Exception, nil] any error raised during processing, or nil on success
1268
- # @return [void]
1269
- #
1270
- # @rbs (Hash[String, untyped] env, Integer? status, Hash[String, String | Array[String]]? headers, Exception? error) -> void
1271
- def call_response_finished(env, status, headers, error)
1272
- return unless env && env[Rack::RACK_RESPONSE_FINISHED].is_a?(Array)
1273
-
1274
- env[Rack::RACK_RESPONSE_FINISHED].reverse_each do |callable|
1275
- callable.call(env, status, headers, error) rescue nil
1276
- end
1277
- end
1278
-
1279
1331
  # Instance-level wrapper around {Http.write_access_log} that routes to
1280
1332
  # the configured `@access_log_io`.
1281
1333
  #