hyperion-rb 1.6.2 → 2.11.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.
Files changed (57) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +4768 -0
  3. data/README.md +222 -13
  4. data/ext/hyperion_h2_codec/Cargo.lock +7 -0
  5. data/ext/hyperion_h2_codec/Cargo.toml +33 -0
  6. data/ext/hyperion_h2_codec/extconf.rb +73 -0
  7. data/ext/hyperion_h2_codec/src/frames.rs +140 -0
  8. data/ext/hyperion_h2_codec/src/hpack/huffman.rs +161 -0
  9. data/ext/hyperion_h2_codec/src/hpack.rs +457 -0
  10. data/ext/hyperion_h2_codec/src/lib.rs +296 -0
  11. data/ext/hyperion_http/extconf.rb +28 -0
  12. data/ext/hyperion_http/h2_codec_glue.c +408 -0
  13. data/ext/hyperion_http/page_cache.c +1125 -0
  14. data/ext/hyperion_http/parser.c +473 -38
  15. data/ext/hyperion_http/sendfile.c +982 -0
  16. data/ext/hyperion_http/websocket.c +493 -0
  17. data/ext/hyperion_io_uring/Cargo.lock +33 -0
  18. data/ext/hyperion_io_uring/Cargo.toml +34 -0
  19. data/ext/hyperion_io_uring/extconf.rb +74 -0
  20. data/ext/hyperion_io_uring/src/lib.rs +316 -0
  21. data/lib/hyperion/adapter/rack.rb +370 -42
  22. data/lib/hyperion/admin_listener.rb +207 -0
  23. data/lib/hyperion/admin_middleware.rb +36 -7
  24. data/lib/hyperion/cli.rb +310 -11
  25. data/lib/hyperion/config.rb +440 -14
  26. data/lib/hyperion/connection.rb +679 -22
  27. data/lib/hyperion/deprecations.rb +81 -0
  28. data/lib/hyperion/dispatch_mode.rb +165 -0
  29. data/lib/hyperion/fiber_local.rb +75 -13
  30. data/lib/hyperion/h2_admission.rb +77 -0
  31. data/lib/hyperion/h2_codec.rb +499 -0
  32. data/lib/hyperion/http/page_cache.rb +122 -0
  33. data/lib/hyperion/http/sendfile.rb +696 -0
  34. data/lib/hyperion/http2/native_hpack_adapter.rb +70 -0
  35. data/lib/hyperion/http2_handler.rb +618 -19
  36. data/lib/hyperion/io_uring.rb +317 -0
  37. data/lib/hyperion/lint_wrapper_pool.rb +126 -0
  38. data/lib/hyperion/master.rb +96 -9
  39. data/lib/hyperion/metrics/path_templater.rb +68 -0
  40. data/lib/hyperion/metrics.rb +256 -0
  41. data/lib/hyperion/prometheus_exporter.rb +150 -0
  42. data/lib/hyperion/request.rb +13 -0
  43. data/lib/hyperion/response_writer.rb +477 -16
  44. data/lib/hyperion/runtime.rb +195 -0
  45. data/lib/hyperion/server/route_table.rb +179 -0
  46. data/lib/hyperion/server.rb +519 -55
  47. data/lib/hyperion/static_preload.rb +133 -0
  48. data/lib/hyperion/thread_pool.rb +61 -7
  49. data/lib/hyperion/tls.rb +343 -1
  50. data/lib/hyperion/version.rb +1 -1
  51. data/lib/hyperion/websocket/close_codes.rb +71 -0
  52. data/lib/hyperion/websocket/connection.rb +876 -0
  53. data/lib/hyperion/websocket/frame.rb +356 -0
  54. data/lib/hyperion/websocket/handshake.rb +525 -0
  55. data/lib/hyperion/worker.rb +111 -9
  56. data/lib/hyperion.rb +137 -3
  57. metadata +50 -1
@@ -3,47 +3,111 @@
3
3
  require 'stringio'
4
4
  require_relative '../version'
5
5
  require_relative '../pool'
6
+ require_relative '../websocket/handshake'
6
7
 
7
8
  module Hyperion
8
9
  module Adapter
9
10
  # NOTE: this is Hyperion::Adapter::Rack, not the Rack gem.
10
11
  # Reference the Rack gem as ::Rack inside this module if needed.
11
12
  module Rack
12
- # Pre-frozen mapping for the 16 most common HTTP request headers.
13
+ # Pre-frozen mapping for the 30 most common HTTP request headers.
13
14
  # Skips the per-request `"HTTP_#{name.upcase.tr('-', '_')}"` allocation
14
15
  # (5–15 string ops per request × N headers). Uncached header names fall
15
16
  # back to the dynamic computation. Keys are lowercased to match the
16
17
  # parser's normalisation.
18
+ #
19
+ # Phase 2c (1.7.1) widened from 16 to 30 entries to cover the full
20
+ # production-traffic top-30 (Sec-Fetch-*, X-Forwarded-Host, X-Real-IP,
21
+ # If-None-Match, etc.). When the C extension is built, the values
22
+ # below are replaced with the *same* frozen VALUEs registered by
23
+ # CParser::PREINTERNED_HEADERS, so the env hash, parser, and adapter
24
+ # all share string identity for these keys (`#equal?` is true). This
25
+ # is what unlocks the spec assertion that `env['HTTP_USER_AGENT']` key
26
+ # is `equal?` to the pre-interned key — and it lets downstream Rack
27
+ # apps that key into env via these same literal strings hit a
28
+ # GVAR-backed pointer compare instead of a hash byte compare.
17
29
  HTTP_KEY_CACHE = {
18
30
  'host' => 'HTTP_HOST',
19
31
  'user-agent' => 'HTTP_USER_AGENT',
20
32
  'accept' => 'HTTP_ACCEPT',
21
33
  'accept-encoding' => 'HTTP_ACCEPT_ENCODING',
22
34
  'accept-language' => 'HTTP_ACCEPT_LANGUAGE',
35
+ 'cache-control' => 'HTTP_CACHE_CONTROL',
23
36
  'connection' => 'HTTP_CONNECTION',
24
- 'content-type' => 'HTTP_CONTENT_TYPE',
25
- 'content-length' => 'HTTP_CONTENT_LENGTH',
26
37
  'cookie' => 'HTTP_COOKIE',
38
+ 'content-length' => 'HTTP_CONTENT_LENGTH',
39
+ 'content-type' => 'HTTP_CONTENT_TYPE',
27
40
  'authorization' => 'HTTP_AUTHORIZATION',
28
- 'cache-control' => 'HTTP_CACHE_CONTROL',
29
41
  'referer' => 'HTTP_REFERER',
30
42
  'origin' => 'HTTP_ORIGIN',
43
+ 'upgrade' => 'HTTP_UPGRADE',
31
44
  'x-forwarded-for' => 'HTTP_X_FORWARDED_FOR',
32
45
  'x-forwarded-proto' => 'HTTP_X_FORWARDED_PROTO',
33
- 'x-real-ip' => 'HTTP_X_REAL_IP'
46
+ 'x-forwarded-host' => 'HTTP_X_FORWARDED_HOST',
47
+ 'x-real-ip' => 'HTTP_X_REAL_IP',
48
+ 'x-request-id' => 'HTTP_X_REQUEST_ID',
49
+ 'if-none-match' => 'HTTP_IF_NONE_MATCH',
50
+ 'if-modified-since' => 'HTTP_IF_MODIFIED_SINCE',
51
+ 'if-match' => 'HTTP_IF_MATCH',
52
+ 'etag' => 'HTTP_ETAG',
53
+ 'range' => 'HTTP_RANGE',
54
+ 'pragma' => 'HTTP_PRAGMA',
55
+ 'dnt' => 'HTTP_DNT',
56
+ 'sec-ch-ua' => 'HTTP_SEC_CH_UA',
57
+ 'sec-fetch-dest' => 'HTTP_SEC_FETCH_DEST',
58
+ 'sec-fetch-mode' => 'HTTP_SEC_FETCH_MODE',
59
+ 'sec-fetch-site' => 'HTTP_SEC_FETCH_SITE'
34
60
  }.freeze
35
61
 
62
+ # If the C extension is loaded, rebind HTTP_KEY_CACHE values to the
63
+ # *same* frozen VALUEs the parser registers in CParser::PREINTERNED_HEADERS.
64
+ # This collapses three otherwise-distinct frozen Strings ("HTTP_HOST" in
65
+ # this Hash, "HTTP_HOST" in the env, "HTTP_HOST" the parser interned) into
66
+ # one shared object — `equal?` becomes pointer-compare for downstream
67
+ # consumers. The mutation runs once at load time, before the constant is
68
+ # observed externally; from that point on the Hash itself is frozen.
69
+ if defined?(::Hyperion::CParser) &&
70
+ ::Hyperion::CParser.const_defined?(:PREINTERNED_HEADERS)
71
+ pairs = ::Hyperion::CParser::PREINTERNED_HEADERS
72
+ # Walk the parallel [lc, http_key, lc, http_key, ...] flat array.
73
+ unfrozen = HTTP_KEY_CACHE.dup
74
+ i = 0
75
+ while i < pairs.length
76
+ lc = pairs[i]
77
+ http_key = pairs[i + 1]
78
+ unfrozen[lc] = http_key if unfrozen.key?(lc)
79
+ i += 2
80
+ end
81
+ remove_const(:HTTP_KEY_CACHE)
82
+ HTTP_KEY_CACHE = unfrozen.freeze
83
+ end
84
+
36
85
  ENV_POOL = Hyperion::Pool.new(
37
86
  max_size: 256,
38
87
  factory: -> { {} },
39
88
  reset: ->(env) { env.clear }
40
89
  )
41
90
 
91
+ # Phase 11 — shared frozen empty buffer for StringIO reset. Pre-Phase-11
92
+ # the reset lambda allocated a fresh `+''` per request (one String per
93
+ # acquire). The next call to `build_env` immediately swaps in
94
+ # `input.string = request.body`, so we never mutate this buffer — a
95
+ # single frozen empty String is sufficient as a "clean slate" sentinel.
96
+ EMPTY_INPUT_BUFFER = String.new('', encoding: Encoding::ASCII_8BIT).freeze
97
+
98
+ # Phase 11 — frozen literal constants for env values that pre-Phase-11
99
+ # were rebuilt per request:
100
+ # * SERVER_SOFTWARE — `"Hyperion/#{VERSION}"` interpolated each call.
101
+ # * RACK_VERSION — `[3, 0]` Array literal allocated each call.
102
+ # The Array is frozen so Rack apps can't mutate the shared instance.
103
+ SERVER_SOFTWARE_VALUE = "Hyperion/#{Hyperion::VERSION}".freeze
104
+ RACK_VERSION = [3, 0].freeze
105
+
42
106
  INPUT_POOL = Hyperion::Pool.new(
43
107
  max_size: 256,
44
108
  factory: -> { StringIO.new },
45
109
  reset: lambda { |io|
46
- io.string = +''
110
+ io.string = EMPTY_INPUT_BUFFER
47
111
  io.rewind
48
112
  }
49
113
  )
@@ -59,6 +123,19 @@ module Hyperion
59
123
  ::Hyperion::CParser.respond_to?(:upcase_underscore)
60
124
  end
61
125
 
126
+ # Phase 3a (1.7.1) — whether the full env-build loop has moved into C.
127
+ # When true, build_env hands the populated env Hash + Request to the
128
+ # C ext, which sets REQUEST_METHOD / PATH_INFO / QUERY_STRING /
129
+ # HTTP_VERSION / SERVER_PROTOCOL / CONTENT_TYPE / CONTENT_LENGTH +
130
+ # every HTTP_<UPCASED> header in one trip across the FFI boundary.
131
+ # The Ruby fallback below stays exercised by spec for parity coverage.
132
+ def self.c_build_env_available?
133
+ return @c_build_env_available unless @c_build_env_available.nil?
134
+
135
+ @c_build_env_available = defined?(::Hyperion::CParser) &&
136
+ ::Hyperion::CParser.respond_to?(:build_env)
137
+ end
138
+
62
139
  class << self
63
140
  # Pre-allocate `n` env-hash and rack-input objects in master before
64
141
  # fork. Children inherit the populated free-list via copy-on-write —
@@ -74,10 +151,90 @@ module Hyperion
74
151
  nil
75
152
  end
76
153
 
77
- def call(app, request)
78
- env, input = build_env(request)
79
- status, headers, body = app.call(env)
80
- [status, headers, body]
154
+ # 2.1.0 (WS-1): `connection:` is the Hyperion::Connection that owns
155
+ # the underlying socket for this request. When non-nil, the env hash
156
+ # advertises Rack 3 full-hijack support — the app can call
157
+ # `env['rack.hijack'].call` to take ownership of the raw socket and
158
+ # speak any post-HTTP protocol (WebSocket, raw TCP tunnel, etc.).
159
+ # When nil (HTTP/2 path, ad-hoc adapter callers in specs), hijack
160
+ # stays disabled — `env['rack.hijack?']` returns false and the env
161
+ # has no `rack.hijack` key, matching pre-2.1 behaviour.
162
+ #
163
+ # 2.5-C: `runtime:` is the Hyperion::Runtime that owns this
164
+ # request's metrics + logger + lifecycle hooks. When nil (the
165
+ # default — every existing in-tree call site stays untouched),
166
+ # the adapter resolves to `Hyperion::Runtime.default`, which is
167
+ # the same singleton legacy `Hyperion.metrics` / `Hyperion.logger`
168
+ # delegate to. Apps with multiple servers (multi-tenant) pass an
169
+ # explicit Runtime so each server's NewRelic / AppSignal /
170
+ # OpenTelemetry hooks remain isolated.
171
+ def call(app, request, connection: nil, runtime: nil)
172
+ env, input = build_env(request, connection: connection)
173
+
174
+ # 2.1.0 (WS-2) — RFC 6455 §4.2 handshake interception. Runs
175
+ # AFTER env is built (so the WS module sees the same env keys
176
+ # the app would see) but BEFORE app.call. Branches:
177
+ # * :not_websocket — request is plain HTTP; no-op
178
+ # * :ok — valid WS handshake; stash the
179
+ # [:ok, accept, subprotocol] tuple in env so the app can
180
+ # read accept_value without re-running SHA1/base64. The
181
+ # app is still responsible for writing the 101 response
182
+ # to the hijacked socket (Option B from the WS-2 plan,
183
+ # mirrors faye-websocket / ActionCable convention).
184
+ # * :bad_request / :upgrade_required — short-circuit; the
185
+ # app never sees the env. Hyperion writes the 4xx itself.
186
+ ws_result = Hyperion::WebSocket::Handshake.validate(env)
187
+ case ws_result.first
188
+ when :ok
189
+ env['hyperion.websocket.handshake'] = ws_result
190
+ when :bad_request, :upgrade_required
191
+ return websocket_handshake_failure_response(ws_result)
192
+ end
193
+
194
+ # 2.5-C — per-request lifecycle hooks. The `has_request_hooks?`
195
+ # guard collapses to two empty-Array checks when no observers
196
+ # are registered (the default for every Hyperion install that
197
+ # hasn't opted in), so the hot path stays allocation-free
198
+ # — verified by `yjit_alloc_audit_spec`. Resolving `runtime`
199
+ # is itself zero-allocation: `Runtime.default` returns a
200
+ # memoised singleton.
201
+ #
202
+ # 2.6-D — when the response auto-detects into `:inline_blocking`
203
+ # (static-file body responding to `:to_path`, no streaming
204
+ # marker) we SKIP the after-request lifecycle hook. Static
205
+ # asset traffic is high-volume + low-value for trace
206
+ # instrumentation: a NewRelic / OpenTelemetry hook firing on
207
+ # every 200-byte favicon or 1 MiB asset response wastes CPU
208
+ # finishing spans nobody queries. Operators wanting to
209
+ # observe static traffic should use the metrics module
210
+ # (per-route histogram + sendfile_responses counter), which
211
+ # is allocation-free on the hot path. The before-request
212
+ # hook still fires — its semantic ("the request is about
213
+ # to be processed") is preserved across all dispatch modes;
214
+ # it's the after-hook (typically heavy: span flush, DB
215
+ # write, async-queue enqueue) that benefits from the skip.
216
+ rt = runtime || Hyperion::Runtime.default
217
+ if rt.has_request_hooks?
218
+ rt.fire_request_start(request, env)
219
+ begin
220
+ response = app.call(env)
221
+ rescue StandardError => e
222
+ rt.fire_request_end(request, env, nil, e)
223
+ raise
224
+ end
225
+ resolve_dispatch_mode!(env, response, connection)
226
+ rt.fire_request_end(request, env, response, nil) unless inline_blocking_resolved?(connection)
227
+ return response
228
+ end
229
+
230
+ # Phase 11 — return the app's tuple directly. Pre-Phase-11
231
+ # destructured it into 3 locals and re-built the [status, headers,
232
+ # body] Array (one Array allocation per request). Apps already
233
+ # return a [status, headers, body] triple per Rack spec, so the
234
+ # rebuild is pure overhead.
235
+ response = app.call(env)
236
+ resolve_dispatch_mode!(env, response, connection)
237
+ response
81
238
  rescue StandardError => e
82
239
  Hyperion.metrics.increment(:app_errors)
83
240
  Hyperion.logger.error do
@@ -95,13 +252,112 @@ module Hyperion
95
252
  # is iterated lazily — release happens after the writer.
96
253
  # For Phase 5 simplicity we release synchronously since the writer
97
254
  # buffers fully. Phase 7 (HTTP/2 streaming) will revisit.
98
- ENV_POOL.release(env) if env
99
- INPUT_POOL.release(input) if input
255
+ #
256
+ # 2.1.0 hijack: when the app full-hijacked the socket, the env
257
+ # references (notably the rack.hijack proc + buffered carry) are
258
+ # *the* live reference to the connection. Returning the env to the
259
+ # pool here would let a subsequent request reuse the same hash and
260
+ # silently null out the hijacker's state. Skip the pool release on
261
+ # hijacked connections and let the hash be GC'd normally.
262
+ if env && connection && connection.respond_to?(:hijacked?) && connection.hijacked?
263
+ # Drop the input back into the pool (it's a fresh StringIO and
264
+ # the hijacker doesn't reference it). Skip env recycling.
265
+ INPUT_POOL.release(input) if input
266
+ else
267
+ ENV_POOL.release(env) if env
268
+ INPUT_POOL.release(input) if input
269
+ end
100
270
  end
101
271
 
102
272
  private
103
273
 
104
- def build_env(request)
274
+ # 2.1.0 (WS-2) — translate a Handshake.validate failure tuple
275
+ # into a Rack response triple. 400 for protocol errors,
276
+ # 426 for unsupported Sec-WebSocket-Version (RFC 6455 §4.4)
277
+ # — the 426 path always carries `sec-websocket-version: 13`
278
+ # so the client sees the version Hyperion supports.
279
+ def websocket_handshake_failure_response(ws_result)
280
+ tag, body, extra_headers = ws_result
281
+ status = tag == :upgrade_required ? 426 : 400
282
+ response_headers = { 'content-type' => 'text/plain' }
283
+ extra_headers.each { |k, v| response_headers[k.to_s] = v.to_s }
284
+ [status, response_headers, [body.to_s]]
285
+ end
286
+
287
+ # 2.6-C — resolve the per-response dispatch-mode override and
288
+ # stash it on the connection so `Connection#serve` can forward
289
+ # it to `ResponseWriter#write`. Two opt-in mechanisms, in
290
+ # priority order:
291
+ #
292
+ # 1. Explicit: the Rack app set
293
+ # `env['hyperion.dispatch_mode'] = :inline_blocking` (or
294
+ # another future symbol). Operator-level escape hatch
295
+ # for routes the auto-detect doesn't catch (e.g. a custom
296
+ # lazy-streaming body that responds to `:to_path` for
297
+ # Range-request reasons but is logically a streaming
298
+ # response). We honour any explicit non-nil value
299
+ # verbatim; the writer's branch is keyed on the symbol
300
+ # so unknown symbols fall through to the default fiber-
301
+ # yielding path.
302
+ #
303
+ # 2. Auto-detect: response body responds to `:to_path` AND
304
+ # `env['hyperion.streaming']` is not set. `to_path` is
305
+ # Rack's strongest "this is a static file on disk"
306
+ # signal — Rack::Files, Rack::SendFile, asset servers,
307
+ # and signed-download responders all set it; streaming
308
+ # bodies (SSE, JSON streams, chunked Enumerators) do not.
309
+ # The `hyperion.streaming` env key is the operator's
310
+ # escape valve to opt OUT of the auto-detect (e.g. a
311
+ # custom Range-request body that responds to `:to_path`
312
+ # but should still take the fiber-yielding path because
313
+ # the body itself does I/O wait between chunks).
314
+ #
315
+ # Conservative-by-design: if `connection` is nil (no socket-
316
+ # owning Connection in scope — h2 streams, ad-hoc adapter
317
+ # callers in specs), we skip the override entirely. The
318
+ # h2 path has its own per-stream fiber dispatch and doesn't
319
+ # benefit from `:inline_blocking`.
320
+ #
321
+ # Skips a bad response shape (anything that's not a 3-element
322
+ # Array) gracefully — the caller's main rescue clause owns
323
+ # malformed-response handling.
324
+ def resolve_dispatch_mode!(env, response, connection)
325
+ return unless connection
326
+ return unless connection.respond_to?(:response_dispatch_mode=)
327
+ return unless response.is_a?(Array) && response.length == 3
328
+
329
+ # 1. Explicit opt-in via env wins.
330
+ explicit = env && env['hyperion.dispatch_mode']
331
+ if explicit
332
+ connection.response_dispatch_mode = explicit
333
+ return
334
+ end
335
+
336
+ # 2. Auto-detect on `to_path` static-file responses. Skip
337
+ # when the app set `hyperion.streaming` — that's the
338
+ # operator's "this body responds to to_path but is
339
+ # logically a streaming response" escape valve.
340
+ return if env && env['hyperion.streaming']
341
+
342
+ body = response[2]
343
+ return unless body.respond_to?(:to_path)
344
+
345
+ connection.response_dispatch_mode = :inline_blocking
346
+ end
347
+
348
+ # 2.6-D — read-back of the resolved dispatch mode. Used by the
349
+ # lifecycle-hook branch in `#call` to decide whether to fire
350
+ # the after-request hook. Returns false when `connection` is
351
+ # nil (h2 streams, ad-hoc adapter callers in specs) so those
352
+ # paths keep their hook firing behaviour unchanged.
353
+ def inline_blocking_resolved?(connection)
354
+ return false unless connection
355
+ return false unless connection.respond_to?(:response_dispatch_mode)
356
+
357
+ connection.response_dispatch_mode == :inline_blocking
358
+ end
359
+
360
+ def build_env(request, connection: nil)
105
361
  host_header = request.header('host') || ''
106
362
  server_name, server_port = split_host(host_header)
107
363
 
@@ -110,14 +366,13 @@ module Hyperion
110
366
  input.string = request.body
111
367
  input.rewind
112
368
 
113
- env['REQUEST_METHOD'] = request.method
114
- env['PATH_INFO'] = request.path
115
- env['QUERY_STRING'] = request.query_string
369
+ # Adapter-owned (non-header, non-request-line) env. SERVER_NAME/PORT
370
+ # need split_host, REMOTE_ADDR needs peer info, the rack.* keys are
371
+ # constants — none of these benefit from the FFI hop, so they stay
372
+ # in Ruby regardless of c_build_env_available?.
116
373
  env['SERVER_NAME'] = server_name
117
374
  env['SERVER_PORT'] = server_port
118
- env['SERVER_PROTOCOL'] = request.http_version
119
- env['HTTP_VERSION'] = request.http_version
120
- env['SERVER_SOFTWARE'] = "Hyperion/#{Hyperion::VERSION}"
375
+ env['SERVER_SOFTWARE'] = SERVER_SOFTWARE_VALUE
121
376
  # Rack apps (Rack::Attack throttles, IpHelper.real_ip, audit logging)
122
377
  # require REMOTE_ADDR. Fall back to localhost when no peer info is
123
378
  # available — typically when a Request is constructed in specs
@@ -126,32 +381,88 @@ module Hyperion
126
381
  env['rack.url_scheme'] = 'http'
127
382
  env['rack.input'] = input
128
383
  env['rack.errors'] = $stderr
129
- env['rack.hijack?'] = false
130
- env['rack.version'] = [3, 0]
384
+ if connection
385
+ # 2.1.0 (WS-1) — Rack 3 full-hijack. The proc captures the
386
+ # connection (NOT the socket directly) so the connection can
387
+ # flip its @hijacked flag synchronously inside hijack!; that
388
+ # way the writer / cleanup paths see the flag the moment the
389
+ # app takes over the wire. The proc returns the raw socket
390
+ # IO, per Rack 3 spec.
391
+ env['rack.hijack?'] = true
392
+ env['rack.hijack'] = lambda do
393
+ connection.hijack!
394
+ end
395
+ # Hyperion-specific extension: any bytes the connection had
396
+ # buffered past the parsed request (pipelined/keep-alive
397
+ # carry, or — for an Upgrade — bytes the client sent
398
+ # immediately after the headers). Empty string when none.
399
+ # The app reads these BEFORE reading from the hijacked
400
+ # socket. Documented in CHANGELOG; not a Rack 3 spec key.
401
+ env['hyperion.hijack_buffered'] =
402
+ connection.respond_to?(:hijack_buffered) ? connection.hijack_buffered : +''
403
+ else
404
+ env['rack.hijack?'] = false
405
+ end
406
+ env['rack.version'] = RACK_VERSION
131
407
  env['rack.multithread'] = false
132
408
  env['rack.multiprocess'] = false
133
409
  env['rack.run_once'] = false
134
410
  env['SCRIPT_NAME'] = ''
135
411
 
136
- # Header-name → Rack env-key conversion. Cache covers the 16 most
137
- # common names; uncached headers (X-* customs, vendor-specific) flow
138
- # through CParser.upcase_underscore (single C-level allocation) when
139
- # the extension is built, else the pure-Ruby triple-allocation path.
140
- c_upcase = Rack.c_upcase_available?
141
- request.headers.each do |name, value|
142
- key = HTTP_KEY_CACHE[name] ||
143
- (c_upcase ? ::Hyperion::CParser.upcase_underscore(name) : "HTTP_#{name.upcase.tr('-', '_')}")
144
- env[key] = value
145
- end
412
+ if Rack.c_build_env_available?
413
+ # Phase 3a (1.7.1) single FFI call sets REQUEST_METHOD,
414
+ # PATH_INFO, QUERY_STRING, HTTP_VERSION, SERVER_PROTOCOL,
415
+ # CONTENT_TYPE, CONTENT_LENGTH, and every HTTP_* header.
416
+ ::Hyperion::CParser.build_env(env, request)
417
+ else
418
+ env['REQUEST_METHOD'] = request.method
419
+ env['PATH_INFO'] = request.path
420
+ env['QUERY_STRING'] = request.query_string
421
+ env['SERVER_PROTOCOL'] = request.http_version
422
+ env['HTTP_VERSION'] = request.http_version
146
423
 
147
- env['CONTENT_TYPE'] = env['HTTP_CONTENT_TYPE'] if env.key?('HTTP_CONTENT_TYPE')
148
- env['CONTENT_LENGTH'] = env['HTTP_CONTENT_LENGTH'] if env.key?('HTTP_CONTENT_LENGTH')
424
+ # Header-name → Rack env-key conversion. Cache covers the
425
+ # 30 most common names; uncached headers (X-* customs,
426
+ # vendor-specific) flow through CParser.upcase_underscore
427
+ # (single C-level allocation) when the ext is built, else
428
+ # the pure-Ruby triple-allocation path.
429
+ c_upcase = Rack.c_upcase_available?
430
+ request.headers.each do |name, value|
431
+ key = HTTP_KEY_CACHE[name] ||
432
+ (c_upcase ? ::Hyperion::CParser.upcase_underscore(name) : "HTTP_#{name.upcase.tr('-', '_')}")
433
+ env[key] = value
434
+ end
149
435
 
150
- [env, input]
436
+ env['CONTENT_TYPE'] = env['HTTP_CONTENT_TYPE'] if env.key?('HTTP_CONTENT_TYPE')
437
+ env['CONTENT_LENGTH'] = env['HTTP_CONTENT_LENGTH'] if env.key?('HTTP_CONTENT_LENGTH')
438
+ end
439
+
440
+ # Phase 11 — reuse a per-thread 2-element scratch Array for the
441
+ # `env, input = build_env(...)` destructuring return. Pre-Phase-11
442
+ # allocated a fresh `[env, input]` Array per request; the caller
443
+ # destructures immediately and never holds onto the Array, so a
444
+ # mutable per-thread tuple is safe (each request runs to the
445
+ # destructure on the same thread before any nested build_env call
446
+ # could observe it).
447
+ tup = (Thread.current[:__hyperion_build_env_tuple__] ||= [nil, nil])
448
+ tup[0] = env
449
+ tup[1] = input
450
+ tup
151
451
  end
152
452
 
453
+ # Phase 11 — frozen "no port specified" sentinels so the
454
+ # overwhelmingly common host_header == "host" / "host:80" /
455
+ # "host:443" branches don't allocate a fresh Array on every
456
+ # request. The caller destructures into `server_name, server_port =
457
+ # split_host(...)` immediately and never holds the returned Array,
458
+ # so a per-thread mutable scratch Array is safe (and the frozen
459
+ # `LOCALHOST_DEFAULTS` sentinel covers the empty-host cases without
460
+ # any allocation at all).
461
+ LOCALHOST_DEFAULTS = %w[localhost 80].freeze
462
+ DEFAULT_PORT_80 = '80'
463
+
153
464
  def split_host(host_header)
154
- return %w[localhost 80] if host_header.empty?
465
+ return LOCALHOST_DEFAULTS if host_header.empty?
155
466
 
156
467
  if host_header.start_with?('[')
157
468
  close = host_header.index(']')
@@ -164,20 +475,37 @@ module Hyperion
164
475
  # on header-parse failures, so we degrade gracefully instead.
165
476
  unless close
166
477
  Hyperion.metrics.increment(:malformed_host_header)
167
- return %w[localhost 80]
478
+ return LOCALHOST_DEFAULTS
168
479
  end
169
480
 
170
481
  name = host_header[0..close]
171
482
  rest = host_header[(close + 1)..]
172
- port = rest&.start_with?(':') ? rest[1..] : '80'
173
- [name, port.to_s.empty? ? '80' : port]
174
- elsif host_header.include?(':')
175
- name, port = host_header.split(':', 2)
176
- [name, port]
483
+ port = rest&.start_with?(':') ? rest[1..] : DEFAULT_PORT_80
484
+ split_host_tuple(name, port.to_s.empty? ? DEFAULT_PORT_80 : port)
485
+ elsif (idx = host_header.index(':'))
486
+ # Phase 11 — replace `split(':', 2)` (allocates 2 Strings + 1
487
+ # transient Array that's then discarded for a fresh `[name,
488
+ # port]` literal). Hand-rolled byteslice keeps the 2 substring
489
+ # allocations (unavoidable — the env hash retains them) but
490
+ # routes the surrounding container through the per-thread
491
+ # scratch tuple, dropping 1 Array allocation per request.
492
+ split_host_tuple(host_header.byteslice(0, idx),
493
+ host_header.byteslice(idx + 1, host_header.bytesize - idx - 1))
177
494
  else
178
- [host_header, '80']
495
+ split_host_tuple(host_header, DEFAULT_PORT_80)
179
496
  end
180
497
  end
498
+
499
+ # Per-thread 2-element scratch Array for split_host's return tuple.
500
+ # See note on `__hyperion_build_env_tuple__` in build_env — the
501
+ # caller destructures immediately; no nested split_host call can
502
+ # observe the same thread's tuple before the destructure completes.
503
+ def split_host_tuple(name, port)
504
+ tup = (Thread.current[:__hyperion_split_host_tuple__] ||= [nil, nil])
505
+ tup[0] = name
506
+ tup[1] = port
507
+ tup
508
+ end
181
509
  end
182
510
  end
183
511
  end