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,452 @@
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
+ # Force a reload (test seam). Unsets the memoized state so the next
49
+ # `available?` call probes the filesystem again.
50
+ def self.reset!
51
+ @available = nil
52
+ @cglue_available = nil
53
+ @lib = nil
54
+ end
55
+
56
+ # Ruby-friendly wrapper around the native encoder. Single instance
57
+ # holds an opaque pointer; `#encode([['name','value'], ...])`
58
+ # returns the wire bytes. The dynamic table state is per-instance.
59
+ #
60
+ # fix-B (2.2.x) — per-encoder scratch buffers eliminate per-call
61
+ # FFI marshalling allocations. Each `Encoder` owns:
62
+ #
63
+ # * `@scratch_out` — output buffer reused across encode calls,
64
+ # grown lazily if a single frame exceeds the
65
+ # starting 16 KiB capacity.
66
+ # * `@scratch_argv` — packed `(name_off, name_len, val_off, val_len)`
67
+ # u64-quad buffer (each header is 32 bytes).
68
+ # * `@scratch_blob` — concatenated header bytes
69
+ # (name_1, value_1, name_2, value_2, …).
70
+ # * `@scratch_*_ptr` — `Fiddle::Pointer`s pre-cached for the three
71
+ # scratch strings; recreated only when the
72
+ # underlying string is reallocated by `<<`
73
+ # crossing the existing capacity.
74
+ #
75
+ # `#encode` clears the three buffers (length 0, capacity preserved),
76
+ # appends offset/length quads + raw bytes, and dispatches one FFI
77
+ # call to `hyperion_h2_codec_encoder_encode_v2`. The only unavoidable
78
+ # allocation per call is `byteslice` to extract the written bytes
79
+ # — that's the contract `protocol-http2`'s `encode_headers` returns
80
+ # under, so it can't move further.
81
+ class Encoder
82
+ SCRATCH_OUT_DEFAULT = 16_384
83
+ SCRATCH_ARGV_DEFAULT = 4_096
84
+ SCRATCH_BLOB_DEFAULT = 4_096
85
+ private_constant :SCRATCH_OUT_DEFAULT, :SCRATCH_ARGV_DEFAULT, :SCRATCH_BLOB_DEFAULT
86
+
87
+ def initialize
88
+ raise 'H2Codec native library unavailable' unless H2Codec.available?
89
+
90
+ @ptr = H2Codec.encoder_new
91
+ ObjectSpace.define_finalizer(self, self.class.finalizer(@ptr))
92
+
93
+ @scratch_out = String.new(capacity: SCRATCH_OUT_DEFAULT, encoding: Encoding::ASCII_8BIT)
94
+ @scratch_argv = String.new(capacity: SCRATCH_ARGV_DEFAULT, encoding: Encoding::ASCII_8BIT)
95
+ @scratch_blob = String.new(capacity: SCRATCH_BLOB_DEFAULT, encoding: Encoding::ASCII_8BIT)
96
+ # Pre-cache the Fiddle::Pointer so the per-call hot path
97
+ # doesn't pay a Pointer.new allocation. The pointer's address
98
+ # tracks the underlying String's buffer; if `<<` later reallocates
99
+ # the buffer we refresh the pointer and bump the recorded
100
+ # capacity.
101
+ @scratch_out_ptr = Fiddle::Pointer[@scratch_out]
102
+ @scratch_argv_ptr = Fiddle::Pointer[@scratch_argv]
103
+ @scratch_blob_ptr = Fiddle::Pointer[@scratch_blob]
104
+ @scratch_out_capacity = SCRATCH_OUT_DEFAULT
105
+ @scratch_argv_capacity = SCRATCH_ARGV_DEFAULT
106
+ @scratch_blob_capacity = SCRATCH_BLOB_DEFAULT
107
+ # Per-encoder Int array reused for `pack('Q*', buffer:)` calls.
108
+ # `clear` keeps the array but length-zeros it; the underlying
109
+ # storage capacity is retained by MRI for steady-state reuse.
110
+ @scratch_argv_ints = []
111
+ end
112
+
113
+ def self.finalizer(ptr)
114
+ proc { H2Codec.encoder_free(ptr) if H2Codec.available? && ptr }
115
+ end
116
+
117
+ def encode(headers)
118
+ return ''.b if headers.empty?
119
+
120
+ # 2.4-A — fast path: when the C glue loaded successfully,
121
+ # bypass Fiddle entirely. The C ext walks the headers array,
122
+ # builds the argv quad buffer on the C stack, and calls
123
+ # `hyperion_h2_codec_encoder_encode_v2` directly via a cached
124
+ # function pointer. The only Ruby allocation per call is the
125
+ # final `byteslice(0, written)` which copies the encoded bytes
126
+ # into a new owned String — that's the contract callers rely
127
+ # on (`protocol-http2`'s Compressor#encode returns a String,
128
+ # not a slice into shared mutable memory).
129
+ if H2Codec.cglue_available?
130
+ # Pad the scratch String with zero bytes so its length matches
131
+ # capacity — the C ext writes into RSTRING_PTR up to RSTRING_LEN
132
+ # and then truncates back via rb_str_set_len after encoding.
133
+ # The first encode pads the full SCRATCH_OUT_DEFAULT (16 KiB);
134
+ # subsequent calls find the length already at capacity and
135
+ # skip the pad entirely. On the rare oversize-frame case we
136
+ # catch OutputOverflow, grow, and retry — much cheaper than
137
+ # paying a per-call worst-case computation.
138
+ if @scratch_out.bytesize < @scratch_out_capacity
139
+ @scratch_out << ("\x00".b * (@scratch_out_capacity - @scratch_out.bytesize))
140
+ end
141
+ written = nil
142
+ loop do
143
+ written = H2Codec::CGlue.encoder_encode_v3(@ptr.to_i, headers, @scratch_out)
144
+ break
145
+ rescue H2Codec::OutputOverflow
146
+ # Frame exceeded the running scratch capacity — double
147
+ # and retry. The grown scratch persists for subsequent
148
+ # calls so this is a one-time tax per encoder lifetime
149
+ # (per oversized frame size class).
150
+ @scratch_out_capacity *= 2
151
+ @scratch_out = String.new(capacity: @scratch_out_capacity, encoding: Encoding::ASCII_8BIT)
152
+ @scratch_out << ("\x00".b * @scratch_out_capacity)
153
+ end
154
+ # Single allocation: copy the encoded bytes out into an owned
155
+ # String. byteslice on a binary String returns a new
156
+ # ASCII-8BIT String of exactly `written` bytes.
157
+ return @scratch_out.byteslice(0, written)
158
+ end
159
+
160
+ # v2 (Fiddle) fallback — kept verbatim from fix-B (2.2.x).
161
+ # 1) Reset scratch buffers (length 0, capacity retained).
162
+ @scratch_blob.clear
163
+ argv_ints = @scratch_argv_ints
164
+ argv_ints.clear
165
+
166
+ # 2) Concatenate name+value bytes into one blob, recording
167
+ # (name_off, name_len, value_off, value_len) quads as 4 ints.
168
+ # Append to argv_ints in one go via a `pack('Q*')` at the end —
169
+ # one transient String per call instead of per header.
170
+ offset = 0
171
+ headers.each do |name, value|
172
+ # Avoid `.b` if the source is already binary-encoded — saves
173
+ # one transient String per non-binary header. For frozen
174
+ # binary literals (the common case in protocol-http2), this
175
+ # is a near-zero-cost branch.
176
+ ns = name.encoding == Encoding::ASCII_8BIT ? name : name.b
177
+ vs = value.encoding == Encoding::ASCII_8BIT ? value : value.b
178
+ name_len = ns.bytesize
179
+ val_len = vs.bytesize
180
+
181
+ argv_ints << offset << name_len << (offset + name_len) << val_len
182
+ offset += name_len + val_len
183
+ @scratch_blob << ns << vs
184
+ end
185
+
186
+ # 3) Pack all argv ints into the per-encoder scratch via the
187
+ # `pack(buffer:)` keyword — Ruby reuses the existing String's
188
+ # buffer (length-truncating to 0 first), so this is a zero-alloc
189
+ # path on the steady state. The argv ints array itself reuses
190
+ # the same Array allocation across calls (we `clear`ed it
191
+ # above; capacity is retained by RArray internals).
192
+ @scratch_argv.clear
193
+ argv_ints.pack('Q*', buffer: @scratch_argv)
194
+
195
+ argv_bytes = @scratch_argv.bytesize
196
+ blob_bytes = @scratch_blob.bytesize
197
+
198
+ # 3) Make sure the output scratch can hold the worst-case
199
+ # encoded size. Reuse the existing buffer when it already fits;
200
+ # only grow when a single frame exceeds the running capacity.
201
+ worst_case = blob_bytes + (headers.length * 8) + 64
202
+ if worst_case > @scratch_out_capacity
203
+ new_cap = @scratch_out_capacity
204
+ new_cap *= 2 while new_cap < worst_case
205
+ @scratch_out = String.new(capacity: new_cap, encoding: Encoding::ASCII_8BIT)
206
+ @scratch_out_capacity = new_cap
207
+ end
208
+
209
+ # 4) Refresh Fiddle pointers. `<<` and `clear` may have caused
210
+ # MRI to reallocate the underlying String buffer (different
211
+ # RSTRING_PTR), so the cached pointers can be stale. Refresh
212
+ # them once per encode call — three Pointer wrapper objects vs
213
+ # the v1 path's `2 * headers.length` Pointer wrappers.
214
+ @scratch_blob_ptr = Fiddle::Pointer[@scratch_blob] if blob_bytes.positive?
215
+ @scratch_argv_ptr = Fiddle::Pointer[@scratch_argv] if argv_bytes.positive?
216
+ @scratch_out_ptr = Fiddle::Pointer[@scratch_out]
217
+
218
+ # 5) One FFI call. Returns bytes_written, -1 on overflow, -2 on bad args.
219
+ written = H2Codec.encoder_encode_v2(@ptr,
220
+ @scratch_blob_ptr, blob_bytes,
221
+ @scratch_argv_ptr, headers.length,
222
+ @scratch_out_ptr, @scratch_out_capacity)
223
+ if written == -1
224
+ raise H2Codec::OutputOverflow,
225
+ "H2Codec encoder output buffer overflow (#{worst_case} bytes needed, " \
226
+ "#{@scratch_out_capacity} available)"
227
+ end
228
+ raise "H2Codec encoder failed (rc=#{written})" if written.negative?
229
+
230
+ # 6) Read `written` bytes from the C-written scratch into a
231
+ # fresh ASCII-8BIT String. `Fiddle::Pointer#to_str(len)` copies
232
+ # exactly `len` bytes once — this is the ONE unavoidable
233
+ # allocation per encode call (Ruby strings can't alias
234
+ # arbitrary memory, and the caller's contract is to receive an
235
+ # owned String). Cheaper than v1 because we copy exactly
236
+ # `len` bytes here instead of `capacity` bytes during
237
+ # pre-fill + a `byteslice` of the encoded prefix.
238
+ @scratch_out_ptr.to_str(written)
239
+ end
240
+ end
241
+
242
+ # Ruby-friendly decoder wrapper. `#decode(bytes)` → array of
243
+ # [name, value] byte pairs.
244
+ class Decoder
245
+ DECODER_SCRATCH_DEFAULT = 16_384
246
+ private_constant :DECODER_SCRATCH_DEFAULT
247
+
248
+ def initialize
249
+ raise 'H2Codec native library unavailable' unless H2Codec.available?
250
+
251
+ @ptr = H2Codec.decoder_new
252
+ ObjectSpace.define_finalizer(self, self.class.finalizer(@ptr))
253
+ # 2.4-A — per-decoder reusable scratch buffer for the v3 path.
254
+ @scratch_out = String.new(capacity: DECODER_SCRATCH_DEFAULT, encoding: Encoding::ASCII_8BIT)
255
+ @scratch_out_capacity = DECODER_SCRATCH_DEFAULT
256
+ end
257
+
258
+ def self.finalizer(ptr)
259
+ proc { H2Codec.decoder_free(ptr) if H2Codec.available? && ptr }
260
+ end
261
+
262
+ def decode(bytes)
263
+ bytes = bytes.to_s.b
264
+ return [] if bytes.empty?
265
+
266
+ # Decoded pairs can be ~larger than the wire because Huffman
267
+ # decoding inflates. 8x is a generous upper bound for RFC 7541
268
+ # — a single-bit Huffman input can decode to 8 bits but
269
+ # adding the framing bytes per pair makes 8x conservative.
270
+ capacity = (bytes.bytesize * 8) + 4096
271
+
272
+ # 2.4-A — fast path: reuse a per-decoder scratch and dispatch
273
+ # through the C glue. The Rust ABI writes `[u32 name_len][name]
274
+ # [u32 val_len][val]` repeated; we unpack that in Ruby.
275
+ if H2Codec.cglue_available?
276
+ if capacity > @scratch_out_capacity
277
+ new_cap = @scratch_out_capacity
278
+ new_cap *= 2 while new_cap < capacity
279
+ @scratch_out = String.new(capacity: new_cap, encoding: Encoding::ASCII_8BIT)
280
+ @scratch_out_capacity = new_cap
281
+ end
282
+ # Pad the scratch to its full capacity so RSTRING_LEN ==
283
+ # @scratch_out_capacity inside the C ext (the ext reads
284
+ # RSTRING_LEN to know the writable region size).
285
+ if @scratch_out.bytesize < @scratch_out_capacity
286
+ @scratch_out << ("\x00".b * (@scratch_out_capacity - @scratch_out.bytesize))
287
+ end
288
+ written = H2Codec::CGlue.decoder_decode_v3(@ptr.to_i, bytes, @scratch_out)
289
+ return [] if written.zero?
290
+
291
+ return unpack_headers(@scratch_out.byteslice(0, written))
292
+ end
293
+
294
+ out = (+'').b
295
+ out.force_encoding(Encoding::ASCII_8BIT)
296
+ out << ("\x00".b * capacity)
297
+
298
+ written = H2Codec.decoder_decode(@ptr, bytes, bytes.bytesize, out, capacity)
299
+ raise "H2Codec decoder failed (rc=#{written})" if written.negative?
300
+
301
+ unpack_headers(out.byteslice(0, written))
302
+ end
303
+
304
+ private
305
+
306
+ def unpack_headers(buf)
307
+ result = []
308
+ off = 0
309
+ loop do
310
+ break if off >= buf.bytesize
311
+
312
+ name_len = buf.byteslice(off, 4).unpack1('L<')
313
+ off += 4
314
+ name = buf.byteslice(off, name_len)
315
+ off += name_len
316
+ val_len = buf.byteslice(off, 4).unpack1('L<')
317
+ off += 4
318
+ value = buf.byteslice(off, val_len)
319
+ off += val_len
320
+ result << [name, value]
321
+ end
322
+ result
323
+ end
324
+ end
325
+
326
+ # ---- Internal: Fiddle binding plumbing.
327
+
328
+ # rubocop:disable Metrics/MethodLength
329
+ def self.load!
330
+ return unless @available.nil?
331
+
332
+ @available = false
333
+
334
+ path = candidate_paths.find { |p| File.exist?(p) }
335
+ unless path
336
+ @lib = nil
337
+ return
338
+ end
339
+
340
+ @lib = Fiddle.dlopen(path)
341
+
342
+ @abi_fn = Fiddle::Function.new(@lib['hyperion_h2_codec_abi_version'],
343
+ [], Fiddle::TYPE_INT)
344
+ abi = @abi_fn.call
345
+ if abi != EXPECTED_ABI
346
+ warn "[hyperion] H2Codec ABI mismatch (got #{abi}, expected #{EXPECTED_ABI}); using Ruby fallback"
347
+ @lib = nil
348
+ return
349
+ end
350
+
351
+ @encoder_new_fn = Fiddle::Function.new(@lib['hyperion_h2_codec_encoder_new'],
352
+ [], Fiddle::TYPE_VOIDP)
353
+ @encoder_free_fn = Fiddle::Function.new(@lib['hyperion_h2_codec_encoder_free'],
354
+ [Fiddle::TYPE_VOIDP], Fiddle::TYPE_VOID)
355
+ @encoder_enc_fn = Fiddle::Function.new(@lib['hyperion_h2_codec_encoder_encode'],
356
+ [Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP,
357
+ Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP, Fiddle::TYPE_INT,
358
+ Fiddle::TYPE_VOIDP, Fiddle::TYPE_INT],
359
+ Fiddle::TYPE_INT)
360
+ # fix-B (2.2.x) — v2 flat-blob encode entry.
361
+ # Signature: (handle, blob_ptr, blob_len, argv_ptr, argv_count, out_ptr, out_cap) -> i64
362
+ # Sizes are passed as size_t so Fiddle::TYPE_SIZE_T matches the
363
+ # Rust `usize` exactly on both 64-bit Linux and macOS arm64.
364
+ @encoder_enc_v2_fn = Fiddle::Function.new(@lib['hyperion_h2_codec_encoder_encode_v2'],
365
+ [Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP, Fiddle::TYPE_SIZE_T,
366
+ Fiddle::TYPE_VOIDP, Fiddle::TYPE_SIZE_T,
367
+ Fiddle::TYPE_VOIDP, Fiddle::TYPE_SIZE_T],
368
+ Fiddle::TYPE_LONG_LONG)
369
+ @decoder_new_fn = Fiddle::Function.new(@lib['hyperion_h2_codec_decoder_new'],
370
+ [], Fiddle::TYPE_VOIDP)
371
+ @decoder_free_fn = Fiddle::Function.new(@lib['hyperion_h2_codec_decoder_free'],
372
+ [Fiddle::TYPE_VOIDP], Fiddle::TYPE_VOID)
373
+ @decoder_dec_fn = Fiddle::Function.new(@lib['hyperion_h2_codec_decoder_decode'],
374
+ [Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP, Fiddle::TYPE_INT,
375
+ Fiddle::TYPE_VOIDP, Fiddle::TYPE_INT],
376
+ Fiddle::TYPE_INT)
377
+
378
+ @available = true
379
+
380
+ # 2.4-A — try to install the C glue with the same path the
381
+ # Fiddle loader just used. CGlue is defined by the bundled C
382
+ # extension (`hyperion_http/hyperion_http.bundle`); if that
383
+ # extension didn't compile or the dlopen fails, `@cglue_available`
384
+ # stays nil and Encoder/Decoder transparently fall back to the
385
+ # v2 (Fiddle) path. No warning on this branch — it's purely a
386
+ # perf optimization, not a correctness gate.
387
+ install_cglue(path)
388
+ rescue Fiddle::DLError, StandardError => e
389
+ warn "[hyperion] H2Codec failed to load (#{e.class}: #{e.message}); using Ruby fallback"
390
+ @lib = nil
391
+ @available = false
392
+ @cglue_available = false
393
+ end
394
+ # rubocop:enable Metrics/MethodLength
395
+
396
+ # 2.4-A — wire the C extension's dlopen-based path. We require the
397
+ # bundled C extension (already loaded by `c_parser.rb` at gem boot
398
+ # in normal use, but we guard the constant lookup in case someone
399
+ # required `hyperion/h2_codec` directly without the C ext). Returns
400
+ # true iff CGlue is now installed and `encoder_encode_v3` is safe
401
+ # to call.
402
+ def self.install_cglue(path)
403
+ @cglue_available = false
404
+ return unless defined?(Hyperion::H2Codec::CGlue)
405
+ return unless Hyperion::H2Codec::CGlue.respond_to?(:install)
406
+
407
+ @cglue_available = Hyperion::H2Codec::CGlue.install(path) ? true : false
408
+ rescue StandardError
409
+ @cglue_available = false
410
+ end
411
+
412
+ def self.candidate_paths
413
+ gem_lib = File.expand_path('../hyperion_h2_codec', __dir__)
414
+ ext_target = File.expand_path('../../ext/hyperion_h2_codec/target/release', __dir__)
415
+ %w[libhyperion_h2_codec.dylib libhyperion_h2_codec.so].flat_map do |name|
416
+ [File.join(gem_lib, name), File.join(ext_target, name)]
417
+ end
418
+ end
419
+
420
+ # FFI wrappers — kept thin so callers don't see Fiddle::Pointer
421
+ # types. Each method is a one-liner that the Encoder/Decoder
422
+ # classes above invoke.
423
+ def self.encoder_new
424
+ @encoder_new_fn.call
425
+ end
426
+
427
+ def self.encoder_free(ptr)
428
+ @encoder_free_fn.call(ptr)
429
+ end
430
+
431
+ def self.encoder_encode(ptr, names, name_lens, vals, val_lens, count, out, cap)
432
+ @encoder_enc_fn.call(ptr, names, name_lens, vals, val_lens, count, out, cap)
433
+ end
434
+
435
+ # fix-B (2.2.x) — v2 flat-blob encode. See lib.rs:hyperion_h2_codec_encoder_encode_v2.
436
+ def self.encoder_encode_v2(ptr, blob, blob_len, argv, argv_count, out, out_cap)
437
+ @encoder_enc_v2_fn.call(ptr, blob, blob_len, argv, argv_count, out, out_cap)
438
+ end
439
+
440
+ def self.decoder_new
441
+ @decoder_new_fn.call
442
+ end
443
+
444
+ def self.decoder_free(ptr)
445
+ @decoder_free_fn.call(ptr)
446
+ end
447
+
448
+ def self.decoder_decode(ptr, input, in_len, out, cap)
449
+ @decoder_dec_fn.call(ptr, input, in_len, out, cap)
450
+ end
451
+ end
452
+ 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