raptor 0.16.1 → 0.17.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: 0e564259e8919bb36ded41097d9f3930fcf6ad2f2d090cd4bf0a2ef4cbf424b4
4
- data.tar.gz: 1bf2f2e1077af1c2b61ba4b52bd5b13460bf0f27105a355b5dc01ff05e62b7b8
3
+ metadata.gz: fbd24155ed44e8b8faf1f5b11a6078a34ab58bf43c290f3ffcf2a4a582c74d16
4
+ data.tar.gz: 7d6a0f38240c4ae3e723650fcd7a9888f2b59b69f3145018a918524565324972
5
5
  SHA512:
6
- metadata.gz: 103f96d2f2490ea4bd1f844f29a8980deba5881e52b3c684ec2db963c0b0b0d426d616ce66ff22be93736550583891862502c1216663426249e015e1c68ab29d
7
- data.tar.gz: e377a59417305334b255cf08be1c8c257971840d244b8b14b461ebbdfb857dc5a8cfbd6d37bee26ab2dc95642601bd1b7640100277c0b401f225fab66bbf8c77
6
+ metadata.gz: 811414a1f6a2469ef38c20935564ba204efdc5988437be5d0fa8a167d85a42204abe2a743115d090b24cf2cc8489e8dffdf57a93c1313936dcfa04a7d2213768
7
+ data.tar.gz: 29a62a959b22ca99c55eea39919d8fe3618fdb5061054718c38503c658c803d77a3a2b40cc9b8089da917faf4041569545073c24c468f6e8552f0d113ee0d469
@@ -41,7 +41,7 @@ steps:
41
41
  soft_fail: true
42
42
  image: "rubylang/ruby:master-dev"
43
43
  commands:
44
- - apt-get update && apt-get install -y libyaml-dev
44
+ - apt-get update && apt-get install -y libyaml-dev libssl-dev
45
45
  - ruby -v
46
46
  - bundle install
47
47
  - bundle exec rake ci
@@ -52,7 +52,7 @@ steps:
52
52
  env:
53
53
  RUBY_YJIT_ENABLE: "1"
54
54
  commands:
55
- - apt-get update && apt-get install -y libyaml-dev
55
+ - apt-get update && apt-get install -y libyaml-dev libssl-dev
56
56
  - ruby -v
57
57
  - bundle install
58
58
  - bundle exec rake ci
@@ -63,7 +63,7 @@ steps:
63
63
  env:
64
64
  RUBY_GC_STRESS: "1"
65
65
  commands:
66
- - apt-get update && apt-get install -y libyaml-dev
66
+ - apt-get update && apt-get install -y libyaml-dev libssl-dev
67
67
  - ruby -v
68
68
  - bundle install
69
69
  - bundle exec rake ci
@@ -75,7 +75,7 @@ steps:
75
75
  RUBY_YJIT_ENABLE: "1"
76
76
  RUBY_GC_STRESS: "1"
77
77
  commands:
78
- - apt-get update && apt-get install -y libyaml-dev
78
+ - apt-get update && apt-get install -y libyaml-dev libssl-dev
79
79
  - ruby -v
80
80
  - bundle install
81
81
  - bundle exec rake ci
data/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.17.0] - 2026-08-29
4
+
5
+ - Reserve BPF-routed connections before accept
6
+ - Skip unnecessary HTTP/1.1 response work
7
+ - Assemble HTTP/2 response headers in C
8
+ - Emit HTTP/1.1 response headers and chunked bodies in C
9
+ - Decode chunked request bodies in C
10
+
3
11
  ## [0.16.1] - 2026-08-23
4
12
 
5
13
  - Drop the stale `Ractors=NUM` option from the Rackup handler
data/README.md CHANGED
@@ -37,7 +37,7 @@ run proc { |_env| [200, { "content-type" => "text/plain" }, ["Hello, World!"]] }
37
37
  ```
38
38
  > bundle exec raptor -w 10 -t 3 hello_world.ru
39
39
  [Raptor 83654|Main|Main] Cluster initializing:
40
- [Raptor 83654|Main|Main] ├─ Version: 0.16.1
40
+ [Raptor 83654|Main|Main] ├─ Version: 0.17.0
41
41
  [Raptor 83654|Main|Main] ├─ Ruby Version: ruby 4.0.6 (2026-07-14 revision 03b6d3f889) +YJIT +PRISM [arm64-darwin23]
42
42
  [Raptor 83654|Main|Main] ├─ Environment: development
43
43
  [Raptor 83654|Main|Main] ├─ Master PID: 83654
@@ -201,7 +201,7 @@ Worker 1 (phase 0): pid=91351, requests=1199, busy=1/3, backlog=0, booted, last_
201
201
 
202
202
  ## (Micro) Benchmarks
203
203
 
204
- Raptor 0.16.1 vs Puma 8.0.2 vs Falcon 0.57.0 across two workload profiles. **IO-bound** is a GET endpoint that
204
+ Raptor 0.17.0 vs Puma 8.0.2 vs Falcon 0.57.0 across two workload profiles. **IO-bound** is a GET endpoint that
205
205
  interleaves 5-10 short sleeps (total 2.5-15ms) with small CPU work, simulating a read path that makes several DB or
206
206
  cache calls. **CPU-bound** is a POST endpoint that accepts a small JSON body, interleaves 3-5 chunks of JSON item
207
207
  building (total 450-1500 items) with sub-100µs sleeps, and returns the built array, simulating a write path that does
@@ -210,16 +210,16 @@ most of its work in Ruby with a few near-zero-cost cache hits.
210
210
  Each cell reports the median throughput and median p95 latency independently across 5 runs, so the two numbers in a row
211
211
  may come from different runs. Every run starts a fresh server process so the samples are independent of each other;
212
212
  state accumulated in a previous run cannot bias the next. Across the whole table, the widest spread
213
- ((max - min) / 2 / median) between runs of a single cell was ±24.8% for throughput and ±40.3% for p95.
213
+ ((max - min) / 2 / median) between runs of a single cell was ±22.4% for throughput and ±37.4% for p95.
214
214
 
215
215
  | Protocol | Workload | Raptor req/s | Raptor p95 | Puma req/s | Puma p95 | vs Puma req/s | vs Puma p95 | Falcon req/s | Falcon p95 | vs Falcon req/s | vs Falcon p95 |
216
216
  | --------------------- | -------- | ------------ | ---------- | ----------- | --------- | ------------- | ------------ | ------------ | ---------- | --------------- | ------------- |
217
- | HTTP/1.1 | IO | 3.21k req/s | 63.20 ms | 1.48k req/s | 131.00 ms | 117.7% higher | 51.8% lower | 12.27k req/s | 14.00 ms | 73.8% lower | 351.4% higher |
218
- | HTTP/1.1 | CPU | 8.29k req/s | 33.30 ms | 8.56k req/s | 21.20 ms | 3.2% lower | 57.1% higher | 6.62k req/s | 27.00 ms | 25.2% higher | 23.3% higher |
219
- | HTTP/1.1 (keep-alive) | IO | 2.43k req/s | 54.40 ms | 1.44k req/s | 110.20 ms | 68.7% higher | 50.6% lower | 6.28k req/s | 27.90 ms | 61.3% lower | 95.0% higher |
220
- | HTTP/1.1 (keep-alive) | CPU | 8.40k req/s | 18.80 ms | 8.49k req/s | 21.90 ms | 1.1% lower | 14.2% lower | 7.09k req/s | 31.50 ms | 18.5% higher | 40.3% lower |
221
- | HTTP/2 | IO | 0.97k req/s | 197.97 ms | N/A | N/A | - | - | 6.59k req/s | 27.21 ms | 85.2% lower | 627.6% higher |
222
- | HTTP/2 | CPU | 6.61k req/s | 31.98 ms | N/A | N/A | - | - | 8.09k req/s | 45.04 ms | 18.3% lower | 29.0% lower |
217
+ | HTTP/1.1 | IO | 3.35k req/s | 59.60 ms | 1.51k req/s | 126.70 ms | 121.9% higher | 53.0% lower | 12.19k req/s | 14.10 ms | 72.6% lower | 322.7% higher |
218
+ | HTTP/1.1 | CPU | 8.31k req/s | 25.00 ms | 8.63k req/s | 21.10 ms | 3.7% lower | 18.5% higher | 6.57k req/s | 27.40 ms | 26.4% higher | 8.8% lower |
219
+ | HTTP/1.1 (keep-alive) | IO | 3.22k req/s | 46.90 ms | 1.47k req/s | 113.80 ms | 119.9% higher | 58.8% lower | 6.23k req/s | 28.20 ms | 48.2% lower | 66.3% higher |
220
+ | HTTP/1.1 (keep-alive) | CPU | 8.30k req/s | 23.30 ms | 8.41k req/s | 22.10 ms | 1.3% lower | 5.4% higher | 6.95k req/s | 32.30 ms | 19.5% higher | 27.9% lower |
221
+ | HTTP/2 | IO | 0.98k req/s | 196.28 ms | N/A | N/A | - | - | 6.55k req/s | 27.71 ms | 85.1% lower | 608.4% higher |
222
+ | HTTP/2 | CPU | 6.23k req/s | 33.11 ms | N/A | N/A | - | - | 6.92k req/s | 42.94 ms | 10.0% lower | 22.9% lower |
223
223
 
224
224
  > ruby 4.0.6 (2026-07-14 revision 03b6d3f889) +YJIT +PRISM [aarch64-linux]
225
225
  > 10 worker processes; Raptor and Puma run 3 threads per worker, Falcon runs unbounded fibers per worker;
@@ -37,7 +37,7 @@ _[luma.com/t3l24s8h](https://luma.com/t3l24s8h)_
37
37
  - <big>I built a Ruby web server over the last few months</big>
38
38
  - <big>It's called Raptor</big>
39
39
  - <big>It runs Rack apps, like Puma and Falcon do</big>
40
- - <big>It's built around Ruby 4's Ractors</big>
40
+ - <big>It's built around a Ractor-parallel HTTP parser</big>
41
41
  - <big>It's fast enough that the numbers are interesting</big>
42
42
  - <big>It's not production-ready. Nobody's Rails app should be behind it yet.</big>
43
43
 
@@ -1,6 +1,6 @@
1
1
  # Raptor vs Puma: A Design Comparison
2
2
 
3
- Raptor is a Ruby web server designed around Ruby 4's Ractors. On CPU-bound HTTP/1.1 it holds a real edge over the latest Puma release on the same hardware regardless of keep-alive; on IO-heavy work fibers still win, and Falcon is the server to compare to there rather than Raptor or Puma. Raptor also speaks HTTP/2 natively, which Puma does not. This document is a systems-design walkthrough of how it manages both.
3
+ Raptor is a Ruby web server designed around a Ractor-parallel HTTP parser. On CPU-bound HTTP/1.1 it holds a real edge over the latest Puma release on the same hardware regardless of keep-alive; on IO-heavy work fibers still win, and Falcon is the server to compare to there rather than Raptor or Puma. Raptor also speaks HTTP/2 natively, which Puma does not. This document is a systems-design walkthrough of how it manages both.
4
4
 
5
5
  ## Why this document exists
6
6
 
@@ -2,7 +2,6 @@
2
2
  #include <bpf/bpf_helpers.h>
3
3
 
4
4
  #define MAX_WORKERS 64
5
- #define LOAD_TIE_TOLERANCE 1
6
5
 
7
6
  // Per-worker listening sockets, keyed by worker index.
8
7
  struct {
@@ -20,14 +19,12 @@ struct {
20
19
  __uint(max_entries, MAX_WORKERS + 1);
21
20
  } loads SEC(".maps");
22
21
 
23
- // Routes each incoming connection to the least-loaded worker. When the
24
- // spread between the busiest and idlest worker is within
25
- // `LOAD_TIE_TOLERANCE`, all workers are treated as tied and the connection
26
- // is placed by 4-tuple hash so bursts of accepts spread across workers
27
- // instead of clustering on whichever worker most recently reported the
28
- // lowest load.
22
+ // Uses the power-of-two-choices strategy and reserves the selected worker's
23
+ // slot before returning. Sampling two hash-selected workers avoids
24
+ // concentrating variable-cost requests, while the reservation prevents a
25
+ // burst from repeatedly selecting the same reported load.
29
26
  SEC("sk_reuseport")
30
- int select_least_loaded(struct sk_reuseport_md *ctx) {
27
+ int select_less_loaded(struct sk_reuseport_md *ctx) {
31
28
  __u32 count_key = 0;
32
29
  __u32 *count_ptr = bpf_map_lookup_elem(&loads, &count_key);
33
30
  if (!count_ptr || *count_ptr == 0) {
@@ -38,29 +35,23 @@ int select_least_loaded(struct sk_reuseport_md *ctx) {
38
35
  num_workers = MAX_WORKERS;
39
36
  }
40
37
 
41
- __u32 min_load = ~0u;
42
- __u32 max_load = 0;
43
- __u32 min_idx = 0;
44
-
45
- for (__u32 worker_idx = 0; worker_idx < MAX_WORKERS; worker_idx++) {
46
- if (worker_idx >= num_workers) {
47
- break;
48
- }
49
- __u32 worker_key = worker_idx + 1;
50
- __u32 *load_ptr = bpf_map_lookup_elem(&loads, &worker_key);
51
- if (!load_ptr) {
52
- continue;
53
- }
54
- if (*load_ptr < min_load) {
55
- min_load = *load_ptr;
56
- min_idx = worker_idx;
57
- }
58
- if (*load_ptr > max_load) {
59
- max_load = *load_ptr;
38
+ __u32 chosen_idx = ctx->hash % num_workers;
39
+ __u32 chosen_key = chosen_idx + 1;
40
+ __u32 *chosen_load = bpf_map_lookup_elem(&loads, &chosen_key);
41
+ if (num_workers > 1) {
42
+ __u32 alternate_idx = (chosen_idx + 1 + ((ctx->hash >> 16) % (num_workers - 1))) % num_workers;
43
+ __u32 alternate_key = alternate_idx + 1;
44
+ __u32 *alternate_load = bpf_map_lookup_elem(&loads, &alternate_key);
45
+ if (chosen_load && alternate_load && *alternate_load < *chosen_load) {
46
+ chosen_idx = alternate_idx;
47
+ chosen_load = alternate_load;
60
48
  }
61
49
  }
62
50
 
63
- __u32 chosen_idx = (max_load - min_load <= LOAD_TIE_TOLERANCE) ? (ctx->hash % num_workers) : min_idx;
51
+ if (chosen_load) {
52
+ __sync_fetch_and_add(chosen_load, 1);
53
+ }
54
+
64
55
  bpf_sk_select_reuseport(ctx, &socks, &chosen_idx, 0);
65
56
  return SK_PASS;
66
57
  }
@@ -37,6 +37,8 @@ typedef struct raptor_parser {
37
37
  #define FLAG_HAS_BODY 0x2
38
38
  #define FLAG_FINISHED 0x4
39
39
 
40
+ #define MAX_CHUNK_OVERHEAD (16 * 1024)
41
+
40
42
  static VALUE eHttpParserError;
41
43
  static VALUE global_request_method;
42
44
  static VALUE global_request_uri;
@@ -44,6 +46,10 @@ static VALUE global_query_string;
44
46
  static VALUE global_server_protocol;
45
47
  static VALUE global_request_path;
46
48
  static VALUE global_fragment;
49
+ static ID id_complete;
50
+ static ID id_incomplete;
51
+ static ID id_malformed;
52
+ static ID id_too_large;
47
53
 
48
54
  struct common_field {
49
55
  const char *name;
@@ -1316,6 +1322,200 @@ static VALUE parser_body(VALUE self) {
1316
1322
  return parser->body;
1317
1323
  }
1318
1324
 
1325
+ static const char *find_crlf(const char *buf, size_t len) {
1326
+ if (len < 2) return NULL;
1327
+ const char *end = buf + len - 1;
1328
+ for (const char *p = buf; p < end; p++) {
1329
+ if (*p == '\r' && *(p + 1) == '\n') return p;
1330
+ }
1331
+ return NULL;
1332
+ }
1333
+
1334
+ static VALUE parser_decode_chunked(int argc, VALUE *argv, VALUE self) {
1335
+ (void)self;
1336
+ VALUE buffer, max_size;
1337
+ rb_scan_args(argc, argv, "11", &buffer, &max_size);
1338
+ Check_Type(buffer, T_STRING);
1339
+
1340
+ const char *buf = RSTRING_PTR(buffer);
1341
+ size_t len = (size_t)RSTRING_LEN(buffer);
1342
+ size_t offset = 0;
1343
+ size_t overhead = 0;
1344
+ int has_max = !NIL_P(max_size);
1345
+ size_t max_bytes = has_max ? NUM2SIZET(max_size) : 0;
1346
+
1347
+ VALUE decoded = rb_str_buf_new((long)len);
1348
+
1349
+ while (offset < len) {
1350
+ const char *crlf = find_crlf(buf + offset, len - offset);
1351
+ if (!crlf) return rb_ary_new_from_args(2, decoded, ID2SYM(id_incomplete));
1352
+
1353
+ size_t line_len = (size_t)(crlf - (buf + offset));
1354
+ size_t size_len = line_len;
1355
+ for (size_t i = 0; i < line_len; i++) {
1356
+ if (buf[offset + i] == ';') { size_len = i; break; }
1357
+ }
1358
+ if (size_len == 0) return rb_ary_new_from_args(2, decoded, ID2SYM(id_malformed));
1359
+
1360
+ uint64_t chunk_size = 0;
1361
+ for (size_t i = 0; i < size_len; i++) {
1362
+ char c = buf[offset + i];
1363
+ uint64_t digit;
1364
+ if (c >= '0' && c <= '9') digit = (uint64_t)(c - '0');
1365
+ else if (c >= 'a' && c <= 'f') digit = (uint64_t)(c - 'a' + 10);
1366
+ else if (c >= 'A' && c <= 'F') digit = (uint64_t)(c - 'A' + 10);
1367
+ else return rb_ary_new_from_args(2, decoded, ID2SYM(id_malformed));
1368
+ if (chunk_size > (UINT64_MAX >> 4)) return rb_ary_new_from_args(2, decoded, ID2SYM(id_malformed));
1369
+ chunk_size = (chunk_size << 4) | digit;
1370
+ }
1371
+ if (chunk_size > (uint64_t)(SIZE_MAX / 2)) return rb_ary_new_from_args(2, decoded, ID2SYM(id_malformed));
1372
+
1373
+ size_t crlf_offset = offset + line_len;
1374
+
1375
+ if (chunk_size == 0) {
1376
+ size_t trailer_offset = crlf_offset + 2;
1377
+ while (1) {
1378
+ if (trailer_offset >= len) return rb_ary_new_from_args(2, decoded, ID2SYM(id_incomplete));
1379
+ const char *tcrlf = find_crlf(buf + trailer_offset, len - trailer_offset);
1380
+ if (!tcrlf) return rb_ary_new_from_args(2, decoded, ID2SYM(id_incomplete));
1381
+ if (tcrlf == buf + trailer_offset) return rb_ary_new_from_args(2, decoded, ID2SYM(id_complete));
1382
+ trailer_offset = (size_t)(tcrlf - buf) + 2;
1383
+ }
1384
+ }
1385
+
1386
+ size_t decoded_len = (size_t)RSTRING_LEN(decoded);
1387
+ if (has_max && (decoded_len + (size_t)chunk_size) > max_bytes) {
1388
+ return rb_ary_new_from_args(2, decoded, ID2SYM(id_too_large));
1389
+ }
1390
+
1391
+ overhead += line_len + 4;
1392
+ if (overhead > (decoded_len + (size_t)chunk_size + MAX_CHUNK_OVERHEAD)) {
1393
+ return rb_ary_new_from_args(2, decoded, ID2SYM(id_malformed));
1394
+ }
1395
+
1396
+ size_t data_offset = crlf_offset + 2;
1397
+ size_t available = len > data_offset ? len - data_offset : 0;
1398
+ size_t bytes_to_copy = (size_t)chunk_size < available ? (size_t)chunk_size : available;
1399
+ rb_str_cat(decoded, buf + data_offset, (long)bytes_to_copy);
1400
+
1401
+ offset = data_offset + (size_t)chunk_size + 2;
1402
+ }
1403
+
1404
+ return rb_ary_new_from_args(2, decoded, ID2SYM(id_incomplete));
1405
+ }
1406
+
1407
+ static uint8_t illegal_header_key_bitmap[32];
1408
+ static uint8_t illegal_header_value_bitmap[32];
1409
+
1410
+ static void bitmap_set(uint8_t *bitmap, unsigned char byte) {
1411
+ bitmap[byte >> 3] |= (uint8_t)(1u << (byte & 7));
1412
+ }
1413
+
1414
+ static int contains_illegal_byte(const uint8_t *bitmap, const char *str, size_t len) {
1415
+ for (size_t i = 0; i < len; i++) {
1416
+ unsigned char byte = (unsigned char)str[i];
1417
+ if ((bitmap[byte >> 3] >> (byte & 7)) & 1) return 1;
1418
+ }
1419
+ return 0;
1420
+ }
1421
+
1422
+ static VALUE parser_illegal_header_key_p(VALUE self, VALUE key) {
1423
+ (void)self;
1424
+ Check_Type(key, T_STRING);
1425
+ return contains_illegal_byte(illegal_header_key_bitmap, RSTRING_PTR(key), (size_t)RSTRING_LEN(key)) ? Qtrue : Qfalse;
1426
+ }
1427
+
1428
+ static VALUE parser_illegal_header_value_p(VALUE self, VALUE value) {
1429
+ (void)self;
1430
+ Check_Type(value, T_STRING);
1431
+ return contains_illegal_byte(illegal_header_value_bitmap, RSTRING_PTR(value), (size_t)RSTRING_LEN(value)) ? Qtrue : Qfalse;
1432
+ }
1433
+
1434
+ static void emit_header_line(VALUE buffer, const char *name, long name_len,
1435
+ const char *value, long value_len) {
1436
+ if (value_len == 0) return;
1437
+ if (contains_illegal_byte(illegal_header_value_bitmap, value, (size_t)value_len)) return;
1438
+ rb_str_cat(buffer, name, name_len);
1439
+ rb_str_cat(buffer, ": ", 2);
1440
+ rb_str_cat(buffer, value, value_len);
1441
+ rb_str_cat(buffer, "\r\n", 2);
1442
+ }
1443
+
1444
+ static void emit_header(VALUE buffer, const char *name, long name_len, VALUE value) {
1445
+ VALUE str = RB_TYPE_P(value, T_STRING) ? value : rb_funcall(value, rb_intern("to_s"), 0);
1446
+ const char *ptr = RSTRING_PTR(str);
1447
+ long len = RSTRING_LEN(str);
1448
+ if (len == 0) return;
1449
+
1450
+ if (!memchr(ptr, '\n', (size_t)len)) {
1451
+ emit_header_line(buffer, name, name_len, ptr, len);
1452
+ return;
1453
+ }
1454
+
1455
+ long line_start = 0;
1456
+ for (long i = 0; i < len; i++) {
1457
+ if (ptr[i] == '\n') {
1458
+ emit_header_line(buffer, name, name_len, ptr + line_start, i - line_start);
1459
+ line_start = i + 1;
1460
+ }
1461
+ }
1462
+ if (line_start < len) {
1463
+ emit_header_line(buffer, name, name_len, ptr + line_start, len - line_start);
1464
+ }
1465
+ }
1466
+
1467
+ static int format_header_iter(VALUE key, VALUE value, VALUE data) {
1468
+ VALUE buffer = (VALUE)data;
1469
+ if (!RB_TYPE_P(key, T_STRING)) return ST_CONTINUE;
1470
+ const char *name = RSTRING_PTR(key);
1471
+ long name_len = RSTRING_LEN(key);
1472
+ if (contains_illegal_byte(illegal_header_key_bitmap, name, (size_t)name_len)) return ST_CONTINUE;
1473
+
1474
+ if (RB_TYPE_P(value, T_ARRAY)) {
1475
+ long entries = RARRAY_LEN(value);
1476
+ for (long i = 0; i < entries; i++) {
1477
+ emit_header(buffer, name, name_len, RARRAY_AREF(value, i));
1478
+ }
1479
+ } else {
1480
+ emit_header(buffer, name, name_len, value);
1481
+ }
1482
+ return ST_CONTINUE;
1483
+ }
1484
+
1485
+ static VALUE parser_format_headers(VALUE self, VALUE buffer, VALUE headers) {
1486
+ (void)self;
1487
+ Check_Type(buffer, T_STRING);
1488
+ Check_Type(headers, T_HASH);
1489
+ rb_hash_foreach(headers, format_header_iter, buffer);
1490
+ return buffer;
1491
+ }
1492
+
1493
+ static VALUE parser_chunked_encode(VALUE self, VALUE buffer, VALUE chunk) {
1494
+ (void)self;
1495
+ Check_Type(buffer, T_STRING);
1496
+ Check_Type(chunk, T_STRING);
1497
+ long chunk_len = RSTRING_LEN(chunk);
1498
+ if (chunk_len == 0) return buffer;
1499
+
1500
+ char hex_buf[17];
1501
+ int hex_len = snprintf(hex_buf, sizeof(hex_buf), "%lx", (unsigned long)chunk_len);
1502
+ rb_str_cat(buffer, hex_buf, hex_len);
1503
+ rb_str_cat(buffer, "\r\n", 2);
1504
+ rb_str_cat(buffer, RSTRING_PTR(chunk), chunk_len);
1505
+ rb_str_cat(buffer, "\r\n", 2);
1506
+ return buffer;
1507
+ }
1508
+
1509
+ static void init_illegal_header_bitmaps(void) {
1510
+ for (int byte = 0x00; byte <= 0x20; byte++) bitmap_set(illegal_header_key_bitmap, (unsigned char)byte);
1511
+ const char *key_specials = "()<>@,;:\\\"/[]?={}";
1512
+ for (const char *p = key_specials; *p; p++) bitmap_set(illegal_header_key_bitmap, (unsigned char)*p);
1513
+ bitmap_set(illegal_header_key_bitmap, 0x7F);
1514
+
1515
+ for (int byte = 0x00; byte <= 0x08; byte++) bitmap_set(illegal_header_value_bitmap, (unsigned char)byte);
1516
+ for (int byte = 0x0A; byte <= 0x1F; byte++) bitmap_set(illegal_header_value_bitmap, (unsigned char)byte);
1517
+ }
1518
+
1319
1519
  RUBY_FUNC_EXPORTED void Init_raptor_http(void) {
1320
1520
  rb_ext_ractor_safe(true);
1321
1521
 
@@ -1337,6 +1537,13 @@ RUBY_FUNC_EXPORTED void Init_raptor_http(void) {
1337
1537
  global_request_path = rb_str_new2("PATH_INFO");
1338
1538
  global_fragment = rb_str_new2("FRAGMENT");
1339
1539
 
1540
+ id_complete = rb_intern("complete");
1541
+ id_incomplete = rb_intern("incomplete");
1542
+ id_malformed = rb_intern("malformed");
1543
+ id_too_large = rb_intern("too_large");
1544
+
1545
+ init_illegal_header_bitmaps();
1546
+
1340
1547
  for (size_t i = 0; i < NUM_COMMON_FIELDS; i++) {
1341
1548
  common_fields[i].interned = rb_enc_interned_str(common_fields[i].name, common_fields[i].len, rb_utf8_encoding());
1342
1549
  rb_global_variable(&common_fields[i].interned);
@@ -1351,4 +1558,10 @@ RUBY_FUNC_EXPORTED void Init_raptor_http(void) {
1351
1558
  rb_define_method(cHttpParser, "nread", parser_nread, 0);
1352
1559
  rb_define_method(cHttpParser, "reset", parser_reset, 0);
1353
1560
  rb_define_method(cHttpParser, "body", parser_body, 0);
1561
+ rb_define_singleton_method(cHttpParser, "decode_chunked", parser_decode_chunked, -1);
1562
+ rb_define_singleton_method(cHttpParser, "illegal_header_key?", parser_illegal_header_key_p, 1);
1563
+ rb_define_singleton_method(cHttpParser, "illegal_header_value?", parser_illegal_header_value_p, 1);
1564
+ rb_define_singleton_method(cHttpParser, "format_headers", parser_format_headers, 2);
1565
+ rb_define_singleton_method(cHttpParser, "chunked_encode", parser_chunked_encode, 2);
1566
+ rb_define_const(cHttpParser, "MAX_CHUNK_OVERHEAD", INT2FIX(MAX_CHUNK_OVERHEAD));
1354
1567
  }
@@ -599,6 +599,65 @@ static VALUE h2_encode_headers(VALUE self, VALUE headers) {
599
599
  return hpack_encode_header_block(headers);
600
600
  }
601
601
 
602
+ static const char *const HOP_BY_HOP_HEADERS[] = {
603
+ "connection", "transfer-encoding", "keep-alive", "upgrade", "proxy-connection"
604
+ };
605
+ #define NUM_HOP_BY_HOP_HEADERS 5
606
+
607
+ static int is_hop_by_hop(const char *name, long name_len) {
608
+ for (size_t i = 0; i < NUM_HOP_BY_HOP_HEADERS; i++) {
609
+ size_t hbh_len = strlen(HOP_BY_HOP_HEADERS[i]);
610
+ if ((long)hbh_len == name_len && memcmp(name, HOP_BY_HOP_HEADERS[i], hbh_len) == 0) return 1;
611
+ }
612
+ return 0;
613
+ }
614
+
615
+ static VALUE ascii_lowercase_str(VALUE str) {
616
+ long len = RSTRING_LEN(str);
617
+ VALUE lower = rb_str_new(RSTRING_PTR(str), len);
618
+ char *ptr = RSTRING_PTR(lower);
619
+ for (long i = 0; i < len; i++) {
620
+ if (ptr[i] >= 'A' && ptr[i] <= 'Z') ptr[i] = (char)(ptr[i] | 0x20);
621
+ }
622
+ return lower;
623
+ }
624
+
625
+ static int response_headers_iter(VALUE key, VALUE value, VALUE data) {
626
+ VALUE pairs = data;
627
+ if (!RB_TYPE_P(key, T_STRING)) return ST_CONTINUE;
628
+
629
+ VALUE lowered = ascii_lowercase_str(key);
630
+ const char *lname = RSTRING_PTR(lowered);
631
+ long lname_len = RSTRING_LEN(lowered);
632
+
633
+ if (lname_len >= 5 && memcmp(lname, "rack.", 5) == 0) return ST_CONTINUE;
634
+ if (is_hop_by_hop(lname, lname_len)) return ST_CONTINUE;
635
+
636
+ if (RB_TYPE_P(value, T_ARRAY)) {
637
+ long entries = RARRAY_LEN(value);
638
+ for (long i = 0; i < entries; i++) {
639
+ VALUE entry = RARRAY_AREF(value, i);
640
+ VALUE str = RB_TYPE_P(entry, T_STRING) ? entry : rb_obj_as_string(entry);
641
+ rb_ary_push(pairs, rb_ary_new_from_args(2, lowered, str));
642
+ }
643
+ } else {
644
+ VALUE str = RB_TYPE_P(value, T_STRING) ? value : rb_obj_as_string(value);
645
+ rb_ary_push(pairs, rb_ary_new_from_args(2, lowered, str));
646
+ }
647
+ return ST_CONTINUE;
648
+ }
649
+
650
+ static VALUE h2_encode_response_headers(VALUE self, VALUE status, VALUE headers) {
651
+ (void)self;
652
+ Check_Type(headers, T_HASH);
653
+
654
+ VALUE pairs = rb_ary_new();
655
+ rb_ary_push(pairs, rb_ary_new_from_args(2, rb_str_new_lit(":status"), rb_obj_as_string(status)));
656
+ rb_hash_foreach(headers, response_headers_iter, pairs);
657
+
658
+ return hpack_encode_header_block(pairs);
659
+ }
660
+
602
661
  static VALUE h2_parse_frame(VALUE self, VALUE buffer) {
603
662
  (void)self;
604
663
  Check_Type(buffer, T_STRING);
@@ -765,6 +824,7 @@ RUBY_FUNC_EXPORTED void Init_raptor_http2(void) {
765
824
  rb_define_method(cHttp2Parser, "parse_frame", h2_parse_frame, 1);
766
825
  rb_define_method(cHttp2Parser, "parse_headers", h2_parse_headers, 2);
767
826
  rb_define_method(cHttp2Parser, "encode_headers", h2_encode_headers, 1);
827
+ rb_define_method(cHttp2Parser, "encode_response_headers", h2_encode_response_headers, 2);
768
828
  rb_define_method(cHttp2Parser, "parse_settings", h2_parse_settings, 1);
769
829
  rb_define_method(cHttp2Parser, "build_settings", h2_build_settings, 1);
770
830
  rb_define_method(cHttp2Parser, "build_frame", h2_build_frame, 4);
data/lib/raptor/http1.rb CHANGED
@@ -21,7 +21,6 @@ module Raptor
21
21
  BODY_BUFFER_THRESHOLD = 256 * 1024
22
22
  CHUNKED_WRITE_THRESHOLD = 512 * 1024
23
23
  FILE_CHUNK_SIZE = 64 * 1024
24
- MAX_CHUNK_OVERHEAD = 16 * 1024
25
24
  READ_BUFFER_SIZE = 64 * 1024
26
25
  RESPONSE_BUFFER_CAPACITY = 4 * 1024
27
26
  KEEPALIVE_READ_TIMEOUT = 0.001
@@ -38,7 +37,6 @@ module Raptor
38
37
  h[status] = "HTTP/1.1 #{status}#{reason ? " #{reason}" : ""}\r\n".freeze
39
38
  end
40
39
 
41
- STATUS_WITH_NO_ENTITY_BODY = [204, 304, *100..199].freeze
42
40
  CONTINUE_RESPONSE = "HTTP/1.1 100 Continue\r\n\r\n"
43
41
  BAD_REQUEST_RESPONSE = "HTTP/1.1 400 Bad Request\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
44
42
  CONTENT_TOO_LARGE_RESPONSE = "HTTP/1.1 413 Content Too Large\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
@@ -48,6 +46,7 @@ module Raptor
48
46
  CONNECTION_KEEPALIVE = "keep-alive"
49
47
  EXPECT_100_CONTINUE = "100-continue"
50
48
  TRANSFER_ENCODING_CHUNKED = "chunked"
49
+ CHUNKED_TERMINATOR = "0\r\n\r\n"
51
50
 
52
51
  HTTP_CONNECTION = "HTTP_CONNECTION"
53
52
  HTTP_EXPECT = "HTTP_EXPECT"
@@ -56,10 +55,6 @@ module Raptor
56
55
  RACK_HIJACKED = "rack.hijacked"
57
56
  RACK_HIJACK_IO = "rack.hijack_io"
58
57
 
59
- ILLEGAL_HEADER_KEY_REGEX = /[\x00-\x20\(\)<>@,;:\\"\/\[\]\?=\{\}\x7F]/
60
- ILLEGAL_HEADER_VALUE_REGEX = /[\x00-\x08\x0A-\x1F]/
61
- CHUNK_SIZE_REGEX = /\A[0-9A-Fa-f]+\z/
62
-
63
58
  # Returns true when an HTTP/1.1 request lacks a valid `Host` header per
64
59
  # RFC 9112 section 3.2, where a valid value is a non-empty single-value
65
60
  # line.
@@ -102,59 +97,6 @@ module Raptor
102
97
  false
103
98
  end
104
99
 
105
- # Decodes a chunked transfer-encoded body buffer.
106
- #
107
- # Returns the decoded bytes and a state symbol: `:complete` when the
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.
112
- #
113
- # @param buffer [String] the raw body buffer to decode
114
- # @param max_size [Integer, nil] maximum decoded body size, or nil for unlimited
115
- # @return [Array(String, Symbol)] decoded body and completion state
116
- #
117
- # @rbs (String buffer, ?Integer? max_size) -> [String, Symbol]
118
- def self.decode_chunked(buffer, max_size = nil)
119
- decoded = String.new
120
- offset = 0
121
- overhead = 0
122
-
123
- while offset < buffer.bytesize
124
- crlf = buffer.index("\r\n", offset)
125
- return [decoded, :incomplete] unless crlf
126
-
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.zero?
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
-
145
- return [decoded, :too_large] if max_size && (decoded.bytesize + chunk_size) > max_size
146
-
147
- overhead += (crlf - offset) + 4
148
- return [decoded, :malformed] if overhead > (decoded.bytesize + chunk_size + MAX_CHUNK_OVERHEAD)
149
-
150
- offset = crlf + 2
151
- decoded << buffer.byteslice(offset, chunk_size)
152
- offset += chunk_size + 2
153
- end
154
-
155
- [decoded, :incomplete]
156
- end
157
-
158
100
  # Advances an HTTP/1.x request parse from the state hash's buffered
159
101
  # bytes. A complete well-formed request returns with `:complete` set
160
102
  # plus populated `:env` and `:body`; malformed or oversized input flips
@@ -191,7 +133,7 @@ module Raptor
191
133
  if max_body_size && parser.content_length > max_body_size
192
134
  data.merge(env: env, body: nil, parse_data: parse_data, complete: true, too_large: true)
193
135
  elsif parser.chunked?
194
- decoded_body, chunked_state = decode_chunked(body_buffer, max_body_size)
136
+ decoded_body, chunked_state = HttpParser.decode_chunked(body_buffer, max_body_size)
195
137
 
196
138
  case chunked_state
197
139
  when :complete
@@ -479,7 +421,7 @@ module Raptor
479
421
  if parser.chunked?
480
422
  return :incomplete unless decode_chunked
481
423
 
482
- body, chunked_state = Http1.decode_chunked(body, @max_body_size)
424
+ body, chunked_state = HttpParser.decode_chunked(body, @max_body_size)
483
425
  case chunked_state
484
426
  when :complete
485
427
  env.delete(HTTP_TRANSFER_ENCODING)
@@ -581,7 +523,7 @@ module Raptor
581
523
  hijacked = headers.is_a?(Hash) && !!headers[Rack::RACK_HIJACK]
582
524
  streaming = body.respond_to?(:call) && !body.respond_to?(:each)
583
525
  keep_alive = (hijacked || streaming) ? false : keep_alive?(rack_env, request_count)
584
- response_size = response_size(headers, body) unless hijacked
526
+ response_size = response_size(headers, body) if @access_log_io && !hijacked
585
527
  response_started = true
586
528
  write_response(socket, rack_env, status, headers, body, keep_alive: keep_alive)
587
529
  end
@@ -919,11 +861,11 @@ module Raptor
919
861
 
920
862
  response = +"#{HTTP_11} 103 Early Hints\r\n"
921
863
  hints.each do |key, value|
922
- next if illegal_header_key?(key)
864
+ next if HttpParser.illegal_header_key?(key)
923
865
 
924
866
  values = value.is_a?(Array) ? value : [value]
925
867
  values.each do |hint_value|
926
- next if illegal_header_value?(hint_value.to_s)
868
+ next if HttpParser.illegal_header_value?(hint_value.to_s)
927
869
 
928
870
  response << "#{key.downcase}: #{hint_value}\r\n"
929
871
  end
@@ -948,27 +890,29 @@ module Raptor
948
890
  validate_status(status)
949
891
  response_hijack = headers.is_a?(Hash) ? headers.delete(Rack::RACK_HIJACK) : nil
950
892
  headers = normalize_headers(headers)
951
- validate_headers(headers, status)
893
+ no_entity_body = (status >= 100 && status < 200) || status == 204 || status == 304
894
+ validate_headers(headers, status, no_entity_body)
952
895
 
953
896
  headers["connection"] = keep_alive ? CONNECTION_KEEPALIVE : CONNECTION_CLOSE
954
897
 
955
898
  http_version = env[Rack::SERVER_PROTOCOL] == HTTP_11 ? HTTP_11 : HTTP_10
956
- no_body = env[Rack::REQUEST_METHOD] == "HEAD" || STATUS_WITH_NO_ENTITY_BODY.include?(status)
899
+ no_body = env[Rack::REQUEST_METHOD] == "HEAD" || no_entity_body
957
900
 
958
901
  response = build_status_line(http_version, status)
959
902
 
960
- cork_socket(socket)
903
+ corked = !keep_alive
904
+ cork_socket(socket) if corked
961
905
 
962
906
  if response_hijack
963
907
  write_hijacked_response(socket, response, headers, response_hijack)
964
908
  elsif no_body
965
- write_no_body_response(socket, response, headers, status)
909
+ write_no_body_response(socket, response, headers, no_entity_body)
966
910
  else
967
911
  write_full_response(socket, response, headers, body, http_version)
968
912
  end
969
913
  ensure
970
914
  body.close if body.respond_to?(:close)
971
- uncork_socket(socket)
915
+ uncork_socket(socket) if corked
972
916
  socket.flush rescue nil
973
917
  end
974
918
 
@@ -1001,7 +945,7 @@ module Raptor
1001
945
  headers.each do |key, value|
1002
946
  raise TypeError, "header keys must be Strings" unless key.is_a?(String)
1003
947
 
1004
- next if illegal_header_key?(key)
948
+ next if HttpParser.illegal_header_key?(key)
1005
949
 
1006
950
  normalized_key = key.match?(/[A-Z]/) ? key.downcase : key
1007
951
  next if normalized_key.start_with?(RACK_HEADER_PREFIX)
@@ -1017,12 +961,13 @@ module Raptor
1017
961
  #
1018
962
  # @param headers [Hash] normalized response headers
1019
963
  # @param status [Integer] HTTP status code
964
+ # @param no_entity_body [Boolean] whether the status forbids an entity body
1020
965
  # @return [void]
1021
966
  # @raise [ArgumentError] if a forbidden header is present for the status
1022
967
  #
1023
- # @rbs (Hash[String, String | Array[String]] headers, Integer status) -> void
1024
- def validate_headers(headers, status)
1025
- if STATUS_WITH_NO_ENTITY_BODY.include?(status)
968
+ # @rbs (Hash[String, String | Array[String]] headers, Integer status, bool no_entity_body) -> void
969
+ def validate_headers(headers, status, no_entity_body)
970
+ if no_entity_body
1026
971
  raise ArgumentError, "content-type must not be present for status #{status}" if headers.key?(Rack::CONTENT_TYPE)
1027
972
 
1028
973
  raise ArgumentError, "content-length must not be present for status #{status}" if headers.key?(Rack::CONTENT_LENGTH)
@@ -1055,7 +1000,7 @@ module Raptor
1055
1000
  #
1056
1001
  # @rbs (TCPSocket socket, String response, Hash[String, String | Array[String]] headers, ^(TCPSocket) -> void response_hijack) -> void
1057
1002
  def write_hijacked_response(socket, response, headers, response_hijack)
1058
- format_headers(response, headers)
1003
+ HttpParser.format_headers(response, headers)
1059
1004
  response << "\r\n"
1060
1005
  socket_write(socket, response)
1061
1006
  uncork_socket(socket)
@@ -1069,16 +1014,16 @@ module Raptor
1069
1014
  # @param socket [TCPSocket] the client socket
1070
1015
  # @param response [String] the status line accumulated so far
1071
1016
  # @param headers [Hash] normalized response headers
1072
- # @param status [Integer] HTTP status code
1017
+ # @param no_entity_body [Boolean] whether the response status forbids an entity body
1073
1018
  # @return [void]
1074
1019
  #
1075
- # @rbs (TCPSocket socket, String response, Hash[String, String | Array[String]] headers, Integer status) -> void
1076
- def write_no_body_response(socket, response, headers, status)
1077
- unless STATUS_WITH_NO_ENTITY_BODY.include?(status)
1020
+ # @rbs (TCPSocket socket, String response, Hash[String, String | Array[String]] headers, bool no_entity_body) -> void
1021
+ def write_no_body_response(socket, response, headers, no_entity_body)
1022
+ unless no_entity_body
1078
1023
  headers[Rack::CONTENT_LENGTH] = "0" unless headers.key?(Rack::CONTENT_LENGTH) || headers.key?(Rack::TRANSFER_ENCODING)
1079
1024
  end
1080
1025
 
1081
- format_headers(response, headers)
1026
+ HttpParser.format_headers(response, headers)
1082
1027
  response << "\r\n"
1083
1028
  socket_write(socket, response)
1084
1029
  end
@@ -1097,7 +1042,7 @@ module Raptor
1097
1042
  # @rbs (TCPSocket socket, String response, Hash[String, String | Array[String]] headers, untyped body, String http_version) -> void
1098
1043
  def write_full_response(socket, response, headers, body, http_version)
1099
1044
  if body.respond_to?(:call)
1100
- format_headers(response, headers)
1045
+ HttpParser.format_headers(response, headers)
1101
1046
  response << "\r\n"
1102
1047
  socket_write(socket, response)
1103
1048
  uncork_socket(socket)
@@ -1123,7 +1068,7 @@ module Raptor
1123
1068
  headers[Rack::TRANSFER_ENCODING] = TRANSFER_ENCODING_CHUNKED
1124
1069
  end
1125
1070
 
1126
- format_headers(response, headers)
1071
+ HttpParser.format_headers(response, headers)
1127
1072
  response << "\r\n"
1128
1073
 
1129
1074
  if body.respond_to?(:to_path) && (path = body.to_path) && File.readable?(path)
@@ -1172,13 +1117,13 @@ module Raptor
1172
1117
  if use_chunked
1173
1118
  buffer = response
1174
1119
  while (chunk = file.read(FILE_CHUNK_SIZE))
1175
- buffer << chunk.bytesize.to_s(16) << "\r\n" << chunk << "\r\n"
1120
+ HttpParser.chunked_encode(buffer, chunk)
1176
1121
  if buffer.bytesize >= CHUNKED_WRITE_THRESHOLD
1177
1122
  socket_write(socket, buffer)
1178
1123
  buffer = +""
1179
1124
  end
1180
1125
  end
1181
- buffer << "0\r\n\r\n"
1126
+ buffer << CHUNKED_TERMINATOR
1182
1127
  socket_write(socket, buffer)
1183
1128
  elsif content_length && content_length < BODY_BUFFER_THRESHOLD
1184
1129
  response << file.read(content_length)
@@ -1221,7 +1166,8 @@ module Raptor
1221
1166
  raise TypeError, "body must yield String values" unless chunk.is_a?(String)
1222
1167
 
1223
1168
  if use_chunked
1224
- response << chunk.bytesize.to_s(16) << "\r\n" << chunk << "\r\n0\r\n\r\n"
1169
+ HttpParser.chunked_encode(response, chunk)
1170
+ response << CHUNKED_TERMINATOR
1225
1171
  socket_write(socket, response)
1226
1172
  else
1227
1173
  socket_writev(socket, [response, chunk])
@@ -1244,15 +1190,13 @@ module Raptor
1244
1190
  body_array.each do |chunk|
1245
1191
  raise TypeError, "body must yield String values" unless chunk.is_a?(String)
1246
1192
 
1247
- next if chunk.empty?
1248
-
1249
- buffer << chunk.bytesize.to_s(16) << "\r\n" << chunk << "\r\n"
1193
+ HttpParser.chunked_encode(buffer, chunk)
1250
1194
  if buffer.bytesize >= CHUNKED_WRITE_THRESHOLD
1251
1195
  socket_write(socket, buffer)
1252
1196
  buffer = +""
1253
1197
  end
1254
1198
  end
1255
- buffer << "0\r\n\r\n"
1199
+ buffer << CHUNKED_TERMINATOR
1256
1200
  socket_write(socket, buffer)
1257
1201
  else
1258
1202
  body_array.each do |chunk|
@@ -1275,14 +1219,15 @@ module Raptor
1275
1219
  def write_enumerable_body(socket, response, body, use_chunked)
1276
1220
  if use_chunked
1277
1221
  socket_write(socket, response)
1222
+ buffer = +""
1278
1223
  body.each do |chunk|
1279
1224
  raise TypeError, "body must yield String values" unless chunk.is_a?(String)
1280
1225
 
1281
- next if chunk.empty?
1282
-
1283
- socket_write(socket, "#{chunk.bytesize.to_s(16)}\r\n#{chunk}\r\n")
1226
+ buffer.clear
1227
+ HttpParser.chunked_encode(buffer, chunk)
1228
+ socket_write(socket, buffer) unless buffer.empty?
1284
1229
  end
1285
- socket_write(socket, "0\r\n\r\n")
1230
+ socket_write(socket, CHUNKED_TERMINATOR)
1286
1231
  else
1287
1232
  body.each do |chunk|
1288
1233
  raise TypeError, "body must yield String values" unless chunk.is_a?(String)
@@ -1293,72 +1238,6 @@ module Raptor
1293
1238
  end
1294
1239
  end
1295
1240
 
1296
- # Returns true if the header key contains characters illegal in HTTP headers.
1297
- #
1298
- # @param key [String] the header key to check
1299
- # @return [Boolean] true if the key is illegal
1300
- #
1301
- # @rbs (String key) -> bool
1302
- def illegal_header_key?(key)
1303
- key.match?(ILLEGAL_HEADER_KEY_REGEX)
1304
- end
1305
-
1306
- # Returns true if the header value contains characters illegal in HTTP headers.
1307
- #
1308
- # @param value [String] the header value to check
1309
- # @return [Boolean] true if the value is illegal
1310
- #
1311
- # @rbs (String value) -> bool
1312
- def illegal_header_value?(value)
1313
- value.match?(ILLEGAL_HEADER_VALUE_REGEX)
1314
- end
1315
-
1316
- # Appends normalised header lines to `result`. Skips entries with
1317
- # illegal keys or values. Array values are written as separate lines.
1318
- #
1319
- # @param headers [Hash] normalized response headers
1320
- # @return [String] formatted header lines, each ending with CRLF
1321
- #
1322
- # @rbs (String result, Hash[String, String | Array[String]] headers) -> void
1323
- def format_headers(result, headers)
1324
- headers.each do |name, value|
1325
- next if illegal_header_key?(name)
1326
-
1327
- if value.is_a?(Array)
1328
- value.each { |entry| append_header_value(result, name, entry) }
1329
- else
1330
- append_header_value(result, name, value)
1331
- end
1332
- end
1333
- end
1334
-
1335
- # Appends one or more `name: value` header lines to `result`, splitting
1336
- # newline-joined values across separate lines and skipping empty or
1337
- # illegal values.
1338
- #
1339
- # @param result [String] the buffer to append to
1340
- # @param name [String] the header name
1341
- # @param value [Object] the header value (any object responding to `to_s`)
1342
- # @return [void]
1343
- #
1344
- # @rbs (String result, String name, untyped value) -> void
1345
- def append_header_value(result, name, value)
1346
- string_value = value.is_a?(String) ? value : value.to_s
1347
- return if string_value.empty?
1348
-
1349
- if string_value.include?("\n")
1350
- string_value.split("\n").each do |line|
1351
- next if line.empty? || illegal_header_value?(line)
1352
-
1353
- result << name << ": " << line << "\r\n"
1354
- end
1355
- else
1356
- return if illegal_header_value?(string_value)
1357
-
1358
- result << name << ": " << string_value << "\r\n"
1359
- end
1360
- end
1361
-
1362
1241
  # Calls every `rack.response_finished` callback in reverse
1363
1242
  # registration order, rescuing any that raise.
1364
1243
  #
data/lib/raptor/http2.rb CHANGED
@@ -229,8 +229,6 @@ module Raptor
229
229
  MAX_FRAME_SIZE = 16_384
230
230
 
231
231
  SERVER_PROTOCOL = "HTTP/2"
232
- RACK_HEADER_PREFIX = "rack."
233
- HOP_BY_HOP_HEADERS = ["connection", "transfer-encoding", "keep-alive", "upgrade", "proxy-connection"].freeze
234
232
 
235
233
  REQUEST_PSEUDO_HEADERS = [":method", ":scheme", ":path", ":authority"].freeze
236
234
  REQUIRED_REQUEST_PSEUDO_HEADERS = [":method", ":scheme", ":path"].freeze
@@ -766,20 +764,7 @@ module Raptor
766
764
  def write_http2_response(socket, writer, flow_control, stream_id, status, headers, body)
767
765
  parser = Http2Parser.new
768
766
 
769
- header_pairs = [[":status", status.to_s]]
770
- headers.each do |name, value|
771
- lowered = name.downcase
772
- next if lowered.start_with?(RACK_HEADER_PREFIX)
773
- next if HOP_BY_HOP_HEADERS.include?(lowered)
774
-
775
- if value.is_a?(Array)
776
- value.each { |entry| header_pairs << [lowered, entry.to_s] }
777
- else
778
- header_pairs << [lowered, value.to_s]
779
- end
780
- end
781
-
782
- encoded_headers = parser.encode_headers(header_pairs)
767
+ encoded_headers = parser.encode_response_headers(status, headers)
783
768
  body_chunks = []
784
769
  body_bytes = 0
785
770
  body.each do |chunk|
@@ -10,8 +10,8 @@ rescue LoadError
10
10
  end
11
11
 
12
12
  module Raptor
13
- # Routes incoming connections across worker processes to the worker with
14
- # the lowest reactor backlog.
13
+ # Routes incoming connections across worker processes using two-choice
14
+ # load sampling, atomically reserving the selected slot before routing.
15
15
  #
16
16
  # Auto-enabled on Linux when the `libbpf-ruby` gem is installed and the
17
17
  # BPF object file has been compiled. Falls back silently to standard
@@ -47,7 +47,7 @@ module Raptor
47
47
  return false unless supported?
48
48
 
49
49
  @object = LibBPFRuby::Object.new(BPF_OBJECT_PATH)
50
- @program_fd = @object.program_fd("select_least_loaded")
50
+ @program_fd = @object.program_fd("select_less_loaded")
51
51
  @socks_fd = @object.map_fd("socks")
52
52
  @loads_fd = @object.map_fd("loads")
53
53
 
@@ -99,6 +99,7 @@ module Raptor
99
99
  def self.enable_load_reporting(worker_index)
100
100
  @load_key = [worker_index + 1].pack("L")
101
101
  @load_value = String.new("\x00\x00\x00\x00", encoding: Encoding::ASCII_8BIT)
102
+ @accepted_load_value = String.new("\x00\x00\x00\x00", encoding: Encoding::ASCII_8BIT)
102
103
  end
103
104
 
104
105
  # Publishes this worker's current backlog to the BPF map.
@@ -115,6 +116,22 @@ module Raptor
115
116
  LibBPFRuby.map_update(@loads_fd, @load_key, @load_value)
116
117
  end
117
118
 
119
+ # Publishes an accept-side backlog estimate. This uses a separate value
120
+ # buffer because the server and load reporter call into the map from
121
+ # different threads.
122
+ #
123
+ # @param backlog [Integer] the estimated backlog after accepting a socket
124
+ # @return [void]
125
+ #
126
+ # @rbs (Integer backlog) -> void
127
+ def self.update_accepted_load(backlog)
128
+ @accepted_load_value.setbyte(0, backlog & 0xff)
129
+ @accepted_load_value.setbyte(1, (backlog >> 8) & 0xff)
130
+ @accepted_load_value.setbyte(2, (backlog >> 16) & 0xff)
131
+ @accepted_load_value.setbyte(3, (backlog >> 24) & 0xff)
132
+ LibBPFRuby.map_update(@loads_fd, @load_key, @accepted_load_value)
133
+ end
134
+
118
135
  # Marks a worker's slot as unavailable by parking its load at the
119
136
  # sentinel maximum so the BPF program's least-loaded pick skips it.
120
137
  #
data/lib/raptor/server.rb CHANGED
@@ -152,8 +152,9 @@ module Raptor
152
152
  end
153
153
  end
154
154
 
155
- # Accepts a connection from the given listener and dispatches it into
156
- # the HTTP/1.1 or SSL/HTTP-2 pipeline.
155
+ # Accepts a connection from the given listener, immediately publishes
156
+ # the pending work to the BPF map, and dispatches the socket into the
157
+ # HTTP/1.1 or SSL/HTTP-2 pipeline.
157
158
  #
158
159
  # @param listener [TCPServer, UNIXServer, Binder::SslListener] the ready listener
159
160
  # @return [Boolean] true if a connection was accepted, false if the listener had nothing to dispatch
@@ -172,6 +173,8 @@ module Raptor
172
173
  remote_addr = DEFAULT_REMOTE_ADDR
173
174
  end
174
175
 
176
+ ReuseportBPF.update_accepted_load(@reactor.backlog + 1) if @bpf_active
177
+
175
178
  if listener.is_a?(Binder::SslListener)
176
179
  @thread_pool << proc do
177
180
  dispatch_ssl_connection(listener, tcp_client, remote_addr)
@@ -2,5 +2,5 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  module Raptor
5
- VERSION = "0.16.1"
5
+ VERSION = "0.17.0"
6
6
  end
@@ -12,8 +12,6 @@ module Raptor
12
12
 
13
13
  FILE_CHUNK_SIZE: untyped
14
14
 
15
- MAX_CHUNK_OVERHEAD: untyped
16
-
17
15
  READ_BUFFER_SIZE: untyped
18
16
 
19
17
  RESPONSE_BUFFER_CAPACITY: untyped
@@ -30,8 +28,6 @@ module Raptor
30
28
 
31
29
  STATUS_LINE_CACHE_11: untyped
32
30
 
33
- STATUS_WITH_NO_ENTITY_BODY: untyped
34
-
35
31
  CONTINUE_RESPONSE: ::String
36
32
 
37
33
  BAD_REQUEST_RESPONSE: ::String
@@ -48,6 +44,8 @@ module Raptor
48
44
 
49
45
  TRANSFER_ENCODING_CHUNKED: ::String
50
46
 
47
+ CHUNKED_TERMINATOR: ::String
48
+
51
49
  HTTP_CONNECTION: ::String
52
50
 
53
51
  HTTP_EXPECT: ::String
@@ -60,12 +58,6 @@ module Raptor
60
58
 
61
59
  RACK_HIJACK_IO: ::String
62
60
 
63
- ILLEGAL_HEADER_KEY_REGEX: ::Regexp
64
-
65
- ILLEGAL_HEADER_VALUE_REGEX: ::Regexp
66
-
67
- CHUNK_SIZE_REGEX: ::Regexp
68
-
69
61
  # Returns true when an HTTP/1.1 request lacks a valid `Host` header per
70
62
  # RFC 9112 section 3.2, where a valid value is a non-empty single-value
71
63
  # line.
@@ -88,21 +80,6 @@ module Raptor
88
80
  # @rbs (Hash[String, untyped] env) -> bool
89
81
  def self.request_smuggling?: (Hash[String, untyped] env) -> bool
90
82
 
91
- # Decodes a chunked transfer-encoded body buffer.
92
- #
93
- # Returns the decoded bytes and a state symbol: `:complete` when the
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.
98
- #
99
- # @param buffer [String] the raw body buffer to decode
100
- # @param max_size [Integer, nil] maximum decoded body size, or nil for unlimited
101
- # @return [Array(String, Symbol)] decoded body and completion state
102
- #
103
- # @rbs (String buffer, ?Integer? max_size) -> [String, Symbol]
104
- def self.decode_chunked: (String buffer, ?Integer? max_size) -> [ String, Symbol ]
105
-
106
83
  # Advances an HTTP/1.x request parse from the state hash's buffered
107
84
  # bytes. A complete well-formed request returns with `:complete` set
108
85
  # plus populated `:env` and `:body`; malformed or oversized input flips
@@ -117,27 +94,27 @@ module Raptor
117
94
  # @rbs (Hash[Symbol, untyped] data, Hash[String, untyped] env_template, Integer? max_body_size) -> Hash[Symbol, untyped]
118
95
  def self.parse: (Hash[Symbol, untyped] data, Hash[String, untyped] env_template, Integer? max_body_size) -> Hash[Symbol, untyped]
119
96
 
120
- @env_template: Hash[String, untyped]
97
+ @app: ^(Hash[String, untyped]) -> [ Integer, Hash[String, String | Array[String]], untyped ]
121
98
 
122
- @running: AtomicBoolean
99
+ @server_port: Integer
123
100
 
124
- @on_error: ^(Hash[String, untyped]?, Exception) -> void | nil
101
+ @server_port_string: String
125
102
 
126
- @access_log_io: IO?
103
+ @write_timeout: Integer
127
104
 
128
- @max_keepalive_requests: Integer
105
+ @max_body_size: Integer?
129
106
 
130
107
  @body_spool_threshold: Integer?
131
108
 
132
- @max_body_size: Integer?
109
+ @max_keepalive_requests: Integer
133
110
 
134
- @write_timeout: Integer
111
+ @access_log_io: IO?
135
112
 
136
- @server_port_string: String
113
+ @on_error: ^(Hash[String, untyped]?, Exception) -> void | nil
137
114
 
138
- @server_port: Integer
115
+ @running: AtomicBoolean
139
116
 
140
- @app: ^(Hash[String, untyped]) -> [ Integer, Hash[String, String | Array[String]], untyped ]
117
+ @env_template: Hash[String, untyped]
141
118
 
142
119
  # Creates a new Http1 handler.
143
120
  #
@@ -470,11 +447,12 @@ module Raptor
470
447
  #
471
448
  # @param headers [Hash] normalized response headers
472
449
  # @param status [Integer] HTTP status code
450
+ # @param no_entity_body [Boolean] whether the status forbids an entity body
473
451
  # @return [void]
474
452
  # @raise [ArgumentError] if a forbidden header is present for the status
475
453
  #
476
- # @rbs (Hash[String, String | Array[String]] headers, Integer status) -> void
477
- def validate_headers: (Hash[String, String | Array[String]] headers, Integer status) -> void
454
+ # @rbs (Hash[String, String | Array[String]] headers, Integer status, bool no_entity_body) -> void
455
+ def validate_headers: (Hash[String, String | Array[String]] headers, Integer status, bool no_entity_body) -> void
478
456
 
479
457
  # Returns the HTTP status line for `status`.
480
458
  #
@@ -504,11 +482,11 @@ module Raptor
504
482
  # @param socket [TCPSocket] the client socket
505
483
  # @param response [String] the status line accumulated so far
506
484
  # @param headers [Hash] normalized response headers
507
- # @param status [Integer] HTTP status code
485
+ # @param no_entity_body [Boolean] whether the response status forbids an entity body
508
486
  # @return [void]
509
487
  #
510
- # @rbs (TCPSocket socket, String response, Hash[String, String | Array[String]] headers, Integer status) -> void
511
- def write_no_body_response: (TCPSocket socket, String response, Hash[String, String | Array[String]] headers, Integer status) -> void
488
+ # @rbs (TCPSocket socket, String response, Hash[String, String | Array[String]] headers, bool no_entity_body) -> void
489
+ def write_no_body_response: (TCPSocket socket, String response, Hash[String, String | Array[String]] headers, bool no_entity_body) -> void
512
490
 
513
491
  # Writes a complete response with a body. Emits a `Content-Length`
514
492
  # when the total size is known upfront, otherwise chunked encoding on
@@ -592,43 +570,6 @@ module Raptor
592
570
  # @rbs (TCPSocket socket, String response, untyped body, bool use_chunked) -> void
593
571
  def write_enumerable_body: (TCPSocket socket, String response, untyped body, bool use_chunked) -> void
594
572
 
595
- # Returns true if the header key contains characters illegal in HTTP headers.
596
- #
597
- # @param key [String] the header key to check
598
- # @return [Boolean] true if the key is illegal
599
- #
600
- # @rbs (String key) -> bool
601
- def illegal_header_key?: (String key) -> bool
602
-
603
- # Returns true if the header value contains characters illegal in HTTP headers.
604
- #
605
- # @param value [String] the header value to check
606
- # @return [Boolean] true if the value is illegal
607
- #
608
- # @rbs (String value) -> bool
609
- def illegal_header_value?: (String value) -> bool
610
-
611
- # Appends normalised header lines to `result`. Skips entries with
612
- # illegal keys or values. Array values are written as separate lines.
613
- #
614
- # @param headers [Hash] normalized response headers
615
- # @return [String] formatted header lines, each ending with CRLF
616
- #
617
- # @rbs (String result, Hash[String, String | Array[String]] headers) -> void
618
- def format_headers: (String result, Hash[String, String | Array[String]] headers) -> void
619
-
620
- # Appends one or more `name: value` header lines to `result`, splitting
621
- # newline-joined values across separate lines and skipping empty or
622
- # illegal values.
623
- #
624
- # @param result [String] the buffer to append to
625
- # @param name [String] the header name
626
- # @param value [Object] the header value (any object responding to `to_s`)
627
- # @return [void]
628
- #
629
- # @rbs (String result, String name, untyped value) -> void
630
- def append_header_value: (String result, String name, untyped value) -> void
631
-
632
573
  # Calls every `rack.response_finished` callback in reverse
633
574
  # registration order, rescuing any that raise.
634
575
  #
@@ -132,10 +132,6 @@ module Raptor
132
132
 
133
133
  SERVER_PROTOCOL: ::String
134
134
 
135
- RACK_HEADER_PREFIX: ::String
136
-
137
- HOP_BY_HOP_HEADERS: untyped
138
-
139
135
  REQUEST_PSEUDO_HEADERS: untyped
140
136
 
141
137
  REQUIRED_REQUEST_PSEUDO_HEADERS: untyped
@@ -1,8 +1,8 @@
1
1
  # Generated from lib/raptor/reuseport_bpf.rb with RBS::Inline
2
2
 
3
3
  module Raptor
4
- # Routes incoming connections across worker processes to the worker with
5
- # the lowest reactor backlog.
4
+ # Routes incoming connections across worker processes using two-choice
5
+ # load sampling, atomically reserving the selected slot before routing.
6
6
  #
7
7
  # Auto-enabled on Linux when the `libbpf-ruby` gem is installed and the
8
8
  # BPF object file has been compiled. Falls back silently to standard
@@ -62,6 +62,16 @@ module Raptor
62
62
  # @rbs (Integer backlog) -> void
63
63
  def self.update_load: (Integer backlog) -> void
64
64
 
65
+ # Publishes an accept-side backlog estimate. This uses a separate value
66
+ # buffer because the server and load reporter call into the map from
67
+ # different threads.
68
+ #
69
+ # @param backlog [Integer] the estimated backlog after accepting a socket
70
+ # @return [void]
71
+ #
72
+ # @rbs (Integer backlog) -> void
73
+ def self.update_accepted_load: (Integer backlog) -> void
74
+
65
75
  # Marks a worker's slot as unavailable by parking its load at the
66
76
  # sentinel maximum so the BPF program's least-loaded pick skips it.
67
77
  #
@@ -98,8 +98,9 @@ module Raptor
98
98
  # @rbs () -> void
99
99
  def drain_accept_queue: () -> void
100
100
 
101
- # Accepts a connection from the given listener and dispatches it into
102
- # the HTTP/1.1 or SSL/HTTP-2 pipeline.
101
+ # Accepts a connection from the given listener, immediately publishes
102
+ # the pending work to the BPF map, and dispatches the socket into the
103
+ # HTTP/1.1 or SSL/HTTP-2 pipeline.
103
104
  #
104
105
  # @param listener [TCPServer, UNIXServer, Binder::SslListener] the ready listener
105
106
  # @return [Boolean] true if a connection was accepted, false if the listener had nothing to dispatch
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.16.1
4
+ version: 0.17.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Joshua Young