hyperion-rb 2.12.0 → 2.14.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +1117 -0
- data/README.md +301 -674
- data/ext/hyperion_http/page_cache.c +538 -43
- data/ext/hyperion_http/parser.c +382 -51
- data/lib/hyperion/adapter/rack.rb +303 -4
- data/lib/hyperion/connection.rb +65 -4
- data/lib/hyperion/http2_handler.rb +348 -21
- data/lib/hyperion/metrics.rb +174 -38
- data/lib/hyperion/server/connection_loop.rb +104 -6
- data/lib/hyperion/server/route_table.rb +64 -0
- data/lib/hyperion/server.rb +202 -2
- data/lib/hyperion/version.rb +1 -1
- metadata +1 -1
|
@@ -151,6 +151,291 @@ module Hyperion
|
|
|
151
151
|
nil
|
|
152
152
|
end
|
|
153
153
|
|
|
154
|
+
# 2.14-A — C-accept-loop dispatch helper.
|
|
155
|
+
#
|
|
156
|
+
# The C accept loop (`PageCache.run_static_accept_loop`) calls
|
|
157
|
+
# this helper, under the GVL, when a request matches a
|
|
158
|
+
# `RouteTable::DynamicBlockEntry`. The C side has already done
|
|
159
|
+
# accept + recv + parse without holding the GVL; this helper
|
|
160
|
+
# owns the `app.call(env)` slice and returns the fully-formed
|
|
161
|
+
# HTTP/1.1 response bytes for C to write (also without the GVL).
|
|
162
|
+
#
|
|
163
|
+
# Args (all positional, all Strings except `block` and
|
|
164
|
+
# `keep_alive` and `runtime`):
|
|
165
|
+
# * `method_str` — e.g. "GET"
|
|
166
|
+
# * `path_str` — request path, no query
|
|
167
|
+
# * `query_str` — query (no leading '?'), or "" if none
|
|
168
|
+
# * `host_str` — `Host:` header value, or ""
|
|
169
|
+
# * `headers_blob` — raw header section as bytes
|
|
170
|
+
# (the slice between request-line CRLF and the closing
|
|
171
|
+
# CRLFCRLF, terminated by a CRLF on the last header). The
|
|
172
|
+
# helper parses this in Ruby — header parse is a few µs
|
|
173
|
+
# even for the 30-header case, dwarfed by `app.call`.
|
|
174
|
+
# * `remote_addr` — peer IP as a String, or "" if unknown
|
|
175
|
+
# * `block` — the registered Proc / lambda
|
|
176
|
+
# * `keep_alive` — true to emit `connection: keep-alive`
|
|
177
|
+
# in the response head, false for `connection: close`
|
|
178
|
+
# * `runtime` — the `Hyperion::Runtime` instance the
|
|
179
|
+
# server was constructed with (for lifecycle hooks); the
|
|
180
|
+
# C loop captures this once at boot via the registered
|
|
181
|
+
# callback closure
|
|
182
|
+
#
|
|
183
|
+
# Returns a single binary String of HTTP/1.1 response bytes
|
|
184
|
+
# (status line + response headers + CRLF + body). The C loop
|
|
185
|
+
# writes this verbatim. On exception, returns a 500 envelope
|
|
186
|
+
# so the C loop can still respond to the peer (better UX than
|
|
187
|
+
# closing the fd silently).
|
|
188
|
+
def dispatch_for_c_loop(method_str, path_str, query_str,
|
|
189
|
+
host_str, headers_blob, remote_addr,
|
|
190
|
+
block, keep_alive, runtime)
|
|
191
|
+
env, input = build_c_loop_env(method_str, path_str, query_str,
|
|
192
|
+
host_str, headers_blob, remote_addr)
|
|
193
|
+
request = nil
|
|
194
|
+
response = nil
|
|
195
|
+
error = nil
|
|
196
|
+
|
|
197
|
+
rt = runtime || Hyperion::Runtime.default
|
|
198
|
+
if rt.has_request_hooks?
|
|
199
|
+
request = c_loop_request_for(env)
|
|
200
|
+
rt.fire_request_start(request, env)
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
begin
|
|
204
|
+
response = block.call(env)
|
|
205
|
+
rescue StandardError => e
|
|
206
|
+
error = e
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
if rt.has_request_hooks?
|
|
210
|
+
request ||= c_loop_request_for(env)
|
|
211
|
+
rt.fire_request_end(request, env, response, error)
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
if error
|
|
215
|
+
::Hyperion.metrics.increment(:app_errors)
|
|
216
|
+
::Hyperion.logger.error do
|
|
217
|
+
{
|
|
218
|
+
message: 'app raised (c-accept-loop dispatch)',
|
|
219
|
+
error: error.message,
|
|
220
|
+
error_class: error.class.name,
|
|
221
|
+
backtrace: (error.backtrace || []).first(20).join(' | ')
|
|
222
|
+
}
|
|
223
|
+
end
|
|
224
|
+
response = [500, { 'content-type' => 'text/plain' }, ['Internal Server Error']]
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
render_c_loop_response(response, keep_alive)
|
|
228
|
+
ensure
|
|
229
|
+
ENV_POOL.release(env) if env
|
|
230
|
+
INPUT_POOL.release(input) if input
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# 2.14-A — assemble the Rack env for a C-accept-loop dispatch.
|
|
234
|
+
# Mirrors the constants `build_env` sets on the regular path
|
|
235
|
+
# but skips the `connection`/hijack branches: the C accept
|
|
236
|
+
# loop owns the fd; full-hijack semantics are out of scope
|
|
237
|
+
# for this dispatch shape (h1 keep-alive is handled in C).
|
|
238
|
+
# Returns `[env, input]` so the caller can release both back
|
|
239
|
+
# to their pools after the response is rendered.
|
|
240
|
+
def build_c_loop_env(method_str, path_str, query_str,
|
|
241
|
+
host_str, headers_blob, remote_addr)
|
|
242
|
+
server_name, server_port = split_host(host_str || '')
|
|
243
|
+
|
|
244
|
+
env = ENV_POOL.acquire
|
|
245
|
+
input = INPUT_POOL.acquire
|
|
246
|
+
input.string = EMPTY_INPUT_BUFFER
|
|
247
|
+
input.rewind
|
|
248
|
+
|
|
249
|
+
env['REQUEST_METHOD'] = method_str
|
|
250
|
+
env['PATH_INFO'] = path_str
|
|
251
|
+
env['QUERY_STRING'] = query_str || ''
|
|
252
|
+
env['SERVER_PROTOCOL'] = 'HTTP/1.1'
|
|
253
|
+
env['HTTP_VERSION'] = 'HTTP/1.1'
|
|
254
|
+
env['SERVER_NAME'] = server_name
|
|
255
|
+
env['SERVER_PORT'] = server_port
|
|
256
|
+
env['SERVER_SOFTWARE'] = SERVER_SOFTWARE_VALUE
|
|
257
|
+
env['REMOTE_ADDR'] = remote_addr.nil? || remote_addr.empty? ? '127.0.0.1' : remote_addr
|
|
258
|
+
env['rack.url_scheme'] = 'http'
|
|
259
|
+
env['rack.errors'] = $stderr
|
|
260
|
+
env['rack.version'] = RACK_VERSION
|
|
261
|
+
env['rack.multithread'] = true
|
|
262
|
+
env['rack.multiprocess'] = false
|
|
263
|
+
env['rack.run_once'] = false
|
|
264
|
+
env['rack.hijack?'] = false
|
|
265
|
+
env['SCRIPT_NAME'] = ''
|
|
266
|
+
env['rack.input'] = input
|
|
267
|
+
# 2.14-A — guarded `is_a?(String) && !empty?` (rather than
|
|
268
|
+
# `present?`) so rubocop-rails's Style/Present autocorrect
|
|
269
|
+
# can't rewrite the branch to a Rails-only API. Same pattern
|
|
270
|
+
# `Server#dispatch_handed_off` uses for `partial`.
|
|
271
|
+
env['HTTP_HOST'] = host_str if host_str.is_a?(String) && !host_str.empty?
|
|
272
|
+
|
|
273
|
+
parse_c_loop_headers!(env, headers_blob) if headers_blob.is_a?(String) && !headers_blob.empty?
|
|
274
|
+
|
|
275
|
+
[env, input]
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
# 2.14-A — parse the raw header block the C accept loop hands
|
|
279
|
+
# us into the env hash. Each line is `name: value\r\n`; the
|
|
280
|
+
# final empty line is already trimmed by the caller (the C
|
|
281
|
+
# loop slices between request-line-end and the closing
|
|
282
|
+
# CRLFCRLF and passes the inner bytes verbatim).
|
|
283
|
+
#
|
|
284
|
+
# We honour the same HTTP_KEY_CACHE the regular adapter path
|
|
285
|
+
# uses, so `equal?` pointer-compares from upstream Rack code
|
|
286
|
+
# (Rack::Attack et al.) keep working.
|
|
287
|
+
def parse_c_loop_headers!(env, headers_blob)
|
|
288
|
+
return if headers_blob.empty?
|
|
289
|
+
|
|
290
|
+
c_upcase = c_upcase_available?
|
|
291
|
+
# The Ruby parser walks line-by-line; allocations are 1
|
|
292
|
+
# String per header (the value). Header names go through
|
|
293
|
+
# the cache hit (no alloc) or the C-ext upcase_underscore
|
|
294
|
+
# (single-call alloc).
|
|
295
|
+
start = 0
|
|
296
|
+
blen = headers_blob.bytesize
|
|
297
|
+
while start < blen
|
|
298
|
+
eol = headers_blob.index("\r\n", start) || blen
|
|
299
|
+
line = headers_blob.byteslice(start, eol - start)
|
|
300
|
+
start = eol + 2
|
|
301
|
+
next if line.empty?
|
|
302
|
+
|
|
303
|
+
colon = line.index(':')
|
|
304
|
+
next unless colon
|
|
305
|
+
|
|
306
|
+
name = line.byteslice(0, colon).downcase
|
|
307
|
+
# Skip the colon, then any leading whitespace.
|
|
308
|
+
v_start = colon + 1
|
|
309
|
+
v_start += 1 while v_start < line.bytesize && [32, 9].include?(line.getbyte(v_start))
|
|
310
|
+
v_end = line.bytesize
|
|
311
|
+
v_end -= 1 while v_end > v_start && [32, 9].include?(line.getbyte(v_end - 1))
|
|
312
|
+
value = line.byteslice(v_start, v_end - v_start)
|
|
313
|
+
|
|
314
|
+
key = HTTP_KEY_CACHE[name] ||
|
|
315
|
+
(c_upcase ? ::Hyperion::CParser.upcase_underscore(name) : "HTTP_#{name.upcase.tr('-', '_')}")
|
|
316
|
+
env[key] = value
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
env['CONTENT_TYPE'] = env['HTTP_CONTENT_TYPE'] if env.key?('HTTP_CONTENT_TYPE')
|
|
320
|
+
env['CONTENT_LENGTH'] = env['HTTP_CONTENT_LENGTH'] if env.key?('HTTP_CONTENT_LENGTH')
|
|
321
|
+
nil
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
# 2.14-A — minimal `Hyperion::Request` value for lifecycle
|
|
325
|
+
# hook observers. Only built when hooks are active (the
|
|
326
|
+
# `has_request_hooks?` guard skips the alloc on the no-hook
|
|
327
|
+
# hot path).
|
|
328
|
+
def c_loop_request_for(env)
|
|
329
|
+
::Hyperion::Request.new(
|
|
330
|
+
method: env['REQUEST_METHOD'],
|
|
331
|
+
path: env['PATH_INFO'],
|
|
332
|
+
query_string: env['QUERY_STRING'],
|
|
333
|
+
http_version: 'HTTP/1.1',
|
|
334
|
+
headers: {},
|
|
335
|
+
body: nil
|
|
336
|
+
)
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
# 2.14-A — render a Rack `[status, headers, body]` triple to
|
|
340
|
+
# the wire bytes for the C loop. Honours:
|
|
341
|
+
# * `keep_alive` — emit `connection: keep-alive` vs
|
|
342
|
+
# `connection: close`. The C loop honours the
|
|
343
|
+
# `connection: close` request header by passing
|
|
344
|
+
# `keep_alive=false`; ditto on Rack apps that opt in via
|
|
345
|
+
# the response header.
|
|
346
|
+
# * `content-length` — auto-computed from the body bytes
|
|
347
|
+
# unless the app set it explicitly. Required for
|
|
348
|
+
# keep-alive correctness.
|
|
349
|
+
# * `body.each` — collected into a single binary blob; the
|
|
350
|
+
# C loop writes head + body in one syscall.
|
|
351
|
+
# * `body.close` — invoked after iteration per Rack spec.
|
|
352
|
+
#
|
|
353
|
+
# Streaming bodies (Rack 3 `body.call(stream)` shape) are NOT
|
|
354
|
+
# supported in the C-loop dispatch. Apps that need streaming
|
|
355
|
+
# must register via the legacy `Connection#serve` path
|
|
356
|
+
# (don't use the block form of `Server.handle`); the
|
|
357
|
+
# `eligible_route_table?` check refuses to engage the C loop
|
|
358
|
+
# for tables containing those handlers.
|
|
359
|
+
def render_c_loop_response(response, keep_alive)
|
|
360
|
+
unless response.is_a?(Array) && response.length == 3
|
|
361
|
+
response = [500, { 'content-type' => 'text/plain' }, ['Invalid Rack response']]
|
|
362
|
+
end
|
|
363
|
+
status, headers, body = response
|
|
364
|
+
|
|
365
|
+
body_bytes = collect_body_bytes(body)
|
|
366
|
+
headers_out = normalize_response_headers(headers, body_bytes.bytesize, keep_alive)
|
|
367
|
+
head = build_status_line(status) + headers_out + "\r\n"
|
|
368
|
+
|
|
369
|
+
buf = String.new(capacity: head.bytesize + body_bytes.bytesize, encoding: Encoding::ASCII_8BIT)
|
|
370
|
+
buf << head.b << body_bytes
|
|
371
|
+
buf
|
|
372
|
+
ensure
|
|
373
|
+
begin
|
|
374
|
+
body.close if body.respond_to?(:close)
|
|
375
|
+
rescue StandardError
|
|
376
|
+
nil
|
|
377
|
+
end
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
# 2.14-A — drain a Rack body into a single binary blob.
|
|
381
|
+
# Honours both Array bodies (the common case — `[body_str]`)
|
|
382
|
+
# and `each`-yielding bodies. Rack 3 streaming bodies (the
|
|
383
|
+
# `call(stream)` variant) raise here; the eligibility check
|
|
384
|
+
# is supposed to refuse them at registration time.
|
|
385
|
+
def collect_body_bytes(body)
|
|
386
|
+
return body[0].b if body.is_a?(Array) && body.length == 1 && body[0].is_a?(String)
|
|
387
|
+
|
|
388
|
+
buf = String.new(encoding: Encoding::ASCII_8BIT)
|
|
389
|
+
body.each { |chunk| buf << chunk.to_s.b } if body.respond_to?(:each)
|
|
390
|
+
buf
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
# 2.14-A — Build the response header lines including
|
|
394
|
+
# `content-length`, `connection`, and `server`. Skips any
|
|
395
|
+
# header named `connection`/`content-length`/`transfer-encoding`
|
|
396
|
+
# the app set (we own those in the C-loop path).
|
|
397
|
+
def normalize_response_headers(headers, body_len, keep_alive)
|
|
398
|
+
out = String.new(encoding: Encoding::ASCII_8BIT)
|
|
399
|
+
if headers.is_a?(Hash)
|
|
400
|
+
headers.each do |name, value|
|
|
401
|
+
ln = name.to_s.downcase
|
|
402
|
+
next if %w[connection content-length transfer-encoding].include?(ln)
|
|
403
|
+
|
|
404
|
+
# Multi-value headers (Rack 3: Array of values, or
|
|
405
|
+
# newline-joined String) — emit one line per value.
|
|
406
|
+
vals = value.is_a?(Array) ? value : value.to_s.split("\n")
|
|
407
|
+
vals.each do |v|
|
|
408
|
+
out << ln << ': ' << v.to_s << "\r\n"
|
|
409
|
+
end
|
|
410
|
+
end
|
|
411
|
+
end
|
|
412
|
+
out << 'content-length: ' << body_len.to_s << "\r\n"
|
|
413
|
+
out << (keep_alive ? "connection: keep-alive\r\n" : "connection: close\r\n")
|
|
414
|
+
out
|
|
415
|
+
end
|
|
416
|
+
|
|
417
|
+
# 2.14-A — minimal status line builder. Covers the canonical
|
|
418
|
+
# 200/201/204/301/302/304/400/401/403/404/500 by name; everything
|
|
419
|
+
# else falls back to a generic reason phrase since the Rack
|
|
420
|
+
# body still wins at the protocol level.
|
|
421
|
+
STATUS_LINES = {
|
|
422
|
+
200 => "HTTP/1.1 200 OK\r\n",
|
|
423
|
+
201 => "HTTP/1.1 201 Created\r\n",
|
|
424
|
+
204 => "HTTP/1.1 204 No Content\r\n",
|
|
425
|
+
301 => "HTTP/1.1 301 Moved Permanently\r\n",
|
|
426
|
+
302 => "HTTP/1.1 302 Found\r\n",
|
|
427
|
+
304 => "HTTP/1.1 304 Not Modified\r\n",
|
|
428
|
+
400 => "HTTP/1.1 400 Bad Request\r\n",
|
|
429
|
+
401 => "HTTP/1.1 401 Unauthorized\r\n",
|
|
430
|
+
403 => "HTTP/1.1 403 Forbidden\r\n",
|
|
431
|
+
404 => "HTTP/1.1 404 Not Found\r\n",
|
|
432
|
+
500 => "HTTP/1.1 500 Internal Server Error\r\n"
|
|
433
|
+
}.freeze
|
|
434
|
+
|
|
435
|
+
def build_status_line(status)
|
|
436
|
+
STATUS_LINES[status] || "HTTP/1.1 #{status} OK\r\n"
|
|
437
|
+
end
|
|
438
|
+
|
|
154
439
|
# 2.1.0 (WS-1): `connection:` is the Hyperion::Connection that owns
|
|
155
440
|
# the underlying socket for this request. When non-nil, the env hash
|
|
156
441
|
# advertises Rack 3 full-hijack support — the app can call
|
|
@@ -362,9 +647,24 @@ module Hyperion
|
|
|
362
647
|
server_name, server_port = split_host(host_header)
|
|
363
648
|
|
|
364
649
|
env = ENV_POOL.acquire
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
650
|
+
# 2.13-D — gRPC streaming requests pass a non-String IO-shaped
|
|
651
|
+
# body (Hyperion::Http2Handler::StreamingInput) and must NOT go
|
|
652
|
+
# through the StringIO pool: the StringIO would `string=` consume
|
|
653
|
+
# it as a String and lose the streaming-read semantic. Fall back
|
|
654
|
+
# to the legacy buffered path only when `request.body` is a
|
|
655
|
+
# String — covers HTTP/1.1 (always String) and HTTP/2 unary
|
|
656
|
+
# (String per RequestStream#@request_body). The streaming path
|
|
657
|
+
# tags `input` as nil so the ensure-block release skips the
|
|
658
|
+
# pool return for this request.
|
|
659
|
+
if request.body.is_a?(String)
|
|
660
|
+
input = INPUT_POOL.acquire
|
|
661
|
+
input.string = request.body
|
|
662
|
+
input.rewind
|
|
663
|
+
env['rack.input'] = input
|
|
664
|
+
else
|
|
665
|
+
input = nil
|
|
666
|
+
env['rack.input'] = request.body
|
|
667
|
+
end
|
|
368
668
|
|
|
369
669
|
# Adapter-owned (non-header, non-request-line) env. SERVER_NAME/PORT
|
|
370
670
|
# need split_host, REMOTE_ADDR needs peer info, the rack.* keys are
|
|
@@ -379,7 +679,6 @@ module Hyperion
|
|
|
379
679
|
# without a backing socket.
|
|
380
680
|
env['REMOTE_ADDR'] = request.peer_address || '127.0.0.1'
|
|
381
681
|
env['rack.url_scheme'] = 'http'
|
|
382
|
-
env['rack.input'] = input
|
|
383
682
|
env['rack.errors'] = $stderr
|
|
384
683
|
if connection
|
|
385
684
|
# 2.1.0 (WS-1) — Rack 3 full-hijack. The proc captures the
|
data/lib/hyperion/connection.rb
CHANGED
|
@@ -141,6 +141,18 @@ module Hyperion
|
|
|
141
141
|
# asked Ruby for the symbol/label). Each Connection lives in
|
|
142
142
|
# exactly one process, so the cache is tight and never stale.
|
|
143
143
|
@worker_id = Process.pid.to_s
|
|
144
|
+
# 2.13-A — pre-build the frozen single-element label tuple that
|
|
145
|
+
# `tick_worker_request` would otherwise allocate every request
|
|
146
|
+
# (`[@worker_id]` per call). Per-Connection caching is safe
|
|
147
|
+
# because @worker_id is process-constant and the tuple is
|
|
148
|
+
# frozen so consumers can't mutate the shared instance.
|
|
149
|
+
@worker_id_label_tuple = [@worker_id].freeze
|
|
150
|
+
# 2.13-A — register the labeled-counter family ONCE here (used
|
|
151
|
+
# to fire on every `tick_worker_request` via an `unless`-flag
|
|
152
|
+
# check; the early-return cost is small but real on the
|
|
153
|
+
# 8000 r/s -c1 single-thread profile). After this, the
|
|
154
|
+
# request loop calls `increment_labeled_counter` directly.
|
|
155
|
+
@metrics.ensure_worker_request_family_registered!
|
|
144
156
|
# 2.10-D — direct-dispatch route table. The hot-path lookup
|
|
145
157
|
# is `@route_table&.lookup(method, path)` so the nil-default
|
|
146
158
|
# case (no operator-registered direct routes — the
|
|
@@ -320,7 +332,16 @@ module Hyperion
|
|
|
320
332
|
# via `dispatch_request`, direct dispatch via `dispatch_direct!`,
|
|
321
333
|
# and the StaticEntry fast path via `dispatch_direct_static!`
|
|
322
334
|
# all flow through this point in `serve`.
|
|
323
|
-
|
|
335
|
+
#
|
|
336
|
+
# 2.13-A — call `increment_labeled_counter` directly with the
|
|
337
|
+
# pre-built frozen `[@worker_id]` tuple instead of going
|
|
338
|
+
# through `tick_worker_request`. The wrapper allocates a
|
|
339
|
+
# fresh `[label]` array AND calls `worker_id.to_s` per
|
|
340
|
+
# request; cached tuple skips both. Family registration was
|
|
341
|
+
# done once in the constructor (idempotent on the Metrics
|
|
342
|
+
# instance) so the request loop is registration-free.
|
|
343
|
+
@metrics.increment_labeled_counter(Hyperion::Metrics::REQUESTS_DISPATCH_TOTAL,
|
|
344
|
+
@worker_id_label_tuple)
|
|
324
345
|
# 2.4-C: capture start time for the per-route duration histogram.
|
|
325
346
|
# Same Process.clock_gettime that the access-log path was already
|
|
326
347
|
# paying — at default-ON log_requests the second call here is
|
|
@@ -788,10 +809,35 @@ module Hyperion
|
|
|
788
809
|
)
|
|
789
810
|
end
|
|
790
811
|
|
|
812
|
+
# 2.13-A — Rack 3 (the version Hyperion advertises in
|
|
813
|
+
# `env['rack.version']`) requires response header keys to be
|
|
814
|
+
# lowercase Strings (Rack 3 spec §6.4 "Headers must be a Hash;
|
|
815
|
+
# the header keys must be lowercase Strings"). Pre-2.13-A this
|
|
816
|
+
# method scanned the whole Hash via `headers.find` + per-key
|
|
817
|
+
# `k.to_s.downcase` to find the Connection header — that's an
|
|
818
|
+
# O(N) walk + N transient string allocations on EVERY response
|
|
819
|
+
# (and most responses don't carry a Connection header at all,
|
|
820
|
+
# so the loop ran to completion every time).
|
|
821
|
+
#
|
|
822
|
+
# The new path is a single Hash lookup. Apps that violate the
|
|
823
|
+
# Rack 3 spec by returning mixed-case keys (some legacy gems
|
|
824
|
+
# still do; less common in 2026) lose the Connection-close
|
|
825
|
+
# signal and stay on keep-alive — that's a benign degradation
|
|
826
|
+
# (the connection is reused; the next request still goes through
|
|
827
|
+
# request-side `Connection: close` parsing) and the fix is to
|
|
828
|
+
# update the app to spec.
|
|
829
|
+
CONNECTION_HEADER_KEY_DOWNCASE = 'connection'
|
|
830
|
+
|
|
791
831
|
def should_keep_alive?(request, _status, headers)
|
|
792
|
-
# App-emitted Connection: close wins.
|
|
793
|
-
|
|
794
|
-
|
|
832
|
+
# App-emitted Connection: close wins. Rack-3 fast path: O(1)
|
|
833
|
+
# Hash lookup; non-Hash headers (Array-of-pairs, etc.) fall
|
|
834
|
+
# back to a single allocation-free scan.
|
|
835
|
+
conn_response_value = if headers.is_a?(Hash)
|
|
836
|
+
headers[CONNECTION_HEADER_KEY_DOWNCASE]
|
|
837
|
+
else
|
|
838
|
+
find_connection_header_array(headers)
|
|
839
|
+
end
|
|
840
|
+
return false if conn_response_value && conn_response_value.to_s.downcase == 'close'
|
|
795
841
|
|
|
796
842
|
# Request-side Connection header.
|
|
797
843
|
conn_request = request.header('connection')&.downcase
|
|
@@ -806,6 +852,21 @@ module Hyperion
|
|
|
806
852
|
end
|
|
807
853
|
end
|
|
808
854
|
|
|
855
|
+
# 2.13-A — non-Hash headers fallback (Array of [key, value] pairs).
|
|
856
|
+
# Rack 3 mandates Hash, but legacy code occasionally returns an
|
|
857
|
+
# Array; we walk it case-sensitively because Rack-3 lowercase is
|
|
858
|
+
# part of the contract for non-Hash returns too. Apps emitting
|
|
859
|
+
# `'Connection'`-cased keys via Array form fall through to no-
|
|
860
|
+
# match and stay on keep-alive — same benign degradation as the
|
|
861
|
+
# Hash branch.
|
|
862
|
+
def find_connection_header_array(headers)
|
|
863
|
+
headers.each do |pair|
|
|
864
|
+
next unless pair.is_a?(Array) && pair.length >= 2
|
|
865
|
+
return pair[1] if pair[0] == CONNECTION_HEADER_KEY_DOWNCASE
|
|
866
|
+
end
|
|
867
|
+
nil
|
|
868
|
+
end
|
|
869
|
+
|
|
809
870
|
def set_idle_timeout(socket)
|
|
810
871
|
socket.timeout = IDLE_KEEPALIVE_TIMEOUT_SECONDS if socket.respond_to?(:timeout=)
|
|
811
872
|
rescue StandardError
|