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
data/lib/hyperion/server.rb
CHANGED
|
@@ -5,6 +5,8 @@ require 'openssl'
|
|
|
5
5
|
require 'async'
|
|
6
6
|
require 'async/scheduler'
|
|
7
7
|
|
|
8
|
+
require_relative 'server/route_table'
|
|
9
|
+
|
|
8
10
|
module Hyperion
|
|
9
11
|
# Phase 2a server: bind a TCPServer, accept connections, schedule each on its
|
|
10
12
|
# own fiber via Async. Multiple in-flight requests run concurrently on a
|
|
@@ -38,11 +40,145 @@ module Hyperion
|
|
|
38
40
|
(head + body).freeze
|
|
39
41
|
}.call
|
|
40
42
|
|
|
41
|
-
attr_reader :host, :port
|
|
43
|
+
attr_reader :host, :port, :runtime
|
|
44
|
+
|
|
45
|
+
# 2.10-D — process-wide direct-dispatch route table. Operators
|
|
46
|
+
# register routes via `Hyperion::Server.handle(:GET, '/hello',
|
|
47
|
+
# handler)` BEFORE forking workers; each forked worker inherits
|
|
48
|
+
# the populated table via copy-on-write. Per-Server instances
|
|
49
|
+
# can override by passing `route_table:` to the constructor (a
|
|
50
|
+
# test seam — production code uses the class singleton).
|
|
51
|
+
#
|
|
52
|
+
# Lazily initialized so `require 'hyperion'` itself doesn't pay
|
|
53
|
+
# the allocation when the operator never registers a direct
|
|
54
|
+
# route (the common 1.x deployment).
|
|
55
|
+
def self.route_table
|
|
56
|
+
@route_table ||= RouteTable.new
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Test seam: replace the process-wide route table with a fresh
|
|
60
|
+
# (or stub) instance. Used by `direct_route_spec.rb` so each
|
|
61
|
+
# example starts from an empty table without needing to call
|
|
62
|
+
# `clear` (which would interfere with parallel registration
|
|
63
|
+
# tests).
|
|
64
|
+
class << self
|
|
65
|
+
attr_writer :route_table
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# 2.10-D — register a direct-dispatch handler. Bypasses the Rack
|
|
69
|
+
# adapter on hit: when a request whose method + path matches
|
|
70
|
+
# this entry arrives, `Connection#serve` skips the env-hash
|
|
71
|
+
# build, the middleware chain, and the body-iteration loop —
|
|
72
|
+
# the handler is called directly with a `Hyperion::Request`
|
|
73
|
+
# value object.
|
|
74
|
+
#
|
|
75
|
+
# `method_sym` is one of `:GET`, `:POST`, `:PUT`, `:DELETE`,
|
|
76
|
+
# `:HEAD`, `:PATCH`, `:OPTIONS` (case-insensitive — `:get`
|
|
77
|
+
# works too). `path` is an exact-match String (regex / glob
|
|
78
|
+
# routing is intentionally out of scope; future work).
|
|
79
|
+
# `handler` is any object responding to `#call(request)` that
|
|
80
|
+
# returns a `[status, headers, body]` Rack tuple.
|
|
81
|
+
#
|
|
82
|
+
# Lifecycle hooks (`Runtime#on_request_start` /
|
|
83
|
+
# `on_request_end`) still fire on direct routes so NewRelic /
|
|
84
|
+
# AppSignal / OpenTelemetry instrumentation works regardless
|
|
85
|
+
# of dispatch shape.
|
|
86
|
+
#
|
|
87
|
+
# On a non-match (any path / method not registered here) the
|
|
88
|
+
# request falls through to the regular Rack adapter dispatch
|
|
89
|
+
# — existing behaviour for un-handled routes is unchanged.
|
|
90
|
+
def self.handle(method_sym, path, handler)
|
|
91
|
+
route_table.register(method_sym, path, handler)
|
|
92
|
+
end
|
|
42
93
|
|
|
94
|
+
# 2.10-D — register a direct-dispatch route whose response is
|
|
95
|
+
# FULLY known at registration time. The full HTTP/1.1 response
|
|
96
|
+
# buffer (status line + Content-Type + Content-Length + body)
|
|
97
|
+
# is built ONCE here and stashed in a `RouteTable::StaticEntry`;
|
|
98
|
+
# on hit, `Connection#serve` issues a single `socket.write` of
|
|
99
|
+
# the pre-built bytes — no header build, no body iteration,
|
|
100
|
+
# zero per-request allocation past the Connection ivars.
|
|
101
|
+
#
|
|
102
|
+
# Mirrors agoo's optimal hello-world path. `body_bytes` is
|
|
103
|
+
# the response body (frozen automatically); `content_type`
|
|
104
|
+
# defaults to `text/plain`. Returns the registered
|
|
105
|
+
# `StaticEntry` for inspection.
|
|
106
|
+
def self.handle_static(method_sym, path, body_bytes, content_type: 'text/plain')
|
|
107
|
+
raise ArgumentError, 'body_bytes must be a String' unless body_bytes.is_a?(String)
|
|
108
|
+
raise ArgumentError, 'content_type must be a String' unless content_type.is_a?(String)
|
|
109
|
+
|
|
110
|
+
body = body_bytes.dup.b.freeze
|
|
111
|
+
head = +"HTTP/1.1 200 OK\r\n" \
|
|
112
|
+
"content-type: #{content_type}\r\n" \
|
|
113
|
+
"content-length: #{body.bytesize}\r\n" \
|
|
114
|
+
"\r\n"
|
|
115
|
+
head.force_encoding(Encoding::ASCII_8BIT)
|
|
116
|
+
buffer = (head + body).freeze
|
|
117
|
+
|
|
118
|
+
method_key = method_sym.to_s.upcase.to_sym
|
|
119
|
+
# 2.10-F — record the headers prefix length on the StaticEntry
|
|
120
|
+
# struct so HEAD-method writes can serve a headers-only prefix.
|
|
121
|
+
entry = RouteTable::StaticEntry.new(method_key, path.dup.freeze, buffer, head.bytesize).freeze
|
|
122
|
+
# 2.10-F — register the entry DIRECTLY (StaticEntry responds to
|
|
123
|
+
# `#call`) instead of wrapping it in a closure, so the dispatch
|
|
124
|
+
# path can branch on `is_a?(StaticEntry)` BEFORE invoking the
|
|
125
|
+
# handler — that's what unlocks the C-ext fast path.
|
|
126
|
+
route_table.register(method_sym, path, entry)
|
|
127
|
+
# 2.10-F — also register HEAD for any GET registration. HTTP
|
|
128
|
+
# mandates HEAD-on-a-GET-resource, and the C fast path strips
|
|
129
|
+
# the body bytes for HEAD requests inside `serve_request`.
|
|
130
|
+
# Idiomatic for static-asset routes (every CDN-shaped GET URL
|
|
131
|
+
# MUST also answer HEAD with the same headers). No-op on a
|
|
132
|
+
# POST/PUT/etc. registration — those don't get a HEAD twin.
|
|
133
|
+
route_table.register(:HEAD, path, entry) if method_key == :GET
|
|
134
|
+
# 2.10-F — fold the prebuilt response into the C-side PageCache so
|
|
135
|
+
# `PageCache.serve_request` can write it without ever crossing
|
|
136
|
+
# back into Ruby. Best-effort: if the C ext isn't available
|
|
137
|
+
# (JRuby / TruffleRuby), the dispatcher silently falls back to
|
|
138
|
+
# the Ruby `socket.write` path that's been there since 2.10-D.
|
|
139
|
+
if defined?(::Hyperion::Http::PageCache) && ::Hyperion::Http::PageCache.respond_to?(:register_prebuilt)
|
|
140
|
+
::Hyperion::Http::PageCache.register_prebuilt(path, buffer, body.bytesize)
|
|
141
|
+
end
|
|
142
|
+
entry
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# 1.7.0 added kwargs (all default to current behaviour):
|
|
146
|
+
# * `runtime:` — `Hyperion::Runtime` instance (default
|
|
147
|
+
# `Runtime.default`). Threaded through to
|
|
148
|
+
# every per-connection / per-stream code
|
|
149
|
+
# path so per-server metrics/logger
|
|
150
|
+
# isolation works.
|
|
151
|
+
# * `accept_fibers_per_worker:` — Integer, default 1. When > 1 and the
|
|
152
|
+
# accept loop is async-wrapped, spawn N
|
|
153
|
+
# accept fibers that race on the same
|
|
154
|
+
# listening fd. Linear scaling on
|
|
155
|
+
# `:reuseport` (Linux); Darwin honours the
|
|
156
|
+
# knob silently with no scaling benefit
|
|
157
|
+
# (RFC §5 Q5).
|
|
158
|
+
# * `h2_max_total_streams:` — Integer or nil (default nil). Process-
|
|
159
|
+
# wide cap on simultaneously-open h2
|
|
160
|
+
# streams across all connections. nil
|
|
161
|
+
# disables (current behaviour); set to
|
|
162
|
+
# opt into RFC A7 admission control.
|
|
163
|
+
# * `admin_listener_port:` — Integer or nil (default nil). When set,
|
|
164
|
+
# spawn a sibling HTTP listener on
|
|
165
|
+
# `127.0.0.1:<port>` that serves only
|
|
166
|
+
# `/-/quit` and `/-/metrics`. nil keeps
|
|
167
|
+
# admin mounted in-app (current shape).
|
|
43
168
|
def initialize(app:, host: '127.0.0.1', port: 9292, read_timeout: DEFAULT_READ_TIMEOUT_SECONDS,
|
|
44
169
|
tls: nil, thread_count: DEFAULT_THREAD_COUNT, max_pending: nil,
|
|
45
|
-
max_request_read_seconds: 60, h2_settings: nil, async_io: nil
|
|
170
|
+
max_request_read_seconds: 60, h2_settings: nil, async_io: nil,
|
|
171
|
+
runtime: nil, accept_fibers_per_worker: 1,
|
|
172
|
+
h2_max_total_streams: nil, admin_listener_port: nil,
|
|
173
|
+
admin_listener_host: '127.0.0.1', admin_token: nil,
|
|
174
|
+
tls_session_cache_size: TLS::DEFAULT_SESSION_CACHE_SIZE,
|
|
175
|
+
tls_ktls: :auto,
|
|
176
|
+
io_uring: :off,
|
|
177
|
+
max_in_flight_per_conn: nil,
|
|
178
|
+
tls_handshake_rate_limit: :unlimited,
|
|
179
|
+
route_table: nil,
|
|
180
|
+
preload_static_dirs: nil)
|
|
181
|
+
validate_async_io!(async_io)
|
|
46
182
|
@host = host
|
|
47
183
|
@port = port
|
|
48
184
|
@app = app
|
|
@@ -53,16 +189,97 @@ module Hyperion
|
|
|
53
189
|
@max_request_read_seconds = max_request_read_seconds
|
|
54
190
|
@h2_settings = h2_settings
|
|
55
191
|
@async_io = async_io
|
|
192
|
+
# `@explicit_runtime` toggles between 1.7.0 isolation (an
|
|
193
|
+
# explicitly-passed Runtime) and 1.6.x compat (legacy module-level
|
|
194
|
+
# accessors honoured for stub seams). All record_dispatch /
|
|
195
|
+
# reject_connection / log lines route through `runtime_metrics` /
|
|
196
|
+
# `runtime_logger` helpers below.
|
|
197
|
+
@runtime = runtime || Hyperion::Runtime.default
|
|
198
|
+
@explicit_runtime = !runtime.nil?
|
|
199
|
+
@accept_fibers_per_worker = [accept_fibers_per_worker.to_i, 1].max
|
|
200
|
+
# 2.0: `h2_max_total_streams` is normally a positive integer (the
|
|
201
|
+
# default-flipped cap from `Config#finalize!`) or nil (operator
|
|
202
|
+
# opted out via `h2.max_total_streams :unbounded`). Defensive
|
|
203
|
+
# branch: treat the `:auto` / `:unbounded` sentinels as "no cap"
|
|
204
|
+
# if a caller bypasses Config and constructs Server directly.
|
|
205
|
+
@h2_admission = if h2_max_total_streams.is_a?(Integer) && h2_max_total_streams.positive?
|
|
206
|
+
Hyperion::H2Admission.new(max_total_streams: h2_max_total_streams)
|
|
207
|
+
end
|
|
208
|
+
@admin_listener_port = admin_listener_port
|
|
209
|
+
@admin_listener_host = admin_listener_host
|
|
210
|
+
@admin_token = admin_token
|
|
211
|
+
@admin_listener = nil
|
|
56
212
|
@thread_pool = nil
|
|
57
213
|
@stopped = false
|
|
214
|
+
@tls_session_cache_size = tls_session_cache_size
|
|
215
|
+
@tls_ktls = tls_ktls
|
|
216
|
+
@ktls_logged = false
|
|
217
|
+
# 2.3-A: resolve the io_uring accept policy. `:off` (the 2.3.0
|
|
218
|
+
# default) skips the resolve step entirely so hosts without the
|
|
219
|
+
# cdylib don't trigger any Fiddle.dlopen probe at boot.
|
|
220
|
+
# Workers don't share rings across fork — each child opens its
|
|
221
|
+
# own ring lazily on first use inside `run_accept_fiber`.
|
|
222
|
+
@io_uring_policy = io_uring
|
|
223
|
+
@io_uring_active = io_uring != :off && Hyperion::IOUring.resolve_policy!(io_uring)
|
|
224
|
+
log_io_uring_state_once
|
|
225
|
+
# 2.3-B: per-conn fairness cap (validated/finalized upstream by
|
|
226
|
+
# `Config#finalize!`; constructor accepts the resolved value, not
|
|
227
|
+
# a sentinel). nil = no cap (default). The cap propagates to
|
|
228
|
+
# every Connection the ThreadPool's `:connection` worker builds.
|
|
229
|
+
@max_in_flight_per_conn = max_in_flight_per_conn
|
|
230
|
+
# 2.3-B: TLS handshake CPU throttle. One limiter per worker
|
|
231
|
+
# (per-Server). `:unlimited` short-circuits every `acquire_token!`
|
|
232
|
+
# to true so the hot path stays branchless. Built eagerly so
|
|
233
|
+
# bench harnesses can introspect via `server.tls_handshake_limiter`.
|
|
234
|
+
@tls_handshake_limiter = Hyperion::TLS::HandshakeRateLimiter.new(tls_handshake_rate_limit)
|
|
235
|
+
# 2.10-D: per-instance route table (defaults to the class-level
|
|
236
|
+
# singleton). Tests can inject a fresh table to isolate
|
|
237
|
+
# registrations from other examples.
|
|
238
|
+
@route_table = route_table || Hyperion::Server.route_table
|
|
239
|
+
# 2.10-E: list of `{path:, immutable:}` entries the worker warms
|
|
240
|
+
# into `Hyperion::Http::PageCache` at boot. Resolved by
|
|
241
|
+
# `Config#resolved_preload_static_dirs` and threaded through
|
|
242
|
+
# Master → Worker → Server. nil/[] = no preload (1.x cold-cache
|
|
243
|
+
# behaviour).
|
|
244
|
+
@preload_static_dirs = preload_static_dirs
|
|
245
|
+
@preloaded = false
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# Read-only handle for tests + bench harness introspection.
|
|
249
|
+
attr_reader :tls_handshake_limiter
|
|
250
|
+
|
|
251
|
+
# 2.10-D — read-only handle to the per-instance route table.
|
|
252
|
+
# Connection#serve consults this after parse to decide whether
|
|
253
|
+
# to engage the direct-dispatch fast path. Defaults to the
|
|
254
|
+
# process-wide `Hyperion::Server.route_table` singleton.
|
|
255
|
+
attr_reader :route_table
|
|
256
|
+
|
|
257
|
+
# Read-only handle to the per-worker SSL context (nil when the
|
|
258
|
+
# listener is plain TCP). Exposed so the worker can call
|
|
259
|
+
# `Hyperion::TLS.rotate!(server.ssl_context)` from its SIGUSR2
|
|
260
|
+
# handler without reaching into Server internals.
|
|
261
|
+
attr_reader :ssl_ctx
|
|
262
|
+
|
|
263
|
+
# Strict validation of the tri-state `async_io` flag (RFC A9). Pre-1.7
|
|
264
|
+
# the Server constructor accepted any object; `1`, `:yes`, `'true'`
|
|
265
|
+
# silently landed in the wrong matrix cell. Now: raise immediately so
|
|
266
|
+
# the operator's typo surfaces at boot, not as a "why is my fiber-pg
|
|
267
|
+
# config not behaving" report three hours later.
|
|
268
|
+
def validate_async_io!(value)
|
|
269
|
+
return if value.nil? || value == true || value == false
|
|
270
|
+
|
|
271
|
+
raise ArgumentError, "async_io must be nil, true, or false (got #{value.inspect})"
|
|
58
272
|
end
|
|
273
|
+
private :validate_async_io!
|
|
59
274
|
|
|
60
275
|
def listen
|
|
61
276
|
tcp = ::TCPServer.new(@host, @port)
|
|
62
277
|
@port = tcp.addr[1]
|
|
63
278
|
|
|
64
279
|
if @tls
|
|
65
|
-
@ssl_ctx = TLS.context(cert: @tls[:cert], key: @tls[:key], chain: @tls[:chain]
|
|
280
|
+
@ssl_ctx = TLS.context(cert: @tls[:cert], key: @tls[:key], chain: @tls[:chain],
|
|
281
|
+
session_cache_size: @tls_session_cache_size,
|
|
282
|
+
ktls: @tls_ktls)
|
|
66
283
|
ssl_server = ::OpenSSL::SSL::SSLServer.new(tcp, @ssl_ctx)
|
|
67
284
|
ssl_server.start_immediately = false
|
|
68
285
|
@server = ssl_server
|
|
@@ -90,7 +307,11 @@ module Hyperion
|
|
|
90
307
|
else
|
|
91
308
|
sock.local_address.ip_port
|
|
92
309
|
end
|
|
93
|
-
|
|
310
|
+
if @tls
|
|
311
|
+
@ssl_ctx = TLS.context(cert: @tls[:cert], key: @tls[:key], chain: @tls[:chain],
|
|
312
|
+
session_cache_size: @tls_session_cache_size,
|
|
313
|
+
ktls: @tls_ktls)
|
|
314
|
+
end
|
|
94
315
|
self
|
|
95
316
|
end
|
|
96
317
|
|
|
@@ -106,7 +327,19 @@ module Hyperion
|
|
|
106
327
|
|
|
107
328
|
def start
|
|
108
329
|
listen unless @server
|
|
109
|
-
|
|
330
|
+
# 2.10-E: warm the page cache before any request can land. Idempotent
|
|
331
|
+
# via `@preloaded`, so repeated `start` calls (test harnesses,
|
|
332
|
+
# Worker#run respawn) don't re-walk the tree. Runs after `listen`
|
|
333
|
+
# (so `@server` exists for the operator's introspection hooks if any
|
|
334
|
+
# future runtime fires off boot-side instrumentation) but before the
|
|
335
|
+
# accept loop fires up — first request hits warm cache.
|
|
336
|
+
preload_static!
|
|
337
|
+
if @thread_count.positive?
|
|
338
|
+
@thread_pool = ThreadPool.new(size: @thread_count, max_pending: @max_pending,
|
|
339
|
+
max_in_flight_per_conn: @max_in_flight_per_conn,
|
|
340
|
+
route_table: @route_table)
|
|
341
|
+
end
|
|
342
|
+
maybe_start_admin_listener
|
|
110
343
|
|
|
111
344
|
if @tls || @async_io
|
|
112
345
|
# TLS path: ALPN may pick `h2`, and h2 spawns one fiber per stream
|
|
@@ -133,6 +366,7 @@ module Hyperion
|
|
|
133
366
|
end
|
|
134
367
|
ensure
|
|
135
368
|
@thread_pool&.shutdown
|
|
369
|
+
@admin_listener&.stop
|
|
136
370
|
end
|
|
137
371
|
|
|
138
372
|
def stop
|
|
@@ -142,6 +376,24 @@ module Hyperion
|
|
|
142
376
|
@tcp_server = nil
|
|
143
377
|
end
|
|
144
378
|
|
|
379
|
+
# 2.10-E — Walk every configured preload directory, populate
|
|
380
|
+
# `Hyperion::Http::PageCache`, and mark every entry immutable when
|
|
381
|
+
# asked. Called from `start` once per worker. Idempotent — second
|
|
382
|
+
# call is a no-op so test harnesses + Worker respawn paths don't
|
|
383
|
+
# re-walk the tree.
|
|
384
|
+
#
|
|
385
|
+
# `logger` is exposed as a kwarg purely for the spec suite; production
|
|
386
|
+
# callers omit it and the runtime logger is used.
|
|
387
|
+
def preload_static!(logger: runtime_logger)
|
|
388
|
+
return 0 if @preloaded
|
|
389
|
+
|
|
390
|
+
@preloaded = true
|
|
391
|
+
entries = @preload_static_dirs
|
|
392
|
+
return 0 if entries.nil? || entries.empty?
|
|
393
|
+
|
|
394
|
+
Hyperion::StaticPreload.run(entries, logger: logger)
|
|
395
|
+
end
|
|
396
|
+
|
|
145
397
|
private
|
|
146
398
|
|
|
147
399
|
# Plain HTTP/1.1 accept loop — no fiber wrap. Connections go straight to
|
|
@@ -155,15 +407,24 @@ module Hyperion
|
|
|
155
407
|
|
|
156
408
|
apply_timeout(socket)
|
|
157
409
|
if @thread_pool
|
|
410
|
+
mode = DispatchMode.new(:threadpool_h1)
|
|
158
411
|
if @thread_pool.submit_connection(socket, @app,
|
|
159
412
|
max_request_read_seconds: @max_request_read_seconds)
|
|
160
|
-
|
|
413
|
+
record_dispatch(mode)
|
|
161
414
|
else
|
|
162
415
|
reject_connection(socket)
|
|
163
416
|
end
|
|
164
417
|
else
|
|
165
|
-
|
|
166
|
-
|
|
418
|
+
# `-t 0` plain HTTP/1.1 — no pool, serve inline on the accept
|
|
419
|
+
# thread. RFC §5 Q3: `--async-io -t 0` keeps working — see
|
|
420
|
+
# start_async_loop's `inline_h1_no_pool` branch.
|
|
421
|
+
mode = DispatchMode.new(:inline_h1_no_pool)
|
|
422
|
+
record_dispatch(mode)
|
|
423
|
+
Connection.new(runtime: @explicit_runtime ? @runtime : nil,
|
|
424
|
+
max_in_flight_per_conn: @max_in_flight_per_conn,
|
|
425
|
+
route_table: @route_table).serve(
|
|
426
|
+
socket, @app, max_request_read_seconds: @max_request_read_seconds
|
|
427
|
+
)
|
|
167
428
|
end
|
|
168
429
|
end
|
|
169
430
|
end
|
|
@@ -171,27 +432,129 @@ module Hyperion
|
|
|
171
432
|
# TLS / h2-capable accept loop. The Async wrapper is required because
|
|
172
433
|
# h2 streams (inside Http2Handler) and the ALPN handshake yield
|
|
173
434
|
# cooperatively via the scheduler.
|
|
435
|
+
#
|
|
436
|
+
# 1.7.0 (RFC A6): `accept_fibers_per_worker > 1` spawns N accept
|
|
437
|
+
# fibers that each `IO.select` on the same listening fd. On `:reuseport`
|
|
438
|
+
# workers (Linux) the kernel hashes connections fairly across siblings;
|
|
439
|
+
# on `:share` (Darwin) the knob is silently honoured but shows no
|
|
440
|
+
# scaling benefit — operators already know Darwin is special.
|
|
174
441
|
def start_async_loop
|
|
175
442
|
Async do |task|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
443
|
+
n = @accept_fibers_per_worker
|
|
444
|
+
n.times { task.async { run_accept_fiber(task) } }
|
|
445
|
+
# `task.children.each(&:wait)` would deadlock if no children — n is
|
|
446
|
+
# always >= 1, so we're safe; but use rescue-wait pattern in case
|
|
447
|
+
# one accept fiber raises.
|
|
448
|
+
task.children.each do |child|
|
|
449
|
+
child.wait
|
|
450
|
+
rescue StandardError
|
|
451
|
+
nil
|
|
182
452
|
end
|
|
183
453
|
end
|
|
184
454
|
end
|
|
185
455
|
|
|
456
|
+
# Single accept fiber's run loop. Called N times (default 1) from
|
|
457
|
+
# `start_async_loop`. All accept fibers share `@server` / `@tcp_server`
|
|
458
|
+
# via closure; the kernel arbitrates which fiber wins each
|
|
459
|
+
# IO.select / accept_nonblock race.
|
|
460
|
+
#
|
|
461
|
+
# 2.3-A: when `io_uring: :auto/:on` resolves to active, each accept
|
|
462
|
+
# fiber lazily opens its OWN ring (per-fiber lifecycle — see
|
|
463
|
+
# `Hyperion::IOUring` docs for the fork+threads sharp edges this
|
|
464
|
+
# avoids). The ring is closed at fiber exit. The TLS path keeps the
|
|
465
|
+
# epoll branch — io_uring accept is wired only for the plain TCP
|
|
466
|
+
# listener; the SSL handshake still wants the userspace
|
|
467
|
+
# `accept` + `SSL_accept` dance.
|
|
468
|
+
def run_accept_fiber(task)
|
|
469
|
+
if @io_uring_active && !@tls
|
|
470
|
+
run_accept_fiber_io_uring(task)
|
|
471
|
+
else
|
|
472
|
+
run_accept_fiber_epoll(task)
|
|
473
|
+
end
|
|
474
|
+
end
|
|
475
|
+
|
|
476
|
+
def run_accept_fiber_epoll(task)
|
|
477
|
+
until @stopped
|
|
478
|
+
socket = accept_or_nil
|
|
479
|
+
next unless socket
|
|
480
|
+
|
|
481
|
+
apply_timeout(socket)
|
|
482
|
+
task.async { dispatch(socket) }
|
|
483
|
+
end
|
|
484
|
+
end
|
|
485
|
+
|
|
486
|
+
# 2.3-A: io_uring accept loop. Opens a per-fiber ring on first
|
|
487
|
+
# use, drains accept CQEs, and hands the resulting fd to the
|
|
488
|
+
# existing `dispatch` path via a Ruby `Socket.for_fd` wrapper so
|
|
489
|
+
# the rest of the server (Connection, ResponseWriter, …) keeps
|
|
490
|
+
# working off a `::Socket` object identical to what
|
|
491
|
+
# `accept_nonblock` would have returned.
|
|
492
|
+
def run_accept_fiber_io_uring(task)
|
|
493
|
+
ring = Fiber.current[:hyperion_io_uring] ||= Hyperion::IOUring::Ring.new(queue_depth: 256)
|
|
494
|
+
listener_fd = listening_io.fileno
|
|
495
|
+
until @stopped
|
|
496
|
+
client_fd = ring.accept(listener_fd)
|
|
497
|
+
next if client_fd == :wouldblock
|
|
498
|
+
|
|
499
|
+
socket = ::Socket.for_fd(client_fd)
|
|
500
|
+
socket.autoclose = true
|
|
501
|
+
apply_timeout(socket)
|
|
502
|
+
task.async { dispatch(socket) }
|
|
503
|
+
end
|
|
504
|
+
rescue IOError, Errno::EBADF
|
|
505
|
+
@stopped = true
|
|
506
|
+
rescue StandardError => e
|
|
507
|
+
runtime_logger.warn do
|
|
508
|
+
{ message: 'io_uring accept fiber error; falling back to epoll for this fiber',
|
|
509
|
+
error: e.message, error_class: e.class.name }
|
|
510
|
+
end
|
|
511
|
+
run_accept_fiber_epoll(task)
|
|
512
|
+
ensure
|
|
513
|
+
ring = Fiber.current[:hyperion_io_uring]
|
|
514
|
+
if ring && !ring.closed?
|
|
515
|
+
ring.close
|
|
516
|
+
Fiber.current[:hyperion_io_uring] = nil
|
|
517
|
+
end
|
|
518
|
+
end
|
|
519
|
+
|
|
520
|
+
# Boot-time log line per worker capturing the resolved io_uring
|
|
521
|
+
# state. Mirrors the `log_ktls_state_once` pattern from 2.2.0.
|
|
522
|
+
# Single-shot via the class-level ivar so multi-worker boots
|
|
523
|
+
# don't fan into N identical lines.
|
|
524
|
+
def log_io_uring_state_once
|
|
525
|
+
return if Hyperion::Server.instance_variable_get(:@io_uring_state_logged)
|
|
526
|
+
return if @io_uring_policy == :off
|
|
527
|
+
|
|
528
|
+
Hyperion::Server.instance_variable_set(:@io_uring_state_logged, true)
|
|
529
|
+
runtime_logger.info do
|
|
530
|
+
{
|
|
531
|
+
message: 'io_uring accept policy resolved',
|
|
532
|
+
policy: @io_uring_policy,
|
|
533
|
+
active: @io_uring_active,
|
|
534
|
+
supported: Hyperion::IOUring.supported?
|
|
535
|
+
}
|
|
536
|
+
end
|
|
537
|
+
rescue StandardError
|
|
538
|
+
nil
|
|
539
|
+
end
|
|
540
|
+
|
|
186
541
|
def dispatch(socket)
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
#
|
|
193
|
-
|
|
194
|
-
|
|
542
|
+
alpn = socket.is_a?(::OpenSSL::SSL::SSLSocket) ? socket.alpn_protocol : nil
|
|
543
|
+
mode = DispatchMode.resolve(tls: !@tls.nil?, async_io: @async_io,
|
|
544
|
+
thread_count: @thread_count, alpn: alpn)
|
|
545
|
+
case mode.name
|
|
546
|
+
when :tls_h2
|
|
547
|
+
# HTTP/2: each stream runs on a fiber inside Http2Handler. Per-
|
|
548
|
+
# stream counters live there. We bump the per-mode counter
|
|
549
|
+
# (`:requests_dispatch_tls_h2`) at connection-accept time so
|
|
550
|
+
# operators see the connection's chosen transport even when the
|
|
551
|
+
# h2 streams happen on later fibers.
|
|
552
|
+
record_dispatch(mode)
|
|
553
|
+
Http2Handler.new(app: @app, thread_pool: @thread_pool,
|
|
554
|
+
h2_settings: @h2_settings,
|
|
555
|
+
runtime: @explicit_runtime ? @runtime : nil,
|
|
556
|
+
h2_admission: @h2_admission).serve(socket)
|
|
557
|
+
when :tls_h1_inline, :async_io_h1_inline
|
|
195
558
|
# Inline-on-fiber HTTP/1.1 dispatch. Two ways to land here:
|
|
196
559
|
# 1. async_io: true — operator explicitly opted into fiber I/O on
|
|
197
560
|
# the plain HTTP/1.1 path.
|
|
@@ -202,44 +565,85 @@ module Hyperion
|
|
|
202
565
|
# for no perf benefit (we paid the Async-loop cost already)
|
|
203
566
|
# and would defeat hyperion-async-pg / async-redis on the
|
|
204
567
|
# TLS h1 path.
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
568
|
+
record_dispatch(mode)
|
|
569
|
+
Connection.new(runtime: @explicit_runtime ? @runtime : nil,
|
|
570
|
+
max_in_flight_per_conn: @max_in_flight_per_conn,
|
|
571
|
+
route_table: @route_table).serve(
|
|
572
|
+
socket, @app, max_request_read_seconds: @max_request_read_seconds
|
|
573
|
+
)
|
|
574
|
+
when :threadpool_h1
|
|
211
575
|
# HTTP/1.1 default plain-HTTP path, OR explicit async_io: false on
|
|
212
576
|
# TLS (operator opted out of inline-on-fiber dispatch). Hand the
|
|
213
577
|
# connection to a worker thread; the fiber that called dispatch
|
|
214
578
|
# returns immediately. On overflow, reject with 503 + close.
|
|
215
|
-
if @thread_pool
|
|
216
|
-
|
|
217
|
-
|
|
579
|
+
if @thread_pool
|
|
580
|
+
if @thread_pool.submit_connection(socket, @app,
|
|
581
|
+
max_request_read_seconds: @max_request_read_seconds)
|
|
582
|
+
record_dispatch(mode)
|
|
583
|
+
else
|
|
584
|
+
reject_connection(socket)
|
|
585
|
+
end
|
|
218
586
|
else
|
|
219
|
-
|
|
587
|
+
# `run_one` / spec entry points dispatch without having
|
|
588
|
+
# started the pool — serve inline and count under
|
|
589
|
+
# threadpool_h1 (the connection's logical mode).
|
|
590
|
+
record_dispatch(mode)
|
|
591
|
+
Connection.new(runtime: @explicit_runtime ? @runtime : nil,
|
|
592
|
+
max_in_flight_per_conn: @max_in_flight_per_conn,
|
|
593
|
+
route_table: @route_table).serve(
|
|
594
|
+
socket, @app, max_request_read_seconds: @max_request_read_seconds
|
|
595
|
+
)
|
|
220
596
|
end
|
|
221
|
-
|
|
222
|
-
#
|
|
223
|
-
#
|
|
224
|
-
#
|
|
225
|
-
|
|
226
|
-
Connection.new
|
|
597
|
+
when :inline_h1_no_pool
|
|
598
|
+
# `-t 0` on the TLS / async-wrap path. Rare config — debug /
|
|
599
|
+
# spec aid (RFC §5 Q3 keeps `--async-io -t 0` valid). Counted
|
|
600
|
+
# under its own bucket now (pre-1.7 it was un-counted).
|
|
601
|
+
record_dispatch(mode)
|
|
602
|
+
Connection.new(runtime: @explicit_runtime ? @runtime : nil,
|
|
603
|
+
max_in_flight_per_conn: @max_in_flight_per_conn,
|
|
604
|
+
route_table: @route_table).serve(
|
|
605
|
+
socket, @app, max_request_read_seconds: @max_request_read_seconds
|
|
606
|
+
)
|
|
227
607
|
end
|
|
228
608
|
end
|
|
229
609
|
|
|
230
|
-
#
|
|
231
|
-
#
|
|
232
|
-
#
|
|
233
|
-
#
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
610
|
+
# Resolve the metrics sink for write-side ops. When the operator
|
|
611
|
+
# passed an explicit `runtime:` we honour it; otherwise we read
|
|
612
|
+
# the module-level singleton (`Hyperion.metrics`) so 1.6.x test
|
|
613
|
+
# stubs (`allow(Hyperion).to receive(:metrics)`) keep working.
|
|
614
|
+
def runtime_metrics
|
|
615
|
+
@explicit_runtime ? @runtime.metrics : Hyperion.metrics
|
|
616
|
+
end
|
|
617
|
+
|
|
618
|
+
def runtime_logger
|
|
619
|
+
@explicit_runtime ? @runtime.logger : Hyperion.logger
|
|
620
|
+
end
|
|
621
|
+
|
|
622
|
+
# Bump the per-mode dispatch counter. 1.7→1.8 dual-emitted under the
|
|
623
|
+
# legacy `:requests_async_dispatched` / `:requests_threadpool_dispatched`
|
|
624
|
+
# keys for one full release cycle so operators could migrate Grafana
|
|
625
|
+
# boards. 2.0 retires the legacy keys: only `:requests_dispatch_<mode>`
|
|
626
|
+
# is emitted (one of `:requests_dispatch_threadpool_h1`,
|
|
627
|
+
# `:requests_dispatch_inline_h1_no_pool`, `:requests_dispatch_tls_h1_inline`,
|
|
628
|
+
# `:requests_dispatch_async_io_h1_inline`, `:requests_dispatch_tls_h2`).
|
|
629
|
+
def record_dispatch(mode)
|
|
630
|
+
runtime_metrics.increment(mode.metric_key)
|
|
631
|
+
end
|
|
632
|
+
|
|
633
|
+
# Spawn the optional sibling admin listener (RFC A8). When
|
|
634
|
+
# `admin.listener_port` is unset (default), admin endpoints stay
|
|
635
|
+
# mounted in-app via `AdminMiddleware` — no behaviour change.
|
|
636
|
+
def maybe_start_admin_listener
|
|
637
|
+
return unless @admin_listener_port
|
|
638
|
+
return if @admin_token.nil? || @admin_token.empty?
|
|
639
|
+
|
|
640
|
+
@admin_listener = Hyperion::AdminListener.new(
|
|
641
|
+
host: @admin_listener_host,
|
|
642
|
+
port: @admin_listener_port,
|
|
643
|
+
token: @admin_token,
|
|
644
|
+
runtime: @runtime
|
|
645
|
+
)
|
|
646
|
+
@admin_listener.start
|
|
243
647
|
end
|
|
244
648
|
|
|
245
649
|
# Backpressure rejection. Emits a pre-built 503 + closes the socket.
|
|
@@ -249,7 +653,7 @@ module Hyperion
|
|
|
249
653
|
# can alert on sustained overload.
|
|
250
654
|
def reject_connection(socket)
|
|
251
655
|
socket.write(REJECT_503)
|
|
252
|
-
|
|
656
|
+
runtime_metrics.increment(:rejected_connections)
|
|
253
657
|
rescue StandardError
|
|
254
658
|
# Client may have hung up between accept and our 503 write — that's
|
|
255
659
|
# the failure mode we're protecting them from anyway, so swallow.
|
|
@@ -275,6 +679,11 @@ module Hyperion
|
|
|
275
679
|
ssl = ::OpenSSL::SSL::SSLSocket.new(raw, @ssl_ctx)
|
|
276
680
|
ssl.sync_close = true
|
|
277
681
|
ssl.accept # blocks; under Async this yields cooperatively via the scheduler
|
|
682
|
+
log_ktls_state_once(ssl)
|
|
683
|
+
# 2.4-C: bump the per-worker active-kTLS-connections gauge if
|
|
684
|
+
# the kernel module accepted this connection. Connection#serve
|
|
685
|
+
# decrements on close.
|
|
686
|
+
Hyperion::TLS.track_ktls_handshake!(ssl)
|
|
278
687
|
ssl
|
|
279
688
|
else
|
|
280
689
|
socket, = listening_io.accept_nonblock
|
|
@@ -286,7 +695,7 @@ module Hyperion
|
|
|
286
695
|
@stopped = true
|
|
287
696
|
nil
|
|
288
697
|
rescue OpenSSL::SSL::SSLError => e
|
|
289
|
-
|
|
698
|
+
runtime_logger.warn { { message: 'tls handshake failed', error: e.message } }
|
|
290
699
|
nil
|
|
291
700
|
end
|
|
292
701
|
|
|
@@ -296,13 +705,43 @@ module Hyperion
|
|
|
296
705
|
ssl = ::OpenSSL::SSL::SSLSocket.new(raw, @ssl_ctx)
|
|
297
706
|
ssl.sync_close = true
|
|
298
707
|
ssl.accept
|
|
708
|
+
log_ktls_state_once(ssl)
|
|
709
|
+
Hyperion::TLS.track_ktls_handshake!(ssl)
|
|
299
710
|
ssl
|
|
300
711
|
else
|
|
301
712
|
socket, = @server.accept
|
|
302
713
|
socket
|
|
303
714
|
end
|
|
304
715
|
rescue OpenSSL::SSL::SSLError => e
|
|
305
|
-
|
|
716
|
+
runtime_logger.warn { { message: 'tls handshake failed', error: e.message } }
|
|
717
|
+
nil
|
|
718
|
+
end
|
|
719
|
+
|
|
720
|
+
# 2.2.0 (Phase 9): emit a single info-level log line per worker boot
|
|
721
|
+
# capturing whether kTLS_TX engaged for this listener and which cipher
|
|
722
|
+
# the first connection landed on. The cipher is per-connection (not
|
|
723
|
+
# per-context), so we wait for the first successful handshake — at
|
|
724
|
+
# that point either the kernel module is in use or the listener fell
|
|
725
|
+
# back to userspace SSL_write. Subsequent connections skip the log
|
|
726
|
+
# via `@ktls_logged`.
|
|
727
|
+
def log_ktls_state_once(ssl)
|
|
728
|
+
return if @ktls_logged
|
|
729
|
+
|
|
730
|
+
@ktls_logged = true
|
|
731
|
+
cipher_name = ssl.cipher && ssl.cipher.first rescue nil # rubocop:disable Style/RescueModifier
|
|
732
|
+
ktls_active = Hyperion::TLS.ktls_active?(ssl)
|
|
733
|
+
runtime_logger.info do
|
|
734
|
+
{
|
|
735
|
+
message: 'tls listener ready',
|
|
736
|
+
ktls_policy: @tls_ktls,
|
|
737
|
+
ktls_supported: Hyperion::TLS.ktls_supported?,
|
|
738
|
+
ktls_active: ktls_active,
|
|
739
|
+
cipher: cipher_name
|
|
740
|
+
}
|
|
741
|
+
end
|
|
742
|
+
rescue StandardError
|
|
743
|
+
# Logging is best-effort — never let a log line take down the
|
|
744
|
+
# accept loop.
|
|
306
745
|
nil
|
|
307
746
|
end
|
|
308
747
|
|
|
@@ -317,10 +756,35 @@ module Hyperion
|
|
|
317
756
|
timeval = [@read_timeout, 0].pack('l_l_')
|
|
318
757
|
target.setsockopt(::Socket::SOL_SOCKET, ::Socket::SO_RCVTIMEO, timeval)
|
|
319
758
|
end
|
|
759
|
+
apply_tcp_nodelay(target)
|
|
320
760
|
rescue StandardError => e
|
|
321
|
-
|
|
761
|
+
runtime_logger.warn do
|
|
322
762
|
{ message: 'failed to set read timeout', error: e.message, error_class: e.class.name }
|
|
323
763
|
end
|
|
324
764
|
end
|
|
765
|
+
|
|
766
|
+
# 2.10-G — disable Nagle so HTTP/2 stream responses (and any small-payload
|
|
767
|
+
# write that doesn't already coalesce head+body via the 2.0.1 Phase 8 path)
|
|
768
|
+
# don't stall ~40 ms on the client's delayed-ACK timer.
|
|
769
|
+
#
|
|
770
|
+
# Symptom that surfaced this: 2.9-B Falcon comparison flagged Hyperion's
|
|
771
|
+
# h2 max-latency stuck at ~40 ms across all rows; the 2.10-G bench showed
|
|
772
|
+
# the **min** latency was 40.6 ms (every stream, not just the first).
|
|
773
|
+
# That's the canonical Linux delayed-ACK + Nagle interaction —
|
|
774
|
+
# protocol-http2 emits HEADERS and DATA as separate framer writes, the
|
|
775
|
+
# first arrives at the peer alone, the peer waits 40 ms for an ACK so it
|
|
776
|
+
# can piggyback, Hyperion's writer fiber waits because Nagle is buffering
|
|
777
|
+
# the DATA frame until the HEADERS ACK lands. TCP_NODELAY breaks the
|
|
778
|
+
# cycle — every framer write goes out immediately.
|
|
779
|
+
#
|
|
780
|
+
# Cost: a few extra TCP packets for chatty streams. Worth it; Falcon and
|
|
781
|
+
# Agoo both set TCP_NODELAY.
|
|
782
|
+
def apply_tcp_nodelay(target)
|
|
783
|
+
target.setsockopt(::Socket::IPPROTO_TCP, ::Socket::TCP_NODELAY, 1)
|
|
784
|
+
rescue StandardError
|
|
785
|
+
# SSLSocket-without-#io, UDPSocket, or platform without TCP_NODELAY
|
|
786
|
+
# (Windows-on-WSL2 occasionally). Silently skip — the socket still
|
|
787
|
+
# works; only delayed-ACK behavior is affected.
|
|
788
|
+
end
|
|
325
789
|
end
|
|
326
790
|
end
|