Module: Hyperion::Adapter::Rack
- Defined in:
- lib/hyperion/adapter/rack.rb
Overview
NOTE: this is Hyperion::Adapter::Rack, not the Rack gem. Reference the Rack gem as ::Rack inside this module if needed.
Constant Summary collapse
- HTTP_KEY_CACHE =
Pre-frozen mapping for the 30 most common HTTP request headers. Skips the per-request ‘“HTTP_#’_’)”‘ allocation (5–15 string ops per request × N headers). Uncached header names fall back to the dynamic computation. Keys are lowercased to match the parser’s normalisation.
Phase 2c (1.7.1) widened from 16 to 30 entries to cover the full production-traffic top-30 (Sec-Fetch-*, X-Forwarded-Host, X-Real-IP, If-None-Match, etc.). When the C extension is built, the values below are replaced with the same frozen VALUEs registered by CParser::PREINTERNED_HEADERS, so the env hash, parser, and adapter all share string identity for these keys (‘#equal?` is true). This is what unlocks the spec assertion that `env` key is `equal?` to the pre-interned key — and it lets downstream Rack apps that key into env via these same literal strings hit a GVAR-backed pointer compare instead of a hash byte compare.
unfrozen.freeze
- ENV_POOL =
Hyperion::Pool.new( max_size: 256, factory: -> { {} }, reset: ->(env) { env.clear } )
- EMPTY_INPUT_BUFFER =
Phase 11 — shared frozen empty buffer for StringIO reset. Pre-Phase-11 the reset lambda allocated a fresh ‘+”` per request (one String per acquire). The next call to `build_env` immediately swaps in `input.string = request.body`, so we never mutate this buffer — a single frozen empty String is sufficient as a “clean slate” sentinel.
String.new('', encoding: Encoding::ASCII_8BIT).freeze
- SERVER_SOFTWARE_VALUE =
Phase 11 — frozen literal constants for env values that pre-Phase-11 were rebuilt per request:
* SERVER_SOFTWARE — `"Hyperion/#{VERSION}"` interpolated each call. * RACK_VERSION — `[3, 0]` Array literal allocated each call.The Array is frozen so Rack apps can’t mutate the shared instance.
"Hyperion/#{Hyperion::VERSION}".freeze
- RACK_VERSION =
[3, 0].freeze
- INPUT_POOL =
Hyperion::Pool.new( max_size: 256, factory: -> { StringIO.new }, reset: lambda { |io| io.string = EMPTY_INPUT_BUFFER io.rewind } )
Class Method Summary collapse
-
.c_build_env_available? ⇒ Boolean
Phase 3a (1.7.1) — whether the full env-build loop has moved into C.
-
.c_upcase_available? ⇒ Boolean
Whether Hyperion::CParser.upcase_underscore is available.
-
.call(app, request, connection: nil, runtime: nil) ⇒ Object
2.1.0 (WS-1): ‘connection:` is the Hyperion::Connection that owns the underlying socket for this request.
-
.warmup_pool(count = 8) ⇒ Object
Pre-allocate ‘n` env-hash and rack-input objects in master before fork.
Class Method Details
.c_build_env_available? ⇒ Boolean
Phase 3a (1.7.1) — whether the full env-build loop has moved into C. When true, build_env hands the populated env Hash + Request to the C ext, which sets REQUEST_METHOD / PATH_INFO / QUERY_STRING / HTTP_VERSION / SERVER_PROTOCOL / CONTENT_TYPE / CONTENT_LENGTH + every HTTP_<UPCASED> header in one trip across the FFI boundary. The Ruby fallback below stays exercised by spec for parity coverage.
132 133 134 135 136 137 |
# File 'lib/hyperion/adapter/rack.rb', line 132 def self.c_build_env_available? return @c_build_env_available unless @c_build_env_available.nil? @c_build_env_available = defined?(::Hyperion::CParser) && ::Hyperion::CParser.respond_to?(:build_env) end |
.c_upcase_available? ⇒ Boolean
Whether Hyperion::CParser.upcase_underscore is available. Probed lazily at first use (CParser is required after this file, so an eager check at load time would always be false). Memoised in a class-level ivar to keep the hot path branchless.
119 120 121 122 123 124 |
# File 'lib/hyperion/adapter/rack.rb', line 119 def self.c_upcase_available? return @c_upcase_available unless @c_upcase_available.nil? @c_upcase_available = defined?(::Hyperion::CParser) && ::Hyperion::CParser.respond_to?(:upcase_underscore) end |
.call(app, request, connection: nil, runtime: nil) ⇒ Object
2.1.0 (WS-1): ‘connection:` is the Hyperion::Connection that owns the underlying socket for this request. When non-nil, the env hash advertises Rack 3 full-hijack support — the app can call `env.call` to take ownership of the raw socket and speak any post-HTTP protocol (WebSocket, raw TCP tunnel, etc.). When nil (HTTP/2 path, ad-hoc adapter callers in specs), hijack stays disabled — `env` returns false and the env has no `rack.hijack` key, matching pre-2.1 behaviour.
2.5-C: ‘runtime:` is the Hyperion::Runtime that owns this request’s metrics + logger + lifecycle hooks. When nil (the default — every existing in-tree call site stays untouched), the adapter resolves to ‘Hyperion::Runtime.default`, which is the same singleton legacy `Hyperion.metrics` / `Hyperion.logger` delegate to. Apps with multiple servers (multi-tenant) pass an explicit Runtime so each server’s NewRelic / AppSignal / OpenTelemetry hooks remain isolated.
171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 |
# File 'lib/hyperion/adapter/rack.rb', line 171 def call(app, request, connection: nil, runtime: nil) env, input = build_env(request, connection: connection) # 2.1.0 (WS-2) — RFC 6455 §4.2 handshake interception. Runs # AFTER env is built (so the WS module sees the same env keys # the app would see) but BEFORE app.call. Branches: # * :not_websocket — request is plain HTTP; no-op # * :ok — valid WS handshake; stash the # [:ok, accept, subprotocol] tuple in env so the app can # read accept_value without re-running SHA1/base64. The # app is still responsible for writing the 101 response # to the hijacked socket (Option B from the WS-2 plan, # mirrors faye-websocket / ActionCable convention). # * :bad_request / :upgrade_required — short-circuit; the # app never sees the env. Hyperion writes the 4xx itself. ws_result = Hyperion::WebSocket::Handshake.validate(env) case ws_result.first when :ok env['hyperion.websocket.handshake'] = ws_result when :bad_request, :upgrade_required return websocket_handshake_failure_response(ws_result) end # 2.5-C — per-request lifecycle hooks. The `has_request_hooks?` # guard collapses to two empty-Array checks when no observers # are registered (the default for every Hyperion install that # hasn't opted in), so the hot path stays allocation-free # — verified by `yjit_alloc_audit_spec`. Resolving `runtime` # is itself zero-allocation: `Runtime.default` returns a # memoised singleton. # # 2.6-D — when the response auto-detects into `:inline_blocking` # (static-file body responding to `:to_path`, no streaming # marker) we SKIP the after-request lifecycle hook. Static # asset traffic is high-volume + low-value for trace # instrumentation: a NewRelic / OpenTelemetry hook firing on # every 200-byte favicon or 1 MiB asset response wastes CPU # finishing spans nobody queries. Operators wanting to # observe static traffic should use the metrics module # (per-route histogram + sendfile_responses counter), which # is allocation-free on the hot path. The before-request # hook still fires — its semantic ("the request is about # to be processed") is preserved across all dispatch modes; # it's the after-hook (typically heavy: span flush, DB # write, async-queue enqueue) that benefits from the skip. rt = runtime || Hyperion::Runtime.default if rt.has_request_hooks? rt.fire_request_start(request, env) begin response = app.call(env) rescue StandardError => e rt.fire_request_end(request, env, nil, e) raise end resolve_dispatch_mode!(env, response, connection) rt.fire_request_end(request, env, response, nil) unless inline_blocking_resolved?(connection) return response end # Phase 11 — return the app's tuple directly. Pre-Phase-11 # destructured it into 3 locals and re-built the [status, headers, # body] Array (one Array allocation per request). Apps already # return a [status, headers, body] triple per Rack spec, so the # rebuild is pure overhead. response = app.call(env) resolve_dispatch_mode!(env, response, connection) response rescue StandardError => e Hyperion.metrics.increment(:app_errors) Hyperion.logger.error do { message: 'app raised', error: e., error_class: e.class.name, backtrace: (e.backtrace || []).first(20).join(' | ') } end [500, { 'content-type' => 'text/plain' }, ['Internal Server Error']] ensure # Return env + input to pools after the response has been fully # iterated by the writer. We can't release here because Rack body # is iterated lazily — release happens after the writer. # For Phase 5 simplicity we release synchronously since the writer # buffers fully. Phase 7 (HTTP/2 streaming) will revisit. # # 2.1.0 hijack: when the app full-hijacked the socket, the env # references (notably the rack.hijack proc + buffered carry) are # *the* live reference to the connection. Returning the env to the # pool here would let a subsequent request reuse the same hash and # silently null out the hijacker's state. Skip the pool release on # hijacked connections and let the hash be GC'd normally. if env && connection && connection.respond_to?(:hijacked?) && connection.hijacked? # Drop the input back into the pool (it's a fresh StringIO and # the hijacker doesn't reference it). Skip env recycling. INPUT_POOL.release(input) if input else ENV_POOL.release(env) if env INPUT_POOL.release(input) if input end end |
.warmup_pool(count = 8) ⇒ Object
Pre-allocate ‘n` env-hash and rack-input objects in master before fork. Children inherit the populated free-list via copy-on-write —the hash slots stay shared until a request mutates them. Eliminates the first-N-requests allocation tax that every fresh worker would otherwise pay on cold start. Idempotent: safe to call multiple times; the pool simply caps at its configured `max_size`.
146 147 148 149 150 151 152 |
# File 'lib/hyperion/adapter/rack.rb', line 146 def warmup_pool(count = 8) warmed_envs = Array.new(count) { ENV_POOL.acquire } warmed_inputs = Array.new(count) { INPUT_POOL.acquire } warmed_envs.each { |e| ENV_POOL.release(e) } warmed_inputs.each { |i| INPUT_POOL.release(i) } nil end |