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 } )
- STATUS_LINES =
2.14-A — minimal status line builder. Covers the canonical 200/201/204/301/302/304/400/401/403/404/500 by name; everything else falls back to a generic reason phrase since the Rack body still wins at the protocol level.
{ 200 => "HTTP/1.1 200 OK\r\n", 201 => "HTTP/1.1 201 Created\r\n", 204 => "HTTP/1.1 204 No Content\r\n", 301 => "HTTP/1.1 301 Moved Permanently\r\n", 302 => "HTTP/1.1 302 Found\r\n", 304 => "HTTP/1.1 304 Not Modified\r\n", 400 => "HTTP/1.1 400 Bad Request\r\n", 401 => "HTTP/1.1 401 Unauthorized\r\n", 403 => "HTTP/1.1 403 Forbidden\r\n", 404 => "HTTP/1.1 404 Not Found\r\n", 500 => "HTTP/1.1 500 Internal Server Error\r\n" }.freeze
Class Method Summary collapse
-
.build_c_loop_env(method_str, path_str, query_str, host_str, headers_blob, remote_addr) ⇒ Object
2.14-A — assemble the Rack env for a C-accept-loop dispatch.
- .build_status_line(status) ⇒ Object
-
.c_build_env_available? ⇒ Boolean
Phase 3a (1.7.1) — whether the full env-build loop has moved into C.
-
.c_loop_request_for(env) ⇒ Object
2.14-A — minimal ‘Hyperion::Request` value for lifecycle hook observers.
-
.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.
-
.collect_body_bytes(body) ⇒ Object
2.14-A — drain a Rack body into a single binary blob.
-
.dispatch_for_c_loop(method_str, path_str, query_str, host_str, headers_blob, remote_addr, block, keep_alive, runtime) ⇒ Object
2.14-A — C-accept-loop dispatch helper.
-
.normalize_response_headers(headers, body_len, keep_alive) ⇒ Object
2.14-A — Build the response header lines including ‘content-length`, `connection`, and `server`.
-
.parse_c_loop_headers!(env, headers_blob) ⇒ Object
2.14-A — parse the raw header block the C accept loop hands us into the env hash.
-
.render_c_loop_response(response, keep_alive) ⇒ Object
2.14-A — render a Rack ‘[status, headers, body]` triple to the wire bytes for the C loop.
-
.warmup_pool(count = 8) ⇒ Object
Pre-allocate ‘n` env-hash and rack-input objects in master before fork.
Class Method Details
.build_c_loop_env(method_str, path_str, query_str, host_str, headers_blob, remote_addr) ⇒ Object
2.14-A — assemble the Rack env for a C-accept-loop dispatch. Mirrors the constants ‘build_env` sets on the regular path but skips the `connection`/hijack branches: the C accept loop owns the fd; full-hijack semantics are out of scope for this dispatch shape (h1 keep-alive is handled in C). Returns `[env, input]` so the caller can release both back to their pools after the response is rendered.
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 271 272 273 274 275 276 |
# File 'lib/hyperion/adapter/rack.rb', line 240 def build_c_loop_env(method_str, path_str, query_str, host_str, headers_blob, remote_addr) server_name, server_port = split_host(host_str || '') env = ENV_POOL.acquire input = INPUT_POOL.acquire input.string = EMPTY_INPUT_BUFFER input.rewind env['REQUEST_METHOD'] = method_str env['PATH_INFO'] = path_str env['QUERY_STRING'] = query_str || '' env['SERVER_PROTOCOL'] = 'HTTP/1.1' env['HTTP_VERSION'] = 'HTTP/1.1' env['SERVER_NAME'] = server_name env['SERVER_PORT'] = server_port env['SERVER_SOFTWARE'] = SERVER_SOFTWARE_VALUE env['REMOTE_ADDR'] = remote_addr.nil? || remote_addr.empty? ? '127.0.0.1' : remote_addr env['rack.url_scheme'] = 'http' env['rack.errors'] = $stderr env['rack.version'] = RACK_VERSION env['rack.multithread'] = true env['rack.multiprocess'] = false env['rack.run_once'] = false env['rack.hijack?'] = false env['SCRIPT_NAME'] = '' env['rack.input'] = input # 2.14-A — guarded `is_a?(String) && !empty?` (rather than # `present?`) so rubocop-rails's Style/Present autocorrect # can't rewrite the branch to a Rails-only API. Same pattern # `Server#dispatch_handed_off` uses for `partial`. env['HTTP_HOST'] = host_str if host_str.is_a?(String) && !host_str.empty? parse_c_loop_headers!(env, headers_blob) if headers_blob.is_a?(String) && !headers_blob.empty? [env, input] end |
.build_status_line(status) ⇒ Object
435 436 437 |
# File 'lib/hyperion/adapter/rack.rb', line 435 def build_status_line(status) STATUS_LINES[status] || "HTTP/1.1 #{status} OK\r\n" end |
.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_loop_request_for(env) ⇒ Object
2.14-A — minimal ‘Hyperion::Request` value for lifecycle hook observers. Only built when hooks are active (the `has_request_hooks?` guard skips the alloc on the no-hook hot path).
328 329 330 331 332 333 334 335 336 337 |
# File 'lib/hyperion/adapter/rack.rb', line 328 def c_loop_request_for(env) ::Hyperion::Request.new( method: env['REQUEST_METHOD'], path: env['PATH_INFO'], query_string: env['QUERY_STRING'], http_version: 'HTTP/1.1', headers: {}, body: nil ) 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.
456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 |
# File 'lib/hyperion/adapter/rack.rb', line 456 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 |
.collect_body_bytes(body) ⇒ Object
2.14-A — drain a Rack body into a single binary blob. Honours both Array bodies (the common case — ‘[body_str]`) and `each`-yielding bodies. Rack 3 streaming bodies (the `call(stream)` variant) raise here; the eligibility check is supposed to refuse them at registration time.
385 386 387 388 389 390 391 |
# File 'lib/hyperion/adapter/rack.rb', line 385 def collect_body_bytes(body) return body[0].b if body.is_a?(Array) && body.length == 1 && body[0].is_a?(String) buf = String.new(encoding: Encoding::ASCII_8BIT) body.each { |chunk| buf << chunk.to_s.b } if body.respond_to?(:each) buf end |
.dispatch_for_c_loop(method_str, path_str, query_str, host_str, headers_blob, remote_addr, block, keep_alive, runtime) ⇒ Object
2.14-A — C-accept-loop dispatch helper.
The C accept loop (‘PageCache.run_static_accept_loop`) calls this helper, under the GVL, when a request matches a `RouteTable::DynamicBlockEntry`. The C side has already done accept + recv + parse without holding the GVL; this helper owns the `app.call(env)` slice and returns the fully-formed HTTP/1.1 response bytes for C to write (also without the GVL).
Args (all positional, all Strings except ‘block` and `keep_alive` and `runtime`):
* `method_str` — e.g. "GET"
* `path_str` — request path, no query
* `query_str` — query (no leading '?'), or "" if none
* `host_str` — `Host:` header value, or ""
* `headers_blob` — raw header section as bytes
(the slice between request-line CRLF and the closing
CRLFCRLF, terminated by a CRLF on the last header). The
helper parses this in Ruby — header parse is a few µs
even for the 30-header case, dwarfed by `app.call`.
* `remote_addr` — peer IP as a String, or "" if unknown
* `block` — the registered Proc / lambda
* `keep_alive` — true to emit `connection: keep-alive`
in the response head, false for `connection: close`
* `runtime` — the `Hyperion::Runtime` instance the
server was constructed with (for lifecycle hooks); the
C loop captures this once at boot via the registered
callback closure
Returns a single binary String of HTTP/1.1 response bytes (status line + response headers + CRLF + body). The C loop writes this verbatim. On exception, returns a 500 envelope so the C loop can still respond to the peer (better UX than closing the fd silently).
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 |
# File 'lib/hyperion/adapter/rack.rb', line 188 def dispatch_for_c_loop(method_str, path_str, query_str, host_str, headers_blob, remote_addr, block, keep_alive, runtime) env, input = build_c_loop_env(method_str, path_str, query_str, host_str, headers_blob, remote_addr) request = nil response = nil error = nil rt = runtime || Hyperion::Runtime.default if rt.has_request_hooks? request = c_loop_request_for(env) rt.fire_request_start(request, env) end begin response = block.call(env) rescue StandardError => e error = e end if rt.has_request_hooks? request ||= c_loop_request_for(env) rt.fire_request_end(request, env, response, error) end if error ::Hyperion.metrics.increment(:app_errors) ::Hyperion.logger.error do { message: 'app raised (c-accept-loop dispatch)', error: error., error_class: error.class.name, backtrace: (error.backtrace || []).first(20).join(' | ') } end response = [500, { 'content-type' => 'text/plain' }, ['Internal Server Error']] end render_c_loop_response(response, keep_alive) ensure ENV_POOL.release(env) if env INPUT_POOL.release(input) if input end |
.normalize_response_headers(headers, body_len, keep_alive) ⇒ Object
2.14-A — Build the response header lines including ‘content-length`, `connection`, and `server`. Skips any header named `connection`/`content-length`/`transfer-encoding` the app set (we own those in the C-loop path).
397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 |
# File 'lib/hyperion/adapter/rack.rb', line 397 def normalize_response_headers(headers, body_len, keep_alive) out = String.new(encoding: Encoding::ASCII_8BIT) if headers.is_a?(Hash) headers.each do |name, value| ln = name.to_s.downcase next if %w[connection content-length transfer-encoding].include?(ln) # Multi-value headers (Rack 3: Array of values, or # newline-joined String) — emit one line per value. vals = value.is_a?(Array) ? value : value.to_s.split("\n") vals.each do |v| out << ln << ': ' << v.to_s << "\r\n" end end end out << 'content-length: ' << body_len.to_s << "\r\n" out << (keep_alive ? "connection: keep-alive\r\n" : "connection: close\r\n") out end |
.parse_c_loop_headers!(env, headers_blob) ⇒ Object
2.14-A — parse the raw header block the C accept loop hands us into the env hash. Each line is ‘name: valuern`; the final empty line is already trimmed by the caller (the C loop slices between request-line-end and the closing CRLFCRLF and passes the inner bytes verbatim).
We honour the same HTTP_KEY_CACHE the regular adapter path uses, so ‘equal?` pointer-compares from upstream Rack code (Rack::Attack et al.) keep working.
287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 |
# File 'lib/hyperion/adapter/rack.rb', line 287 def parse_c_loop_headers!(env, headers_blob) return if headers_blob.empty? c_upcase = c_upcase_available? # The Ruby parser walks line-by-line; allocations are 1 # String per header (the value). Header names go through # the cache hit (no alloc) or the C-ext upcase_underscore # (single-call alloc). start = 0 blen = headers_blob.bytesize while start < blen eol = headers_blob.index("\r\n", start) || blen line = headers_blob.byteslice(start, eol - start) start = eol + 2 next if line.empty? colon = line.index(':') next unless colon name = line.byteslice(0, colon).downcase # Skip the colon, then any leading whitespace. v_start = colon + 1 v_start += 1 while v_start < line.bytesize && [32, 9].include?(line.getbyte(v_start)) v_end = line.bytesize v_end -= 1 while v_end > v_start && [32, 9].include?(line.getbyte(v_end - 1)) value = line.byteslice(v_start, v_end - v_start) key = HTTP_KEY_CACHE[name] || (c_upcase ? ::Hyperion::CParser.upcase_underscore(name) : "HTTP_#{name.upcase.tr('-', '_')}") env[key] = value end env['CONTENT_TYPE'] = env['HTTP_CONTENT_TYPE'] if env.key?('HTTP_CONTENT_TYPE') env['CONTENT_LENGTH'] = env['HTTP_CONTENT_LENGTH'] if env.key?('HTTP_CONTENT_LENGTH') nil end |
.render_c_loop_response(response, keep_alive) ⇒ Object
2.14-A — render a Rack ‘[status, headers, body]` triple to the wire bytes for the C loop. Honours:
* `keep_alive` — emit `connection: keep-alive` vs
`connection: close`. The C loop honours the
`connection: close` request header by passing
`keep_alive=false`; ditto on Rack apps that opt in via
the response header.
* `content-length` — auto-computed from the body bytes
unless the app set it explicitly. Required for
keep-alive correctness.
* `body.each` — collected into a single binary blob; the
C loop writes head + body in one syscall.
* `body.close` — invoked after iteration per Rack spec.
Streaming bodies (Rack 3 ‘body.call(stream)` shape) are NOT supported in the C-loop dispatch. Apps that need streaming must register via the legacy `Connection#serve` path (don’t use the block form of ‘Server.handle`); the `eligible_route_table?` check refuses to engage the C loop for tables containing those handlers.
359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 |
# File 'lib/hyperion/adapter/rack.rb', line 359 def render_c_loop_response(response, keep_alive) unless response.is_a?(Array) && response.length == 3 response = [500, { 'content-type' => 'text/plain' }, ['Invalid Rack response']] end status, headers, body = response body_bytes = collect_body_bytes(body) headers_out = normalize_response_headers(headers, body_bytes.bytesize, keep_alive) head = build_status_line(status) + headers_out + "\r\n" buf = String.new(capacity: head.bytesize + body_bytes.bytesize, encoding: Encoding::ASCII_8BIT) buf << head.b << body_bytes buf ensure begin body.close if body.respond_to?(:close) rescue StandardError nil 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 |