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.
@@ -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 */
@@ -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
- @request_body = +''
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
- payload = +''
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
- def send_body(stream, payload, writer_ctx)
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
- writer_ctx.encode_mutex.synchronize { stream.send_data('', ::Protocol::HTTP2::END_STREAM) }
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
- flags = offset >= bytesize ? ::Protocol::HTTP2::END_STREAM : 0
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
@@ -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
- 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