hyperion-rb 2.10.1 → 2.12.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 +771 -0
- data/README.md +135 -5
- data/ext/hyperion_http/extconf.rb +41 -0
- data/ext/hyperion_http/io_uring_loop.c +710 -0
- data/ext/hyperion_http/page_cache.c +1032 -0
- data/ext/hyperion_http/page_cache_internal.h +132 -0
- data/lib/hyperion/connection.rb +14 -0
- data/lib/hyperion/dispatch_mode.rb +19 -1
- data/lib/hyperion/h2_codec.rb +52 -5
- data/lib/hyperion/http2_handler.rb +399 -41
- data/lib/hyperion/metrics.rb +38 -0
- data/lib/hyperion/prometheus_exporter.rb +76 -1
- data/lib/hyperion/server/connection_loop.rb +159 -0
- data/lib/hyperion/server.rb +183 -0
- data/lib/hyperion/thread_pool.rb +23 -7
- data/lib/hyperion/version.rb +1 -1
- metadata +4 -1
|
@@ -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
|
-
|
|
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
|
data/lib/hyperion/server.rb
CHANGED
|
@@ -6,6 +6,7 @@ require 'async'
|
|
|
6
6
|
require 'async/scheduler'
|
|
7
7
|
|
|
8
8
|
require_relative 'server/route_table'
|
|
9
|
+
require_relative 'server/connection_loop'
|
|
9
10
|
|
|
10
11
|
module Hyperion
|
|
11
12
|
# Phase 2a server: bind a TCPServer, accept connections, schedule each on its
|
|
@@ -400,7 +401,16 @@ module Hyperion
|
|
|
400
401
|
# a worker via the thread pool, or are served inline when no pool is
|
|
401
402
|
# configured (thread_count: 0). Matches the dispatch contract used by
|
|
402
403
|
# the TLS path; just skips the irrelevant h2/ALPN branch.
|
|
404
|
+
#
|
|
405
|
+
# 2.12-C — when the route table is composed entirely of `StaticEntry`
|
|
406
|
+
# registrations (and at least one is present), and the C ext is
|
|
407
|
+
# available, the entire accept-and-serve loop runs in C via
|
|
408
|
+
# `Hyperion::Http::PageCache.run_static_accept_loop`. Ruby is only
|
|
409
|
+
# re-entered for lifecycle hooks (gated by a C-side flag) and for
|
|
410
|
+
# the handoff path when a request doesn't match any StaticEntry.
|
|
403
411
|
def start_raw_loop
|
|
412
|
+
return run_c_accept_loop if engage_c_accept_loop?
|
|
413
|
+
|
|
404
414
|
until @stopped
|
|
405
415
|
socket = accept_or_nil
|
|
406
416
|
next unless socket
|
|
@@ -429,6 +439,179 @@ module Hyperion
|
|
|
429
439
|
end
|
|
430
440
|
end
|
|
431
441
|
|
|
442
|
+
# Whether all engagement conditions hold for the C accept loop.
|
|
443
|
+
def engage_c_accept_loop?
|
|
444
|
+
return false if @tls
|
|
445
|
+
return false unless ConnectionLoop.available?
|
|
446
|
+
return false unless ConnectionLoop.eligible_route_table?(@route_table)
|
|
447
|
+
|
|
448
|
+
true
|
|
449
|
+
end
|
|
450
|
+
private :engage_c_accept_loop?
|
|
451
|
+
|
|
452
|
+
# Hand control of the listening fd to the C loop. Wires up the
|
|
453
|
+
# lifecycle + handoff callbacks first; on return (clean stop or
|
|
454
|
+
# `:crashed` sentinel from C), bumps the dispatch metric and
|
|
455
|
+
# falls through to the regular Ruby accept loop on `:crashed`
|
|
456
|
+
# so an unrecoverable C-side accept error doesn't leave the
|
|
457
|
+
# listener idle.
|
|
458
|
+
def run_c_accept_loop
|
|
459
|
+
pc = ::Hyperion::Http::PageCache
|
|
460
|
+
pc.set_lifecycle_callback(ConnectionLoop.build_lifecycle_callback(@runtime))
|
|
461
|
+
pc.set_lifecycle_active(@runtime.has_request_hooks?)
|
|
462
|
+
pc.set_handoff_callback(ConnectionLoop.build_handoff_callback(self))
|
|
463
|
+
# 2.12-E — register the per-worker request counter family on the
|
|
464
|
+
# runtime's metrics sink BEFORE the C loop starts ticking. The
|
|
465
|
+
# PrometheusExporter's C-loop fold-in is gated on the family
|
|
466
|
+
# already existing in the snapshot (so spec-only sinks that
|
|
467
|
+
# never tick stay clean), and the C accept loop bypasses
|
|
468
|
+
# `Connection#serve` — without an explicit boot-time register,
|
|
469
|
+
# a 100% C-loop worker would scrape zero requests even with
|
|
470
|
+
# the atomic happily ticking.
|
|
471
|
+
runtime_metrics.ensure_worker_request_family_registered! \
|
|
472
|
+
if runtime_metrics.respond_to?(:ensure_worker_request_family_registered!)
|
|
473
|
+
|
|
474
|
+
# 2.12-D — io_uring path takes precedence over accept4 when the
|
|
475
|
+
# operator opted in AND the runtime probe at boot succeeds. The
|
|
476
|
+
# C-side `run_static_io_uring_loop` returns `:unavailable` if
|
|
477
|
+
# `io_uring_queue_init` fails; we treat that as "fall back to
|
|
478
|
+
# the 2.12-C accept4 path" without operator intervention. A
|
|
479
|
+
# `:crashed` from io_uring also falls back — the contract
|
|
480
|
+
# mirrors 2.12-C's `:crashed -> Ruby accept loop`.
|
|
481
|
+
use_io_uring = ConnectionLoop.io_uring_eligible?
|
|
482
|
+
mode_name = use_io_uring ? :c_accept_loop_io_uring_h1 : :c_accept_loop_h1
|
|
483
|
+
mode = DispatchMode.new(mode_name)
|
|
484
|
+
record_dispatch(mode)
|
|
485
|
+
runtime_logger.info do
|
|
486
|
+
{ message: 'engaging C accept loop',
|
|
487
|
+
variant: use_io_uring ? :io_uring : :accept4,
|
|
488
|
+
static_routes: @route_table.size,
|
|
489
|
+
host: @host,
|
|
490
|
+
port: @port }
|
|
491
|
+
end
|
|
492
|
+
result = if use_io_uring
|
|
493
|
+
io_uring_result = pc.run_static_io_uring_loop(@tcp_server.fileno)
|
|
494
|
+
if io_uring_result == :unavailable
|
|
495
|
+
runtime_logger.warn do
|
|
496
|
+
{ message: 'io_uring runtime probe failed; falling back to accept4 loop' }
|
|
497
|
+
end
|
|
498
|
+
pc.run_static_accept_loop(@tcp_server.fileno)
|
|
499
|
+
else
|
|
500
|
+
io_uring_result
|
|
501
|
+
end
|
|
502
|
+
else
|
|
503
|
+
pc.run_static_accept_loop(@tcp_server.fileno)
|
|
504
|
+
end
|
|
505
|
+
if result == :crashed
|
|
506
|
+
runtime_logger.warn do
|
|
507
|
+
{ message: 'C accept loop crashed; falling back to Ruby accept loop' }
|
|
508
|
+
end
|
|
509
|
+
# Fall back to the Ruby loop so the listener doesn't go silent.
|
|
510
|
+
until @stopped
|
|
511
|
+
socket = accept_or_nil
|
|
512
|
+
next unless socket
|
|
513
|
+
|
|
514
|
+
apply_timeout(socket)
|
|
515
|
+
dispatch_one_h1(socket)
|
|
516
|
+
end
|
|
517
|
+
else
|
|
518
|
+
runtime_logger.info do
|
|
519
|
+
{ message: 'C accept loop exited', requests_served: result.to_i,
|
|
520
|
+
variant: use_io_uring ? :io_uring : :accept4 }
|
|
521
|
+
end
|
|
522
|
+
runtime_metrics.increment(:c_accept_loop_requests, result.to_i)
|
|
523
|
+
end
|
|
524
|
+
ensure
|
|
525
|
+
# Best-effort: clear the lifecycle callback so a subsequent
|
|
526
|
+
# Server boot in the same process (test harnesses) doesn't see
|
|
527
|
+
# stale state.
|
|
528
|
+
pc&.set_lifecycle_active(false) if defined?(pc)
|
|
529
|
+
pc&.set_lifecycle_callback(nil) if defined?(pc)
|
|
530
|
+
pc&.set_handoff_callback(nil) if defined?(pc)
|
|
531
|
+
end
|
|
532
|
+
private :run_c_accept_loop
|
|
533
|
+
|
|
534
|
+
# Dispatch a connection that the C accept loop handed off to Ruby
|
|
535
|
+
# because it couldn't be served from the static cache (path miss,
|
|
536
|
+
# malformed request, body present, h2 upgrade requested, etc.).
|
|
537
|
+
# `partial` is the partial header buffer the C loop already read
|
|
538
|
+
# off the fd, or nil if the C loop hadn't started reading.
|
|
539
|
+
#
|
|
540
|
+
# The fd is owned by Ruby from this point on — the C loop will
|
|
541
|
+
# not touch it again. We wrap it in a `::Socket` (matches the
|
|
542
|
+
# `accept_nonblock` path's return type) and dispatch through the
|
|
543
|
+
# existing thread-pool / inline path.
|
|
544
|
+
def dispatch_handed_off(fd, partial)
|
|
545
|
+
require 'socket'
|
|
546
|
+
socket = ::Socket.for_fd(fd)
|
|
547
|
+
socket.autoclose = true
|
|
548
|
+
apply_timeout(socket)
|
|
549
|
+
# 2.12-E — `partial.present?` was a Rails-ism that crashed the
|
|
550
|
+
# handoff path with NoMethodError on plain Ruby. Hit by every
|
|
551
|
+
# request that lands on a static-only server (e.g. /-/metrics
|
|
552
|
+
# against `bench/hello_static.ru`) — the C loop hands off, the
|
|
553
|
+
# handoff dispatch raised, and the connection was force-closed
|
|
554
|
+
# with `:c_loop_handoff_failed`. Surfaced by the 2.12-E audit
|
|
555
|
+
# harness which scrapes /-/metrics on a handle_static-only
|
|
556
|
+
# cluster; pre-existing bug, fixed here so the metric is
|
|
557
|
+
# actually readable. The `is_a?(String)` guard is deliberate —
|
|
558
|
+
# it both narrows the contract (the C loop never hands off
|
|
559
|
+
# anything but a plain String or nil) and pins the shape so
|
|
560
|
+
# `rubocop-rails`'s Style/Present autocorrect can't rewrite
|
|
561
|
+
# this back to `partial.present?`.
|
|
562
|
+
carry = partial.is_a?(String) && !partial.empty? ? partial.dup.b : nil
|
|
563
|
+
|
|
564
|
+
if @thread_pool
|
|
565
|
+
mode = DispatchMode.new(:threadpool_h1)
|
|
566
|
+
# 2.12-E — thread the carry through to the worker so the
|
|
567
|
+
# `Connection#@inbuf` is preloaded with the partial header
|
|
568
|
+
# bytes the C accept loop already drained off the fd. Pre-2.12-E
|
|
569
|
+
# the threadpool handoff path silently dropped the buffer
|
|
570
|
+
# (only the inline-no-pool branch wired it), so every
|
|
571
|
+
# `-t N>0` server with the C accept loop engaged returned
|
|
572
|
+
# "Request Timeout" on every handed-off request — including
|
|
573
|
+
# the audit harness's own `/-/metrics` scrape.
|
|
574
|
+
if @thread_pool.submit_connection(socket, @app,
|
|
575
|
+
max_request_read_seconds: @max_request_read_seconds,
|
|
576
|
+
carry: carry)
|
|
577
|
+
record_dispatch(mode)
|
|
578
|
+
else
|
|
579
|
+
reject_connection(socket)
|
|
580
|
+
end
|
|
581
|
+
else
|
|
582
|
+
mode = DispatchMode.new(:inline_h1_no_pool)
|
|
583
|
+
record_dispatch(mode)
|
|
584
|
+
connection = Connection.new(runtime: @explicit_runtime ? @runtime : nil,
|
|
585
|
+
max_in_flight_per_conn: @max_in_flight_per_conn,
|
|
586
|
+
route_table: @route_table)
|
|
587
|
+
connection.instance_variable_set(:@inbuf, +carry.b) if carry
|
|
588
|
+
connection.serve(socket, @app,
|
|
589
|
+
max_request_read_seconds: @max_request_read_seconds)
|
|
590
|
+
end
|
|
591
|
+
end
|
|
592
|
+
private :dispatch_handed_off
|
|
593
|
+
|
|
594
|
+
def dispatch_one_h1(socket)
|
|
595
|
+
if @thread_pool
|
|
596
|
+
mode = DispatchMode.new(:threadpool_h1)
|
|
597
|
+
if @thread_pool.submit_connection(socket, @app,
|
|
598
|
+
max_request_read_seconds: @max_request_read_seconds)
|
|
599
|
+
record_dispatch(mode)
|
|
600
|
+
else
|
|
601
|
+
reject_connection(socket)
|
|
602
|
+
end
|
|
603
|
+
else
|
|
604
|
+
mode = DispatchMode.new(:inline_h1_no_pool)
|
|
605
|
+
record_dispatch(mode)
|
|
606
|
+
Connection.new(runtime: @explicit_runtime ? @runtime : nil,
|
|
607
|
+
max_in_flight_per_conn: @max_in_flight_per_conn,
|
|
608
|
+
route_table: @route_table).serve(
|
|
609
|
+
socket, @app, max_request_read_seconds: @max_request_read_seconds
|
|
610
|
+
)
|
|
611
|
+
end
|
|
612
|
+
end
|
|
613
|
+
private :dispatch_one_h1
|
|
614
|
+
|
|
432
615
|
# TLS / h2-capable accept loop. The Async wrapper is required because
|
|
433
616
|
# h2 streams (inside Http2Handler) and the ALPN handshake yield
|
|
434
617
|
# cooperatively via the scheduler.
|
data/lib/hyperion/thread_pool.rb
CHANGED
|
@@ -85,10 +85,20 @@ module Hyperion
|
|
|
85
85
|
# not strict. Off-by-one over the configured cap during a thundering
|
|
86
86
|
# accept burst is acceptable; the cost of stricter sync would be a
|
|
87
87
|
# mutex on every enqueue, which we won't pay on the hot path.
|
|
88
|
-
def submit_connection(socket, app, max_request_read_seconds: 60)
|
|
88
|
+
def submit_connection(socket, app, max_request_read_seconds: 60, carry: nil)
|
|
89
89
|
return false if @max_pending && @inbox.size >= @max_pending
|
|
90
90
|
|
|
91
|
-
|
|
91
|
+
# 2.12-E — `carry:` carries any partial header bytes the C accept
|
|
92
|
+
# loop already read off the fd before deciding to hand the
|
|
93
|
+
# connection off to Ruby. The worker thread pre-loads them into
|
|
94
|
+
# `Connection#@inbuf` so the parser sees the full request, not a
|
|
95
|
+
# short read that times out. Pre-2.12-E the threadpool handoff
|
|
96
|
+
# path silently dropped the partial buffer (the inline-no-pool
|
|
97
|
+
# path was the only one wired) — a server with `-t N>0` and the
|
|
98
|
+
# C accept loop engaged returned "Request Timeout" on every
|
|
99
|
+
# handed-off request, including the audit harness's own
|
|
100
|
+
# `/-/metrics` scrape.
|
|
101
|
+
@inbox << [:connection, socket, app, max_request_read_seconds, carry]
|
|
92
102
|
true
|
|
93
103
|
end
|
|
94
104
|
|
|
@@ -139,17 +149,23 @@ module Hyperion
|
|
|
139
149
|
|
|
140
150
|
case job[0]
|
|
141
151
|
when :connection
|
|
142
|
-
_, socket, app, max_request_read_seconds = job
|
|
152
|
+
_, socket, app, max_request_read_seconds, carry = job
|
|
143
153
|
# Worker thread owns the connection for its full lifetime. Pass
|
|
144
154
|
# thread_pool: nil so Connection#call_app inlines Adapter::Rack.call
|
|
145
155
|
# — the worker IS the pool, no further hop required. 2.3-B
|
|
146
156
|
# threads `max_in_flight_per_conn` so the per-conn fairness
|
|
147
157
|
# cap (if configured) takes effect on this worker's serve loop.
|
|
148
158
|
begin
|
|
149
|
-
Hyperion::Connection
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
159
|
+
connection = Hyperion::Connection
|
|
160
|
+
.new(max_in_flight_per_conn: @max_in_flight_per_conn,
|
|
161
|
+
route_table: @route_table)
|
|
162
|
+
# 2.12-E — preload `@inbuf` with the partial buffer the C
|
|
163
|
+
# accept loop already drained off the fd, mirroring the
|
|
164
|
+
# inline-no-pool branch in `Server#dispatch_handed_off`.
|
|
165
|
+
# `carry` is nil on the regular accept path; only the C
|
|
166
|
+
# loop's handoff path supplies it.
|
|
167
|
+
connection.instance_variable_set(:@inbuf, +carry.b) if carry.is_a?(String) && !carry.empty?
|
|
168
|
+
connection.serve(socket, app, max_request_read_seconds: max_request_read_seconds)
|
|
153
169
|
rescue StandardError => e
|
|
154
170
|
Hyperion.logger.error do
|
|
155
171
|
{
|
data/lib/hyperion/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: hyperion-rb
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 2.
|
|
4
|
+
version: 2.12.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Andrey Lobanov
|
|
@@ -164,11 +164,13 @@ files:
|
|
|
164
164
|
- ext/hyperion_h2_codec/src/lib.rs
|
|
165
165
|
- ext/hyperion_http/extconf.rb
|
|
166
166
|
- ext/hyperion_http/h2_codec_glue.c
|
|
167
|
+
- ext/hyperion_http/io_uring_loop.c
|
|
167
168
|
- ext/hyperion_http/llhttp/api.c
|
|
168
169
|
- ext/hyperion_http/llhttp/http.c
|
|
169
170
|
- ext/hyperion_http/llhttp/llhttp.c
|
|
170
171
|
- ext/hyperion_http/llhttp/llhttp.h
|
|
171
172
|
- ext/hyperion_http/page_cache.c
|
|
173
|
+
- ext/hyperion_http/page_cache_internal.h
|
|
172
174
|
- ext/hyperion_http/parser.c
|
|
173
175
|
- ext/hyperion_http/sendfile.c
|
|
174
176
|
- ext/hyperion_http/websocket.c
|
|
@@ -207,6 +209,7 @@ files:
|
|
|
207
209
|
- lib/hyperion/response_writer.rb
|
|
208
210
|
- lib/hyperion/runtime.rb
|
|
209
211
|
- lib/hyperion/server.rb
|
|
212
|
+
- lib/hyperion/server/connection_loop.rb
|
|
210
213
|
- lib/hyperion/server/route_table.rb
|
|
211
214
|
- lib/hyperion/static_preload.rb
|
|
212
215
|
- lib/hyperion/thread_pool.rb
|