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

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.

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_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.



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

.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