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.
@@ -103,6 +103,47 @@ module Hyperion
103
103
  private
104
104
 
105
105
  def write_buffered(io, status, headers, body, keep_alive:)
106
+ if c_path_eligible?(io)
107
+ date_str = cached_date
108
+
109
+ # Plan #2 — io_uring hotpath write-side: when the current
110
+ # accept fiber owns a HotpathRing AND the C ext exposes the
111
+ # via-ring entrypoint, route the response through a send SQE
112
+ # instead of issuing sendmsg(2) directly. The hotpath ring is
113
+ # set as a fiber-local by Server#run_accept_fiber_io_uring_hotpath
114
+ # (Task 2.3.3) and inherited by the dispatch fiber. Ring is
115
+ # nil for non-hotpath connections (TLS / accept-only ring /
116
+ # epoll path) — we fall through to direct-syscall write.
117
+ ring = Fiber[:hyperion_hotpath_ring]
118
+ if ring && !ring.closed? &&
119
+ ::Hyperion::Http::ResponseWriter.respond_to?(:c_write_buffered_via_ring)
120
+ bytes_out = ::Hyperion::Http::ResponseWriter.c_write_buffered_via_ring(
121
+ io, status, headers, Array(body), keep_alive, date_str, ring.ptr
122
+ )
123
+ Hyperion.metrics.increment(:bytes_written, bytes_out)
124
+ Hyperion.metrics.increment(:hotpath_writes_via_ring)
125
+ body.close if body.respond_to?(:close)
126
+ return
127
+ end
128
+
129
+ bytes_out = ::Hyperion::Http::ResponseWriter.c_write_buffered(
130
+ io, status, headers, Array(body), keep_alive, date_str
131
+ )
132
+ if bytes_out == ::Hyperion::Http::ResponseWriter::WOULDBLOCK
133
+ # EAGAIN on the C path — fall back to the Ruby writer, which
134
+ # yields the fiber under Async / blocks the thread under
135
+ # threadpool correctly.
136
+ return write_buffered_ruby(io, status, headers, body, keep_alive: keep_alive)
137
+ end
138
+ Hyperion.metrics.increment(:bytes_written, bytes_out)
139
+ body.close if body.respond_to?(:close)
140
+ return
141
+ end
142
+
143
+ write_buffered_ruby(io, status, headers, body, keep_alive: keep_alive)
144
+ end
145
+
146
+ def write_buffered_ruby(io, status, headers, body, keep_alive:)
106
147
  # Phase 1 buffers the full body so Content-Length is exact.
107
148
  # Phase 2 introduces chunked transfer-encoding for streaming bodies;
108
149
  # Phase 5 batches via IO::Buffer to avoid this intermediate String.
@@ -386,6 +427,55 @@ module Hyperion
386
427
  false
387
428
  end
388
429
 
430
+ # Plan #1 (perf roadmap) — predicate for the C-side direct-syscall
431
+ # write path. True when:
432
+ # (a) The Hyperion::Http::ResponseWriter C ext loaded.
433
+ # (b) `io` exposes a real kernel fd (real_fd_io? handles the
434
+ # SSLSocket / StringIO / IO-like-but-no-fileno cases).
435
+ # (c) The class-level operator switch hasn't been flipped off.
436
+ #
437
+ # Operators flip the switch off via:
438
+ # Hyperion::Http::ResponseWriter.c_writer_available = false
439
+ # (mirrors the Hyperion::ResponseWriter.page_cache_available pattern).
440
+ def c_path_eligible?(io)
441
+ return false unless defined?(::Hyperion::Http::ResponseWriter)
442
+ return false unless ::Hyperion::Http::ResponseWriter.c_writer_available?
443
+ return false unless real_fd_io?(io)
444
+
445
+ true
446
+ end
447
+
448
+ def write_chunked(io, status, headers, body, keep_alive:)
449
+ if c_path_eligible?(io)
450
+ date_str = cached_date
451
+ begin
452
+ bytes_out = ::Hyperion::Http::ResponseWriter.c_write_chunked(
453
+ io, status, headers, body, keep_alive, date_str
454
+ )
455
+ rescue Errno::EAGAIN
456
+ # Mid-body backpressure on the chunked path leaves the wire
457
+ # in a half-written state; we cannot recover by falling back
458
+ # to the Ruby writer because the chunked head has already
459
+ # been emitted. Re-raise so Connection#serve closes the
460
+ # connection cleanly. This is the documented contract from
461
+ # response_writer.c hyp_chunked_drain.
462
+ raise
463
+ end
464
+ if bytes_out == ::Hyperion::Http::ResponseWriter::WOULDBLOCK
465
+ # Pre-body WOULDBLOCK — only the head failed to ship; nothing
466
+ # is on the wire yet. Fall back to the Ruby chunked writer,
467
+ # which yields under Async / blocks under threadpool correctly.
468
+ return write_chunked_ruby(io, status, headers, body, keep_alive: keep_alive)
469
+ end
470
+ Hyperion.metrics.increment(:bytes_written, bytes_out)
471
+ Hyperion.metrics.increment(:chunked_responses)
472
+ body.close if body.respond_to?(:close)
473
+ return
474
+ end
475
+
476
+ write_chunked_ruby(io, status, headers, body, keep_alive: keep_alive)
477
+ end
478
+
389
479
  # Phase 5 — streaming chunked writer with per-response coalescing.
390
480
  #
391
481
  # Wire format per RFC 7230 §4.1:
@@ -405,7 +495,7 @@ module Hyperion
405
495
  # past per-event coalescing latency.
406
496
  # * body.close (or end-of-each) drains the buffer and appends the
407
497
  # 0\r\n\r\n terminator in a single syscall (atomic w.r.t. the wire).
408
- def write_chunked(io, status, headers, body, keep_alive:)
498
+ def write_chunked_ruby(io, status, headers, body, keep_alive:)
409
499
  reason = REASONS[status] || 'Unknown'
410
500
  date_str = cached_date
411
501
  head = build_head_chunked(status, reason, headers, keep_alive, date_str)
@@ -195,6 +195,7 @@ module Hyperion
195
195
  tls_session_cache_size: TLS::DEFAULT_SESSION_CACHE_SIZE,
196
196
  tls_ktls: :auto,
197
197
  io_uring: :off,
198
+ io_uring_hotpath: :off,
198
199
  max_in_flight_per_conn: nil,
199
200
  tls_handshake_rate_limit: :unlimited,
200
201
  route_table: nil,
@@ -243,6 +244,17 @@ module Hyperion
243
244
  @io_uring_policy = io_uring
244
245
  @io_uring_active = io_uring != :off && Hyperion::IOUring.resolve_policy!(io_uring)
245
246
  log_io_uring_state_once
247
+ # Plan #2 — hotpath gate. Independent of @io_uring_active: the
248
+ # hotpath owns multishot accept + multishot recv + send SQEs on a
249
+ # single unified ring, while the accept-only path uses a simpler
250
+ # ring that only drives accept SQEs. The two paths are mutually
251
+ # exclusive at runtime — HotpathRing takes priority when active.
252
+ # Workers don't share hotpath rings across fork; each child opens
253
+ # its own ring lazily on first use inside `run_accept_fiber`.
254
+ @io_uring_hotpath_policy = io_uring_hotpath
255
+ @io_uring_hotpath_active = io_uring_hotpath != :off &&
256
+ Hyperion::IOUring.resolve_hotpath_policy!(io_uring_hotpath)
257
+ log_io_uring_hotpath_state_once
246
258
  # 2.3-B: per-conn fairness cap (validated/finalized upstream by
247
259
  # `Config#finalize!`; constructor accepts the resolved value, not
248
260
  # a sentinel). nil = no cap (default). The cap propagates to
@@ -880,8 +892,15 @@ module Hyperion
880
892
  # epoll branch — io_uring accept is wired only for the plain TCP
881
893
  # listener; the SSL handshake still wants the userspace
882
894
  # `accept` + `SSL_accept` dance.
895
+ #
896
+ # Plan #2: when `io_uring_hotpath: :auto/:on` resolves to active, the
897
+ # hotpath ring takes priority — it owns multishot accept + multishot recv
898
+ # + send SQEs. The accept-only ring and the epoll path are mutually
899
+ # exclusive with it. TLS still uses the epoll branch regardless.
883
900
  def run_accept_fiber(task)
884
- if @io_uring_active && !@tls
901
+ if @io_uring_hotpath_active && !@tls
902
+ run_accept_fiber_io_uring_hotpath(task)
903
+ elsif @io_uring_active && !@tls
885
904
  run_accept_fiber_io_uring(task)
886
905
  else
887
906
  run_accept_fiber_epoll(task)
@@ -905,7 +924,7 @@ module Hyperion
905
924
  # working off a `::Socket` object identical to what
906
925
  # `accept_nonblock` would have returned.
907
926
  def run_accept_fiber_io_uring(task)
908
- ring = Fiber.current[:hyperion_io_uring] ||= Hyperion::IOUring::Ring.new(queue_depth: 256)
927
+ ring = Fiber[:hyperion_io_uring] ||= Hyperion::IOUring::Ring.new(queue_depth: 256)
909
928
  listener_fd = listening_io.fileno
910
929
  until @stopped
911
930
  client_fd = ring.accept(listener_fd)
@@ -925,13 +944,168 @@ module Hyperion
925
944
  end
926
945
  run_accept_fiber_epoll(task)
927
946
  ensure
928
- ring = Fiber.current[:hyperion_io_uring]
947
+ ring = Fiber[:hyperion_io_uring]
929
948
  if ring && !ring.closed?
930
949
  ring.close
931
- Fiber.current[:hyperion_io_uring] = nil
950
+ Fiber[:hyperion_io_uring] = nil
932
951
  end
933
952
  end
934
953
 
954
+ # Plan #2 — io_uring hotpath accept loop. Opens a per-fiber
955
+ # HotpathRing on first use (multishot accept + multishot recv +
956
+ # send SQEs on one unified ring). For this task the loop only
957
+ # submits the multishot accept SQE and drains accept completions;
958
+ # the per-connection recv wiring is added in Task 2.3.4.
959
+ #
960
+ # On failure it closes the hotpath ring and falls back to the epoll
961
+ # path, matching the accept-only ring's fallback contract.
962
+ def run_accept_fiber_io_uring_hotpath(task)
963
+ ring = Fiber[:hyperion_hotpath_ring] ||=
964
+ Hyperion::IOUring::HotpathRing.new
965
+ listener_fd = listening_io.fileno
966
+ ring.submit_accept_multishot(listener_fd)
967
+ # Plan #2 Task 2.3.4 — per-connection state map.
968
+ # Maps client_fd (Integer) → Connection. Populated on OP_ACCEPT
969
+ # and cleared on OP_RECV result <= 0 (EOF / error). Single-fiber
970
+ # — no mutex needed.
971
+ hotpath_connections = {}
972
+ until @stopped
973
+ ring.each_completion(min_complete: 1, timeout_ms: 100) do |c|
974
+ case c[:op_kind]
975
+ when Hyperion::IOUring::HotpathRing::OP_ACCEPT
976
+ next if c[:result].negative?
977
+
978
+ client_fd = c[:result].to_i
979
+ socket = ::Socket.for_fd(client_fd)
980
+ socket.autoclose = true
981
+ apply_timeout(socket)
982
+ # Build a Connection for the accepted fd. io_uring_owned: true
983
+ # arms the guard in read_chunk (should never be called for
984
+ # these connections) and signals close_for_eof callers.
985
+ conn = Connection.new(runtime: @explicit_runtime ? @runtime : nil,
986
+ max_in_flight_per_conn: @max_in_flight_per_conn,
987
+ route_table: @route_table,
988
+ io_uring_owned: true,
989
+ app: @app)
990
+ conn.instance_variable_set(:@socket, socket)
991
+ # Server has no @metrics ivar — sibling accept paths bump
992
+ # these counters via runtime_metrics (record_dispatch et al.)
993
+ # or via Connection#serve, which we bypass here. Using @metrics
994
+ # here raised NoMethodError on the first ACCEPT completion and
995
+ # took down the --async-io + hotpath=on + 1w boot before
996
+ # wait_for_bind could land its first probe (row-19 BOOT-FAIL).
997
+ runtime_metrics.increment(:connections_accepted)
998
+ runtime_metrics.increment(:connections_active)
999
+ hotpath_connections[client_fd] = conn
1000
+ # Post the first multishot-recv SQE for this fd. From here
1001
+ # on, the kernel delivers recv CQEs for every batch of bytes
1002
+ # that arrives on this socket until EOF or cancel.
1003
+ ring.submit_recv_multishot(client_fd)
1004
+
1005
+ when Hyperion::IOUring::HotpathRing::OP_RECV
1006
+ fd = c[:fd]
1007
+ result = c[:result]
1008
+ buf_id = c[:buf_id]
1009
+
1010
+ conn = hotpath_connections[fd]
1011
+ next unless conn
1012
+
1013
+ if result <= 0
1014
+ # 0 = peer EOF; negative = error. Clean up and discard.
1015
+ hotpath_connections.delete(fd)
1016
+ conn.close_for_eof
1017
+ begin
1018
+ conn.socket&.close unless conn.socket&.closed?
1019
+ rescue StandardError
1020
+ nil
1021
+ end
1022
+ else
1023
+ # Copy `result` bytes from the kernel buffer slot into a
1024
+ # Ruby String (one allocation), then release the slot so
1025
+ # the kernel can reuse it for the next recv.
1026
+ bytes = ring.copy_buffer(buf_id, result)
1027
+ ring.release_buffer(buf_id)
1028
+ conn.feed_read_bytes(bytes)
1029
+ end
1030
+ end
1031
+ end
1032
+
1033
+ # Plan #2 Task 2.5.2 — per-worker fallback-engaged detection.
1034
+ # After each completion drain, check whether the ring went
1035
+ # unhealthy (sustained SQE submit failures / repeated EBADR
1036
+ # from the Rust side set a dirty flag). When detected:
1037
+ # 1. Increment the observable metric so operators can alert.
1038
+ # 2. Emit a single warn-level log line (actionable, not spammy).
1039
+ # 3. Flip @io_uring_hotpath_active to false so subsequent
1040
+ # accept-fiber dispatch (run_accept_fiber's top-level branch)
1041
+ # uses the epoll path for newly-spawned accept fibers on
1042
+ # restart — the current fiber exits the loop and falls
1043
+ # through to run_accept_fiber_epoll below.
1044
+ unless ring.healthy?
1045
+ runtime_metrics.increment(:io_uring_hotpath_fallback_engaged)
1046
+ runtime_logger.warn do
1047
+ { message: 'io_uring hotpath ring unhealthy; engaging accept4 fallback per-worker',
1048
+ worker_pid: Process.pid }
1049
+ end
1050
+ @io_uring_hotpath_active = false
1051
+ begin
1052
+ ring.close
1053
+ rescue StandardError
1054
+ nil
1055
+ end
1056
+ Fiber[:hyperion_hotpath_ring] = nil
1057
+ break
1058
+ end
1059
+ end
1060
+ # If we broke out due to an unhealthy ring (not a clean stop), fall
1061
+ # through to the epoll path so existing + new connections keep being
1062
+ # served. @stopped is still false in that case — the server is alive.
1063
+ run_accept_fiber_epoll(task) unless @stopped
1064
+ rescue IOError, Errno::EBADF
1065
+ @stopped = true
1066
+ rescue Hyperion::IOUring::Unsupported => e
1067
+ runtime_logger.warn do
1068
+ { message: 'io_uring hotpath unsupported at fiber open; falling back to epoll',
1069
+ error: e.message }
1070
+ end
1071
+ close_hotpath_ring_for_fallback
1072
+ run_accept_fiber_epoll(task)
1073
+ rescue StandardError => e
1074
+ runtime_logger.warn do
1075
+ { message: 'io_uring hotpath accept fiber error; falling back to epoll',
1076
+ error: e.message, error_class: e.class.name }
1077
+ end
1078
+ close_hotpath_ring_for_fallback
1079
+ run_accept_fiber_epoll(task)
1080
+ ensure
1081
+ close_hotpath_ring_for_fallback
1082
+ end
1083
+
1084
+ # Cancel the multishot-accept SQE armed on @listener_fd by closing
1085
+ # the hotpath ring before the epoll fallback takes over. Without this,
1086
+ # `accept_or_nil` in run_accept_fiber_epoll competes with a still-armed
1087
+ # kernel-side multishot-accept consumer and inbound connections can be
1088
+ # delivered to a dead CQ. Idempotent.
1089
+ def close_hotpath_ring_for_fallback
1090
+ ring = Fiber[:hyperion_hotpath_ring]
1091
+ return unless ring && !ring.closed?
1092
+
1093
+ ring.close
1094
+ Fiber[:hyperion_hotpath_ring] = nil
1095
+ rescue StandardError
1096
+ nil
1097
+ end
1098
+ private :close_hotpath_ring_for_fallback
1099
+
1100
+ # Plan #2 — test seam: returns the active HotpathRing on the current
1101
+ # accept fiber, or nil if none. Used by io_uring_hotpath_fallback_engaged_spec
1102
+ # to inject force_unhealthy! without exposing the ring through the
1103
+ # public Server surface.
1104
+ def hotpath_ring_for_test
1105
+ Fiber[:hyperion_hotpath_ring]
1106
+ end
1107
+ private :hotpath_ring_for_test
1108
+
935
1109
  # Boot-time log line per worker capturing the resolved io_uring
936
1110
  # state. Mirrors the `log_ktls_state_once` pattern from 2.2.0.
937
1111
  # Single-shot via the class-level ivar so multi-worker boots
@@ -953,6 +1127,28 @@ module Hyperion
953
1127
  nil
954
1128
  end
955
1129
 
1130
+ # Plan #2 — boot-time log for the hotpath gate. Single-shot via the
1131
+ # class-level ivar so multi-worker boots don't fan into N identical
1132
+ # lines. Mirrors the log_io_uring_state_once pattern.
1133
+ def log_io_uring_hotpath_state_once
1134
+ return if Hyperion::Server.instance_variable_get(:@io_uring_hotpath_state_logged)
1135
+ return if @io_uring_hotpath_policy == :off
1136
+
1137
+ Hyperion::Server.instance_variable_set(:@io_uring_hotpath_state_logged, true)
1138
+ runtime_logger.info do
1139
+ {
1140
+ message: 'io_uring hotpath state',
1141
+ policy: @io_uring_hotpath_policy,
1142
+ active: @io_uring_hotpath_active,
1143
+ kernel_ok: Hyperion::IOUring.kernel_supports_io_uring?,
1144
+ hotpath_supported: Hyperion::IOUring.respond_to?(:hotpath_supported?) &&
1145
+ Hyperion::IOUring.hotpath_supported?
1146
+ }
1147
+ end
1148
+ rescue StandardError
1149
+ nil
1150
+ end
1151
+
956
1152
  def dispatch(socket)
957
1153
  alpn = socket.is_a?(::OpenSSL::SSL::SSLSocket) ? socket.alpn_protocol : nil
958
1154
  mode = DispatchMode.resolve(tls: !@tls.nil?, async_io: @async_io,
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Hyperion
4
- VERSION = '2.16.3'
4
+ VERSION = '2.16.4'
5
5
  end
data/lib/hyperion.rb CHANGED
@@ -263,6 +263,7 @@ require_relative 'hyperion/parser'
263
263
  require_relative 'hyperion/c_parser'
264
264
  require_relative 'hyperion/http/sendfile'
265
265
  require_relative 'hyperion/http/page_cache'
266
+ require_relative 'hyperion/http/response_writer'
266
267
  require_relative 'hyperion/static_preload'
267
268
  require_relative 'hyperion/adapter/rack'
268
269
  require_relative 'hyperion/lint_wrapper_pool'
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: hyperion-rb
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.16.3
4
+ version: 2.16.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrey Lobanov
@@ -173,11 +173,15 @@ files:
173
173
  - ext/hyperion_http/page_cache.c
174
174
  - ext/hyperion_http/page_cache_internal.h
175
175
  - ext/hyperion_http/parser.c
176
+ - ext/hyperion_http/response_writer.c
177
+ - ext/hyperion_http/response_writer.h
176
178
  - ext/hyperion_http/sendfile.c
177
179
  - ext/hyperion_http/websocket.c
178
180
  - ext/hyperion_io_uring/Cargo.lock
179
181
  - ext/hyperion_io_uring/Cargo.toml
180
182
  - ext/hyperion_io_uring/extconf.rb
183
+ - ext/hyperion_io_uring/src/buffer_ring.rs
184
+ - ext/hyperion_io_uring/src/hotpath.rs
181
185
  - ext/hyperion_io_uring/src/lib.rs
182
186
  - lib/hyperion-rb.rb
183
187
  - lib/hyperion.rb
@@ -194,6 +198,7 @@ files:
194
198
  - lib/hyperion/h2_admission.rb
195
199
  - lib/hyperion/h2_codec.rb
196
200
  - lib/hyperion/http/page_cache.rb
201
+ - lib/hyperion/http/response_writer.rb
197
202
  - lib/hyperion/http/sendfile.rb
198
203
  - lib/hyperion/http2/native_hpack_adapter.rb
199
204
  - lib/hyperion/http2_handler.rb