hyperion-rb 2.16.3 → 2.16.4

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.
data/lib/hyperion/cli.rb CHANGED
@@ -44,6 +44,7 @@ module Hyperion
44
44
  # known sharp edges (SQ inheritance, SQPOLL non-survival across
45
45
  # fork). The env var is the sanctioned way to opt in.
46
46
  apply_io_uring_env_override!(config)
47
+ apply_io_uring_hotpath_env_override!(config)
47
48
 
48
49
  # 2.3-B: env-var overrides for the per-conn fairness cap and the
49
50
  # TLS handshake CPU throttle. Same precedence rule as the other
@@ -186,6 +187,10 @@ module Hyperion
186
187
  'Run plain HTTP/1.1 connections under Async::Scheduler (required for hyperion-async-pg and other fiber-cooperative I/O; default off)') do |v|
187
188
  cli_opts[:async_io] = v
188
189
  end
190
+ o.on('--io-uring-hotpath POLICY',
191
+ 'io_uring hot path policy (off|auto|on); default off; Linux 5.19+ only') do |v|
192
+ cli_opts[:io_uring_hotpath] = v.to_sym
193
+ end
189
194
  o.on('--max-body-bytes BYTES', Integer,
190
195
  'Maximum request body size in bytes (default 16777216 = 16 MiB)') do |n|
191
196
  cli_opts[:max_body_bytes] = n
@@ -321,6 +326,7 @@ WARNING: argv is visible via `ps`; prefer --admin-token-file PATH for production
321
326
  tls_session_cache_size: config.tls.session_cache_size,
322
327
  tls_ktls: config.tls.ktls,
323
328
  io_uring: config.io_uring,
329
+ io_uring_hotpath: config.io_uring_hotpath,
324
330
  max_in_flight_per_conn: config.max_in_flight_per_conn,
325
331
  tls_handshake_rate_limit: config.tls.handshake_rate_limit,
326
332
  preload_static_dirs: config.resolved_preload_static_dirs)
@@ -515,6 +521,23 @@ WARNING: argv is visible via `ps`; prefer --admin-token-file PATH for production
515
521
  end
516
522
  private_class_method :apply_io_uring_env_override!
517
523
 
524
+ # Plan #2 — env override for the hotpath gate.
525
+ def self.apply_io_uring_hotpath_env_override!(config)
526
+ raw = ENV['HYPERION_IO_URING_HOTPATH']
527
+ return unless raw
528
+
529
+ case raw.downcase
530
+ when 'off', '0', 'false' then config.io_uring_hotpath = :off
531
+ when 'on', '1', 'true' then config.io_uring_hotpath = :on
532
+ when 'auto' then config.io_uring_hotpath = :auto
533
+ else
534
+ Hyperion.logger.warn do
535
+ { message: 'HYPERION_IO_URING_HOTPATH ignored (must be off|on|auto)', value: raw }
536
+ end
537
+ end
538
+ end
539
+ private_class_method :apply_io_uring_hotpath_env_override!
540
+
518
541
  # 2.3-B: shared parser for `--max-in-flight-per-conn VALUE` and
519
542
  # `HYPERION_MAX_IN_FLIGHT_PER_CONN=VALUE`. Returns either a positive
520
543
  # Integer (explicit cap) or the `:auto` sentinel which `Config#finalize!`
@@ -47,6 +47,15 @@ module Hyperion
47
47
  # Default flips to :auto in 2.4 only after soak. Operators flip on
48
48
  # via `HYPERION_IO_URING={on,auto}` env var to A/B test.
49
49
  io_uring: :off,
50
+ # Plan #2 (perf roadmap) — io_uring hot path policy. Independent
51
+ # gate from the accept-only `io_uring:` above. Tri-state:
52
+ # :off — accept and read/write stay on the existing paths
53
+ # (default; no behavior change in 2.18 minor cut).
54
+ # :auto — engage when supported (Linux 5.19+ + buffer-ring
55
+ # registration succeeds); quietly fall back otherwise.
56
+ # :on — demand it. Boot raises if unsupported.
57
+ # Override at runtime via `HYPERION_IO_URING_HOTPATH={off,auto,on}`.
58
+ io_uring_hotpath: :off,
50
59
  # 2.3-B: per-connection in-flight cap. nginx upstream keep-alive
51
60
  # pipelines many client requests through one upstream connection;
52
61
  # without this cap a single greedy upstream conn can hog the worker
@@ -76,11 +76,26 @@ module Hyperion
76
76
 
77
77
  def initialize(parser: self.class.default_parser, writer: ResponseWriter.new, thread_pool: nil,
78
78
  log_requests: nil, max_body_bytes: MAX_BODY_BYTES, runtime: nil,
79
- max_in_flight_per_conn: nil, path_templater: nil, route_table: nil)
79
+ max_in_flight_per_conn: nil, path_templater: nil, route_table: nil,
80
+ io_uring_owned: false, app: nil)
80
81
  @parser = parser
81
82
  @writer = writer
82
83
  @thread_pool = thread_pool
83
84
  @max_body_bytes = max_body_bytes
85
+ # Plan #2 Task 2.4.1 — Rack app reference for io_uring-owned connections.
86
+ # On the regular (non-hotpath) path the app is passed to Connection#serve
87
+ # as a parameter and never stored. On the io_uring hotpath, Connection#serve
88
+ # is never called; instead the accept fiber drives dispatch via
89
+ # feed_read_bytes → drive_pending_requests, which needs the app stored.
90
+ # Nil on non-io_uring connections; those still use the serve(socket, app)
91
+ # call signature.
92
+ @app = app
93
+ # Plan #2 Task 2.3.4 — when true, this connection's reads are
94
+ # delivered via feed_read_bytes by the accept fiber's OP_RECV
95
+ # handler. read_chunk must NOT be called on io_uring-owned
96
+ # connections (the socket is not polled via read_nonblock on
97
+ # the hotpath). Default false preserves existing behaviour.
98
+ @io_uring_owned = io_uring_owned
84
99
  # 2.3-B: per-conn fairness cap. nil disables the check entirely
85
100
  # (the hot path stays branchless). Positive integer sets the
86
101
  # in-flight ceiling. The counter + dedup-warn flag live as ivars
@@ -1071,6 +1086,193 @@ module Hyperion
1071
1086
  end
1072
1087
  end
1073
1088
 
1089
+ # Plan #2 Task 2.3.4 — hotpath recv entry point.
1090
+ #
1091
+ # Called by the accept fiber's OP_RECV completion handler when a
1092
+ # multishot-recv CQE arrives carrying bytes from the kernel buffer
1093
+ # ring. `bytes` is a String copied from the kernel-mmaped buffer
1094
+ # slot (one allocation per CQE); the kernel can recycle the slot the
1095
+ # moment `ring.release_buffer(buf_id)` is called in the accept fiber
1096
+ # (AFTER this method returns).
1097
+ #
1098
+ # The method appends `bytes` to the per-connection read accumulator
1099
+ # (`@inbuf`, pre-sized once per connection), then drives the parse
1100
+ # loop to completion. Any complete request found is dispatched via
1101
+ # `dispatch_request` (Rack adapter path) synchronously in the accept
1102
+ # fiber. Carry-over bytes (pipelined input) remain in `@inbuf` for
1103
+ # the next CQE.
1104
+ #
1105
+ # Returns :need_more when no complete HTTP header block was found yet,
1106
+ # :dispatched when at least one request was parsed and dispatched, or
1107
+ # :eof when the buffer is empty (peer closed cleanly before sending
1108
+ # a header). The return value is informational; the accept fiber can
1109
+ # use it to decide whether to dispatch or simply await more CQEs.
1110
+ #
1111
+ # NOTE: this method runs in the accept fiber (single-threaded, GVL
1112
+ # held). No mutex is needed on @inbuf.
1113
+ def feed_read_bytes(bytes)
1114
+ @inbuf ||= String.new(capacity: INBUF_INITIAL_CAPACITY,
1115
+ encoding: Encoding::ASCII_8BIT)
1116
+ @inbuf << bytes
1117
+
1118
+ return :eof if @inbuf.empty?
1119
+ return :need_more unless @inbuf.include?(HEADER_TERM)
1120
+
1121
+ # At least one complete header block is present — drive parse + dispatch.
1122
+ drive_pending_requests
1123
+ end
1124
+ public :feed_read_bytes
1125
+
1126
+ # Plan #2 Task 2.4.1 — parse-and-dispatch loop for io_uring-owned
1127
+ # connections. Called by feed_read_bytes after appending CQE bytes
1128
+ # to @inbuf. Mirrors the core of Connection#serve's request loop but
1129
+ # uses @socket / @app ivars (set at accept time) instead of locals.
1130
+ #
1131
+ # Returns :dispatched when at least one request was fully processed,
1132
+ # :need_more when @inbuf holds data but not a complete request yet, or
1133
+ # :eof when @inbuf is empty after carry-over trimming.
1134
+ #
1135
+ # Runs in the accept fiber (single-threaded, GVL held). No mutex needed
1136
+ # on @inbuf or any of the per-connection ivars it touches.
1137
+ def drive_pending_requests
1138
+ @hijacked = false unless defined?(@hijacked)
1139
+ @last_response_was_static_inline_blocking = false \
1140
+ unless defined?(@last_response_was_static_inline_blocking)
1141
+
1142
+ dispatched_any = false
1143
+
1144
+ loop do
1145
+ # Fast-fail: no complete headers yet.
1146
+ break unless @inbuf.include?(HEADER_TERM)
1147
+
1148
+ # Attempt to parse one request from @inbuf. @inbuf IS the buffer
1149
+ # (not a copy) — carry_into_inbuf! trims it in-place after parse.
1150
+ begin
1151
+ request, body_end = @parser.parse(@inbuf)
1152
+ rescue ParseError => e
1153
+ @metrics.increment(:parse_errors)
1154
+ @logger.warn { { message: 'parse error (hotpath)', error: e.message,
1155
+ error_class: e.class.name } }
1156
+ begin
1157
+ @socket.write("HTTP/1.1 400 Bad Request\r\ncontent-length: 11\r\n" \
1158
+ "connection: close\r\n\r\nBad Request")
1159
+ rescue StandardError
1160
+ nil
1161
+ end
1162
+ @inbuf.clear
1163
+ break
1164
+ rescue UnsupportedError => e
1165
+ @logger.warn { { message: 'unsupported request (hotpath)', error: e.message,
1166
+ error_class: e.class.name } }
1167
+ begin
1168
+ @socket.write("HTTP/1.1 501 Not Implemented\r\ncontent-length: 15\r\n" \
1169
+ "connection: close\r\n\r\nNot Implemented")
1170
+ rescue StandardError
1171
+ nil
1172
+ end
1173
+ @inbuf.clear
1174
+ break
1175
+ end
1176
+
1177
+ # Snapshot hijack-buffered carry BEFORE trimming @inbuf.
1178
+ @hijack_buffered = if @inbuf.bytesize > body_end
1179
+ @inbuf.byteslice(body_end, @inbuf.bytesize - body_end).b
1180
+ else
1181
+ EMPTY_BIN
1182
+ end
1183
+ carry_into_inbuf!(@inbuf, body_end)
1184
+
1185
+ peer_addr = peer_address(@socket)
1186
+ request = enrich_with_peer(request, peer_addr) if peer_addr && request.peer_address.nil?
1187
+
1188
+ @metrics.increment(:bytes_read, body_end)
1189
+ @metrics.increment(:requests_total)
1190
+ @metrics.increment(:requests_in_flight)
1191
+ @metrics.increment_labeled_counter(Hyperion::Metrics::REQUESTS_DISPATCH_TOTAL,
1192
+ @worker_id_label_tuple)
1193
+
1194
+ @response_dispatch_mode = nil
1195
+ request_started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
1196
+
1197
+ # 2.10-D direct-dispatch fast path.
1198
+ if @route_table && (direct_handler = @route_table.lookup(request.method, request.path))
1199
+ dispatch_direct!(@socket, request, direct_handler, request_started_at, peer_addr)
1200
+ dispatched_any = true
1201
+ # keep-alive: loop to process any pipelined requests already in @inbuf.
1202
+ next if should_keep_alive_after_direct?(request)
1203
+
1204
+ break
1205
+ end
1206
+
1207
+ # Per-conn fairness gate (mirrors serve's logic).
1208
+ skip_per_conn_fairness = @last_response_was_static_inline_blocking
1209
+ unless @max_in_flight_per_conn.nil? || skip_per_conn_fairness || \
1210
+ per_conn_admit!(@socket, peer_addr)
1211
+ @metrics.decrement(:requests_in_flight)
1212
+ dispatched_any = true
1213
+ next
1214
+ end
1215
+
1216
+ begin
1217
+ status, headers, body = call_app(@app, request)
1218
+ ensure
1219
+ @metrics.decrement(:requests_in_flight)
1220
+ per_conn_release! if @max_in_flight_per_conn && !skip_per_conn_fairness
1221
+ end
1222
+
1223
+ # Rack hijack: app took the socket — stop processing.
1224
+ if @hijacked
1225
+ @logger.debug do
1226
+ { message: 'rack hijack (hotpath)', method: request.method,
1227
+ path: request.path, peer_addr: peer_addr }
1228
+ end
1229
+ body.close if body.respond_to?(:close)
1230
+ break
1231
+ end
1232
+
1233
+ keep_alive = should_keep_alive?(request, status, headers)
1234
+ @writer.write(@socket, status, headers, body, keep_alive: keep_alive,
1235
+ dispatch_mode: @response_dispatch_mode)
1236
+ @last_response_was_static_inline_blocking =
1237
+ @response_dispatch_mode == :inline_blocking
1238
+ @metrics.increment_status(status)
1239
+ log_request(request, status, request_started_at) if @log_requests
1240
+ observe_request_duration(request, status, request_started_at)
1241
+ dispatched_any = true
1242
+
1243
+ # Stop looping if the response told the peer we're closing.
1244
+ break unless keep_alive
1245
+ end
1246
+
1247
+ if dispatched_any
1248
+ :dispatched
1249
+ elsif @inbuf.empty?
1250
+ :eof
1251
+ else
1252
+ :need_more
1253
+ end
1254
+ rescue StandardError => e
1255
+ @metrics.increment(:app_errors)
1256
+ @logger.error do
1257
+ { message: 'unhandled in drive_pending_requests', error: e.message,
1258
+ error_class: e.class.name }
1259
+ end
1260
+ :error
1261
+ end
1262
+
1263
+ # Plan #2 Task 2.3.4 — called by the accept fiber when the OP_RECV
1264
+ # CQE result is <= 0 (peer EOF or error). Decrements the active-
1265
+ # connection gauge that was incremented at accept time (normally
1266
+ # handled in Connection#serve's ensure block — that block is NOT
1267
+ # reached for io_uring-owned connections whose lifecycle is managed
1268
+ # entirely by the accept fiber).
1269
+ #
1270
+ # The socket itself is closed by the accept fiber after this returns.
1271
+ def close_for_eof
1272
+ @metrics.decrement(:connections_active)
1273
+ end
1274
+ public :close_for_eof
1275
+
1074
1276
  # Read up to READ_CHUNK bytes, returning whatever's available. Unlike
1075
1277
  # IO#read(N) — which blocks until N bytes or EOF — read_nonblock returns
1076
1278
  # as soon as any data arrives, which is what we need for live HTTP
@@ -1084,6 +1286,12 @@ module Hyperion
1084
1286
  # against stalled peers (SO_RCVTIMEO and IO#timeout= don't reliably trip
1085
1287
  # readpartial on Ruby 3.3).
1086
1288
  def read_chunk(socket)
1289
+ # Plan #2 Task 2.3.4 — io_uring-owned connections receive data
1290
+ # via feed_read_bytes from the accept fiber's OP_RECV handler.
1291
+ # read_chunk must not be called on these connections; the accept
1292
+ # fiber never calls Connection#serve for hotpath conns.
1293
+ raise 'BUG: read_chunk called on io_uring-owned connection' if @io_uring_owned
1294
+
1087
1295
  result = socket.read_nonblock(READ_CHUNK, exception: false)
1088
1296
  return result if result.is_a?(String) # hot path: data was buffered, return immediately
1089
1297
  return nil if result.nil? # EOF
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hyperion
4
+ module Http
5
+ # Direct-syscall response writer for plain-TCP kernel fds.
6
+ #
7
+ # The C primitives are registered as singleton methods on this
8
+ # very module by `ext/hyperion_http/response_writer.c` (see
9
+ # `Init_hyperion_response_writer`). Surface from C:
10
+ #
11
+ # ResponseWriter.available? -> true | false
12
+ # ResponseWriter.c_write_buffered(io, status, headers, body,
13
+ # keep_alive, date_str) -> Integer
14
+ # ResponseWriter.c_write_chunked(io, status, headers, body,
15
+ # keep_alive, date_str) -> Integer
16
+ # ResponseWriter.c_write_buffered_via_ring(io, status, headers,
17
+ # body, keep_alive,
18
+ # date_str, ring_ptr)
19
+ # -> Integer
20
+ # Plan #2 seam: submits a send SQE via the io_uring crate instead
21
+ # of issuing writev directly. Falls back to c_write_buffered when
22
+ # the io_uring crate is not loaded (hyp_submit_send_fn == NULL
23
+ # after lazy dlsym attempt). ring_ptr is the HotpathRing raw
24
+ # pointer as an Integer.
25
+ #
26
+ # Operators can flip the dispatcher off at runtime with
27
+ # `Hyperion::Http::ResponseWriter.c_writer_available = false`
28
+ # (test seam / A/B rollback). Mirrors the
29
+ # `Hyperion::ResponseWriter.page_cache_available = false`
30
+ # pattern (response_writer.rb:60-65).
31
+ module ResponseWriter
32
+ class << self
33
+ attr_writer :c_writer_available
34
+
35
+ def c_writer_available?
36
+ return @c_writer_available unless @c_writer_available.nil?
37
+
38
+ @c_writer_available =
39
+ respond_to?(:available?) && available? &&
40
+ respond_to?(:c_write_buffered) &&
41
+ respond_to?(:c_write_chunked)
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
@@ -39,7 +39,7 @@ module Hyperion
39
39
  # * One ring per fiber that needs it (the accept fiber, optionally
40
40
  # per-connection read fibers in a future phase).
41
41
  # * Ring is opened lazily on first use:
42
- # Fiber.current[:hyperion_io_uring] ||=
42
+ # Fiber[:hyperion_io_uring] ||=
43
43
  # Hyperion::IOUring::Ring.new(queue_depth: 256)
44
44
  # * Ring is closed when the fiber exits.
45
45
  # * Workers don't share rings across fork — each child opens its own.
@@ -52,7 +52,7 @@ module Hyperion
52
52
  # 2.4 only after 6 months of soak. io_uring code in production has
53
53
  # too many sharp edges to default-on without field validation.
54
54
  module IOUring
55
- EXPECTED_ABI = 1
55
+ EXPECTED_ABI = 2
56
56
  # Linux 5.6 stabilized IORING_OP_ACCEPT (commit 17f2fe35d080,
57
57
  # mainlined Mar 2020). 5.5 had a buggy precursor that the io-uring
58
58
  # crate refuses to use. We gate on 5.6 to match the crate's stance.
@@ -139,6 +139,123 @@ module Hyperion
139
139
  end
140
140
  end
141
141
 
142
+ # Plan #2 — io_uring hot path: multishot accept + multishot recv
143
+ # with kernel-managed buffer rings + send SQEs. One ring per
144
+ # worker; the accept fiber drains the unified completion queue.
145
+ class HotpathRing
146
+ DEFAULT_QUEUE_DEPTH = 1024
147
+ DEFAULT_N_BUFS = 512
148
+ DEFAULT_BUF_SIZE = 8192
149
+
150
+ # Layout matches the Rust `#[repr(C)] Completion` struct
151
+ # (hotpath.rs has a compile-time assert of size = 24):
152
+ # u8 op_kind | i32 fd | i64 result | i32 buf_id | u32 flags
153
+ # padded to native alignment. Total: 24 bytes.
154
+ COMPLETION_BYTES = 24
155
+ MAX_BATCH = 64
156
+
157
+ # Op-kind values must match Rust's `OpKind` enum in hotpath.rs.
158
+ OP_ACCEPT = 1
159
+ OP_RECV = 2
160
+ OP_SEND = 3
161
+ OP_CLOSE = 4
162
+
163
+ def initialize(queue_depth: DEFAULT_QUEUE_DEPTH,
164
+ n_bufs: DEFAULT_N_BUFS,
165
+ buf_size: DEFAULT_BUF_SIZE)
166
+ raise Unsupported, 'io_uring hotpath not supported on this host' \
167
+ unless IOUring.hotpath_supported?
168
+
169
+ @ptr = IOUring.hotpath_ring_new(queue_depth, n_bufs, buf_size)
170
+ raise Unsupported, 'hotpath ring allocation failed' if @ptr.nil?
171
+
172
+ @completion_buf = Fiddle::Pointer.malloc(COMPLETION_BYTES * MAX_BATCH,
173
+ Fiddle::RUBY_FREE)
174
+ @closed = false
175
+ end
176
+
177
+ def submit_accept_multishot(listener_fd)
178
+ rc = IOUring.hotpath_submit_accept(@ptr, listener_fd.to_i)
179
+ raise SystemCallError.new('hotpath submit_accept', -rc) if rc.negative?
180
+ nil
181
+ end
182
+
183
+ def submit_recv_multishot(fd)
184
+ rc = IOUring.hotpath_submit_recv(@ptr, fd.to_i)
185
+ raise SystemCallError.new('hotpath submit_recv', -rc) if rc.negative?
186
+ nil
187
+ end
188
+
189
+ def submit_send(fd, iov_ptr, iov_count)
190
+ rc = IOUring.hotpath_submit_send(@ptr, fd.to_i, iov_ptr, iov_count.to_i)
191
+ raise SystemCallError.new('hotpath submit_send', -rc) if rc.negative?
192
+ nil
193
+ end
194
+
195
+ # Drain up to MAX_BATCH completions. Yields each as a frozen
196
+ # Hash; returns the count yielded. Caller is responsible for
197
+ # `release_buffer(buf_id)` after consuming a recv buffer view.
198
+ #
199
+ # If wait_completions returns -1 (ring went unhealthy), this
200
+ # method returns 0 yielded and the caller must check `healthy?`
201
+ # to detect the state and fall back to accept4.
202
+ def each_completion(min_complete: 1, timeout_ms: 100)
203
+ n = IOUring.hotpath_wait(@ptr, min_complete, timeout_ms,
204
+ @completion_buf, MAX_BATCH)
205
+ return 0 if n.negative?
206
+
207
+ n.times do |i|
208
+ offset = i * COMPLETION_BYTES
209
+ op_kind = @completion_buf[offset, 1].unpack1('C')
210
+ fd = @completion_buf[offset + 4, 4].unpack1('l<')
211
+ result = @completion_buf[offset + 8, 8].unpack1('q<')
212
+ buf_id = @completion_buf[offset + 16, 4].unpack1('l<')
213
+ flags = @completion_buf[offset + 20, 4].unpack1('L<')
214
+ yield(op_kind: op_kind, fd: fd, result: result,
215
+ buf_id: buf_id, flags: flags)
216
+ end
217
+ n
218
+ end
219
+
220
+ def release_buffer(buf_id)
221
+ IOUring.hotpath_release_buf(@ptr, buf_id.to_i)
222
+ end
223
+
224
+ # Plan #2 Task 2.3.4 — copy `len` bytes from kernel buffer-ring
225
+ # slot `buf_id` into a freshly-allocated Ruby String.
226
+ #
227
+ # The caller MUST call `release_buffer(buf_id)` after this returns
228
+ # so the kernel can reuse the slot. One allocation per recv CQE —
229
+ # acceptable as a baseline; the zero-copy variant is deferred.
230
+ def copy_buffer(buf_id, len)
231
+ out = Fiddle::Pointer.malloc(len, Fiddle::RUBY_FREE)
232
+ rc = IOUring.hotpath_copy_buffer(@ptr, buf_id.to_i, len.to_i, out, len.to_i)
233
+ raise SystemCallError.new('hotpath copy_buffer', -rc) if rc.negative?
234
+ out.to_str(rc)
235
+ end
236
+
237
+ def force_unhealthy!
238
+ IOUring.hotpath_force_unhealthy(@ptr)
239
+ end
240
+
241
+ def healthy?
242
+ IOUring.hotpath_is_healthy(@ptr) == 1
243
+ end
244
+
245
+ def close
246
+ return if @closed
247
+ @closed = true
248
+ IOUring.hotpath_ring_free(@ptr) if @ptr && !@ptr.null?
249
+ @ptr = nil
250
+ end
251
+
252
+ def closed?
253
+ @closed
254
+ end
255
+
256
+ attr_reader :ptr
257
+ end
258
+
142
259
  class << self
143
260
  # Cached three-state result: nil = not-yet-probed, true/false = result.
144
261
  #
@@ -155,9 +272,29 @@ module Hyperion
155
272
  # specs that stub Etc.uname or RbConfig.
156
273
  def reset!
157
274
  @supported = nil
275
+ @hotpath_supported = nil
158
276
  @lib = nil
159
277
  end
160
278
 
279
+ # Plan #2 — true when (a) accept-only `supported?` true, AND
280
+ # (b) the kernel actually accepts pbuf-ring registration (5.19+).
281
+ # NOTE: this does NOT probe RecvMulti (6.0+); the Ruby caller
282
+ # must treat the first recv CQE failure as a feature-unavailable
283
+ # signal — see hotpath.rs's hyperion_io_uring_hotpath_supported
284
+ # doc comment for the full kernel-version matrix.
285
+ def hotpath_supported?
286
+ return @hotpath_supported unless @hotpath_supported.nil?
287
+ @hotpath_supported = compute_hotpath_supported
288
+ end
289
+
290
+ def compute_hotpath_supported
291
+ return false unless supported?
292
+ return false unless @hotpath_supported_fn
293
+ @hotpath_supported_fn.call.zero?
294
+ rescue StandardError
295
+ false
296
+ end
297
+
161
298
  # ---- Internal: feature gate ----
162
299
 
163
300
  def compute_supported
@@ -249,6 +386,63 @@ module Hyperion
249
386
  Fiddle::TYPE_VOIDP, Fiddle::TYPE_INT,
250
387
  Fiddle::TYPE_VOIDP],
251
388
  Fiddle::TYPE_INT)
389
+
390
+ # Plan #2 — hotpath surface (5.19+ for PBUF_RING, 6.0+ for RecvMulti).
391
+ @hotpath_supported_fn = Fiddle::Function.new(
392
+ @lib['hyperion_io_uring_hotpath_supported'], [], Fiddle::TYPE_INT
393
+ )
394
+ @hotpath_ring_new_fn = Fiddle::Function.new(
395
+ @lib['hyperion_io_uring_hotpath_ring_new'],
396
+ [Fiddle::TYPE_INT, Fiddle::TYPE_SHORT, Fiddle::TYPE_INT],
397
+ Fiddle::TYPE_VOIDP
398
+ )
399
+ @hotpath_ring_free_fn = Fiddle::Function.new(
400
+ @lib['hyperion_io_uring_hotpath_ring_free'],
401
+ [Fiddle::TYPE_VOIDP], Fiddle::TYPE_VOID
402
+ )
403
+ @hotpath_submit_accept_fn = Fiddle::Function.new(
404
+ @lib['hyperion_io_uring_hotpath_submit_accept_multishot'],
405
+ [Fiddle::TYPE_VOIDP, Fiddle::TYPE_INT], Fiddle::TYPE_INT
406
+ )
407
+ @hotpath_submit_recv_fn = Fiddle::Function.new(
408
+ @lib['hyperion_io_uring_hotpath_submit_recv_multishot'],
409
+ [Fiddle::TYPE_VOIDP, Fiddle::TYPE_INT], Fiddle::TYPE_INT
410
+ )
411
+ @hotpath_submit_send_fn = Fiddle::Function.new(
412
+ @lib['hyperion_io_uring_hotpath_submit_send'],
413
+ [Fiddle::TYPE_VOIDP, Fiddle::TYPE_INT, Fiddle::TYPE_VOIDP, Fiddle::TYPE_INT],
414
+ Fiddle::TYPE_INT
415
+ )
416
+ @hotpath_wait_fn = Fiddle::Function.new(
417
+ @lib['hyperion_io_uring_hotpath_wait_completions'],
418
+ [Fiddle::TYPE_VOIDP, Fiddle::TYPE_INT, Fiddle::TYPE_INT,
419
+ Fiddle::TYPE_VOIDP, Fiddle::TYPE_INT],
420
+ Fiddle::TYPE_INT
421
+ )
422
+ @hotpath_release_buf_fn = Fiddle::Function.new(
423
+ @lib['hyperion_io_uring_hotpath_release_buffer'],
424
+ [Fiddle::TYPE_VOIDP, Fiddle::TYPE_SHORT], Fiddle::TYPE_VOID
425
+ )
426
+ @hotpath_force_unhealthy_fn = Fiddle::Function.new(
427
+ @lib['hyperion_io_uring_hotpath_force_unhealthy'],
428
+ [Fiddle::TYPE_VOIDP], Fiddle::TYPE_VOID
429
+ )
430
+ @hotpath_is_healthy_fn = Fiddle::Function.new(
431
+ @lib['hyperion_io_uring_hotpath_is_healthy'],
432
+ [Fiddle::TYPE_VOIDP], Fiddle::TYPE_INT
433
+ )
434
+ # Plan #2 Task 2.3.4 — one-copy buffer extraction: copies `len`
435
+ # bytes from kernel buffer-ring slot `buf_id` into a
436
+ # caller-supplied output buffer. Returns byte count or negative errno.
437
+ @hotpath_copy_buffer_fn = Fiddle::Function.new(
438
+ @lib['hyperion_io_uring_hotpath_copy_buffer'],
439
+ [Fiddle::TYPE_VOIDP, # ptr
440
+ Fiddle::TYPE_SHORT, # buf_id (u16)
441
+ Fiddle::TYPE_INT, # len (u32)
442
+ Fiddle::TYPE_VOIDP, # out_ptr
443
+ Fiddle::TYPE_INT], # out_cap (u32)
444
+ Fiddle::TYPE_INT
445
+ )
252
446
  @lib
253
447
  rescue Fiddle::DLError, StandardError => e
254
448
  warn "[hyperion] IOUring failed to load (#{e.class}: #{e.message}); falling back to epoll"
@@ -259,9 +453,16 @@ module Hyperion
259
453
  def candidate_paths
260
454
  gem_lib = File.expand_path('../hyperion_io_uring', __dir__)
261
455
  ext_target = File.expand_path('../../ext/hyperion_io_uring/target/release', __dir__)
262
- %w[libhyperion_io_uring.dylib libhyperion_io_uring.so].flat_map do |name|
263
- [File.join(gem_lib, name), File.join(ext_target, name)]
264
- end
456
+ # Prefer the platform-native extension first so Fiddle.dlopen doesn't
457
+ # accidentally pick up a cross-platform binary (e.g. a macOS .dylib
458
+ # rsync'd into a Linux lib dir) which causes an ArgumentError on read.
459
+ native, other = linux? ? %w[.so .dylib] : %w[.dylib .so]
460
+ [
461
+ File.join(gem_lib, "libhyperion_io_uring#{native}"),
462
+ File.join(ext_target, "libhyperion_io_uring#{native}"),
463
+ File.join(gem_lib, "libhyperion_io_uring#{other}"),
464
+ File.join(ext_target, "libhyperion_io_uring#{other}"),
465
+ ]
265
466
  end
266
467
 
267
468
  # ---- FFI wrappers ----
@@ -282,6 +483,48 @@ module Hyperion
282
483
  def ring_read(ptr, fd, buf, max, errno_buf)
283
484
  @read_fn.call(ptr, fd, buf, max, errno_buf)
284
485
  end
486
+
487
+ def hotpath_ring_new(qd, n_bufs, buf_size)
488
+ ptr = @hotpath_ring_new_fn.call(qd, n_bufs, buf_size)
489
+ ptr.null? ? nil : ptr
490
+ end
491
+
492
+ def hotpath_ring_free(ptr); @hotpath_ring_free_fn.call(ptr); end
493
+
494
+ def hotpath_submit_accept(ptr, fd)
495
+ @hotpath_submit_accept_fn.call(ptr, fd)
496
+ end
497
+
498
+ def hotpath_submit_recv(ptr, fd)
499
+ @hotpath_submit_recv_fn.call(ptr, fd)
500
+ end
501
+
502
+ def hotpath_submit_send(ptr, fd, iov, n)
503
+ @hotpath_submit_send_fn.call(ptr, fd, iov, n)
504
+ end
505
+
506
+ def hotpath_wait(ptr, mc, t, out, cap)
507
+ @hotpath_wait_fn.call(ptr, mc, t, out, cap)
508
+ end
509
+
510
+ def hotpath_release_buf(ptr, buf_id)
511
+ @hotpath_release_buf_fn.call(ptr, buf_id)
512
+ end
513
+
514
+ def hotpath_force_unhealthy(ptr)
515
+ @hotpath_force_unhealthy_fn.call(ptr)
516
+ end
517
+
518
+ def hotpath_is_healthy(ptr)
519
+ @hotpath_is_healthy_fn.call(ptr)
520
+ end
521
+
522
+ # Plan #2 Task 2.3.4 — copy `len` bytes from kernel buffer-ring
523
+ # slot `buf_id` into the caller-supplied output buffer. Returns
524
+ # the number of bytes written, or a negative errno on failure.
525
+ def hotpath_copy_buffer(ptr, buf_id, len, out_ptr, out_cap)
526
+ @hotpath_copy_buffer_fn.call(ptr, buf_id, len, out_ptr, out_cap)
527
+ end
285
528
  end
286
529
 
287
530
  # ---- Server-side helpers ----
@@ -313,5 +556,27 @@ module Hyperion
313
556
  raise ArgumentError, "io_uring must be :off, :auto, or :on (got #{policy.inspect})"
314
557
  end
315
558
  end
559
+
560
+ # Plan #2 — resolve `:off | :auto | :on` for the hotpath gate.
561
+ # Mirrors `resolve_policy!` semantics: `:on` raises on unsupported,
562
+ # `:auto` quietly falls back, `:off` returns false.
563
+ def self.resolve_hotpath_policy!(policy)
564
+ case policy
565
+ when :off, nil, false
566
+ false
567
+ when :auto
568
+ hotpath_supported?
569
+ when :on, true
570
+ unless hotpath_supported?
571
+ raise Unsupported,
572
+ 'io_uring hotpath required (io_uring_hotpath: :on) but unsupported on this host ' \
573
+ "(linux=#{linux?}, kernel_ok=#{kernel_supports_io_uring?}, " \
574
+ "hotpath_supported=#{hotpath_supported?})"
575
+ end
576
+ true
577
+ else
578
+ raise ArgumentError, "io_uring_hotpath must be :off, :auto, or :on (got #{policy.inspect})"
579
+ end
580
+ end
316
581
  end
317
582
  end