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

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.

Returns:

  • (Boolean)


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.

Returns:

  • (Boolean)


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.message,
      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.message,
        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