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,356 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hyperion
4
+ # WS-3 (2.1.0) — RFC 6455 frame ser/de + XOR-unmask primitives.
5
+ #
6
+ # This module is intentionally narrow: it owns "given a buffer of socket
7
+ # bytes, parse one frame" and "given an opcode + payload, build a
8
+ # serialized frame". WS-1 (the hijacked-socket loop) and WS-2 (the
9
+ # handshake) compose these primitives — they don't reach into
10
+ # Hyperion::WebSocket::CFrame directly.
11
+ #
12
+ # Two layers:
13
+ #
14
+ # * `Hyperion::WebSocket::CFrame` — the C ext singleton (defined by
15
+ # ext/hyperion_http/websocket.c when the .bundle/.so loads).
16
+ # Methods: `unmask(payload, key)`, `parse(buf, offset = 0)`,
17
+ # `build(opcode, payload, fin:, mask:, mask_key:)`.
18
+ #
19
+ # * `Hyperion::WebSocket::Parser` / `::Builder` — Ruby façades over
20
+ # the C calls with a more idiomatic API (Frame structs, Symbol
21
+ # opcodes, ProtocolError on malformed frames).
22
+ #
23
+ # If the C ext is unavailable (JRuby, TruffleRuby, a build where
24
+ # extconf.rb fell back gracefully) the same module names are bound to
25
+ # a pure-Ruby fallback declared at the bottom of this file. The
26
+ # fallback is correct but slow (≥ 5× slower XOR than C) — it's a
27
+ # safety net so the gem never refuses to require.
28
+ module WebSocket
29
+ # Per-message struct returned by `Parser.parse`. `opcode` is a
30
+ # Symbol (`:text`, `:binary`, …); `payload` is a freshly-allocated
31
+ # binary String already unmasked. `rsv1` is the per-message-deflate
32
+ # marker (RFC 7692 §6); always false on parsed control frames (the
33
+ # parser would have errored out), only ever true on text/binary/
34
+ # continuation frames when the connection negotiated the extension.
35
+ Frame = Struct.new(:fin, :opcode, :payload, :rsv1, keyword_init: true) do
36
+ # Defaults — keep `Frame.new(fin:, opcode:, payload:)` working for
37
+ # the (overwhelming) majority of call sites that don't care about
38
+ # rsv1. New WS-2.3 callers pass `rsv1:` explicitly when building a
39
+ # compressed frame.
40
+ def initialize(fin:, opcode:, payload:, rsv1: false)
41
+ super(fin: fin, opcode: opcode, payload: payload, rsv1: rsv1)
42
+ end
43
+ end
44
+
45
+ # Symbolic opcode table. Reverse table built lazily for the
46
+ # parse-side lookup. Frozen so accidental mutation can't corrupt
47
+ # the parse hot path.
48
+ OPCODES = {
49
+ continuation: 0x0,
50
+ text: 0x1,
51
+ binary: 0x2,
52
+ close: 0x8,
53
+ ping: 0x9,
54
+ pong: 0xA
55
+ }.freeze
56
+ OPCODE_NAMES = OPCODES.invert.freeze
57
+
58
+ class ProtocolError < StandardError; end
59
+
60
+ NATIVE_AVAILABLE = defined?(::Hyperion::WebSocket::CFrame) &&
61
+ ::Hyperion::WebSocket::CFrame.respond_to?(:parse)
62
+
63
+ # 2.4-B (S5): the empty-payload Frame body. Pre-2.4-B every empty
64
+ # text/binary/control frame allocated `(+'').b` — two String
65
+ # allocations (the unfrozen `+''` and its `.b` re-encoding clone).
66
+ # Frames carry frozen empty payloads idempotently — a downstream
67
+ # caller that mutates would have been silently broken pre-2.4-B
68
+ # because the allocation was already non-shared. Sharing one frozen
69
+ # binary empty String per process is a strict win.
70
+ EMPTY_BIN_PAYLOAD = String.new('', encoding: Encoding::ASCII_8BIT).freeze
71
+
72
+ # 2.4-B (S4): pre-allocated Encoding identity check. `payload.b`
73
+ # always allocates a new String, even when payload is already
74
+ # ASCII-8BIT. Skipping the no-op clone saves one String per send().
75
+ BINARY_ENCODING = Encoding::ASCII_8BIT
76
+
77
+ module Parser
78
+ # Parse one frame out of `buf` starting at `offset`. Does NOT mutate
79
+ # or consume `buf` — the caller is responsible for advancing its
80
+ # cursor by `frame_total_len` (see `parse_with_cursor` below).
81
+ #
82
+ # Returns:
83
+ # * a Hyperion::WebSocket::Frame on success
84
+ # * `:incomplete` if `buf[offset..]` doesn't yet hold a full frame
85
+ #
86
+ # Raises Hyperion::WebSocket::ProtocolError on malformed frames
87
+ # (RSV bits set without a negotiated extension, unknown opcode,
88
+ # control frame > 125 bytes, fragmented control frame, 64-bit
89
+ # length with high bit set).
90
+ def self.parse(buf, offset = 0)
91
+ result = ::Hyperion::WebSocket::CFrame.parse(buf, offset)
92
+ return result if result == :incomplete
93
+
94
+ raise ProtocolError, 'malformed WebSocket frame' if result == :error
95
+
96
+ fin, opcode, payload_len, masked, mask_key, payload_offset, _frame_total_len, rsv1 = result
97
+
98
+ opcode_sym = OPCODE_NAMES[opcode] ||
99
+ raise(ProtocolError, "unknown opcode 0x#{opcode.to_s(16)}")
100
+
101
+ payload =
102
+ if payload_len.zero?
103
+ # 2.4-B (S5): share one frozen empty binary String across
104
+ # every empty-payload frame instead of allocating
105
+ # `(+'').b` (= 2 strings) per parse.
106
+ EMPTY_BIN_PAYLOAD
107
+ else
108
+ slice = buf.byteslice(payload_offset, payload_len)
109
+ # 2.4-B (S5): when the input @inbuf is ASCII-8BIT (which
110
+ # WS::Connection guarantees: see `@inbuf = String.new(
111
+ # capacity:, encoding: ASCII_8BIT)`), `slice.b` is a no-op
112
+ # that allocates a redundant String clone. Skip when the
113
+ # source slice already has the right encoding.
114
+ if masked
115
+ ::Hyperion::WebSocket::CFrame.unmask(slice, mask_key)
116
+ elsif slice.encoding == BINARY_ENCODING
117
+ slice
118
+ else
119
+ slice.b
120
+ end
121
+ end
122
+
123
+ Frame.new(fin: fin, opcode: opcode_sym, payload: payload, rsv1: rsv1 ? true : false)
124
+ end
125
+
126
+ # Lower-level variant exposing the raw 7-tuple from the C parser
127
+ # AND the cursor advance the caller should apply. WS-1's read loop
128
+ # uses this form to drain multiple frames out of a single buffer
129
+ # in one pass without re-parsing the leading bytes.
130
+ #
131
+ # Returns `[Frame, frame_total_len]` on success, `:incomplete` if
132
+ # not enough bytes have arrived yet. Raises ProtocolError on
133
+ # malformed input.
134
+ def self.parse_with_cursor(buf, offset = 0)
135
+ result = ::Hyperion::WebSocket::CFrame.parse(buf, offset)
136
+ return result if result == :incomplete
137
+
138
+ raise ProtocolError, 'malformed WebSocket frame' if result == :error
139
+
140
+ fin, opcode, payload_len, masked, mask_key, payload_offset, frame_total_len, rsv1 = result
141
+
142
+ opcode_sym = OPCODE_NAMES[opcode] ||
143
+ raise(ProtocolError, "unknown opcode 0x#{opcode.to_s(16)}")
144
+
145
+ payload =
146
+ if payload_len.zero?
147
+ # 2.4-B (S5): share one frozen empty binary String. See
148
+ # parse() above for the rationale.
149
+ EMPTY_BIN_PAYLOAD
150
+ else
151
+ slice = buf.byteslice(payload_offset, payload_len)
152
+ if masked
153
+ ::Hyperion::WebSocket::CFrame.unmask(slice, mask_key)
154
+ elsif slice.encoding == BINARY_ENCODING
155
+ slice
156
+ else
157
+ slice.b
158
+ end
159
+ end
160
+
161
+ [
162
+ Frame.new(fin: fin, opcode: opcode_sym, payload: payload, rsv1: rsv1 ? true : false),
163
+ frame_total_len
164
+ ]
165
+ end
166
+ end
167
+
168
+ module Builder
169
+ # Build a serialized frame ready for `socket.write`.
170
+ #
171
+ # opcode — Symbol from OPCODES.keys (`:text`, `:binary`, …)
172
+ # OR Integer (raw opcode bits).
173
+ # payload — String. Coerced to ASCII-8BIT internally so callers
174
+ # may pass UTF-8 text frames without manual conversion.
175
+ # fin — true/false. RFC 6455 §5.4 fragmentation: false on all
176
+ # but the final frame of a multi-frame message.
177
+ # mask — server frames are unmasked (default). Clients MUST
178
+ # pass mask: true and a 4-byte mask_key.
179
+ #
180
+ # Control frames (close/ping/pong) MUST have payload <= 125 bytes
181
+ # and MUST have fin: true; the C builder raises ArgumentError if
182
+ # those invariants are violated, which we re-raise as-is.
183
+ def self.build(opcode:, payload: '', fin: true, mask: false, mask_key: nil, rsv1: false)
184
+ opcode_int = opcode.is_a?(Symbol) ? OPCODES.fetch(opcode) : Integer(opcode)
185
+ # 2.4-B (S4): skip the redundant `.b` re-encoding when the
186
+ # caller already passes ASCII-8BIT. Servers echoing inbound
187
+ # WebSocket frames (chat, ActionCable, /echo benchmarks) all
188
+ # have binary-encoded payloads at this point — the previous
189
+ # unconditional `.b` allocated a String per send() that just
190
+ # equalled the input.
191
+ bin_payload =
192
+ if payload.is_a?(String)
193
+ payload.encoding == BINARY_ENCODING ? payload : payload.b
194
+ else
195
+ payload.to_s.b
196
+ end
197
+
198
+ if mask && mask_key.nil?
199
+ # Caller didn't supply a key — generate one with SecureRandom
200
+ # so client-side tests / scripted clients don't have to.
201
+ require 'securerandom'
202
+ mask_key = SecureRandom.bytes(4)
203
+ end
204
+
205
+ ::Hyperion::WebSocket::CFrame.build(
206
+ opcode_int,
207
+ bin_payload,
208
+ fin: fin,
209
+ mask: mask,
210
+ mask_key: mask_key,
211
+ rsv1: rsv1
212
+ )
213
+ end
214
+ end
215
+
216
+ # Pure-Ruby fallback used when the C ext is missing. Same public
217
+ # surface as `CFrame` so the Parser / Builder façades above don't
218
+ # need to branch on `NATIVE_AVAILABLE` per call. Performance is
219
+ # ~5–10× worse on XOR — fine for a safety net, fine for JRuby
220
+ # interop, NOT recommended for the production hot path.
221
+ module RubyFrame
222
+ module_function
223
+
224
+ def unmask(payload, key)
225
+ raise ArgumentError, 'mask_key must be 4 bytes' if key.bytesize != 4
226
+
227
+ out = String.new(capacity: payload.bytesize, encoding: Encoding::BINARY)
228
+ bytes = payload.bytes
229
+ kbytes = key.bytes
230
+ bytes.each_with_index do |b, i|
231
+ out << (b ^ kbytes[i & 0x3]).chr
232
+ end
233
+ out
234
+ end
235
+
236
+ def parse(buf, offset = 0)
237
+ return :incomplete if offset > buf.bytesize
238
+
239
+ avail = buf.bytesize - offset
240
+ return :incomplete if avail < 2
241
+
242
+ b0 = buf.getbyte(offset)
243
+ b1 = buf.getbyte(offset + 1)
244
+
245
+ fin = (b0 & 0x80) != 0
246
+ rsv1 = (b0 & 0x40) != 0
247
+ rsv2 = (b0 & 0x20) != 0
248
+ rsv3 = (b0 & 0x10) != 0
249
+ opcode = b0 & 0x0F
250
+ masked = (b1 & 0x80) != 0
251
+ len7 = b1 & 0x7F
252
+
253
+ # RFC 7692 §6: RSV1 is the permessage-deflate marker. Allow it
254
+ # through in the parse tuple; the Connection wrapper rejects
255
+ # RSV1 when no extension was negotiated. RSV2/RSV3 are reserved
256
+ # with no defined semantics → reject.
257
+ return :error if rsv2 || rsv3
258
+
259
+ return :error unless [0x0, 0x1, 0x2, 0x8, 0x9, 0xA].include?(opcode)
260
+
261
+ if opcode >= 0x8
262
+ return :error unless fin
263
+ return :error if len7 > 125
264
+ # RFC 7692 §6.1 — control frames MUST NOT be compressed.
265
+ return :error if rsv1
266
+ end
267
+
268
+ header_len = 2
269
+ payload_len =
270
+ case len7
271
+ when 0..125
272
+ len7
273
+ when 126
274
+ return :incomplete if avail < header_len + 2
275
+
276
+ v = (buf.getbyte(offset + 2) << 8) | buf.getbyte(offset + 3)
277
+ header_len += 2
278
+ v
279
+ else
280
+ return :incomplete if avail < header_len + 8
281
+
282
+ return :error if (buf.getbyte(offset + 2) & 0x80) != 0
283
+
284
+ v = 0
285
+ 8.times { |i| v = (v << 8) | buf.getbyte(offset + 2 + i) }
286
+ header_len += 8
287
+ v
288
+ end
289
+
290
+ mask_key = nil
291
+ if masked
292
+ return :incomplete if avail < header_len + 4
293
+
294
+ mask_key = buf.byteslice(offset + header_len, 4)
295
+ header_len += 4
296
+ end
297
+
298
+ payload_offset = offset + header_len
299
+ frame_total_len = header_len + payload_len
300
+ return :incomplete if avail < frame_total_len
301
+
302
+ [fin, opcode, payload_len, masked, mask_key, payload_offset, frame_total_len, rsv1]
303
+ end
304
+
305
+ def build(opcode, payload, fin: true, mask: false, mask_key: nil, rsv1: false)
306
+ raise ArgumentError, "unknown opcode 0x#{opcode.to_s(16)}" unless [0x0, 0x1, 0x2, 0x8, 0x9,
307
+ 0xA].include?(opcode)
308
+
309
+ payload_len = payload.bytesize
310
+
311
+ if opcode >= 0x8
312
+ raise ArgumentError, 'control frame must have fin=true' unless fin
313
+ raise ArgumentError, 'control frame payload exceeds 125 bytes' if payload_len > 125
314
+ raise ArgumentError, 'control frame must not have rsv1=true' if rsv1
315
+ end
316
+
317
+ if mask
318
+ raise ArgumentError, 'mask: true requires a 4-byte mask_key' if mask_key.nil?
319
+ raise ArgumentError, 'mask_key must be 4 bytes' if mask_key.bytesize != 4
320
+ end
321
+
322
+ out = String.new(encoding: Encoding::BINARY)
323
+ out << ((fin ? 0x80 : 0x00) | (rsv1 ? 0x40 : 0x00) | (opcode & 0x0F)).chr
324
+ mask_bit = mask ? 0x80 : 0x00
325
+
326
+ if payload_len < 126
327
+ out << (mask_bit | payload_len).chr
328
+ elsif payload_len <= 0xFFFF
329
+ out << (mask_bit | 126).chr
330
+ out << ((payload_len >> 8) & 0xFF).chr
331
+ out << (payload_len & 0xFF).chr
332
+ else
333
+ out << (mask_bit | 127).chr
334
+ 8.times { |i| out << ((payload_len >> ((7 - i) * 8)) & 0xFF).chr }
335
+ end
336
+
337
+ if mask
338
+ out << mask_key.b
339
+ out << unmask(payload.b, mask_key) # XOR is symmetric
340
+ else
341
+ out << payload.b
342
+ end
343
+
344
+ out
345
+ end
346
+ end
347
+
348
+ # If the C ext didn't load, point CFrame at the Ruby fallback. We
349
+ # do this AFTER defining the façades above so they can call
350
+ # `CFrame.parse` etc. uniformly.
351
+ unless defined?(::Hyperion::WebSocket::CFrame) &&
352
+ ::Hyperion::WebSocket::CFrame.respond_to?(:parse)
353
+ CFrame = RubyFrame
354
+ end
355
+ end
356
+ end