hyperion-rb 1.6.2 → 2.10.1

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 +4563 -0
  3. data/README.md +189 -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 +452 -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 +368 -9
  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,493 @@
1
+ /* ----------------------------------------------------------------------
2
+ * Hyperion::WebSocket::CFrame — RFC 6455 frame ser/de in C.
3
+ *
4
+ * Public surface (defined as singleton methods on Hyperion::WebSocket::CFrame):
5
+ *
6
+ * CFrame.unmask(payload, mask_key) -> String
7
+ * Input: binary String `payload`, 4-byte binary String `mask_key`.
8
+ * Output: a freshly-allocated binary String of `payload.bytesize` bytes,
9
+ * XOR-unmasked with `mask_key` per RFC 6455 §5.3.
10
+ * Implementation: word-at-a-time XOR with the 4-byte key smeared into a
11
+ * uint32_t, then a 0..3-byte tail. GVL is released for payloads larger
12
+ * than 64 KiB so other fibers / threads can run while we crunch.
13
+ *
14
+ * CFrame.parse(buf, offset = 0) ->
15
+ * [fin, opcode, payload_len, masked, mask_key, payload_offset,
16
+ * frame_total_len, rsv1]
17
+ * OR :incomplete OR :error
18
+ * Non-copying parser. Returns metadata only; the caller still owns
19
+ * `buf` and uses `payload_offset` + `frame_total_len` to slice or to
20
+ * advance to the next frame. `mask_key` is `nil` when `masked == false`.
21
+ * Returns `:incomplete` if `buf[offset..]` does not yet hold a full
22
+ * header. Returns `:error` for malformed frames (RSV2/RSV3 bits set,
23
+ * unknown opcode, control frame > 125 bytes, fragmented control
24
+ * frame, 64-bit length with high bit set, or RSV1 set on a control
25
+ * frame). RSV1 is preserved as the 8th tuple slot; the Ruby façade
26
+ * decides whether to treat it as a permessage-deflate marker (when
27
+ * the extension was negotiated) or as a protocol error (when it was
28
+ * not).
29
+ *
30
+ * CFrame.build(opcode, payload, fin: true, mask: false, mask_key: nil,
31
+ * rsv1: false)
32
+ * -> String
33
+ * Builds a serialized frame ready for `socket.write`. Server frames are
34
+ * unmasked (`mask: false`, the default) per §5.1. Client frames must
35
+ * pass `mask: true` and a 4-byte `mask_key`. Control frames (close 0x8,
36
+ * ping 0x9, pong 0xA) MUST have `payload.bytesize <= 125` and MUST have
37
+ * `fin: true` and `rsv1: false` — this helper raises ArgumentError
38
+ * otherwise. Pass `rsv1: true` only on a text/binary frame whose
39
+ * payload is permessage-deflate compressed.
40
+ *
41
+ * Why C?
42
+ * The dominant CPU cost on the receive path is XOR-unmasking the
43
+ * payload. A tight uint32 loop in C is ~5–10× faster than Ruby's
44
+ * `unpack1('N*')` + manual XOR for typical 1 KiB–1 MiB messages.
45
+ * RFC 6455 framing itself is ~200 lines of C with no dependencies, so
46
+ * we keep parser+builder+unmask in one translation unit.
47
+ * ---------------------------------------------------------------------- */
48
+
49
+ #include <ruby.h>
50
+ #include <ruby/thread.h>
51
+ #include <ruby/encoding.h>
52
+
53
+ #include <stdint.h>
54
+ #include <stdlib.h>
55
+ #include <string.h>
56
+
57
+ /* Threshold above which CFrame.unmask releases the GVL. Below this,
58
+ * the GVL-release ceremony itself costs more than the XOR work. */
59
+ #define HYP_WS_GVL_RELEASE_THRESHOLD (64 * 1024)
60
+
61
+ /* Control-frame payload cap per RFC 6455 §5.5. */
62
+ #define HYP_WS_CONTROL_MAX_PAYLOAD 125
63
+
64
+ static VALUE rb_mHyperion;
65
+ static VALUE rb_mHyperionWebSocket;
66
+ static VALUE rb_cCFrame;
67
+
68
+ static VALUE sym_incomplete;
69
+ static VALUE sym_error;
70
+
71
+ /* ------------------------------------------------------------------ */
72
+ /* Unmask */
73
+ /* ------------------------------------------------------------------ */
74
+
75
+ typedef struct {
76
+ const uint8_t *src;
77
+ uint8_t *dst;
78
+ size_t len;
79
+ uint32_t key32; /* host-order uint32 with the 4 mask bytes smeared */
80
+ uint8_t key[4];
81
+ } unmask_args_t;
82
+
83
+ /* Core XOR loop. Word-at-a-time for the body, byte-by-byte for the
84
+ * 0..3-byte tail. We deliberately avoid SIMD intrinsics — they don't
85
+ * portably outperform a unaligned uint32 read+xor on the codegen path
86
+ * GCC/Clang produce for this inner loop, and they'd hurt portability. */
87
+ static void hyp_ws_xor_inplace(unmask_args_t *a) {
88
+ size_t len = a->len;
89
+ size_t words = len / 4;
90
+ size_t tail = len & 0x3;
91
+
92
+ /* Use memcpy() to avoid strict-aliasing UB on the unaligned uint32
93
+ * read. -O2 collapses the memcpy into a single mov on x86_64 and
94
+ * arm64. */
95
+ for (size_t i = 0; i < words; i++) {
96
+ uint32_t v;
97
+ memcpy(&v, a->src + i * 4, 4);
98
+ v ^= a->key32;
99
+ memcpy(a->dst + i * 4, &v, 4);
100
+ }
101
+
102
+ /* Tail: index into the original 4-byte key by absolute byte offset
103
+ * mod 4, per RFC 6455 §5.3. */
104
+ size_t base = words * 4;
105
+ for (size_t i = 0; i < tail; i++) {
106
+ a->dst[base + i] = a->src[base + i] ^ a->key[(base + i) & 0x3];
107
+ }
108
+ }
109
+
110
+ static void *hyp_ws_xor_blocking(void *raw) {
111
+ hyp_ws_xor_inplace((unmask_args_t *)raw);
112
+ return NULL;
113
+ }
114
+
115
+ static VALUE rb_cframe_unmask(VALUE self, VALUE rb_payload, VALUE rb_key) {
116
+ (void)self;
117
+
118
+ Check_Type(rb_payload, T_STRING);
119
+ Check_Type(rb_key, T_STRING);
120
+
121
+ long key_len = RSTRING_LEN(rb_key);
122
+ if (key_len != 4) {
123
+ rb_raise(rb_eArgError,
124
+ "Hyperion::WebSocket::CFrame.unmask: mask_key must be exactly "
125
+ "4 bytes (got %ld)", key_len);
126
+ }
127
+
128
+ long payload_len = RSTRING_LEN(rb_payload);
129
+ /* Empty payload — return an empty binary String of the right encoding. */
130
+ if (payload_len == 0) {
131
+ VALUE empty = rb_str_new(NULL, 0);
132
+ rb_enc_associate(empty, rb_ascii8bit_encoding());
133
+ return empty;
134
+ }
135
+
136
+ VALUE out = rb_str_new(NULL, payload_len);
137
+ rb_enc_associate(out, rb_ascii8bit_encoding());
138
+
139
+ unmask_args_t args;
140
+ args.src = (const uint8_t *)RSTRING_PTR(rb_payload);
141
+ args.dst = (uint8_t *)RSTRING_PTR(out);
142
+ args.len = (size_t)payload_len;
143
+ memcpy(args.key, RSTRING_PTR(rb_key), 4);
144
+ /* Smear the 4 mask bytes into a host-order uint32. memcpy() handles
145
+ * platform endianness — we treat the key as a stream of 4 bytes that
146
+ * we want to apply at offsets {0,1,2,3,4,5,...} ≡ {0,1,2,3,0,1,2,3,...},
147
+ * and reading 4 bytes at a time with memcpy preserves that pattern
148
+ * independent of endianness. */
149
+ memcpy(&args.key32, args.key, 4);
150
+
151
+ if ((size_t)payload_len > HYP_WS_GVL_RELEASE_THRESHOLD) {
152
+ rb_thread_call_without_gvl(hyp_ws_xor_blocking, &args, RUBY_UBF_IO, NULL);
153
+ } else {
154
+ hyp_ws_xor_inplace(&args);
155
+ }
156
+
157
+ /* Keep `rb_payload` and `out` alive across the GVL release. */
158
+ RB_GC_GUARD(rb_payload);
159
+ RB_GC_GUARD(rb_key);
160
+
161
+ return out;
162
+ }
163
+
164
+ /* ------------------------------------------------------------------ */
165
+ /* Parse */
166
+ /* ------------------------------------------------------------------ */
167
+
168
+ /* Returns 1 if opcode is a control frame (0x8 close, 0x9 ping, 0xA pong),
169
+ * 0 otherwise. Per §5.5 control opcodes are 0x8..0xF; 0xB..0xF are
170
+ * reserved and rejected by the unknown-opcode check. */
171
+ static inline int hyp_ws_is_control(uint8_t opcode) {
172
+ return opcode >= 0x8;
173
+ }
174
+
175
+ static inline int hyp_ws_is_known_opcode(uint8_t opcode) {
176
+ /* 0x0 continuation, 0x1 text, 0x2 binary, 0x8 close, 0x9 ping, 0xA pong. */
177
+ return opcode == 0x0 || opcode == 0x1 || opcode == 0x2 ||
178
+ opcode == 0x8 || opcode == 0x9 || opcode == 0xA;
179
+ }
180
+
181
+ static VALUE rb_cframe_parse(int argc, VALUE *argv, VALUE self) {
182
+ (void)self;
183
+
184
+ VALUE rb_buf, rb_offset;
185
+ rb_scan_args(argc, argv, "11", &rb_buf, &rb_offset);
186
+
187
+ Check_Type(rb_buf, T_STRING);
188
+ long offset = NIL_P(rb_offset) ? 0 : NUM2LONG(rb_offset);
189
+ if (offset < 0) {
190
+ rb_raise(rb_eArgError, "offset must be >= 0 (got %ld)", offset);
191
+ }
192
+
193
+ long buf_len = RSTRING_LEN(rb_buf);
194
+ if (offset > buf_len) {
195
+ return sym_incomplete;
196
+ }
197
+
198
+ long avail = buf_len - offset;
199
+ if (avail < 2) {
200
+ return sym_incomplete;
201
+ }
202
+
203
+ const uint8_t *p = (const uint8_t *)RSTRING_PTR(rb_buf) + offset;
204
+ uint8_t b0 = p[0];
205
+ uint8_t b1 = p[1];
206
+
207
+ int fin = (b0 & 0x80) != 0;
208
+ int rsv1 = (b0 & 0x40) != 0;
209
+ int rsv2 = (b0 & 0x20) != 0;
210
+ int rsv3 = (b0 & 0x10) != 0;
211
+ uint8_t opcode = b0 & 0x0F;
212
+ int masked = (b1 & 0x80) != 0;
213
+ uint8_t len7 = b1 & 0x7F;
214
+
215
+ /* RSV2/RSV3 are still reserved with no negotiated semantics; reject.
216
+ * RSV1 is the permessage-deflate marker (RFC 7692 §6) — allow it to
217
+ * pass through here so the Ruby façade can decide what to do based
218
+ * on whether the extension was negotiated for this connection. The
219
+ * Connection wrapper closes 1002 if it sees RSV1 without a
220
+ * negotiated extension. RSV1 on a control frame is always a
221
+ * protocol error per RFC 7692 §6.1 ("control frames MUST NOT be
222
+ * compressed"); we trap that one case below. */
223
+ if (rsv2 || rsv3) {
224
+ return sym_error;
225
+ }
226
+
227
+ if (!hyp_ws_is_known_opcode(opcode)) {
228
+ return sym_error;
229
+ }
230
+
231
+ /* §5.5 — control frames MUST have FIN=1 and len <= 125. RFC 7692
232
+ * §6.1 — control frames MUST NOT have RSV1 set. */
233
+ if (hyp_ws_is_control(opcode)) {
234
+ if (!fin) {
235
+ return sym_error;
236
+ }
237
+ if (len7 > HYP_WS_CONTROL_MAX_PAYLOAD) {
238
+ return sym_error;
239
+ }
240
+ if (rsv1) {
241
+ return sym_error;
242
+ }
243
+ }
244
+
245
+ long header_len = 2;
246
+ uint64_t payload_len64 = 0;
247
+
248
+ if (len7 < 126) {
249
+ payload_len64 = len7;
250
+ } else if (len7 == 126) {
251
+ if (avail < header_len + 2) return sym_incomplete;
252
+ payload_len64 = ((uint64_t)p[2] << 8) | (uint64_t)p[3];
253
+ header_len += 2;
254
+ } else { /* len7 == 127 */
255
+ if (avail < header_len + 8) return sym_incomplete;
256
+ /* Network byte order; high bit MUST be 0 per RFC 6455 §5.2. */
257
+ if (p[2] & 0x80) {
258
+ return sym_error;
259
+ }
260
+ payload_len64 =
261
+ ((uint64_t)p[2] << 56) | ((uint64_t)p[3] << 48) |
262
+ ((uint64_t)p[4] << 40) | ((uint64_t)p[5] << 32) |
263
+ ((uint64_t)p[6] << 24) | ((uint64_t)p[7] << 16) |
264
+ ((uint64_t)p[8] << 8) | (uint64_t)p[9];
265
+ header_len += 8;
266
+ }
267
+
268
+ VALUE rb_mask_key = Qnil;
269
+ if (masked) {
270
+ if (avail < header_len + 4) return sym_incomplete;
271
+ rb_mask_key = rb_str_new((const char *)(p + header_len), 4);
272
+ rb_enc_associate(rb_mask_key, rb_ascii8bit_encoding());
273
+ header_len += 4;
274
+ }
275
+
276
+ /* Bound payload_len64: a frame_total_len that overflows a Ruby
277
+ * Integer is fine (Bignum), but we still want to make sure the
278
+ * header_len + payload_len arithmetic below doesn't overflow size_t
279
+ * on 32-bit hosts. On 64-bit Linux/Darwin this is academic; the
280
+ * cap is 2^63-1 and Ruby Strings can't be larger than LONG_MAX
281
+ * anyway. */
282
+ if (payload_len64 > (uint64_t)LONG_MAX - (uint64_t)header_len) {
283
+ return sym_error;
284
+ }
285
+
286
+ /* Incomplete payload — the caller should buffer more bytes. */
287
+ long payload_offset_abs = offset + header_len;
288
+ long frame_total_len = header_len + (long)payload_len64;
289
+ if (avail < frame_total_len) {
290
+ return sym_incomplete;
291
+ }
292
+
293
+ VALUE result = rb_ary_new_capa(8);
294
+ rb_ary_push(result, fin ? Qtrue : Qfalse);
295
+ rb_ary_push(result, INT2FIX(opcode));
296
+ rb_ary_push(result, ULL2NUM(payload_len64));
297
+ rb_ary_push(result, masked ? Qtrue : Qfalse);
298
+ rb_ary_push(result, rb_mask_key);
299
+ rb_ary_push(result, LONG2NUM(payload_offset_abs));
300
+ rb_ary_push(result, LONG2NUM(frame_total_len));
301
+ rb_ary_push(result, rsv1 ? Qtrue : Qfalse);
302
+
303
+ RB_GC_GUARD(rb_buf);
304
+ return result;
305
+ }
306
+
307
+ /* ------------------------------------------------------------------ */
308
+ /* Build */
309
+ /* ------------------------------------------------------------------ */
310
+
311
+ static ID id_kw_fin;
312
+ static ID id_kw_mask;
313
+ static ID id_kw_mask_key;
314
+ static ID id_kw_rsv1;
315
+
316
+ static VALUE rb_cframe_build(int argc, VALUE *argv, VALUE self) {
317
+ (void)self;
318
+
319
+ VALUE rb_opcode, rb_payload, rb_kwargs;
320
+ rb_scan_args(argc, argv, "20:", &rb_opcode, &rb_payload, &rb_kwargs);
321
+
322
+ long opcode_l = NUM2LONG(rb_opcode);
323
+ if (opcode_l < 0 || opcode_l > 0xF) {
324
+ rb_raise(rb_eArgError, "opcode must be 0..15 (got %ld)", opcode_l);
325
+ }
326
+ uint8_t opcode = (uint8_t)opcode_l;
327
+ if (!hyp_ws_is_known_opcode(opcode)) {
328
+ rb_raise(rb_eArgError, "unknown opcode 0x%x", (unsigned)opcode);
329
+ }
330
+
331
+ Check_Type(rb_payload, T_STRING);
332
+ long payload_len = RSTRING_LEN(rb_payload);
333
+
334
+ int fin = 1;
335
+ int mask = 0;
336
+ int rsv1 = 0;
337
+ VALUE rb_mask_key = Qnil;
338
+
339
+ if (!NIL_P(rb_kwargs)) {
340
+ VALUE kw_vals[4] = { Qundef, Qundef, Qundef, Qundef };
341
+ ID kw_ids[4] = { id_kw_fin, id_kw_mask, id_kw_mask_key, id_kw_rsv1 };
342
+ rb_get_kwargs(rb_kwargs, kw_ids, 0, 4, kw_vals);
343
+ if (kw_vals[0] != Qundef) {
344
+ fin = RTEST(kw_vals[0]) ? 1 : 0;
345
+ }
346
+ if (kw_vals[1] != Qundef) {
347
+ mask = RTEST(kw_vals[1]) ? 1 : 0;
348
+ }
349
+ if (kw_vals[2] != Qundef) {
350
+ rb_mask_key = kw_vals[2];
351
+ }
352
+ if (kw_vals[3] != Qundef) {
353
+ rsv1 = RTEST(kw_vals[3]) ? 1 : 0;
354
+ }
355
+ }
356
+
357
+ /* §5.5 control-frame validation. RFC 7692 §6.1 — control frames
358
+ * MUST NOT have RSV1 set. */
359
+ if (hyp_ws_is_control(opcode)) {
360
+ if (!fin) {
361
+ rb_raise(rb_eArgError,
362
+ "control frame (opcode 0x%x) MUST have fin=true",
363
+ (unsigned)opcode);
364
+ }
365
+ if (payload_len > HYP_WS_CONTROL_MAX_PAYLOAD) {
366
+ rb_raise(rb_eArgError,
367
+ "control frame (opcode 0x%x) payload %ld exceeds 125-byte cap",
368
+ (unsigned)opcode, payload_len);
369
+ }
370
+ if (rsv1) {
371
+ rb_raise(rb_eArgError,
372
+ "control frame (opcode 0x%x) MUST NOT have rsv1=true",
373
+ (unsigned)opcode);
374
+ }
375
+ }
376
+
377
+ if (mask) {
378
+ if (NIL_P(rb_mask_key)) {
379
+ rb_raise(rb_eArgError, "mask: true requires a 4-byte mask_key");
380
+ }
381
+ Check_Type(rb_mask_key, T_STRING);
382
+ if (RSTRING_LEN(rb_mask_key) != 4) {
383
+ rb_raise(rb_eArgError, "mask_key must be exactly 4 bytes (got %ld)",
384
+ RSTRING_LEN(rb_mask_key));
385
+ }
386
+ }
387
+
388
+ /* Compute header length. */
389
+ long header_len = 2;
390
+ if (payload_len < 126) {
391
+ /* 7-bit length encoded inline. */
392
+ } else if (payload_len <= 0xFFFF) {
393
+ header_len += 2;
394
+ } else {
395
+ header_len += 8;
396
+ }
397
+ if (mask) header_len += 4;
398
+
399
+ long frame_len = header_len + payload_len;
400
+ VALUE out = rb_str_new(NULL, frame_len);
401
+ rb_enc_associate(out, rb_ascii8bit_encoding());
402
+ uint8_t *q = (uint8_t *)RSTRING_PTR(out);
403
+
404
+ q[0] = (uint8_t)((fin ? 0x80 : 0x00) | (rsv1 ? 0x40 : 0x00) | (opcode & 0x0F));
405
+ uint8_t mask_bit = mask ? 0x80 : 0x00;
406
+
407
+ long body_offset;
408
+ if (payload_len < 126) {
409
+ q[1] = mask_bit | (uint8_t)payload_len;
410
+ body_offset = 2;
411
+ } else if (payload_len <= 0xFFFF) {
412
+ q[1] = mask_bit | 126;
413
+ q[2] = (uint8_t)((payload_len >> 8) & 0xFF);
414
+ q[3] = (uint8_t)(payload_len & 0xFF);
415
+ body_offset = 4;
416
+ } else {
417
+ q[1] = mask_bit | 127;
418
+ uint64_t pl = (uint64_t)payload_len;
419
+ q[2] = (uint8_t)((pl >> 56) & 0xFF);
420
+ q[3] = (uint8_t)((pl >> 48) & 0xFF);
421
+ q[4] = (uint8_t)((pl >> 40) & 0xFF);
422
+ q[5] = (uint8_t)((pl >> 32) & 0xFF);
423
+ q[6] = (uint8_t)((pl >> 24) & 0xFF);
424
+ q[7] = (uint8_t)((pl >> 16) & 0xFF);
425
+ q[8] = (uint8_t)((pl >> 8) & 0xFF);
426
+ q[9] = (uint8_t)(pl & 0xFF);
427
+ body_offset = 10;
428
+ }
429
+
430
+ if (mask) {
431
+ memcpy(q + body_offset, RSTRING_PTR(rb_mask_key), 4);
432
+ body_offset += 4;
433
+ }
434
+
435
+ if (payload_len > 0) {
436
+ if (mask) {
437
+ /* Reuse the unmask kernel — XOR is symmetric. */
438
+ unmask_args_t args;
439
+ args.src = (const uint8_t *)RSTRING_PTR(rb_payload);
440
+ args.dst = q + body_offset;
441
+ args.len = (size_t)payload_len;
442
+ memcpy(args.key, RSTRING_PTR(rb_mask_key), 4);
443
+ memcpy(&args.key32, args.key, 4);
444
+ if ((size_t)payload_len > HYP_WS_GVL_RELEASE_THRESHOLD) {
445
+ rb_thread_call_without_gvl(hyp_ws_xor_blocking, &args, RUBY_UBF_IO, NULL);
446
+ } else {
447
+ hyp_ws_xor_inplace(&args);
448
+ }
449
+ } else {
450
+ memcpy(q + body_offset, RSTRING_PTR(rb_payload), (size_t)payload_len);
451
+ }
452
+ }
453
+
454
+ RB_GC_GUARD(rb_payload);
455
+ RB_GC_GUARD(rb_mask_key);
456
+ return out;
457
+ }
458
+
459
+ /* ------------------------------------------------------------------ */
460
+ /* Init */
461
+ /* ------------------------------------------------------------------ */
462
+
463
+ void Init_hyperion_websocket(void) {
464
+ rb_mHyperion = rb_const_get(rb_cObject, rb_intern("Hyperion"));
465
+
466
+ if (rb_const_defined(rb_mHyperion, rb_intern("WebSocket"))) {
467
+ rb_mHyperionWebSocket = rb_const_get(rb_mHyperion, rb_intern("WebSocket"));
468
+ } else {
469
+ rb_mHyperionWebSocket = rb_define_module_under(rb_mHyperion, "WebSocket");
470
+ }
471
+
472
+ rb_cCFrame = rb_define_module_under(rb_mHyperionWebSocket, "CFrame");
473
+
474
+ rb_define_singleton_method(rb_cCFrame, "unmask", rb_cframe_unmask, 2);
475
+ rb_define_singleton_method(rb_cCFrame, "parse", rb_cframe_parse, -1);
476
+ rb_define_singleton_method(rb_cCFrame, "build", rb_cframe_build, -1);
477
+
478
+ sym_incomplete = ID2SYM(rb_intern("incomplete"));
479
+ sym_error = ID2SYM(rb_intern("error"));
480
+ rb_gc_register_mark_object(sym_incomplete);
481
+ rb_gc_register_mark_object(sym_error);
482
+
483
+ id_kw_fin = rb_intern("fin");
484
+ id_kw_mask = rb_intern("mask");
485
+ id_kw_mask_key = rb_intern("mask_key");
486
+ id_kw_rsv1 = rb_intern("rsv1");
487
+
488
+ /* Expose constants for the Ruby façade & specs. */
489
+ rb_define_const(rb_cCFrame, "GVL_RELEASE_THRESHOLD",
490
+ INT2NUM(HYP_WS_GVL_RELEASE_THRESHOLD));
491
+ rb_define_const(rb_cCFrame, "CONTROL_MAX_PAYLOAD",
492
+ INT2NUM(HYP_WS_CONTROL_MAX_PAYLOAD));
493
+ }
@@ -0,0 +1,33 @@
1
+ # This file is automatically @generated by Cargo.
2
+ # It is not intended for manual editing.
3
+ version = 3
4
+
5
+ [[package]]
6
+ name = "bitflags"
7
+ version = "1.3.2"
8
+ source = "registry+https://github.com/rust-lang/crates.io-index"
9
+ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
10
+
11
+ [[package]]
12
+ name = "hyperion_io_uring"
13
+ version = "2.3.0"
14
+ dependencies = [
15
+ "io-uring",
16
+ "libc",
17
+ ]
18
+
19
+ [[package]]
20
+ name = "io-uring"
21
+ version = "0.6.4"
22
+ source = "registry+https://github.com/rust-lang/crates.io-index"
23
+ checksum = "595a0399f411a508feb2ec1e970a4a30c249351e30208960d58298de8660b0e5"
24
+ dependencies = [
25
+ "bitflags",
26
+ "libc",
27
+ ]
28
+
29
+ [[package]]
30
+ name = "libc"
31
+ version = "0.2.186"
32
+ source = "registry+https://github.com/rust-lang/crates.io-index"
33
+ checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
@@ -0,0 +1,34 @@
1
+ [package]
2
+ name = "hyperion_io_uring"
3
+ version = "2.3.0"
4
+ edition = "2021"
5
+ publish = false
6
+ description = "io_uring accept/read primitives for Hyperion (2.3-A, Linux 5.6+ only)"
7
+
8
+ [lib]
9
+ crate-type = ["cdylib"]
10
+ name = "hyperion_io_uring"
11
+
12
+ # Linux-only dep. io-uring (https://docs.rs/io-uring) is the
13
+ # well-maintained safe Rust wrapper around liburing — owns the ring
14
+ # lifecycle, SQE/CQE submission, kernel-feature probing, and the dance
15
+ # of io_uring's evolving feature set across kernel versions. We pull
16
+ # it in only on Linux so the macOS dev build still cargo-checks
17
+ # cleanly; on Darwin the cdylib compiles down to stubbed
18
+ # `extern "C"` entry points that always return -ENOSYS, mirroring the
19
+ # kernel surface we'd see on Linux < 5.6.
20
+ #
21
+ # 2.3-A keeps the surface intentionally small: ring lifecycle (new /
22
+ # close), one accept submission API, and a feature-gate hook used by
23
+ # `Hyperion::IOUring.supported?`. fix-A read_into hooks are wired but
24
+ # not exercised on the wire path yet; future 2.3-x rounds will plumb
25
+ # them once the accept-only path is field-validated.
26
+ [target.'cfg(target_os = "linux")'.dependencies]
27
+ io-uring = "0.6"
28
+ libc = "0.2"
29
+
30
+ [profile.release]
31
+ opt-level = 3
32
+ lto = true
33
+ codegen-units = 1
34
+ strip = true
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Build the hyperion_io_uring Rust extension (2.3-A).
4
+ #
5
+ # Mirrors `ext/hyperion_h2_codec/extconf.rb`: shells out to
6
+ # `cargo build --release`, drops the resulting cdylib into
7
+ # `lib/hyperion_io_uring/`, and writes a no-op Makefile so `gem
8
+ # install` succeeds.
9
+ #
10
+ # Cargo is OPTIONAL. If it's missing, the extconf writes a stub
11
+ # Makefile that prints a friendly note and exits cleanly — Hyperion
12
+ # still ships and `Hyperion::IOUring.supported?` returns false on
13
+ # both Darwin (no kernel support anyway) and Linux hosts that lack
14
+ # Rust. Operators who want the perf bump install Rust via `rustup`
15
+ # and `gem pristine hyperion-rb` to rebuild.
16
+ #
17
+ # Cross-platform notes:
18
+ # * Linux + GNU libc: cargo emits `libhyperion_io_uring.so`.
19
+ # * macOS: `libhyperion_io_uring.dylib`.
20
+ # * The Linux-only `io-uring` crate is gated via Cargo
21
+ # `target.'cfg(target_os = "linux")'`, so the macOS build
22
+ # compiles cleanly but every entry point returns -ENOSYS. The
23
+ # Ruby loader checks the OS first via `IOUring.supported?` and
24
+ # never reaches those stubs.
25
+
26
+ require 'mkmf'
27
+ require 'fileutils'
28
+ require 'rbconfig'
29
+
30
+ ext_dir = __dir__
31
+ crate_dir = ext_dir
32
+ target_dir = File.join(crate_dir, 'target', 'release')
33
+ gem_lib_dir = File.expand_path('../../lib/hyperion_io_uring', __dir__)
34
+
35
+ cargo_present = system('cargo --version > /dev/null 2>&1')
36
+
37
+ if cargo_present
38
+ warn '[hyperion_io_uring] cargo detected — building io_uring accept extension'
39
+ Dir.chdir(crate_dir) do
40
+ ok = system('cargo build --release')
41
+ unless ok
42
+ warn '[hyperion_io_uring] cargo build failed; falling back to epoll accept path'
43
+ cargo_present = false
44
+ end
45
+ end
46
+ end
47
+
48
+ FileUtils.mkdir_p(gem_lib_dir)
49
+
50
+ if cargo_present
51
+ candidates = %w[libhyperion_io_uring.dylib libhyperion_io_uring.so]
52
+ found = candidates.find { |c| File.exist?(File.join(target_dir, c)) }
53
+ if found
54
+ src = File.join(target_dir, found)
55
+ dst = File.join(gem_lib_dir, found)
56
+ FileUtils.cp(src, dst)
57
+ warn "[hyperion_io_uring] installed #{dst}"
58
+ else
59
+ warn '[hyperion_io_uring] cargo finished but no cdylib artifact found; falling back'
60
+ cargo_present = false
61
+ end
62
+ end
63
+
64
+ # Always emit a Makefile — gem install protocol expects one. The body
65
+ # is a no-op when cargo isn't present so `make` exits 0 and gem
66
+ # install completes.
67
+ File.open(File.join(ext_dir, 'Makefile'), 'w') do |f|
68
+ f.puts 'all:'
69
+ f.puts "\t@echo \"[hyperion_io_uring] no-op make (cargo handled the build)\""
70
+ f.puts 'clean:'
71
+ f.puts "\t@rm -rf target"
72
+ f.puts 'install:'
73
+ f.puts "\t@echo \"[hyperion_io_uring] no-op install (artifact already in lib/)\""
74
+ end