raptor 0.9.0 → 0.11.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: 6dcf0e8beb3e5b74822eff407c51fbb0e83445ab54f75c37dad2cab2f9113ec5
4
- data.tar.gz: 9131a6153b10e93e2e0b717355645fe485a1db0af52c3472f07a35fa4f95d64f
3
+ metadata.gz: be4ff0cf1729c188fab8261b749c2a279616bd565dbad81d18f5a3e75311b734
4
+ data.tar.gz: 0acc2ece57f69207a34696df792e45bf52bfce22bb7617c5ceb3113223c175f5
5
5
  SHA512:
6
- metadata.gz: 8a020f0e5c42eb4755a69c790f891aa334369d2ec58c063516893a53785574290feca84ebdd9d072b11601129f720eb2d82f52a7abbd7e0aa7bf79b17c57705c
7
- data.tar.gz: 3b90235078097337dcade23681c4b3ae638d04094a40a5c21e81f8aac127bfa547dd8d07e7dac1adda539299ab62c3883d15d84a692942da2a1b69eed7946035
6
+ metadata.gz: 787fabc9128f3c4e16a394dc5634ff8435916b60b68ff3f6d569b0db561f07af0c61ab5d17e7440301c850d87fbbe1f0dc3691274b1c060d1cbbd6f7bc84bfbf
7
+ data.tar.gz: 26d154b6c0abbc63986262e1212e526bd9e4e492d06f9a86e723e73fd85afd142550a15324b55ad91b575633db2d0231f262aebb818f198e2de4c96c9afa8666
data/CHANGELOG.md CHANGED
@@ -1,5 +1,30 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.11.0] - 2026-07-12
4
+
5
+ - Yield subsequent HTTP/1.1 keep-alive requests to the thread pool only under saturation
6
+ - Reuse a per-thread HTTP/1.1 parser across requests
7
+ - Skip a per-response allocation when formatting response headers
8
+ - Reject HTTP/2 streams with malformed pseudo-headers with `RST_STREAM`
9
+ - Populate `PATH_INFO` from absolute-form request targets
10
+ - Consume the trailer section after the chunked-body terminator
11
+ - Reject request headers larger than 112KB with 400
12
+ - Reject chunk sizes containing non-hex characters with 400
13
+ - Advance the parser's `nread` past the request header terminator
14
+ - Detect chunked `Transfer-Encoding` case-insensitively
15
+ - Reject HTTP/1.1 requests without a valid `Host` header with 400
16
+
17
+ ## [0.10.0] - 2026-07-07
18
+
19
+ - Memoize the server port string for the Rack env
20
+ - Parse the Host header without regex
21
+ - Apply `TCP_NODELAY` on the listener rather than each accepted socket
22
+ - Buffer chunked body writes up to 512KB before flushing
23
+ - Add `QUERY_STRING` to the Rack env template
24
+ - Skip intermediate array allocations when formatting response headers
25
+ - Intern common HTTP header keys in the parser
26
+ - Reuse a Rack env template across HTTP/1.1 requests
27
+
3
28
  ## [0.9.0] - 2026-07-07
4
29
 
5
30
  - Reuse a per-thread response buffer for status lines and headers
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 4 -t 3 hello_world.ru
39
39
  [Raptor 76577|Main|Main] Cluster initializing:
40
- [Raptor 76577|Main|Main] ├─ Version: 0.9.0
40
+ [Raptor 76577|Main|Main] ├─ Version: 0.11.0
41
41
  [Raptor 76577|Main|Main] ├─ Ruby Version: ruby 4.0.5 (2026-05-20 revision 64336ffd0e) +YJIT +PRISM [arm64-darwin23]
42
42
  [Raptor 76577|Main|Main] ├─ Environment: development
43
43
  [Raptor 76577|Main|Main] ├─ Master PID: 76577
@@ -187,19 +187,20 @@ Worker 1 (phase 0): pid=91351, requests=1199, busy=1/3, backlog=0, booted, last_
187
187
 
188
188
  ## (Micro) Benchmarks
189
189
 
190
- Raptor 0.9.0 vs Puma 8.0.2, median of 3 runs across two workload profiles: IO-bound (sleep for a random 5-50ms then return small JSON) and CPU-bound (serialise a JSON array of 20-200 items).
190
+ Raptor 0.11.0 vs Puma 8.0.2, median of 3 runs across two workload profiles: IO-bound (sleep for a random 1-10ms then
191
+ return small JSON) and CPU-bound (serialise a JSON array of 20-200 items).
191
192
 
192
- | Protocol | Workload | Raptor | Puma | +/- vs Puma |
193
- | --------------------- | -------- | ----------- | ---------- | ----------- |
194
- | HTTP/1.1 | IO | 0.4k req/s | 0.4k req/s | +3.4% |
195
- | HTTP/1.1 | CPU | 11.5k req/s | 9k req/s | +27.6% |
196
- | HTTP/1.1 (keep-alive) | IO | 0.4k req/s | 0.4k req/s | +1.5% |
197
- | HTTP/1.1 (keep-alive) | CPU | 28.8k req/s | 26k req/s | +10.9% |
198
- | HTTP/2 | IO | 0.3k req/s | N/A | - |
199
- | HTTP/2 | CPU | 29.6k req/s | N/A | - |
193
+ | Protocol | Workload | Raptor | Puma | +/- vs Puma |
194
+ | --------------------- | -------- | ------------ | ------------ | ----------- |
195
+ | HTTP/1.1 | IO | 2.05k req/s | 1.94k req/s | +5.7% |
196
+ | HTTP/1.1 | CPU | 13.66k req/s | 9.21k req/s | +48.3% |
197
+ | HTTP/1.1 (keep-alive) | IO | 2.08k req/s | 1.95k req/s | +6.6% |
198
+ | HTTP/1.1 (keep-alive) | CPU | 27.85k req/s | 27.47k req/s | +1.4% |
199
+ | HTTP/2 | IO | 0.96k req/s | N/A | - |
200
+ | HTTP/2 | CPU | 28.92k req/s | N/A | - |
200
201
 
201
202
  > ruby 4.0.5 (2026-05-20 revision 64336ffd0e) +YJIT +PRISM [aarch64-linux]
202
- > 4 workers, 3 threads, 24 concurrent connections
203
+ > 4 workers, 3 threads, 48 concurrent connections
203
204
 
204
205
  See [bin/benchmark](bin/benchmark) for more details.
205
206
 
@@ -12,7 +12,7 @@ This document walks through both servers at systems-design depth. By the end you
12
12
 
13
13
  ## The shape of the benchmark
14
14
 
15
- The Raptor README carries the [current head-to-head numbers](../README.md#micro-benchmarks) against the latest Puma release, run on the same hardware with the same Rack app, 4 workers, 3 threads, 24 concurrent connections, on a recent Ruby with YJIT enabled. Two workload profiles are measured: **IO-bound** (each request sleeps for a random 5-50ms then returns a small JSON response) and **CPU-bound** (each request serialises a JSON array of 20-200 items). Rather than pin specific numbers into this document (they drift with Ruby versions and hardware), the shape of the result is what matters.
15
+ The Raptor README carries the [current head-to-head numbers](../README.md#micro-benchmarks) against the latest Puma release, run on the same hardware with the same Rack app, 4 workers, 3 threads, 48 concurrent connections, on a recent Ruby with YJIT enabled. Two workload profiles are measured: **IO-bound** (each request sleeps for a random 1-10ms then returns a small JSON response) and **CPU-bound** (each request serialises a JSON array of 20-200 items). Rather than pin specific numbers into this document (they drift with Ruby versions and hardware), the shape of the result is what matters.
16
16
 
17
17
  - On IO-bound work, both servers land near parity across all protocols; throughput is bounded by the per-request sleep and the benchmark client's concurrency, not by anything the server does.
18
18
  - On CPU-bound HTTP/1.1 without keep-alive, Raptor holds a meaningful lead; per-request parsing and response-writing overhead is a real fraction of the work.
@@ -84,7 +84,7 @@ Puma stores its reactor timeouts in a Ruby array of `Client` objects. New client
84
84
 
85
85
  Cross-thread waking is done through a pipe. When work needs to enter the reactor from another thread (a Client being registered), a byte is written to a pipe that the selector is also watching; the selector wakes, drains the input queue, and gets on with it.
86
86
 
87
- ### Request lifecycle (HTTP/1.1)
87
+ ### HTTP/1.1 request lifecycle
88
88
 
89
89
  Putting the pieces together, a request through Puma looks like this:
90
90
 
@@ -104,16 +104,17 @@ Response writing is done from the app thread. Puma sets `TCP_CORK` on Linux, wri
104
104
  ```mermaid
105
105
  flowchart TB
106
106
  subgraph Master["Master process, cluster mode"]
107
- M["Master loop"]
108
- M -->|fork| W1["Worker 0"]
109
- M -->|fork| W2["Worker 1"]
110
- M -->|fork| W3["Worker N"]
107
+ MM["Master loop<br/>Reads worker pipes for<br/>per-worker vitals<br/>Handles signals"]
108
+ MM -->|"fork"| W1["Worker 0"]
109
+ MM -->|"fork"| W2["Worker 1"]
110
+ MM -->|"fork"| W3["Worker N"]
111
+ MM -.->|"SIGUSR2<br/>exec with argv"| MM
111
112
  end
112
113
 
113
- subgraph Worker["Puma worker process"]
114
- A["Server thread<br/>IO.select on listeners<br/>accept_nonblock"]
114
+ subgraph Worker["Puma worker process, one of N"]
115
+ SRV["Server thread<br/>IO.select on listeners<br/>accept_nonblock"]
115
116
 
116
- R["Reactor thread<br/>NIO::Selector<br/>sorted timeout array"]
117
+ RCT["Reactor thread<br/>NIO::Selector<br/>sorted timeout array"]
117
118
 
118
119
  subgraph TP["Thread pool, Mutex + CondVar"]
119
120
  T1["Thread 1"]
@@ -121,37 +122,45 @@ flowchart TB
121
122
  T3["Thread N"]
122
123
  end
123
124
 
125
+ STA["Stat thread<br/>writes to worker pipe<br/>on worker_check_interval"]
126
+
124
127
  C1{"Complete<br/>request<br/>on accept?"}
125
128
  C2{"Complete<br/>request<br/>after read?"}
126
129
  KA{"Keep-alive?"}
127
130
  BB{"Back-to-back<br/>data in buffer?"}
128
131
 
129
- A -->|"push new socket"| TP
132
+ SRV -->|"push new socket"| TP
130
133
  TP -->|"eagerly_finish"| C1
131
134
  C1 -->|"complete, app.call"| APP["Rack app"]
132
- C1 -->|"incomplete, register"| R
133
- R -->|"socket readable"| C2
135
+ C1 -->|"incomplete, register"| RCT
136
+ RCT -->|"socket readable"| C2
134
137
  C2 -->|"complete, push"| TP
135
- C2 -->|"incomplete, keep waiting"| R
136
- R -->|"timeout"| CLOSE1["write 408, close"]
138
+ C2 -->|"incomplete, keep waiting"| RCT
139
+ RCT -->|"timeout"| TO["write 408, close"]
137
140
  APP -->|"write response"| KA
138
- KA -->|"no"| CLOSE2["close socket"]
141
+ KA -->|"no"| CLS["close socket"]
139
142
  KA -->|"yes"| BB
140
143
  BB -->|"yes, reprocess"| TP
141
- BB -->|"no, back to reactor"| R
144
+ BB -->|"no, back to reactor"| RCT
145
+
146
+ STA -.->|"writes ping"| PIPE[("worker→master pipe")]
142
147
  end
143
148
 
149
+ MM -.->|"reads ping"| PIPE
150
+
144
151
  classDef accept fill:#dbeafe,stroke:#2563eb,color:#1e3a8a
145
152
  classDef reactor fill:#fecdd3,stroke:#e11d48,color:#881337
146
153
  classDef pool fill:#fef3c7,stroke:#d97706,color:#78350f
147
154
  classDef app fill:#d1fae5,stroke:#059669,color:#064e3b
148
- class A accept
149
- class R reactor
155
+ classDef storage fill:#e5e7eb,stroke:#6b7280,color:#374151
156
+ class SRV accept
157
+ class RCT reactor
150
158
  class TP pool
151
159
  class APP app
160
+ class PIPE storage
152
161
  ```
153
162
 
154
- The key thing to notice: the parser runs inside an app thread. Puma's concurrency model is "many app threads, each doing one full request (parse plus app plus write) at a time". Under MRI's GVL this is genuine concurrency but not parallelism; at any given moment at most one of those threads is executing Ruby, and the others are either blocked on I/O (which releases the GVL and lets another thread proceed) or waiting their turn. Puma leans on the fact that most Rack apps spend most of their time blocked on the database or an external API, where threads do effectively overlap.
163
+ Notably, the parser runs inside an app thread. Puma's concurrency model is "many app threads, each doing one full request (parse plus app plus write) at a time". Under MRI's GVL this is genuine concurrency but not parallelism; at any given moment at most one of those threads is executing Ruby, and the others are either blocked on I/O (which releases the GVL and lets another thread proceed) or waiting their turn. Puma leans on the fact that most Rack apps spend most of their time blocked on the database or an external API, where threads do effectively overlap.
155
164
 
156
165
  ## Part II: Raptor
157
166
 
@@ -175,7 +184,7 @@ Master-to-worker communication does not use pipes. Every worker writes its stats
175
184
 
176
185
  On Linux, each worker pins itself to a distinct CPU via `sched_setaffinity` when the worker count fits within the process's allowed CPU set, so it stays on one core and its L1/L2 caches stay warm. When workers outnumber available CPUs the pin is skipped and the kernel scheduler manages placement.
177
186
 
178
- ### Per-worker threading model
187
+ ### Threading model
179
188
 
180
189
  This is where Raptor diverges dramatically. Inside a worker there are five kinds of concurrent activity:
181
190
 
@@ -188,11 +197,13 @@ This is where Raptor diverges dramatically. Inside a worker there are five kinds
188
197
 
189
198
  That is a lot of moving parts. Let us go through why.
190
199
 
191
- **Why Ractors for parsing.** Ractors are Ruby's answer to true parallelism. Multiple Ractors can execute Ruby code simultaneously on different OS threads, each with its own GVL. But Ractors are heavily restricted: they can only share frozen data, they cannot access most global mutable state, and code inside a Ractor cannot use most existing gems (which universally assume shared-state semantics).
200
+ **Why Ractors for parsing.** Ractors are Ruby's answer to true parallelism. Multiple Ractors can execute Ruby code simultaneously on different OS threads, each with its own GVL. But Ractors are heavily restricted. They can only share frozen data, they cannot access most global mutable state, and code inside a Ractor cannot use most existing gems (which universally assume shared-state semantics).
192
201
 
193
202
  For a web server, this restriction turns out to be almost exactly right for HTTP parsing. Parsing a request is CPU-bound (tokenising bytes, uppercasing header names, decoding chunked bodies), it does not need to touch any global state, and it produces a result (a hash) that can be safely frozen and handed off. The native HTTP/1 parser (`raptor_http.c`) is declared `rb_ext_ractor_safe(true)`: it holds no per-parser Ruby state in the extension itself, and it writes only into the caller-supplied env hash. Same for the HTTP/2 parser plus HPACK.
194
203
 
195
- The upshot: while your Rack app runs on regular threads under the GVL (so your app does not need to be Ractor-safe), the protocol-level work runs in parallel across Ractors. Under heavy load with lots of small requests, the GVL contention that would otherwise dominate parsing simply is not there.
204
+ The HTTP/1 parser also pre-interns the ~40 most common header keys (`HTTP_HOST`, `HTTP_USER_AGENT`, the `HTTP_ACCEPT_*` family, `CONTENT_LENGTH`, `HTTP_X_FORWARDED_*`, the `HTTP_SEC_FETCH_*` client hints, and so on) once at load time. During parsing, a `memcmp` lookup against that table returns the shared frozen `VALUE` for known keys and falls back to `rb_enc_interned_str` for the rest. Every request's env hash therefore reuses the same String object for its header names, which both skips per-request allocation and lets Ruby's hash lookup use the interned key's cached hash code.
205
+
206
+ The upshot is that while your Rack app runs on regular threads under the GVL (so your app does not need to be Ractor-safe), the protocol-level work runs in parallel across Ractors. Under heavy load with lots of small requests, the GVL contention that would otherwise dominate parsing simply is not there.
196
207
 
197
208
  **How the Ractor pool actually works.** Raptor uses the `ractor-pool` gem, which is another one of my libraries. The 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; this is the default because a single Ractor already parses in parallel with the app threads and adds enough headroom for typical workloads.
198
209
 
@@ -341,7 +352,7 @@ flowchart TB
341
352
  ATP -->|"app.call + write"| KA
342
353
  KA -->|"no, close"| CLS["close socket"]
343
354
  KA -->|"yes"| EAG
344
- EAG -->|"bytes within 1ms"| ATP
355
+ EAG -.->|"bytes within 1ms, parse+dispatch on same thread"| ATP
345
356
  EAG -->|"no bytes, reactor.persist"| RCT
346
357
  RCT -->|"timeout expired"| TO["write 408, close"]
347
358
 
@@ -364,7 +375,7 @@ flowchart TB
364
375
  class SHM storage
365
376
  ```
366
377
 
367
- The critical structural difference from Puma: parsing is not on the app thread. It is on the Ractor pool. The app thread only does the Rack call and the response write. This decouples the two costs and lets Ruby actually use more than one CPU for the protocol work.
378
+ The critical structural difference from Puma is that parsing is not on the app thread. It is on the Ractor pool. The app thread only does the Rack call and the response write. This decouples the two costs and lets Ruby actually use more than one CPU for the protocol work.
368
379
 
369
380
  ## Part III: Head to head
370
381
 
@@ -398,11 +409,11 @@ Under moderate load these look equivalent. Under high concurrency (many threads
398
409
 
399
410
  **Puma.** After a response, if the connection is keep-alive and there are already buffered bytes for the next request (`has_back_to_back_requests?`) and there is a spare app thread, loop inline. Otherwise, if `eagerly_finish` (a single non-blocking read attempt) returns true, either loop inline (if spare threads) or hand back to the thread pool (`@thread_pool << client`). Otherwise, back to the reactor with `@persistent_timeout`.
400
411
 
401
- **Raptor.** After a response, the app thread does `socket.wait_readable(0.001)`, waiting up to 1ms for bytes. If bytes arrive, parse and dispatch the next request inline on the same thread. Otherwise `reactor.persist` and return.
412
+ **Raptor.** After a response, the app thread does `socket.wait_readable(0.001)`, waiting up to 1ms for bytes. If bytes arrive, it parses the next request inline. If the thread pool queue is at least as deep as the pool, the parsed request is handed back to the pool so other threads share the load; otherwise the same thread dispatches it inline. If no bytes arrive, `reactor.persist` and return.
402
413
 
403
- The difference is subtle but the numbers show it up. Real-world clients pipelining requests often send the next request 100-500 microseconds after the previous response completes. Puma's `eagerly_finish` catches the case where bytes are already in the socket buffer; Raptor's `wait_readable(1ms)` catches both the "already buffered" case and the "arriving very shortly" case. And Raptor never has to hand the socket back to a different thread; the response-writing thread parses the next request itself.
414
+ The difference is subtle but the numbers show it up. Real-world clients pipelining requests often send the next request 100-500 microseconds after the previous response completes. Puma's `eagerly_finish` catches the case where bytes are already in the socket buffer; Raptor's `wait_readable(1ms)` catches both the "already buffered" case and the "arriving very shortly" case. And Raptor always parses the next request on the response-writing thread, so the parse itself never crosses a thread boundary.
404
415
 
405
- This is where most of Raptor's keep-alive edge comes from. Under the benchmark load, with a small number of concurrent connections all doing keep-alive, every connection is issuing back-to-back requests, and Raptor's app threads are essentially processing a single connection each in a tight loop until the connection stops. Puma's app threads do the same when they can, but the coordination overhead between thread pool and reactor for the transitions costs meaningfully more.
416
+ This is where most of Raptor's keep-alive edge comes from. Subsequent requests are always parsed and dispatched without a reactor round-trip: the response-writing thread either continues serving that connection inline (when the pool queue is shallower than the pool) or hands the parsed request back to the pool for another thread to pick up (when it is not). Puma's app threads do the same when they can, but the coordination overhead between thread pool and reactor for the transitions costs meaningfully more.
406
417
 
407
418
  ### Backpressure
408
419
 
@@ -434,6 +445,10 @@ On the HTTP/1.1 path, Raptor has a small `writev(2)` C extension (`Raptor::Vecto
434
445
 
435
446
  HTTP/1.1 responses also reuse a per-thread String buffer for the status line and headers rather than allocating one per response. The buffer grows once to fit the largest response the thread has served and stays that size afterwards, so subsequent responses on that thread skip the allocation entirely.
436
447
 
448
+ Chunked responses with an array or file body accumulate chunk-framed writes into the response buffer up to 512KB before flushing to the socket, collapsing what would be N `write` syscalls for an N-chunk body into a handful. Enumerable bodies (SSE, long-polling, per-line log tailing) still flush every chunk, so streaming semantics are preserved.
449
+
450
+ Around the response boundary, HTTP/1.1 also amortises the common per-request allocations. A frozen Rack env template with the static keys (`rack.version`, `rack.hijack?`, `SCRIPT_NAME`, `QUERY_STRING`, `SERVER_SOFTWARE`) is duped per request, so the app-thread env build only writes the dynamic keys. Response header serialisation appends directly onto the response buffer instead of allocating intermediate `Array` wraps and `flat_map` products for each header value.
451
+
437
452
  ### Keep-alive request by request
438
453
 
439
454
  Because the keep-alive fast path is where Raptor's throughput edge shows up, here is each server processing three back-to-back requests on the same connection. Drawn separately so the participant columns stay wide enough to read. Compare the number of boundaries each request has to cross.
@@ -476,16 +491,12 @@ sequenceDiagram
476
491
  autonumber
477
492
  participant Client
478
493
  participant RS as Server thread
479
- participant RR as Reactor thread
480
- participant RC as Pipeline Ractor
481
494
  participant RP as App thread
482
495
 
483
496
  Note over Client,RP: Request 1, initial
484
497
  Client->>RS: SYN, request bytes
485
- RS->>RR: reactor.add
486
- RR->>RC: shareable state
487
- RC->>RC: parse on own Ractor GVL
488
- RC->>RP: dispatch complete request
498
+ RS->>RS: eager_accept, read + parse inline
499
+ RS->>RP: push proc to thread pool
489
500
  RP->>Client: response 1
490
501
 
491
502
  Note over Client,RP: Request 2, pipelined a few hundred us later
@@ -508,7 +519,7 @@ Puma has to bounce Request 3 through the reactor and back through the mutex-prot
508
519
 
509
520
  ### IO-bound work, close to parity
510
521
 
511
- On the IO-bound benchmark profile, each request sleeps for a random 5-50ms then returns a small JSON payload. Throughput is bounded by the per-request sleep and the benchmark client's 24-connection concurrency; both servers push those requests through their pipelines faster than the sleep completes, so what shows up in the numbers is the workload cost itself, not the server cost. Raptor and Puma land within a few percent of each other in both keep-alive modes because there is nothing to differentiate.
522
+ On the IO-bound benchmark profile, each request sleeps for a random 1-10ms then returns a small JSON payload. Throughput is bounded by the per-request sleep and the benchmark client's 48-connection concurrency; both servers push those requests through their pipelines faster than the sleep completes, so what shows up in the numbers is the workload cost itself, not the server cost. Raptor and Puma land within a few percent of each other in both keep-alive modes because there is nothing to differentiate.
512
523
 
513
524
  Real applications that spend most of their time waiting on a database or an upstream service look like this. The choice between Raptor and Puma there is not throughput-driven.
514
525
 
@@ -4,6 +4,7 @@
4
4
  */
5
5
 
6
6
  #include "ruby.h"
7
+ #include "ruby/encoding.h"
7
8
  #include <assert.h>
8
9
  #include <string.h>
9
10
  #include <ctype.h>
@@ -44,6 +45,75 @@ static VALUE global_server_protocol;
44
45
  static VALUE global_request_path;
45
46
  static VALUE global_fragment;
46
47
 
48
+ struct common_field {
49
+ const char *name;
50
+ size_t len;
51
+ VALUE interned;
52
+ };
53
+
54
+ #define FIELD(name) { name, sizeof(name) - 1, Qnil }
55
+
56
+ static struct common_field common_fields[] = {
57
+ FIELD("HTTP_HOST"),
58
+ FIELD("HTTP_USER_AGENT"),
59
+ FIELD("HTTP_CONNECTION"),
60
+ FIELD("HTTP_ACCEPT"),
61
+ FIELD("HTTP_ACCEPT_ENCODING"),
62
+ FIELD("HTTP_ACCEPT_LANGUAGE"),
63
+ FIELD("HTTP_ACCEPT_CHARSET"),
64
+ FIELD("HTTP_COOKIE"),
65
+ FIELD("HTTP_REFERER"),
66
+ FIELD("HTTP_CACHE_CONTROL"),
67
+ FIELD("HTTP_PRAGMA"),
68
+
69
+ FIELD("CONTENT_LENGTH"),
70
+ FIELD("CONTENT_TYPE"),
71
+ FIELD("HTTP_TRANSFER_ENCODING"),
72
+
73
+ FIELD("HTTP_AUTHORIZATION"),
74
+ FIELD("HTTP_ORIGIN"),
75
+ FIELD("HTTP_EXPECT"),
76
+
77
+ FIELD("HTTP_IF_MATCH"),
78
+ FIELD("HTTP_IF_NONE_MATCH"),
79
+ FIELD("HTTP_IF_MODIFIED_SINCE"),
80
+ FIELD("HTTP_IF_UNMODIFIED_SINCE"),
81
+ FIELD("HTTP_IF_RANGE"),
82
+ FIELD("HTTP_RANGE"),
83
+
84
+ FIELD("HTTP_UPGRADE"),
85
+ FIELD("HTTP_UPGRADE_INSECURE_REQUESTS"),
86
+
87
+ FIELD("HTTP_SEC_FETCH_DEST"),
88
+ FIELD("HTTP_SEC_FETCH_MODE"),
89
+ FIELD("HTTP_SEC_FETCH_SITE"),
90
+ FIELD("HTTP_SEC_FETCH_USER"),
91
+ FIELD("HTTP_SEC_CH_UA"),
92
+ FIELD("HTTP_SEC_CH_UA_MOBILE"),
93
+ FIELD("HTTP_SEC_CH_UA_PLATFORM"),
94
+ FIELD("HTTP_DNT"),
95
+
96
+ FIELD("HTTP_X_FORWARDED_FOR"),
97
+ FIELD("HTTP_X_FORWARDED_HOST"),
98
+ FIELD("HTTP_X_FORWARDED_PROTO"),
99
+ FIELD("HTTP_X_FORWARDED_SCHEME"),
100
+ FIELD("HTTP_X_FORWARDED_SSL"),
101
+ FIELD("HTTP_X_REAL_IP")
102
+ };
103
+
104
+ #undef FIELD
105
+
106
+ #define NUM_COMMON_FIELDS (sizeof(common_fields) / sizeof(common_fields[0]))
107
+
108
+ static VALUE raptor_http_intern_field(const char *buf, size_t len) {
109
+ for (size_t i = 0; i < NUM_COMMON_FIELDS; i++) {
110
+ if (common_fields[i].len == len && memcmp(common_fields[i].name, buf, len) == 0) {
111
+ return common_fields[i].interned;
112
+ }
113
+ }
114
+ return rb_enc_interned_str(buf, len, rb_utf8_encoding());
115
+ }
116
+
47
117
  static inline void upcase_header_char(char *c) {
48
118
  if (*c >= 'a' && *c <= 'z')
49
119
  *c &= ~0x20;
@@ -51,6 +121,21 @@ static inline void upcase_header_char(char *c) {
51
121
  *c = '_';
52
122
  }
53
123
 
124
+ static int contains_chunked(const char *value, long len) {
125
+ static const char chunked[] = "chunked";
126
+ static const long chunked_len = sizeof(chunked) - 1;
127
+
128
+ if (len < chunked_len) return 0;
129
+ for (long start = 0; start + chunked_len <= len; start++) {
130
+ long i;
131
+ for (i = 0; i < chunked_len; i++) {
132
+ if ((char)tolower((unsigned char)value[start + i]) != chunked[i]) break;
133
+ }
134
+ if (i == chunked_len) return 1;
135
+ }
136
+ return 0;
137
+ }
138
+
54
139
  static const int raptor_parser_start = 1;
55
140
  static const int raptor_parser_first_final = 46;
56
141
  static const int raptor_parser_error = 0;
@@ -294,16 +379,19 @@ tr26:
294
379
  else if (parser->field_len == 12 && memcmp(field_ptr, "CONTENT_TYPE", 12) == 0)
295
380
  needs_http_prefix = 0;
296
381
 
382
+ size_t key_len;
297
383
  if (needs_http_prefix) {
298
384
  memcpy(parser->buf, "HTTP_", 5);
299
385
  memcpy(parser->buf + 5, field_ptr, parser->field_len);
300
- parser->buf[5 + parser->field_len] = '\0';
386
+ key_len = 5 + parser->field_len;
387
+ parser->buf[key_len] = '\0';
301
388
  } else {
302
389
  memcpy(parser->buf, field_ptr, parser->field_len);
303
- parser->buf[parser->field_len] = '\0';
390
+ key_len = parser->field_len;
391
+ parser->buf[key_len] = '\0';
304
392
  }
305
393
 
306
- VALUE key = rb_str_new2(parser->buf);
394
+ VALUE key = raptor_http_intern_field(parser->buf, key_len);
307
395
  VALUE value = rb_str_new(PTR_TO(mark), value_len);
308
396
 
309
397
  char *value_ptr = RSTRING_PTR(value);
@@ -319,7 +407,7 @@ tr26:
319
407
  parser->flags |= FLAG_HAS_BODY;
320
408
  } else if (needs_http_prefix && parser->field_len == 17 &&
321
409
  memcmp(field_ptr, "TRANSFER_ENCODING", 17) == 0) {
322
- if (strstr(RSTRING_PTR(value), "chunked")) {
410
+ if (contains_chunked(value_ptr, value_real_len)) {
323
411
  parser->flags |= FLAG_CHUNKED | FLAG_HAS_BODY;
324
412
  parser->content_len = 0;
325
413
  }
@@ -351,16 +439,19 @@ tr29:
351
439
  else if (parser->field_len == 12 && memcmp(field_ptr, "CONTENT_TYPE", 12) == 0)
352
440
  needs_http_prefix = 0;
353
441
 
442
+ size_t key_len;
354
443
  if (needs_http_prefix) {
355
444
  memcpy(parser->buf, "HTTP_", 5);
356
445
  memcpy(parser->buf + 5, field_ptr, parser->field_len);
357
- parser->buf[5 + parser->field_len] = '\0';
446
+ key_len = 5 + parser->field_len;
447
+ parser->buf[key_len] = '\0';
358
448
  } else {
359
449
  memcpy(parser->buf, field_ptr, parser->field_len);
360
- parser->buf[parser->field_len] = '\0';
450
+ key_len = parser->field_len;
451
+ parser->buf[key_len] = '\0';
361
452
  }
362
453
 
363
- VALUE key = rb_str_new2(parser->buf);
454
+ VALUE key = raptor_http_intern_field(parser->buf, key_len);
364
455
  VALUE value = rb_str_new(PTR_TO(mark), value_len);
365
456
 
366
457
  char *value_ptr = RSTRING_PTR(value);
@@ -376,7 +467,7 @@ tr29:
376
467
  parser->flags |= FLAG_HAS_BODY;
377
468
  } else if (needs_http_prefix && parser->field_len == 17 &&
378
469
  memcmp(field_ptr, "TRANSFER_ENCODING", 17) == 0) {
379
- if (strstr(RSTRING_PTR(value), "chunked")) {
470
+ if (contains_chunked(value_ptr, value_real_len)) {
380
471
  parser->flags |= FLAG_CHUNKED | FLAG_HAS_BODY;
381
472
  parser->content_len = 0;
382
473
  }
@@ -1084,7 +1175,10 @@ case 45:
1084
1175
 
1085
1176
  done:
1086
1177
  parser->cs = cs;
1087
- parser->nread = p - buffer;
1178
+ parser->nread = (parser->flags & FLAG_FINISHED) ? parser->body_start : (size_t)(p - buffer);
1179
+
1180
+ if (parser->nread > MAX_HEADER_LENGTH)
1181
+ rb_raise(eHttpParserError, "request header too long");
1088
1182
 
1089
1183
  assert(p <= pe && "buffer overflow after parsing execute");
1090
1184
  assert(parser->nread <= len && "nread longer than length");
@@ -1191,6 +1285,12 @@ static VALUE parser_has_body_p(VALUE self) {
1191
1285
  return raptor_parser_has_body(parser) ? Qtrue : Qfalse;
1192
1286
  }
1193
1287
 
1288
+ static VALUE parser_chunked_p(VALUE self) {
1289
+ raptor_parser *parser;
1290
+ TypedData_Get_Struct(self, raptor_parser, &parser_type, parser);
1291
+ return raptor_parser_is_chunked(parser) ? Qtrue : Qfalse;
1292
+ }
1293
+
1194
1294
  static VALUE parser_content_length(VALUE self) {
1195
1295
  raptor_parser *parser;
1196
1296
  TypedData_Get_Struct(self, raptor_parser, &parser_type, parser);
@@ -1237,10 +1337,16 @@ RUBY_FUNC_EXPORTED void Init_raptor_http(void) {
1237
1337
  global_request_path = rb_str_new2("PATH_INFO");
1238
1338
  global_fragment = rb_str_new2("FRAGMENT");
1239
1339
 
1340
+ for (size_t i = 0; i < NUM_COMMON_FIELDS; i++) {
1341
+ common_fields[i].interned = rb_enc_interned_str(common_fields[i].name, common_fields[i].len, rb_utf8_encoding());
1342
+ rb_global_variable(&common_fields[i].interned);
1343
+ }
1344
+
1240
1345
  rb_define_alloc_func(cHttpParser, parser_alloc);
1241
1346
  rb_define_method(cHttpParser, "execute", parser_execute, 3);
1242
1347
  rb_define_method(cHttpParser, "finished?", parser_finished_p, 0);
1243
1348
  rb_define_method(cHttpParser, "has_body?", parser_has_body_p, 0);
1349
+ rb_define_method(cHttpParser, "chunked?", parser_chunked_p, 0);
1244
1350
  rb_define_method(cHttpParser, "content_length", parser_content_length, 0);
1245
1351
  rb_define_method(cHttpParser, "nread", parser_nread, 0);
1246
1352
  rb_define_method(cHttpParser, "reset", parser_reset, 0);
data/lib/raptor/binder.rb CHANGED
@@ -258,6 +258,7 @@ module Raptor
258
258
  socket = Socket.new(addrinfo.afamily, Socket::SOCK_STREAM, 0)
259
259
  socket.setsockopt(Socket::SOL_SOCKET, Socket::SO_REUSEADDR, true)
260
260
  socket.setsockopt(Socket::SOL_SOCKET, Socket::SO_REUSEPORT, true) if Socket.const_defined?(:SO_REUSEPORT)
261
+ socket.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_NODELAY, 1)
261
262
  socket.bind(addrinfo)
262
263
  socket.listen(@socket_backlog)
263
264
 
data/lib/raptor/cli.rb CHANGED
@@ -187,9 +187,9 @@ module Raptor
187
187
  #
188
188
  # @rbs (Array[String] argv) -> String?
189
189
  def extract_config_path(argv)
190
- argv.each_with_index do |arg, i|
190
+ argv.each_with_index do |arg, index|
191
191
  case arg
192
- when "-c", "--config" then return argv[i + 1]
192
+ when "-c", "--config" then return argv[index + 1]
193
193
  when /\A--config=(.*)\z/, /\A-c(.+)\z/ then return Regexp.last_match(1)
194
194
  end
195
195
  end
data/lib/raptor/http.rb CHANGED
@@ -18,6 +18,7 @@ module Raptor
18
18
  CONTENT_TYPE = "CONTENT_TYPE"
19
19
  HTTP_VERSION = "HTTP_VERSION"
20
20
  REMOTE_ADDR = "REMOTE_ADDR"
21
+ REQUEST_URI = "REQUEST_URI"
21
22
  SERVER_SOFTWARE = "SERVER_SOFTWARE"
22
23
  SERVER_SOFTWARE_VALUE = "Raptor/#{Raptor::VERSION}".freeze
23
24
 
data/lib/raptor/http1.rb CHANGED
@@ -19,6 +19,7 @@ module Raptor
19
19
  #
20
20
  class Http1
21
21
  BODY_BUFFER_THRESHOLD = 256 * 1024
22
+ CHUNKED_WRITE_THRESHOLD = 512 * 1024
22
23
  FILE_CHUNK_SIZE = 64 * 1024
23
24
  MAX_CHUNK_OVERHEAD = 16 * 1024
24
25
  READ_BUFFER_SIZE = 64 * 1024
@@ -57,6 +58,22 @@ module Raptor
57
58
 
58
59
  ILLEGAL_HEADER_KEY_REGEX = /[\x00-\x20\(\)<>@,;:\\"\/\[\]\?=\{\}\x7F]/
59
60
  ILLEGAL_HEADER_VALUE_REGEX = /[\x00-\x08\x0A-\x1F]/
61
+ CHUNK_SIZE_REGEX = /\A[0-9A-Fa-f]+\z/
62
+
63
+ # Returns true when an HTTP/1.1 request lacks a valid `Host` header per
64
+ # RFC 9112 section 3.2, where a valid value is a non-empty single-value
65
+ # line.
66
+ #
67
+ # @param env [Hash] the Rack environment after header parsing
68
+ # @return [Boolean]
69
+ #
70
+ # @rbs (Hash[String, untyped] env) -> bool
71
+ def self.invalid_host?(env)
72
+ return false unless env[Rack::SERVER_PROTOCOL] == HTTP_11
73
+
74
+ http_host = env[Rack::HTTP_HOST]
75
+ !http_host || http_host.empty? || http_host.include?(",")
76
+ end
60
77
 
61
78
  # Returns true when the message framing shows a request-smuggling vector
62
79
  # per RFC 9112 section 6.3: a `Transfer-Encoding` where `chunked` is
@@ -88,9 +105,10 @@ module Raptor
88
105
  # Decodes a chunked transfer-encoded body buffer.
89
106
  #
90
107
  # Returns the decoded bytes and a state symbol: `:complete` when the
91
- # terminating zero-length chunk was found, `:too_large` when the decoded
92
- # size would exceed `max_size`, `:malformed` when chunk framing overhead
93
- # exceeds `MAX_CHUNK_OVERHEAD`, or `:incomplete` otherwise.
108
+ # terminating zero-length chunk and trailer section were fully consumed,
109
+ # `:too_large` when the decoded size would exceed `max_size`, `:malformed`
110
+ # when a chunk-size line is not valid hex or chunk framing overhead exceeds
111
+ # `MAX_CHUNK_OVERHEAD`, or `:incomplete` otherwise.
94
112
  #
95
113
  # @param buffer [String] the raw body buffer to decode
96
114
  # @param max_size [Integer, nil] maximum decoded body size, or nil for unlimited
@@ -106,8 +124,24 @@ module Raptor
106
124
  crlf = buffer.index("\r\n", offset)
107
125
  return [decoded, :incomplete] unless crlf
108
126
 
109
- chunk_size = buffer.byteslice(offset, crlf - offset).to_i(16)
110
- return [decoded, :complete] if chunk_size == 0
127
+ size_line = buffer.byteslice(offset, crlf - offset)
128
+ semicolon = size_line.index(";")
129
+ size_part = semicolon ? size_line.byteslice(0, semicolon) : size_line
130
+ return [decoded, :malformed] unless size_part.match?(CHUNK_SIZE_REGEX)
131
+
132
+ chunk_size = size_part.to_i(16)
133
+
134
+ if chunk_size == 0
135
+ trailer_offset = crlf + 2
136
+ loop do
137
+ trailer_crlf = buffer.index("\r\n", trailer_offset)
138
+ return [decoded, :incomplete] unless trailer_crlf
139
+ return [decoded, :complete] if trailer_crlf == trailer_offset
140
+
141
+ trailer_offset = trailer_crlf + 2
142
+ end
143
+ end
144
+
111
145
  return [decoded, :too_large] if max_size && (decoded.bytesize + chunk_size) > max_size
112
146
 
113
147
  overhead += (crlf - offset) + 4
@@ -123,6 +157,7 @@ module Raptor
123
157
 
124
158
  # @rbs @app: ^(Hash[String, untyped]) -> [Integer, Hash[String, String | Array[String]], untyped]
125
159
  # @rbs @server_port: Integer
160
+ # @rbs @server_port_string: String
126
161
  # @rbs @write_timeout: Integer
127
162
  # @rbs @max_body_size: Integer?
128
163
  # @rbs @body_spool_threshold: Integer?
@@ -130,6 +165,7 @@ module Raptor
130
165
  # @rbs @access_log_io: IO?
131
166
  # @rbs @on_error: ^(Hash[String, untyped]?, Exception) -> void | nil
132
167
  # @rbs @running: AtomicBoolean
168
+ # @rbs @env_template: Hash[String, untyped]
133
169
 
134
170
  # Creates a new Http1 handler.
135
171
  #
@@ -149,6 +185,7 @@ module Raptor
149
185
  def initialize(app, server_port, connection_options: {}, http1_options: {}, access_log_io: nil, on_error: nil)
150
186
  @app = app
151
187
  @server_port = server_port
188
+ @server_port_string = server_port.to_s.freeze
152
189
  @write_timeout = connection_options[:write_timeout] || Http::WRITE_TIMEOUT
153
190
  @max_body_size = connection_options[:max_body_size]
154
191
  @body_spool_threshold = connection_options[:body_spool_threshold]
@@ -156,6 +193,13 @@ module Raptor
156
193
  @access_log_io = access_log_io
157
194
  @on_error = on_error
158
195
  @running = AtomicBoolean.new(true)
196
+ @env_template = {
197
+ Rack::RACK_VERSION => Rack::VERSION,
198
+ Rack::RACK_IS_HIJACK => true,
199
+ Rack::SCRIPT_NAME => "",
200
+ Rack::QUERY_STRING => "",
201
+ Http::SERVER_SOFTWARE => Http::SERVER_SOFTWARE_VALUE
202
+ }.freeze
159
203
  end
160
204
 
161
205
  # Instance-level wrapper around {Http.socket_write} that applies the
@@ -229,8 +273,9 @@ module Raptor
229
273
  buffer << socket.read_nonblock(socket.pending)
230
274
  end
231
275
 
232
- parser = HttpParser.new
233
- env = {}
276
+ parser = (Thread.current[:raptor_http_parser] ||= HttpParser.new)
277
+ parser.reset
278
+ env = @env_template.dup
234
279
  nread = begin
235
280
  parser.execute(env, buffer, 0)
236
281
  rescue HttpParserError
@@ -243,7 +288,7 @@ module Raptor
243
288
  if !parser.finished?
244
289
  fallback_to_reactor(socket, id, buffer, env, parse_data, reactor, 0, remote_addr, url_scheme, persisted: false)
245
290
  return
246
- elsif Http1.request_smuggling?(env)
291
+ elsif Http1.invalid_host?(env) || Http1.request_smuggling?(env)
247
292
  reject_malformed(socket)
248
293
  return
249
294
  elsif parser.has_body?
@@ -254,7 +299,7 @@ module Raptor
254
299
 
255
300
  body = buffer.byteslice(nread..-1) || ""
256
301
 
257
- if env[HTTP_TRANSFER_ENCODING]&.include?(TRANSFER_ENCODING_CHUNKED)
302
+ if parser.chunked?
258
303
  body, chunked_state = Http1.decode_chunked(body, @max_body_size)
259
304
  case chunked_state
260
305
  when :complete
@@ -291,12 +336,13 @@ module Raptor
291
336
  # @rbs () -> ^(Hash[Symbol, untyped]) -> Hash[Symbol, untyped]
292
337
  def http_parser_worker
293
338
  max_body_size = @max_body_size
339
+ env_template = @env_template
294
340
 
295
341
  proc do |data|
296
342
  next Raptor::Http2.process_frames(data) if data[:protocol] == :http2
297
343
 
298
344
  parser = Raptor::HttpParser.new
299
- env = {}
345
+ env = env_template.dup
300
346
  nread = begin
301
347
  parser.execute(env, data[:buffer], 0)
302
348
  rescue Raptor::HttpParserError
@@ -310,14 +356,14 @@ module Raptor
310
356
  parse_data[:parse_count] += 1
311
357
 
312
358
  message = if parser.finished?
313
- if Raptor::Http1.request_smuggling?(env)
359
+ if Raptor::Http1.invalid_host?(env) || Raptor::Http1.request_smuggling?(env)
314
360
  data.merge(env: env, body: nil, parse_data: parse_data, complete: true, malformed: true)
315
361
  elsif parser.has_body?
316
362
  body_buffer = data[:buffer].byteslice(nread..-1) || ""
317
363
 
318
364
  if max_body_size && parser.content_length > max_body_size
319
365
  data.merge(env: env, body: nil, parse_data: parse_data, complete: true, too_large: true)
320
- elsif env[HTTP_TRANSFER_ENCODING]&.include?(TRANSFER_ENCODING_CHUNKED)
366
+ elsif parser.chunked?
321
367
  decoded_body, chunked_state = Raptor::Http1.decode_chunked(body_buffer, max_body_size)
322
368
 
323
369
  case chunked_state
@@ -559,8 +605,9 @@ module Raptor
559
605
  buffer << socket.read_nonblock(socket.pending)
560
606
  end
561
607
 
562
- parser = HttpParser.new
563
- env = {}
608
+ parser = (Thread.current[:raptor_http_parser] ||= HttpParser.new)
609
+ parser.reset
610
+ env = @env_template.dup
564
611
  nread = begin
565
612
  parser.execute(env, buffer, 0)
566
613
  rescue HttpParserError
@@ -576,8 +623,7 @@ module Raptor
576
623
  elsif parser.has_body?
577
624
  body = buffer.byteslice(nread..-1) || ""
578
625
 
579
- chunked = env[HTTP_TRANSFER_ENCODING]&.include?(TRANSFER_ENCODING_CHUNKED)
580
- if chunked || parser.content_length > body.bytesize
626
+ if parser.chunked? || parser.content_length > body.bytesize
581
627
  fallback_to_reactor(socket, id, buffer, env, parse_data, reactor, request_count, remote_addr, url_scheme)
582
628
  return
583
629
  end
@@ -585,7 +631,7 @@ module Raptor
585
631
 
586
632
  request_count += 1
587
633
 
588
- if thread_pool.queue_size > 0
634
+ if thread_pool.queue_size >= thread_pool.size
589
635
  thread_pool << proc do
590
636
  process_client(
591
637
  socket,
@@ -694,53 +740,69 @@ module Raptor
694
740
  #
695
741
  # @rbs (Hash[String, untyped] env, Hash[Symbol, untyped] parse_data, String? body, TCPSocket socket, ?remote_addr: String, ?url_scheme: String) -> Hash[String, untyped]
696
742
  def build_rack_env(env, parse_data, body, socket, remote_addr: Server::DEFAULT_REMOTE_ADDR, url_scheme: Server::HTTP_SCHEME)
697
- env[Rack::RACK_VERSION] = Rack::VERSION
698
743
  env[Rack::RACK_INPUT] = build_rack_input(body)
699
744
  env[Rack::RACK_ERRORS] = $stderr
700
745
  env[Rack::RACK_RESPONSE_FINISHED] = []
701
-
702
- env[Rack::RACK_IS_HIJACK] = true
703
746
  env[Rack::RACK_HIJACK] = proc do
704
747
  env[RACK_HIJACKED] = true
705
748
  env[RACK_HIJACK_IO] = socket
706
749
  socket
707
750
  end
708
-
709
751
  env[Rack::RACK_EARLY_HINTS] = proc do |hints|
710
752
  send_early_hints(socket, hints) rescue nil
711
753
  end
712
754
 
713
- env[Rack::SCRIPT_NAME] = "" unless env.key?(Rack::SCRIPT_NAME)
714
- env[Rack::PATH_INFO] = env.delete(Rack::REQUEST_PATH) if env.key?(Rack::REQUEST_PATH)
715
- env[Rack::PATH_INFO] = "" unless env.key?(Rack::PATH_INFO)
716
- env[Rack::QUERY_STRING] = "" unless env.key?(Rack::QUERY_STRING)
755
+ unless env.key?(Rack::PATH_INFO)
756
+ request_uri = env[Http::REQUEST_URI]
757
+ scheme_end = request_uri&.index("://")
758
+ if scheme_end
759
+ authority_end = request_uri.index("/", scheme_end + 3) || request_uri.bytesize
760
+ path_and_query = request_uri.byteslice(authority_end..-1) || ""
761
+ if query_delim = path_and_query.index("?")
762
+ env[Rack::PATH_INFO] = query_delim.zero? ? "/" : path_and_query.byteslice(0, query_delim)
763
+ env[Rack::QUERY_STRING] = path_and_query.byteslice(query_delim + 1..-1)
764
+ else
765
+ env[Rack::PATH_INFO] = path_and_query.empty? ? "/" : path_and_query
766
+ end
767
+ else
768
+ env[Rack::PATH_INFO] = ""
769
+ end
770
+ end
717
771
 
718
772
  if (content_length = parse_data[:content_length]).positive?
719
773
  env[Http::CONTENT_LENGTH] = content_length.to_s
720
774
  end
721
775
 
722
776
  env[Http::REMOTE_ADDR] = remote_addr
723
- env[Http::SERVER_SOFTWARE] = Http::SERVER_SOFTWARE_VALUE
724
777
  env[Http::HTTP_VERSION] = env[Rack::SERVER_PROTOCOL]
725
778
 
726
779
  behind_tls_proxy = (url_scheme == Server::HTTP_SCHEME) && forwarded_https?(env)
727
780
  env[Rack::RACK_URL_SCHEME] = behind_tls_proxy ? Server::HTTPS_SCHEME : url_scheme
728
- default_port = behind_tls_proxy ? "443" : @server_port.to_s
781
+ default_port = behind_tls_proxy ? "443" : @server_port_string
729
782
 
730
783
  http_host = env[Rack::HTTP_HOST]
731
- if http_host
784
+ host = nil
785
+ port = nil
786
+ if http_host && !http_host.empty?
732
787
  if http_host.start_with?("[")
733
- host = http_host[/\A\[([^\]]+)\]/, 1]
734
- port = http_host[/\]:(\d+)\z/, 1]
788
+ bracket_end = http_host.index("]")
789
+ if bracket_end
790
+ host = http_host.byteslice(1, bracket_end - 1)
791
+ port_colon = http_host.index(":", bracket_end + 1)
792
+ port = port_colon && http_host.byteslice(port_colon + 1, http_host.bytesize - port_colon - 1)
793
+ end
735
794
  else
736
- host, port = http_host.split(":", 2)
795
+ colon = http_host.index(":")
796
+ if colon
797
+ host = http_host.byteslice(0, colon)
798
+ port = http_host.byteslice(colon + 1, http_host.bytesize - colon - 1)
799
+ else
800
+ host = http_host
801
+ end
737
802
  end
738
- env[Rack::SERVER_NAME] ||= host
739
- env[Rack::SERVER_PORT] ||= port || default_port
740
- else
741
- env[Rack::SERVER_NAME] ||= Server::DEFAULT_SERVER_NAME
742
- env[Rack::SERVER_PORT] ||= default_port
743
803
  end
804
+ env[Rack::SERVER_NAME] ||= host || Server::DEFAULT_SERVER_NAME
805
+ env[Rack::SERVER_PORT] ||= port || default_port
744
806
 
745
807
  env
746
808
  end
@@ -963,7 +1025,7 @@ module Raptor
963
1025
  #
964
1026
  # @rbs (TCPSocket socket, String response, Hash[String, String | Array[String]] headers, ^(TCPSocket) -> void response_hijack) -> void
965
1027
  def write_hijacked_response(socket, response, headers, response_hijack)
966
- response << format_headers(headers)
1028
+ format_headers(response, headers)
967
1029
  response << "\r\n"
968
1030
  socket_write(socket, response)
969
1031
  uncork_socket(socket)
@@ -988,7 +1050,7 @@ module Raptor
988
1050
  headers[Rack::CONTENT_LENGTH] = "0" unless headers.key?(Rack::CONTENT_LENGTH) || headers.key?(Rack::TRANSFER_ENCODING)
989
1051
  end
990
1052
 
991
- response << format_headers(headers)
1053
+ format_headers(response, headers)
992
1054
  response << "\r\n"
993
1055
  socket_write(socket, response)
994
1056
  end
@@ -1010,7 +1072,7 @@ module Raptor
1010
1072
  # @rbs (TCPSocket socket, String response, Hash[String, String | Array[String]] headers, untyped body, String http_version) -> void
1011
1073
  def write_full_response(socket, response, headers, body, http_version)
1012
1074
  if body.respond_to?(:call)
1013
- response << format_headers(headers)
1075
+ format_headers(response, headers)
1014
1076
  response << "\r\n"
1015
1077
  socket_write(socket, response)
1016
1078
  uncork_socket(socket)
@@ -1036,7 +1098,7 @@ module Raptor
1036
1098
  headers[Rack::TRANSFER_ENCODING] = TRANSFER_ENCODING_CHUNKED
1037
1099
  end
1038
1100
 
1039
- response << format_headers(headers)
1101
+ format_headers(response, headers)
1040
1102
  response << "\r\n"
1041
1103
 
1042
1104
  if body.respond_to?(:to_path) && (path = body.to_path) && File.readable?(path)
@@ -1048,8 +1110,6 @@ module Raptor
1048
1110
  else
1049
1111
  raise TypeError, "body must respond to each, to_ary, or to_path"
1050
1112
  end
1051
-
1052
- socket_write(socket, "0\r\n\r\n") if use_chunked
1053
1113
  end
1054
1114
 
1055
1115
  # Calculates content length from an array or file body without consuming it.
@@ -1089,10 +1149,16 @@ module Raptor
1089
1149
  def write_file_body(socket, response, path, content_length, use_chunked)
1090
1150
  File.open(path, "rb") do |file|
1091
1151
  if use_chunked
1092
- socket_write(socket, response)
1152
+ buffer = response
1093
1153
  while (chunk = file.read(FILE_CHUNK_SIZE))
1094
- socket_write(socket, "#{chunk.bytesize.to_s(16)}\r\n#{chunk}\r\n")
1154
+ buffer << chunk.bytesize.to_s(16) << "\r\n" << chunk << "\r\n"
1155
+ if buffer.bytesize >= CHUNKED_WRITE_THRESHOLD
1156
+ socket_write(socket, buffer)
1157
+ buffer = +""
1158
+ end
1095
1159
  end
1160
+ buffer << "0\r\n\r\n"
1161
+ socket_write(socket, buffer)
1096
1162
  elsif content_length && content_length < BODY_BUFFER_THRESHOLD
1097
1163
  response << file.read(content_length)
1098
1164
  socket_write(socket, response)
@@ -1136,7 +1202,7 @@ module Raptor
1136
1202
  raise TypeError, "body must yield String values" unless chunk.is_a?(String)
1137
1203
 
1138
1204
  if use_chunked
1139
- response << "#{chunk.bytesize.to_s(16)}\r\n#{chunk}\r\n"
1205
+ response << chunk.bytesize.to_s(16) << "\r\n" << chunk << "\r\n0\r\n\r\n"
1140
1206
  socket_write(socket, response)
1141
1207
  else
1142
1208
  socket_writev(socket, [response, chunk])
@@ -1155,14 +1221,20 @@ module Raptor
1155
1221
  # @rbs (TCPSocket socket, String response, Array[String] body_array, bool use_chunked) -> void
1156
1222
  def write_multiple_chunks(socket, response, body_array, use_chunked)
1157
1223
  if use_chunked
1158
- socket_write(socket, response)
1224
+ buffer = response
1159
1225
  body_array.each do |chunk|
1160
1226
  raise TypeError, "body must yield String values" unless chunk.is_a?(String)
1161
1227
 
1162
1228
  next if chunk.empty?
1163
1229
 
1164
- socket_write(socket, "#{chunk.bytesize.to_s(16)}\r\n#{chunk}\r\n")
1230
+ buffer << chunk.bytesize.to_s(16) << "\r\n" << chunk << "\r\n"
1231
+ if buffer.bytesize >= CHUNKED_WRITE_THRESHOLD
1232
+ socket_write(socket, buffer)
1233
+ buffer = +""
1234
+ end
1165
1235
  end
1236
+ buffer << "0\r\n\r\n"
1237
+ socket_write(socket, buffer)
1166
1238
  else
1167
1239
  body_array.each do |chunk|
1168
1240
  raise TypeError, "body must yield String values" unless chunk.is_a?(String)
@@ -1191,6 +1263,7 @@ module Raptor
1191
1263
 
1192
1264
  socket_write(socket, "#{chunk.bytesize.to_s(16)}\r\n#{chunk}\r\n")
1193
1265
  end
1266
+ socket_write(socket, "0\r\n\r\n")
1194
1267
  else
1195
1268
  body.each do |chunk|
1196
1269
  raise TypeError, "body must yield String values" unless chunk.is_a?(String)
@@ -1229,19 +1302,44 @@ module Raptor
1229
1302
  # @param headers [Hash] normalized response headers
1230
1303
  # @return [String] formatted header lines, each ending with CRLF
1231
1304
  #
1232
- # @rbs (Hash[String, String | Array[String]] headers) -> String
1233
- def format_headers(headers)
1234
- result = +""
1305
+ # @rbs (String result, Hash[String, String | Array[String]] headers) -> void
1306
+ def format_headers(result, headers)
1235
1307
  headers.each do |name, value|
1236
1308
  next if illegal_header_key?(name)
1237
1309
 
1238
- Array(value).flat_map { |entry| entry.to_s.split("\n") }.each do |header_value|
1239
- next if illegal_header_value?(header_value)
1310
+ if value.is_a?(Array)
1311
+ value.each { |entry| append_header_value(result, name, entry) }
1312
+ else
1313
+ append_header_value(result, name, value)
1314
+ end
1315
+ end
1316
+ end
1317
+
1318
+ # Appends one or more `name: value` header lines to `result`. Newline-
1319
+ # separated values are emitted as separate lines; empty values and values
1320
+ # with illegal characters are skipped silently.
1321
+ #
1322
+ # @param result [String] the buffer to append to
1323
+ # @param name [String] the header name
1324
+ # @param value [Object] the header value (any object responding to `to_s`)
1325
+ # @return [void]
1326
+ #
1327
+ # @rbs (String result, String name, untyped value) -> void
1328
+ def append_header_value(result, name, value)
1329
+ string_value = value.is_a?(String) ? value : value.to_s
1330
+ return if string_value.empty?
1331
+
1332
+ if string_value.include?("\n")
1333
+ string_value.split("\n").each do |line|
1334
+ next if line.empty? || illegal_header_value?(line)
1240
1335
 
1241
- result << "#{name}: #{header_value}\r\n"
1336
+ result << name << ": " << line << "\r\n"
1242
1337
  end
1338
+ else
1339
+ return if illegal_header_value?(string_value)
1340
+
1341
+ result << name << ": " << string_value << "\r\n"
1243
1342
  end
1244
- result
1245
1343
  end
1246
1344
 
1247
1345
  # Calls all rack.response_finished callbacks registered in the environment.
data/lib/raptor/http2.rb CHANGED
@@ -247,6 +247,39 @@ module Raptor
247
247
  RACK_HEADER_PREFIX = "rack."
248
248
  HOP_BY_HOP_HEADERS = ["connection", "transfer-encoding", "keep-alive", "upgrade", "proxy-connection"].freeze
249
249
 
250
+ REQUEST_PSEUDO_HEADERS = [":method", ":scheme", ":path", ":authority"].freeze
251
+ REQUIRED_REQUEST_PSEUDO_HEADERS = [":method", ":scheme", ":path"].freeze
252
+
253
+ # Returns true when a decoded header block violates the HTTP/2 pseudo-header
254
+ # rules from RFC 9113 section 8.3: an unknown pseudo-header, a duplicate
255
+ # pseudo-header, a pseudo-header appearing after any regular header, or a
256
+ # required pseudo-header (`:method`, `:scheme`, `:path`) missing on
257
+ # non-`CONNECT` requests.
258
+ #
259
+ # @param headers [Array<Array(String, String)>] decoded header pairs
260
+ # @return [Boolean]
261
+ #
262
+ # @rbs (Array[[String, String]] headers) -> bool
263
+ def self.invalid_pseudo_headers?(headers)
264
+ seen_pseudo = {}
265
+ seen_regular = false
266
+
267
+ headers.each do |name, _value|
268
+ if name.start_with?(":")
269
+ return true if seen_regular
270
+ return true unless REQUEST_PSEUDO_HEADERS.include?(name)
271
+ return true if seen_pseudo[name]
272
+ seen_pseudo[name] = true
273
+ else
274
+ seen_regular = true
275
+ end
276
+ end
277
+
278
+ return false if seen_pseudo[":method"] && headers.assoc(":method")&.last == "CONNECT"
279
+
280
+ REQUIRED_REQUEST_PSEUDO_HEADERS.any? { |name| !seen_pseudo[name] }
281
+ end
282
+
250
283
  # @rbs @app: ^(Hash[String, untyped]) -> [Integer, Hash[String, String | Array[String]], untyped]
251
284
  # @rbs @server_port: Integer
252
285
  # @rbs @write_timeout: Integer
@@ -370,7 +403,12 @@ module Raptor
370
403
 
371
404
  if (frame[:flags] & FLAG_END_HEADERS) != 0
372
405
  decoded_headers, hpack_table = parser.parse_headers(header_payload, hpack_table)
373
- streams, completed_requests = finalize_headers(streams, completed_requests, stream_id, decoded_headers, end_stream)
406
+ if invalid_pseudo_headers?(decoded_headers)
407
+ streams.delete(stream_id)
408
+ outgoing_frames << parser.build_frame(:rst_stream, 0, stream_id, [ERROR_PROTOCOL_ERROR].pack("N"))
409
+ else
410
+ streams, completed_requests = finalize_headers(streams, completed_requests, stream_id, decoded_headers, end_stream)
411
+ end
374
412
  else
375
413
  pending_headers = { stream_id: stream_id, buffer: header_payload, end_stream: end_stream }
376
414
  end
@@ -386,7 +424,12 @@ module Raptor
386
424
  if (frame[:flags] & FLAG_END_HEADERS) != 0
387
425
  stream_id = pending_headers[:stream_id]
388
426
  decoded_headers, hpack_table = parser.parse_headers(pending_headers[:buffer], hpack_table)
389
- streams, completed_requests = finalize_headers(streams, completed_requests, stream_id, decoded_headers, pending_headers[:end_stream])
427
+ if invalid_pseudo_headers?(decoded_headers)
428
+ streams.delete(stream_id)
429
+ outgoing_frames << parser.build_frame(:rst_stream, 0, stream_id, [ERROR_PROTOCOL_ERROR].pack("N"))
430
+ else
431
+ streams, completed_requests = finalize_headers(streams, completed_requests, stream_id, decoded_headers, pending_headers[:end_stream])
432
+ end
390
433
  pending_headers = nil
391
434
  end
392
435
 
@@ -688,7 +731,7 @@ module Raptor
688
731
  next if HOP_BY_HOP_HEADERS.include?(lowered)
689
732
 
690
733
  if value.is_a?(Array)
691
- value.each { |val| header_pairs << [lowered, val.to_s] }
734
+ value.each { |entry| header_pairs << [lowered, entry.to_s] }
692
735
  else
693
736
  header_pairs << [lowered, value.to_s]
694
737
  end
@@ -79,6 +79,7 @@ module Raptor
79
79
  socket = Socket.new(addrinfo.afamily, Socket::SOCK_STREAM, 0)
80
80
  socket.setsockopt(Socket::SOL_SOCKET, Socket::SO_REUSEADDR, true)
81
81
  socket.setsockopt(Socket::SOL_SOCKET, Socket::SO_REUSEPORT, true)
82
+ socket.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_NODELAY, 1)
82
83
  socket.bind(addrinfo)
83
84
  socket.listen(socket_backlog)
84
85
 
data/lib/raptor/server.rb CHANGED
@@ -179,7 +179,6 @@ module Raptor
179
179
  end
180
180
 
181
181
  if tcp_client.is_a?(TCPSocket)
182
- tcp_client.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_NODELAY, 1)
183
182
  remote_addr = tcp_client.remote_address.ip_address
184
183
  else
185
184
  remote_addr = DEFAULT_REMOTE_ADDR
@@ -2,5 +2,5 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  module Raptor
5
- VERSION = "0.9.0"
5
+ VERSION = "0.11.0"
6
6
  end
@@ -15,6 +15,8 @@ module Raptor
15
15
 
16
16
  REMOTE_ADDR: ::String
17
17
 
18
+ REQUEST_URI: ::String
19
+
18
20
  SERVER_SOFTWARE: ::String
19
21
 
20
22
  SERVER_SOFTWARE_VALUE: untyped
@@ -8,6 +8,8 @@ module Raptor
8
8
  class Http1
9
9
  BODY_BUFFER_THRESHOLD: untyped
10
10
 
11
+ CHUNKED_WRITE_THRESHOLD: untyped
12
+
11
13
  FILE_CHUNK_SIZE: untyped
12
14
 
13
15
  MAX_CHUNK_OVERHEAD: untyped
@@ -62,6 +64,18 @@ module Raptor
62
64
 
63
65
  ILLEGAL_HEADER_VALUE_REGEX: ::Regexp
64
66
 
67
+ CHUNK_SIZE_REGEX: ::Regexp
68
+
69
+ # Returns true when an HTTP/1.1 request lacks a valid `Host` header per
70
+ # RFC 9112 section 3.2, where a valid value is a non-empty single-value
71
+ # line.
72
+ #
73
+ # @param env [Hash] the Rack environment after header parsing
74
+ # @return [Boolean]
75
+ #
76
+ # @rbs (Hash[String, untyped] env) -> bool
77
+ def self.invalid_host?: (Hash[String, untyped] env) -> bool
78
+
65
79
  # Returns true when the message framing shows a request-smuggling vector
66
80
  # per RFC 9112 section 6.3: a `Transfer-Encoding` where `chunked` is
67
81
  # missing, not the final encoding, or duplicated; a `Transfer-Encoding`
@@ -77,9 +91,10 @@ module Raptor
77
91
  # Decodes a chunked transfer-encoded body buffer.
78
92
  #
79
93
  # Returns the decoded bytes and a state symbol: `:complete` when the
80
- # terminating zero-length chunk was found, `:too_large` when the decoded
81
- # size would exceed `max_size`, `:malformed` when chunk framing overhead
82
- # exceeds `MAX_CHUNK_OVERHEAD`, or `:incomplete` otherwise.
94
+ # terminating zero-length chunk and trailer section were fully consumed,
95
+ # `:too_large` when the decoded size would exceed `max_size`, `:malformed`
96
+ # when a chunk-size line is not valid hex or chunk framing overhead exceeds
97
+ # `MAX_CHUNK_OVERHEAD`, or `:incomplete` otherwise.
83
98
  #
84
99
  # @param buffer [String] the raw body buffer to decode
85
100
  # @param max_size [Integer, nil] maximum decoded body size, or nil for unlimited
@@ -88,23 +103,27 @@ module Raptor
88
103
  # @rbs (String buffer, ?Integer? max_size) -> [String, Symbol]
89
104
  def self.decode_chunked: (String buffer, ?Integer? max_size) -> [ String, Symbol ]
90
105
 
91
- @running: AtomicBoolean
106
+ @app: ^(Hash[String, untyped]) -> [ Integer, Hash[String, String | Array[String]], untyped ]
92
107
 
93
- @on_error: ^(Hash[String, untyped]?, Exception) -> void | nil
108
+ @server_port: Integer
94
109
 
95
- @access_log_io: IO?
110
+ @server_port_string: String
96
111
 
97
- @max_keepalive_requests: Integer
112
+ @write_timeout: Integer
113
+
114
+ @max_body_size: Integer?
98
115
 
99
116
  @body_spool_threshold: Integer?
100
117
 
101
- @max_body_size: Integer?
118
+ @max_keepalive_requests: Integer
102
119
 
103
- @write_timeout: Integer
120
+ @access_log_io: IO?
104
121
 
105
- @server_port: Integer
122
+ @on_error: ^(Hash[String, untyped]?, Exception) -> void | nil
106
123
 
107
- @app: ^(Hash[String, untyped]) -> [ Integer, Hash[String, String | Array[String]], untyped ]
124
+ @running: AtomicBoolean
125
+
126
+ @env_template: Hash[String, untyped]
108
127
 
109
128
  # Creates a new Http1 handler.
110
129
  #
@@ -573,8 +592,20 @@ module Raptor
573
592
  # @param headers [Hash] normalized response headers
574
593
  # @return [String] formatted header lines, each ending with CRLF
575
594
  #
576
- # @rbs (Hash[String, String | Array[String]] headers) -> String
577
- def format_headers: (Hash[String, String | Array[String]] headers) -> String
595
+ # @rbs (String result, Hash[String, String | Array[String]] headers) -> void
596
+ def format_headers: (String result, Hash[String, String | Array[String]] headers) -> void
597
+
598
+ # Appends one or more `name: value` header lines to `result`. Newline-
599
+ # separated values are emitted as separate lines; empty values and values
600
+ # with illegal characters are skipped silently.
601
+ #
602
+ # @param result [String] the buffer to append to
603
+ # @param name [String] the header name
604
+ # @param value [Object] the header value (any object responding to `to_s`)
605
+ # @return [void]
606
+ #
607
+ # @rbs (String result, String name, untyped value) -> void
608
+ def append_header_value: (String result, String name, untyped value) -> void
578
609
 
579
610
  # Calls all rack.response_finished callbacks registered in the environment.
580
611
  #
@@ -145,6 +145,22 @@ module Raptor
145
145
 
146
146
  HOP_BY_HOP_HEADERS: untyped
147
147
 
148
+ REQUEST_PSEUDO_HEADERS: untyped
149
+
150
+ REQUIRED_REQUEST_PSEUDO_HEADERS: untyped
151
+
152
+ # Returns true when a decoded header block violates the HTTP/2 pseudo-header
153
+ # rules from RFC 9113 section 8.3: an unknown pseudo-header, a duplicate
154
+ # pseudo-header, a pseudo-header appearing after any regular header, or a
155
+ # required pseudo-header (`:method`, `:scheme`, `:path`) missing on
156
+ # non-`CONNECT` requests.
157
+ #
158
+ # @param headers [Array<Array(String, String)>] decoded header pairs
159
+ # @return [Boolean]
160
+ #
161
+ # @rbs (Array[[String, String]] headers) -> bool
162
+ def self.invalid_pseudo_headers?: (Array[[ String, String ]] headers) -> bool
163
+
148
164
  @initial_settings_frame: String
149
165
 
150
166
  @on_error: ^(Hash[String, untyped]?, Exception) -> void | nil
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: raptor
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.9.0
4
+ version: 0.11.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Joshua Young