raptor 0.20.2 → 0.21.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c7d7d507ad52990de191f14ad645065b414e4d9b5f5d55f610626a0a1b3e6484
4
- data.tar.gz: 7d06c53f9fc1e3531cc804dafccf28a715c1cb47924a69a19bd84e449164f935
3
+ metadata.gz: 9fa644eb1b4bc0f253823d74dcd806125176ef6940410f6ed332a7ae446be2e6
4
+ data.tar.gz: a100f4830e44b1ed3cce30e2549ddfe95c4bc648d4d0e1b0f4ffa9aee9906269
5
5
  SHA512:
6
- metadata.gz: 4759064e2e344d4d3f4669e62d04056df8d78bbc6b7fe8ba85881e6a8babb136d1f5ca5855606014e06fa43ac18d3d2946121b59bb98be1e8499f8024b4bb3bf
7
- data.tar.gz: 439b5f3fce057ce5f2fe8ae8f1d7d47931a688e4ab7ac2f9a59ab9917c4939fb3b5b42e3898d216bb734f0122d4de1e4c9b029f7c71339bffa16e036a4a61838
6
+ metadata.gz: 79ac90d569223b7826731aae20441dcec3362cd92b8e03eca3ee243492c0f41d20edab8dc5ddb06a3099b642bb6774b1bd013397d3c33798be9057c57c4f53e4
7
+ data.tar.gz: e007cf30032b2a0053c8f7b15fca20a376a7dcd85c5cb3ec57591dd48d84daa69f51d4e294a60cfa6ac8c9df1f15b39d1f30a33ffa6709071efa72dfde4a6a33
data/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.21.0] - 2026-09-27
4
+
5
+ - Add HTTP/2 keepalive
6
+ - Support HTTP/2 response trailers
7
+ - Gracefully drain HTTP/2 connections
8
+ - Support cleartext HTTP/2 bindings
9
+ - Accept HTTP/2 request trailers
10
+ - Propagate HTTP/2 stream cancellation to response bodies
11
+ - Support Rack response lifecycle over HTTP/2
12
+ - Support Rack streaming bodies over HTTP/2
13
+ - Stream HTTP/2 response bodies incrementally
14
+ - Suppress HTTP/2 response bodies when required
15
+
3
16
  ## [0.20.2] - 2026-09-26
4
17
 
5
18
  - Close idle connections before reforking workers
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.20.2
40
+ [Raptor 72876|Main|Main] ├─ Version: 0.21.0
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
@@ -85,6 +85,15 @@ The config file is a Ruby file that evaluates to a hash of options. By default R
85
85
  `config/raptor.rb` from the working directory; pass `-c PATH` to point at a specific file. Settings are nested under
86
86
  `connection:` (shared across protocols), `http1:` (HTTP/1.1-specific), and `http2:` (HTTP/2-specific).
87
87
 
88
+ Use an `ssl://` bind for HTTP/2 negotiated with ALPN, or an `h2c://` bind for cleartext HTTP/2 clients using prior
89
+ knowledge. Each `h2c://` listener accepts HTTP/2 only.
90
+
91
+ HTTP/2 applications can populate `env["raptor.response_trailers"]` with trailing response headers. Values may be
92
+ strings or arrays of strings.
93
+
94
+ Idle HTTP/2 connections receive a PING after `keepalive_interval` seconds and close when its acknowledgement does not
95
+ arrive within `keepalive_timeout`. Set `keepalive_interval` to `0` to disable these probes.
96
+
88
97
  ```ruby
89
98
  # raptor.rb
90
99
 
@@ -116,6 +125,8 @@ The config file is a Ruby file that evaluates to a hash of options. By default R
116
125
  http2: {
117
126
  ractors: nil,
118
127
  max_concurrent_streams: 100,
128
+ keepalive_interval: 10,
129
+ keepalive_timeout: 5,
119
130
  },
120
131
  worker_boot_timeout: 60,
121
132
  worker_timeout: 60,
@@ -231,7 +242,7 @@ unbounded configured limit. The control server is read-only and currently expose
231
242
 
232
243
  ## (Micro) Benchmarks
233
244
 
234
- Raptor 0.20.2 vs Puma 8.0.2 vs Falcon 0.57.0 across two workload profiles. **IO-bound** is a GET endpoint that
245
+ Raptor 0.21.0 vs Puma 8.0.2 vs Falcon 0.57.0 across two workload profiles. **IO-bound** is a GET endpoint that
235
246
  interleaves 5-10 short sleeps (total 2.5-15ms) with small CPU work, simulating a read path that makes several DB or
236
247
  cache calls. **CPU-bound** is a POST endpoint that accepts a small JSON body, interleaves 3-5 chunks of JSON item
237
248
  building (total 450-1500 items) with sub-100µs sleeps, and returns the built array, simulating a write path that does
@@ -245,24 +256,24 @@ disabled, and both threaded servers allow 999 requests per HTTP/1.1 keep-alive c
245
256
  Each cell reports the median throughput and median p95 latency independently across 3 runs, so the two numbers in a row
246
257
  may come from different runs. Every run starts a fresh server process so the samples are independent of each other;
247
258
  state accumulated in a previous run cannot bias the next. Across the whole table, the widest spread
248
- ((max - min) / 2 / median) between runs of a single cell was ±12.4% for throughput and ±16.5% for p95.
259
+ ((max - min) / 2 / median) between runs of a single cell was ±22.1% for throughput and ±26.6% for p95.
249
260
 
250
261
  | 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 |
251
262
  | --------------------- | -------- | ----------- | ------------ | ---------- | ----------- | --------- | ------------- | ------------ | ------------ | ---------- | --------------- | ------------- |
252
- | HTTP/1.1 | IO | Fixed | 2.90k req/s | 81.10 ms | 1.41k req/s | 141.50 ms | 105.8% higher | 42.7% lower | 12.26k req/s | 14.00 ms | 76.4% lower | 479.3% higher |
253
- | HTTP/1.1 | IO | Scaling | 6.24k req/s | 36.20 ms | 1.41k req/s | 141.50 ms | 343.2% higher | 74.4% lower | 12.26k req/s | 14.00 ms | 49.1% lower | 158.6% higher |
254
- | HTTP/1.1 | CPU | Fixed | 7.21k req/s | 37.90 ms | 8.20k req/s | 23.20 ms | 12.1% lower | 63.4% higher | 5.28k req/s | 35.00 ms | 36.6% higher | 8.3% higher |
255
- | HTTP/1.1 | CPU | Scaling | 6.04k req/s | 41.60 ms | 8.20k req/s | 23.20 ms | 26.4% lower | 79.3% higher | 5.28k req/s | 35.00 ms | 14.4% higher | 18.9% higher |
256
- | HTTP/1.1 (keep-alive) | IO | Fixed | 2.35k req/s | 73.50 ms | 1.37k req/s | 124.50 ms | 71.2% higher | 41.0% lower | 6.35k req/s | 27.50 ms | 63.0% lower | 167.3% higher |
257
- | HTTP/1.1 (keep-alive) | IO | Scaling | 7.83k req/s | 23.20 ms | 1.37k req/s | 124.50 ms | 470.1% higher | 81.4% lower | 6.35k req/s | 27.50 ms | 23.2% higher | 15.6% lower |
258
- | HTTP/1.1 (keep-alive) | CPU | Fixed | 7.14k req/s | 28.60 ms | 7.85k req/s | 24.50 ms | 9.1% lower | 16.7% higher | 5.56k req/s | 41.60 ms | 28.4% higher | 31.2% lower |
259
- | HTTP/1.1 (keep-alive) | CPU | Scaling | 7.48k req/s | 26.20 ms | 7.85k req/s | 24.50 ms | 4.8% lower | 6.9% higher | 5.56k req/s | 41.60 ms | 34.5% higher | 37.0% lower |
260
- | HTTP/2 | IO | Fixed | 1.13k req/s | 174.70 ms | N/A | N/A | - | - | 7.18k req/s | 26.37 ms | 84.3% lower | 562.5% higher |
261
- | HTTP/2 | IO | Scaling | 6.48k req/s | 29.15 ms | N/A | N/A | - | - | 7.18k req/s | 26.37 ms | 9.7% lower | 10.6% higher |
262
- | HTTP/2 | CPU | Fixed | 6.31k req/s | 31.74 ms | N/A | N/A | - | - | 6.85k req/s | 51.60 ms | 7.9% lower | 38.5% lower |
263
- | HTTP/2 | CPU | Scaling | 6.70k req/s | 31.91 ms | N/A | N/A | - | - | 6.85k req/s | 51.60 ms | 2.2% lower | 38.2% lower |
264
-
265
- > ruby 4.0.6 (2026-07-14 revision 03b6d3f889) +YJIT +PRISM [aarch64-linux]
263
+ | HTTP/1.1 | IO | Fixed | 2.83k req/s | 84.00 ms | 1.51k req/s | 126.00 ms | 87.5% higher | 33.3% lower | 12.19k req/s | 14.10 ms | 76.8% lower | 495.7% higher |
264
+ | HTTP/1.1 | IO | Scaling | 7.12k req/s | 31.00 ms | 1.51k req/s | 126.00 ms | 371.2% higher | 75.4% lower | 12.19k req/s | 14.10 ms | 41.6% lower | 119.9% higher |
265
+ | HTTP/1.1 | CPU | Fixed | 7.42k req/s | 37.30 ms | 8.63k req/s | 21.30 ms | 14.0% lower | 75.1% higher | 6.55k req/s | 28.10 ms | 13.2% higher | 32.7% higher |
266
+ | HTTP/1.1 | CPU | Scaling | 6.66k req/s | 37.70 ms | 8.63k req/s | 21.30 ms | 22.8% lower | 77.0% higher | 6.55k req/s | 28.10 ms | 1.6% higher | 34.2% higher |
267
+ | HTTP/1.1 (keep-alive) | IO | Fixed | 2.43k req/s | 65.40 ms | 1.47k req/s | 106.30 ms | 65.2% higher | 38.5% lower | 6.22k req/s | 28.10 ms | 61.0% lower | 132.7% higher |
268
+ | HTTP/1.1 (keep-alive) | IO | Scaling | 7.77k req/s | 23.40 ms | 1.47k req/s | 106.30 ms | 428.4% higher | 78.0% lower | 6.22k req/s | 28.10 ms | 24.9% higher | 16.7% lower |
269
+ | HTTP/1.1 (keep-alive) | CPU | Fixed | 7.46k req/s | 26.60 ms | 8.46k req/s | 22.80 ms | 11.8% lower | 16.7% higher | 6.92k req/s | 33.80 ms | 7.9% higher | 21.3% lower |
270
+ | HTTP/1.1 (keep-alive) | CPU | Scaling | 7.53k req/s | 26.50 ms | 8.46k req/s | 22.80 ms | 11.0% lower | 16.2% higher | 6.92k req/s | 33.80 ms | 8.9% higher | 21.6% lower |
271
+ | HTTP/2 | IO | Fixed | 1.25k req/s | 138.74 ms | N/A | N/A | - | - | 6.17k req/s | 28.61 ms | 79.7% lower | 384.9% higher |
272
+ | HTTP/2 | IO | Scaling | 6.20k req/s | 30.00 ms | N/A | N/A | - | - | 6.17k req/s | 28.61 ms | 0.5% higher | 4.9% higher |
273
+ | HTTP/2 | CPU | Fixed | 6.84k req/s | 32.02 ms | N/A | N/A | - | - | 7.90k req/s | 56.51 ms | 13.4% lower | 43.3% lower |
274
+ | HTTP/2 | CPU | Scaling | 7.12k req/s | 31.07 ms | N/A | N/A | - | - | 7.90k req/s | 56.51 ms | 9.9% lower | 45.0% lower |
275
+
276
+ > ruby 4.0.7 (2026-09-15 revision 229531a6cf) +YJIT +PRISM [aarch64-linux]
266
277
  > 10 worker processes; fixed Raptor and Puma run 3 threads per worker; scaling Raptor starts at 3 with no fixed limit;
267
278
  > Falcon runs unbounded fibers per worker; 120 concurrent HTTP/1.1 client connections; 40 concurrent HTTP/2 client
268
279
  > connections × 3 streams each
@@ -238,7 +238,7 @@ Your Rack app still runs on ordinary threads under one worker GVL, so the app do
238
238
 
239
239
  **How the Ractor pools actually work.** Raptor uses the `ractor-pool` gem, which is another one of my libraries. Each pool has one coordinator Ractor and M pipeline Ractors. When a pipeline Ractor is idle, it sends itself back to the coordinator via `coordinator.send(Ractor.current, move: true)`. When work arrives at the coordinator, it either forwards it to a waiting Ractor (if any) or queues it. This coordinator-dispatch pattern guarantees that no Ractor sits idle while there is work. Results flow back through a shared `Ractor::Port` (a many-to-one channel added in recent Ruby versions and stable in 4.0) to a Ruby-side collector thread. If `M == 1` the coordinator is skipped and work goes straight to the single pipeline Ractor.
240
240
 
241
- Raptor always runs an HTTP/1.1 pool because every binding supports HTTP/1.1. It only starts an HTTP/2 pool when an SSL binding is present, since HTTP/2 is negotiated over TLS. SSL bindings still need both pools because ALPN allows each client to choose HTTP/2 or HTTP/1.1. Both pool sizes scale with headroom via `round(cores / workers)`, clamped to `[1, 3]` for `http1.ractors` and `[1, 2]` for `http2.ractors`. Keeping the pools separate means h1 and h2 parsing never share ractor slots, so a burst of small HTTP/1.1 requests cannot delay HTTP/2 frame handling on the same connection, and vice versa.
241
+ Raptor always runs an HTTP/1.1 pool because TCP, Unix, and SSL bindings support HTTP/1.1. It starts an HTTP/2 pool when an SSL or cleartext HTTP/2 binding is present. SSL bindings need both pools because ALPN allows each client to choose HTTP/2 or HTTP/1.1, while `h2c://` bindings accept only HTTP/2 clients using prior knowledge. Both pool sizes scale with headroom via `round(cores / workers)`, clamped to `[1, 3]` for `http1.ractors` and `[1, 2]` for `http2.ractors`. Keeping the pools separate means h1 and h2 parsing never share ractor slots, so a burst of small HTTP/1.1 requests cannot delay HTTP/2 frame handling on the same connection, and vice versa.
242
242
 
243
243
  **Why a custom thread pool.** The `AtomicThreadPool` in `atomic-ruby` (another one of my libraries) is backed by an `AtomicQueue`. The queue is a Michael-Scott multi-producer, multi-consumer FIFO: a singly linked list with a dummy sentinel and atomic head and tail pointers. Producers append nodes at the tail; consumers advance the head. Both operations are O(1) and make progress through compare-and-swap rather than a queue-wide mutex. Separate atoms track queue size and active app threads for backpressure.
244
244
 
@@ -289,7 +289,7 @@ Three timeout classes are tracked:
289
289
  - `chunk_data_timeout` (10s): applied once data has started arriving but the request is incomplete.
290
290
  - `persistent_data_timeout` (65s): applied to a keep-alive socket sitting idle between requests.
291
291
 
292
- On timeout, the reactor writes `HTTP/1.1 408 Request Timeout` and closes.
292
+ An incomplete HTTP/1.1 request receives `408 Request Timeout` before the connection closes. Once an HTTP/2 connection has received its preface, its deadline instead sends a PING; an acknowledgement restores the idle deadline, while a missing acknowledgement closes the connection.
293
293
 
294
294
  ### HTTP/1.1 request lifecycle
295
295
 
@@ -328,9 +328,9 @@ The `reactor.persist` call re-registers the socket with the reactor using `persi
328
328
 
329
329
  ### HTTP/2 request lifecycle
330
330
 
331
- Raptor speaks HTTP/2 on TLS connections where the client negotiates it via ALPN. The binder sets `alpn_protocols = ["h2", "http/1.1"]` on the SSL context and the ALPN callback picks h2 whenever the client offers it. Puma does not do this. Puma's SSL context does not advertise `h2` in ALPN, so clients transparently fall back to HTTP/1.1.
331
+ Raptor speaks HTTP/2 on TLS connections where the client negotiates it via ALPN and on `h2c://` listeners for cleartext clients using prior knowledge. The binder sets `alpn_protocols = ["h2", "http/1.1"]` on the SSL context and the ALPN callback picks h2 whenever the client offers it. Puma does not do this. Puma's SSL context does not advertise `h2` in ALPN, so clients transparently fall back to HTTP/1.1.
332
332
 
333
- Once ALPN selects h2, the pool worker that ran the TLS handshake calls `Http2#eager_accept`, which creates the per-connection `Writer` and `FlowControl`, attaches them to the reactor, writes the initial `SETTINGS` frame, and tries a non-blocking read on the socket. If bytes are already there, it parses the first frame batch inline via `Http2.process_frames` and dispatches completed streams to the thread pool without ever touching the ractor pool. If not, it hands the socket to the reactor to watch for the first bytes.
333
+ Once ALPN selects h2 or an `h2c://` listener accepts a connection, the server calls `Http2#eager_accept`, which creates the per-connection `Writer` and `FlowControl`, attaches them to the reactor, writes the initial `SETTINGS` frame, and tries a non-blocking read on the socket. If bytes are already there, it parses the first frame batch inline via `Http2.process_frames` and dispatches completed streams to the thread pool without ever touching the ractor pool. If not, it hands the socket to the reactor to watch for the first bytes.
334
334
 
335
335
  From there the shape is similar to HTTP/1.1:
336
336
 
@@ -339,6 +339,10 @@ From there the shape is similar to HTTP/1.1:
339
339
  3. Completed requests (once `HEADERS` and `DATA` are complete for a stream) go to the thread pool as separate work items. **A single connection can be servicing many streams in parallel across the thread pool.**
340
340
  4. Each stream's response is written back through the connection's `Writer`, which serialises frame writes across threads without a mutex.
341
341
 
342
+ Responses to `HEAD` requests and statuses that prohibit a message body end with the response `HEADERS` frame.
343
+ Early hints and response-finished callbacks follow the same Rack lifecycle as HTTP/1.1.
344
+ Trailing request `HEADERS` complete an open request. Rack has no standard request-trailer key, so Raptor validates them without adding them to the environment.
345
+
342
346
  The `Writer` is worth a paragraph. Naive per-connection writing would need a mutex around every socket write. Contention grows with concurrent streams. Raptor's `Writer` stores the "pending frames" queue in an `Atom` whose value is either `:idle` (nobody is writing) or an array of frames waiting to go out. A thread that wants to write does a CAS:
343
347
 
344
348
  - If current value is `:idle`, the thread claims the writer by CAS-ing to its own array of frames, then loops draining any additional frames other threads have appended.
@@ -346,12 +350,14 @@ The `Writer` is worth a paragraph. Naive per-connection writing would need a mut
346
350
 
347
351
  So under contention, only one thread does socket I/O at a time (because a socket can only be written to serially anyway), but no thread ever blocks on a lock. The "loser" of the CAS hands its frames off to the "winner" and returns immediately to whatever it was doing next, whether that is starting another stream, waiting for the next work item, or servicing a different connection.
348
352
 
349
- Once the writer thread has claimed a batch of pending frames, it concatenates them into a single buffer and issues one socket write for the whole batch. For a typical response of a HEADERS frame plus several DATA frames, that is one SSL_write call rather than one per frame.
353
+ Once the writer thread has claimed a batch of pending frames, it concatenates them into a single buffer and issues one socket write for the whole batch. Frames handed off concurrently can share that write, while sequential body chunks reach the socket as the Rack body yields or writes them.
350
354
 
351
- Flow control uses similar CAS-protected atoms. The connection-level window and the per-stream windows live in separate `Atom` cells. `acquire` atomically reserves connection capacity and, where per-stream tracking is needed, deducts the same grant from that stream's window. If either window is exhausted, the caller sleeps 1ms and retries until a `WINDOW_UPDATE` makes progress possible.
355
+ Flow control uses similar CAS-protected atoms. The connection-level window and the per-stream windows live in separate `Atom` cells. `acquire` atomically reserves connection capacity and, where per-stream tracking is needed, deducts the same grant from that stream's window. If either window is exhausted, the caller parks on an `AtomicConditionVariable`; a `WINDOW_UPDATE`, stream reset, or connection shutdown wakes it.
352
356
 
353
357
  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.
354
358
 
359
+ During worker shutdown, Raptor stops accepting connections and sends GOAWAY with the last stream handed to the Rack application. Later streams are refused while the application pool drains. The reactor remains active during that period so in-flight responses can receive flow-control updates and finish before their connections close.
360
+
355
361
  ### Raptor request flow diagram
356
362
 
357
363
  ```mermaid
@@ -407,7 +413,7 @@ flowchart TB
407
413
  KA -->|"yes"| EAG
408
414
  EAG -.->|"bytes ready, parse+dispatch on same thread"| ATP
409
415
  EAG -->|"no bytes, reactor.persist"| RCT
410
- RCT -->|"timeout expired"| TO["write 408, close"]
416
+ RCT -->|"deadline expired"| TO["PING or close"]
411
417
 
412
418
  STA -.->|"writes slot"| SHM[("mmap shared memory")]
413
419
  end
@@ -489,7 +495,7 @@ For external monitoring, `control_url` can expose a read-only `GET /stats` endpo
489
495
 
490
496
  **Puma.** Not implemented. Puma's [position](https://github.com/puma/puma/issues/2697) is that HTTP/2 belongs at the edge (nginx, Caddy, ALB), which terminates it and speaks HTTP/1.1 to the app server. That's a reasonable call for the deployments Puma is aimed at, and it's where most Rails production actually sits.
491
497
 
492
- **Raptor.** Native C parser plus HPACK, per-stream flow control, lock-free frame writer, stream multiplexing over a single connection. Once a request is complete it takes the same path as HTTP/1.1 and enters the same thread pool. Under HTTP/2, a single client connection can be issuing many concurrent requests, and Raptor services all of them in parallel on the same thread pool.
498
+ **Raptor.** Native C parser plus HPACK, per-stream flow control, lock-free frame writer, stream multiplexing over a single connection, configurable PING keepalive, and response trailers exposed through `env["raptor.response_trailers"]`. Once a request is complete it takes the same path as HTTP/1.1 and enters the same thread pool. Under HTTP/2, a single client connection can be issuing many concurrent requests, and Raptor services all of them in parallel on the same thread pool.
493
499
 
494
500
  Whether that matters depends on your setup. If you terminate TLS at an edge proxy that already speaks HTTP/2, both servers see HTTP/1.1 and it doesn't matter which of them you pick on this axis. If you're building an all-Ruby stack with no proxy in front, serving direct HTTP/2 clients, or measuring the app server itself, HTTP/2 support is where Raptor and Puma stop being comparable.
495
501
 
@@ -598,7 +604,7 @@ On the CPU-bound benchmark profile, each POST request accepts a small JSON body
598
604
 
599
605
  Puma doesn't implement HTTP/2, and most Rails production terminates HTTP/2 at nginx or a similar edge proxy before it reaches the app server. If that describes your stack, Raptor's HTTP/2 support isn't going to help you. Both servers see HTTP/1.1 from the proxy and the throughput numbers above are what actually matter. Puma's [position](https://github.com/puma/puma/issues/2697) is that this is where h2 belongs, and it's a reasonable one.
600
606
 
601
- Where Raptor's HTTP/2 support does matter is the all-Ruby stack: no proxy in front, TLS terminated at the app, and browsers or API clients speaking h2 directly to it. In that setup, Puma negotiates HTTP/1.1 instead, so the app-server connection does not get HTTP/2 multiplexing or HPACK header compression.
607
+ Where Raptor's HTTP/2 support does matter is the all-Ruby stack: clients can speak h2 directly over TLS, or a trusted proxy can use cleartext HTTP/2 to an `h2c://` listener. In that setup, Puma uses HTTP/1.1 instead, so the app-server connection does not get HTTP/2 multiplexing or HPACK header compression.
602
608
 
603
609
  Falcon also speaks HTTP/2 natively, so it's the interesting comparison there rather than Puma. On the CPU profile, scaling narrows Raptor's throughput gap from 26.9% to 14.2% and improves its p95 advantage from 12.3% to 21.3%. On IO, scaling raises Raptor from 1.14k to 4.46k req/s, but remains 30.3% behind Falcon. The h2 samples vary substantially more than the h1 samples, so these results establish broad shape rather than a precise ranking.
604
610
 
@@ -630,6 +630,7 @@ static int response_headers_iter(VALUE key, VALUE value, VALUE data) {
630
630
  const char *lname = RSTRING_PTR(lowered);
631
631
  long lname_len = RSTRING_LEN(lowered);
632
632
 
633
+ if (lname_len > 0 && lname[0] == ':') return ST_CONTINUE;
633
634
  if (lname_len >= 5 && memcmp(lname, "rack.", 5) == 0) return ST_CONTINUE;
634
635
  if (is_hop_by_hop(lname, lname_len)) return ST_CONTINUE;
635
636
 
@@ -658,6 +659,16 @@ static VALUE h2_encode_response_headers(VALUE self, VALUE status, VALUE headers)
658
659
  return hpack_encode_header_block(pairs);
659
660
  }
660
661
 
662
+ static VALUE h2_encode_response_trailers(VALUE self, VALUE trailers) {
663
+ (void)self;
664
+ Check_Type(trailers, T_HASH);
665
+
666
+ VALUE pairs = rb_ary_new();
667
+ rb_hash_foreach(trailers, response_headers_iter, pairs);
668
+
669
+ return hpack_encode_header_block(pairs);
670
+ }
671
+
661
672
  static VALUE h2_parse_frame(VALUE self, VALUE buffer) {
662
673
  (void)self;
663
674
  Check_Type(buffer, T_STRING);
@@ -825,6 +836,7 @@ RUBY_FUNC_EXPORTED void Init_raptor_http2(void) {
825
836
  rb_define_method(cHttp2Parser, "parse_headers", h2_parse_headers, 2);
826
837
  rb_define_method(cHttp2Parser, "encode_headers", h2_encode_headers, 1);
827
838
  rb_define_method(cHttp2Parser, "encode_response_headers", h2_encode_response_headers, 2);
839
+ rb_define_method(cHttp2Parser, "encode_response_trailers", h2_encode_response_trailers, 1);
828
840
  rb_define_method(cHttp2Parser, "parse_settings", h2_parse_settings, 1);
829
841
  rb_define_method(cHttp2Parser, "build_settings", h2_build_settings, 1);
830
842
  rb_define_method(cHttp2Parser, "build_frame", h2_build_frame, 4);
data/lib/raptor/binder.rb CHANGED
@@ -5,9 +5,9 @@ require "socket"
5
5
  require "uri"
6
6
 
7
7
  module Raptor
8
- # Binds `tcp://`, `unix://`, and `ssl://` URIs to listening sockets and
9
- # holds them for the server. Reconstructs listeners from inherited file
10
- # descriptors when provided (systemd socket activation, hot restart).
8
+ # Binds TCP, Unix, SSL, and cleartext HTTP/2 URIs to listening sockets
9
+ # and holds them for the server. Reconstructs listeners from inherited
10
+ # file descriptors when provided (systemd socket activation, hot restart).
11
11
  #
12
12
  class Binder
13
13
  SOCKET_BACKLOG = 1024
@@ -31,11 +31,27 @@ module Raptor
31
31
  def close = tcp_server.close
32
32
  end
33
33
 
34
+ # Marks a TCPServer as accepting cleartext HTTP/2 connections.
35
+ #
36
+ H2cListener = Data.define(:tcp_server) do
37
+ # @rbs (*untyped) -> TCPSocket
38
+ def accept_nonblock(...) = tcp_server.accept_nonblock(...)
39
+
40
+ # @rbs () -> TCPServer
41
+ def to_io = tcp_server
42
+
43
+ # @rbs () -> Addrinfo
44
+ def local_address = tcp_server.local_address
45
+
46
+ # @rbs () -> void
47
+ def close = tcp_server.close
48
+ end
49
+
34
50
  # @rbs @bind_uris: Array[String]
35
51
  # @rbs @socket_backlog: Integer
36
52
  # @rbs @inherited_fds: Hash[String, Array[Integer]]
37
- # @rbs @listeners: Array[TCPServer | UNIXServer | SslListener]
38
- # @rbs @uri_listeners: Hash[String, Array[TCPServer | UNIXServer | SslListener]]
53
+ # @rbs @listeners: Array[TCPServer | UNIXServer | SslListener | H2cListener]
54
+ # @rbs @uri_listeners: Hash[String, Array[TCPServer | UNIXServer | SslListener | H2cListener]]
39
55
 
40
56
  # Returns the array of bind URIs.
41
57
  #
@@ -49,7 +65,7 @@ module Raptor
49
65
 
50
66
  # Returns the array of listening sockets.
51
67
  #
52
- # @return [Array<TCPServer, UNIXServer, SslListener>]
68
+ # @return [Array<TCPServer, UNIXServer, SslListener, H2cListener>]
53
69
  attr_reader :listeners
54
70
 
55
71
  # Creates a new Binder and binds each URI. `localhost` expands to both
@@ -73,7 +89,8 @@ module Raptor
73
89
  end
74
90
 
75
91
  # Returns the bound addresses as strings: TCP as `host:port`, Unix as
76
- # the socket path, SSL as `ssl://host:port`.
92
+ # the socket path, SSL as `ssl://host:port`, and cleartext HTTP/2 as
93
+ # `h2c://host:port`.
77
94
  #
78
95
  # @return [Array<String>]
79
96
  #
@@ -86,6 +103,9 @@ module Raptor
86
103
  when SslListener
87
104
  address = listener.local_address
88
105
  "ssl://#{address.ip_address}:#{address.ip_port}"
106
+ when H2cListener
107
+ address = listener.local_address
108
+ "h2c://#{address.ip_address}:#{address.ip_port}"
89
109
  else
90
110
  address = listener.local_address
91
111
  "#{address.ip_address}:#{address.ip_port}"
@@ -100,7 +120,9 @@ module Raptor
100
120
  #
101
121
  # @rbs () -> Integer
102
122
  def server_port
103
- tcp_listener = @listeners.find { |listener| listener.is_a?(TCPServer) || listener.is_a?(SslListener) }
123
+ tcp_listener = @listeners.find do |listener|
124
+ listener.is_a?(TCPServer) || listener.is_a?(SslListener) || listener.is_a?(H2cListener)
125
+ end
104
126
  return 0 unless tcp_listener
105
127
 
106
128
  tcp_listener.local_address.ip_port
@@ -112,7 +134,7 @@ module Raptor
112
134
  #
113
135
  # @rbs () -> bool
114
136
  def http2?
115
- @listeners.any? { |listener| listener.is_a?(SslListener) }
137
+ @listeners.any? { |listener| listener.is_a?(SslListener) || listener.is_a?(H2cListener) }
116
138
  end
117
139
 
118
140
  # Closes all listening sockets.
@@ -164,14 +186,16 @@ module Raptor
164
186
  # Creates fresh listeners for the given URI.
165
187
  #
166
188
  # @param uri [URI] the parsed bind URI
167
- # @return [Array<TCPServer, UNIXServer, SslListener>]
189
+ # @return [Array<TCPServer, UNIXServer, SslListener, H2cListener>]
168
190
  # @raise [UnknownBindSchemeError] if the URI scheme is not supported
169
191
  #
170
- # @rbs (URI::Generic uri) -> Array[TCPServer | UNIXServer | SslListener]
192
+ # @rbs (URI::Generic uri) -> Array[TCPServer | UNIXServer | SslListener | H2cListener]
171
193
  def create_listeners(uri)
172
194
  case uri.scheme
173
195
  when "tcp"
174
196
  create_tcp_listeners(uri.host, uri.port)
197
+ when "h2c"
198
+ create_tcp_listeners(uri.host, uri.port).map { |listener| H2cListener.new(tcp_server: listener) }
175
199
  when "unix"
176
200
  create_unix_listeners(uri.path)
177
201
  when "ssl"
@@ -186,14 +210,16 @@ module Raptor
186
210
  #
187
211
  # @param uri [URI] the parsed bind URI the FDs were bound to
188
212
  # @param filenos [Array<Integer>] file descriptors to wrap
189
- # @return [Array<TCPServer, UNIXServer, SslListener>]
213
+ # @return [Array<TCPServer, UNIXServer, SslListener, H2cListener>]
190
214
  # @raise [UnknownBindSchemeError] if the URI scheme is not supported
191
215
  #
192
- # @rbs (URI::Generic uri, Array[Integer] filenos) -> Array[TCPServer | UNIXServer | SslListener]
216
+ # @rbs (URI::Generic uri, Array[Integer] filenos) -> Array[TCPServer | UNIXServer | SslListener | H2cListener]
193
217
  def restore_listeners(uri, filenos)
194
218
  case uri.scheme
195
219
  when "tcp"
196
220
  filenos.map { |fileno| TCPServer.for_fd(fileno) }
221
+ when "h2c"
222
+ filenos.map { |fileno| H2cListener.new(tcp_server: TCPServer.for_fd(fileno)) }
197
223
  when "unix"
198
224
  register_unix_socket_cleanup(uri.path)
199
225
  filenos.map { |fileno| UNIXServer.for_fd(fileno) }
data/lib/raptor/cli.rb CHANGED
@@ -56,6 +56,8 @@ module Raptor
56
56
  http2: {
57
57
  ractors: nil,
58
58
  max_concurrent_streams: 100,
59
+ keepalive_interval: 10,
60
+ keepalive_timeout: 5,
59
61
  },
60
62
  worker_boot_timeout: 60,
61
63
  worker_timeout: 60,
@@ -350,6 +352,14 @@ module Raptor
350
352
  @options[:http2][:max_concurrent_streams] = num
351
353
  end
352
354
 
355
+ opts.on("--http2-keepalive-interval SECONDS", Integer, "HTTP/2 idle time before sending a PING, or 0 to disable (default: 10)") do |seconds|
356
+ @options[:http2][:keepalive_interval] = seconds
357
+ end
358
+
359
+ opts.on("--http2-keepalive-timeout SECONDS", Integer, "HTTP/2 PING acknowledgement timeout in seconds (default: 5)") do |seconds|
360
+ @options[:http2][:keepalive_timeout] = seconds
361
+ end
362
+
353
363
  opts.on("--worker-boot-timeout SECONDS", Integer, "Worker boot timeout in seconds (default: 60)") do |timeout|
354
364
  @options[:worker_boot_timeout] = timeout
355
365
  end
@@ -876,7 +876,8 @@ module Raptor
876
876
  http2_ractor_pool,
877
877
  thread_pool,
878
878
  connection_options: @connection_options,
879
- http1_options: @http1_options
879
+ http1_options: @http1_options,
880
+ http2_options: @http2_options
880
881
  )
881
882
  reactor_thread = reactor.run
882
883
 
@@ -943,12 +944,13 @@ module Raptor
943
944
  end
944
945
 
945
946
  server_thread.join
947
+ http1.shutdown
948
+ http2.shutdown(reactor)
949
+ drain_thread_pool(thread_pool)
946
950
  reactor.shutdown
947
951
  reactor_thread.join
948
952
  http1_ractor_pool.shutdown
949
953
  http2_ractor_pool&.shutdown
950
- http1.shutdown
951
- drain_thread_pool(thread_pool)
952
954
  stats_thread.join
953
955
 
954
956
  run_seed_loop(index) if promote_to_seed
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
@@ -548,7 +548,7 @@ module Raptor
548
548
  end
549
549
 
550
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)
551
+ Http.call_response_finished(rack_env, status, headers, nil)
552
552
  keep_alive && !hijacked
553
553
  rescue => error
554
554
  keep_alive = false
@@ -579,7 +579,7 @@ module Raptor
579
579
  #
580
580
  # @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
581
  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
582
+ Http.call_response_finished(rack_env, status, headers, error)
583
583
  socket.write(INTERNAL_SERVER_ERROR_RESPONSE) rescue nil unless response_started || hijacked
584
584
 
585
585
  if @on_error
@@ -1258,24 +1258,6 @@ module Raptor
1258
1258
  end
1259
1259
  end
1260
1260
 
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
1261
  # Instance-level wrapper around {Http.write_access_log} that routes to
1280
1262
  # the configured `@access_log_io`.
1281
1263
  #