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,525 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'base64'
4
+ require 'digest/sha1'
5
+
6
+ module Hyperion
7
+ # WS-2 (2.1.0) — RFC 6455 §1.3 / §4.2 HTTP/1.1 → WebSocket handshake.
8
+ #
9
+ # This module is intentionally narrow: given a Rack env (Hyperion's adapter
10
+ # has just built it from the parsed Hyperion::Request), validate that the
11
+ # request is a well-formed WebSocket upgrade attempt and compute the
12
+ # `Sec-WebSocket-Accept` header value the client expects to see in the
13
+ # 101 response. Hyperion does NOT write the 101 itself — that's the
14
+ # application's responsibility (faye-websocket / ActionCable convention,
15
+ # Option B in the WS-2 plan). All Hyperion does is:
16
+ #
17
+ # 1. Detect upgrade requests (Connection: upgrade + Upgrade: websocket)
18
+ # 2. Validate them per RFC 6455 §4.2.1
19
+ # 3. Stash the result in env['hyperion.websocket.handshake'] so the app
20
+ # (or a middleware like rack-websocket) can echo the right
21
+ # Sec-WebSocket-Accept header without re-doing the SHA-1/base64 dance
22
+ # 4. Short-circuit a 400 / 426 on validation failure BEFORE the app
23
+ # sees the env (the app shouldn't have to know about malformed
24
+ # WS handshake attempts — that's protocol-level)
25
+ #
26
+ # WS-1 supplies the hijack primitive (env['rack.hijack'].call → live
27
+ # socket); WS-3 supplies frame ser/de (Hyperion::WebSocket::Parser /
28
+ # ::Builder); WS-4 will compose all three into a Hyperion::WebSocket::Connection
29
+ # wrapper. WS-2 owns ONLY the HTTP-side handshake.
30
+ module WebSocket
31
+ # GUID from RFC 6455 §1.3 — concatenated with the client's
32
+ # Sec-WebSocket-Key, SHA-1'd, base64'd to compute the accept value.
33
+ GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'
34
+
35
+ # RFC 6455 §4.2.1: only protocol version 13 is supported. The handshake
36
+ # responds 426 Upgrade Required + `Sec-WebSocket-Version: 13` so the
37
+ # client knows the right version to retry with.
38
+ SUPPORTED_VERSION = '13'
39
+
40
+ # Public end-user API: applications / middleware can `rescue` this to
41
+ # know "this was a WS upgrade attempt that failed at validation, NOT
42
+ # an unrelated 4xx". WS-2 itself doesn't raise this — it's supplied
43
+ # for downstream consumers who want to build their own WS facade on
44
+ # top of `Handshake.validate`.
45
+ class HandshakeError < StandardError
46
+ attr_reader :status, :extra_headers
47
+
48
+ def initialize(status, message, extra_headers = {})
49
+ super(message)
50
+ @status = status
51
+ @extra_headers = extra_headers
52
+ end
53
+ end
54
+
55
+ module Handshake
56
+ # Common-case header keys looked up in env. All UPPER_SNAKE; values
57
+ # come from the Rack adapter's HTTP_KEY_CACHE (frozen) so straight
58
+ # `env[KEY]` is cheaper than building the key per call.
59
+ UPGRADE_KEY = 'HTTP_UPGRADE'
60
+ CONNECTION_KEY = 'HTTP_CONNECTION'
61
+ WS_KEY_KEY = 'HTTP_SEC_WEBSOCKET_KEY'
62
+ WS_VERSION_KEY = 'HTTP_SEC_WEBSOCKET_VERSION'
63
+ WS_PROTO_KEY = 'HTTP_SEC_WEBSOCKET_PROTOCOL'
64
+ WS_EXT_KEY = 'HTTP_SEC_WEBSOCKET_EXTENSIONS'
65
+ ORIGIN_KEY = 'HTTP_ORIGIN'
66
+ HOST_KEY = 'HTTP_HOST'
67
+ METHOD_KEY = 'REQUEST_METHOD'
68
+ PROTO_KEY = 'SERVER_PROTOCOL'
69
+
70
+ # Phase 11 — frozen sentinel returned by `validate` for plain HTTP
71
+ # requests (the overwhelmingly common branch). Pre-Phase-11 the
72
+ # function allocated a fresh `[:not_websocket, nil, nil]` Array on
73
+ # every non-WS request — one Array per HTTP request. The caller
74
+ # only reads `.first` and `case` on the tag, never mutates the
75
+ # tuple, so a frozen shared instance is safe.
76
+ #
77
+ # 2.3-C: the handshake result tuple now has a 4th slot (`extensions`)
78
+ # for permessage-deflate parameters. Existing destructuring of
79
+ # `[:ok, accept, sub]` is unchanged; the 4th slot is appended and
80
+ # ignored by 3-arg callers. The `:not_websocket` sentinel keeps the
81
+ # 4-slot shape with a frozen empty hash so `.frozen?` invariants on
82
+ # the slot stay stable.
83
+ NOT_WEBSOCKET_RESULT = [:not_websocket, nil, nil, {}].freeze
84
+ EMPTY_EXTENSIONS = {}.freeze
85
+
86
+ # RFC 7692 §7.1 — permessage-deflate extension token + parameter
87
+ # names. We accept these spellings only (case-sensitive per RFC).
88
+ PERMESSAGE_DEFLATE = 'permessage-deflate'
89
+ PARAM_SERVER_NO_TAKEOVER = 'server_no_context_takeover'
90
+ PARAM_CLIENT_NO_TAKEOVER = 'client_no_context_takeover'
91
+ PARAM_SERVER_MAX_WINDOW = 'server_max_window_bits'
92
+ PARAM_CLIENT_MAX_WINDOW = 'client_max_window_bits'
93
+
94
+ # RFC 7692 §7.1.2.2 — window_bits range. RFC says 8..15, but
95
+ # zlib's raw deflate rejects window_bits=8 in some versions; we
96
+ # clamp to 9..15 in practice, the lower bound matches what
97
+ # browsers actually use.
98
+ MIN_WINDOW_BITS = 9
99
+ MAX_WINDOW_BITS = 15
100
+ DEFAULT_WINDOW_BITS = 15
101
+
102
+ # Validate WS-upgrade preconditions on a Rack env.
103
+ #
104
+ # Returns a 4-tuple. The first slot is a Symbol tag the caller
105
+ # branches on:
106
+ #
107
+ # [:ok, accept_header_value, selected_subprotocol_or_nil,
108
+ # negotiated_extensions]
109
+ # — request is a valid RFC 6455 §4.2.1 handshake. Caller should
110
+ # stash the tuple in env and let the app handle the 101.
111
+ # `negotiated_extensions` is a Hash keyed by extension symbol;
112
+ # `{}` when no extension was negotiated. For permessage-deflate
113
+ # (RFC 7692) the value carries the resolved parameter set:
114
+ # {
115
+ # permessage_deflate: {
116
+ # server_no_context_takeover: false,
117
+ # client_no_context_takeover: false,
118
+ # server_max_window_bits: 15,
119
+ # client_max_window_bits: 15
120
+ # }
121
+ # }
122
+ #
123
+ # [:bad_request, body, extra_headers]
124
+ # — request is a WS upgrade attempt with a protocol error
125
+ # (missing/invalid Sec-WebSocket-Key, wrong method, etc.).
126
+ # Caller short-circuits a 400.
127
+ #
128
+ # [:upgrade_required, body, extra_headers]
129
+ # — Sec-WebSocket-Version is missing or not 13.
130
+ # `extra_headers` always includes `'sec-websocket-version' => '13'`
131
+ # so the client sees the version Hyperion supports.
132
+ # Caller short-circuits a 426 (RFC 6455 §4.4).
133
+ #
134
+ # [:not_websocket, nil, nil]
135
+ # — request is not a WS upgrade (no Upgrade header, or Upgrade:
136
+ # value other than `websocket`). Caller proceeds with the
137
+ # normal HTTP flow. We don't trip on h2c / other Upgrade
138
+ # variants — only `websocket` is intercepted.
139
+ #
140
+ # Optional kwargs:
141
+ #
142
+ # subprotocol_selector — a Proc that receives the array of
143
+ # client-offered subprotocols (parsed from
144
+ # Sec-WebSocket-Protocol). Returns:
145
+ # * a String matching one of the offers → echoed back in the
146
+ # Sec-WebSocket-Protocol response header
147
+ # * nil → no Sec-WebSocket-Protocol header (server silently
148
+ # declines, RFC 6455 §4.2.2)
149
+ # * a String NOT matching any offer → treated as nil (server
150
+ # MUST NOT pick a protocol the client didn't offer)
151
+ #
152
+ # origin_allow_list — an Array of allowed Origin header values.
153
+ # When nil (default), any Origin (including missing) is accepted
154
+ # — browsers enforce CORS-style restrictions on the WS upgrade
155
+ # independently. Pass [] to reject all browser-originated WS,
156
+ # pass ['https://example.com'] to allow only that origin.
157
+ def self.validate(env, subprotocol_selector: nil, origin_allow_list: default_origin_allow_list,
158
+ permessage_deflate: :auto)
159
+ return NOT_WEBSOCKET_RESULT unless websocket_upgrade?(env)
160
+
161
+ # Once we've decided this IS a WS attempt, every subsequent
162
+ # validation failure is a 4xx, NOT a passthrough. The order
163
+ # below mirrors RFC 6455 §4.2.1's MUST list.
164
+
165
+ return bad_request('WebSocket upgrade requires GET') unless env[METHOD_KEY] == 'GET'
166
+
167
+ proto = env[PROTO_KEY].to_s
168
+ unless proto.start_with?('HTTP/') &&
169
+ http_version_at_least_1_1?(proto)
170
+ return bad_request('WebSocket upgrade requires HTTP/1.1+')
171
+ end
172
+
173
+ host = env[HOST_KEY]
174
+ return bad_request('Host header required') if host.nil? || host.empty?
175
+
176
+ # Sec-WebSocket-Version check before Sec-WebSocket-Key so a
177
+ # client speaking the old hixie-76 / draft-08 dialect gets the
178
+ # 426 hint to upgrade rather than a generic 400 on the missing
179
+ # key (the old dialect uses different key headers).
180
+ version = env[WS_VERSION_KEY]
181
+ unless version == SUPPORTED_VERSION
182
+ return [
183
+ :upgrade_required,
184
+ "Unsupported Sec-WebSocket-Version (need #{SUPPORTED_VERSION})",
185
+ { 'sec-websocket-version' => SUPPORTED_VERSION }
186
+ ]
187
+ end
188
+
189
+ client_key = env[WS_KEY_KEY]
190
+ return bad_request('Sec-WebSocket-Key required') if client_key.nil? || client_key.empty?
191
+
192
+ return bad_request('Sec-WebSocket-Key must decode to 16 bytes') unless valid_client_key?(client_key)
193
+
194
+ if origin_allow_list && !origin_allowed?(env[ORIGIN_KEY], origin_allow_list)
195
+ return bad_request('Origin not in allow-list')
196
+ end
197
+
198
+ accept = accept_value(client_key)
199
+ subprotocol = pick_subprotocol(env[WS_PROTO_KEY], subprotocol_selector)
200
+
201
+ # RFC 7692 negotiation. Returns either a {permessage_deflate: {...}}
202
+ # hash or `EMPTY_EXTENSIONS`. With `permessage_deflate: :on` and
203
+ # no client offer, returns the bad_request tuple itself — the
204
+ # operator opted into "compression-required" semantics.
205
+ extensions = negotiate_extensions(env[WS_EXT_KEY], permessage_deflate)
206
+ return extensions if extensions.is_a?(Array) && extensions.first == :bad_request
207
+
208
+ [:ok, accept, subprotocol, extensions]
209
+ end
210
+
211
+ # Compute the Sec-WebSocket-Accept value per RFC 6455 §4.2.2:
212
+ # base64( SHA1( client_key + GUID ) ).
213
+ #
214
+ # Test vector: key="dGhlIHNhbXBsZSBub25jZQ==" → "s3pPLMBiTxaQ9kYGzzhZRbK+xOo="
215
+ def self.accept_value(client_key)
216
+ Base64.strict_encode64(Digest::SHA1.digest("#{client_key}#{GUID}"))
217
+ end
218
+
219
+ # Build the wire bytes of the 101 Switching Protocols response.
220
+ # Apps that don't want to hand-roll headers can call this and write
221
+ # the result to `env['rack.hijack'].call` (the raw socket) before
222
+ # sending any frames. Header keys are lowercased (RFC 7230 says
223
+ # field names are case-insensitive; lowercasing matches what every
224
+ # other Hyperion writer does).
225
+ #
226
+ # accept_value — String, the value of Sec-WebSocket-Accept
227
+ # subprotocol — String or nil, echoed back in
228
+ # Sec-WebSocket-Protocol when non-nil
229
+ # extra_headers — Hash<String,String>, any additional 101
230
+ # headers (e.g. Sec-WebSocket-Extensions for
231
+ # permessage-deflate, when negotiated by the app)
232
+ def self.build_101_response(accept_value, subprotocol = nil, extra_headers = {})
233
+ lines = String.new(encoding: Encoding::ASCII_8BIT)
234
+ lines << "HTTP/1.1 101 Switching Protocols\r\n"
235
+ lines << "upgrade: websocket\r\n"
236
+ lines << "connection: Upgrade\r\n"
237
+ lines << "sec-websocket-accept: #{accept_value}\r\n"
238
+ lines << "sec-websocket-protocol: #{subprotocol}\r\n" if subprotocol
239
+ extra_headers.each do |k, v|
240
+ lines << "#{k.to_s.downcase}: #{v}\r\n"
241
+ end
242
+ lines << "\r\n"
243
+ lines
244
+ end
245
+
246
+ # Render the negotiated `extensions` hash from `validate` as the
247
+ # `sec-websocket-extensions` header value the server should echo
248
+ # back in the 101 response. Returns nil when nothing was
249
+ # negotiated (caller should omit the header). Operators can pass
250
+ # the result straight into the `extra_headers` slot of
251
+ # `build_101_response`:
252
+ #
253
+ # ext_value = Handshake.format_extensions_header(extensions)
254
+ # extras = ext_value ? { 'sec-websocket-extensions' => ext_value } : {}
255
+ # socket.write(Handshake.build_101_response(accept, sub, extras))
256
+ def self.format_extensions_header(extensions)
257
+ return nil if extensions.nil? || extensions.empty?
258
+
259
+ params = extensions[:permessage_deflate]
260
+ return nil if params.nil?
261
+
262
+ parts = [PERMESSAGE_DEFLATE]
263
+ parts << PARAM_SERVER_NO_TAKEOVER if params[:server_no_context_takeover]
264
+ parts << PARAM_CLIENT_NO_TAKEOVER if params[:client_no_context_takeover]
265
+ # Only echo window-bits parameters if the negotiated value
266
+ # differs from the RFC default of 15. RFC 7692 §7.1.2.1 says
267
+ # the absence of the parameter means 15 bits; including it
268
+ # redundantly is allowed but adds wire bytes for no win.
269
+ if (server_max = params[:server_max_window_bits]) && server_max != DEFAULT_WINDOW_BITS
270
+ parts << "#{PARAM_SERVER_MAX_WINDOW}=#{server_max}"
271
+ end
272
+ if (client_max = params[:client_max_window_bits]) && client_max != DEFAULT_WINDOW_BITS
273
+ parts << "#{PARAM_CLIENT_MAX_WINDOW}=#{client_max}"
274
+ end
275
+ parts.join('; ')
276
+ end
277
+
278
+ # Default origin allow-list. nil = accept any origin (the safe
279
+ # default for backend services where browsers enforce CORS-style
280
+ # restrictions independently). Operators can override via the env
281
+ # var fallback `HYPERION_WS_ORIGIN_ALLOW_LIST` (comma-separated)
282
+ # without needing to thread a Hyperion::Config DSL change in.
283
+ def self.default_origin_allow_list
284
+ raw = ENV.fetch('HYPERION_WS_ORIGIN_ALLOW_LIST', nil)
285
+ return nil if raw.nil? || raw.empty?
286
+
287
+ raw.split(',').map(&:strip).reject(&:empty?)
288
+ end
289
+
290
+ # --- internals below ---------------------------------------------
291
+
292
+ def self.websocket_upgrade?(env)
293
+ upgrade = env[UPGRADE_KEY]
294
+ return false if upgrade.nil? || upgrade.empty?
295
+
296
+ # RFC 6455 §4.1: Upgrade may carry a comma-separated token list
297
+ # in theory, but in practice browsers always send a single
298
+ # token. Accept either; we just need `websocket` to appear.
299
+ return false unless tokenize(upgrade).any? { |t| t.casecmp?('websocket') }
300
+
301
+ # Connection MUST contain `upgrade` (case-insensitive) but the
302
+ # full value can be a token list like `keep-alive, Upgrade` —
303
+ # particularly common from older Firefox + some proxy chains.
304
+ connection = env[CONNECTION_KEY]
305
+ return false if connection.nil? || connection.empty?
306
+
307
+ tokenize(connection).any? { |t| t.casecmp?('upgrade') }
308
+ end
309
+ private_class_method :websocket_upgrade?
310
+
311
+ def self.tokenize(header_value)
312
+ header_value.to_s.split(',').map(&:strip)
313
+ end
314
+ private_class_method :tokenize
315
+
316
+ def self.http_version_at_least_1_1?(proto)
317
+ # proto is like "HTTP/1.1" or "HTTP/2.0" — split off the version
318
+ # tail and compare numerically. RFC 6455 says "HTTP/1.1 or higher".
319
+ version = proto.sub('HTTP/', '')
320
+ major_s, minor_s = version.split('.', 2)
321
+ major = major_s.to_i
322
+ minor = minor_s.to_i
323
+ return true if major > 1
324
+ return false if major < 1
325
+
326
+ minor >= 1
327
+ end
328
+ private_class_method :http_version_at_least_1_1?
329
+
330
+ # RFC 6455 §4.1: client key MUST be a base64-encoded random nonce of
331
+ # 16 bytes. Validate by decoding strictly (no newlines, no padding
332
+ # tolerance) and asserting decoded length. A key like
333
+ # `not-base64!!` decodes to garbage of arbitrary length, so we
334
+ # rescue ArgumentError from strict_decode64 and treat it as invalid.
335
+ def self.valid_client_key?(client_key)
336
+ decoded = Base64.strict_decode64(client_key)
337
+ decoded.bytesize == 16
338
+ rescue ArgumentError
339
+ false
340
+ end
341
+ private_class_method :valid_client_key?
342
+
343
+ def self.origin_allowed?(origin, allow_list)
344
+ # An empty / missing Origin is allowed only if the allow-list
345
+ # explicitly includes nil or the empty string. This matches the
346
+ # principle of "browsers send Origin, non-browsers may not — if
347
+ # you've configured an allow-list, you've decided non-browsers
348
+ # don't get a free pass".
349
+ return true if allow_list.nil?
350
+
351
+ allow_list.any? { |allowed| allowed == origin }
352
+ end
353
+ private_class_method :origin_allowed?
354
+
355
+ def self.pick_subprotocol(header_value, selector)
356
+ return nil if selector.nil?
357
+ return nil if header_value.nil? || header_value.empty?
358
+
359
+ offered = tokenize(header_value)
360
+ return nil if offered.empty?
361
+
362
+ chosen = selector.call(offered)
363
+ return nil unless chosen.is_a?(String)
364
+ return nil unless offered.include?(chosen)
365
+
366
+ chosen
367
+ end
368
+ private_class_method :pick_subprotocol
369
+
370
+ def self.bad_request(message)
371
+ [:bad_request, message, {}]
372
+ end
373
+ private_class_method :bad_request
374
+
375
+ # RFC 7692 negotiation. `header_value` is the raw request-side
376
+ # `Sec-WebSocket-Extensions` header (may be nil / empty / multi-
377
+ # offer). `policy` is one of:
378
+ #
379
+ # :off — server never advertises permessage-deflate; returns
380
+ # EMPTY_EXTENSIONS regardless of client offers.
381
+ # :auto — accept if the client offered any usable variant of
382
+ # permessage-deflate; otherwise return EMPTY_EXTENSIONS.
383
+ # This is the safe default — backwards compatible with
384
+ # clients that don't offer the extension.
385
+ # :on — require permessage-deflate. If the client didn't offer
386
+ # a usable variant, return a bad_request tuple so the
387
+ # caller short-circuits a 400. Operators only flip this
388
+ # on when they've measured savings on their workload AND
389
+ # controlled the client population.
390
+ #
391
+ # Per RFC 7692 §5.1 the request header may carry multiple offers
392
+ # separated by commas (e.g. `permessage-deflate; server_no_context_takeover,
393
+ # permessage-deflate`). We pick the FIRST offer we can satisfy —
394
+ # this matches the RFC's "the first acceptable extension" guidance
395
+ # and gives clients a deterministic ordering.
396
+ def self.negotiate_extensions(header_value, policy)
397
+ return EMPTY_EXTENSIONS if policy == :off
398
+
399
+ offers = parse_extension_offers(header_value)
400
+ deflate_offers = offers.select { |o| o[:name] == PERMESSAGE_DEFLATE }
401
+
402
+ if deflate_offers.empty?
403
+ return bad_request('permessage-deflate required but not offered') if policy == :on
404
+
405
+ return EMPTY_EXTENSIONS
406
+ end
407
+
408
+ # Try offers in order; first one we can satisfy wins.
409
+ deflate_offers.each do |offer|
410
+ accepted = accept_deflate_offer(offer)
411
+ return { permessage_deflate: accepted } if accepted
412
+ end
413
+
414
+ # All offers had params we can't satisfy.
415
+ return bad_request('no acceptable permessage-deflate parameter set') if policy == :on
416
+
417
+ EMPTY_EXTENSIONS
418
+ end
419
+ private_class_method :negotiate_extensions
420
+
421
+ # RFC 7692 §5.1 — parse `Sec-WebSocket-Extensions` header into
422
+ # an Array of `{ name: String, params: { String => String|true } }`
423
+ # hashes. The parser is forgiving: garbage parameter values are
424
+ # logged-and-skipped (we set the param to a sentinel marker the
425
+ # acceptor rejects), not raised. Multiple offers are
426
+ # comma-separated; each offer's params are semicolon-separated;
427
+ # each param is `name` (boolean) or `name=value` (string).
428
+ def self.parse_extension_offers(header_value)
429
+ return [] if header_value.nil? || header_value.empty?
430
+
431
+ offers = []
432
+ header_value.split(',').each do |raw_offer|
433
+ tokens = raw_offer.split(';').map(&:strip).reject(&:empty?)
434
+ next if tokens.empty?
435
+
436
+ name = tokens.shift
437
+ params = {}
438
+ tokens.each do |token|
439
+ k, v = token.split('=', 2).map(&:strip)
440
+ next if k.nil? || k.empty?
441
+
442
+ params[k] = if v.nil?
443
+ true
444
+ else
445
+ # Trim optional quoted-string per RFC 7692 §5.1.
446
+ v.start_with?('"') && v.end_with?('"') && v.length >= 2 ? v[1..-2] : v
447
+ end
448
+ end
449
+ offers << { name: name, params: params }
450
+ end
451
+ offers
452
+ end
453
+ private_class_method :parse_extension_offers
454
+
455
+ # Try to accept one parsed permessage-deflate offer. Returns the
456
+ # resolved param hash on success, or nil if any param is
457
+ # unrecognized / out of range (we silently skip — the next offer
458
+ # may be acceptable, or the policy may downgrade to no-extension).
459
+ def self.accept_deflate_offer(offer)
460
+ accepted = {
461
+ server_no_context_takeover: false,
462
+ client_no_context_takeover: false,
463
+ server_max_window_bits: DEFAULT_WINDOW_BITS,
464
+ client_max_window_bits: DEFAULT_WINDOW_BITS
465
+ }
466
+
467
+ offer[:params].each do |key, value|
468
+ case key
469
+ when PARAM_SERVER_NO_TAKEOVER
470
+ return nil unless value == true
471
+
472
+ accepted[:server_no_context_takeover] = true
473
+ when PARAM_CLIENT_NO_TAKEOVER
474
+ return nil unless value == true
475
+
476
+ accepted[:client_no_context_takeover] = true
477
+ when PARAM_SERVER_MAX_WINDOW
478
+ # Server-side window bits — the client requesting an upper
479
+ # bound on what we use. Accept if in range; we never set a
480
+ # value larger than the client asked for.
481
+ bits = window_bits_or_nil(value)
482
+ return nil if bits.nil?
483
+
484
+ accepted[:server_max_window_bits] = bits
485
+ when PARAM_CLIENT_MAX_WINDOW
486
+ # Client-side window bits — RFC 7692 §7.1.2.2: this
487
+ # parameter MAY appear without a value (just the token,
488
+ # meaning "client supports any bit size; pick one"). When
489
+ # it has a value, accept up to that value.
490
+ if value == true
491
+ # Client advertises support; we don't request a smaller
492
+ # window because there's no operational reason to (memory
493
+ # is on the client side).
494
+ accepted[:client_max_window_bits] = DEFAULT_WINDOW_BITS
495
+ else
496
+ bits = window_bits_or_nil(value)
497
+ return nil if bits.nil?
498
+
499
+ accepted[:client_max_window_bits] = bits
500
+ end
501
+ else
502
+ # Unknown parameter — RFC 7692 §5.1 says reject the offer
503
+ # entirely. Skip; the caller will try the next offer.
504
+ return nil
505
+ end
506
+ end
507
+
508
+ accepted
509
+ end
510
+ private_class_method :accept_deflate_offer
511
+
512
+ # Validate a window_bits value String. Returns the Integer when
513
+ # in [MIN_WINDOW_BITS..MAX_WINDOW_BITS], nil otherwise.
514
+ def self.window_bits_or_nil(value)
515
+ return nil unless value.is_a?(String) && value.match?(/\A\d+\z/)
516
+
517
+ bits = value.to_i
518
+ return nil if bits < MIN_WINDOW_BITS || bits > MAX_WINDOW_BITS
519
+
520
+ bits
521
+ end
522
+ private_class_method :window_bits_or_nil
523
+ end
524
+ end
525
+ end
@@ -20,7 +20,17 @@ module Hyperion
20
20
  thread_count: Server::DEFAULT_THREAD_COUNT,
21
21
  config: nil, worker_index: 0, listener: nil,
22
22
  max_pending: nil, max_request_read_seconds: 60,
23
- h2_settings: nil, async_io: nil)
23
+ h2_settings: nil, async_io: nil, runtime: nil,
24
+ accept_fibers_per_worker: 1, h2_max_total_streams: nil,
25
+ admin_listener_port: nil, admin_listener_host: '127.0.0.1',
26
+ admin_token: nil,
27
+ tls_session_cache_size: TLS::DEFAULT_SESSION_CACHE_SIZE,
28
+ tls_ticket_key_rotation_signal: :USR2,
29
+ tls_ktls: :auto,
30
+ io_uring: :off,
31
+ max_in_flight_per_conn: nil,
32
+ tls_handshake_rate_limit: :unlimited,
33
+ preload_static_dirs: nil)
24
34
  @host = host
25
35
  @port = port
26
36
  @app = app
@@ -34,6 +44,19 @@ module Hyperion
34
44
  @max_request_read_seconds = max_request_read_seconds
35
45
  @h2_settings = h2_settings
36
46
  @async_io = async_io
47
+ @runtime = runtime
48
+ @accept_fibers_per_worker = accept_fibers_per_worker
49
+ @h2_max_total_streams = h2_max_total_streams
50
+ @admin_listener_port = admin_listener_port
51
+ @admin_listener_host = admin_listener_host
52
+ @admin_token = admin_token
53
+ @tls_session_cache_size = tls_session_cache_size
54
+ @tls_ticket_key_rotation_signal = tls_ticket_key_rotation_signal
55
+ @tls_ktls = tls_ktls
56
+ @io_uring = io_uring
57
+ @max_in_flight_per_conn = max_in_flight_per_conn
58
+ @tls_handshake_rate_limit = tls_handshake_rate_limit
59
+ @preload_static_dirs = preload_static_dirs
37
60
  end
38
61
 
39
62
  def run
@@ -53,22 +76,62 @@ module Hyperion
53
76
  max_pending: @max_pending,
54
77
  max_request_read_seconds: @max_request_read_seconds,
55
78
  h2_settings: @h2_settings,
56
- async_io: @async_io)
79
+ async_io: @async_io,
80
+ runtime: @runtime,
81
+ accept_fibers_per_worker: @accept_fibers_per_worker,
82
+ h2_max_total_streams: @h2_max_total_streams,
83
+ admin_listener_port: @admin_listener_port,
84
+ admin_listener_host: @admin_listener_host,
85
+ admin_token: @admin_token,
86
+ tls_session_cache_size: @tls_session_cache_size,
87
+ tls_ktls: @tls_ktls,
88
+ io_uring: @io_uring,
89
+ max_in_flight_per_conn: @max_in_flight_per_conn,
90
+ tls_handshake_rate_limit: @tls_handshake_rate_limit,
91
+ preload_static_dirs: @preload_static_dirs)
92
+
93
+ # `on_worker_boot` runs in the child after fork, BEFORE the worker
94
+ # adopts/binds its listener and before any accept. App code reconnects
95
+ # DB/Redis pools here so each worker has its own. Index identifies the
96
+ # slot (0..workers-1) so apps can shard background work if they want.
97
+ #
98
+ # Pre-1.6.3 this hook fired AFTER the listener was adopted (`:share`)
99
+ # or freshly bound with SO_REUSEPORT (`:reuseport`). On `:reuseport`
100
+ # that meant the kernel could queue inbound connections to the
101
+ # worker's listen socket while the operator's hook was still warming
102
+ # up DB pools — observable as first-request latency spikes against
103
+ # an unready handler. Firing the hook before listener setup makes the
104
+ # two worker models behave identically: no socket exists for this
105
+ # worker until the boot hook has returned.
106
+ @config.on_worker_boot.each { |h| h.call(@worker_index) }
107
+
108
+ # 2.4-C: register the io_uring active gauge for this worker.
109
+ # `Hyperion::IOUring.resolve_policy!` was already called by the
110
+ # Server constructor; if it returned true, this worker is using
111
+ # the io_uring accept path and the gauge value is 1.
112
+ io_uring_active = @io_uring != :off && Hyperion::IOUring.resolve_policy!(@io_uring) ? 1 : 0
113
+ Hyperion.metrics.set_gauge(
114
+ :hyperion_io_uring_workers_active,
115
+ io_uring_active,
116
+ [Process.pid.to_s]
117
+ )
118
+
57
119
  tcp_server = @listener || build_reuseport_listener
58
120
  server.adopt_listener(tcp_server)
59
121
 
60
122
  Signal.trap('TERM') { server.stop }
61
123
  Signal.trap('INT') { server.stop }
62
-
63
- # `on_worker_boot` runs in the child after fork, after the listener is
64
- # ready, and before we start accepting. App code reconnects DB/Redis
65
- # pools here so each worker has its own. Index identifies the slot
66
- # (0..workers-1) so apps can shard background work if they want.
67
- @config.on_worker_boot.each { |h| h.call(@worker_index) }
124
+ install_tls_rotation_signal_handler(server)
68
125
 
69
126
  begin
70
127
  server.start
71
128
  ensure
129
+ # 2.4-C: clear the per-worker io_uring gauge slot at shutdown
130
+ # so post-shutdown scrapes don't claim the policy is still
131
+ # active for this worker.
132
+ Hyperion.metrics.set_gauge(:hyperion_io_uring_workers_active,
133
+ 0,
134
+ [Process.pid.to_s])
72
135
  # `on_worker_shutdown` fires when the accept loop exits — either
73
136
  # due to graceful SIGTERM or a hard error. Use it to flush metrics,
74
137
  # close DB connections cleanly, etc.
@@ -87,7 +150,9 @@ module Hyperion
87
150
  sock.listen(::Socket::SOMAXCONN)
88
151
 
89
152
  if @tls
90
- ctx = Hyperion::TLS.context(cert: @tls[:cert], key: @tls[:key], chain: @tls[:chain])
153
+ ctx = Hyperion::TLS.context(cert: @tls[:cert], key: @tls[:key], chain: @tls[:chain],
154
+ session_cache_size: @tls_session_cache_size,
155
+ ktls: @tls_ktls)
91
156
  ssl = ::OpenSSL::SSL::SSLServer.new(sock, ctx)
92
157
  ssl.start_immediately = false
93
158
  ssl
@@ -97,5 +162,42 @@ module Hyperion
97
162
  sock
98
163
  end
99
164
  end
165
+
166
+ # Wire the TLS ticket-key rotation signal (default SIGUSR2) to call
167
+ # `Hyperion::TLS.rotate!` against the per-worker SSLContext. The
168
+ # signal is broadcast by the master on operator demand or on the
169
+ # worker's own initiative; either way the receiving worker flushes
170
+ # its session cache so subsequent connections can no longer resume
171
+ # against pre-rotation entries.
172
+ #
173
+ # When the operator picks `:NONE` the trap is skipped — the default
174
+ # SIG_DFL handler stays in place and the worker keeps the original
175
+ # session cache for its full lifetime.
176
+ def install_tls_rotation_signal_handler(server)
177
+ return unless @tls
178
+ return if @tls_ticket_key_rotation_signal.nil?
179
+ return if @tls_ticket_key_rotation_signal == :NONE
180
+
181
+ sig = @tls_ticket_key_rotation_signal.to_s
182
+ Signal.trap(sig) do
183
+ ctx = server.ssl_ctx
184
+ ::Hyperion::TLS.rotate!(ctx) if ctx
185
+ rescue StandardError
186
+ # Signal handlers run in the main thread context; swallowing
187
+ # here avoids a corrupted-trap state if `flush_sessions` raises
188
+ # against an in-progress flush from a previous signal.
189
+ nil
190
+ end
191
+ rescue ArgumentError
192
+ # Operator passed a bogus signal name (`:DOES_NOT_EXIST`). Log
193
+ # and continue — rotation off is acceptable, the worker should
194
+ # not refuse to boot over a knob typo.
195
+ Hyperion.logger.warn do
196
+ {
197
+ message: 'invalid tls_ticket_key_rotation_signal; rotation disabled',
198
+ signal: @tls_ticket_key_rotation_signal
199
+ }
200
+ end
201
+ end
100
202
  end
101
203
  end