hyperion-rb 2.11.0 → 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 +566 -0
- data/README.md +102 -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/http2_handler.rb +123 -5
- 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
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/* ----------------------------------------------------------------------
|
|
2
|
+
* page_cache_internal.h — internal C-ext sharing surface.
|
|
3
|
+
*
|
|
4
|
+
* 2.12-D — exposes the request-parsing + lookup + write helpers built by
|
|
5
|
+
* `page_cache.c`'s C accept loop so the io_uring sibling
|
|
6
|
+
* (`io_uring_loop.c`) can reuse them rather than copy-pasting. The
|
|
7
|
+
* helpers stay `static` inside `page_cache.c` and the symbols below are
|
|
8
|
+
* thin extern wrappers — one indirection per call, but the io_uring
|
|
9
|
+
* loop calls them at most once per request, so the cost is negligible
|
|
10
|
+
* (single-direct-call jump) compared to the syscall savings the loop
|
|
11
|
+
* delivers.
|
|
12
|
+
*
|
|
13
|
+
* NOT public surface. NOT installed in any include path. The header
|
|
14
|
+
* lives next to the .c files and is included only by the in-tree C
|
|
15
|
+
* sources.
|
|
16
|
+
* ---------------------------------------------------------------------- */
|
|
17
|
+
#ifndef HYP_PAGE_CACHE_INTERNAL_H
|
|
18
|
+
#define HYP_PAGE_CACHE_INTERNAL_H
|
|
19
|
+
|
|
20
|
+
#include <stddef.h>
|
|
21
|
+
#include <sys/types.h>
|
|
22
|
+
|
|
23
|
+
#ifdef __cplusplus
|
|
24
|
+
extern "C" {
|
|
25
|
+
#endif
|
|
26
|
+
|
|
27
|
+
/* Method classification (mirrors `hyp_pc_method_t` in page_cache.c). The
|
|
28
|
+
* io_uring loop uses this via `pc_internal_classify_method` to decide
|
|
29
|
+
* how much of the cached response to write (HEAD = headers only, GET =
|
|
30
|
+
* full response). */
|
|
31
|
+
typedef enum {
|
|
32
|
+
PC_INTERNAL_METHOD_GET = 0,
|
|
33
|
+
PC_INTERNAL_METHOD_HEAD = 1,
|
|
34
|
+
PC_INTERNAL_METHOD_OTHER = 2
|
|
35
|
+
} pc_internal_method_t;
|
|
36
|
+
|
|
37
|
+
/* End-of-headers scanner. Returns the byte offset PAST the trailing
|
|
38
|
+
* CRLFCRLF, or -1 if not found. */
|
|
39
|
+
long pc_internal_find_eoh(const char *buf, size_t len);
|
|
40
|
+
|
|
41
|
+
/* Request-line parser. On success fills *m_off, *m_len, *p_off, *p_len
|
|
42
|
+
* with offsets/lengths of METHOD and PATH inside `buf`, and returns the
|
|
43
|
+
* length of the request line including the trailing CRLF. Returns -1
|
|
44
|
+
* on malformed input or non-HTTP/1.1 versions (HTTP/1.0 differs in
|
|
45
|
+
* keep-alive defaults; the caller must hand it off to Ruby). */
|
|
46
|
+
long pc_internal_parse_request_line(const char *buf, size_t len,
|
|
47
|
+
size_t *m_off, size_t *m_len,
|
|
48
|
+
size_t *p_off, size_t *p_len);
|
|
49
|
+
|
|
50
|
+
/* Header-block scanner. `start` and `end` bracket the headers section
|
|
51
|
+
* (between request-line end and the closing CRLFCRLF). Reports:
|
|
52
|
+
* *connection_close — Connection: close seen
|
|
53
|
+
* *has_body — non-zero Content-Length OR Transfer-Encoding
|
|
54
|
+
* *upgrade_seen — Upgrade or HTTP2-Settings seen
|
|
55
|
+
* Returns 0 on success, -1 on malformed framing. */
|
|
56
|
+
int pc_internal_scan_headers(const char *buf, size_t start, size_t end,
|
|
57
|
+
int *connection_close, int *has_body,
|
|
58
|
+
int *upgrade_seen);
|
|
59
|
+
|
|
60
|
+
/* Method classifier. Returns GET / HEAD / OTHER. */
|
|
61
|
+
pc_internal_method_t pc_internal_classify_method(const char *m, size_t len);
|
|
62
|
+
|
|
63
|
+
/* Snapshot the response bytes for `(path, kind)` into a freshly malloc'd
|
|
64
|
+
* buffer. On hit: returns the malloc'd buffer (caller must `free()` it)
|
|
65
|
+
* and writes the byte length into *out_len. On miss: returns NULL and
|
|
66
|
+
* sets *out_len = 0. The buffer is whatever the page cache's lookup
|
|
67
|
+
* picks given the recheck/staleness rules; the io_uring loop writes it
|
|
68
|
+
* verbatim. Takes the C-side cache lock briefly; releases it before
|
|
69
|
+
* returning. Returns NULL on OOM as well — the caller treats both as
|
|
70
|
+
* "couldn't serve from C, hand off to Ruby". */
|
|
71
|
+
char *pc_internal_snapshot_response(const char *path, size_t path_len,
|
|
72
|
+
pc_internal_method_t kind,
|
|
73
|
+
size_t *out_len);
|
|
74
|
+
|
|
75
|
+
/* Apply TCP_NODELAY to an accepted fd (best-effort; failures swallowed). */
|
|
76
|
+
void pc_internal_apply_tcp_nodelay(int fd);
|
|
77
|
+
|
|
78
|
+
/* Lifecycle hook fire wrapper. The io_uring loop calls this AFTER the
|
|
79
|
+
* write completion arrives so observers see a finished request. The
|
|
80
|
+
* C-side gate (`lifecycle_active`) is checked inside; the wrapper is
|
|
81
|
+
* a no-op when no callback is registered or the gate is off. Must be
|
|
82
|
+
* called under the GVL. */
|
|
83
|
+
void pc_internal_fire_lifecycle(const char *method, size_t mlen,
|
|
84
|
+
const char *path, size_t plen);
|
|
85
|
+
|
|
86
|
+
/* Whether the lifecycle gate is currently on. The io_uring loop reads
|
|
87
|
+
* this BEFORE re-acquiring the GVL — when it's off, the loop skips
|
|
88
|
+
* the rb_thread_call_with_gvl round-trip entirely. */
|
|
89
|
+
int pc_internal_lifecycle_active(void);
|
|
90
|
+
|
|
91
|
+
/* Handoff wrapper — invokes the registered Ruby callback with
|
|
92
|
+
* (fd, partial_buffer_or_nil). Must be called under the GVL. Closes
|
|
93
|
+
* the fd locally if no callback is registered or if the callback
|
|
94
|
+
* raised. */
|
|
95
|
+
void pc_internal_handoff(int client_fd, const char *partial, size_t partial_len);
|
|
96
|
+
|
|
97
|
+
/* Read the stop flag flipped by `PageCache.stop_accept_loop`. Both the
|
|
98
|
+
* 2.12-C accept4 loop AND the 2.12-D io_uring loop honour it as a
|
|
99
|
+
* graceful-shutdown signal. */
|
|
100
|
+
int pc_internal_stop_requested(void);
|
|
101
|
+
|
|
102
|
+
/* Reset the stop flag to 0. Called by the loop entry points
|
|
103
|
+
* (`run_static_accept_loop`, `run_static_io_uring_loop`) so a previous
|
|
104
|
+
* invocation's `stop_accept_loop` doesn't immediately tear down a
|
|
105
|
+
* fresh loop. Specs hammer this path between examples — the 2.12-C
|
|
106
|
+
* loop resets inline; the io_uring sibling needs the same surface. */
|
|
107
|
+
void pc_internal_reset_stop(void);
|
|
108
|
+
|
|
109
|
+
/* 2.12-E — bump the per-process served-request counter (atomic; safe
|
|
110
|
+
* to call from any thread / fiber / accept-loop context). Both the
|
|
111
|
+
* 2.12-C accept4 loop and the 2.12-D io_uring loop call this after
|
|
112
|
+
* a successful response write so the SO_REUSEPORT distribution audit
|
|
113
|
+
* (`PageCache.c_loop_requests_total`) sees ticks regardless of which
|
|
114
|
+
* loop variant is active. */
|
|
115
|
+
void pc_internal_tick_request(void);
|
|
116
|
+
|
|
117
|
+
/* 2.12-E — reset the per-process served-request counter. Mirrors the
|
|
118
|
+
* stop-flag reset rationale: loop entry points call this so a prior
|
|
119
|
+
* invocation's count doesn't bleed into the new loop's snapshot. */
|
|
120
|
+
void pc_internal_reset_requests_served(void);
|
|
121
|
+
|
|
122
|
+
/* The 64 KiB header-cap shared with `page_cache.c`. Re-declared here
|
|
123
|
+
* so io_uring_loop.c doesn't need to mirror the magic number. */
|
|
124
|
+
#ifndef PC_INTERNAL_MAX_HEADER_BYTES
|
|
125
|
+
#define PC_INTERNAL_MAX_HEADER_BYTES 65536
|
|
126
|
+
#endif
|
|
127
|
+
|
|
128
|
+
#ifdef __cplusplus
|
|
129
|
+
}
|
|
130
|
+
#endif
|
|
131
|
+
|
|
132
|
+
#endif /* HYP_PAGE_CACHE_INTERNAL_H */
|
data/lib/hyperion/connection.rb
CHANGED
|
@@ -135,6 +135,12 @@ module Hyperion
|
|
|
135
135
|
# keep the existing pattern of caching boot-time refs as ivars so
|
|
136
136
|
# the per-request observe stays a single Hash lookup.
|
|
137
137
|
@path_templater = path_templater || Hyperion::Metrics.default_path_templater
|
|
138
|
+
# 2.12-E — per-worker request counter label. Cached once per
|
|
139
|
+
# Connection (Process.pid is process-constant — re-reading it per
|
|
140
|
+
# request would allocate the to_s String every time the operator
|
|
141
|
+
# asked Ruby for the symbol/label). Each Connection lives in
|
|
142
|
+
# exactly one process, so the cache is tight and never stale.
|
|
143
|
+
@worker_id = Process.pid.to_s
|
|
138
144
|
# 2.10-D — direct-dispatch route table. The hot-path lookup
|
|
139
145
|
# is `@route_table&.lookup(method, path)` so the nil-default
|
|
140
146
|
# case (no operator-registered direct routes — the
|
|
@@ -307,6 +313,14 @@ module Hyperion
|
|
|
307
313
|
@metrics.increment(:bytes_read, body_end)
|
|
308
314
|
@metrics.increment(:requests_total)
|
|
309
315
|
@metrics.increment(:requests_in_flight)
|
|
316
|
+
# 2.12-E — per-worker request counter for the SO_REUSEPORT
|
|
317
|
+
# load-balancing audit. Worker_id is the OS pid (matches the
|
|
318
|
+
# 2.4-C `hyperion_io_uring_workers_active` convention). Single
|
|
319
|
+
# location for every Ruby-side dispatch shape: regular Rack
|
|
320
|
+
# via `dispatch_request`, direct dispatch via `dispatch_direct!`,
|
|
321
|
+
# and the StaticEntry fast path via `dispatch_direct_static!`
|
|
322
|
+
# all flow through this point in `serve`.
|
|
323
|
+
@metrics.tick_worker_request(@worker_id)
|
|
310
324
|
# 2.4-C: capture start time for the per-route duration histogram.
|
|
311
325
|
# Same Process.clock_gettime that the access-log path was already
|
|
312
326
|
# paying — at default-ON log_requests the second call here is
|
|
@@ -46,8 +46,26 @@ module Hyperion
|
|
|
46
46
|
# escape hatch via `env['hyperion.dispatch_mode']
|
|
47
47
|
# = :inline_blocking` for routes the auto-
|
|
48
48
|
# detect doesn't catch.
|
|
49
|
+
# 2.12-C — `:c_accept_loop_h1` is a connection-wide mode (NOT a
|
|
50
|
+
# per-response override): the entire accept-and-serve loop runs in
|
|
51
|
+
# C via `Hyperion::Http::PageCache.run_static_accept_loop`. Engaged
|
|
52
|
+
# only when the operator's route table is composed entirely of
|
|
53
|
+
# `Server.handle_static`-registered routes AND the listener is
|
|
54
|
+
# plain TCP. Counted under `:requests_dispatch_c_accept_loop_h1`
|
|
55
|
+
# at engage time (one bump per worker boot) so operators can see
|
|
56
|
+
# the path is on without scraping the per-request `:c_accept_loop_requests`
|
|
57
|
+
# counter.
|
|
58
|
+
# 2.12-D — `:c_accept_loop_io_uring_h1` is a sibling of
|
|
59
|
+
# `:c_accept_loop_h1`. Engaged when the operator opts into
|
|
60
|
+
# `HYPERION_IO_URING_ACCEPT=1` AND the C ext was compiled with
|
|
61
|
+
# liburing AND the runtime probe succeeded. Same eligibility gates
|
|
62
|
+
# as `:c_accept_loop_h1` (handle_static-only routes, plain TCP),
|
|
63
|
+
# different syscall shape (single `io_uring_enter` per N requests
|
|
64
|
+
# vs. N×3 syscalls). Counted under
|
|
65
|
+
# `:requests_dispatch_c_accept_loop_io_uring_h1` so operators can
|
|
66
|
+
# confirm the path is on without scraping logs.
|
|
49
67
|
MODES = %i[tls_h2 tls_h1_inline async_io_h1_inline threadpool_h1 inline_h1_no_pool
|
|
50
|
-
inline_blocking].freeze
|
|
68
|
+
inline_blocking c_accept_loop_h1 c_accept_loop_io_uring_h1].freeze
|
|
51
69
|
|
|
52
70
|
INLINE_MODES = %i[tls_h1_inline async_io_h1_inline inline_h1_no_pool inline_blocking].freeze
|
|
53
71
|
|
|
@@ -275,7 +275,16 @@ module Hyperion
|
|
|
275
275
|
def initialize(*)
|
|
276
276
|
super
|
|
277
277
|
@request_headers = []
|
|
278
|
-
|
|
278
|
+
# 2.12-F — gRPC carries opaque protobuf bytes
|
|
279
|
+
# ([1-byte compressed flag][4-byte length-prefix][message bytes]) in the
|
|
280
|
+
# request body. The default UTF-8 encoding on a `+''` literal would
|
|
281
|
+
# break valid_encoding? on byte sequences that don't form UTF-8
|
|
282
|
+
# codepoints, leading to a Rack app reading `body.string` and getting
|
|
283
|
+
# a String that misreports its bytesize / corrupts when string-
|
|
284
|
+
# interpolated. ASCII_8BIT (binary) preserves bytes verbatim and is
|
|
285
|
+
# the encoding gRPC Ruby clients expect. Same change is applied to
|
|
286
|
+
# the HTTP/1.1 path as a separate concern; see Connection.
|
|
287
|
+
@request_body = String.new(encoding: Encoding::ASCII_8BIT)
|
|
279
288
|
@request_body_bytes = 0
|
|
280
289
|
@request_complete = false
|
|
281
290
|
@window_available = ::Async::Notification.new
|
|
@@ -506,6 +515,10 @@ module Hyperion
|
|
|
506
515
|
@logger = Hyperion.logger
|
|
507
516
|
end
|
|
508
517
|
@h2_admission = h2_admission
|
|
518
|
+
# 2.12-E — per-worker request counter label. Identical caching
|
|
519
|
+
# rationale to Connection#initialize: process-constant ID, looked
|
|
520
|
+
# up once and held in the ivar.
|
|
521
|
+
@worker_id = Process.pid.to_s
|
|
509
522
|
@h2_codec_available = Hyperion::H2Codec.available?
|
|
510
523
|
# 2.5-B [breaking-default-change]: native HPACK now defaults to ON
|
|
511
524
|
# when the Rust crate is available. The 2026-04-30 Rails-shape
|
|
@@ -1121,6 +1134,11 @@ module Hyperion
|
|
|
1121
1134
|
|
|
1122
1135
|
@metrics.increment(:requests_total)
|
|
1123
1136
|
@metrics.increment(:requests_in_flight)
|
|
1137
|
+
# 2.12-E — per-worker request counter, ticked once per h2 stream.
|
|
1138
|
+
# Same family as Connection#serve so the audit metric reflects
|
|
1139
|
+
# cluster distribution across BOTH transports without operators
|
|
1140
|
+
# needing to alert on two separate counters.
|
|
1141
|
+
@metrics.tick_worker_request(@worker_id)
|
|
1124
1142
|
# 2.1.0 (WS-1): HTTP/2 hijack is intentionally NOT plumbed here.
|
|
1125
1143
|
# Rack 3 hijack over HTTP/2 requires Extended CONNECT (RFC 8441 +
|
|
1126
1144
|
# RFC 9220) — a separate feature with its own SETTINGS handshake,
|
|
@@ -1155,8 +1173,17 @@ module Hyperion
|
|
|
1155
1173
|
end
|
|
1156
1174
|
end
|
|
1157
1175
|
|
|
1158
|
-
|
|
1176
|
+
# 2.12-F — gRPC support: bodies that respond to `:trailers` carry a
|
|
1177
|
+
# final HEADERS frame (with END_STREAM=1) right after the DATA frames.
|
|
1178
|
+
# The Rack 3 contract is "iterate body first, then call body.trailers"
|
|
1179
|
+
# — so we materialise the payload, then *before* `body.close`
|
|
1180
|
+
# (`Rack::BodyProxy` clears state on close) snapshot the trailers Hash.
|
|
1181
|
+
# `nil` / empty Hash → no trailing frame. Non-Hash values are coerced
|
|
1182
|
+
# to a Hash defensively; a misbehaving app must not be able to crash
|
|
1183
|
+
# the connection.
|
|
1184
|
+
payload = String.new(encoding: Encoding::ASCII_8BIT)
|
|
1159
1185
|
body_chunks.each { |c| payload << c.to_s }
|
|
1186
|
+
response_trailers = collect_response_trailers(body_chunks)
|
|
1160
1187
|
body_chunks.close if body_chunks.respond_to?(:close)
|
|
1161
1188
|
|
|
1162
1189
|
# Hotfix C2: empty-body responses (RFC 7230 §3.3.3 — 204/304 + HEAD)
|
|
@@ -1165,10 +1192,21 @@ module Hyperion
|
|
|
1165
1192
|
# one writer-fiber wakeup instead of two. Any body the app returned
|
|
1166
1193
|
# for HEAD is discarded here per spec (the bytes were already
|
|
1167
1194
|
# built — that's a Rack-app smell, not our problem to fix).
|
|
1195
|
+
#
|
|
1196
|
+
# Trailers on body-suppressed responses (HEAD/204/304) are dropped:
|
|
1197
|
+
# the response is end-of-stream after HEADERS, with no place to put
|
|
1198
|
+
# a trailing HEADERS frame. This matches what curl --http2 / grpc
|
|
1199
|
+
# clients do (HEAD + gRPC isn't a meaningful combination).
|
|
1168
1200
|
if body_suppressed?(method, status)
|
|
1169
1201
|
writer_ctx.encode_mutex.synchronize do
|
|
1170
1202
|
stream.send_headers(out_headers, ::Protocol::HTTP2::END_STREAM)
|
|
1171
1203
|
end
|
|
1204
|
+
elsif have_trailers?(response_trailers)
|
|
1205
|
+
# gRPC / Rack-3-trailers path: HEADERS (no END_STREAM), DATA frames
|
|
1206
|
+
# (no END_STREAM on last DATA), final HEADERS with END_STREAM=1.
|
|
1207
|
+
writer_ctx.encode_mutex.synchronize { stream.send_headers(out_headers) }
|
|
1208
|
+
send_body(stream, payload, writer_ctx, end_stream: false)
|
|
1209
|
+
send_trailers(stream, response_trailers, writer_ctx)
|
|
1172
1210
|
else
|
|
1173
1211
|
writer_ctx.encode_mutex.synchronize { stream.send_headers(out_headers) }
|
|
1174
1212
|
send_body(stream, payload, writer_ctx)
|
|
@@ -1229,9 +1267,24 @@ module Hyperion
|
|
|
1229
1267
|
#
|
|
1230
1268
|
# The encode_mutex protects HPACK state and per-stream frame ordering;
|
|
1231
1269
|
# the actual socket write happens off-fiber via the writer task.
|
|
1232
|
-
|
|
1270
|
+
#
|
|
1271
|
+
# 2.12-F — `end_stream:` controls whether the LAST DATA frame carries
|
|
1272
|
+
# the END_STREAM flag. The default `true` preserves pre-2.12-F semantics
|
|
1273
|
+
# (final DATA frame closes the stream). Callers that intend to send a
|
|
1274
|
+
# trailing HEADERS frame after the body pass `end_stream: false` so the
|
|
1275
|
+
# final DATA frame leaves the stream half-open from the server side
|
|
1276
|
+
# and the trailer HEADERS frame can carry END_STREAM=1.
|
|
1277
|
+
def send_body(stream, payload, writer_ctx, end_stream: true)
|
|
1233
1278
|
if payload.empty?
|
|
1234
|
-
|
|
1279
|
+
if end_stream
|
|
1280
|
+
writer_ctx.encode_mutex.synchronize do
|
|
1281
|
+
stream.send_data('', ::Protocol::HTTP2::END_STREAM)
|
|
1282
|
+
end
|
|
1283
|
+
end
|
|
1284
|
+
# When end_stream is false AND payload is empty, we deliberately
|
|
1285
|
+
# send NO DATA frame at all — gRPC trailers-only responses (the
|
|
1286
|
+
# error-without-payload shape) are HEADERS → trailer-HEADERS, no
|
|
1287
|
+
# DATA in between. send_trailers handles the closing END_STREAM.
|
|
1235
1288
|
return
|
|
1236
1289
|
end
|
|
1237
1290
|
|
|
@@ -1250,12 +1303,77 @@ module Hyperion
|
|
|
1250
1303
|
|
|
1251
1304
|
chunk = payload.byteslice(offset, available)
|
|
1252
1305
|
offset += chunk.bytesize
|
|
1253
|
-
|
|
1306
|
+
last_chunk = offset >= bytesize
|
|
1307
|
+
flags = last_chunk && end_stream ? ::Protocol::HTTP2::END_STREAM : 0
|
|
1254
1308
|
|
|
1255
1309
|
writer_ctx.encode_mutex.synchronize { stream.send_data(chunk, flags) }
|
|
1256
1310
|
end
|
|
1257
1311
|
end
|
|
1258
1312
|
|
|
1313
|
+
# 2.12-F — pull a trailers Hash off the response body if Rack 3
|
|
1314
|
+
# `body.trailers` is implemented. Called AFTER the body has been
|
|
1315
|
+
# fully iterated (Rack 3 contract: trailers are computed by the body
|
|
1316
|
+
# while it streams; reading them before iteration is undefined).
|
|
1317
|
+
# Returns nil when the body doesn't expose trailers, when the call
|
|
1318
|
+
# raises, or when the result isn't a Hash-coercible map. Defensive
|
|
1319
|
+
# by design: a misbehaving app must not crash the dispatch loop.
|
|
1320
|
+
def collect_response_trailers(body)
|
|
1321
|
+
return nil unless body.respond_to?(:trailers)
|
|
1322
|
+
|
|
1323
|
+
raw = body.trailers
|
|
1324
|
+
return nil if raw.nil?
|
|
1325
|
+
return raw if raw.is_a?(Hash)
|
|
1326
|
+
return raw.to_h if raw.respond_to?(:to_h)
|
|
1327
|
+
|
|
1328
|
+
nil
|
|
1329
|
+
rescue StandardError => e
|
|
1330
|
+
@logger.warn do
|
|
1331
|
+
{ message: 'h2 body.trailers raised; ignoring',
|
|
1332
|
+
error: e.message, error_class: e.class.name }
|
|
1333
|
+
end
|
|
1334
|
+
nil
|
|
1335
|
+
end
|
|
1336
|
+
|
|
1337
|
+
# 2.12-F — predicate for "we have trailers worth sending". Defined as
|
|
1338
|
+
# a method (rather than the more idiomatic `!h.nil? && !h.empty?` /
|
|
1339
|
+
# `h&.any?`) because rubocop-rails on the hot path autocorrects both
|
|
1340
|
+
# of those forms to `h.present?`, which raises NoMethodError on a
|
|
1341
|
+
# plain Hash outside ActiveSupport. Hyperion is a stand-alone gem;
|
|
1342
|
+
# we don't depend on ActiveSupport, so we route through this helper
|
|
1343
|
+
# to keep the rubocop-rails formatter quiet without adding a Cop
|
|
1344
|
+
# disable comment everywhere a nil-or-empty Hash check appears.
|
|
1345
|
+
def have_trailers?(trailers)
|
|
1346
|
+
return false if trailers.nil?
|
|
1347
|
+
return false if trailers.respond_to?(:empty?) && trailers.empty?
|
|
1348
|
+
|
|
1349
|
+
true
|
|
1350
|
+
end
|
|
1351
|
+
|
|
1352
|
+
# 2.12-F — emit the final HEADERS frame carrying response trailers.
|
|
1353
|
+
# The wire shape is one HEADERS frame with END_STREAM=1; HPACK
|
|
1354
|
+
# encodes the trailer block exactly like a regular HEADERS frame.
|
|
1355
|
+
# Trailer keys MUST be lowercased (RFC 7540 §8.1.2) — same rule as
|
|
1356
|
+
# regular HTTP/2 headers. We strip CR/LF from values defensively
|
|
1357
|
+
# (a header-injection guard) and split multi-line values on \n the
|
|
1358
|
+
# same way the regular response-header path does.
|
|
1359
|
+
def send_trailers(stream, trailers, writer_ctx)
|
|
1360
|
+
pairs = []
|
|
1361
|
+
trailers.each do |k, v|
|
|
1362
|
+
name = k.to_s.downcase
|
|
1363
|
+
# Pseudo-headers and forbidden names cannot appear in trailers.
|
|
1364
|
+
next if name.empty?
|
|
1365
|
+
next if name.start_with?(':')
|
|
1366
|
+
next if RequestStream::FORBIDDEN_HEADERS.include?(name)
|
|
1367
|
+
|
|
1368
|
+
Array(v).each do |val|
|
|
1369
|
+
val.to_s.split("\n").each { |line| pairs << [name, line] }
|
|
1370
|
+
end
|
|
1371
|
+
end
|
|
1372
|
+
writer_ctx.encode_mutex.synchronize do
|
|
1373
|
+
stream.send_headers(pairs, ::Protocol::HTTP2::END_STREAM)
|
|
1374
|
+
end
|
|
1375
|
+
end
|
|
1376
|
+
|
|
1259
1377
|
# Drain bytes off the per-connection send queue onto the real socket.
|
|
1260
1378
|
# This fiber is the SOLE writer to `socket` for the connection's
|
|
1261
1379
|
# lifetime, which satisfies SSLSocket's "no concurrent writes from
|
data/lib/hyperion/metrics.rb
CHANGED
|
@@ -113,6 +113,44 @@ module Hyperion
|
|
|
113
113
|
increment(:"responses_#{code}")
|
|
114
114
|
end
|
|
115
115
|
|
|
116
|
+
# 2.12-E — labeled counter family that observes which worker
|
|
117
|
+
# process a given request landed on. Ticks once per dispatched
|
|
118
|
+
# request from every dispatch shape (Connection#serve, h2 streams,
|
|
119
|
+
# the C accept4 + io_uring loops; see PrometheusExporter for the
|
|
120
|
+
# C-loop fold-in at scrape time).
|
|
121
|
+
#
|
|
122
|
+
# `worker_id` is conventionally `Process.pid.to_s` — matches the
|
|
123
|
+
# 2.4-C `hyperion_io_uring_workers_active` and
|
|
124
|
+
# `hyperion_per_conn_rejections_total` labeling convention; lets
|
|
125
|
+
# operators correlate distribution rows with `ps`/`/proc` data
|
|
126
|
+
# without a separate worker_id <-> pid mapping table.
|
|
127
|
+
#
|
|
128
|
+
# Hot-path cost: one `@hg_mutex` acquisition per tick. That's
|
|
129
|
+
# acceptable for the audit metric: contention shows up only on
|
|
130
|
+
# the `tick + render` overlap, never inside the C accept loop
|
|
131
|
+
# (which uses its own atomic counter folded in at scrape time).
|
|
132
|
+
# Worth the simplicity over an extra lock-free per-thread cache.
|
|
133
|
+
REQUESTS_DISPATCH_TOTAL = :hyperion_requests_dispatch_total
|
|
134
|
+
WORKER_ID_LABEL_KEYS = %w[worker_id].freeze
|
|
135
|
+
|
|
136
|
+
def tick_worker_request(worker_id)
|
|
137
|
+
label = worker_id.nil? || worker_id.to_s.empty? ? '0' : worker_id.to_s
|
|
138
|
+
ensure_worker_request_family_registered!
|
|
139
|
+
increment_labeled_counter(REQUESTS_DISPATCH_TOTAL, [label])
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# 2.12-E — Idempotently register the labeled-counter family. Public
|
|
143
|
+
# so `Server#run_c_accept_loop` can register at boot — the
|
|
144
|
+
# PrometheusExporter's C-loop fold-in is gated on the family being
|
|
145
|
+
# in the snapshot, and a 100% C-loop worker never goes through
|
|
146
|
+
# `tick_worker_request` to register lazily.
|
|
147
|
+
def ensure_worker_request_family_registered!
|
|
148
|
+
return if @worker_request_family_registered
|
|
149
|
+
|
|
150
|
+
register_labeled_counter(REQUESTS_DISPATCH_TOTAL, label_keys: WORKER_ID_LABEL_KEYS)
|
|
151
|
+
@worker_request_family_registered = true
|
|
152
|
+
end
|
|
153
|
+
|
|
116
154
|
def snapshot
|
|
117
155
|
result = Hash.new(0)
|
|
118
156
|
counters_snapshot = @counters_mutex.synchronize { @thread_counters.dup }
|
|
@@ -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
|