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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +4768 -0
- data/README.md +222 -13
- data/ext/hyperion_h2_codec/Cargo.lock +7 -0
- data/ext/hyperion_h2_codec/Cargo.toml +33 -0
- data/ext/hyperion_h2_codec/extconf.rb +73 -0
- data/ext/hyperion_h2_codec/src/frames.rs +140 -0
- data/ext/hyperion_h2_codec/src/hpack/huffman.rs +161 -0
- data/ext/hyperion_h2_codec/src/hpack.rs +457 -0
- data/ext/hyperion_h2_codec/src/lib.rs +296 -0
- data/ext/hyperion_http/extconf.rb +28 -0
- data/ext/hyperion_http/h2_codec_glue.c +408 -0
- data/ext/hyperion_http/page_cache.c +1125 -0
- data/ext/hyperion_http/parser.c +473 -38
- data/ext/hyperion_http/sendfile.c +982 -0
- data/ext/hyperion_http/websocket.c +493 -0
- data/ext/hyperion_io_uring/Cargo.lock +33 -0
- data/ext/hyperion_io_uring/Cargo.toml +34 -0
- data/ext/hyperion_io_uring/extconf.rb +74 -0
- data/ext/hyperion_io_uring/src/lib.rs +316 -0
- data/lib/hyperion/adapter/rack.rb +370 -42
- data/lib/hyperion/admin_listener.rb +207 -0
- data/lib/hyperion/admin_middleware.rb +36 -7
- data/lib/hyperion/cli.rb +310 -11
- data/lib/hyperion/config.rb +440 -14
- data/lib/hyperion/connection.rb +679 -22
- data/lib/hyperion/deprecations.rb +81 -0
- data/lib/hyperion/dispatch_mode.rb +165 -0
- data/lib/hyperion/fiber_local.rb +75 -13
- data/lib/hyperion/h2_admission.rb +77 -0
- data/lib/hyperion/h2_codec.rb +499 -0
- data/lib/hyperion/http/page_cache.rb +122 -0
- data/lib/hyperion/http/sendfile.rb +696 -0
- data/lib/hyperion/http2/native_hpack_adapter.rb +70 -0
- data/lib/hyperion/http2_handler.rb +618 -19
- data/lib/hyperion/io_uring.rb +317 -0
- data/lib/hyperion/lint_wrapper_pool.rb +126 -0
- data/lib/hyperion/master.rb +96 -9
- data/lib/hyperion/metrics/path_templater.rb +68 -0
- data/lib/hyperion/metrics.rb +256 -0
- data/lib/hyperion/prometheus_exporter.rb +150 -0
- data/lib/hyperion/request.rb +13 -0
- data/lib/hyperion/response_writer.rb +477 -16
- data/lib/hyperion/runtime.rb +195 -0
- data/lib/hyperion/server/route_table.rb +179 -0
- data/lib/hyperion/server.rb +519 -55
- data/lib/hyperion/static_preload.rb +133 -0
- data/lib/hyperion/thread_pool.rb +61 -7
- data/lib/hyperion/tls.rb +343 -1
- data/lib/hyperion/version.rb +1 -1
- data/lib/hyperion/websocket/close_codes.rb +71 -0
- data/lib/hyperion/websocket/connection.rb +876 -0
- data/lib/hyperion/websocket/frame.rb +356 -0
- data/lib/hyperion/websocket/handshake.rb +525 -0
- data/lib/hyperion/worker.rb +111 -9
- data/lib/hyperion.rb +137 -3
- 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
|