hyperion-rb 2.12.0 → 2.14.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +1117 -0
- data/README.md +301 -674
- data/ext/hyperion_http/page_cache.c +538 -43
- data/ext/hyperion_http/parser.c +382 -51
- data/lib/hyperion/adapter/rack.rb +303 -4
- data/lib/hyperion/connection.rb +65 -4
- data/lib/hyperion/http2_handler.rb +348 -21
- data/lib/hyperion/metrics.rb +174 -38
- data/lib/hyperion/server/connection_loop.rb +104 -6
- data/lib/hyperion/server/route_table.rb +64 -0
- data/lib/hyperion/server.rb +202 -2
- data/lib/hyperion/version.rb +1 -1
- metadata +1 -1
data/lib/hyperion/metrics.rb
CHANGED
|
@@ -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
|
|
@@ -162,6 +186,12 @@ module Hyperion
|
|
|
162
186
|
end
|
|
163
187
|
|
|
164
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.
|
|
165
195
|
def reset!
|
|
166
196
|
@counters_mutex.synchronize do
|
|
167
197
|
@thread_counters.each(&:clear)
|
|
@@ -171,6 +201,10 @@ module Hyperion
|
|
|
171
201
|
@gauges.each_value(&:clear)
|
|
172
202
|
@gauge_blocks.each_value(&:clear)
|
|
173
203
|
end
|
|
204
|
+
@hg_thread_mutex.synchronize do
|
|
205
|
+
@thread_histograms.each(&:clear)
|
|
206
|
+
@thread_labeled_counters.each(&:clear)
|
|
207
|
+
end
|
|
174
208
|
end
|
|
175
209
|
|
|
176
210
|
# ---- 2.4-C histogram + gauge API ---------------------------------
|
|
@@ -197,27 +231,37 @@ module Hyperion
|
|
|
197
231
|
|
|
198
232
|
# Observe `value` on a previously-registered histogram. `label_values`
|
|
199
233
|
# MUST be supplied in the same order as `label_keys` at registration.
|
|
200
|
-
#
|
|
201
|
-
#
|
|
202
|
-
#
|
|
203
|
-
#
|
|
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.
|
|
204
246
|
def observe_histogram(name, value, label_values = EMPTY_LABELS)
|
|
205
|
-
@
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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
|
|
220
263
|
end
|
|
264
|
+
accum.observe(value)
|
|
221
265
|
end
|
|
222
266
|
|
|
223
267
|
# Set a gauge value. `label_values` follows the same convention as
|
|
@@ -263,15 +307,40 @@ module Hyperion
|
|
|
263
307
|
|
|
264
308
|
# Snapshot helpers — read-only views of the current histogram /
|
|
265
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).
|
|
266
317
|
def histogram_snapshot
|
|
267
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).
|
|
268
324
|
@hg_mutex.synchronize do
|
|
269
|
-
@
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
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
|
|
273
341
|
end
|
|
274
342
|
end
|
|
343
|
+
|
|
275
344
|
out
|
|
276
345
|
end
|
|
277
346
|
|
|
@@ -309,19 +378,33 @@ module Hyperion
|
|
|
309
378
|
|
|
310
379
|
# Labeled counter — separate from the legacy thread-local counter
|
|
311
380
|
# surface (which is unlabeled and per-thread for hot-path
|
|
312
|
-
# contention-free increments).
|
|
313
|
-
#
|
|
314
|
-
#
|
|
315
|
-
#
|
|
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.
|
|
316
389
|
def increment_labeled_counter(name, label_values = EMPTY_LABELS, by = 1)
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
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
|
|
324
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
|
|
325
408
|
end
|
|
326
409
|
|
|
327
410
|
def register_labeled_counter(name, label_keys: [])
|
|
@@ -333,14 +416,27 @@ module Hyperion
|
|
|
333
416
|
end
|
|
334
417
|
end
|
|
335
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).
|
|
336
423
|
def labeled_counter_snapshot
|
|
337
424
|
out = {}
|
|
338
425
|
@hg_mutex.synchronize do
|
|
339
|
-
(@
|
|
340
|
-
|
|
341
|
-
|
|
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|
|
|
342
434
|
meta = (@labeled_counters_meta || {})[name] || { label_keys: [].freeze }
|
|
343
|
-
out[name]
|
|
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
|
|
344
440
|
end
|
|
345
441
|
end
|
|
346
442
|
out
|
|
@@ -392,6 +488,46 @@ module Hyperion
|
|
|
392
488
|
@counters_mutex.synchronize { @thread_counters << counters }
|
|
393
489
|
counters
|
|
394
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
|
|
395
531
|
end
|
|
396
532
|
end
|
|
397
533
|
|
|
@@ -34,6 +34,92 @@ module Hyperion
|
|
|
34
34
|
module ConnectionLoop
|
|
35
35
|
module_function
|
|
36
36
|
|
|
37
|
+
# 2.14-B — bound applied to the wake-connect dial inside
|
|
38
|
+
# `Server#stop`. The listener is local — a successful connect
|
|
39
|
+
# is sub-millisecond — so the cap exists purely as a sanity
|
|
40
|
+
# bound for the pathological case where the listener was
|
|
41
|
+
# already torn down (Errno::ECONNREFUSED is fast) or the
|
|
42
|
+
# kernel netstack is somehow stuck (e.g. CI under heavy load).
|
|
43
|
+
WAKE_CONNECT_TIMEOUT_SECONDS = 1.0
|
|
44
|
+
|
|
45
|
+
# 2.14-B — number of wake-connect dials issued per `Server#stop`.
|
|
46
|
+
# In single-server / `:share` cluster mode (Darwin/BSD), one dial
|
|
47
|
+
# is enough — the listener is shared and any wake races to a
|
|
48
|
+
# parked accept call. In `:reuseport` cluster mode (Linux), the
|
|
49
|
+
# kernel hashes incoming SYNs across each worker's per-process
|
|
50
|
+
# listener fd; one dial may hash to a sibling whose stop hasn't
|
|
51
|
+
# progressed, leaving THIS worker's accept thread parked. K=8
|
|
52
|
+
# drops the miss probability to <1% for realistic worker counts
|
|
53
|
+
# (≤32 workers per host) and adds at most ~8ms to a stop call —
|
|
54
|
+
# well below the master-side `graceful_timeout` (30s default).
|
|
55
|
+
WAKE_CONNECT_BURST = 8
|
|
56
|
+
|
|
57
|
+
# 2.14-B — Wake any thread parked in `accept(2)` on the listener
|
|
58
|
+
# bound at `host:port` by dialing one (or `count`) throwaway TCP
|
|
59
|
+
# connections.
|
|
60
|
+
#
|
|
61
|
+
# Background. On Linux ≥ 6.x, calling `close()` on a listening
|
|
62
|
+
# socket from one thread does NOT interrupt another thread that
|
|
63
|
+
# is currently blocked in `accept(2)` on that same fd — the
|
|
64
|
+
# kernel silently dropped the close-wake guarantee that
|
|
65
|
+
# `Server#stop` (and 2.13-C's spec teardown) had relied on.
|
|
66
|
+
# Without this helper, the C accept loop stays parked until a
|
|
67
|
+
# real connection arrives, which during a SIGTERM-driven graceful
|
|
68
|
+
# shutdown means "until SIGKILL".
|
|
69
|
+
#
|
|
70
|
+
# The fix is structural: dial a throwaway TCP connection at the
|
|
71
|
+
# listener's bound address. The accept call returns with the new
|
|
72
|
+
# fd, the C loop services it (a 0-byte read drops it), then
|
|
73
|
+
# re-checks `hyp_cl_stop` between accepts and exits cleanly. The
|
|
74
|
+
# 2.13-C connection_loop_spec helper does the same thing in spec
|
|
75
|
+
# land — this is the production-side mirror.
|
|
76
|
+
#
|
|
77
|
+
# Burst semantics. With SO_REUSEPORT (Linux cluster mode), the
|
|
78
|
+
# kernel hashes each SYN to one of the N still-open per-worker
|
|
79
|
+
# listeners. A single dial from worker A may hash to worker B —
|
|
80
|
+
# leaving A's parked accept un-woken. Dialing K times (default
|
|
81
|
+
# `WAKE_CONNECT_BURST`) drives the miss probability down to
|
|
82
|
+
# negligible for typical worker counts.
|
|
83
|
+
#
|
|
84
|
+
# Failure-tolerant by construction:
|
|
85
|
+
# * `Errno::ECONNREFUSED` — listener already closed (the close
|
|
86
|
+
# raced ahead of us). Nothing to wake; bail out of the burst
|
|
87
|
+
# so we don't spend the timeout budget on doomed dials.
|
|
88
|
+
# * `Errno::EADDRNOTAVAIL` — interface gone. Same.
|
|
89
|
+
# * Connect timeout — kernel netstack is stuck; we tried, the
|
|
90
|
+
# caller's `thread.join(timeout)` will surface the symptom.
|
|
91
|
+
# * Any other socket error — log nothing (we may be running
|
|
92
|
+
# inside a signal handler thread); just swallow.
|
|
93
|
+
def wake_listener(host, port, connect_timeout: WAKE_CONNECT_TIMEOUT_SECONDS,
|
|
94
|
+
count: 1)
|
|
95
|
+
return unless host && port
|
|
96
|
+
return if count <= 0
|
|
97
|
+
|
|
98
|
+
count.times do
|
|
99
|
+
break unless dial_wake_once(host, port, connect_timeout)
|
|
100
|
+
end
|
|
101
|
+
nil
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# 2.14-B — single dial. Returns true on success (continue
|
|
105
|
+
# bursting), false on a "listener gone" outcome (abort the burst
|
|
106
|
+
# so we don't waste the timeout budget on N×ECONNREFUSED).
|
|
107
|
+
def dial_wake_once(host, port, connect_timeout)
|
|
108
|
+
::Socket.tcp(host, port, connect_timeout: connect_timeout, &:close)
|
|
109
|
+
true
|
|
110
|
+
rescue Errno::ECONNREFUSED, Errno::EADDRNOTAVAIL, Errno::EHOSTUNREACH,
|
|
111
|
+
Errno::ENETUNREACH
|
|
112
|
+
# Listener gone — no point retrying, the kernel will refuse
|
|
113
|
+
# every dial in this burst the same way.
|
|
114
|
+
false
|
|
115
|
+
rescue Errno::ETIMEDOUT, Errno::ECONNRESET, Errno::EPIPE,
|
|
116
|
+
Errno::EBADF, IOError, SocketError
|
|
117
|
+
# Transient — keep bursting in case a later dial races into a
|
|
118
|
+
# still-open sibling listener (REUSEPORT cluster mode).
|
|
119
|
+
true
|
|
120
|
+
end
|
|
121
|
+
private_class_method :dial_wake_once
|
|
122
|
+
|
|
37
123
|
# Whether the C accept loop is available and the env didn't
|
|
38
124
|
# disable it.
|
|
39
125
|
def available?
|
|
@@ -78,20 +164,32 @@ module Hyperion
|
|
|
78
164
|
%w[1 on true yes].include?(env.downcase)
|
|
79
165
|
end
|
|
80
166
|
|
|
81
|
-
# Whether the route table is C-loop eligible:
|
|
82
|
-
#
|
|
167
|
+
# Whether the route table is C-loop eligible: every registered
|
|
168
|
+
# entry is either a `StaticEntry` (2.12-C path) or a
|
|
169
|
+
# `DynamicBlockEntry` (2.14-A path), and the table has at least
|
|
170
|
+
# one of either. Legacy `Server.handle(method, path, handler)`
|
|
171
|
+
# registrations (where `handler` takes a `Hyperion::Request`)
|
|
172
|
+
# disable the C path — those still flow through `Connection#serve`.
|
|
83
173
|
def eligible_route_table?(route_table)
|
|
84
174
|
return false unless route_table
|
|
85
175
|
|
|
86
|
-
|
|
176
|
+
any_eligible = false
|
|
87
177
|
route_table.instance_variable_get(:@routes).each_value do |path_table|
|
|
88
178
|
path_table.each_value do |handler|
|
|
89
|
-
return false unless
|
|
179
|
+
return false unless eligible_entry?(handler)
|
|
90
180
|
|
|
91
|
-
|
|
181
|
+
any_eligible = true
|
|
92
182
|
end
|
|
93
183
|
end
|
|
94
|
-
|
|
184
|
+
any_eligible
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# 2.14-A — predicate split out so specs and the engagement check
|
|
188
|
+
# can introspect single entries. Lives here (rather than on the
|
|
189
|
+
# entry classes) so the eligibility surface stays in one place.
|
|
190
|
+
def eligible_entry?(handler)
|
|
191
|
+
handler.is_a?(::Hyperion::Server::RouteTable::StaticEntry) ||
|
|
192
|
+
handler.is_a?(::Hyperion::Server::RouteTable::DynamicBlockEntry)
|
|
95
193
|
end
|
|
96
194
|
|
|
97
195
|
# Build a lifecycle callback that, when invoked from the C loop
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'stringio'
|
|
4
|
+
|
|
3
5
|
module Hyperion
|
|
4
6
|
class Server
|
|
5
7
|
# 2.10-D — direct-dispatch route registry. Mirrors agoo's
|
|
@@ -86,6 +88,68 @@ module Hyperion
|
|
|
86
88
|
end
|
|
87
89
|
end
|
|
88
90
|
|
|
91
|
+
# 2.14-A — wrapper for a Rack-style block registered via
|
|
92
|
+
# `Server.handle(:GET, '/path') { |env| [...] }`. Differs from
|
|
93
|
+
# `StaticEntry` in that the response is computed per-request
|
|
94
|
+
# rather than baked at registration time — but the route table
|
|
95
|
+
# entry shape is uniform, so the C accept loop can branch on
|
|
96
|
+
# `is_a?(DynamicBlockEntry)` AFTER the StaticEntry check and
|
|
97
|
+
# invoke the block via the registered C-loop dispatch helper.
|
|
98
|
+
#
|
|
99
|
+
# The struct holds:
|
|
100
|
+
# * `method` — request-method symbol (`:GET`, `:POST`, ...)
|
|
101
|
+
# * `path` — exact-match path String (frozen)
|
|
102
|
+
# * `block` — the registered Proc / lambda; receives a Rack
|
|
103
|
+
# env hash and must return a `[status, headers, body]`
|
|
104
|
+
# triple per the Rack spec. The C accept loop hands it a
|
|
105
|
+
# populated env via the `Adapter::Rack.dispatch_for_c_loop`
|
|
106
|
+
# helper; the block sees the same env shape Rack apps
|
|
107
|
+
# normally see (HTTP_*, REQUEST_METHOD, PATH_INFO, etc.).
|
|
108
|
+
#
|
|
109
|
+
# Calling the entry directly (the legacy fall-through path used
|
|
110
|
+
# when the C accept loop is NOT engaged — TLS listeners, mixed
|
|
111
|
+
# tables, operator escape hatch via `HYPERION_C_ACCEPT_LOOP=0`)
|
|
112
|
+
# delegates straight to the block with a freshly-built env via
|
|
113
|
+
# the existing `Adapter::Rack#call` machinery. The Connection
|
|
114
|
+
# path's direct-route dispatcher already handles
|
|
115
|
+
# `respond_to?(:call)` entries by invoking them with a
|
|
116
|
+
# `Hyperion::Request` value object — we route through that
|
|
117
|
+
# surface so the legacy fallback stays bit-identical to a
|
|
118
|
+
# 2.13-shape `Server.handle` registration.
|
|
119
|
+
DynamicBlockEntry = Struct.new(:method, :path, :block) do
|
|
120
|
+
# Legacy direct-route surface: `RouteTable#lookup` → handler →
|
|
121
|
+
# `handler.call(request)` returning a `[status, headers, body]`
|
|
122
|
+
# triple. Used by the Connection path when the C accept loop is
|
|
123
|
+
# disengaged (TLS, mixed tables). We hand the block a minimal
|
|
124
|
+
# env hash so it sees the same Rack-style API regardless of
|
|
125
|
+
# which dispatch shape served the request.
|
|
126
|
+
def call(request)
|
|
127
|
+
env = build_legacy_env(request)
|
|
128
|
+
block.call(env)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
private
|
|
132
|
+
|
|
133
|
+
def build_legacy_env(request)
|
|
134
|
+
headers = request.respond_to?(:headers) ? (request.headers || {}) : {}
|
|
135
|
+
env = {
|
|
136
|
+
'REQUEST_METHOD' => request.method,
|
|
137
|
+
'PATH_INFO' => request.path,
|
|
138
|
+
'QUERY_STRING' => request.query_string.to_s,
|
|
139
|
+
'SERVER_NAME' => 'localhost',
|
|
140
|
+
'SERVER_PORT' => '80',
|
|
141
|
+
'rack.input' => StringIO.new(request.body.to_s),
|
|
142
|
+
'rack.errors' => $stderr,
|
|
143
|
+
'rack.url_scheme' => 'http'
|
|
144
|
+
}
|
|
145
|
+
headers.each do |name, value|
|
|
146
|
+
key = "HTTP_#{name.to_s.upcase.tr('-', '_')}"
|
|
147
|
+
env[key] = value
|
|
148
|
+
end
|
|
149
|
+
env
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
89
153
|
def initialize
|
|
90
154
|
# Per-method Hash so the lookup is `@routes[:GET][path]`
|
|
91
155
|
# — two integer-keyed-Hash hits. Pre-allocate the seven
|