raptor 0.10.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: 1b7d4b6790a256864f0c2b4e8d061231c372a110493ef2846f547f4566232d5d
4
- data.tar.gz: dc6454ad796395d9eb7ab81327410ddd2004f0e1dd0bb03857636c508e84f7bc
3
+ metadata.gz: be4ff0cf1729c188fab8261b749c2a279616bd565dbad81d18f5a3e75311b734
4
+ data.tar.gz: 0acc2ece57f69207a34696df792e45bf52bfce22bb7617c5ceb3113223c175f5
5
5
  SHA512:
6
- metadata.gz: 6c0198c8697a569b7caf4492cf80c64edfbe7e6d0c1b4d7be7880175655bfa36b89040d3ab5b9597db995ac469eed26a1c74b7bb7c0c3206c9c47193b3930a48
7
- data.tar.gz: 6a73d6a927a118dea756b8012108dbdaf1f2462ea9ea5a6fbd4dabd56cfffa51a23f059f2c51c39cd5a4f41a3fc0e752022cd1963115edacecb3cd53e56b83fe
6
+ metadata.gz: 787fabc9128f3c4e16a394dc5634ff8435916b60b68ff3f6d569b0db561f07af0c61ab5d17e7440301c850d87fbbe1f0dc3691274b1c060d1cbbd6f7bc84bfbf
7
+ data.tar.gz: 26d154b6c0abbc63986262e1212e526bd9e4e492d06f9a86e723e73fd85afd142550a15324b55ad91b575633db2d0231f262aebb818f198e2de4c96c9afa8666
data/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
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
+
3
17
  ## [0.10.0] - 2026-07-07
4
18
 
5
19
  - Memoize the server port string for the Rack env
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,20 +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.10.0 vs Puma 8.0.2, median of 3 runs across two workload profiles: IO-bound (sleep for a random 5-50ms then
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
191
  return small JSON) and CPU-bound (serialise a JSON array of 20-200 items).
192
192
 
193
193
  | Protocol | Workload | Raptor | Puma | +/- vs Puma |
194
194
  | --------------------- | -------- | ------------ | ------------ | ----------- |
195
- | HTTP/1.1 | IO | 0.43k req/s | 0.41k req/s | +4.0% |
196
- | HTTP/1.1 | CPU | 10.99k req/s | 9.22k req/s | +19.1% |
197
- | HTTP/1.1 (keep-alive) | IO | 0.41k req/s | 0.40k req/s | +2.6% |
198
- | HTTP/1.1 (keep-alive) | CPU | 27.42k req/s | 25.75k req/s | +6.5% |
199
- | HTTP/2 | IO | 0.31k req/s | N/A | - |
200
- | HTTP/2 | CPU | 26.28k req/s | N/A | - |
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 | - |
201
201
 
202
202
  > ruby 4.0.5 (2026-05-20 revision 64336ffd0e) +YJIT +PRISM [aarch64-linux]
203
- > 4 workers, 3 threads, 24 concurrent connections
203
+ > 4 workers, 3 threads, 48 concurrent connections
204
204
 
205
205
  See [bin/benchmark](bin/benchmark) for more details.
206
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
 
@@ -121,6 +121,21 @@ static inline void upcase_header_char(char *c) {
121
121
  *c = '_';
122
122
  }
123
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
+
124
139
  static const int raptor_parser_start = 1;
125
140
  static const int raptor_parser_first_final = 46;
126
141
  static const int raptor_parser_error = 0;
@@ -392,7 +407,7 @@ tr26:
392
407
  parser->flags |= FLAG_HAS_BODY;
393
408
  } else if (needs_http_prefix && parser->field_len == 17 &&
394
409
  memcmp(field_ptr, "TRANSFER_ENCODING", 17) == 0) {
395
- if (strstr(RSTRING_PTR(value), "chunked")) {
410
+ if (contains_chunked(value_ptr, value_real_len)) {
396
411
  parser->flags |= FLAG_CHUNKED | FLAG_HAS_BODY;
397
412
  parser->content_len = 0;
398
413
  }
@@ -452,7 +467,7 @@ tr29:
452
467
  parser->flags |= FLAG_HAS_BODY;
453
468
  } else if (needs_http_prefix && parser->field_len == 17 &&
454
469
  memcmp(field_ptr, "TRANSFER_ENCODING", 17) == 0) {
455
- if (strstr(RSTRING_PTR(value), "chunked")) {
470
+ if (contains_chunked(value_ptr, value_real_len)) {
456
471
  parser->flags |= FLAG_CHUNKED | FLAG_HAS_BODY;
457
472
  parser->content_len = 0;
458
473
  }
@@ -1160,7 +1175,10 @@ case 45:
1160
1175
 
1161
1176
  done:
1162
1177
  parser->cs = cs;
1163
- 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");
1164
1182
 
1165
1183
  assert(p <= pe && "buffer overflow after parsing execute");
1166
1184
  assert(parser->nread <= len && "nread longer than length");
@@ -1267,6 +1285,12 @@ static VALUE parser_has_body_p(VALUE self) {
1267
1285
  return raptor_parser_has_body(parser) ? Qtrue : Qfalse;
1268
1286
  }
1269
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
+
1270
1294
  static VALUE parser_content_length(VALUE self) {
1271
1295
  raptor_parser *parser;
1272
1296
  TypedData_Get_Struct(self, raptor_parser, &parser_type, parser);
@@ -1322,6 +1346,7 @@ RUBY_FUNC_EXPORTED void Init_raptor_http(void) {
1322
1346
  rb_define_method(cHttpParser, "execute", parser_execute, 3);
1323
1347
  rb_define_method(cHttpParser, "finished?", parser_finished_p, 0);
1324
1348
  rb_define_method(cHttpParser, "has_body?", parser_has_body_p, 0);
1349
+ rb_define_method(cHttpParser, "chunked?", parser_chunked_p, 0);
1325
1350
  rb_define_method(cHttpParser, "content_length", parser_content_length, 0);
1326
1351
  rb_define_method(cHttpParser, "nread", parser_nread, 0);
1327
1352
  rb_define_method(cHttpParser, "reset", parser_reset, 0);
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
@@ -58,6 +58,22 @@ module Raptor
58
58
 
59
59
  ILLEGAL_HEADER_KEY_REGEX = /[\x00-\x20\(\)<>@,;:\\"\/\[\]\?=\{\}\x7F]/
60
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
61
77
 
62
78
  # Returns true when the message framing shows a request-smuggling vector
63
79
  # per RFC 9112 section 6.3: a `Transfer-Encoding` where `chunked` is
@@ -89,9 +105,10 @@ module Raptor
89
105
  # Decodes a chunked transfer-encoded body buffer.
90
106
  #
91
107
  # Returns the decoded bytes and a state symbol: `:complete` when the
92
- # terminating zero-length chunk was found, `:too_large` when the decoded
93
- # size would exceed `max_size`, `:malformed` when chunk framing overhead
94
- # 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.
95
112
  #
96
113
  # @param buffer [String] the raw body buffer to decode
97
114
  # @param max_size [Integer, nil] maximum decoded body size, or nil for unlimited
@@ -107,8 +124,24 @@ module Raptor
107
124
  crlf = buffer.index("\r\n", offset)
108
125
  return [decoded, :incomplete] unless crlf
109
126
 
110
- chunk_size = buffer.byteslice(offset, crlf - offset).to_i(16)
111
- 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
+
112
145
  return [decoded, :too_large] if max_size && (decoded.bytesize + chunk_size) > max_size
113
146
 
114
147
  overhead += (crlf - offset) + 4
@@ -240,7 +273,8 @@ module Raptor
240
273
  buffer << socket.read_nonblock(socket.pending)
241
274
  end
242
275
 
243
- parser = HttpParser.new
276
+ parser = (Thread.current[:raptor_http_parser] ||= HttpParser.new)
277
+ parser.reset
244
278
  env = @env_template.dup
245
279
  nread = begin
246
280
  parser.execute(env, buffer, 0)
@@ -254,7 +288,7 @@ module Raptor
254
288
  if !parser.finished?
255
289
  fallback_to_reactor(socket, id, buffer, env, parse_data, reactor, 0, remote_addr, url_scheme, persisted: false)
256
290
  return
257
- elsif Http1.request_smuggling?(env)
291
+ elsif Http1.invalid_host?(env) || Http1.request_smuggling?(env)
258
292
  reject_malformed(socket)
259
293
  return
260
294
  elsif parser.has_body?
@@ -265,7 +299,7 @@ module Raptor
265
299
 
266
300
  body = buffer.byteslice(nread..-1) || ""
267
301
 
268
- if env[HTTP_TRANSFER_ENCODING]&.include?(TRANSFER_ENCODING_CHUNKED)
302
+ if parser.chunked?
269
303
  body, chunked_state = Http1.decode_chunked(body, @max_body_size)
270
304
  case chunked_state
271
305
  when :complete
@@ -322,14 +356,14 @@ module Raptor
322
356
  parse_data[:parse_count] += 1
323
357
 
324
358
  message = if parser.finished?
325
- if Raptor::Http1.request_smuggling?(env)
359
+ if Raptor::Http1.invalid_host?(env) || Raptor::Http1.request_smuggling?(env)
326
360
  data.merge(env: env, body: nil, parse_data: parse_data, complete: true, malformed: true)
327
361
  elsif parser.has_body?
328
362
  body_buffer = data[:buffer].byteslice(nread..-1) || ""
329
363
 
330
364
  if max_body_size && parser.content_length > max_body_size
331
365
  data.merge(env: env, body: nil, parse_data: parse_data, complete: true, too_large: true)
332
- elsif env[HTTP_TRANSFER_ENCODING]&.include?(TRANSFER_ENCODING_CHUNKED)
366
+ elsif parser.chunked?
333
367
  decoded_body, chunked_state = Raptor::Http1.decode_chunked(body_buffer, max_body_size)
334
368
 
335
369
  case chunked_state
@@ -571,7 +605,8 @@ module Raptor
571
605
  buffer << socket.read_nonblock(socket.pending)
572
606
  end
573
607
 
574
- parser = HttpParser.new
608
+ parser = (Thread.current[:raptor_http_parser] ||= HttpParser.new)
609
+ parser.reset
575
610
  env = @env_template.dup
576
611
  nread = begin
577
612
  parser.execute(env, buffer, 0)
@@ -588,8 +623,7 @@ module Raptor
588
623
  elsif parser.has_body?
589
624
  body = buffer.byteslice(nread..-1) || ""
590
625
 
591
- chunked = env[HTTP_TRANSFER_ENCODING]&.include?(TRANSFER_ENCODING_CHUNKED)
592
- if chunked || parser.content_length > body.bytesize
626
+ if parser.chunked? || parser.content_length > body.bytesize
593
627
  fallback_to_reactor(socket, id, buffer, env, parse_data, reactor, request_count, remote_addr, url_scheme)
594
628
  return
595
629
  end
@@ -597,7 +631,7 @@ module Raptor
597
631
 
598
632
  request_count += 1
599
633
 
600
- if thread_pool.queue_size > 0
634
+ if thread_pool.queue_size >= thread_pool.size
601
635
  thread_pool << proc do
602
636
  process_client(
603
637
  socket,
@@ -718,8 +752,23 @@ module Raptor
718
752
  send_early_hints(socket, hints) rescue nil
719
753
  end
720
754
 
721
- env[Rack::PATH_INFO] = env.delete(Rack::REQUEST_PATH) if env.key?(Rack::REQUEST_PATH)
722
- env[Rack::PATH_INFO] = "" unless env.key?(Rack::PATH_INFO)
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
771
+
723
772
  if (content_length = parse_data[:content_length]).positive?
724
773
  env[Http::CONTENT_LENGTH] = content_length.to_s
725
774
  end
@@ -976,7 +1025,7 @@ module Raptor
976
1025
  #
977
1026
  # @rbs (TCPSocket socket, String response, Hash[String, String | Array[String]] headers, ^(TCPSocket) -> void response_hijack) -> void
978
1027
  def write_hijacked_response(socket, response, headers, response_hijack)
979
- response << format_headers(headers)
1028
+ format_headers(response, headers)
980
1029
  response << "\r\n"
981
1030
  socket_write(socket, response)
982
1031
  uncork_socket(socket)
@@ -1001,7 +1050,7 @@ module Raptor
1001
1050
  headers[Rack::CONTENT_LENGTH] = "0" unless headers.key?(Rack::CONTENT_LENGTH) || headers.key?(Rack::TRANSFER_ENCODING)
1002
1051
  end
1003
1052
 
1004
- response << format_headers(headers)
1053
+ format_headers(response, headers)
1005
1054
  response << "\r\n"
1006
1055
  socket_write(socket, response)
1007
1056
  end
@@ -1023,7 +1072,7 @@ module Raptor
1023
1072
  # @rbs (TCPSocket socket, String response, Hash[String, String | Array[String]] headers, untyped body, String http_version) -> void
1024
1073
  def write_full_response(socket, response, headers, body, http_version)
1025
1074
  if body.respond_to?(:call)
1026
- response << format_headers(headers)
1075
+ format_headers(response, headers)
1027
1076
  response << "\r\n"
1028
1077
  socket_write(socket, response)
1029
1078
  uncork_socket(socket)
@@ -1049,7 +1098,7 @@ module Raptor
1049
1098
  headers[Rack::TRANSFER_ENCODING] = TRANSFER_ENCODING_CHUNKED
1050
1099
  end
1051
1100
 
1052
- response << format_headers(headers)
1101
+ format_headers(response, headers)
1053
1102
  response << "\r\n"
1054
1103
 
1055
1104
  if body.respond_to?(:to_path) && (path = body.to_path) && File.readable?(path)
@@ -1253,9 +1302,8 @@ module Raptor
1253
1302
  # @param headers [Hash] normalized response headers
1254
1303
  # @return [String] formatted header lines, each ending with CRLF
1255
1304
  #
1256
- # @rbs (Hash[String, String | Array[String]] headers) -> String
1257
- def format_headers(headers)
1258
- result = +""
1305
+ # @rbs (String result, Hash[String, String | Array[String]] headers) -> void
1306
+ def format_headers(result, headers)
1259
1307
  headers.each do |name, value|
1260
1308
  next if illegal_header_key?(name)
1261
1309
 
@@ -1265,7 +1313,6 @@ module Raptor
1265
1313
  append_header_value(result, name, value)
1266
1314
  end
1267
1315
  end
1268
- result
1269
1316
  end
1270
1317
 
1271
1318
  # Appends one or more `name: value` header lines to `result`. Newline-
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
@@ -2,5 +2,5 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  module Raptor
5
- VERSION = "0.10.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
@@ -64,6 +64,18 @@ module Raptor
64
64
 
65
65
  ILLEGAL_HEADER_VALUE_REGEX: ::Regexp
66
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
+
67
79
  # Returns true when the message framing shows a request-smuggling vector
68
80
  # per RFC 9112 section 6.3: a `Transfer-Encoding` where `chunked` is
69
81
  # missing, not the final encoding, or duplicated; a `Transfer-Encoding`
@@ -79,9 +91,10 @@ module Raptor
79
91
  # Decodes a chunked transfer-encoded body buffer.
80
92
  #
81
93
  # Returns the decoded bytes and a state symbol: `:complete` when the
82
- # terminating zero-length chunk was found, `:too_large` when the decoded
83
- # size would exceed `max_size`, `:malformed` when chunk framing overhead
84
- # 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.
85
98
  #
86
99
  # @param buffer [String] the raw body buffer to decode
87
100
  # @param max_size [Integer, nil] maximum decoded body size, or nil for unlimited
@@ -579,8 +592,8 @@ module Raptor
579
592
  # @param headers [Hash] normalized response headers
580
593
  # @return [String] formatted header lines, each ending with CRLF
581
594
  #
582
- # @rbs (Hash[String, String | Array[String]] headers) -> String
583
- 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
584
597
 
585
598
  # Appends one or more `name: value` header lines to `result`. Newline-
586
599
  # separated values are emitted as separate lines; empty values and values
@@ -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.10.0
4
+ version: 0.11.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Joshua Young