raptor 0.22.0 → 0.22.2

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fc05eb1a4a916e5094c4496f18a3d3b179869a42efc1a7fd42ccda546b46e0bc
4
- data.tar.gz: 1b975f682fcbaea183226775277cf441d4996899f4348237b96bf073586b1082
3
+ metadata.gz: cb1bd33db4e839d7c8e2ef3717de3123187d4d4960a240811156a0a342d3a086
4
+ data.tar.gz: c1363e325747276176c271b68a2b9f7e066b34fc8073289848720d70b9ba98e5
5
5
  SHA512:
6
- metadata.gz: c39ee7c2d2c4f68b8015a841c3fdadfb26b42f587041d6e85e69fc930abcb9fe65ce4841e3562c31b2ff6a0ac904787ba97ca1a26dafa25ba7a0f71b0069b2a1
7
- data.tar.gz: 71604474925af588347c5e82f294eca27b5d51c3dc161ece9bb63508b9ea389196173cf890be8f8ebbd030dea284fc1e0d9f4aac26650f02c05303eb09707c7c
6
+ metadata.gz: 799e78896522004ad98dd8cb9dc0170d32408bcd507f2fc11d511a753f5b2b04d2fcbff0588463a35245626b069d6c93d07cd3dcab7c2f07fda30c6c4f5134b7
7
+ data.tar.gz: ebc5e4eb852c1af7d413b5a6ed6614d5bd5f3c569bdca6398583fc892485767fbab3770217f0a266ea774303c0e1b16a566fbba69e863e00ea491453e164d281
data/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.22.2] - 2026-10-03
4
+
5
+ - Load `Raptor::DetachedBody` with `require "raptor"`
6
+
7
+ ## [0.22.1] - 2026-10-03
8
+
9
+ - Stop waiting for more HTTP/2 frames on the collector thread
10
+ - Run `DetachedBody#on_open` callbacks on the request thread
11
+ - Attach detached response bodies without polling
12
+
3
13
  ## [0.22.0] - 2026-10-03
4
14
 
5
15
  - Add detached HTTP/1.1 response bodies
data/README.md CHANGED
@@ -37,7 +37,7 @@ run proc { |_env| [200, { "content-type" => "text/plain" }, ["Hello, World!"]] }
37
37
  ```
38
38
  > bundle exec raptor -w 10 -t 3 hello_world.ru
39
39
  [Raptor 72876|Main|Main] Cluster initializing:
40
- [Raptor 72876|Main|Main] ├─ Version: 0.22.0
40
+ [Raptor 72876|Main|Main] ├─ Version: 0.22.2
41
41
  [Raptor 72876|Main|Main] ├─ Ruby Version: ruby 4.0.6 (2026-07-14 revision 03b6d3f889) +YJIT +PRISM [arm64-darwin23]
42
42
  [Raptor 72876|Main|Main] ├─ Environment: development
43
43
  [Raptor 72876|Main|Main] ├─ Master PID: 72876
@@ -208,7 +208,7 @@ Set `control_url` to a Unix socket URL such as `unix:///tmp/raptor-control.sock`
208
208
 
209
209
  ## (Micro) Benchmarks
210
210
 
211
- Raptor 0.22.0 vs Puma 8.0.2 vs Falcon 0.57.0 across two workload profiles. **IO-bound** is a GET endpoint that
211
+ Raptor 0.22.2 vs Puma 8.0.2 vs Falcon 0.57.0 across two workload profiles. **IO-bound** is a GET endpoint that
212
212
  interleaves 5-10 short sleeps (total 2.5-15ms) with small CPU work, simulating a read path that makes several DB or
213
213
  cache calls. **CPU-bound** is a POST endpoint that accepts a small JSON body, interleaves 3-5 chunks of JSON item
214
214
  building (total 450-1500 items) with sub-100µs sleeps, and returns the built array, simulating a write path that does
@@ -222,22 +222,22 @@ disabled, and both threaded servers allow 999 requests per HTTP/1.1 keep-alive c
222
222
  Each cell reports the median throughput and median p95 latency independently across 3 runs, so the two numbers in a row
223
223
  may come from different runs. Every run starts a fresh server process so the samples are independent of each other;
224
224
  state accumulated in a previous run cannot bias the next. Across the whole table, the widest spread
225
- ((max - min) / 2 / median) between runs of a single cell was ±13.2% for throughput and ±25.6% for p95.
225
+ ((max - min) / 2 / median) between runs of a single cell was ±19.6% for throughput and ±13.6% for p95.
226
226
 
227
227
  | Protocol | Workload | Raptor mode | Raptor req/s | Raptor p95 | Puma req/s | Puma p95 | vs Puma req/s | vs Puma p95 | Falcon req/s | Falcon p95 | vs Falcon req/s | vs Falcon p95 |
228
228
  | --------------------- | -------- | ----------- | ------------ | ---------- | ----------- | --------- | ------------- | ------------ | ------------ | ---------- | --------------- | ------------- |
229
- | HTTP/1.1 | IO | Fixed | 2.91k req/s | 82.40 ms | 1.51k req/s | 125.20 ms | 92.2% higher | 34.2% lower | 12.08k req/s | 14.40 ms | 75.9% lower | 472.2% higher |
230
- | HTTP/1.1 | IO | Scaling | 7.01k req/s | 31.70 ms | 1.51k req/s | 125.20 ms | 362.9% higher | 74.7% lower | 12.08k req/s | 14.40 ms | 42.0% lower | 120.1% higher |
231
- | HTTP/1.1 | CPU | Fixed | 7.24k req/s | 37.90 ms | 8.50k req/s | 23.10 ms | 14.9% lower | 64.1% higher | 6.44k req/s | 28.90 ms | 12.4% higher | 31.1% higher |
232
- | HTTP/1.1 | CPU | Scaling | 6.48k req/s | 39.50 ms | 8.50k req/s | 23.10 ms | 23.8% lower | 71.0% higher | 6.44k req/s | 28.90 ms | 0.6% higher | 36.7% higher |
233
- | HTTP/1.1 (keep-alive) | IO | Fixed | 2.26k req/s | 67.20 ms | 1.47k req/s | 105.60 ms | 53.7% higher | 36.4% lower | 6.22k req/s | 28.20 ms | 63.7% lower | 138.3% higher |
234
- | HTTP/1.1 (keep-alive) | IO | Scaling | 8.20k req/s | 21.80 ms | 1.47k req/s | 105.60 ms | 458.3% higher | 79.4% lower | 6.22k req/s | 28.20 ms | 32.0% higher | 22.7% lower |
235
- | HTTP/1.1 (keep-alive) | CPU | Fixed | 7.21k req/s | 27.10 ms | 8.38k req/s | 23.60 ms | 14.0% lower | 14.8% higher | 6.87k req/s | 34.20 ms | 5.0% higher | 20.8% lower |
236
- | HTTP/1.1 (keep-alive) | CPU | Scaling | 7.56k req/s | 28.10 ms | 8.38k req/s | 23.60 ms | 9.8% lower | 19.1% higher | 6.87k req/s | 34.20 ms | 10.1% higher | 17.8% lower |
237
- | HTTP/2 | IO | Fixed | 1.64k req/s | 112.08 ms | N/A | N/A | - | - | 6.34k req/s | 28.16 ms | 74.2% lower | 298.1% higher |
238
- | HTTP/2 | IO | Scaling | 6.90k req/s | 27.33 ms | N/A | N/A | - | - | 6.34k req/s | 28.16 ms | 8.8% higher | 2.9% lower |
239
- | HTTP/2 | CPU | Fixed | 6.90k req/s | 29.30 ms | N/A | N/A | - | - | 6.69k req/s | 65.77 ms | 3.2% higher | 55.4% lower |
240
- | HTTP/2 | CPU | Scaling | 7.51k req/s | 26.86 ms | N/A | N/A | - | - | 6.69k req/s | 65.77 ms | 12.2% higher | 59.2% lower |
229
+ | HTTP/1.1 | IO | Fixed | 2.95k req/s | 80.20 ms | 1.55k req/s | 122.60 ms | 90.1% higher | 34.6% lower | 12.24k req/s | 14.00 ms | 75.9% lower | 472.9% higher |
230
+ | HTTP/1.1 | IO | Scaling | 7.05k req/s | 30.40 ms | 1.55k req/s | 122.60 ms | 354.1% higher | 75.2% lower | 12.24k req/s | 14.00 ms | 42.4% lower | 117.1% higher |
231
+ | HTTP/1.1 | CPU | Fixed | 7.29k req/s | 35.00 ms | 8.73k req/s | 20.30 ms | 16.4% lower | 72.4% higher | 6.71k req/s | 26.80 ms | 8.7% higher | 30.6% higher |
232
+ | HTTP/1.1 | CPU | Scaling | 6.73k req/s | 36.90 ms | 8.73k req/s | 20.30 ms | 22.8% lower | 81.8% higher | 6.71k req/s | 26.80 ms | 0.4% higher | 37.7% higher |
233
+ | HTTP/1.1 (keep-alive) | IO | Fixed | 2.52k req/s | 70.70 ms | 1.50k req/s | 102.60 ms | 67.8% higher | 31.1% lower | 6.23k req/s | 28.20 ms | 59.5% lower | 150.7% higher |
234
+ | HTTP/1.1 (keep-alive) | IO | Scaling | 8.19k req/s | 21.90 ms | 1.50k req/s | 102.60 ms | 445.8% higher | 78.7% lower | 6.23k req/s | 28.20 ms | 31.6% higher | 22.3% lower |
235
+ | HTTP/1.1 (keep-alive) | CPU | Fixed | 7.04k req/s | 28.30 ms | 8.62k req/s | 21.50 ms | 18.3% lower | 31.6% higher | 7.07k req/s | 32.10 ms | 0.4% lower | 11.8% lower |
236
+ | HTTP/1.1 (keep-alive) | CPU | Scaling | 7.78k req/s | 25.70 ms | 8.62k req/s | 21.50 ms | 9.8% lower | 19.5% higher | 7.07k req/s | 32.10 ms | 9.9% higher | 19.9% lower |
237
+ | HTTP/2 | IO | Fixed | 1.50k req/s | 128.92 ms | N/A | N/A | - | - | 6.57k req/s | 27.29 ms | 77.2% lower | 372.4% higher |
238
+ | HTTP/2 | IO | Scaling | 9.41k req/s | 19.30 ms | N/A | N/A | - | - | 6.57k req/s | 27.29 ms | 43.1% higher | 29.3% lower |
239
+ | HTTP/2 | CPU | Fixed | 7.75k req/s | 26.55 ms | N/A | N/A | - | - | 7.24k req/s | 49.42 ms | 7.1% higher | 46.3% lower |
240
+ | HTTP/2 | CPU | Scaling | 7.54k req/s | 26.97 ms | N/A | N/A | - | - | 7.24k req/s | 49.42 ms | 4.2% higher | 45.4% lower |
241
241
 
242
242
  > ruby 4.0.7 (2026-09-15 revision 229531a6cf) +YJIT +PRISM [aarch64-linux]
243
243
  > 10 worker processes; fixed Raptor and Puma run 3 threads per worker; scaling Raptor starts at 3 with no fixed limit;
@@ -349,7 +349,7 @@ The `Writer` hands serialized frames to the reactor, which writes as the socket
349
349
 
350
350
  Flow control uses similar CAS-protected atoms. Ordinary Rack bodies wait for connection and stream capacity as they yield. A `Raptor::DetachedBody` instead returns its application thread immediately; the reactor schedules its bounded buffer as capacity becomes available and closes it when the client cancels. This keeps long-lived streams from consuming one application thread each.
351
351
 
352
- Frame processing also has an eager loop. After processing one batch of frames, the h2 handler tries to `read_nonblock` one more time to see if the next batch is already available. Up to eight rounds are consumed inline before handing back to the reactor, and the loop bails out early once the app thread pool has more queued work than worker slots so one busy connection cannot starve the collector. This is the same principle as the HTTP/1.1 eager keep-alive: amortise the reactor round-trip when the client is actively sending, but back off under saturation.
352
+ Frame processing also has an eager loop. After processing one batch of frames, the h2 handler checks `wait_readable(0)` and reads the next batch only if it has already arrived. Up to eight rounds are consumed inline before handing back to the reactor, and the loop bails out early once the app thread pool has more queued work than worker slots so one busy connection cannot starve the collector. This is the same principle as the HTTP/1.1 eager keep-alive: amortise the reactor round-trip when the client is actively sending, but back off under saturation.
353
353
 
354
354
  During worker shutdown, Raptor stops accepting connections and sends GOAWAY with the last stream handed to the Rack application. Later streams are refused while application work and detached bodies drain. The reactor remains active so in-flight responses can receive flow-control updates; detached bodies still open when the drain period expires are cancelled before their connections close.
355
355
 
data/lib/raptor/cli.rb CHANGED
@@ -1,10 +1,11 @@
1
1
  # rbs_inline: enabled
2
2
  # frozen_string_literal: true
3
3
 
4
- require "concurrent/utility/processor_counter"
5
4
  require "json"
6
5
  require "optparse"
7
6
 
7
+ require "concurrent/utility/processor_counter"
8
+
8
9
  require_relative "cluster"
9
10
 
10
11
  module Raptor
@@ -46,18 +47,18 @@ module Raptor
46
47
  chunk_data_timeout: 10,
47
48
  write_timeout: 5,
48
49
  max_body_size: nil,
49
- body_spool_threshold: 1024 * 1024,
50
+ body_spool_threshold: 1024 * 1024
50
51
  },
51
52
  http1: {
52
53
  ractors: nil,
53
54
  persistent_data_timeout: 65,
54
- max_keepalive_requests: 1000,
55
+ max_keepalive_requests: 1000
55
56
  },
56
57
  http2: {
57
58
  ractors: nil,
58
59
  max_concurrent_streams: 100,
59
60
  keepalive_interval: 10,
60
- keepalive_timeout: 5,
61
+ keepalive_timeout: 5
61
62
  },
62
63
  worker_boot_timeout: 60,
63
64
  worker_timeout: 60,
@@ -1,11 +1,11 @@
1
1
  # rbs_inline: enabled
2
2
  # frozen_string_literal: true
3
3
 
4
- require "concurrent/utility/processor_counter"
5
4
  require "json"
6
5
  require "time"
7
6
 
8
7
  require "atomic-ruby/atomic_thread_pool"
8
+ require "concurrent/utility/processor_counter"
9
9
  require "rack/builder"
10
10
  require "ractor-pool"
11
11
 
@@ -339,8 +339,8 @@ module Raptor
339
339
  pool_capacity: [capacity - total_work, 0].max,
340
340
  busy_threads: active,
341
341
  max_threads: capacity,
342
- requests_count: stat.fetch(:requests, 0),
343
- },
342
+ requests_count: stat.fetch(:requests, 0)
343
+ }
344
344
  }
345
345
  end
346
346
 
@@ -350,7 +350,7 @@ module Raptor
350
350
  phase: @phase,
351
351
  booted_workers: worker_status.count { |worker| worker[:booted] },
352
352
  old_workers: worker_status.count { |worker| worker[:phase] != @phase },
353
- worker_status: worker_status,
353
+ worker_status: worker_status
354
354
  }
355
355
  end
356
356
 
@@ -5,17 +5,19 @@ require "json"
5
5
  require "socket"
6
6
  require "uri"
7
7
 
8
+ require "atomic-ruby/atom"
9
+
8
10
  module Raptor
9
11
  # Serves cluster statistics over a Unix socket.
10
12
  #
11
13
  class ControlServer
14
+ SHUTDOWN = :shutdown
15
+
12
16
  # @rbs @path: String
13
17
  # @rbs @stats: ^() -> Hash[Symbol, untyped]
14
18
  # @rbs @server: UNIXServer?
15
- # @rbs @client: UNIXSocket?
19
+ # @rbs @client: Atom
16
20
  # @rbs @thread: Thread?
17
- # @rbs @running: bool
18
- # @rbs @mutex: Mutex
19
21
 
20
22
  # Creates a control server for `url` without binding it.
21
23
  #
@@ -32,10 +34,8 @@ module Raptor
32
34
  @path = uri.path
33
35
  @stats = stats
34
36
  @server = nil
35
- @client = nil
37
+ @client = Atom.new(nil)
36
38
  @thread = nil
37
- @running = false
38
- @mutex = Mutex.new
39
39
  end
40
40
 
41
41
  # Binds the Unix socket.
@@ -54,7 +54,6 @@ module Raptor
54
54
  #
55
55
  # @rbs () -> void
56
56
  def start
57
- @running = true
58
57
  owner_pid = Process.pid
59
58
  at_exit { File.delete(@path) rescue nil if Process.pid == owner_pid }
60
59
 
@@ -71,17 +70,24 @@ module Raptor
71
70
  #
72
71
  # @rbs () -> void
73
72
  def shutdown
74
- @running = false
75
- @mutex.synchronize do
76
- @server&.close
77
- @client&.close
73
+ client = nil
74
+ @client.swap do |current|
75
+ client = current
76
+ SHUTDOWN
78
77
  end
78
+ @server&.close
79
+ client.close if client.is_a?(UNIXSocket)
79
80
  @thread&.join
80
81
  File.delete(@path) rescue nil
81
82
  end
82
83
 
83
84
  private
84
85
 
86
+ # Removes a stale socket while refusing to replace an active server.
87
+ #
88
+ # @return [void]
89
+ # @raise [RuntimeError] if another server is listening on the socket
90
+ #
85
91
  # @rbs () -> void
86
92
  def remove_stale_socket
87
93
  return unless File.exist?(@path)
@@ -94,21 +100,34 @@ module Raptor
94
100
  end
95
101
  end
96
102
 
103
+ # Accepts and handles control requests until shutdown begins.
104
+ #
105
+ # @return [void]
106
+ #
97
107
  # @rbs () -> void
98
108
  def serve
99
- while @running
109
+ until @client.value == SHUTDOWN
100
110
  readable, = IO.select([@server], nil, nil, 1)
101
111
  next unless readable
102
112
 
103
- @mutex.synchronize do
104
- client = @server.accept_nonblock(exception: false)
105
- @client = client if client.is_a?(UNIXSocket)
113
+ client = @server.accept_nonblock(exception: false)
114
+ next unless client.is_a?(UNIXSocket)
115
+
116
+ if @client.swap { |current| current == SHUTDOWN ? current : client } == SHUTDOWN
117
+ client.close
118
+ return
106
119
  end
107
- handle(@client) if @client
120
+
121
+ handle(client)
108
122
  end
109
123
  rescue IOError, Errno::EBADF
110
124
  end
111
125
 
126
+ # Writes the response for one control-socket request.
127
+ #
128
+ # @param client [UNIXSocket] connected control client
129
+ # @return [void]
130
+ #
112
131
  # @rbs (UNIXSocket client) -> void
113
132
  def handle(client)
114
133
  request_line = client.gets
@@ -125,7 +144,7 @@ module Raptor
125
144
  rescue IOError, SystemCallError
126
145
  ensure
127
146
  client.close rescue nil
128
- @mutex.synchronize { @client = nil }
147
+ @client.swap { |current| current.equal?(client) ? nil : current }
129
148
  end
130
149
  end
131
150
  end
@@ -16,12 +16,22 @@ module Raptor
16
16
  # @rbs @max_size: Integer
17
17
  # @rbs @size: Atom
18
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
+ #
19
24
  # @rbs (Integer max_size) -> void
20
25
  def initialize(max_size)
21
26
  @max_size = max_size
22
27
  @size = Atom.new(0)
23
28
  end
24
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
+ #
25
35
  # @rbs (Integer bytes) -> bool
26
36
  def reserve(bytes)
27
37
  reserved = false
@@ -32,6 +42,11 @@ module Raptor
32
42
  reserved
33
43
  end
34
44
 
45
+ # Releases bytes previously charged to the shared limit.
46
+ #
47
+ # @param bytes [Integer] number of bytes to release
48
+ # @return [void]
49
+ #
35
50
  # @rbs (Integer bytes) -> void
36
51
  def release(bytes)
37
52
  @size.swap { |size| size - bytes }
@@ -57,7 +72,7 @@ module Raptor
57
72
  raise ArgumentError, "max_buffer_size must be positive" unless max_buffer_size.positive?
58
73
 
59
74
  @max_buffer_size = max_buffer_size
60
- @state = Atom.new({chunks: [], size: 0, closing: false, closed: false, notified: false, trailers: {}})
75
+ @state = Atom.new({ chunks: [], size: 0, closing: false, closed: false, notified: false, trailers: {} })
61
76
  @budgets = []
62
77
  @wake = proc {}
63
78
  @dispatch = proc { |callback| callback.call }
@@ -66,7 +81,8 @@ module Raptor
66
81
  @on_close = nil
67
82
  end
68
83
 
69
- # Registers a callback for when the server accepts the response stream.
84
+ # Registers a callback that runs on the request thread once the server
85
+ # accepts the response stream.
70
86
  #
71
87
  # @yieldparam stream [DetachedBody] the opened response stream
72
88
  # @return [DetachedBody]
@@ -169,6 +185,12 @@ module Raptor
169
185
 
170
186
  # Connects the body to its reactor-owned response stream.
171
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
+ #
172
194
  # @rbs (Array[Budget] budgets, ^() -> void wake, ^(Proc) -> void dispatch, ^(Symbol) -> void finished) -> bool
173
195
  def attach(budgets, wake, dispatch, finished)
174
196
  @wake = wake
@@ -191,16 +213,19 @@ module Raptor
191
213
 
192
214
  # Notifies the application that its response stream is ready.
193
215
  #
216
+ # @return [void]
217
+ #
194
218
  # @rbs () -> void
195
219
  def open
196
- callback = @on_open
197
- @dispatch.call(proc { callback.call(self) }) if callback
220
+ @on_open&.call(self)
198
221
  @wake.call if @state.value[:notified]
199
222
  end
200
223
 
201
224
  # Returns the size of the next buffered chunk, 0 when closing, or nil
202
225
  # while waiting for more data.
203
226
  #
227
+ # @return [Integer, nil]
228
+ #
204
229
  # @rbs () -> Integer?
205
230
  def next_size
206
231
  state = @state.value
@@ -209,6 +234,9 @@ module Raptor
209
234
 
210
235
  # Removes up to `max_bytes` from the next buffered chunk.
211
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
+ #
212
240
  # @rbs (Integer max_bytes) -> String?
213
241
  def shift(max_bytes)
214
242
  chunk = nil
@@ -236,6 +264,8 @@ module Raptor
236
264
 
237
265
  # Returns the response trailers supplied when the body closed.
238
266
  #
267
+ # @return [Hash] trailing response headers
268
+ #
239
269
  # @rbs () -> Hash[String, String | Array[String]]
240
270
  def trailers
241
271
  @state.value[:trailers]
@@ -243,6 +273,9 @@ module Raptor
243
273
 
244
274
  # Closes the stream and invokes its callback exactly once.
245
275
  #
276
+ # @param reason [Symbol] why the stream closed
277
+ # @return [void]
278
+ #
246
279
  # @rbs (Symbol reason) -> void
247
280
  def finish(reason)
248
281
  callback = false
@@ -272,6 +305,11 @@ module Raptor
272
305
 
273
306
  private
274
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
+ #
275
313
  # @rbs (Integer bytes) -> bool
276
314
  def reserve(bytes)
277
315
  reserved = []
@@ -285,6 +323,11 @@ module Raptor
285
323
  true
286
324
  end
287
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
+ #
288
331
  # @rbs (Integer bytes) -> void
289
332
  def release(bytes)
290
333
  @budgets.each { |budget| budget.release(bytes) }
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
@@ -100,6 +100,9 @@ module Raptor
100
100
 
101
101
  # Encodes one HTTP/1.1 response chunk.
102
102
  #
103
+ # @param chunk [String] response body bytes
104
+ # @return [String] the encoded chunk
105
+ #
103
106
  # @rbs (String chunk) -> String
104
107
  def self.encode_chunk(chunk)
105
108
  HttpParser.chunked_encode(String.new, chunk)
@@ -107,6 +110,9 @@ module Raptor
107
110
 
108
111
  # Encodes the final HTTP/1.1 response chunk and its trailers.
109
112
  #
113
+ # @param trailers [Hash] trailing response headers
114
+ # @return [String] the final chunk followed by the trailer section
115
+ #
110
116
  # @rbs (Hash[String, String | Array[String]] trailers) -> String
111
117
  def self.encode_trailers(trailers)
112
118
  normalized = trailers.each_with_object({}) do |(key, value), headers|
@@ -547,6 +553,17 @@ module Raptor
547
553
 
548
554
  # Calls the Rack app and writes its response for one request.
549
555
  #
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
+ #
550
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
551
568
  def perform_request(socket, id, env, parse_data, body, reactor, request_count, remote_addr, url_scheme)
552
569
  rack_env = nil
@@ -566,7 +583,7 @@ module Raptor
566
583
  body.close if body.respond_to?(:close)
567
584
  else
568
585
  hijacked = headers.is_a?(Hash) && !!headers[Rack::RACK_HIJACK]
569
- no_body = rack_env[Rack::REQUEST_METHOD] == "HEAD" || (status >= 100 && status < 200) || status == 204 || status == 304
586
+ no_body = rack_env[Rack::REQUEST_METHOD] == "HEAD" || Http.no_entity_body_status?(status)
570
587
  detached = body.is_a?(DetachedBody) && !no_body
571
588
  if detached
572
589
  chunked = rack_env[Rack::SERVER_PROTOCOL] == HTTP_11
@@ -582,7 +599,7 @@ module Raptor
582
599
  socket,
583
600
  id,
584
601
  body,
585
- {chunked: chunked, keep_alive: keep_alive, request_count: request_count, remote_addr: remote_addr, url_scheme: url_scheme},
602
+ { chunked: chunked, keep_alive: keep_alive, request_count: request_count, remote_addr: remote_addr, url_scheme: url_scheme },
586
603
  finished
587
604
  )
588
605
  else
@@ -950,6 +967,13 @@ module Raptor
950
967
  # Starts a detached response, chunked on HTTP/1.1 and ended by closing
951
968
  # the connection on HTTP/1.0.
952
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
+ #
953
977
  # @rbs (TCPSocket socket, Integer status, Hash[String, String | Array[String]] headers, chunked: bool, keep_alive: bool) -> void
954
978
  def write_detached_response(socket, status, headers, chunked:, keep_alive:)
955
979
  validate_status(status)
@@ -980,7 +1004,7 @@ module Raptor
980
1004
  validate_status(status)
981
1005
  response_hijack = headers.is_a?(Hash) ? headers.delete(Rack::RACK_HIJACK) : nil
982
1006
  headers = normalize_headers(headers)
983
- no_entity_body = (status >= 100 && status < 200) || status == 204 || status == 304
1007
+ no_entity_body = Http.no_entity_body_status?(status)
984
1008
  validate_headers(headers, status, no_entity_body)
985
1009
 
986
1010
  headers["connection"] = keep_alive ? CONNECTION_KEEPALIVE : CONNECTION_CLOSE
data/lib/raptor/http2.rb CHANGED
@@ -71,6 +71,11 @@ module Raptor
71
71
 
72
72
  # Attaches a response body that outlives its application thread.
73
73
  #
74
+ # @param stream_id [Integer] the HTTP/2 stream identifier
75
+ # @param body [DetachedBody] the response body
76
+ # @param finished [Proc] called with the close reason
77
+ # @return [Boolean] whether the reactor accepted the body
78
+ #
74
79
  # @rbs (Integer stream_id, DetachedBody body, ^(Symbol) -> void finished) -> bool
75
80
  def attach_body(stream_id, body, finished)
76
81
  @reactor.attach_http2_body(@connection_id, stream_id, body, finished)
@@ -416,7 +421,6 @@ module Raptor
416
421
  end
417
422
  end
418
423
 
419
- EAGER_READ_TIMEOUT = 0.001
420
424
  EAGER_READ_BUFFER_SIZE = 64 * 1024
421
425
  EAGER_MAX_ROUNDS = 8
422
426
 
@@ -997,15 +1001,15 @@ module Raptor
997
1001
  end
998
1002
  end
999
1003
 
1000
- # Reads the next frame batch from `socket` within a short window, or
1001
- # returns nil if nothing arrives in time.
1004
+ # Reads the next frame batch from `socket` if it has already arrived,
1005
+ # without waiting on the shared collector thread.
1002
1006
  #
1003
1007
  # @param socket [OpenSSL::SSL::SSLSocket] the connection socket
1004
1008
  # @return [String, nil] the bytes read, or nil if nothing was available
1005
1009
  #
1006
1010
  # @rbs (OpenSSL::SSL::SSLSocket socket) -> String?
1007
1011
  def eager_read_next_batch(socket)
1008
- return unless socket.wait_readable(EAGER_READ_TIMEOUT)
1012
+ return unless socket.wait_readable(0)
1009
1013
 
1010
1014
  data = begin
1011
1015
  socket.read_nonblock(EAGER_READ_BUFFER_SIZE)
@@ -1048,6 +1052,15 @@ module Raptor
1048
1052
 
1049
1053
  # Calls the Rack app and writes its response for one stream.
1050
1054
  #
1055
+ # @param socket [OpenSSL::SSL::SSLSocket] the connection socket
1056
+ # @param writer [Writer] lock-free frame writer for the connection
1057
+ # @param flow_control [FlowControl] per-connection outbound flow controller
1058
+ # @param stream_id [Integer] the HTTP/2 stream identifier
1059
+ # @param headers [Array<Array(String, String)>] request headers
1060
+ # @param body [String] request body
1061
+ # @param remote_addr [String] the client IP address
1062
+ # @return [void]
1063
+ #
1051
1064
  # @rbs (OpenSSL::SSL::SSLSocket socket, Writer writer, FlowControl flow_control, Integer stream_id, Array[[String, String]] headers, String body, remote_addr: String) -> void
1052
1065
  def perform_stream_request(socket, writer, flow_control, stream_id, headers, body, remote_addr:)
1053
1066
  env = nil
@@ -1060,7 +1073,7 @@ module Raptor
1060
1073
  env = build_rack_env(headers, body, socket, writer, flow_control, stream_id, remote_addr: remote_addr)
1061
1074
  status, response_headers, response_body = @app.call(env)
1062
1075
 
1063
- no_body = env[Rack::REQUEST_METHOD] == "HEAD" || (status >= 100 && status < 200) || status == 204 || status == 304
1076
+ no_body = env[Rack::REQUEST_METHOD] == "HEAD" || Http.no_entity_body_status?(status)
1064
1077
  if response_body.is_a?(DetachedBody) && !no_body
1065
1078
  finished = proc do |reason|
1066
1079
  error = StreamClosedError.new unless reason == :closed
@@ -1068,9 +1081,10 @@ module Raptor
1068
1081
  Http.call_response_finished(env, status, response_headers, error)
1069
1082
  flow_control.discard_stream(stream_id)
1070
1083
  end
1071
- detached = write_http2_detached_response(socket, writer, flow_control, stream_id, status, response_headers, response_body, finished) do
1084
+ write_http2_detached_response(socket, writer, flow_control, stream_id, status, response_headers, response_body, finished) do
1072
1085
  response_started = true
1073
1086
  end
1087
+ detached = true
1074
1088
  return
1075
1089
  end
1076
1090
 
@@ -1113,7 +1127,18 @@ module Raptor
1113
1127
 
1114
1128
  # Starts a detached HTTP/2 response after writing its response headers.
1115
1129
  #
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
1130
+ # @param socket [OpenSSL::SSL::SSLSocket] the connection socket
1131
+ # @param writer [Writer] lock-free frame writer for the connection
1132
+ # @param flow_control [FlowControl] per-connection outbound flow controller
1133
+ # @param stream_id [Integer] the HTTP/2 stream identifier
1134
+ # @param status [Integer] HTTP status code
1135
+ # @param headers [Hash] response headers from the Rack application
1136
+ # @param body [DetachedBody] the response body
1137
+ # @param finished [Proc] called with the close reason
1138
+ # @yield once the response headers are queued
1139
+ # @return [void]
1140
+ #
1141
+ # @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 } -> void
1117
1142
  def write_http2_detached_response(socket, writer, flow_control, stream_id, status, headers, body, finished)
1118
1143
  flow_control.check(stream_id)
1119
1144
  parser = Http2Parser.new
@@ -1122,7 +1147,6 @@ module Raptor
1122
1147
  yield
1123
1148
  attached = writer.attach_body(stream_id, body, finished)
1124
1149
  write_http2_reset_stream(socket, writer, stream_id, ERROR_REFUSED_STREAM) unless attached
1125
- true
1126
1150
  end
1127
1151
 
1128
1152
  # Writes a Rack response as HTTP/2 frames to the socket, partitioning
@@ -1145,7 +1169,7 @@ module Raptor
1145
1169
 
1146
1170
  encoded_headers = parser.encode_response_headers(status, headers)
1147
1171
  flow_control.check(stream_id)
1148
- no_body = request_method == "HEAD" || (status >= 100 && status < 200) || status == 204 || status == 304
1172
+ no_body = request_method == "HEAD" || Http.no_entity_body_status?(status)
1149
1173
  if no_body
1150
1174
  writer.write_frames(socket, [parser.build_frame(:headers, FLAG_END_STREAM | FLAG_END_HEADERS, stream_id, encoded_headers)])
1151
1175
  yield if block_given?