hyperion-rb 2.11.0 → 2.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -79,6 +79,30 @@ module Hyperion
79
79
  # Snapshot block hooks for gauges whose value is read on demand
80
80
  # (ThreadPool queue depth, etc.). `{ name => { labels_tuple => Proc } }`.
81
81
  @gauge_blocks = {}
82
+
83
+ # 2.13-A — per-thread shards for the hot-path metrics that USED to
84
+ # take @hg_mutex on every observe / increment. The pre-2.13-A
85
+ # comment in `increment_labeled_counter` claimed those paths were
86
+ # "low-rate" — that turned out to be wrong: `tick_worker_request`
87
+ # fires once per Rack request, and `observe_histogram` fires once
88
+ # per request via the per-route duration histogram. Under -t 32
89
+ # the single mutex serialised every worker thread on the
90
+ # request-completion tail. Per-thread shards remove the
91
+ # contention; snapshots merge across threads under the mutex
92
+ # (snapshot is a low-rate operation — once per /-/metrics scrape).
93
+ #
94
+ # Thread-variable storage (NOT Thread.current[]) for the same
95
+ # reason as the unlabeled counter path: under an Async scheduler
96
+ # `Thread.current[:k]` is fiber-local, which would let snapshots
97
+ # miss observations made on a fiber that already exited.
98
+ @hg_thread_key = :"__hyperion_metrics_hg_#{object_id}__"
99
+ @lc_thread_key = :"__hyperion_metrics_lc_#{object_id}__"
100
+ # Holds direct references to every per-thread shard ever
101
+ # allocated through this Metrics instance (mirrors @thread_counters)
102
+ # so snapshots survive thread death.
103
+ @thread_histograms = []
104
+ @thread_labeled_counters = []
105
+ @hg_thread_mutex = Mutex.new
82
106
  end
83
107
 
84
108
  # Hot path: one thread-variable lookup + one hash op. No mutex on the
@@ -113,6 +137,44 @@ module Hyperion
113
137
  increment(:"responses_#{code}")
114
138
  end
115
139
 
140
+ # 2.12-E — labeled counter family that observes which worker
141
+ # process a given request landed on. Ticks once per dispatched
142
+ # request from every dispatch shape (Connection#serve, h2 streams,
143
+ # the C accept4 + io_uring loops; see PrometheusExporter for the
144
+ # C-loop fold-in at scrape time).
145
+ #
146
+ # `worker_id` is conventionally `Process.pid.to_s` — matches the
147
+ # 2.4-C `hyperion_io_uring_workers_active` and
148
+ # `hyperion_per_conn_rejections_total` labeling convention; lets
149
+ # operators correlate distribution rows with `ps`/`/proc` data
150
+ # without a separate worker_id <-> pid mapping table.
151
+ #
152
+ # Hot-path cost: one `@hg_mutex` acquisition per tick. That's
153
+ # acceptable for the audit metric: contention shows up only on
154
+ # the `tick + render` overlap, never inside the C accept loop
155
+ # (which uses its own atomic counter folded in at scrape time).
156
+ # Worth the simplicity over an extra lock-free per-thread cache.
157
+ REQUESTS_DISPATCH_TOTAL = :hyperion_requests_dispatch_total
158
+ WORKER_ID_LABEL_KEYS = %w[worker_id].freeze
159
+
160
+ def tick_worker_request(worker_id)
161
+ label = worker_id.nil? || worker_id.to_s.empty? ? '0' : worker_id.to_s
162
+ ensure_worker_request_family_registered!
163
+ increment_labeled_counter(REQUESTS_DISPATCH_TOTAL, [label])
164
+ end
165
+
166
+ # 2.12-E — Idempotently register the labeled-counter family. Public
167
+ # so `Server#run_c_accept_loop` can register at boot — the
168
+ # PrometheusExporter's C-loop fold-in is gated on the family being
169
+ # in the snapshot, and a 100% C-loop worker never goes through
170
+ # `tick_worker_request` to register lazily.
171
+ def ensure_worker_request_family_registered!
172
+ return if @worker_request_family_registered
173
+
174
+ register_labeled_counter(REQUESTS_DISPATCH_TOTAL, label_keys: WORKER_ID_LABEL_KEYS)
175
+ @worker_request_family_registered = true
176
+ end
177
+
116
178
  def snapshot
117
179
  result = Hash.new(0)
118
180
  counters_snapshot = @counters_mutex.synchronize { @thread_counters.dup }
@@ -124,6 +186,12 @@ module Hyperion
124
186
  end
125
187
 
126
188
  # Tests can call .reset! between examples to avoid cross-spec leakage.
189
+ #
190
+ # 2.13-A — also clear per-thread histogram and labeled-counter
191
+ # shards. Without this, an observation made on thread A in spec X
192
+ # would leak into spec Y's snapshot because the shard hashes are
193
+ # held alive by `@thread_histograms` / `@thread_labeled_counters`
194
+ # for the lifetime of the Metrics instance.
127
195
  def reset!
128
196
  @counters_mutex.synchronize do
129
197
  @thread_counters.each(&:clear)
@@ -133,6 +201,10 @@ module Hyperion
133
201
  @gauges.each_value(&:clear)
134
202
  @gauge_blocks.each_value(&:clear)
135
203
  end
204
+ @hg_thread_mutex.synchronize do
205
+ @thread_histograms.each(&:clear)
206
+ @thread_labeled_counters.each(&:clear)
207
+ end
136
208
  end
137
209
 
138
210
  # ---- 2.4-C histogram + gauge API ---------------------------------
@@ -159,27 +231,37 @@ module Hyperion
159
231
 
160
232
  # Observe `value` on a previously-registered histogram. `label_values`
161
233
  # MUST be supplied in the same order as `label_keys` at registration.
162
- # The hot path: one Hash lookup, one accumulator update under a mutex.
163
- # Allocation footprint per observe: zero on the cached-key path
164
- # (same labels seen before); one frozen Array on first observation
165
- # for a given label-set.
234
+ #
235
+ # 2.13-A Hot-path lock-free shard. Each thread keeps its own
236
+ # `{ name => { labels => HistogramAccumulator } }` map; observations
237
+ # never block on `@hg_mutex`. Snapshots merge across threads under
238
+ # the mutex (low-rate). Allocation footprint per observe: zero on
239
+ # the cached-key path; one frozen Array + one HistogramAccumulator
240
+ # on first observation for a given (name, label-set, thread).
241
+ #
242
+ # Bench impact (generic Rack hello, -t 32 -c 100):
243
+ # contention on `@hg_mutex` was the dominant tail latency
244
+ # contributor — this fires once per request via the per-route
245
+ # request-duration histogram, multiplied by N worker threads.
166
246
  def observe_histogram(name, value, label_values = EMPTY_LABELS)
167
- @hg_mutex.synchronize do
168
- meta = @histograms_meta[name]
169
- return unless meta # silently skip unregistered observations
170
-
171
- family = @histograms[name]
172
- accum = family[label_values]
173
- unless accum
174
- accum = HistogramAccumulator.new(meta[:buckets])
175
- # Freeze the label tuple so future identical-content tuples
176
- # hash to the same bucket — but we keep the original ref
177
- # provided by the caller as the canonical key so subsequent
178
- # observes with the same Array bypass the freeze step.
179
- family[label_values.frozen? ? label_values : label_values.dup.freeze] = accum
180
- end
181
- accum.observe(value)
247
+ meta = @histograms_meta[name]
248
+ return unless meta # silently skip unregistered observations
249
+
250
+ thread = Thread.current
251
+ shard = thread.thread_variable_get(@hg_thread_key)
252
+ shard = register_thread_histograms(thread) if shard.nil?
253
+
254
+ family = (shard[name] ||= {})
255
+ accum = family[label_values]
256
+ unless accum
257
+ accum = HistogramAccumulator.new(meta[:buckets])
258
+ # Freeze the label tuple so future identical-content tuples
259
+ # hash to the same bucket — but we keep the original ref
260
+ # provided by the caller as the canonical key so subsequent
261
+ # observes with the same Array bypass the freeze step.
262
+ family[label_values.frozen? ? label_values : label_values.dup.freeze] = accum
182
263
  end
264
+ accum.observe(value)
183
265
  end
184
266
 
185
267
  # Set a gauge value. `label_values` follows the same convention as
@@ -225,15 +307,40 @@ module Hyperion
225
307
 
226
308
  # Snapshot helpers — read-only views of the current histogram /
227
309
  # gauge state. The exporter uses these to render the scrape body.
310
+ #
311
+ # 2.13-A — Histograms merge across the per-thread shards on the
312
+ # snapshot path. The mutex is held only long enough to copy the
313
+ # shard list (every shard Hash is owned by one thread, so we can
314
+ # iterate its current contents safely while merging — torn reads
315
+ # of in-progress observations show as a slightly stale snapshot,
316
+ # never as a corrupted Accumulator).
228
317
  def histogram_snapshot
229
318
  out = {}
319
+
320
+ # Pre-seed names from registered families so a histogram with
321
+ # zero observations still appears in the scrape (matches the
322
+ # pre-2.13-A behaviour where `register_histogram` populated the
323
+ # `@histograms[name] = {}` slot eagerly).
230
324
  @hg_mutex.synchronize do
231
- @histograms.each do |name, family|
232
- per_labels = {}
233
- family.each { |labels, accum| per_labels[labels] = accum.snapshot }
234
- out[name] = { meta: @histograms_meta[name], series: per_labels }
325
+ @histograms_meta.each_key { |name| out[name] = { meta: @histograms_meta[name], series: {} } }
326
+ end
327
+
328
+ shards = @hg_thread_mutex.synchronize { @thread_histograms.dup }
329
+ shards.each do |shard|
330
+ shard.each do |name, family|
331
+ slot = (out[name] ||= { meta: @histograms_meta[name], series: {} })
332
+ series = slot[:series]
333
+ family.each do |labels, accum|
334
+ existing = series[labels]
335
+ if existing.nil?
336
+ series[labels] = accum.snapshot
337
+ else
338
+ merge_histogram_snapshot!(existing, accum)
339
+ end
340
+ end
235
341
  end
236
342
  end
343
+
237
344
  out
238
345
  end
239
346
 
@@ -271,19 +378,33 @@ module Hyperion
271
378
 
272
379
  # Labeled counter — separate from the legacy thread-local counter
273
380
  # surface (which is unlabeled and per-thread for hot-path
274
- # contention-free increments). Labeled counters take a mutex per
275
- # increment, but they're called from low-rate paths (per-conn
276
- # rejection ~ kHz worst case, vs M+req/s on the unlabeled side)
277
- # so the contention cost is invisible.
381
+ # contention-free increments).
382
+ #
383
+ # 2.13-A moved to a per-thread shard for the same reason as
384
+ # `observe_histogram`: the previous "low-rate paths" claim was wrong
385
+ # (`tick_worker_request` is per-Rack-request), and at -t 32 the
386
+ # single mutex serialised every worker thread on the request-
387
+ # completion tail. Per-thread shards remove the contention;
388
+ # `labeled_counter_snapshot` merges shards under the mutex.
278
389
  def increment_labeled_counter(name, label_values = EMPTY_LABELS, by = 1)
279
- @hg_mutex.synchronize do
280
- @labeled_counters_meta ||= {}
281
- @labeled_counters_meta[name] ||= { label_keys: [].freeze }
282
- @labeled_counters ||= {}
283
- family = (@labeled_counters[name] ||= {})
284
- key = label_values.frozen? ? label_values : label_values.dup.freeze
285
- family[key] = (family[key] || 0) + by
390
+ thread = Thread.current
391
+ shard = thread.thread_variable_get(@lc_thread_key)
392
+ shard = register_thread_labeled_counters(thread) if shard.nil?
393
+
394
+ # Defensive: ensure the family meta exists so `register_labeled_counter`
395
+ # is not strictly required for hot-path increments. Pre-2.13-A the
396
+ # mutex'd path lazily registered an unlabeled meta; we mirror that
397
+ # under @hg_mutex so the shape stays consistent across threads.
398
+ unless @labeled_counters_meta && @labeled_counters_meta[name]
399
+ @hg_mutex.synchronize do
400
+ @labeled_counters_meta ||= {}
401
+ @labeled_counters_meta[name] ||= { label_keys: [].freeze }
402
+ end
286
403
  end
404
+
405
+ family = (shard[name] ||= {})
406
+ key = label_values.frozen? ? label_values : label_values.dup.freeze
407
+ family[key] = (family[key] || 0) + by
287
408
  end
288
409
 
289
410
  def register_labeled_counter(name, label_keys: [])
@@ -295,14 +416,27 @@ module Hyperion
295
416
  end
296
417
  end
297
418
 
419
+ # 2.13-A — Snapshot merges per-thread shards. Pre-seeded with
420
+ # `@labeled_counters_meta` so registered-but-unticked families still
421
+ # show up in the scrape (matches pre-2.13-A behaviour where
422
+ # `register_labeled_counter` eagerly created the `[name] = {}` slot).
298
423
  def labeled_counter_snapshot
299
424
  out = {}
300
425
  @hg_mutex.synchronize do
301
- (@labeled_counters || {}).each do |name, family|
302
- per_labels = {}
303
- family.each { |labels, count| per_labels[labels] = count }
426
+ (@labeled_counters_meta || {}).each do |name, meta|
427
+ out[name] = { meta: meta, series: {} }
428
+ end
429
+ end
430
+
431
+ shards = @hg_thread_mutex.synchronize { @thread_labeled_counters.dup }
432
+ shards.each do |shard|
433
+ shard.each do |name, family|
304
434
  meta = (@labeled_counters_meta || {})[name] || { label_keys: [].freeze }
305
- out[name] = { meta: meta, series: per_labels }
435
+ slot = (out[name] ||= { meta: meta, series: {} })
436
+ series = slot[:series]
437
+ family.each do |labels, count|
438
+ series[labels] = (series[labels] || 0) + count
439
+ end
306
440
  end
307
441
  end
308
442
  out
@@ -354,6 +488,46 @@ module Hyperion
354
488
  @counters_mutex.synchronize { @thread_counters << counters }
355
489
  counters
356
490
  end
491
+
492
+ # 2.13-A — allocate this thread's histogram shard and register it
493
+ # in `@thread_histograms` so snapshots find it. Idempotent per
494
+ # thread: callers always check `thread_variable_get` first.
495
+ def register_thread_histograms(thread)
496
+ shard = {}
497
+ thread.thread_variable_set(@hg_thread_key, shard)
498
+ @hg_thread_mutex.synchronize { @thread_histograms << shard }
499
+ shard
500
+ end
501
+
502
+ # 2.13-A — same shape as register_thread_histograms but for labeled
503
+ # counters. Each thread gets its own `{ name => { labels => Integer } }`
504
+ # map; the snapshot path merges across shards.
505
+ def register_thread_labeled_counters(thread)
506
+ shard = {}
507
+ thread.thread_variable_set(@lc_thread_key, shard)
508
+ @hg_thread_mutex.synchronize { @thread_labeled_counters << shard }
509
+ shard
510
+ end
511
+
512
+ # 2.13-A — fold a per-thread HistogramAccumulator's contents into an
513
+ # already-snapshotted entry (`{buckets:, counts:, sum:, count:}`).
514
+ # Both arguments share the same `:buckets` so the bucket index axis
515
+ # is identical; we sum `counts` per bucket plus the scalars. Used
516
+ # when two threads observed the SAME (name, labels) pair — a
517
+ # legitimate steady state on a Rack route hit by concurrent
518
+ # workers.
519
+ def merge_histogram_snapshot!(existing, accum)
520
+ counts = existing[:counts]
521
+ acc_counts = accum.counts
522
+ i = 0
523
+ len = counts.length
524
+ while i < len
525
+ counts[i] += acc_counts[i]
526
+ i += 1
527
+ end
528
+ existing[:sum] += accum.sum
529
+ existing[:count] += accum.count
530
+ end
357
531
  end
358
532
  end
359
533
 
@@ -74,9 +74,22 @@ module Hyperion
74
74
  hyperion_threadpool_queue_depth: {
75
75
  help: 'In-flight count in the worker ThreadPool inbox (snapshot at scrape time)',
76
76
  type: 'gauge'
77
+ },
78
+ # 2.12-E — per-worker request counter for the SO_REUSEPORT
79
+ # load-balancing audit. One series per worker (label_value =
80
+ # `Process.pid.to_s`); ticks on every dispatched request from
81
+ # every dispatch shape. Operators scrape /-/metrics N times in
82
+ # cluster mode to gather distribution across workers.
83
+ hyperion_requests_dispatch_total: {
84
+ help: 'Requests dispatched per worker (PID-labeled), across all dispatch modes',
85
+ type: 'counter'
77
86
  }
78
87
  }.freeze
79
88
 
89
+ # 2.12-E — name of the per-worker request counter family. Hoisted to
90
+ # a constant so the C-loop fold-in below stays declarative.
91
+ REQUESTS_DISPATCH_TOTAL = :hyperion_requests_dispatch_total
92
+
80
93
  def render(stats)
81
94
  buf = +''
82
95
  grouped_status = {}
@@ -129,10 +142,72 @@ module Hyperion
129
142
  buf << render(metrics_sink.snapshot)
130
143
  buf << render_histograms(metrics_sink.histogram_snapshot)
131
144
  buf << render_gauges(metrics_sink.gauge_snapshot)
132
- buf << render_labeled_counters(metrics_sink.labeled_counter_snapshot)
145
+ labeled = merge_c_loop_into_dispatch_snapshot(metrics_sink.labeled_counter_snapshot)
146
+ buf << render_labeled_counters(labeled)
133
147
  buf
134
148
  end
135
149
 
150
+ # 2.12-E — merge `Hyperion::Http::PageCache.c_loop_requests_total`
151
+ # (process-global atomic ticked by the C accept4 + io_uring loops)
152
+ # into the `hyperion_requests_dispatch_total{worker_id=PID}` series
153
+ # for the current worker. Without this fold-in, a `-w 4` cluster
154
+ # serving from the C accept loop would scrape zeros from every
155
+ # worker even though the loop's atomic counter is ticking — the
156
+ # loop bypasses `Connection#serve`, so no Ruby-side
157
+ # `tick_worker_request` call ever lands.
158
+ #
159
+ # We only fold in when the labeled-counter family is ALREADY in
160
+ # the snapshot — i.e., something on the Ruby side has called
161
+ # `Metrics#tick_worker_request` at least once on this sink. The
162
+ # `Server#run_c_accept_loop` boot path performs a single
163
+ # registration tick on the runtime's metrics sink so this
164
+ # condition holds for any production cluster engaging the C loop.
165
+ #
166
+ # Why the gate matters: spec fixtures that `Hyperion::Metrics.new`
167
+ # to assert "empty body when no metrics have been recorded" would
168
+ # otherwise pull in a process-global atomic bumped by an earlier
169
+ # spec's C-loop run. Those fixtures never register the family, so
170
+ # the fold-in skips them cleanly.
171
+ #
172
+ # Idempotent on snapshots that already contain a series for the
173
+ # current PID. Pure on the input — we build a shallow copy of the
174
+ # snapshot so the live Hash behind `Metrics#labeled_counter_snapshot`
175
+ # isn't mutated.
176
+ #
177
+ # Defensive: when the C ext isn't loaded (JRuby / TruffleRuby) we
178
+ # silently skip — the snapshot stays Ruby-only.
179
+ def merge_c_loop_into_dispatch_snapshot(snap)
180
+ family = snap[REQUESTS_DISPATCH_TOTAL]
181
+ return snap if family.nil?
182
+
183
+ c_loop_count = c_loop_requests_total
184
+ return snap if c_loop_count <= 0
185
+
186
+ pid_label = Process.pid.to_s
187
+ merged_series = family[:series].dup
188
+ key = [pid_label].freeze
189
+ existing_key = merged_series.keys.find { |k| k.first == pid_label } || key
190
+ merged_series[existing_key] = (merged_series[existing_key] || 0) + c_loop_count
191
+
192
+ merged = snap.dup
193
+ merged[REQUESTS_DISPATCH_TOTAL] = {
194
+ meta: family[:meta] || { label_keys: %w[worker_id].freeze },
195
+ series: merged_series
196
+ }
197
+ merged
198
+ end
199
+
200
+ def c_loop_requests_total
201
+ return 0 unless defined?(::Hyperion::Http::PageCache)
202
+ return 0 unless ::Hyperion::Http::PageCache.respond_to?(:c_loop_requests_total)
203
+
204
+ ::Hyperion::Http::PageCache.c_loop_requests_total.to_i
205
+ rescue StandardError
206
+ # The scrape path must never raise — observability code degrades
207
+ # to "no fold-in" rather than failing the metrics endpoint.
208
+ 0
209
+ end
210
+
136
211
  def render_histograms(snap)
137
212
  buf = +''
138
213
  snap.each do |name, payload|
@@ -0,0 +1,159 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hyperion
4
+ class Server
5
+ # 2.12-C — Connection lifecycle in C.
6
+ #
7
+ # Engaged by `Server#start_raw_loop` when ALL of the following hold:
8
+ #
9
+ # * The listener is plain TCP (no TLS, no h2 ALPN dance).
10
+ # * The route table has at least one `RouteTable::StaticEntry`
11
+ # registration (i.e. `Server.handle_static` was called).
12
+ # * The route table has NO non-StaticEntry registrations
13
+ # (any `Server.handle(:GET, '/api', dynamic_handler)` disables
14
+ # the C path; the C loop only knows how to write prebuilt
15
+ # responses).
16
+ # * The `HYPERION_C_ACCEPT_LOOP` env knob is not set to `"0"` /
17
+ # `"off"` (operator escape hatch for debug).
18
+ #
19
+ # On engage, the Ruby accept loop is *not* run for this listener;
20
+ # `Hyperion::Http::PageCache.run_static_accept_loop` drives the
21
+ # accept-and-serve loop entirely in C and only re-enters Ruby for:
22
+ #
23
+ # 1. Per-request lifecycle hooks
24
+ # (`Runtime#fire_request_start` / `fire_request_end`), gated
25
+ # by a single C-side integer flag so the no-hook hot path
26
+ # stays one syscall.
27
+ # 2. Connection handoff: requests that don't match a `StaticEntry`
28
+ # (or are malformed, h2/upgrade, or carry a body) are passed
29
+ # back as `(fd, partial_buffer)` — Ruby resumes ownership of
30
+ # the fd and dispatches via the regular `Connection` path.
31
+ #
32
+ # The wiring lives in this module so the conditional logic stays
33
+ # out of the Server hot-path entry methods.
34
+ module ConnectionLoop
35
+ module_function
36
+
37
+ # Whether the C accept loop is available and the env didn't
38
+ # disable it.
39
+ def available?
40
+ return false unless defined?(::Hyperion::Http::PageCache)
41
+ return false unless ::Hyperion::Http::PageCache.respond_to?(:run_static_accept_loop)
42
+
43
+ env = ENV['HYPERION_C_ACCEPT_LOOP']
44
+ env.nil? || !%w[0 off false no].include?(env.downcase)
45
+ end
46
+
47
+ # 2.12-D — whether to engage the io_uring accept loop variant
48
+ # over the 2.12-C `accept4` loop. All four conditions must hold:
49
+ #
50
+ # 1. Operator opted in via `HYPERION_IO_URING_ACCEPT=1`. This
51
+ # is OFF by default for 2.12.0 — flipping the default to ON
52
+ # is a 2.13 decision after production-soak.
53
+ # 2. The C ext was compiled with `HAVE_LIBURING` (probed at
54
+ # gem-install time via `extconf.rb` — needs `liburing-dev`
55
+ # headers). Builds without it ship the stub method that
56
+ # returns `:unavailable` regardless of the env var.
57
+ # 3. `Hyperion::Http::PageCache.run_static_io_uring_loop` is
58
+ # defined (paranoia: the symbol always exists on builds
59
+ # that loaded the C ext, but the check keeps us from
60
+ # NameError'ing on partial installs).
61
+ # 4. A liburing runtime probe — opening a tiny ring with
62
+ # `io_uring_queue_init`. The probe lives inside the C
63
+ # method itself (`run_static_io_uring_loop` returns
64
+ # `:unavailable` if `io_uring_queue_init` fails); we
65
+ # don't pre-probe here because that would require holding
66
+ # a ring open across the eligibility check, and the
67
+ # penalty for "engaged but probe-fail at run time" is
68
+ # one cheap fall-through to the `accept4` path.
69
+ def io_uring_eligible?
70
+ return false unless available?
71
+ return false unless ::Hyperion::Http::PageCache.respond_to?(:run_static_io_uring_loop)
72
+ return false unless ::Hyperion::Http::PageCache.respond_to?(:io_uring_loop_compiled?) &&
73
+ ::Hyperion::Http::PageCache.io_uring_loop_compiled?
74
+
75
+ env = ENV['HYPERION_IO_URING_ACCEPT']
76
+ return false unless env
77
+
78
+ %w[1 on true yes].include?(env.downcase)
79
+ end
80
+
81
+ # Whether the route table is C-loop eligible: only `StaticEntry`
82
+ # handlers, at least one of them, no dynamic handlers anywhere.
83
+ def eligible_route_table?(route_table)
84
+ return false unless route_table
85
+
86
+ any_static = false
87
+ route_table.instance_variable_get(:@routes).each_value do |path_table|
88
+ path_table.each_value do |handler|
89
+ return false unless handler.is_a?(::Hyperion::Server::RouteTable::StaticEntry)
90
+
91
+ any_static = true
92
+ end
93
+ end
94
+ any_static
95
+ end
96
+
97
+ # Build a lifecycle callback that, when invoked from the C loop
98
+ # with `(method_str, path_str)`, fires the runtime's
99
+ # `fire_request_start` / `fire_request_end` hooks against a
100
+ # minimal `Hyperion::Request` value object. `env=nil` and the
101
+ # response slot carries the `:c_static` symbol (just a marker —
102
+ # the wire write already happened in C and we have no
103
+ # `[status, headers, body]` tuple to hand back).
104
+ #
105
+ # The proc captures `runtime` so multi-tenant deployments with
106
+ # per-Server runtimes route hooks to the right observer
107
+ # registry. Allocation cost: one Request per request when
108
+ # hooks are active. The C loop only invokes this callback when
109
+ # `lifecycle_active?` is true; the no-hook path pays nothing.
110
+ def build_lifecycle_callback(runtime)
111
+ lambda do |method_str, path_str|
112
+ request = ::Hyperion::Request.new(
113
+ method: method_str,
114
+ path: path_str,
115
+ query_string: nil,
116
+ http_version: 'HTTP/1.1',
117
+ headers: {},
118
+ body: nil
119
+ )
120
+ if runtime.has_request_hooks?
121
+ runtime.fire_request_start(request, nil)
122
+ runtime.fire_request_end(request, nil, :c_static, nil)
123
+ end
124
+ nil
125
+ rescue StandardError
126
+ # Hook errors are already swallowed inside `Runtime#fire_*`;
127
+ # this rescue catches Request allocation oddities so a
128
+ # misbehaving observer can't take down the C loop.
129
+ nil
130
+ end
131
+ end
132
+
133
+ # Build the handoff callback the C loop invokes when a
134
+ # connection's first request can't be served from the static
135
+ # cache. Receives `(fd, partial_buffer_or_nil)` — Ruby owns
136
+ # the fd from that point on. We wrap the fd in a `Socket`
137
+ # (so `apply_timeout` and the rest of the Connection path see
138
+ # the same surface they always see) and dispatch through the
139
+ # server's existing `dispatch_handed_off` helper.
140
+ def build_handoff_callback(server)
141
+ lambda do |fd, partial|
142
+ server.send(:dispatch_handed_off, fd, partial)
143
+ rescue StandardError => e
144
+ server.send(:runtime_logger).warn do
145
+ { message: 'C loop handoff dispatch failed',
146
+ error: e.message, error_class: e.class.name }
147
+ end
148
+ # Always close the fd if dispatch raised — Ruby owns it.
149
+ begin
150
+ require 'socket'
151
+ ::Socket.for_fd(fd).close
152
+ rescue StandardError
153
+ nil
154
+ end
155
+ end
156
+ end
157
+ end
158
+ end
159
+ end