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,317 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'etc'
4
+ require 'fiddle'
5
+ require 'fiddle/import'
6
+
7
+ module Hyperion
8
+ # 2.3-A — io_uring accept on Linux 5.6+ (opt-in).
9
+ #
10
+ # The biggest unmovable bottleneck below the GVL on the plaintext h1
11
+ # path is the kernel accept loop: every accept costs accept_nonblock +
12
+ # IO.select on the EAGAIN edge (two syscalls per accepted connection
13
+ # under burst). io_uring lets us submit accept SQEs and reap CQEs in
14
+ # one syscall, with the kernel batching multiple accepts in a single
15
+ # CQE drain when connections arrive faster than the fiber can consume
16
+ # them.
17
+ #
18
+ # ## Surface
19
+ #
20
+ # Hyperion::IOUring.supported? # bool — Linux ≥ 5.6 + cdylib loaded
21
+ # # + runtime probe succeeds
22
+ # Hyperion::IOUring::Ring.new(queue_depth: 256)
23
+ # # per-fiber ring; #accept(fd) → fd
24
+ # # or :wouldblock; #close releases
25
+ # # the ring's SQ/CQ memory.
26
+ #
27
+ # ## Per-fiber, NEVER per-process or per-thread
28
+ #
29
+ # io_uring under fork+threads has known sharp edges:
30
+ #
31
+ # * Submission queue is process-shared by default — under fork, the
32
+ # parent's outstanding SQEs leak into the child's CQ.
33
+ # * IORING_SETUP_SQPOLL kernel thread does not survive fork.
34
+ # * Threads sharing a ring need IORING_SETUP_SINGLE_ISSUER + careful
35
+ # submission discipline.
36
+ #
37
+ # Hyperion's safe pattern, matching the fiber-per-conn architecture:
38
+ #
39
+ # * One ring per fiber that needs it (the accept fiber, optionally
40
+ # per-connection read fibers in a future phase).
41
+ # * Ring is opened lazily on first use:
42
+ # Fiber.current[:hyperion_io_uring] ||=
43
+ # Hyperion::IOUring::Ring.new(queue_depth: 256)
44
+ # * Ring is closed when the fiber exits.
45
+ # * Workers don't share rings across fork — each child opens its own.
46
+ #
47
+ # ## Default off in 2.3.0
48
+ #
49
+ # Mirrors the 2.2.0 fix-B HYPERION_H2_NATIVE_HPACK pattern: ship the
50
+ # plumbing in 2.3.0 with the default OFF, give operators an env-var to
51
+ # A/B (HYPERION_IO_URING={on,auto}), flip the default to :auto in
52
+ # 2.4 only after 6 months of soak. io_uring code in production has
53
+ # too many sharp edges to default-on without field validation.
54
+ module IOUring
55
+ EXPECTED_ABI = 1
56
+ # Linux 5.6 stabilized IORING_OP_ACCEPT (commit 17f2fe35d080,
57
+ # mainlined Mar 2020). 5.5 had a buggy precursor that the io-uring
58
+ # crate refuses to use. We gate on 5.6 to match the crate's stance.
59
+ MIN_LINUX_KERNEL = [5, 6].freeze
60
+
61
+ class Unsupported < StandardError; end
62
+
63
+ # Per-Ring instance. Wraps the opaque pointer returned by
64
+ # `hyperion_io_uring_ring_new` and exposes the accept / read
65
+ # primitives over Fiddle.
66
+ class Ring
67
+ DEFAULT_QUEUE_DEPTH = 256
68
+
69
+ def initialize(queue_depth: DEFAULT_QUEUE_DEPTH)
70
+ raise Unsupported, 'io_uring not supported on this platform' unless IOUring.supported?
71
+
72
+ @ptr = IOUring.ring_new(queue_depth.to_i)
73
+ raise Unsupported, 'io_uring_setup failed at ring allocation' if @ptr.nil? || @ptr.null?
74
+
75
+ # `errno` scratch — reused across calls. Fiddle::Pointer to a
76
+ # 4-byte buffer that the C side writes into on error. Saves
77
+ # one Pointer allocation per accept.
78
+ @errno_buf = Fiddle::Pointer.malloc(4, Fiddle::RUBY_FREE)
79
+ @closed = false
80
+ end
81
+
82
+ # Accept one connection on `listener_fd`. Returns the integer
83
+ # client fd, or `:wouldblock` on EAGAIN. Raises on hard errors.
84
+ #
85
+ # The ring's submit_and_wait drives io_uring_enter with
86
+ # min_complete=1, so this fiber parks here until the kernel
87
+ # delivers the matching CQE. Under Async, the Ruby side calls
88
+ # this from a Fiber — the fiber is logically blocked but the
89
+ # OS thread keeps running other fibers via the scheduler ONLY
90
+ # if `submit_and_wait` itself yields. It does not yield (it's
91
+ # a syscall under FFI), so the accept fiber must be the only
92
+ # fiber with work-pending on its OS thread. In Hyperion's
93
+ # default 1-accept-fiber-per-worker shape that's always true.
94
+ def accept(listener_fd)
95
+ raise IOError, 'ring closed' if @closed
96
+
97
+ rc = IOUring.ring_accept(@ptr, listener_fd.to_i, @errno_buf)
98
+ return rc if rc.positive? || rc.zero?
99
+ return :wouldblock if rc == -1
100
+
101
+ errno = @errno_buf.to_str(4).unpack1('l<')
102
+ # ECANCELED / EBADF / EINTR → caller treats as wouldblock and
103
+ # loops. Anything else is a hard error.
104
+ return :wouldblock if [4, 9, 103, 125].include?(errno) # EINTR / EBADF / ECONNABORTED / ECANCELED
105
+
106
+ raise SystemCallError.new('io_uring accept failed', errno)
107
+ end
108
+
109
+ # Read up to `max` bytes from `fd` into a fresh ASCII-8BIT
110
+ # String. 2.3-A ships this for the accept-only path's sibling
111
+ # use (per-connection short reads); the connection layer keeps
112
+ # using regular `read_nonblock` until a future 2.3-x round wires
113
+ # io_uring reads into the request-line + header parse.
114
+ def read(fd, max: 4096)
115
+ raise IOError, 'ring closed' if @closed
116
+
117
+ buf = Fiddle::Pointer.malloc(max, Fiddle::RUBY_FREE)
118
+ rc = IOUring.ring_read(@ptr, fd.to_i, buf, max.to_i, @errno_buf)
119
+ return buf.to_str(rc) if rc >= 0
120
+ return :wouldblock if rc == -1
121
+
122
+ errno = @errno_buf.to_str(4).unpack1('l<')
123
+ raise SystemCallError.new('io_uring read failed', errno)
124
+ end
125
+
126
+ # Close the ring + free its SQ/CQ memory. Idempotent — calling
127
+ # twice is a no-op (we null-out @ptr after the first free). Must
128
+ # be called from the same fiber that opened the ring.
129
+ def close
130
+ return if @closed
131
+
132
+ @closed = true
133
+ IOUring.ring_free(@ptr) if @ptr && !@ptr.null?
134
+ @ptr = nil
135
+ end
136
+
137
+ def closed?
138
+ @closed
139
+ end
140
+ end
141
+
142
+ class << self
143
+ # Cached three-state result: nil = not-yet-probed, true/false = result.
144
+ #
145
+ # The probe is intentionally process-local (not Fiber-local) — the
146
+ # answer is the same for every fiber in this process, and probing
147
+ # once at boot avoids per-request syscall overhead.
148
+ def supported?
149
+ return @supported unless @supported.nil?
150
+
151
+ @supported = compute_supported
152
+ end
153
+
154
+ # Test seam: clear cached probe so `supported?` re-runs. Used by
155
+ # specs that stub Etc.uname or RbConfig.
156
+ def reset!
157
+ @supported = nil
158
+ @lib = nil
159
+ end
160
+
161
+ # ---- Internal: feature gate ----
162
+
163
+ def compute_supported
164
+ # Gate 1: Linux only. macOS/BSD don't have io_uring.
165
+ return false unless linux?
166
+
167
+ # Gate 2: Kernel ≥ 5.6.
168
+ return false unless kernel_supports_io_uring?
169
+
170
+ # Gate 3: cdylib loaded.
171
+ load!
172
+ return false unless @lib
173
+
174
+ # Gate 4: runtime probe — try to set up a tiny ring. Catches
175
+ # sandboxed containers (seccomp blocking io_uring_setup,
176
+ # locked-down environments returning -EPERM, kernels with
177
+ # io_uring disabled via /proc/sys/kernel/io_uring_disabled).
178
+ rc = @probe_fn.call
179
+ rc.zero?
180
+ rescue StandardError
181
+ false
182
+ end
183
+
184
+ def linux?
185
+ Etc.uname[:sysname] == 'Linux'
186
+ rescue StandardError
187
+ false
188
+ end
189
+
190
+ def kernel_supports_io_uring?
191
+ return false unless linux?
192
+
193
+ release = parse_kernel_release
194
+ return false unless release
195
+
196
+ major, minor = release
197
+ min_major, min_minor = MIN_LINUX_KERNEL
198
+ major > min_major || (major == min_major && minor >= min_minor)
199
+ end
200
+
201
+ # `Etc.uname[:release]` is the canonical source. Falls back to
202
+ # `/proc/sys/kernel/osrelease` when uname isn't available (e.g.
203
+ # specs that stub Etc.uname[:sysname] but leave release alone).
204
+ def parse_kernel_release
205
+ release = Etc.uname[:release].to_s
206
+ if release.empty? && File.exist?('/proc/sys/kernel/osrelease')
207
+ release = File.read('/proc/sys/kernel/osrelease').strip
208
+ end
209
+ m = release.match(/\A(\d+)\.(\d+)/)
210
+ return nil unless m
211
+
212
+ [m[1].to_i, m[2].to_i]
213
+ rescue StandardError
214
+ nil
215
+ end
216
+
217
+ # ---- Internal: Fiddle loader ----
218
+
219
+ def load!
220
+ return @lib if defined?(@lib) && !@lib.nil?
221
+
222
+ path = candidate_paths.find { |p| File.exist?(p) }
223
+ unless path
224
+ @lib = nil
225
+ return nil
226
+ end
227
+
228
+ @lib = Fiddle.dlopen(path)
229
+ @abi_fn = Fiddle::Function.new(@lib['hyperion_io_uring_abi_version'],
230
+ [], Fiddle::TYPE_INT)
231
+ abi = @abi_fn.call
232
+ if abi != EXPECTED_ABI
233
+ warn "[hyperion] IOUring ABI mismatch (got #{abi}, expected #{EXPECTED_ABI}); falling back"
234
+ @lib = nil
235
+ return nil
236
+ end
237
+
238
+ @probe_fn = Fiddle::Function.new(@lib['hyperion_io_uring_probe'],
239
+ [], Fiddle::TYPE_INT)
240
+ @ring_new_fn = Fiddle::Function.new(@lib['hyperion_io_uring_ring_new'],
241
+ [Fiddle::TYPE_INT], Fiddle::TYPE_VOIDP)
242
+ @ring_free_fn = Fiddle::Function.new(@lib['hyperion_io_uring_ring_free'],
243
+ [Fiddle::TYPE_VOIDP], Fiddle::TYPE_VOID)
244
+ @accept_fn = Fiddle::Function.new(@lib['hyperion_io_uring_accept'],
245
+ [Fiddle::TYPE_VOIDP, Fiddle::TYPE_INT, Fiddle::TYPE_VOIDP],
246
+ Fiddle::TYPE_INT)
247
+ @read_fn = Fiddle::Function.new(@lib['hyperion_io_uring_read'],
248
+ [Fiddle::TYPE_VOIDP, Fiddle::TYPE_INT,
249
+ Fiddle::TYPE_VOIDP, Fiddle::TYPE_INT,
250
+ Fiddle::TYPE_VOIDP],
251
+ Fiddle::TYPE_INT)
252
+ @lib
253
+ rescue Fiddle::DLError, StandardError => e
254
+ warn "[hyperion] IOUring failed to load (#{e.class}: #{e.message}); falling back to epoll"
255
+ @lib = nil
256
+ nil
257
+ end
258
+
259
+ def candidate_paths
260
+ gem_lib = File.expand_path('../hyperion_io_uring', __dir__)
261
+ ext_target = File.expand_path('../../ext/hyperion_io_uring/target/release', __dir__)
262
+ %w[libhyperion_io_uring.dylib libhyperion_io_uring.so].flat_map do |name|
263
+ [File.join(gem_lib, name), File.join(ext_target, name)]
264
+ end
265
+ end
266
+
267
+ # ---- FFI wrappers ----
268
+
269
+ def ring_new(depth)
270
+ ptr = @ring_new_fn.call(depth)
271
+ ptr.null? ? nil : ptr
272
+ end
273
+
274
+ def ring_free(ptr)
275
+ @ring_free_fn.call(ptr)
276
+ end
277
+
278
+ def ring_accept(ptr, fd, errno_buf)
279
+ @accept_fn.call(ptr, fd, errno_buf)
280
+ end
281
+
282
+ def ring_read(ptr, fd, buf, max, errno_buf)
283
+ @read_fn.call(ptr, fd, buf, max, errno_buf)
284
+ end
285
+ end
286
+
287
+ # ---- Server-side helpers ----
288
+
289
+ # Resolve the operator's `io_uring` policy + the runtime gate
290
+ # into a boolean "use io_uring on this server". Called by Server
291
+ # at boot.
292
+ #
293
+ # Policy values:
294
+ # :off → never. Returns false. Used for the 2.3.0 default.
295
+ # :auto → use it when supported; quietly fall back otherwise.
296
+ # :on → demand it. Raise UnsupportedError if not available
297
+ # so the operator's misconfig surfaces at boot, not as
298
+ # a slow-fallback mystery hours later.
299
+ def self.resolve_policy!(policy)
300
+ case policy
301
+ when :off, nil, false
302
+ false
303
+ when :auto
304
+ supported?
305
+ when :on, true
306
+ unless supported?
307
+ raise Unsupported,
308
+ 'io_uring required (io_uring: :on) but not supported on this host ' \
309
+ "(linux=#{linux?}, kernel_ok=#{kernel_supports_io_uring?}, lib_loaded=#{!@lib.nil?})"
310
+ end
311
+ true
312
+ else
313
+ raise ArgumentError, "io_uring must be :off, :auto, or :on (got #{policy.inspect})"
314
+ end
315
+ end
316
+ end
317
+ end
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rack/lint'
4
+
5
+ module Hyperion
6
+ # Phase 2a (1.7.1) — per-worker `Rack::Lint::Wrapper` pool.
7
+ #
8
+ # In dev mode (`RACK_ENV != 'production'`), Rack guidance is to wrap the
9
+ # response body with a `Rack::Lint::Wrapper` so spec violations surface
10
+ # immediately. The naive shape is one wrapper allocation per request. On a
11
+ # high-rps dev/staging fleet that's a measurable allocation tax — every
12
+ # wrapper carries 8 ivars and a non-trivial init.
13
+ #
14
+ # The pool keeps up to `MAX_POOL_SIZE` reusable wrappers per worker fiber
15
+ # scheduler. On request entry, callers `acquire(app, env)` to get a
16
+ # ready-to-go wrapper. On response close, callers `release(wrapper)` to put
17
+ # it back in the free list. The wrapper's per-request state (`@app`, `@env`,
18
+ # `@response`, status/headers/body, content-length tracking) is reset before
19
+ # reuse so each request gets clean state.
20
+ #
21
+ # Safety:
22
+ # * Production short-circuit: `acquire` always allocates fresh in
23
+ # `RACK_ENV=production` so production never carries pool overhead and
24
+ # never reuses a wrapper that's mid-iteration on another fiber.
25
+ # * Pool cap: `MAX_POOL_SIZE` bounds steady-state memory. Excess wrappers
26
+ # fall out of scope and the GC reaps them.
27
+ # * Single-thread safety: each Hyperion worker runs one fiber scheduler on
28
+ # one thread, so the underlying `Pool` is contention-free. We don't add
29
+ # a Mutex — that would be measurable overhead for zero correctness gain
30
+ # in the supported deployment shape. If a host embeds Hyperion in a
31
+ # multi-thread context the pool simply won't be reused (each thread
32
+ # allocates fresh; no corruption).
33
+ #
34
+ # Lint semantics are unchanged: every reused wrapper still validates the
35
+ # body each request via `check_environment`/`check_headers`/etc. inside
36
+ # `Rack::Lint::Wrapper#response`. The only thing reuse skips is the
37
+ # allocation itself — not the validation work.
38
+ module LintWrapperPool
39
+ MAX_POOL_SIZE = 32
40
+
41
+ # Reset hook — clear all per-request ivars on a wrapper before it goes
42
+ # back into the free list. Mirrors `Rack::Lint::Wrapper#initialize` so
43
+ # that the wrapper looks freshly-constructed on the next acquire.
44
+ RESET = lambda do |wrapper|
45
+ wrapper.instance_variable_set(:@app, nil)
46
+ wrapper.instance_variable_set(:@env, nil)
47
+ wrapper.instance_variable_set(:@response, nil)
48
+ wrapper.instance_variable_set(:@head_request, false)
49
+ wrapper.instance_variable_set(:@status, nil)
50
+ wrapper.instance_variable_set(:@headers, nil)
51
+ wrapper.instance_variable_set(:@body, nil)
52
+ wrapper.instance_variable_set(:@consumed, nil)
53
+ wrapper.instance_variable_set(:@content_length, nil)
54
+ wrapper.instance_variable_set(:@closed, false)
55
+ wrapper.instance_variable_set(:@size, 0)
56
+ wrapper
57
+ end
58
+
59
+ class << self
60
+ # Whether this process should pool Lint wrappers. False in production
61
+ # (Lint is a dev tool; production never inserts it) and false when
62
+ # explicitly disabled via `RACK_LINT_DISABLE=1` for operators who want
63
+ # to side-step the pool entirely.
64
+ def enabled?
65
+ return false if production?
66
+ return false if ENV['RACK_LINT_DISABLE'] == '1'
67
+
68
+ true
69
+ end
70
+
71
+ def production?
72
+ ENV['RACK_ENV'] == 'production'
73
+ end
74
+
75
+ # Acquire a wrapper for `(app, env)`. In production we always allocate
76
+ # fresh (skipping the pool entirely). Outside production we pop a
77
+ # reusable wrapper, rebind it to (app, env) via the reset hook + ivar
78
+ # writes, and return it ready for `#response`.
79
+ #
80
+ # The returned wrapper behaves identically to `Rack::Lint::Wrapper.new(app, env)`.
81
+ def acquire(app, env)
82
+ if enabled?
83
+ wrapper = pool.acquire
84
+ wrapper.instance_variable_set(:@app, app)
85
+ wrapper.instance_variable_set(:@env, env)
86
+ wrapper
87
+ else
88
+ ::Rack::Lint::Wrapper.new(app, env)
89
+ end
90
+ end
91
+
92
+ # Release a wrapper back to the pool. No-op in production (where
93
+ # `acquire` returned a fresh allocation that the GC will reap). The
94
+ # underlying `Hyperion::Pool` enforces MAX_POOL_SIZE; releases past
95
+ # the cap drop the wrapper on the floor.
96
+ def release(wrapper)
97
+ return unless enabled?
98
+ return unless wrapper.is_a?(::Rack::Lint::Wrapper)
99
+
100
+ pool.release(wrapper)
101
+ end
102
+
103
+ # Test seam: clear the free list so spec runs that toggle RACK_ENV
104
+ # don't see warm wrappers from a previous example.
105
+ def reset!
106
+ @pool = nil
107
+ end
108
+
109
+ # Read-only accessor for the underlying pool — used by specs to assert
110
+ # reuse without relying on `.equal?` identity through `acquire`.
111
+ def pool_size
112
+ @pool ? @pool.size : 0
113
+ end
114
+
115
+ private
116
+
117
+ def pool
118
+ @pool ||= Hyperion::Pool.new(
119
+ max_size: MAX_POOL_SIZE,
120
+ factory: -> { ::Rack::Lint::Wrapper.allocate },
121
+ reset: RESET
122
+ )
123
+ end
124
+ end
125
+ end
126
+ end
@@ -53,11 +53,16 @@ module Hyperion
53
53
  # place") doesn't accidentally send a SETTINGS entry with a nil value.
54
54
  # Empty hash → no override → Http2Handler skips the SETTINGS push.
55
55
  def self.build_h2_settings(config)
56
+ # 1.7.0 (RFC A4): read from the nested `H2Settings` subconfig.
57
+ # The flat-name forwarders on `Config` still work for callers
58
+ # holding a 1.6.x reference, but Master is in-tree so we point
59
+ # at the nested object directly to avoid the extra hop.
60
+ h2 = config.h2
56
61
  {
57
- max_concurrent_streams: config.h2_max_concurrent_streams,
58
- initial_window_size: config.h2_initial_window_size,
59
- max_frame_size: config.h2_max_frame_size,
60
- max_header_list_size: config.h2_max_header_list_size
62
+ max_concurrent_streams: h2.max_concurrent_streams,
63
+ initial_window_size: h2.initial_window_size,
64
+ max_frame_size: h2.max_frame_size,
65
+ max_header_list_size: h2.max_header_list_size
61
66
  }.compact
62
67
  end
63
68
 
@@ -72,21 +77,32 @@ module Hyperion
72
77
  @tls = tls
73
78
  @thread_count = thread_count
74
79
  @config = config || Hyperion::Config.new
80
+ # 2.0 default flip (RFC A7): if the operator hasn't already
81
+ # finalized the config (e.g. via the CLI bootstrap path), do it
82
+ # now so the worker count for the auto-cap formula is the one
83
+ # Master actually uses. `finalize!` is idempotent — a config the
84
+ # CLI already finalized passes through unchanged.
85
+ @config.finalize!(workers: @workers || 1)
75
86
  @graceful_timeout = @config.graceful_timeout || GRACEFUL_TIMEOUT_SECONDS
76
87
  @children = {} # pid => worker_index
77
88
  @next_index = 0
78
89
  @stopping = false
79
90
  @worker_model = self.class.detect_worker_model
80
91
  @listener = nil # populated only in :share mode
81
- @worker_max_rss_mb = @config.worker_max_rss_mb
82
- @worker_check_interval = @config.worker_check_interval || 30
92
+ @worker_max_rss_mb = @config.worker_health.max_rss_mb
93
+ @worker_check_interval = @config.worker_health.check_interval || 30
83
94
  @last_health_check = 0 # monotonic seconds
84
95
  @cycling = {} # pid => true while we wait for it to exit
85
96
  end
86
97
 
87
98
  def run
88
99
  install_signal_handlers
89
- bind_master_listener if @worker_model == :share
100
+ # Record master PID + export to ENV BEFORE the first fork. Workers
101
+ # inherit the env var via copy-on-write so AdminMiddleware can target
102
+ # the master regardless of whether `Process.ppid` is meaningful in
103
+ # the deployment (containerd / Docker run hyperion as PID 1, where
104
+ # ppid would point at the host's init or 0). See Hyperion.master_pid.
105
+ Hyperion.master_pid!(Process.pid)
90
106
  Hyperion.logger.info do
91
107
  {
92
108
  message: 'master starting',
@@ -108,8 +124,20 @@ module Hyperion
108
124
  # Operators use it to close shared resources (DB pools, Redis sockets)
109
125
  # so each child gets fresh connections rather than inheriting the
110
126
  # parent's open fds. Mirrors Puma's hook of the same name.
127
+ #
128
+ # IMPORTANT: must fire BEFORE the master binds its listening socket on
129
+ # `:share` mode. In `:reuseport` mode the master never binds — workers
130
+ # bind their own SO_REUSEPORT sockets after fork — so `before_fork`
131
+ # there trivially runs "before any listener exists." Pre-1.6.3 we
132
+ # bound the master listener first on `:share` and ran `before_fork`
133
+ # afterwards, which made the two worker models hand off the lifecycle
134
+ # asymmetrically: an operator using `before_fork` to mutate listening
135
+ # behaviour saw a different world depending on host OS. Binding here
136
+ # restores symmetry — in both modes `before_fork` precedes any socket.
111
137
  @config.before_fork.each(&:call)
112
138
 
139
+ bind_master_listener if @worker_model == :share
140
+
113
141
  @workers.times { spawn_worker }
114
142
 
115
143
  supervise
@@ -132,6 +160,41 @@ module Hyperion
132
160
  end
133
161
  end
134
162
  @shutdown_pipe = shutdown_r
163
+ install_tls_rotation_handler
164
+ end
165
+
166
+ # Wire the master-side handler for the configured TLS ticket-key
167
+ # rotation signal (default SIGUSR2). When the operator (or an
168
+ # automated rotation cron) sends SIGUSR2 to the master, we re-emit
169
+ # it to every live child so each worker flushes its session cache
170
+ # and OpenSSL rolls a fresh ticket-encryption key.
171
+ #
172
+ # The master deliberately does NOT mutate its own listener context
173
+ # in `:share` mode — the listening fd is shared across children, so
174
+ # the children's per-context flushes already cover the resumption
175
+ # pool. This keeps the master accept-loop free.
176
+ def install_tls_rotation_handler
177
+ return unless @tls
178
+
179
+ sig = @config.tls.ticket_key_rotation_signal
180
+ return if sig.nil? || sig == :NONE
181
+
182
+ Signal.trap(sig.to_s) do
183
+ @children.each_key do |pid|
184
+ Process.kill(sig.to_s, pid)
185
+ rescue StandardError
186
+ # Worker already exiting / reaped — the next reap_and_respawn
187
+ # cycle will replace it; rotation does not block on liveness.
188
+ nil
189
+ end
190
+ end
191
+ rescue ArgumentError
192
+ Hyperion.logger.warn do
193
+ {
194
+ message: 'invalid tls.ticket_key_rotation_signal on master; rotation disabled',
195
+ signal: @config.tls.ticket_key_rotation_signal
196
+ }
197
+ end
135
198
  end
136
199
 
137
200
  # Bind the listening socket in the master so children inherit the fd
@@ -143,7 +206,9 @@ module Hyperion
143
206
  @port = tcp.addr[1]
144
207
 
145
208
  if @tls
146
- ctx = TLS.context(cert: @tls[:cert], key: @tls[:key])
209
+ ctx = TLS.context(cert: @tls[:cert], key: @tls[:key],
210
+ session_cache_size: @config.tls.session_cache_size,
211
+ ktls: @config.tls.ktls)
147
212
  ssl_server = ::OpenSSL::SSL::SSLServer.new(tcp, ctx)
148
213
  ssl_server.start_immediately = false
149
214
  @listener = ssl_server
@@ -167,7 +232,29 @@ module Hyperion
167
232
  max_pending: @config.max_pending,
168
233
  max_request_read_seconds: @config.max_request_read_seconds,
169
234
  h2_settings: Master.build_h2_settings(@config),
170
- async_io: @config.async_io
235
+ async_io: @config.async_io,
236
+ # 1.7.0 RFC additive plumbing — all default to current
237
+ # behaviour when the operator hasn't opted in.
238
+ accept_fibers_per_worker: @config.accept_fibers_per_worker,
239
+ h2_max_total_streams: @config.h2.max_total_streams,
240
+ admin_listener_port: @config.admin.listener_port,
241
+ admin_listener_host: @config.admin.listener_host,
242
+ admin_token: @config.admin.token,
243
+ # 1.8.0 Phase 4 — TLS session resumption knobs.
244
+ tls_session_cache_size: @config.tls.session_cache_size,
245
+ tls_ticket_key_rotation_signal: @config.tls.ticket_key_rotation_signal,
246
+ # 2.2.0 Phase 9 — kernel TLS_TX policy.
247
+ tls_ktls: @config.tls.ktls,
248
+ # 2.3-A — io_uring accept policy.
249
+ io_uring: @config.io_uring,
250
+ # 2.3-B — per-conn fairness cap + TLS handshake CPU throttle.
251
+ max_in_flight_per_conn: @config.max_in_flight_per_conn,
252
+ tls_handshake_rate_limit: @config.tls.handshake_rate_limit,
253
+ # 2.10-E — boot-time static asset preload list (resolved from
254
+ # operator config + Rails auto-detect at master boot, not in
255
+ # each child, so the spec/log line for auto-detect appears once
256
+ # per cluster rather than once per worker).
257
+ preload_static_dirs: @config.resolved_preload_static_dirs
171
258
  }
172
259
  # Hand the inherited socket to the worker in :share mode. In
173
260
  # :reuseport mode the worker binds its own with SO_REUSEPORT.
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hyperion
4
+ class Metrics
5
+ # 2.4-C — turn raw request paths into low-cardinality templates so the
6
+ # per-route histogram doesn't blow up to one label-set per `/users/<id>`.
7
+ #
8
+ # The default rules collapse `/users/123` → `/users/:id` and
9
+ # `/orders/3fa85f64-5717-4562-b3fc-2c963f66afa6` → `/orders/:uuid`. They
10
+ # cover the bulk of real-world REST paths; operators with Rails-style
11
+ # routes (`/articles/cool-slug-2024`) plug in their own rules via
12
+ # `Hyperion::Config#metrics.path_templater = MyTemplater.new`.
13
+ #
14
+ # An LRU cache keyed on the raw path side-steps repeating the regex walk
15
+ # on every keep-alive request to the same handler. 1000 entries is sized
16
+ # for typical Rails-shape apps (sub-1000 unique route templates); apps
17
+ # with more should pass `lru_size:` explicitly.
18
+ class PathTemplater
19
+ DEFAULT_RULES = [
20
+ [/\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/i, ':uuid'],
21
+ [/\b\d+\b/, ':id']
22
+ ].freeze
23
+
24
+ DEFAULT_LRU_SIZE = 1000
25
+
26
+ attr_reader :lru_size
27
+
28
+ def initialize(rules: DEFAULT_RULES, lru_size: DEFAULT_LRU_SIZE)
29
+ @rules = rules
30
+ @lru_size = lru_size
31
+ @cache = {} # Insertion-ordered Hash doubles as an LRU.
32
+ @mutex = Mutex.new
33
+ end
34
+
35
+ # Translate a raw request path into its template form. The result
36
+ # is memoized in the LRU; a cache hit is a single Hash#[] +
37
+ # re-insert (touch). On miss we run the regex chain and trim the
38
+ # oldest entry if we exceed `lru_size`.
39
+ def template(path)
40
+ return path if path.nil? || path.empty?
41
+
42
+ @mutex.synchronize do
43
+ if (cached = @cache.delete(path))
44
+ # Re-insert to mark "recently used" (Ruby Hashes preserve
45
+ # insertion order, oldest = first key).
46
+ @cache[path] = cached
47
+ return cached
48
+ end
49
+
50
+ templated = compute(path)
51
+ @cache[path] = templated
52
+ @cache.shift if @cache.size > @lru_size
53
+ templated
54
+ end
55
+ end
56
+
57
+ def cache_size
58
+ @mutex.synchronize { @cache.size }
59
+ end
60
+
61
+ private
62
+
63
+ def compute(path)
64
+ @rules.reduce(path) { |p, (regex, replacement)| p.gsub(regex, replacement) }
65
+ end
66
+ end
67
+ end
68
+ end