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 +4 -4
- data/CHANGELOG.md +25 -0
- data/README.md +12 -11
- data/docs/raptor-vs-puma.md +45 -34
- data/ext/raptor_http/raptor_http.c +115 -9
- data/lib/raptor/binder.rb +1 -0
- data/lib/raptor/cli.rb +2 -2
- data/lib/raptor/http.rb +1 -0
- data/lib/raptor/http1.rb +152 -54
- data/lib/raptor/http2.rb +46 -3
- data/lib/raptor/reuseport_bpf.rb +1 -0
- data/lib/raptor/server.rb +0 -1
- data/lib/raptor/version.rb +1 -1
- data/sig/generated/raptor/http.rbs +2 -0
- data/sig/generated/raptor/http1.rbs +44 -13
- data/sig/generated/raptor/http2.rbs +16 -0
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: be4ff0cf1729c188fab8261b749c2a279616bd565dbad81d18f5a3e75311b734
|
|
4
|
+
data.tar.gz: 0acc2ece57f69207a34696df792e45bf52bfce22bb7617c5ceb3113223c175f5
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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.
|
|
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
|
|
193
|
-
| --------------------- | -------- |
|
|
194
|
-
| HTTP/1.1 | IO |
|
|
195
|
-
| HTTP/1.1 | CPU |
|
|
196
|
-
| HTTP/1.1 (keep-alive) | IO |
|
|
197
|
-
| HTTP/1.1 (keep-alive) | CPU |
|
|
198
|
-
| HTTP/2 | IO | 0.
|
|
199
|
-
| HTTP/2 | CPU |
|
|
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,
|
|
203
|
+
> 4 workers, 3 threads, 48 concurrent connections
|
|
203
204
|
|
|
204
205
|
See [bin/benchmark](bin/benchmark) for more details.
|
|
205
206
|
|
data/docs/raptor-vs-puma.md
CHANGED
|
@@ -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,
|
|
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
|
-
###
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
114
|
+
subgraph Worker["Puma worker process, one of N"]
|
|
115
|
+
SRV["Server thread<br/>IO.select on listeners<br/>accept_nonblock"]
|
|
115
116
|
|
|
116
|
-
|
|
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
|
-
|
|
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"|
|
|
133
|
-
|
|
135
|
+
C1 -->|"incomplete, register"| RCT
|
|
136
|
+
RCT -->|"socket readable"| C2
|
|
134
137
|
C2 -->|"complete, push"| TP
|
|
135
|
-
C2 -->|"incomplete, keep waiting"|
|
|
136
|
-
|
|
138
|
+
C2 -->|"incomplete, keep waiting"| RCT
|
|
139
|
+
RCT -->|"timeout"| TO["write 408, close"]
|
|
137
140
|
APP -->|"write response"| KA
|
|
138
|
-
KA -->|"no"|
|
|
141
|
+
KA -->|"no"| CLS["close socket"]
|
|
139
142
|
KA -->|"yes"| BB
|
|
140
143
|
BB -->|"yes, reprocess"| TP
|
|
141
|
-
BB -->|"no, back to reactor"|
|
|
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
|
-
|
|
149
|
-
class
|
|
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
|
-
|
|
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
|
-
###
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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,
|
|
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
|
|
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.
|
|
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->>
|
|
486
|
-
|
|
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
|
|
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
|
-
|
|
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->
|
|
390
|
+
key_len = parser->field_len;
|
|
391
|
+
parser->buf[key_len] = '\0';
|
|
304
392
|
}
|
|
305
393
|
|
|
306
|
-
VALUE key =
|
|
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 (
|
|
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
|
-
|
|
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->
|
|
450
|
+
key_len = parser->field_len;
|
|
451
|
+
parser->buf[key_len] = '\0';
|
|
361
452
|
}
|
|
362
453
|
|
|
363
|
-
VALUE key =
|
|
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 (
|
|
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,
|
|
190
|
+
argv.each_with_index do |arg, index|
|
|
191
191
|
case arg
|
|
192
|
-
when "-c", "--config" then return argv[
|
|
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
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
|
|
92
|
-
# size would exceed `max_size`, `:malformed`
|
|
93
|
-
#
|
|
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
|
-
|
|
110
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
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" : @
|
|
781
|
+
default_port = behind_tls_proxy ? "443" : @server_port_string
|
|
729
782
|
|
|
730
783
|
http_host = env[Rack::HTTP_HOST]
|
|
731
|
-
|
|
784
|
+
host = nil
|
|
785
|
+
port = nil
|
|
786
|
+
if http_host && !http_host.empty?
|
|
732
787
|
if http_host.start_with?("[")
|
|
733
|
-
|
|
734
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
1152
|
+
buffer = response
|
|
1093
1153
|
while (chunk = file.read(FILE_CHUNK_SIZE))
|
|
1094
|
-
|
|
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 <<
|
|
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
|
-
|
|
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
|
-
|
|
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) ->
|
|
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
|
-
|
|
1239
|
-
|
|
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 << "
|
|
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
|
-
|
|
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
|
-
|
|
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 { |
|
|
734
|
+
value.each { |entry| header_pairs << [lowered, entry.to_s] }
|
|
692
735
|
else
|
|
693
736
|
header_pairs << [lowered, value.to_s]
|
|
694
737
|
end
|
data/lib/raptor/reuseport_bpf.rb
CHANGED
|
@@ -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
data/lib/raptor/version.rb
CHANGED
|
@@ -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
|
|
81
|
-
# size would exceed `max_size`, `:malformed`
|
|
82
|
-
#
|
|
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
|
-
@
|
|
106
|
+
@app: ^(Hash[String, untyped]) -> [ Integer, Hash[String, String | Array[String]], untyped ]
|
|
92
107
|
|
|
93
|
-
@
|
|
108
|
+
@server_port: Integer
|
|
94
109
|
|
|
95
|
-
@
|
|
110
|
+
@server_port_string: String
|
|
96
111
|
|
|
97
|
-
@
|
|
112
|
+
@write_timeout: Integer
|
|
113
|
+
|
|
114
|
+
@max_body_size: Integer?
|
|
98
115
|
|
|
99
116
|
@body_spool_threshold: Integer?
|
|
100
117
|
|
|
101
|
-
@
|
|
118
|
+
@max_keepalive_requests: Integer
|
|
102
119
|
|
|
103
|
-
@
|
|
120
|
+
@access_log_io: IO?
|
|
104
121
|
|
|
105
|
-
@
|
|
122
|
+
@on_error: ^(Hash[String, untyped]?, Exception) -> void | nil
|
|
106
123
|
|
|
107
|
-
@
|
|
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) ->
|
|
577
|
-
def format_headers: (Hash[String, String | Array[String]] headers) ->
|
|
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
|