raptor 0.21.0 → 0.22.1

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,336 @@
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
+ # Creates a shared budget with the given byte limit.
20
+ #
21
+ # @param max_size [Integer] maximum bytes that may be reserved
22
+ # @return [void]
23
+ #
24
+ # @rbs (Integer max_size) -> void
25
+ def initialize(max_size)
26
+ @max_size = max_size
27
+ @size = Atom.new(0)
28
+ end
29
+
30
+ # Reserves bytes when they fit within the shared limit.
31
+ #
32
+ # @param bytes [Integer] number of bytes to reserve
33
+ # @return [Boolean] whether the bytes were reserved
34
+ #
35
+ # @rbs (Integer bytes) -> bool
36
+ def reserve(bytes)
37
+ reserved = false
38
+ @size.swap do |size|
39
+ reserved = size + bytes <= @max_size
40
+ reserved ? size + bytes : size
41
+ end
42
+ reserved
43
+ end
44
+
45
+ # Releases bytes previously charged to the shared limit.
46
+ #
47
+ # @param bytes [Integer] number of bytes to release
48
+ # @return [void]
49
+ #
50
+ # @rbs (Integer bytes) -> void
51
+ def release(bytes)
52
+ @size.swap { |size| size - bytes }
53
+ end
54
+ end
55
+
56
+ # @rbs @max_buffer_size: Integer
57
+ # @rbs @state: Atom
58
+ # @rbs @budgets: Array[Budget]
59
+ # @rbs @wake: ^() -> void
60
+ # @rbs @dispatch: ^(Proc) -> void
61
+ # @rbs @finished: ^(Symbol) -> void
62
+ # @rbs @on_open: (^(DetachedBody) -> void)?
63
+ # @rbs @on_close: (^(Symbol) -> void)?
64
+
65
+ # Creates a detached body with a bounded byte buffer.
66
+ #
67
+ # @param max_buffer_size [Integer] maximum body bytes waiting to be written
68
+ # @return [void]
69
+ #
70
+ # @rbs (?max_buffer_size: Integer) -> void
71
+ def initialize(max_buffer_size: DEFAULT_MAX_BUFFER_SIZE)
72
+ raise ArgumentError, "max_buffer_size must be positive" unless max_buffer_size.positive?
73
+
74
+ @max_buffer_size = max_buffer_size
75
+ @state = Atom.new({ chunks: [], size: 0, closing: false, closed: false, notified: false, trailers: {} })
76
+ @budgets = []
77
+ @wake = proc {}
78
+ @dispatch = proc { |callback| callback.call }
79
+ @finished = proc {}
80
+ @on_open = nil
81
+ @on_close = nil
82
+ end
83
+
84
+ # Registers a callback that runs on the request thread once the server
85
+ # accepts the response stream.
86
+ #
87
+ # @yieldparam stream [DetachedBody] the opened response stream
88
+ # @return [DetachedBody]
89
+ #
90
+ # @rbs () { (DetachedBody stream) -> void } -> DetachedBody
91
+ def on_open(&block)
92
+ @on_open = block
93
+ self
94
+ end
95
+
96
+ # Registers a callback for when the response stream closes.
97
+ #
98
+ # @yieldparam reason [Symbol] why the stream closed
99
+ # @return [DetachedBody]
100
+ #
101
+ # @rbs () { (Symbol reason) -> void } -> DetachedBody
102
+ def on_close(&block)
103
+ @on_close = block
104
+ self
105
+ end
106
+
107
+ # Adds body bytes without waiting for socket or flow-control capacity.
108
+ #
109
+ # @param chunk [String] response body bytes
110
+ # @return [Symbol] `:accepted`, `:full`, or `:closed`
111
+ #
112
+ # @rbs (String chunk) -> Symbol
113
+ def try_write(chunk)
114
+ raise TypeError, "body must yield String values" unless chunk.is_a?(String)
115
+
116
+ chunk = chunk.dup.freeze
117
+ if chunk.empty?
118
+ state = @state.value
119
+ return state[:closed] || state[:closing] ? :closed : :accepted
120
+ end
121
+ return :full unless reserve(chunk.bytesize)
122
+
123
+ result = :closed
124
+ wake = false
125
+ @state.swap do |state|
126
+ if state[:closed] || state[:closing]
127
+ result = :closed
128
+ wake = false
129
+ state
130
+ elsif state[:size] + chunk.bytesize > @max_buffer_size
131
+ result = :full
132
+ wake = false
133
+ state
134
+ else
135
+ result = :accepted
136
+ wake = !state[:notified]
137
+ state.merge(
138
+ chunks: state[:chunks] + [chunk],
139
+ size: state[:size] + chunk.bytesize,
140
+ notified: true
141
+ )
142
+ end
143
+ end
144
+
145
+ if result == :accepted
146
+ @wake.call if wake
147
+ else
148
+ release(chunk.bytesize)
149
+ end
150
+ result
151
+ end
152
+
153
+ # Finishes the body after all accepted bytes have been written.
154
+ #
155
+ # @param trailers [Hash] trailing response headers
156
+ # @return [void]
157
+ #
158
+ # @rbs (?trailers: Hash[String, String | Array[String]]) -> void
159
+ def close(trailers: {})
160
+ trailers = trailers.to_h do |name, value|
161
+ value = value.is_a?(Array) ? value.map { _1.dup.freeze }.freeze : value.dup.freeze
162
+ [name.dup.freeze, value]
163
+ end.freeze
164
+ wake = false
165
+ @state.swap do |state|
166
+ if state[:closed] || state[:closing]
167
+ wake = false
168
+ next state
169
+ end
170
+
171
+ wake = !state[:notified]
172
+ state.merge(closing: true, notified: true, trailers: trailers)
173
+ end
174
+ @wake.call if wake
175
+ end
176
+
177
+ # Returns whether the stream has closed.
178
+ #
179
+ # @return [Boolean]
180
+ #
181
+ # @rbs () -> bool
182
+ def closed?
183
+ @state.value[:closed]
184
+ end
185
+
186
+ # Connects the body to its reactor-owned response stream.
187
+ #
188
+ # @param budgets [Array<Budget>] buffer budgets shared with other bodies
189
+ # @param wake [Proc] called when buffered data or a close is ready to write
190
+ # @param dispatch [Proc] schedules application callbacks off the reactor thread
191
+ # @param finished [Proc] called with the close reason after `on_close`
192
+ # @return [Boolean] false when the bytes already buffered exceed a budget
193
+ #
194
+ # @rbs (Array[Budget] budgets, ^() -> void wake, ^(Proc) -> void dispatch, ^(Symbol) -> void finished) -> bool
195
+ def attach(budgets, wake, dispatch, finished)
196
+ @wake = wake
197
+ @dispatch = dispatch
198
+ @finished = finished
199
+ size = @state.value[:size]
200
+ reserved = []
201
+ budgets.each do |budget|
202
+ unless budget.reserve(size)
203
+ reserved.each { |held| held.release(size) }
204
+ finish(:full)
205
+ return false
206
+ end
207
+ reserved << budget
208
+ end
209
+
210
+ @budgets = budgets
211
+ true
212
+ end
213
+
214
+ # Notifies the application that its response stream is ready.
215
+ #
216
+ # @return [void]
217
+ #
218
+ # @rbs () -> void
219
+ def open
220
+ @on_open&.call(self)
221
+ @wake.call if @state.value[:notified]
222
+ end
223
+
224
+ # Returns the size of the next buffered chunk, 0 when closing, or nil
225
+ # while waiting for more data.
226
+ #
227
+ # @return [Integer, nil]
228
+ #
229
+ # @rbs () -> Integer?
230
+ def next_size
231
+ state = @state.value
232
+ state[:chunks].first&.bytesize || (0 if state[:closing])
233
+ end
234
+
235
+ # Removes up to `max_bytes` from the next buffered chunk.
236
+ #
237
+ # @param max_bytes [Integer] the largest chunk to return
238
+ # @return [String, nil] the removed bytes, or nil when nothing is buffered
239
+ #
240
+ # @rbs (Integer max_bytes) -> String?
241
+ def shift(max_bytes)
242
+ chunk = nil
243
+ @state.swap do |state|
244
+ chunk = nil
245
+ first = state[:chunks].first
246
+ next state unless first
247
+
248
+ size = max_bytes < first.bytesize ? max_bytes : first.bytesize
249
+ chunk = first.byteslice(0, size)
250
+ chunks = if size == first.bytesize
251
+ state[:chunks].drop(1)
252
+ else
253
+ [first.byteslice(size..-1).freeze] + state[:chunks].drop(1)
254
+ end
255
+ state.merge(
256
+ chunks: chunks,
257
+ size: state[:size] - size,
258
+ notified: !chunks.empty? || state[:closing]
259
+ )
260
+ end
261
+ release(chunk.bytesize) if chunk
262
+ chunk
263
+ end
264
+
265
+ # Returns the response trailers supplied when the body closed.
266
+ #
267
+ # @return [Hash] trailing response headers
268
+ #
269
+ # @rbs () -> Hash[String, String | Array[String]]
270
+ def trailers
271
+ @state.value[:trailers]
272
+ end
273
+
274
+ # Closes the stream and invokes its callback exactly once.
275
+ #
276
+ # @param reason [Symbol] why the stream closed
277
+ # @return [void]
278
+ #
279
+ # @rbs (Symbol reason) -> void
280
+ def finish(reason)
281
+ callback = false
282
+ remaining = 0
283
+ @state.swap do |state|
284
+ if state[:closed]
285
+ callback = false
286
+ remaining = 0
287
+ state
288
+ else
289
+ callback = true
290
+ remaining = state[:size]
291
+ state.merge(chunks: [], size: 0, closed: true, notified: false)
292
+ end
293
+ end
294
+ return unless callback
295
+
296
+ release(remaining)
297
+ @dispatch.call(proc do
298
+ begin
299
+ @on_close&.call(reason)
300
+ ensure
301
+ @finished.call(reason)
302
+ end
303
+ end)
304
+ end
305
+
306
+ private
307
+
308
+ # Reserves bytes from every budget attached to this body.
309
+ #
310
+ # @param bytes [Integer] number of bytes to reserve
311
+ # @return [Boolean] whether every budget accepted the reservation
312
+ #
313
+ # @rbs (Integer bytes) -> bool
314
+ def reserve(bytes)
315
+ reserved = []
316
+ @budgets.each do |budget|
317
+ unless budget.reserve(bytes)
318
+ reserved.each { |held| held.release(bytes) }
319
+ return false
320
+ end
321
+ reserved << budget
322
+ end
323
+ true
324
+ end
325
+
326
+ # Releases bytes from every budget attached to this body.
327
+ #
328
+ # @param bytes [Integer] number of bytes to release
329
+ # @return [void]
330
+ #
331
+ # @rbs (Integer bytes) -> void
332
+ def release(bytes)
333
+ @budgets.each { |budget| budget.release(bytes) }
334
+ end
335
+ end
336
+ end
data/lib/raptor/http.rb CHANGED
@@ -27,6 +27,16 @@ module Raptor
27
27
  def message = "could not write response"
28
28
  end
29
29
 
30
+ # Returns whether an HTTP status forbids an entity body.
31
+ #
32
+ # @param status [Integer] the response status code
33
+ # @return [Boolean]
34
+ #
35
+ # @rbs (Integer status) -> bool
36
+ def self.no_entity_body_status?(status)
37
+ (status >= 100 && status < 200) || status == 204 || status == 304
38
+ end
39
+
30
40
  # Writes `string` in full, retrying on partial writes. Bounded by
31
41
  # `timeout` so a slow client can't pin the writing thread.
32
42
  #
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,34 @@ module Raptor
97
98
  false
98
99
  end
99
100
 
101
+ # Encodes one HTTP/1.1 response chunk.
102
+ #
103
+ # @param chunk [String] response body bytes
104
+ # @return [String] the encoded chunk
105
+ #
106
+ # @rbs (String chunk) -> String
107
+ def self.encode_chunk(chunk)
108
+ HttpParser.chunked_encode(String.new, chunk)
109
+ end
110
+
111
+ # Encodes the final HTTP/1.1 response chunk and its trailers.
112
+ #
113
+ # @param trailers [Hash] trailing response headers
114
+ # @return [String] the final chunk followed by the trailer section
115
+ #
116
+ # @rbs (Hash[String, String | Array[String]] trailers) -> String
117
+ def self.encode_trailers(trailers)
118
+ normalized = trailers.each_with_object({}) do |(key, value), headers|
119
+ name = key.match?(/[A-Z]/) ? key.downcase : key
120
+ next if name.start_with?(RACK_HEADER_PREFIX) || name == "status"
121
+
122
+ headers[name] = value
123
+ end
124
+ response = +"0\r\n"
125
+ HttpParser.format_headers(response, normalized)
126
+ response << "\r\n"
127
+ end
128
+
100
129
  # Advances an HTTP/1.x request parse from the state hash's buffered
101
130
  # bytes. A complete well-formed request returns with `:complete` set
102
131
  # plus populated `:env` and `:body`; malformed or oversized input flips
@@ -492,7 +521,7 @@ module Raptor
492
521
  #
493
522
  # @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
523
  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)
524
+ keep_alive = process_request(socket, id, env, parse_data, body, reactor, request_count, remote_addr, url_scheme)
496
525
  eager_keepalive(socket, id, reactor, thread_pool, request_count, remote_addr, url_scheme) if keep_alive
497
526
  end
498
527
 
@@ -501,20 +530,22 @@ module Raptor
501
530
  # for another request.
502
531
  #
503
532
  # @param socket [TCPSocket] the client socket
533
+ # @param id [Integer] unique client identifier
504
534
  # @param env [Hash] partial env hash from the HTTP parser
505
535
  # @param parse_data [Hash] metadata from the parsing pass
506
536
  # @param body [String, nil] decoded request body
537
+ # @param reactor [Reactor] the reactor managing the client connection
507
538
  # @param request_count [Integer] number of requests handled on this connection
508
539
  # @param remote_addr [String] client IP address
509
540
  # @param url_scheme [String] "http" or "https"
510
541
  # @return [Boolean] true if the connection should be kept alive
511
542
  #
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)
543
+ # @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
544
+ def process_request(socket, id, env, parse_data, body, reactor, request_count, remote_addr, url_scheme)
514
545
  if @clean_fiber_locals
515
- Fiber.new { perform_request(socket, env, parse_data, body, request_count, remote_addr, url_scheme) }.resume
546
+ Fiber.new { perform_request(socket, id, env, parse_data, body, reactor, request_count, remote_addr, url_scheme) }.resume
516
547
  else
517
- perform_request(socket, env, parse_data, body, request_count, remote_addr, url_scheme)
548
+ perform_request(socket, id, env, parse_data, body, reactor, request_count, remote_addr, url_scheme)
518
549
  end
519
550
  ensure
520
551
  ThreadLocals.clear if @clean_thread_locals
@@ -522,13 +553,25 @@ module Raptor
522
553
 
523
554
  # Calls the Rack app and writes its response for one request.
524
555
  #
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)
556
+ # @param socket [TCPSocket] the client socket
557
+ # @param id [Integer] unique client identifier
558
+ # @param env [Hash] partial env hash from the HTTP parser
559
+ # @param parse_data [Hash] metadata from the parsing pass
560
+ # @param body [String, nil] decoded request body
561
+ # @param reactor [Reactor] the reactor managing the client connection
562
+ # @param request_count [Integer] number of requests handled on this connection
563
+ # @param remote_addr [String] client IP address
564
+ # @param url_scheme [String] "http" or "https"
565
+ # @return [Boolean] true if the connection should be kept alive
566
+ #
567
+ # @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
568
+ def perform_request(socket, id, env, parse_data, body, reactor, request_count, remote_addr, url_scheme)
527
569
  rack_env = nil
528
570
  status = nil
529
571
  headers = nil
530
572
  hijacked = false
531
573
  keep_alive = false
574
+ detached = false
532
575
  response_started = false
533
576
 
534
577
  begin
@@ -540,16 +583,40 @@ module Raptor
540
583
  body.close if body.respond_to?(:close)
541
584
  else
542
585
  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)
586
+ no_body = rack_env[Rack::REQUEST_METHOD] == "HEAD" || Http.no_entity_body_status?(status)
587
+ detached = body.is_a?(DetachedBody) && !no_body
588
+ if detached
589
+ chunked = rack_env[Rack::SERVER_PROTOCOL] == HTTP_11
590
+ keep_alive = chunked && keep_alive?(rack_env, request_count)
591
+ response_started = true
592
+ write_detached_response(socket, status, headers, chunked: chunked, keep_alive: keep_alive)
593
+ finished = proc do |reason|
594
+ error = IOError.new("detached response closed") unless reason == :closed
595
+ write_access_log(rack_env, status, "-", remote_addr) if @access_log_io
596
+ Http.call_response_finished(rack_env, status, headers, error)
597
+ end
598
+ reactor.attach_http1_body(
599
+ socket,
600
+ id,
601
+ body,
602
+ { chunked: chunked, keep_alive: keep_alive, request_count: request_count, remote_addr: remote_addr, url_scheme: url_scheme },
603
+ finished
604
+ )
605
+ else
606
+ streaming = body.respond_to?(:call) && !body.respond_to?(:each)
607
+ keep_alive = (hijacked || streaming) ? false : keep_alive?(rack_env, request_count)
608
+ response_size = response_size(headers, body) if @access_log_io && !hijacked
609
+ response_started = true
610
+ write_response(socket, rack_env, status, headers, body, keep_alive: keep_alive)
611
+ body.finish(:closed) if body.is_a?(DetachedBody)
612
+ end
548
613
  end
549
614
 
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
615
+ unless detached
616
+ write_access_log(rack_env, status, response_size, remote_addr) if @access_log_io && !hijacked
617
+ Http.call_response_finished(rack_env, status, headers, nil)
618
+ end
619
+ keep_alive && !hijacked && !detached
553
620
  rescue => error
554
621
  keep_alive = false
555
622
  handle_app_error(socket, rack_env, status, headers, error, response_started: response_started, hijacked: hijacked)
@@ -557,7 +624,7 @@ module Raptor
557
624
  rack_input = rack_env && rack_env[Rack::RACK_INPUT]
558
625
  rack_input.close! rescue nil if rack_input.respond_to?(:close!)
559
626
 
560
- unless hijacked || keep_alive
627
+ unless hijacked || keep_alive || detached
561
628
  socket.close rescue nil
562
629
  end
563
630
  end
@@ -666,9 +733,11 @@ module Raptor
666
733
 
667
734
  keep_alive = process_request(
668
735
  socket,
736
+ id,
669
737
  env,
670
738
  parse_data,
671
739
  body,
740
+ reactor,
672
741
  request_count,
673
742
  remote_addr,
674
743
  url_scheme
@@ -895,6 +964,31 @@ module Raptor
895
964
  socket_write(socket, response)
896
965
  end
897
966
 
967
+ # Starts a detached response, chunked on HTTP/1.1 and ended by closing
968
+ # the connection on HTTP/1.0.
969
+ #
970
+ # @param socket [TCPSocket] the client socket to write to
971
+ # @param status [Integer] HTTP status code
972
+ # @param headers [Hash] response headers from the Rack application
973
+ # @param chunked [Boolean] whether to use chunked transfer encoding
974
+ # @param keep_alive [Boolean] whether to send a keep-alive connection header
975
+ # @return [void]
976
+ #
977
+ # @rbs (TCPSocket socket, Integer status, Hash[String, String | Array[String]] headers, chunked: bool, keep_alive: bool) -> void
978
+ def write_detached_response(socket, status, headers, chunked:, keep_alive:)
979
+ validate_status(status)
980
+ headers = normalize_headers(headers)
981
+ validate_headers(headers, status, false)
982
+ headers.delete(Rack::CONTENT_LENGTH)
983
+ headers[Rack::TRANSFER_ENCODING] = TRANSFER_ENCODING_CHUNKED if chunked
984
+ headers["connection"] = keep_alive ? CONNECTION_KEEPALIVE : CONNECTION_CLOSE
985
+
986
+ response = build_status_line(chunked ? HTTP_11 : HTTP_10, status)
987
+ HttpParser.format_headers(response, headers)
988
+ response << "\r\n"
989
+ socket_write(socket, response)
990
+ end
991
+
898
992
  # Writes a complete HTTP response to the socket.
899
993
  #
900
994
  # @param socket [TCPSocket] the client socket to write to
@@ -910,7 +1004,7 @@ module Raptor
910
1004
  validate_status(status)
911
1005
  response_hijack = headers.is_a?(Hash) ? headers.delete(Rack::RACK_HIJACK) : nil
912
1006
  headers = normalize_headers(headers)
913
- no_entity_body = (status >= 100 && status < 200) || status == 204 || status == 304
1007
+ no_entity_body = Http.no_entity_body_status?(status)
914
1008
  validate_headers(headers, status, no_entity_body)
915
1009
 
916
1010
  headers["connection"] = keep_alive ? CONNECTION_KEEPALIVE : CONNECTION_CLOSE