hyperion-rb 1.6.1 → 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.
Files changed (57) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +4570 -0
  3. data/README.md +212 -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 +452 -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 +368 -9
  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
@@ -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
- @ssl_ctx = TLS.context(cert: @tls[:cert], key: @tls[:key], chain: @tls[:chain]) if @tls
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
- @thread_pool = ThreadPool.new(size: @thread_count, max_pending: @max_pending) if @thread_count.positive?
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
- Hyperion.metrics.increment(:requests_threadpool_dispatched)
413
+ record_dispatch(mode)
161
414
  else
162
415
  reject_connection(socket)
163
416
  end
164
417
  else
165
- Hyperion.metrics.increment(:requests_threadpool_dispatched)
166
- Connection.new.serve(socket, @app, max_request_read_seconds: @max_request_read_seconds)
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
- until @stopped
177
- socket = accept_or_nil
178
- next unless socket
179
-
180
- apply_timeout(socket)
181
- task.async { dispatch(socket) }
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
- if socket.is_a?(::OpenSSL::SSL::SSLSocket) && socket.alpn_protocol == 'h2'
188
- # HTTP/2: each stream runs on a fiber inside Http2Handler. The
189
- # handler still uses the pool's `#call` for app.call hops on each
190
- # stream (one per stream, not one per connection). Per-stream
191
- # counters live inside Http2Handler; we don't bump either of the
192
- # H1 dispatch buckets here neither fits the h2 model cleanly.
193
- Http2Handler.new(app: @app, thread_pool: @thread_pool, h2_settings: @h2_settings).serve(socket)
194
- elsif inline_h1_dispatch?
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
- # Operators who specifically want TLS+threadpool (e.g. CPU-heavy
206
- # handlers competing for OS threads) can pass async_io: false to
207
- # force the pool branch below.
208
- Hyperion.metrics.increment(:requests_async_dispatched)
209
- Connection.new.serve(socket, @app, max_request_read_seconds: @max_request_read_seconds)
210
- elsif @thread_pool
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.submit_connection(socket, @app,
216
- max_request_read_seconds: @max_request_read_seconds)
217
- Hyperion.metrics.increment(:requests_threadpool_dispatched)
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
- reject_connection(socket)
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
- else
222
- # No pool (thread_count: 0) on the TLS / async-wrap path with
223
- # async_io: false. Rare config neither dispatch bucket fits
224
- # cleanly. Leave un-counted rather than misclassify; the request
225
- # still shows up in :requests_total via Connection.
226
- Connection.new.serve(socket, @app, max_request_read_seconds: @max_request_read_seconds)
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
- # Decide whether to serve HTTP/1.1 inline on the calling fiber instead
231
- # of hopping through the worker thread pool. The matrix:
232
- # async_io == true → inline always (plain h1 + TLS h1).
233
- # async_io == nil + TLS → inline (TLS already runs Async loop, so
234
- # the scheduler is current; preserve it).
235
- # async_io == nil + plain → pool (pure HTTP/1.1 fast path; no scheduler).
236
- # async_io == false → pool always (explicit opt-out).
237
- def inline_h1_dispatch?
238
- return true if @async_io == true
239
- return false if @async_io == false
240
-
241
- # @async_io.nil? — auto: inline on TLS, pool on plain.
242
- !@tls.nil?
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
- Hyperion.metrics.increment(:rejected_connections)
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
- Hyperion.logger.warn { { message: 'tls handshake failed', error: e.message } }
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
- Hyperion.logger.warn { { message: 'tls handshake failed', error: e.message } }
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
- Hyperion.logger.warn do
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