hyperion-rb 1.6.2 → 2.10.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +4563 -0
- data/README.md +189 -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 +452 -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 +368 -9
- 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,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
|
data/lib/hyperion/master.rb
CHANGED
|
@@ -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:
|
|
58
|
-
initial_window_size:
|
|
59
|
-
max_frame_size:
|
|
60
|
-
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.
|
|
82
|
-
@worker_check_interval = @config.
|
|
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
|
-
|
|
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
|