hyperion-rb 2.16.4 → 2.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: 1052090a1cfa42b3ba8807ff239b88f1b5700b0efd2df58a84166b0f754d6496
4
- data.tar.gz: '028fa894acb855151cd2901aa1964fb2fd4be68908060401ba64a80d43987bd1'
3
+ metadata.gz: 3cd6a998da3319e4fa653a2802c6d2114946cfca36dba3c6806f48ada9467e78
4
+ data.tar.gz: 317fb337740577fddd5c1203d2b41f8f8409d8e3998944c13595ea5db06bd1da
5
5
  SHA512:
6
- metadata.gz: b080a73c39cbaa284594bda8b2a0dab21f2e737d8d4fd0b6316026bf9fb72b913f3d001117f4291166a6436fd1af0dae11766c47141aa54ca84c759d718c516d
7
- data.tar.gz: 24fd2ff23e4ace9e29e9b43ef5c1becaaa1d732e4cff0ac6cafd1fa3fce317029afcf4cc26d3ee5cdad19ccfe7b7252f6da8b3d3a731824a1a10d74f043e4107
6
+ metadata.gz: ff99796e54e33fcbae1c8e61e30bbde780a8531db28601f86375a2babfe4c7c7edf546a1b7512523524e57ec49ec88487a7c350e6a1c890772474e2dd9b11e70
7
+ data.tar.gz: '08c49dbb4fe093a9ed4901e841f5504e374faef1fb4b3ff522f698e26afadbea58200f0546a273355113c6d62e3b4353660248f2fc8300d1959547d48460c715'
data/CHANGELOG.md CHANGED
@@ -1,5 +1,85 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.17.0 — 2026-05-08
4
+
5
+ ### Hot-path bucket: parser value intern + RFC-compliant handle_static prebuilt
6
+
7
+ Two hot-path optimisations land together. Both are correctness-positive
8
+ (no behavioural change for compliant clients; the second one moves
9
+ `handle_static` from non-RFC-compliant headers to RFC-compliant ones).
10
+
11
+ #### Parser header-value intern table
12
+
13
+ `Hyperion::CParser` (`ext/hyperion_http/parser.c`) gains a 64-slot,
14
+ 33-seed open-addressed FNV-1a intern table for the highest-frequency
15
+ HTTP/1.1 header *values* (Connection: keep-alive, Accept-Encoding combos,
16
+ common User-Agent strings, localhost / 127.0.0.1, etc.). On every
17
+ `stash_pending_header` call, the materialised value String is hashed and
18
+ probed against the table; on hit, the freshly-allocated `rb_str_new`
19
+ String becomes garbage and the env Hash holds the pre-frozen interned
20
+ VALUE. Net effect: lower GC retention under burst, faster downstream
21
+ identity-based string compares, reduced promote-to-old-gen pressure on
22
+ long-running workers.
23
+
24
+ - `bench/hello_handle_block.ru` (Row 3): **8727 → 9008 rps median** in
25
+ isolation (+3.2%), within noise but trending positive.
26
+ - Static path (Row 1) is parser-bypass — intern doesn't apply.
27
+
28
+ #### `Server.handle_static` — prebuilt wire bytes with Date splice
29
+
30
+ `handle_static` previously emitted lowercase headers and **no Date
31
+ header**, in violation of RFC 7231 §7.1.1.2 ("An origin server with a
32
+ clock MUST generate a Date header field in all 2xx, 3xx, and 4xx
33
+ responses"). 2.17.0 fixes that:
34
+
35
+ - The full HTTP/1.1 wire response is built ONCE at registration
36
+ (status line + `Server: Hyperion` + `Content-Type` + `Content-Length`
37
+ + `Connection: keep-alive` + 29-byte `Date:` placeholder + body),
38
+ frozen, and stored on the `StaticEntry` struct alongside the legacy
39
+ `buffer` field (preserved for back-compat).
40
+ - A new per-second-cached imf-fixdate cache lives in
41
+ `ext/hyperion_http/page_cache.c` (hand-rolled formatter — `strftime`
42
+ is locale-dependent and RFC 7231 mandates ASCII English month/day
43
+ names). Every snapshot site memcpy's the frozen response into a
44
+ per-write scratch buffer and splices the 29-byte cached date into
45
+ the placeholder slot AFTER releasing the page lock. The frozen
46
+ Ruby String is never mutated.
47
+ - `PageCache.register_prebuilt` grows from arity 3 to arity -1 (3 or 4
48
+ args; the optional 4th is `date_offset`). Pre-2.17 callers (3 args
49
+ or 4-with-zero) fall through to the unchanged un-spliced fast path.
50
+ - `Connection#serve_static_entry`'s Ruby fallback (used when the C ext
51
+ is absent — JRuby / TruffleRuby / build failure) mirrors the C path:
52
+ `prebuilt_keepalive_bytes.dup` + `[]= Time.now.httpdate`.
53
+
54
+ Bench (3-trial median on `openclaw-vm`, Linux 6.8 / Ruby 3.3.3):
55
+
56
+ | Row | Workload | 2.16.4 | 2.17.0 | Δ |
57
+ |----:|-----------------------------------------|-------:|-------:|--:|
58
+ | 1 | `handle_static` + io_uring (peak) | 133211 | 127377 | -4.4% (Δ ≈ 80 extra RFC-mandated bytes per response; trial spread 122k–146k = ~19%) |
59
+ | 3 | `Server.handle` block (dynamic) | 8727 | 9458 | **+8.4%** |
60
+
61
+ The Row 1 delta is the cost of writing the new mandatory Date / Server /
62
+ Connection bytes. Trade is throughput-for-compliance — Hyperion's pre-2.17
63
+ `handle_static` wasn't a valid HTTP/1.1 origin server.
64
+
65
+ Captured artefacts:
66
+ - `docs/BENCH_HOTPATH_2026_05_08_before.csv`
67
+ - `docs/BENCH_HOTPATH_2026_05_08_after.csv`
68
+
69
+ #### Tests
70
+
71
+ - New: `spec/hyperion/parser_value_intern_spec.rb` (4 examples — intern
72
+ hits, miss fallback, encoding preservation, wrk User-Agent identity).
73
+ - New: `spec/hyperion/handle_static_prebuilt_spec.rb` (7 examples —
74
+ wire shape, Date offset, custom content-type, back-compat with
75
+ legacy `buffer`, end-to-end real-socket request).
76
+ - Modified: `spec/hyperion/direct_route_spec.rb` (two wire-output
77
+ assertions updated for the new capital-cased headers + Date).
78
+
79
+ `bin/check` clean (mode=quick, 81 examples, 0 failures); full suite
80
+ 1266 examples, 0 failures, 16 pre-existing Linux-only / liburing
81
+ pending on macOS.
82
+
3
83
  ## 2.16.4 — 2026-05-07
4
84
 
5
85
  ### io_uring hotpath accept fiber: row-19 BOOT-FAIL fix
@@ -160,6 +160,15 @@ typedef struct hyp_page_s {
160
160
  * (no on-disk file backing — never re-stat,
161
161
  * never invalidate on missing file). */
162
162
  char *content_type; /* heap-owned, picked at insert time */
163
+ /* 2.17-A (Hot Path Task 2) — byte offset within `response_buf`
164
+ * of the first byte of a 29-byte HTTP `Date:` placeholder ('X' run).
165
+ * Zero means "no placeholder; do not splice". When non-zero, every
166
+ * snapshot site overwrites the 29 bytes at this offset in the
167
+ * stack/heap snapshot copy with the per-second-cached imf-fixdate
168
+ * string before the kernel write fires. The `response_buf` itself
169
+ * is never mutated (it is shared across concurrent reads under the
170
+ * page-cache rwlock). */
171
+ size_t date_offset;
163
172
  } hyp_page_t;
164
173
 
165
174
  typedef struct hyp_page_slot_s {
@@ -254,6 +263,117 @@ static double hyp_pc_now(void) {
254
263
  return (double)tv.tv_sec + (double)tv.tv_usec / 1.0e6;
255
264
  }
256
265
 
266
+ /* ============================================================
267
+ * 2.17-A (Hot Path Task 2) — per-second-cached HTTP `Date:` header.
268
+ *
269
+ * The wire format is RFC 7231 imf-fixdate:
270
+ * "Sun, 06 Nov 1994 08:49:37 GMT"
271
+ * Always exactly 29 bytes, ASCII, no trailing NUL written into the
272
+ * splice slot — the slot lives in the middle of a frozen response
273
+ * buffer and the surrounding bytes (CRLF + remaining headers + body)
274
+ * are already correct.
275
+ *
276
+ * Refresh strategy: lazy, tied to wall-clock seconds. Every call to
277
+ * `hyp_pc_cached_date` checks whether the cached `time_t` matches
278
+ * `time(NULL)`; on miss, the cache is rebuilt from `gmtime_r` under a
279
+ * dedicated mutex (NOT `hyp_pc_lock` — splice happens AFTER the page
280
+ * lock has been released, so we mustn't reacquire the page lock for
281
+ * the date refresh). Concurrent rebuilds race to write the same 29
282
+ * bytes; the result is bit-identical so the race is benign — readers
283
+ * either see the previous second's value (already-stale-by-one-second
284
+ * is RFC-acceptable) or the new one.
285
+ *
286
+ * The cached buffer is sized 32 to leave room for an aligned read on
287
+ * older toolchains that might over-read past `[28]`; we hand out only
288
+ * the first 29 bytes via `hyp_pc_cached_date_copy`. */
289
+ #define HYP_PC_DATE_LEN 29
290
+
291
+ static char hyp_pc_cached_date[32];
292
+ static time_t hyp_pc_cached_date_sec = 0;
293
+ static pthread_mutex_t hyp_pc_date_lock = PTHREAD_MUTEX_INITIALIZER;
294
+
295
+ /* Day-of-week and month tables match RFC 7231 §7.1.1.1 imf-fixdate. */
296
+ static const char *const hyp_pc_dow[7] = {
297
+ "Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"
298
+ };
299
+ static const char *const hyp_pc_mon[12] = {
300
+ "Jan", "Feb", "Mar", "Apr", "May", "Jun",
301
+ "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"
302
+ };
303
+
304
+ /* Format `t` as RFC 7231 imf-fixdate into `dst` (29 bytes, no NUL).
305
+ * Hand-rolled to avoid `strftime`'s locale-dependent output (locale-
306
+ * aware libcs would spell the month / day in non-ASCII for some
307
+ * locales — RFC 7231 mandates English). Branch-free except for the
308
+ * gmtime_r itself. */
309
+ static void hyp_pc_format_imf_fixdate(time_t t, char *dst) {
310
+ struct tm tm;
311
+ if (gmtime_r(&t, &tm) == NULL) {
312
+ /* Fall back to a known-good zero date; should never happen
313
+ * for a sane wall clock. */
314
+ memcpy(dst, "Thu, 01 Jan 1970 00:00:00 GMT", HYP_PC_DATE_LEN);
315
+ return;
316
+ }
317
+ int year = tm.tm_year + 1900;
318
+ int dow = tm.tm_wday & 7; /* defensive: mask to [0,7) */
319
+ int mon = tm.tm_mon % 12; /* defensive */
320
+ if (dow >= 7) dow = 0;
321
+ if (mon < 0) mon = 0;
322
+
323
+ /* "Sun, 06 Nov 1994 08:49:37 GMT" */
324
+ memcpy(dst, hyp_pc_dow[dow], 3);
325
+ dst[3] = ',';
326
+ dst[4] = ' ';
327
+ dst[5] = (char)('0' + (tm.tm_mday / 10));
328
+ dst[6] = (char)('0' + (tm.tm_mday % 10));
329
+ dst[7] = ' ';
330
+ memcpy(dst + 8, hyp_pc_mon[mon], 3);
331
+ dst[11] = ' ';
332
+ dst[12] = (char)('0' + ((year / 1000) % 10));
333
+ dst[13] = (char)('0' + ((year / 100) % 10));
334
+ dst[14] = (char)('0' + ((year / 10) % 10));
335
+ dst[15] = (char)('0' + (year % 10));
336
+ dst[16] = ' ';
337
+ dst[17] = (char)('0' + (tm.tm_hour / 10));
338
+ dst[18] = (char)('0' + (tm.tm_hour % 10));
339
+ dst[19] = ':';
340
+ dst[20] = (char)('0' + (tm.tm_min / 10));
341
+ dst[21] = (char)('0' + (tm.tm_min % 10));
342
+ dst[22] = ':';
343
+ dst[23] = (char)('0' + (tm.tm_sec / 10));
344
+ dst[24] = (char)('0' + (tm.tm_sec % 10));
345
+ dst[25] = ' ';
346
+ dst[26] = 'G';
347
+ dst[27] = 'M';
348
+ dst[28] = 'T';
349
+ }
350
+
351
+ /* Copy the per-second-cached imf-fixdate into `dst` (29 bytes).
352
+ * Refreshes the cache iff the wall-clock second has advanced since the
353
+ * last refresh. Safe to call without `hyp_pc_lock`. */
354
+ static void hyp_pc_cached_date_copy(char *dst) {
355
+ time_t now = time(NULL);
356
+ pthread_mutex_lock(&hyp_pc_date_lock);
357
+ if (now != hyp_pc_cached_date_sec) {
358
+ hyp_pc_format_imf_fixdate(now, hyp_pc_cached_date);
359
+ hyp_pc_cached_date_sec = now;
360
+ }
361
+ memcpy(dst, hyp_pc_cached_date, HYP_PC_DATE_LEN);
362
+ pthread_mutex_unlock(&hyp_pc_date_lock);
363
+ }
364
+
365
+ /* Splice the per-second-cached imf-fixdate into `snapshot` at
366
+ * `date_offset`. No-op when `date_offset == 0` (entry has no
367
+ * placeholder — pre-2.17 register_prebuilt callers). Bounds-checks
368
+ * against `snap_len` so a corrupted header layout never overruns the
369
+ * snapshot buffer. */
370
+ static inline void hyp_pc_splice_date(char *snapshot, size_t snap_len,
371
+ size_t date_offset) {
372
+ if (date_offset == 0) return;
373
+ if (date_offset + HYP_PC_DATE_LEN > snap_len) return;
374
+ hyp_pc_cached_date_copy(snapshot + date_offset);
375
+ }
376
+
257
377
  /* FNV-1a 64-bit. Stable, cheap, branchless on the hot path; not a
258
378
  * cryptographic hash but the cache only stores trusted operator paths. */
259
379
  static uint64_t hyp_pc_hash(const char *key, size_t len) {
@@ -694,6 +814,7 @@ static VALUE rb_pc_write_to(VALUE self, VALUE socket_io, VALUE rb_path) {
694
814
  return sym_missing_pc;
695
815
  }
696
816
  size_t resp_len = slot->page->response_len;
817
+ size_t date_off = slot->page->date_offset;
697
818
  char *snapshot = (char *)malloc(resp_len);
698
819
  if (snapshot == NULL) {
699
820
  pthread_mutex_unlock(&hyp_pc_lock);
@@ -702,6 +823,11 @@ static VALUE rb_pc_write_to(VALUE self, VALUE socket_io, VALUE rb_path) {
702
823
  }
703
824
  memcpy(snapshot, slot->page->response_buf, resp_len);
704
825
  pthread_mutex_unlock(&hyp_pc_lock);
826
+ /* 2.17-A — overwrite the 29-byte Date placeholder in the snapshot
827
+ * (NEVER the page's frozen response_buf) with the per-second-cached
828
+ * imf-fixdate string. No-op when the entry was registered without
829
+ * a placeholder (date_off == 0). */
830
+ hyp_pc_splice_date(snapshot, resp_len, date_off);
705
831
 
706
832
  hyp_pc_write_args_t args;
707
833
  args.fd = fd;
@@ -721,7 +847,7 @@ static VALUE rb_pc_write_to(VALUE self, VALUE socket_io, VALUE rb_path) {
721
847
  return SSIZET2NUM(args.total);
722
848
  }
723
849
 
724
- /* PageCache.register_prebuilt(path, response_bytes, body_len) -> Integer
850
+ /* PageCache.register_prebuilt(path, response_bytes, body_len, date_offset = 0) -> Integer
725
851
  *
726
852
  * 2.10-F — register a fully prebuilt HTTP response under a route path
727
853
  * (e.g. `/health`). Unlike `cache_file`, the entry has NO on-disk
@@ -730,16 +856,37 @@ static VALUE rb_pc_write_to(VALUE self, VALUE socket_io, VALUE rb_path) {
730
856
  * starts inside `response_bytes` so HEAD requests can write the
731
857
  * headers-only prefix.
732
858
  *
733
- * `response_bytes.bytesize` MUST be >= `body_len`. Returns the
734
- * stored response byte count on success.
859
+ * 2.17-A (Hot Path Task 2) — `date_offset` is an OPTIONAL fourth
860
+ * argument: when non-zero, it is the byte offset within
861
+ * `response_bytes` of a 29-byte `XXX...` placeholder reserved for
862
+ * the HTTP `Date:` header. The C splice helper overwrites those 29
863
+ * bytes in a per-write SCRATCH copy with the per-second-cached
864
+ * imf-fixdate string before each kernel write — the registered
865
+ * `response_bytes` is never mutated. Pre-2.17 callers that pass
866
+ * three arguments (or pass 0 for the fourth) get the un-spliced
867
+ * behaviour they had before.
868
+ *
869
+ * `response_bytes.bytesize` MUST be >= `body_len`. When
870
+ * `date_offset != 0` it must satisfy
871
+ * `date_offset + 29 <= response_bytes.bytesize` so the splice never
872
+ * runs off the end of the buffer. Returns the stored response
873
+ * byte count on success.
735
874
  *
736
875
  * Used by `Hyperion::Server.handle_static` to fold the prebuilt
737
876
  * static-route response into the C fast path so the request hot
738
877
  * path is one hash lookup + one `write()` syscall, fully outside
739
878
  * Ruby method dispatch. */
740
- static VALUE rb_pc_register_prebuilt(VALUE self, VALUE rb_path,
741
- VALUE rb_response, VALUE rb_body_len) {
879
+ static VALUE rb_pc_register_prebuilt(int argc, VALUE *argv, VALUE self) {
742
880
  (void)self;
881
+ if (argc < 3 || argc > 4) {
882
+ rb_raise(rb_eArgError,
883
+ "wrong number of arguments (given %d, expected 3..4)", argc);
884
+ }
885
+ VALUE rb_path = argv[0];
886
+ VALUE rb_response = argv[1];
887
+ VALUE rb_body_len = argv[2];
888
+ VALUE rb_date_off = (argc == 4) ? argv[3] : INT2FIX(0);
889
+
743
890
  Check_Type(rb_path, T_STRING);
744
891
  Check_Type(rb_response, T_STRING);
745
892
 
@@ -761,6 +908,16 @@ static VALUE rb_pc_register_prebuilt(VALUE self, VALUE rb_path,
761
908
  "body_len (%zu) must be <= response_bytes.bytesize (%zu)",
762
909
  body_len, resp_len);
763
910
  }
911
+ long date_off_signed = NUM2LONG(rb_date_off);
912
+ if (date_off_signed < 0) {
913
+ rb_raise(rb_eArgError, "date_offset must be >= 0");
914
+ }
915
+ size_t date_off = (size_t)date_off_signed;
916
+ if (date_off != 0 && date_off + HYP_PC_DATE_LEN > resp_len) {
917
+ rb_raise(rb_eArgError,
918
+ "date_offset (%zu) + 29 must be <= response_bytes.bytesize (%zu)",
919
+ date_off, resp_len);
920
+ }
764
921
 
765
922
  hyp_page_t *page = (hyp_page_t *)calloc(1, sizeof(*page));
766
923
  if (page == NULL) {
@@ -802,6 +959,7 @@ static VALUE rb_pc_register_prebuilt(VALUE self, VALUE rb_path,
802
959
  page->last_check = hyp_pc_now();
803
960
  page->immutable = 1;
804
961
  page->prebuilt = 1;
962
+ page->date_offset = date_off;
805
963
 
806
964
  uint64_t h = hyp_pc_hash(path, path_len);
807
965
  pthread_mutex_lock(&hyp_pc_lock);
@@ -887,6 +1045,7 @@ static VALUE rb_pc_serve_request(VALUE self, VALUE socket_io,
887
1045
  size_t write_len = (kind == HYP_PC_METHOD_HEAD)
888
1046
  ? slot->page->headers_len
889
1047
  : slot->page->response_len;
1048
+ size_t date_off = slot->page->date_offset;
890
1049
  char *snapshot = (char *)malloc(write_len);
891
1050
  if (snapshot == NULL) {
892
1051
  pthread_mutex_unlock(&hyp_pc_lock);
@@ -895,6 +1054,11 @@ static VALUE rb_pc_serve_request(VALUE self, VALUE socket_io,
895
1054
  }
896
1055
  memcpy(snapshot, slot->page->response_buf, write_len);
897
1056
  pthread_mutex_unlock(&hyp_pc_lock);
1057
+ /* 2.17-A — splice the per-second-cached imf-fixdate into the
1058
+ * snapshot's Date placeholder (when the entry has one). HEAD
1059
+ * write_len equals headers_len, so a Date placeholder placed
1060
+ * inside the headers span is still in range. */
1061
+ hyp_pc_splice_date(snapshot, write_len, date_off);
898
1062
 
899
1063
  hyp_pc_write_args_t args;
900
1064
  args.fd = fd;
@@ -1920,6 +2084,7 @@ static long hyp_cl_serve_connection(int client_fd, int *handed_off) {
1920
2084
  size_t write_len = (kind == HYP_PC_METHOD_HEAD)
1921
2085
  ? slot->page->headers_len
1922
2086
  : slot->page->response_len;
2087
+ size_t date_off = slot->page->date_offset;
1923
2088
  char *snapshot = (char *)malloc(write_len);
1924
2089
  if (snapshot == NULL) {
1925
2090
  pthread_mutex_unlock(&hyp_pc_lock);
@@ -1930,6 +2095,10 @@ static long hyp_cl_serve_connection(int client_fd, int *handed_off) {
1930
2095
  }
1931
2096
  memcpy(snapshot, slot->page->response_buf, write_len);
1932
2097
  pthread_mutex_unlock(&hyp_pc_lock);
2098
+ /* 2.17-A — splice the per-second-cached imf-fixdate into
2099
+ * the snapshot's Date placeholder before the kernel write.
2100
+ * No-op for entries registered without a placeholder. */
2101
+ hyp_pc_splice_date(snapshot, write_len, date_off);
1933
2102
 
1934
2103
  hyp_pc_write_args_t wargs;
1935
2104
  wargs.fd = client_fd;
@@ -2484,6 +2653,7 @@ char *pc_internal_snapshot_response(const char *path, size_t path_len,
2484
2653
  size_t write_len = (kind == PC_INTERNAL_METHOD_HEAD)
2485
2654
  ? slot->page->headers_len
2486
2655
  : slot->page->response_len;
2656
+ size_t date_off = slot->page->date_offset;
2487
2657
  char *snapshot = (char *)malloc(write_len);
2488
2658
  if (snapshot == NULL) {
2489
2659
  pthread_mutex_unlock(&hyp_pc_lock);
@@ -2491,6 +2661,11 @@ char *pc_internal_snapshot_response(const char *path, size_t path_len,
2491
2661
  }
2492
2662
  memcpy(snapshot, slot->page->response_buf, write_len);
2493
2663
  pthread_mutex_unlock(&hyp_pc_lock);
2664
+ /* 2.17-A — splice the per-second-cached imf-fixdate into the
2665
+ * Date placeholder before handing the snapshot to io_uring (the
2666
+ * kernel reads the bytes asynchronously, so the splice MUST land
2667
+ * before the SQE is submitted). */
2668
+ hyp_pc_splice_date(snapshot, write_len, date_off);
2494
2669
  *out_len = write_len;
2495
2670
  return snapshot;
2496
2671
  }
@@ -2576,8 +2751,10 @@ void Init_hyperion_page_cache(void) {
2576
2751
  rb_pc_auto_threshold, 0);
2577
2752
  rb_define_singleton_method(rb_mHyperionHttpPageCache, "max_key_len",
2578
2753
  rb_pc_max_key_len, 0);
2754
+ /* 2.17-A — variable arity: 3-arg legacy form, 4-arg form adds
2755
+ * `date_offset` for the imf-fixdate splice slot. */
2579
2756
  rb_define_singleton_method(rb_mHyperionHttpPageCache, "register_prebuilt",
2580
- rb_pc_register_prebuilt, 3);
2757
+ rb_pc_register_prebuilt, -1);
2581
2758
  rb_define_singleton_method(rb_mHyperionHttpPageCache, "serve_request",
2582
2759
  rb_pc_serve_request, 3);
2583
2760
  /* 2.12-C — connection lifecycle in C. */
@@ -153,6 +153,117 @@ typedef struct {
153
153
  static VALUE rb_kEMPTY_STR; /* frozen empty ASCII-8BIT String */
154
154
  static VALUE rb_kHTTP_1_1; /* frozen "HTTP/1.1" String */
155
155
 
156
+ /* Phase HotPath-1 (2.17.0) — intern table for the highest-frequency HTTP/1.1
157
+ * *value* tokens (not header names — those are interned via the
158
+ * PREINTERNED_HEADERS table above). On a typical wrk-shaped benchmark, the
159
+ * VALUE side of every header (Host, User-Agent, Accept, Connection,
160
+ * Accept-Encoding, etc.) is identical across every request on a keep-alive
161
+ * connection. Without interning, each request rb_str_new's a fresh String
162
+ * for every value — 6–10 allocations per request, all destined for the
163
+ * young GC. With interning, stash_pending_header's hash check returns the
164
+ * pre-frozen VALUE for known values; the freshly-allocated String becomes
165
+ * unreferenced garbage (collected at next minor GC) and the env Hash holds
166
+ * a single shared identity across all requests. Net wins: lower GC retention
167
+ * under burst, faster string compares downstream (identity), reduced
168
+ * promote-to-old-gen pressure on long-running workers.
169
+ *
170
+ * Layout: open-addressed table sized to 64 slots (power of 2; load factor
171
+ * <0.5 with 33 seeds). Probing capped at 8 to keep cache-friendly.
172
+ * Lookup is FNV-1a on the byte range; on hit we return the frozen VALUE,
173
+ * on miss we return Qundef and the caller falls through to the original
174
+ * (unmodified) String. The table is seeded once at extension init from a
175
+ * static literal list — no growth, no eviction, no thread synchronisation.
176
+ */
177
+ #define HYP_VALUE_INTERN_TABLE_SIZE 64u /* power of 2 */
178
+ #define HYP_VALUE_INTERN_PROBE_MAX 8
179
+ #define HYP_VALUE_INTERN_MAX_LEN 96u /* skip lookup for values > this */
180
+
181
+ typedef struct {
182
+ uint64_t fnv1a;
183
+ uint32_t len;
184
+ VALUE str;
185
+ } hyp_value_intern_entry_t;
186
+
187
+ static hyp_value_intern_entry_t hyp_value_intern_tbl[HYP_VALUE_INTERN_TABLE_SIZE];
188
+
189
+ static const char *const hyp_value_intern_seed[] = {
190
+ /* Connection */
191
+ "keep-alive", "close", "Keep-Alive", "Upgrade", "upgrade",
192
+ /* Accept-Encoding */
193
+ "gzip, deflate", "gzip, deflate, br", "gzip, deflate, br, zstd",
194
+ "identity", "gzip", "deflate", "br", "*",
195
+ /* Accept */
196
+ "*/*", "text/html", "application/json", "text/plain",
197
+ "application/x-www-form-urlencoded", "text/event-stream",
198
+ /* Accept-Language */
199
+ "en-US,en;q=0.9", "en-US", "en",
200
+ /* User-Agent — synthetic / bench clients */
201
+ "Mozilla/5.0 (compatible; wrk/4.2.0)",
202
+ "wrk/4.2.0",
203
+ "curl/8.4.0", "curl/8.5.0", "curl/8.6.0",
204
+ /* Host — local bench */
205
+ "localhost", "127.0.0.1", "0.0.0.0",
206
+ /* Cache-Control */
207
+ "no-cache", "no-store", "max-age=0",
208
+ /* Misc */
209
+ "trailers", "websocket", "h2c",
210
+ };
211
+ #define HYP_VALUE_INTERN_SEED_N \
212
+ (sizeof(hyp_value_intern_seed) / sizeof(hyp_value_intern_seed[0]))
213
+
214
+ static inline uint64_t hyp_fnv1a(const char *bytes, uint32_t len) {
215
+ uint64_t h = 0xcbf29ce484222325ULL;
216
+ for (uint32_t i = 0; i < len; i++) {
217
+ h ^= (uint8_t)bytes[i];
218
+ h *= 0x100000001b3ULL;
219
+ }
220
+ return h;
221
+ }
222
+
223
+ static inline VALUE hyp_intern_lookup(const char *bytes, uint32_t len) {
224
+ if (len == 0 || len > HYP_VALUE_INTERN_MAX_LEN) return Qundef;
225
+ uint64_t h = hyp_fnv1a(bytes, len);
226
+ uint32_t mask = HYP_VALUE_INTERN_TABLE_SIZE - 1u;
227
+ uint32_t idx = (uint32_t)h & mask;
228
+ for (int probe = 0; probe < HYP_VALUE_INTERN_PROBE_MAX; probe++) {
229
+ hyp_value_intern_entry_t *e =
230
+ &hyp_value_intern_tbl[(idx + (uint32_t)probe) & mask];
231
+ if (e->str == 0) return Qundef;
232
+ if (e->fnv1a == h && e->len == len &&
233
+ memcmp(RSTRING_PTR(e->str), bytes, len) == 0) {
234
+ return e->str;
235
+ }
236
+ }
237
+ return Qundef;
238
+ }
239
+
240
+ static void hyp_value_intern_seed_all(void) {
241
+ memset(hyp_value_intern_tbl, 0, sizeof(hyp_value_intern_tbl));
242
+ uint32_t mask = HYP_VALUE_INTERN_TABLE_SIZE - 1u;
243
+ for (size_t i = 0; i < HYP_VALUE_INTERN_SEED_N; i++) {
244
+ const char *s = hyp_value_intern_seed[i];
245
+ uint32_t len = (uint32_t)strlen(s);
246
+ if (len == 0 || len > HYP_VALUE_INTERN_MAX_LEN) continue;
247
+ uint64_t h = hyp_fnv1a(s, len);
248
+ uint32_t idx = (uint32_t)h & mask;
249
+ for (int probe = 0; probe < HYP_VALUE_INTERN_PROBE_MAX; probe++) {
250
+ hyp_value_intern_entry_t *e =
251
+ &hyp_value_intern_tbl[(idx + (uint32_t)probe) & mask];
252
+ if (e->str == 0) {
253
+ /* Match the encoding of values produced by rb_str_new
254
+ * (ASCII-8BIT) so identity comparisons downstream don't
255
+ * fail on encoding mismatch. */
256
+ VALUE v = rb_obj_freeze(rb_str_new(s, (long)len));
257
+ rb_global_variable(&e->str); /* GC root */
258
+ e->str = v;
259
+ e->fnv1a = h;
260
+ e->len = len;
261
+ break;
262
+ }
263
+ }
264
+ }
265
+ }
266
+
156
267
  static void state_init(parser_state_t *s) {
157
268
  s->method = Qnil;
158
269
  s->path = Qnil; /* allocated in on_url first call */
@@ -216,6 +327,15 @@ static void stash_pending_header(parser_state_t *s) {
216
327
  key = rb_funcall(s->current_header_name, id_downcase, 0);
217
328
  }
218
329
  VALUE val = NIL_P(s->current_header_value) ? rb_kEMPTY_STR : s->current_header_value;
330
+ /* Phase HotPath-1 (2.17.0): intern common header values so identical
331
+ * values across keep-alive requests share one frozen VALUE. The
332
+ * freshly-allocated `s->current_header_value` becomes garbage on
333
+ * intern hit. */
334
+ if (val != rb_kEMPTY_STR) {
335
+ long val_len = RSTRING_LEN(val);
336
+ VALUE interned = hyp_intern_lookup(RSTRING_PTR(val), (uint32_t)val_len);
337
+ if (interned != Qundef) val = interned;
338
+ }
219
339
  rb_hash_aset(s->headers, key, val);
220
340
  s->current_header_name = Qnil;
221
341
  s->current_header_value = Qnil;
@@ -1670,6 +1790,13 @@ void Init_hyperion_http(void) {
1670
1790
  rb_obj_freeze(rb_aHeaderTable);
1671
1791
  rb_define_const(rb_cCParser, "PREINTERNED_HEADERS", rb_aHeaderTable);
1672
1792
 
1793
+ /* Phase HotPath-1 (2.17.0): seed the header-value intern table. See
1794
+ * the long comment at the table declaration. Must run after the
1795
+ * encoding registry is up (Init_hyperion_parser is called by
1796
+ * Init_hyperion_http after rb_define_module) so rb_str_new finds
1797
+ * ASCII-8BIT. */
1798
+ hyp_value_intern_seed_all();
1799
+
1673
1800
  /* 2.13-B — status-line, header-key, header-line caches used by
1674
1801
  * cbuild_response_head. The status-line table is fixed-size (no GC
1675
1802
  * concerns; bytes are .rodata). The two header caches are
@@ -670,20 +670,46 @@ module Hyperion
670
670
 
671
671
  # 2.10-F — call into the C ext when available, else fall back to
672
672
  # the 2.10-D Ruby `socket.write` path. Returns bytes written.
673
+ #
674
+ # 2.17-A — when the C ext is unavailable (JRuby / TruffleRuby /
675
+ # build failure) AND the entry carries the new keep-alive prebuilt
676
+ # bytes, splice the per-second-cached httpdate into a per-write
677
+ # scratch String and write THAT — so the wire output shape is
678
+ # identical to the C-loop path even on the slow Ruby fallback.
679
+ # The frozen `prebuilt_keepalive_bytes` itself is never mutated;
680
+ # `String#dup` copies it onto the heap so `[]=` is safe.
673
681
  def serve_static_entry(socket, request, entry)
674
682
  if defined?(::Hyperion::Http::PageCache) &&
675
683
  ::Hyperion::Http::PageCache.respond_to?(:serve_request)
676
684
  result = ::Hyperion::Http::PageCache.serve_request(socket, request.method, entry.path)
677
685
  return result.last if result.is_a?(Array) && result.first == :ok
678
686
  end
679
- # Fallback: Ruby write of the full buffer (or headers-only on HEAD).
680
- bytes = if request.method == 'HEAD' && entry.headers_bytesize < entry.buffer.bytesize
681
- entry.buffer.byteslice(0, entry.headers_bytesize)
682
- else
683
- entry.buffer
684
- end
685
- socket.write(bytes)
687
+ socket.write(static_entry_fallback_bytes(request, entry))
688
+ end
689
+
690
+ # 2.17-A — build the wire bytes for the Ruby fallback path. When
691
+ # the entry carries `prebuilt_keepalive_bytes` + `prebuilt_date_offset`
692
+ # (registered post-2.17), use those bytes with the date spliced in.
693
+ # Otherwise fall through to the legacy `entry.buffer` (lowercase
694
+ # headers, no Date) — this path keeps pre-2.17 callers that
695
+ # constructed StaticEntry directly working unchanged.
696
+ def static_entry_fallback_bytes(request, entry)
697
+ if entry.prebuilt_keepalive_bytes && entry.prebuilt_date_offset.to_i.positive?
698
+ bytes = entry.prebuilt_keepalive_bytes.dup
699
+ bytes[entry.prebuilt_date_offset, 29] = Time.now.httpdate
700
+ return bytes if request.method != 'HEAD'
701
+
702
+ head_end = bytes.index("\r\n\r\n")
703
+ return head_end ? bytes.byteslice(0, head_end + 4) : bytes
704
+ end
705
+
706
+ if request.method == 'HEAD' && entry.headers_bytesize < entry.buffer.bytesize
707
+ entry.buffer.byteslice(0, entry.headers_bytesize)
708
+ else
709
+ entry.buffer
710
+ end
686
711
  end
712
+ private :static_entry_fallback_bytes
687
713
 
688
714
  # 2.10-D — write a direct-route response. Returns the status
689
715
  # code that was written (so `dispatch_direct!` can bump the
@@ -59,7 +59,28 @@ module Hyperion
59
59
  # on HEAD too, which is RFC-correct (HEAD MAY include the body
60
60
  # so long as Content-Length matches; the spec only forbids the
61
61
  # SERVER from sending body bytes the client didn't ask for).
62
- StaticEntry = Struct.new(:method, :path, :buffer, :headers_len) do
62
+ #
63
+ # 2.17-A (Hot Path Task 2) adds two more fields so the C-loop
64
+ # writer can mem-splice a per-second-cached HTTP `Date:` header
65
+ # into a pre-built keep-alive response without rebuilding it
66
+ # from scratch:
67
+ # * `prebuilt_keepalive_bytes` — frozen ASCII-8BIT String of
68
+ # the full HTTP/1.1 wire response (status line + Server +
69
+ # Content-Type + Content-Length + Connection: keep-alive +
70
+ # Date placeholder + body) with a 29-byte 'X' run reserved
71
+ # at `prebuilt_date_offset`. The placeholder is overwritten
72
+ # in a per-write scratch buffer (NEVER in this frozen
73
+ # String) by the C splice helper before the syscall fires.
74
+ # * `prebuilt_date_offset` — Integer byte offset of the first
75
+ # placeholder byte within `prebuilt_keepalive_bytes`. Zero
76
+ # means "no Date placeholder; do not splice". 29 bytes is
77
+ # the canonical RFC 7231 imf-fixdate length.
78
+ # Existing callers that construct StaticEntry with 3 or 4 args
79
+ # see nil for these new fields and the C side falls through to
80
+ # the un-spliced fast path it has used since 2.10-F.
81
+ StaticEntry = Struct.new(:method, :path, :buffer, :headers_len,
82
+ :prebuilt_keepalive_bytes,
83
+ :prebuilt_date_offset) do
63
84
  # Returns the pre-built response bytes ready for one
64
85
  # `socket.write` call. Always frozen.
65
86
  def response_bytes
@@ -136,10 +136,23 @@ module Hyperion
136
136
  head.force_encoding(Encoding::ASCII_8BIT)
137
137
  buffer = (head + body).freeze
138
138
 
139
+ # 2.17-A (Hot Path Task 2) — pre-build the keep-alive wire bytes
140
+ # with a 29-byte Date placeholder so the C-loop writer can splice
141
+ # the per-second-cached httpdate string in without rebuilding the
142
+ # head from scratch every request. Returned as `[bytes, offset]`
143
+ # — `offset` is the byte index of the first placeholder byte.
144
+ prebuilt_ka_bytes, date_offset =
145
+ build_static_wire_bytes(body, content_type: content_type, server_string: 'Hyperion')
146
+
139
147
  method_key = method_sym.to_s.upcase.to_sym
140
148
  # 2.10-F — record the headers prefix length on the StaticEntry
141
149
  # struct so HEAD-method writes can serve a headers-only prefix.
142
- entry = RouteTable::StaticEntry.new(method_key, path.dup.freeze, buffer, head.bytesize).freeze
150
+ # 2.17-A also stash the keep-alive prebuilt bytes + Date offset
151
+ # so the C splice helper (or the Ruby fallback in
152
+ # Connection#serve_static_entry) can stamp the cached date in
153
+ # before each write.
154
+ entry = RouteTable::StaticEntry.new(method_key, path.dup.freeze, buffer, head.bytesize,
155
+ prebuilt_ka_bytes, date_offset).freeze
143
156
  # 2.10-F — register the entry DIRECTLY (StaticEntry responds to
144
157
  # `#call`) instead of wrapping it in a closure, so the dispatch
145
158
  # path can branch on `is_a?(StaticEntry)` BEFORE invoking the
@@ -152,17 +165,58 @@ module Hyperion
152
165
  # MUST also answer HEAD with the same headers). No-op on a
153
166
  # POST/PUT/etc. registration — those don't get a HEAD twin.
154
167
  route_table.register(:HEAD, path, entry) if method_key == :GET
155
- # 2.10-F — fold the prebuilt response into the C-side PageCache so
156
- # `PageCache.serve_request` can write it without ever crossing
157
- # back into Ruby. Best-effort: if the C ext isn't available
158
- # (JRuby / TruffleRuby), the dispatcher silently falls back to
159
- # the Ruby `socket.write` path that's been there since 2.10-D.
168
+ # 2.17-A — fold the keep-alive prebuilt bytes (with Date placeholder)
169
+ # into the C-side PageCache so `PageCache.serve_request` and the
170
+ # C accept loop both serve the new shape. The 4-arg
171
+ # `register_prebuilt` form (introduced in 2.17-A) records the
172
+ # Date offset on the C-side `hyp_page_t` so every snapshot
173
+ # site splices the cached date before writing. Best-effort:
174
+ # the C ext may be absent on JRuby / TruffleRuby — the
175
+ # dispatcher silently falls back to the Ruby `socket.write`
176
+ # path that's been there since 2.10-D.
160
177
  if defined?(::Hyperion::Http::PageCache) && ::Hyperion::Http::PageCache.respond_to?(:register_prebuilt)
161
- ::Hyperion::Http::PageCache.register_prebuilt(path, buffer, body.bytesize)
178
+ ::Hyperion::Http::PageCache.register_prebuilt(path, prebuilt_ka_bytes, body.bytesize, date_offset)
162
179
  end
163
180
  entry
164
181
  end
165
182
 
183
+ # 2.17-A (Hot Path Task 2) — assemble the prebuilt keep-alive
184
+ # wire bytes for a static route registered via `handle_static`.
185
+ # Returns `[frozen_bytes, date_offset]`:
186
+ # * `frozen_bytes` — ASCII-8BIT, frozen String of the full
187
+ # HTTP/1.1 response (status line + Server + Content-Type +
188
+ # Content-Length + Connection: keep-alive + Date placeholder
189
+ # + CRLFCRLF + body). The Date placeholder is a 29-byte 'X'
190
+ # run that the C splice helper overwrites in a per-write
191
+ # scratch buffer (the frozen String itself is NEVER mutated).
192
+ # * `date_offset` — Integer index of the first placeholder
193
+ # byte within `frozen_bytes`. 29 bytes is the canonical RFC
194
+ # 7231 imf-fixdate length (`Sun, 06 Nov 1994 08:49:37 GMT`).
195
+ #
196
+ # Header order is fixed on purpose — the C splice helper relies
197
+ # on the offset being stable across requests; placing Date last
198
+ # keeps the offset arithmetic trivial regardless of body length
199
+ # variations between routes. Headers are capitalized in the
200
+ # canonical RFC 7230 §3.2 form (case-insensitive but
201
+ # conventionally capitalized) so the wire output matches what
202
+ # CDN / proxy logs expect.
203
+ def self.build_static_wire_bytes(body, content_type:, server_string:)
204
+ placeholder = 'X' * 29
205
+ head_prefix = +"HTTP/1.1 200 OK\r\n" \
206
+ "Server: #{server_string}\r\n" \
207
+ "Content-Type: #{content_type}\r\n" \
208
+ "Content-Length: #{body.bytesize}\r\n" \
209
+ "Connection: keep-alive\r\n" \
210
+ "Date: "
211
+ head_prefix.force_encoding(Encoding::ASCII_8BIT)
212
+ date_offset = head_prefix.bytesize
213
+ head = head_prefix + placeholder + "\r\n\r\n"
214
+ head.force_encoding(Encoding::ASCII_8BIT)
215
+ bytes = (head + body).b.freeze
216
+ [bytes, date_offset]
217
+ end
218
+ private_class_method :build_static_wire_bytes
219
+
166
220
  # 1.7.0 added kwargs (all default to current behaviour):
167
221
  # * `runtime:` — `Hyperion::Runtime` instance (default
168
222
  # `Runtime.default`). Threaded through to
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Hyperion
4
- VERSION = '2.16.4'
4
+ VERSION = '2.17.0'
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: hyperion-rb
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.16.4
4
+ version: 2.17.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrey Lobanov