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.
@@ -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
- input = INPUT_POOL.acquire
366
- input.string = request.body
367
- input.rewind
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
@@ -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
- @metrics.tick_worker_request(@worker_id)
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
- conn_response = headers.find { |k, _| k.to_s.downcase == 'connection' }
794
- return false if conn_response && conn_response.last.to_s.downcase == 'close'
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