hyperion-rb 2.11.0 → 2.13.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.
@@ -135,6 +135,24 @@ 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
144
+ # 2.13-A — pre-build the frozen single-element label tuple that
145
+ # `tick_worker_request` would otherwise allocate every request
146
+ # (`[@worker_id]` per call). Per-Connection caching is safe
147
+ # because @worker_id is process-constant and the tuple is
148
+ # frozen so consumers can't mutate the shared instance.
149
+ @worker_id_label_tuple = [@worker_id].freeze
150
+ # 2.13-A — register the labeled-counter family ONCE here (used
151
+ # to fire on every `tick_worker_request` via an `unless`-flag
152
+ # check; the early-return cost is small but real on the
153
+ # 8000 r/s -c1 single-thread profile). After this, the
154
+ # request loop calls `increment_labeled_counter` directly.
155
+ @metrics.ensure_worker_request_family_registered!
138
156
  # 2.10-D — direct-dispatch route table. The hot-path lookup
139
157
  # is `@route_table&.lookup(method, path)` so the nil-default
140
158
  # case (no operator-registered direct routes — the
@@ -307,6 +325,23 @@ module Hyperion
307
325
  @metrics.increment(:bytes_read, body_end)
308
326
  @metrics.increment(:requests_total)
309
327
  @metrics.increment(:requests_in_flight)
328
+ # 2.12-E — per-worker request counter for the SO_REUSEPORT
329
+ # load-balancing audit. Worker_id is the OS pid (matches the
330
+ # 2.4-C `hyperion_io_uring_workers_active` convention). Single
331
+ # location for every Ruby-side dispatch shape: regular Rack
332
+ # via `dispatch_request`, direct dispatch via `dispatch_direct!`,
333
+ # and the StaticEntry fast path via `dispatch_direct_static!`
334
+ # all flow through this point in `serve`.
335
+ #
336
+ # 2.13-A — call `increment_labeled_counter` directly with the
337
+ # pre-built frozen `[@worker_id]` tuple instead of going
338
+ # through `tick_worker_request`. The wrapper allocates a
339
+ # fresh `[label]` array AND calls `worker_id.to_s` per
340
+ # request; cached tuple skips both. Family registration was
341
+ # done once in the constructor (idempotent on the Metrics
342
+ # instance) so the request loop is registration-free.
343
+ @metrics.increment_labeled_counter(Hyperion::Metrics::REQUESTS_DISPATCH_TOTAL,
344
+ @worker_id_label_tuple)
310
345
  # 2.4-C: capture start time for the per-route duration histogram.
311
346
  # Same Process.clock_gettime that the access-log path was already
312
347
  # paying — at default-ON log_requests the second call here is
@@ -774,10 +809,35 @@ module Hyperion
774
809
  )
775
810
  end
776
811
 
812
+ # 2.13-A — Rack 3 (the version Hyperion advertises in
813
+ # `env['rack.version']`) requires response header keys to be
814
+ # lowercase Strings (Rack 3 spec §6.4 "Headers must be a Hash;
815
+ # the header keys must be lowercase Strings"). Pre-2.13-A this
816
+ # method scanned the whole Hash via `headers.find` + per-key
817
+ # `k.to_s.downcase` to find the Connection header — that's an
818
+ # O(N) walk + N transient string allocations on EVERY response
819
+ # (and most responses don't carry a Connection header at all,
820
+ # so the loop ran to completion every time).
821
+ #
822
+ # The new path is a single Hash lookup. Apps that violate the
823
+ # Rack 3 spec by returning mixed-case keys (some legacy gems
824
+ # still do; less common in 2026) lose the Connection-close
825
+ # signal and stay on keep-alive — that's a benign degradation
826
+ # (the connection is reused; the next request still goes through
827
+ # request-side `Connection: close` parsing) and the fix is to
828
+ # update the app to spec.
829
+ CONNECTION_HEADER_KEY_DOWNCASE = 'connection'
830
+
777
831
  def should_keep_alive?(request, _status, headers)
778
- # App-emitted Connection: close wins.
779
- conn_response = headers.find { |k, _| k.to_s.downcase == 'connection' }
780
- return false if conn_response && conn_response.last.to_s.downcase == 'close'
832
+ # App-emitted Connection: close wins. Rack-3 fast path: O(1)
833
+ # Hash lookup; non-Hash headers (Array-of-pairs, etc.) fall
834
+ # back to a single allocation-free scan.
835
+ conn_response_value = if headers.is_a?(Hash)
836
+ headers[CONNECTION_HEADER_KEY_DOWNCASE]
837
+ else
838
+ find_connection_header_array(headers)
839
+ end
840
+ return false if conn_response_value && conn_response_value.to_s.downcase == 'close'
781
841
 
782
842
  # Request-side Connection header.
783
843
  conn_request = request.header('connection')&.downcase
@@ -792,6 +852,21 @@ module Hyperion
792
852
  end
793
853
  end
794
854
 
855
+ # 2.13-A — non-Hash headers fallback (Array of [key, value] pairs).
856
+ # Rack 3 mandates Hash, but legacy code occasionally returns an
857
+ # Array; we walk it case-sensitively because Rack-3 lowercase is
858
+ # part of the contract for non-Hash returns too. Apps emitting
859
+ # `'Connection'`-cased keys via Array form fall through to no-
860
+ # match and stay on keep-alive — same benign degradation as the
861
+ # Hash branch.
862
+ def find_connection_header_array(headers)
863
+ headers.each do |pair|
864
+ next unless pair.is_a?(Array) && pair.length >= 2
865
+ return pair[1] if pair[0] == CONNECTION_HEADER_KEY_DOWNCASE
866
+ end
867
+ nil
868
+ end
869
+
795
870
  def set_idle_timeout(socket)
796
871
  socket.timeout = IDLE_KEEPALIVE_TIMEOUT_SECONDS if socket.respond_to?(:timeout=)
797
872
  rescue StandardError
@@ -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