hyperion-rb 1.6.2 → 2.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +4768 -0
  3. data/README.md +222 -13
  4. data/ext/hyperion_h2_codec/Cargo.lock +7 -0
  5. data/ext/hyperion_h2_codec/Cargo.toml +33 -0
  6. data/ext/hyperion_h2_codec/extconf.rb +73 -0
  7. data/ext/hyperion_h2_codec/src/frames.rs +140 -0
  8. data/ext/hyperion_h2_codec/src/hpack/huffman.rs +161 -0
  9. data/ext/hyperion_h2_codec/src/hpack.rs +457 -0
  10. data/ext/hyperion_h2_codec/src/lib.rs +296 -0
  11. data/ext/hyperion_http/extconf.rb +28 -0
  12. data/ext/hyperion_http/h2_codec_glue.c +408 -0
  13. data/ext/hyperion_http/page_cache.c +1125 -0
  14. data/ext/hyperion_http/parser.c +473 -38
  15. data/ext/hyperion_http/sendfile.c +982 -0
  16. data/ext/hyperion_http/websocket.c +493 -0
  17. data/ext/hyperion_io_uring/Cargo.lock +33 -0
  18. data/ext/hyperion_io_uring/Cargo.toml +34 -0
  19. data/ext/hyperion_io_uring/extconf.rb +74 -0
  20. data/ext/hyperion_io_uring/src/lib.rs +316 -0
  21. data/lib/hyperion/adapter/rack.rb +370 -42
  22. data/lib/hyperion/admin_listener.rb +207 -0
  23. data/lib/hyperion/admin_middleware.rb +36 -7
  24. data/lib/hyperion/cli.rb +310 -11
  25. data/lib/hyperion/config.rb +440 -14
  26. data/lib/hyperion/connection.rb +679 -22
  27. data/lib/hyperion/deprecations.rb +81 -0
  28. data/lib/hyperion/dispatch_mode.rb +165 -0
  29. data/lib/hyperion/fiber_local.rb +75 -13
  30. data/lib/hyperion/h2_admission.rb +77 -0
  31. data/lib/hyperion/h2_codec.rb +499 -0
  32. data/lib/hyperion/http/page_cache.rb +122 -0
  33. data/lib/hyperion/http/sendfile.rb +696 -0
  34. data/lib/hyperion/http2/native_hpack_adapter.rb +70 -0
  35. data/lib/hyperion/http2_handler.rb +618 -19
  36. data/lib/hyperion/io_uring.rb +317 -0
  37. data/lib/hyperion/lint_wrapper_pool.rb +126 -0
  38. data/lib/hyperion/master.rb +96 -9
  39. data/lib/hyperion/metrics/path_templater.rb +68 -0
  40. data/lib/hyperion/metrics.rb +256 -0
  41. data/lib/hyperion/prometheus_exporter.rb +150 -0
  42. data/lib/hyperion/request.rb +13 -0
  43. data/lib/hyperion/response_writer.rb +477 -16
  44. data/lib/hyperion/runtime.rb +195 -0
  45. data/lib/hyperion/server/route_table.rb +179 -0
  46. data/lib/hyperion/server.rb +519 -55
  47. data/lib/hyperion/static_preload.rb +133 -0
  48. data/lib/hyperion/thread_pool.rb +61 -7
  49. data/lib/hyperion/tls.rb +343 -1
  50. data/lib/hyperion/version.rb +1 -1
  51. data/lib/hyperion/websocket/close_codes.rb +71 -0
  52. data/lib/hyperion/websocket/connection.rb +876 -0
  53. data/lib/hyperion/websocket/frame.rb +356 -0
  54. data/lib/hyperion/websocket/handshake.rb +525 -0
  55. data/lib/hyperion/worker.rb +111 -9
  56. data/lib/hyperion.rb +137 -3
  57. metadata +50 -1
@@ -0,0 +1,408 @@
1
+ /* ----------------------------------------------------------------------
2
+ * Hyperion::H2Codec::CGlue — direct C → Rust bridge for HPACK encode/decode.
3
+ *
4
+ * 2.4-A (RFC §3 2.4.0) — round-2 FFI marshalling. The 2.0.0 path used
5
+ * Fiddle::Pointer per call (`Fiddle::Pointer[bytes]` allocates a Ruby
6
+ * object for each pointer wrapper). The 2.2.x fix-B path collapsed
7
+ * argv encoding into a single `pack('Q*', buffer:)` and reused scratch
8
+ * buffers, which trimmed per-call alloc from ~12 strings to ~7.5.
9
+ *
10
+ * The remaining ~7.5 strings/call sat in the Fiddle layer:
11
+ *
12
+ * 1. `pack('Q*', buffer: scratch_argv)` — ~1 alloc
13
+ * 2. `Fiddle::Pointer[scratch_blob/argv/out]` — 3 wrappers/call
14
+ * 3. Per-header `name.b` / `value.b` (when source isn't ASCII-8BIT)
15
+ *
16
+ * 2.4-A bypasses Fiddle entirely on the per-call hot path. Ruby calls
17
+ * `Hyperion::H2Codec::CGlue.encoder_encode_v3(handle_long, headers, scratch_out)`
18
+ * which:
19
+ *
20
+ * * walks `headers` in-place, building a packed argv buffer
21
+ * (4×u64 per header) on the C stack (or a heap-allocated growable
22
+ * buffer if there are >256 headers);
23
+ * * concatenates name+value bytes into the C-side blob buffer (also
24
+ * stack-resident with heap fallback);
25
+ * * directly invokes the cached `hyperion_h2_codec_encoder_encode_v2`
26
+ * function pointer (resolved at install time via `dlsym`);
27
+ * * truncates `scratch_out` to the bytes-written count via
28
+ * `rb_str_set_len`, returning the count to Ruby. Ruby's wrapper
29
+ * does the single unavoidable allocation: `byteslice(0, n)` to
30
+ * hand back the encoded frame as an owned String.
31
+ *
32
+ * Per-call allocations (steady state, no header table growth):
33
+ * * 1 String for the byteslice return.
34
+ * * That's it. argv/blob are stack-resident.
35
+ *
36
+ * Fiddle still owns the build-time loader path: `H2Codec.load!` opens
37
+ * the cdylib via `Fiddle.dlopen` exactly as before, then calls
38
+ * `CGlue.install(path_string)` to hand the cdylib path off to this
39
+ * C unit. We re-`dlopen` it with `RTLD_NOLOAD | RTLD_NOW` (or just
40
+ * `RTLD_NOW` on macOS where NOLOAD isn't honoured the same way) so
41
+ * we get a `void *handle` we can `dlsym` on. The encoder/decoder
42
+ * `_new`/`_free` Rust funcs are still called via Fiddle on the
43
+ * one-time ctor/dtor — only encode/decode is on the hot path.
44
+ *
45
+ * If `dlopen`/`dlsym` fail (or the .so isn't present), `Init_hyperion_h2_codec_glue`
46
+ * leaves `CGlue.available?` returning `false` and Ruby falls back to
47
+ * the v2 (Fiddle) path automatically.
48
+ *
49
+ * Why a separate `_v3` Ruby method instead of replacing v2?
50
+ * * Lets the v2 Fiddle path remain as a drop-in fallback when CGlue
51
+ * fails to load (older glibc, hardened sandbox blocking dlopen).
52
+ * * Spec parity check ("v3 and v2 produce identical decoded
53
+ * headers") catches any C-side argv-packing regression before it
54
+ * hits production traffic.
55
+ * ---------------------------------------------------------------------- */
56
+
57
+ #include <ruby.h>
58
+ #include <ruby/encoding.h>
59
+
60
+ #include <stdint.h>
61
+ #include <stdlib.h>
62
+ #include <string.h>
63
+ #include <dlfcn.h>
64
+
65
+ /* ------------------------------------------------------------------ */
66
+ /* Cached Rust function pointers + module/class globals. */
67
+ /* ------------------------------------------------------------------ */
68
+
69
+ static VALUE rb_mHyperion;
70
+ static VALUE rb_mH2Codec;
71
+ static VALUE rb_mCGlue;
72
+ static VALUE rb_eOutputOverflow;
73
+
74
+ /* Match the Rust ABI from ext/hyperion_h2_codec/src/lib.rs. */
75
+ typedef long long (*rust_encode_v2_fn)(
76
+ void *handle,
77
+ const unsigned char *blob_ptr, size_t blob_len,
78
+ const uint64_t *argv_ptr, size_t argv_count,
79
+ unsigned char *out_ptr, size_t out_capacity);
80
+
81
+ typedef int (*rust_decode_fn)(
82
+ void *handle,
83
+ const unsigned char *in_ptr, unsigned int in_len,
84
+ unsigned char *out_ptr, unsigned int out_capacity);
85
+
86
+ typedef unsigned int (*rust_abi_version_fn)(void);
87
+
88
+ static void *rust_dl_handle = NULL;
89
+ static rust_encode_v2_fn rust_encode_v2 = NULL;
90
+ static rust_decode_fn rust_decode = NULL;
91
+ static rust_abi_version_fn rust_abi_version = NULL;
92
+ static int cglue_available = 0;
93
+
94
+ /* Stack-resident argv/blob caps. 99% of HEADERS frames have <= 32
95
+ * pairs and total name+value bytes < 4 KiB, so these defaults handle
96
+ * the steady state without touching the heap. Above this we malloc
97
+ * a one-shot growable buffer (still cheaper than a Ruby allocation
98
+ * because no GC pressure). */
99
+ #define HYP_GLUE_STACK_ARGV_CAP 64 /* 64 headers × 4×u64 = 2 KiB */
100
+ #define HYP_GLUE_STACK_BLOB_CAP 8192 /* 8 KiB total name+value bytes */
101
+
102
+ /* ------------------------------------------------------------------ */
103
+ /* CGlue.install(path) -> true on success, false otherwise */
104
+ /* */
105
+ /* Called once from `Hyperion::H2Codec.load!` after Fiddle has */
106
+ /* probed the candidate paths and confirmed the cdylib loads. We */
107
+ /* dlopen the same path independently so we get our own handle to */
108
+ /* dlsym against — re-dlopen with the same path is a no-op refcount */
109
+ /* bump per POSIX semantics, so this doesn't double-load the .so. */
110
+ /* ------------------------------------------------------------------ */
111
+ static VALUE rb_cglue_install(VALUE self, VALUE rb_path) {
112
+ (void)self;
113
+ Check_Type(rb_path, T_STRING);
114
+
115
+ if (cglue_available) {
116
+ /* Idempotent — second call is a no-op. */
117
+ return Qtrue;
118
+ }
119
+
120
+ const char *path = StringValueCStr(rb_path);
121
+ void *h = dlopen(path, RTLD_NOW | RTLD_LOCAL);
122
+ if (!h) {
123
+ return Qfalse;
124
+ }
125
+
126
+ rust_abi_version_fn abi_fn =
127
+ (rust_abi_version_fn)dlsym(h, "hyperion_h2_codec_abi_version");
128
+ rust_encode_v2_fn enc_fn =
129
+ (rust_encode_v2_fn)dlsym(h, "hyperion_h2_codec_encoder_encode_v2");
130
+ rust_decode_fn dec_fn =
131
+ (rust_decode_fn)dlsym(h, "hyperion_h2_codec_decoder_decode");
132
+
133
+ if (!abi_fn || !enc_fn || !dec_fn) {
134
+ dlclose(h);
135
+ return Qfalse;
136
+ }
137
+
138
+ /* ABI 1 is the only version currently shipped. If a future Rust
139
+ * crate bumps the ABI, this guard prevents the v3 path from
140
+ * silently dispatching to a mismatched layout. The v2 (Fiddle)
141
+ * path has its own ABI check. */
142
+ if (abi_fn() != 1) {
143
+ dlclose(h);
144
+ return Qfalse;
145
+ }
146
+
147
+ rust_dl_handle = h;
148
+ rust_abi_version = abi_fn;
149
+ rust_encode_v2 = enc_fn;
150
+ rust_decode = dec_fn;
151
+ cglue_available = 1;
152
+ return Qtrue;
153
+ }
154
+
155
+ static VALUE rb_cglue_available_p(VALUE self) {
156
+ (void)self;
157
+ return cglue_available ? Qtrue : Qfalse;
158
+ }
159
+
160
+ /* ------------------------------------------------------------------ */
161
+ /* CGlue.encoder_encode_v3(handle_addr, headers_array, scratch_out) */
162
+ /* */
163
+ /* Per-call allocations: ZERO from C; ONE String alloc happens in */
164
+ /* Ruby for the byteslice return at the call site. */
165
+ /* */
166
+ /* Returns: Integer bytes_written. Raises on overflow / bad args. */
167
+ /* ------------------------------------------------------------------ */
168
+ static VALUE rb_cglue_encoder_encode_v3(VALUE self,
169
+ VALUE rb_handle_addr,
170
+ VALUE rb_headers,
171
+ VALUE rb_scratch_out) {
172
+ (void)self;
173
+
174
+ if (!cglue_available || !rust_encode_v2) {
175
+ rb_raise(rb_eRuntimeError,
176
+ "Hyperion::H2Codec::CGlue not installed (call .install(path) first)");
177
+ }
178
+
179
+ Check_Type(rb_headers, T_ARRAY);
180
+ Check_Type(rb_scratch_out, T_STRING);
181
+
182
+ void *handle = (void *)(intptr_t)NUM2LL(rb_handle_addr);
183
+ long count = RARRAY_LEN(rb_headers);
184
+ if (count == 0) {
185
+ return INT2FIX(0);
186
+ }
187
+
188
+ /* Stack buffers — heap fallback for unusually large header sets. */
189
+ uint64_t stack_argv[HYP_GLUE_STACK_ARGV_CAP * 4];
190
+ uint8_t stack_blob[HYP_GLUE_STACK_BLOB_CAP];
191
+
192
+ uint64_t *argv = stack_argv;
193
+ uint8_t *blob = stack_blob;
194
+ int argv_on_heap = 0;
195
+ int blob_on_heap = 0;
196
+ size_t argv_cap = HYP_GLUE_STACK_ARGV_CAP;
197
+ size_t blob_cap = HYP_GLUE_STACK_BLOB_CAP;
198
+
199
+ if ((size_t)count > argv_cap) {
200
+ argv = (uint64_t *)malloc((size_t)count * 4 * sizeof(uint64_t));
201
+ if (!argv) {
202
+ rb_raise(rb_eNoMemError, "H2Codec::CGlue argv malloc failed");
203
+ }
204
+ argv_cap = (size_t)count;
205
+ argv_on_heap = 1;
206
+ }
207
+
208
+ /* First pass: compute total blob size to decide stack vs heap. */
209
+ size_t total_blob = 0;
210
+ for (long i = 0; i < count; i++) {
211
+ VALUE pair = rb_ary_entry(rb_headers, i);
212
+ if (TYPE(pair) != T_ARRAY || RARRAY_LEN(pair) < 2) {
213
+ if (argv_on_heap) free(argv);
214
+ rb_raise(rb_eArgError,
215
+ "H2Codec::CGlue.encode_v3: each header must be a [name, value] array");
216
+ }
217
+ VALUE name = rb_ary_entry(pair, 0);
218
+ VALUE value = rb_ary_entry(pair, 1);
219
+ if (TYPE(name) != T_STRING || TYPE(value) != T_STRING) {
220
+ if (argv_on_heap) free(argv);
221
+ rb_raise(rb_eTypeError,
222
+ "H2Codec::CGlue.encode_v3: header name and value must be Strings");
223
+ }
224
+ total_blob += (size_t)RSTRING_LEN(name);
225
+ total_blob += (size_t)RSTRING_LEN(value);
226
+ }
227
+
228
+ if (total_blob > blob_cap) {
229
+ blob = (uint8_t *)malloc(total_blob);
230
+ if (!blob) {
231
+ if (argv_on_heap) free(argv);
232
+ rb_raise(rb_eNoMemError, "H2Codec::CGlue blob malloc failed");
233
+ }
234
+ blob_cap = total_blob;
235
+ blob_on_heap = 1;
236
+ }
237
+
238
+ /* Second pass: pack argv quads + concatenate blob. We *do not*
239
+ * call `name.b` / `value.b` here even when the source encoding
240
+ * isn't ASCII_8BIT — HPACK only cares about the byte sequence.
241
+ * `RSTRING_PTR` + `RSTRING_LEN` give us the raw byte view
242
+ * regardless of the Ruby encoding tag, which avoids a per-header
243
+ * String allocation that the v2 Ruby path could not avoid for
244
+ * non-binary inputs. */
245
+ size_t blob_off = 0;
246
+ for (long i = 0; i < count; i++) {
247
+ VALUE pair = rb_ary_entry(rb_headers, i);
248
+ VALUE name = rb_ary_entry(pair, 0);
249
+ VALUE value = rb_ary_entry(pair, 1);
250
+
251
+ size_t nl = (size_t)RSTRING_LEN(name);
252
+ size_t vl = (size_t)RSTRING_LEN(value);
253
+
254
+ size_t base = (size_t)i * 4;
255
+ argv[base + 0] = (uint64_t)blob_off;
256
+ argv[base + 1] = (uint64_t)nl;
257
+ argv[base + 2] = (uint64_t)(blob_off + nl);
258
+ argv[base + 3] = (uint64_t)vl;
259
+
260
+ if (nl > 0) {
261
+ memcpy(blob + blob_off, RSTRING_PTR(name), nl);
262
+ }
263
+ blob_off += nl;
264
+ if (vl > 0) {
265
+ memcpy(blob + blob_off, RSTRING_PTR(value), vl);
266
+ }
267
+ blob_off += vl;
268
+ }
269
+
270
+ /* Make sure scratch_out has at least `out_capacity` bytes of
271
+ * usable buffer space. Ruby pre-sized it via `String.new(capacity:)`
272
+ * + `<<` to set the length, so RSTRING_LEN reflects the full
273
+ * usable region (we'll truncate to `written` after the FFI call).
274
+ */
275
+ size_t out_capacity = (size_t)RSTRING_LEN(rb_scratch_out);
276
+ /* rb_str_modify ensures the scratch String is mutable, has its own
277
+ * (unshared) backing buffer, and that RSTRING_PTR is valid for
278
+ * out_capacity bytes of writes. Required before we hand its raw
279
+ * pointer to Rust. */
280
+ rb_str_modify(rb_scratch_out);
281
+ unsigned char *out_ptr = (unsigned char *)RSTRING_PTR(rb_scratch_out);
282
+
283
+ long long written = rust_encode_v2(handle,
284
+ blob, blob_off,
285
+ argv, (size_t)count,
286
+ out_ptr, out_capacity);
287
+
288
+ if (argv_on_heap) free(argv);
289
+ if (blob_on_heap) free(blob);
290
+
291
+ /* Keep the headers array alive across the FFI call — RSTRING_PTR
292
+ * pointers we read from `name`/`value` are only valid while their
293
+ * VALUEs are live and unmoved. */
294
+ RB_GC_GUARD(rb_headers);
295
+ RB_GC_GUARD(rb_scratch_out);
296
+
297
+ if (written == -1) {
298
+ rb_raise(rb_eOutputOverflow,
299
+ "Hyperion::H2Codec::CGlue.encode_v3 output buffer overflow "
300
+ "(capacity=%zu)", out_capacity);
301
+ }
302
+ if (written < 0) {
303
+ rb_raise(rb_eRuntimeError,
304
+ "Hyperion::H2Codec::CGlue.encode_v3 failed (rc=%lld)", written);
305
+ }
306
+
307
+ /* Truncate the scratch String to the bytes-written count. The
308
+ * caller's Ruby wrapper then `byteslice(0, written)`s it — that
309
+ * single byteslice is the only String alloc per encode call. */
310
+ rb_str_set_len(rb_scratch_out, (long)written);
311
+ return LL2NUM(written);
312
+ }
313
+
314
+ /* ------------------------------------------------------------------ */
315
+ /* CGlue.decoder_decode_v3(handle_addr, bytes_str, scratch_out) */
316
+ /* */
317
+ /* The decoder side is less hot than encode (responses are encode- */
318
+ /* heavy), but the same Fiddle-layer overhead applies on h2 request */
319
+ /* dispatch. Same direct C → Rust path. */
320
+ /* ------------------------------------------------------------------ */
321
+ static VALUE rb_cglue_decoder_decode_v3(VALUE self,
322
+ VALUE rb_handle_addr,
323
+ VALUE rb_bytes,
324
+ VALUE rb_scratch_out) {
325
+ (void)self;
326
+
327
+ if (!cglue_available || !rust_decode) {
328
+ rb_raise(rb_eRuntimeError,
329
+ "Hyperion::H2Codec::CGlue not installed (call .install(path) first)");
330
+ }
331
+
332
+ Check_Type(rb_bytes, T_STRING);
333
+ Check_Type(rb_scratch_out, T_STRING);
334
+
335
+ void *handle = (void *)(intptr_t)NUM2LL(rb_handle_addr);
336
+
337
+ long in_len = RSTRING_LEN(rb_bytes);
338
+ if (in_len == 0) {
339
+ rb_str_set_len(rb_scratch_out, 0);
340
+ return INT2FIX(0);
341
+ }
342
+
343
+ rb_str_modify(rb_scratch_out);
344
+ long out_capacity = RSTRING_LEN(rb_scratch_out);
345
+
346
+ int written = rust_decode(handle,
347
+ (const unsigned char *)RSTRING_PTR(rb_bytes),
348
+ (unsigned int)in_len,
349
+ (unsigned char *)RSTRING_PTR(rb_scratch_out),
350
+ (unsigned int)out_capacity);
351
+
352
+ RB_GC_GUARD(rb_bytes);
353
+ RB_GC_GUARD(rb_scratch_out);
354
+
355
+ if (written == -1) {
356
+ rb_raise(rb_eOutputOverflow,
357
+ "Hyperion::H2Codec::CGlue.decode_v3 output buffer overflow "
358
+ "(capacity=%ld)", out_capacity);
359
+ }
360
+ if (written < 0) {
361
+ rb_raise(rb_eRuntimeError,
362
+ "Hyperion::H2Codec::CGlue.decode_v3 failed (rc=%d)", written);
363
+ }
364
+
365
+ rb_str_set_len(rb_scratch_out, (long)written);
366
+ return INT2NUM(written);
367
+ }
368
+
369
+ /* ------------------------------------------------------------------ */
370
+ /* Init */
371
+ /* ------------------------------------------------------------------ */
372
+
373
+ void Init_hyperion_h2_codec_glue(void) {
374
+ rb_mHyperion = rb_const_get(rb_cObject, rb_intern("Hyperion"));
375
+
376
+ /* `Hyperion::H2Codec` may not be defined yet at C-init time — its
377
+ * Ruby file is loaded lazily by the gem entry point. Define it
378
+ * here as an empty module placeholder if needed; the Ruby file
379
+ * will reopen it. */
380
+ if (rb_const_defined(rb_mHyperion, rb_intern("H2Codec"))) {
381
+ rb_mH2Codec = rb_const_get(rb_mHyperion, rb_intern("H2Codec"));
382
+ } else {
383
+ rb_mH2Codec = rb_define_module_under(rb_mHyperion, "H2Codec");
384
+ }
385
+
386
+ rb_mCGlue = rb_define_module_under(rb_mH2Codec, "CGlue");
387
+
388
+ /* OutputOverflow is defined in lib/hyperion/h2_codec.rb (Ruby
389
+ * side). If the Ruby file loaded first we reuse it; otherwise we
390
+ * define a placeholder that the Ruby file's class definition
391
+ * re-opens (it's a `class OutputOverflow < StandardError; end`
392
+ * so re-opening is safe). */
393
+ if (rb_const_defined(rb_mH2Codec, rb_intern("OutputOverflow"))) {
394
+ rb_eOutputOverflow = rb_const_get(rb_mH2Codec, rb_intern("OutputOverflow"));
395
+ } else {
396
+ rb_eOutputOverflow = rb_define_class_under(rb_mH2Codec,
397
+ "OutputOverflow",
398
+ rb_eStandardError);
399
+ }
400
+ rb_global_variable(&rb_eOutputOverflow);
401
+
402
+ rb_define_singleton_method(rb_mCGlue, "install", rb_cglue_install, 1);
403
+ rb_define_singleton_method(rb_mCGlue, "available?", rb_cglue_available_p, 0);
404
+ rb_define_singleton_method(rb_mCGlue, "encoder_encode_v3",
405
+ rb_cglue_encoder_encode_v3, 3);
406
+ rb_define_singleton_method(rb_mCGlue, "decoder_decode_v3",
407
+ rb_cglue_decoder_decode_v3, 3);
408
+ }