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,499 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fiddle'
4
+ require 'fiddle/import'
5
+
6
+ module Hyperion
7
+ # Phase 6 (RFC §3 2.0.0) — native HPACK encoder/decoder + frame
8
+ # primitives implemented in Rust. The Ruby side here is a thin
9
+ # Fiddle-based loader; all real work happens in
10
+ # `ext/hyperion_h2_codec`.
11
+ #
12
+ # The integration is OPT-IN at runtime: `Http2Handler` checks
13
+ # `Hyperion::H2Codec.available?` and uses the native path only when
14
+ # the cdylib loaded successfully. Operators on systems without Rust
15
+ # (older Debians, locked-down CI runners, JRuby) get the existing
16
+ # `protocol-http2` Ruby HPACK path automatically — no boot-time
17
+ # error.
18
+ #
19
+ # ABI version: bumped on any breaking C ABI change. Ruby refuses to
20
+ # load a binary that disagrees so a stale on-disk codec from an
21
+ # older gem install can't crash the process.
22
+ module H2Codec
23
+ EXPECTED_ABI = 1
24
+
25
+ # Raised when the per-encoder scratch output buffer can't hold a
26
+ # single frame's encoded bytes. fix-B (2.2.x) — the v2 ABI returns
27
+ # -1 on overflow, which the wrapper translates to this.
28
+ class OutputOverflow < StandardError; end
29
+
30
+ # Try to load the native cdylib. Sets `@available = true/false`.
31
+ # Idempotent — second call is a no-op.
32
+ def self.available?
33
+ load!
34
+ @available == true
35
+ end
36
+
37
+ # 2.4-A — has the C glue (`Hyperion::H2Codec::CGlue`) loaded AND
38
+ # successfully resolved the Rust HPACK symbols via dlopen/dlsym?
39
+ # Distinct from `available?` because CGlue can fail to load (older
40
+ # systems without dlfcn, hardened sandboxes blocking dlopen) while
41
+ # the Fiddle path still works. When this is true the per-call
42
+ # encode/decode hot path bypasses Fiddle entirely.
43
+ def self.cglue_available?
44
+ load!
45
+ @cglue_available == true
46
+ end
47
+
48
+ # 2.11-B — operator-controllable gate that overlays CGlue
49
+ # availability. The Encoder/Decoder hot paths probe this (NOT
50
+ # `cglue_available?`) so a `HYPERION_H2_NATIVE_HPACK=v2` boot can
51
+ # force the Fiddle path even on a host where the C glue loaded
52
+ # successfully. This is the bench-isolation knob 2.11-B's
53
+ # `bench/h2_rails_shape.sh` needs to compare native-v2 against
54
+ # native-v3 honestly — without it, "native" and "cglue" variants
55
+ # would always pick the same physical path.
56
+ #
57
+ # `Http2Handler#initialize` writes the gate based on the env var;
58
+ # tests can flip `@cglue_disabled` directly. Default false (i.e.,
59
+ # gate is OPEN — same physical behavior as 2.4-A through 2.10).
60
+ def self.cglue_active?
61
+ cglue_available? && !@cglue_disabled
62
+ end
63
+
64
+ def self.cglue_disabled=(value)
65
+ @cglue_disabled = value ? true : false
66
+ end
67
+
68
+ def self.cglue_disabled
69
+ @cglue_disabled == true
70
+ end
71
+
72
+ # Force a reload (test seam). Unsets the memoized state so the next
73
+ # `available?` call probes the filesystem again.
74
+ def self.reset!
75
+ @available = nil
76
+ @cglue_available = nil
77
+ @cglue_disabled = false
78
+ @lib = nil
79
+ end
80
+
81
+ # Ruby-friendly wrapper around the native encoder. Single instance
82
+ # holds an opaque pointer; `#encode([['name','value'], ...])`
83
+ # returns the wire bytes. The dynamic table state is per-instance.
84
+ #
85
+ # fix-B (2.2.x) — per-encoder scratch buffers eliminate per-call
86
+ # FFI marshalling allocations. Each `Encoder` owns:
87
+ #
88
+ # * `@scratch_out` — output buffer reused across encode calls,
89
+ # grown lazily if a single frame exceeds the
90
+ # starting 16 KiB capacity.
91
+ # * `@scratch_argv` — packed `(name_off, name_len, val_off, val_len)`
92
+ # u64-quad buffer (each header is 32 bytes).
93
+ # * `@scratch_blob` — concatenated header bytes
94
+ # (name_1, value_1, name_2, value_2, …).
95
+ # * `@scratch_*_ptr` — `Fiddle::Pointer`s pre-cached for the three
96
+ # scratch strings; recreated only when the
97
+ # underlying string is reallocated by `<<`
98
+ # crossing the existing capacity.
99
+ #
100
+ # `#encode` clears the three buffers (length 0, capacity preserved),
101
+ # appends offset/length quads + raw bytes, and dispatches one FFI
102
+ # call to `hyperion_h2_codec_encoder_encode_v2`. The only unavoidable
103
+ # allocation per call is `byteslice` to extract the written bytes
104
+ # — that's the contract `protocol-http2`'s `encode_headers` returns
105
+ # under, so it can't move further.
106
+ class Encoder
107
+ SCRATCH_OUT_DEFAULT = 16_384
108
+ SCRATCH_ARGV_DEFAULT = 4_096
109
+ SCRATCH_BLOB_DEFAULT = 4_096
110
+ private_constant :SCRATCH_OUT_DEFAULT, :SCRATCH_ARGV_DEFAULT, :SCRATCH_BLOB_DEFAULT
111
+
112
+ def initialize
113
+ raise 'H2Codec native library unavailable' unless H2Codec.available?
114
+
115
+ @ptr = H2Codec.encoder_new
116
+ ObjectSpace.define_finalizer(self, self.class.finalizer(@ptr))
117
+
118
+ @scratch_out = String.new(capacity: SCRATCH_OUT_DEFAULT, encoding: Encoding::ASCII_8BIT)
119
+ @scratch_argv = String.new(capacity: SCRATCH_ARGV_DEFAULT, encoding: Encoding::ASCII_8BIT)
120
+ @scratch_blob = String.new(capacity: SCRATCH_BLOB_DEFAULT, encoding: Encoding::ASCII_8BIT)
121
+ # Pre-cache the Fiddle::Pointer so the per-call hot path
122
+ # doesn't pay a Pointer.new allocation. The pointer's address
123
+ # tracks the underlying String's buffer; if `<<` later reallocates
124
+ # the buffer we refresh the pointer and bump the recorded
125
+ # capacity.
126
+ @scratch_out_ptr = Fiddle::Pointer[@scratch_out]
127
+ @scratch_argv_ptr = Fiddle::Pointer[@scratch_argv]
128
+ @scratch_blob_ptr = Fiddle::Pointer[@scratch_blob]
129
+ @scratch_out_capacity = SCRATCH_OUT_DEFAULT
130
+ @scratch_argv_capacity = SCRATCH_ARGV_DEFAULT
131
+ @scratch_blob_capacity = SCRATCH_BLOB_DEFAULT
132
+ # Per-encoder Int array reused for `pack('Q*', buffer:)` calls.
133
+ # `clear` keeps the array but length-zeros it; the underlying
134
+ # storage capacity is retained by MRI for steady-state reuse.
135
+ @scratch_argv_ints = []
136
+ end
137
+
138
+ def self.finalizer(ptr)
139
+ proc { H2Codec.encoder_free(ptr) if H2Codec.available? && ptr }
140
+ end
141
+
142
+ def encode(headers)
143
+ return ''.b if headers.empty?
144
+
145
+ # 2.4-A — fast path: when the C glue loaded successfully,
146
+ # bypass Fiddle entirely. The C ext walks the headers array,
147
+ # builds the argv quad buffer on the C stack, and calls
148
+ # `hyperion_h2_codec_encoder_encode_v2` directly via a cached
149
+ # function pointer. The only Ruby allocation per call is the
150
+ # final `byteslice(0, written)` which copies the encoded bytes
151
+ # into a new owned String — that's the contract callers rely
152
+ # on (`protocol-http2`'s Compressor#encode returns a String,
153
+ # not a slice into shared mutable memory).
154
+ #
155
+ # 2.11-B — probe `cglue_active?` (NOT `cglue_available?`) so an
156
+ # operator-set `HYPERION_H2_NATIVE_HPACK=v2` boot routes through
157
+ # Fiddle even when the C glue is physically present. Same
158
+ # branch shape; one extra ivar read on the hot path which
159
+ # disappears under YJIT inlining.
160
+ if H2Codec.cglue_active?
161
+ # Pad the scratch String with zero bytes so its length matches
162
+ # capacity — the C ext writes into RSTRING_PTR up to RSTRING_LEN
163
+ # and then truncates back via rb_str_set_len after encoding.
164
+ # The first encode pads the full SCRATCH_OUT_DEFAULT (16 KiB);
165
+ # subsequent calls find the length already at capacity and
166
+ # skip the pad entirely. On the rare oversize-frame case we
167
+ # catch OutputOverflow, grow, and retry — much cheaper than
168
+ # paying a per-call worst-case computation.
169
+ if @scratch_out.bytesize < @scratch_out_capacity
170
+ @scratch_out << ("\x00".b * (@scratch_out_capacity - @scratch_out.bytesize))
171
+ end
172
+ written = nil
173
+ loop do
174
+ written = H2Codec::CGlue.encoder_encode_v3(@ptr.to_i, headers, @scratch_out)
175
+ break
176
+ rescue H2Codec::OutputOverflow
177
+ # Frame exceeded the running scratch capacity — double
178
+ # and retry. The grown scratch persists for subsequent
179
+ # calls so this is a one-time tax per encoder lifetime
180
+ # (per oversized frame size class).
181
+ @scratch_out_capacity *= 2
182
+ @scratch_out = String.new(capacity: @scratch_out_capacity, encoding: Encoding::ASCII_8BIT)
183
+ @scratch_out << ("\x00".b * @scratch_out_capacity)
184
+ end
185
+ # Single allocation: copy the encoded bytes out into an owned
186
+ # String. byteslice on a binary String returns a new
187
+ # ASCII-8BIT String of exactly `written` bytes.
188
+ return @scratch_out.byteslice(0, written)
189
+ end
190
+
191
+ # v2 (Fiddle) fallback — kept verbatim from fix-B (2.2.x).
192
+ # 1) Reset scratch buffers (length 0, capacity retained).
193
+ @scratch_blob.clear
194
+ argv_ints = @scratch_argv_ints
195
+ argv_ints.clear
196
+
197
+ # 2) Concatenate name+value bytes into one blob, recording
198
+ # (name_off, name_len, value_off, value_len) quads as 4 ints.
199
+ # Append to argv_ints in one go via a `pack('Q*')` at the end —
200
+ # one transient String per call instead of per header.
201
+ offset = 0
202
+ headers.each do |name, value|
203
+ # Avoid `.b` if the source is already binary-encoded — saves
204
+ # one transient String per non-binary header. For frozen
205
+ # binary literals (the common case in protocol-http2), this
206
+ # is a near-zero-cost branch.
207
+ ns = name.encoding == Encoding::ASCII_8BIT ? name : name.b
208
+ vs = value.encoding == Encoding::ASCII_8BIT ? value : value.b
209
+ name_len = ns.bytesize
210
+ val_len = vs.bytesize
211
+
212
+ argv_ints << offset << name_len << (offset + name_len) << val_len
213
+ offset += name_len + val_len
214
+ @scratch_blob << ns << vs
215
+ end
216
+
217
+ # 3) Pack all argv ints into the per-encoder scratch via the
218
+ # `pack(buffer:)` keyword — Ruby reuses the existing String's
219
+ # buffer (length-truncating to 0 first), so this is a zero-alloc
220
+ # path on the steady state. The argv ints array itself reuses
221
+ # the same Array allocation across calls (we `clear`ed it
222
+ # above; capacity is retained by RArray internals).
223
+ @scratch_argv.clear
224
+ argv_ints.pack('Q*', buffer: @scratch_argv)
225
+
226
+ argv_bytes = @scratch_argv.bytesize
227
+ blob_bytes = @scratch_blob.bytesize
228
+
229
+ # 3) Make sure the output scratch can hold the worst-case
230
+ # encoded size. Reuse the existing buffer when it already fits;
231
+ # only grow when a single frame exceeds the running capacity.
232
+ worst_case = blob_bytes + (headers.length * 8) + 64
233
+ if worst_case > @scratch_out_capacity
234
+ new_cap = @scratch_out_capacity
235
+ new_cap *= 2 while new_cap < worst_case
236
+ @scratch_out = String.new(capacity: new_cap, encoding: Encoding::ASCII_8BIT)
237
+ @scratch_out_capacity = new_cap
238
+ end
239
+
240
+ # 4) Refresh Fiddle pointers. `<<` and `clear` may have caused
241
+ # MRI to reallocate the underlying String buffer (different
242
+ # RSTRING_PTR), so the cached pointers can be stale. Refresh
243
+ # them once per encode call — three Pointer wrapper objects vs
244
+ # the v1 path's `2 * headers.length` Pointer wrappers.
245
+ @scratch_blob_ptr = Fiddle::Pointer[@scratch_blob] if blob_bytes.positive?
246
+ @scratch_argv_ptr = Fiddle::Pointer[@scratch_argv] if argv_bytes.positive?
247
+ @scratch_out_ptr = Fiddle::Pointer[@scratch_out]
248
+
249
+ # 5) One FFI call. Returns bytes_written, -1 on overflow, -2 on bad args.
250
+ written = H2Codec.encoder_encode_v2(@ptr,
251
+ @scratch_blob_ptr, blob_bytes,
252
+ @scratch_argv_ptr, headers.length,
253
+ @scratch_out_ptr, @scratch_out_capacity)
254
+ if written == -1
255
+ raise H2Codec::OutputOverflow,
256
+ "H2Codec encoder output buffer overflow (#{worst_case} bytes needed, " \
257
+ "#{@scratch_out_capacity} available)"
258
+ end
259
+ raise "H2Codec encoder failed (rc=#{written})" if written.negative?
260
+
261
+ # 6) Read `written` bytes from the C-written scratch into a
262
+ # fresh ASCII-8BIT String. `Fiddle::Pointer#to_str(len)` copies
263
+ # exactly `len` bytes once — this is the ONE unavoidable
264
+ # allocation per encode call (Ruby strings can't alias
265
+ # arbitrary memory, and the caller's contract is to receive an
266
+ # owned String). Cheaper than v1 because we copy exactly
267
+ # `len` bytes here instead of `capacity` bytes during
268
+ # pre-fill + a `byteslice` of the encoded prefix.
269
+ @scratch_out_ptr.to_str(written)
270
+ end
271
+ end
272
+
273
+ # Ruby-friendly decoder wrapper. `#decode(bytes)` → array of
274
+ # [name, value] byte pairs.
275
+ class Decoder
276
+ DECODER_SCRATCH_DEFAULT = 16_384
277
+ private_constant :DECODER_SCRATCH_DEFAULT
278
+
279
+ def initialize
280
+ raise 'H2Codec native library unavailable' unless H2Codec.available?
281
+
282
+ @ptr = H2Codec.decoder_new
283
+ ObjectSpace.define_finalizer(self, self.class.finalizer(@ptr))
284
+ # 2.4-A — per-decoder reusable scratch buffer for the v3 path.
285
+ @scratch_out = String.new(capacity: DECODER_SCRATCH_DEFAULT, encoding: Encoding::ASCII_8BIT)
286
+ @scratch_out_capacity = DECODER_SCRATCH_DEFAULT
287
+ end
288
+
289
+ def self.finalizer(ptr)
290
+ proc { H2Codec.decoder_free(ptr) if H2Codec.available? && ptr }
291
+ end
292
+
293
+ def decode(bytes)
294
+ bytes = bytes.to_s.b
295
+ return [] if bytes.empty?
296
+
297
+ # Decoded pairs can be ~larger than the wire because Huffman
298
+ # decoding inflates. 8x is a generous upper bound for RFC 7541
299
+ # — a single-bit Huffman input can decode to 8 bits but
300
+ # adding the framing bytes per pair makes 8x conservative.
301
+ capacity = (bytes.bytesize * 8) + 4096
302
+
303
+ # 2.4-A — fast path: reuse a per-decoder scratch and dispatch
304
+ # through the C glue. The Rust ABI writes `[u32 name_len][name]
305
+ # [u32 val_len][val]` repeated; we unpack that in Ruby.
306
+ # 2.11-B — `cglue_active?` overlays an operator-set v2 force.
307
+ if H2Codec.cglue_active?
308
+ if capacity > @scratch_out_capacity
309
+ new_cap = @scratch_out_capacity
310
+ new_cap *= 2 while new_cap < capacity
311
+ @scratch_out = String.new(capacity: new_cap, encoding: Encoding::ASCII_8BIT)
312
+ @scratch_out_capacity = new_cap
313
+ end
314
+ # Pad the scratch to its full capacity so RSTRING_LEN ==
315
+ # @scratch_out_capacity inside the C ext (the ext reads
316
+ # RSTRING_LEN to know the writable region size).
317
+ if @scratch_out.bytesize < @scratch_out_capacity
318
+ @scratch_out << ("\x00".b * (@scratch_out_capacity - @scratch_out.bytesize))
319
+ end
320
+ written = H2Codec::CGlue.decoder_decode_v3(@ptr.to_i, bytes, @scratch_out)
321
+ return [] if written.zero?
322
+
323
+ return unpack_headers(@scratch_out.byteslice(0, written))
324
+ end
325
+
326
+ out = (+'').b
327
+ out.force_encoding(Encoding::ASCII_8BIT)
328
+ out << ("\x00".b * capacity)
329
+
330
+ written = H2Codec.decoder_decode(@ptr, bytes, bytes.bytesize, out, capacity)
331
+ raise "H2Codec decoder failed (rc=#{written})" if written.negative?
332
+
333
+ unpack_headers(out.byteslice(0, written))
334
+ end
335
+
336
+ private
337
+
338
+ def unpack_headers(buf)
339
+ result = []
340
+ off = 0
341
+ loop do
342
+ break if off >= buf.bytesize
343
+
344
+ name_len = buf.byteslice(off, 4).unpack1('L<')
345
+ off += 4
346
+ name = buf.byteslice(off, name_len)
347
+ off += name_len
348
+ val_len = buf.byteslice(off, 4).unpack1('L<')
349
+ off += 4
350
+ value = buf.byteslice(off, val_len)
351
+ off += val_len
352
+ result << [name, value]
353
+ end
354
+ result
355
+ end
356
+ end
357
+
358
+ # ---- Internal: Fiddle binding plumbing.
359
+
360
+ # rubocop:disable Metrics/MethodLength
361
+ def self.load!
362
+ return unless @available.nil?
363
+
364
+ @available = false
365
+
366
+ path = candidate_paths.find { |p| File.exist?(p) }
367
+ unless path
368
+ @lib = nil
369
+ return
370
+ end
371
+
372
+ @lib = Fiddle.dlopen(path)
373
+
374
+ @abi_fn = Fiddle::Function.new(@lib['hyperion_h2_codec_abi_version'],
375
+ [], Fiddle::TYPE_INT)
376
+ abi = @abi_fn.call
377
+ if abi != EXPECTED_ABI
378
+ warn "[hyperion] H2Codec ABI mismatch (got #{abi}, expected #{EXPECTED_ABI}); using Ruby fallback"
379
+ @lib = nil
380
+ return
381
+ end
382
+
383
+ @encoder_new_fn = Fiddle::Function.new(@lib['hyperion_h2_codec_encoder_new'],
384
+ [], Fiddle::TYPE_VOIDP)
385
+ @encoder_free_fn = Fiddle::Function.new(@lib['hyperion_h2_codec_encoder_free'],
386
+ [Fiddle::TYPE_VOIDP], Fiddle::TYPE_VOID)
387
+ @encoder_enc_fn = Fiddle::Function.new(@lib['hyperion_h2_codec_encoder_encode'],
388
+ [Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP,
389
+ Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP, Fiddle::TYPE_INT,
390
+ Fiddle::TYPE_VOIDP, Fiddle::TYPE_INT],
391
+ Fiddle::TYPE_INT)
392
+ # fix-B (2.2.x) — v2 flat-blob encode entry.
393
+ # Signature: (handle, blob_ptr, blob_len, argv_ptr, argv_count, out_ptr, out_cap) -> i64
394
+ # Sizes are passed as size_t so Fiddle::TYPE_SIZE_T matches the
395
+ # Rust `usize` exactly on both 64-bit Linux and macOS arm64.
396
+ @encoder_enc_v2_fn = Fiddle::Function.new(@lib['hyperion_h2_codec_encoder_encode_v2'],
397
+ [Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP, Fiddle::TYPE_SIZE_T,
398
+ Fiddle::TYPE_VOIDP, Fiddle::TYPE_SIZE_T,
399
+ Fiddle::TYPE_VOIDP, Fiddle::TYPE_SIZE_T],
400
+ Fiddle::TYPE_LONG_LONG)
401
+ @decoder_new_fn = Fiddle::Function.new(@lib['hyperion_h2_codec_decoder_new'],
402
+ [], Fiddle::TYPE_VOIDP)
403
+ @decoder_free_fn = Fiddle::Function.new(@lib['hyperion_h2_codec_decoder_free'],
404
+ [Fiddle::TYPE_VOIDP], Fiddle::TYPE_VOID)
405
+ @decoder_dec_fn = Fiddle::Function.new(@lib['hyperion_h2_codec_decoder_decode'],
406
+ [Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP, Fiddle::TYPE_INT,
407
+ Fiddle::TYPE_VOIDP, Fiddle::TYPE_INT],
408
+ Fiddle::TYPE_INT)
409
+
410
+ @available = true
411
+
412
+ # 2.4-A — try to install the C glue with the same path the
413
+ # Fiddle loader just used. CGlue is defined by the bundled C
414
+ # extension (`hyperion_http/hyperion_http.bundle`); if that
415
+ # extension didn't compile or the dlopen fails, `@cglue_available`
416
+ # stays nil and Encoder/Decoder transparently fall back to the
417
+ # v2 (Fiddle) path. No warning on this branch — it's purely a
418
+ # perf optimization, not a correctness gate.
419
+ install_cglue(path)
420
+ rescue Fiddle::DLError, StandardError => e
421
+ warn "[hyperion] H2Codec failed to load (#{e.class}: #{e.message}); using Ruby fallback"
422
+ @lib = nil
423
+ @available = false
424
+ @cglue_available = false
425
+ end
426
+ # rubocop:enable Metrics/MethodLength
427
+
428
+ # 2.4-A — wire the C extension's dlopen-based path. We require the
429
+ # bundled C extension (already loaded by `c_parser.rb` at gem boot
430
+ # in normal use, but we guard the constant lookup in case someone
431
+ # required `hyperion/h2_codec` directly without the C ext). Returns
432
+ # true iff CGlue is now installed and `encoder_encode_v3` is safe
433
+ # to call.
434
+ def self.install_cglue(path)
435
+ @cglue_available = false
436
+ return unless defined?(Hyperion::H2Codec::CGlue)
437
+ return unless Hyperion::H2Codec::CGlue.respond_to?(:install)
438
+
439
+ @cglue_available = Hyperion::H2Codec::CGlue.install(path) ? true : false
440
+ rescue StandardError
441
+ @cglue_available = false
442
+ end
443
+
444
+ def self.candidate_paths
445
+ gem_lib = File.expand_path('../hyperion_h2_codec', __dir__)
446
+ ext_target = File.expand_path('../../ext/hyperion_h2_codec/target/release', __dir__)
447
+ # 2.11-B fix: order suffixes by host OS. Pre-2.11-B this was a
448
+ # static `[dylib, so]` order, which broke on Linux hosts that
449
+ # had a stale macOS `.dylib` on the path (e.g. a developer rsync
450
+ # leaking the `target/release` artifact across platforms). Fiddle
451
+ # would try the `.dylib` first, choke on the Mach-O binary with
452
+ # `ArgumentError: invalid byte sequence in UTF-8` from libffi,
453
+ # and the rescue in `load!` would silently fall back to the Ruby
454
+ # HPACK path with no warning visible to bench harnesses.
455
+ #
456
+ # Ordering by `host_os` makes Linux pick `.so` first and ignore
457
+ # any orphan `.dylib`; macOS keeps the `.dylib`-first behavior
458
+ # for back-compat with existing dev environments.
459
+ suffixes = if /darwin|mac/i.match?(RbConfig::CONFIG['host_os'])
460
+ %w[libhyperion_h2_codec.dylib libhyperion_h2_codec.so]
461
+ else
462
+ %w[libhyperion_h2_codec.so libhyperion_h2_codec.dylib]
463
+ end
464
+ suffixes.flat_map { |name| [File.join(gem_lib, name), File.join(ext_target, name)] }
465
+ end
466
+
467
+ # FFI wrappers — kept thin so callers don't see Fiddle::Pointer
468
+ # types. Each method is a one-liner that the Encoder/Decoder
469
+ # classes above invoke.
470
+ def self.encoder_new
471
+ @encoder_new_fn.call
472
+ end
473
+
474
+ def self.encoder_free(ptr)
475
+ @encoder_free_fn.call(ptr)
476
+ end
477
+
478
+ def self.encoder_encode(ptr, names, name_lens, vals, val_lens, count, out, cap)
479
+ @encoder_enc_fn.call(ptr, names, name_lens, vals, val_lens, count, out, cap)
480
+ end
481
+
482
+ # fix-B (2.2.x) — v2 flat-blob encode. See lib.rs:hyperion_h2_codec_encoder_encode_v2.
483
+ def self.encoder_encode_v2(ptr, blob, blob_len, argv, argv_count, out, out_cap)
484
+ @encoder_enc_v2_fn.call(ptr, blob, blob_len, argv, argv_count, out, out_cap)
485
+ end
486
+
487
+ def self.decoder_new
488
+ @decoder_new_fn.call
489
+ end
490
+
491
+ def self.decoder_free(ptr)
492
+ @decoder_free_fn.call(ptr)
493
+ end
494
+
495
+ def self.decoder_decode(ptr, input, in_len, out, cap)
496
+ @decoder_dec_fn.call(ptr, input, in_len, out, cap)
497
+ end
498
+ end
499
+ end
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'find'
4
+
5
+ module Hyperion
6
+ module Http
7
+ # Pre-built static-response cache. Mirrors agoo's `agooPage`
8
+ # design: each cached asset's full HTTP/1.1 response (status line +
9
+ # Content-Type + Content-Length + body) lives in ONE contiguous
10
+ # heap buffer; the hot path issues a single `write()` syscall with
11
+ # zero Ruby-side allocation.
12
+ #
13
+ # The C primitives are registered as singleton methods on this
14
+ # very module by `ext/hyperion_http/page_cache.c` (see
15
+ # `Init_hyperion_page_cache`). Surface from C:
16
+ #
17
+ # PageCache.fetch(path) -> :ok | :stale | :missing
18
+ # PageCache.cache_file(path) -> Integer | :missing
19
+ # PageCache.write_to(socket, path) -> Integer | :missing
20
+ # PageCache.set_immutable(path, bool) -> bool
21
+ # PageCache.size -> Integer
22
+ # PageCache.clear -> nil
23
+ # PageCache.recheck_seconds -> Float
24
+ # PageCache.recheck_seconds=(secs) -> Float
25
+ # PageCache.response_bytes(path) -> String|nil (specs helper)
26
+ # PageCache.body_bytes(path) -> Integer|nil (specs helper)
27
+ # PageCache.content_type(path) -> String|nil (specs helper)
28
+ # PageCache.auto_threshold -> Integer
29
+ # PageCache.max_key_len -> Integer
30
+ # PageCache.register_prebuilt(path, response_bytes, body_len) -> Integer
31
+ # (2.10-F) — register a prebuilt HTTP/1.1 response under a route
32
+ # path (no on-disk file). `body_len` tells `serve_request` where
33
+ # the body starts so HEAD requests can write headers-only.
34
+ # PageCache.serve_request(socket, method, path) -> [:ok, n] | :miss
35
+ # (2.10-F) — fold the matched-route hot path into C: hash lookup,
36
+ # write the prebuilt response (HEAD = headers only), GVL released
37
+ # during the write() syscall. Method gate: only GET and HEAD are
38
+ # eligible; anything else returns :miss so the Ruby caller can
39
+ # fall back to its non-cache path.
40
+ #
41
+ # This Ruby file extends the surface with composite helpers that
42
+ # are easier to express above the C boundary:
43
+ #
44
+ # PageCache.write_response(socket, path) — alias of #write_to
45
+ # PageCache.preload(dir, immutable: false) — recursive cache_file
46
+ # PageCache.mark_immutable(path) / .mark_mutable(path)
47
+ # PageCache.available? — feature probe (true when C ext loaded)
48
+ #
49
+ # Auto-engaged from `Hyperion::Adapter::Rack` for Rack body objects
50
+ # that respond to `:to_path` and whose file size is below
51
+ # `AUTO_THRESHOLD` (64 KiB). Above the threshold the existing
52
+ # sendfile path keeps winning (Hyperion already dominates big
53
+ # static at 9× Agoo per the 2.10-B baseline).
54
+ #
55
+ # Operators wanting predictable first-request latency can call
56
+ # `PageCache.preload` on boot to warm the cache over a tree of
57
+ # static assets.
58
+ #
59
+ # If the C extension didn't compile (e.g. JRuby, an unusual host),
60
+ # `PageCache.available?` returns false and `Hyperion::Adapter::Rack`
61
+ # skips the cache engagement.
62
+ module PageCache
63
+ # File-size auto-engage threshold. Files at or below this size
64
+ # are eligible for the page-cache path; larger files keep their
65
+ # existing sendfile route (Hyperion's win on big static).
66
+ AUTO_THRESHOLD = 64 * 1024
67
+
68
+ class << self
69
+ # Alias of {.write_to}. The plan-spec public name; calling
70
+ # convention preferred for new operator code. Returns the
71
+ # number of bytes written, or `:missing` when the path is
72
+ # not cached.
73
+ def write_response(socket, path)
74
+ write_to(socket, path)
75
+ end
76
+
77
+ # Walk a directory tree and populate the cache for every
78
+ # regular file inside it. Returns the count of files
79
+ # successfully cached. When `immutable: true`, every cached
80
+ # entry is also marked immutable so subsequent writes never
81
+ # re-stat (use for content-hashed asset bundles).
82
+ def preload(dir, immutable: false)
83
+ return 0 unless File.directory?(dir)
84
+
85
+ count = 0
86
+ Find.find(dir) do |path|
87
+ next unless File.file?(path)
88
+
89
+ result = cache_file(path)
90
+ next if result == :missing
91
+
92
+ count += 1
93
+ set_immutable(path, true) if immutable
94
+ end
95
+ count
96
+ end
97
+
98
+ # Mark a specific path as immutable: subsequent reads via
99
+ # `write_to` skip the mtime stat entirely. Returns true when
100
+ # the path was present in the cache, false otherwise.
101
+ def mark_immutable(path)
102
+ set_immutable(path, true)
103
+ end
104
+
105
+ # Mark a path as mutable (default). Subsequent reads honor
106
+ # the `recheck_seconds` mtime poll.
107
+ def mark_mutable(path)
108
+ set_immutable(path, false)
109
+ end
110
+
111
+ # Whether the C primitive successfully linked into the running
112
+ # interpreter. True on builds where parser.c compiled (the
113
+ # current 2.10-C drop on every supported host). Kept as a
114
+ # forward-looking introspection hook so JRuby / TruffleRuby
115
+ # ports can flip it false without blowing up callers.
116
+ def available?
117
+ respond_to?(:write_to)
118
+ end
119
+ end
120
+ end
121
+ end
122
+ end