hyperion-rb 1.6.1 → 2.10.1

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 +4570 -0
  3. data/README.md +212 -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 +452 -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 +368 -9
  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
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../h2_codec'
4
+
5
+ module Hyperion
6
+ module Http2
7
+ # Phase 10 (RFC §3 Phase 6c, 2.2.0) — adapter shim that exposes the
8
+ # Rust HPACK encoder/decoder behind the same call surface
9
+ # `protocol-http2`'s connection uses today (`#encode_headers(headers)`
10
+ # → bytes, `#decode_headers(bytes)` → array of [name, value]).
11
+ #
12
+ # The adapter is constructed once per HTTP/2 connection, holds an
13
+ # `Hyperion::H2Codec::Encoder` and `Decoder` (each owns its own RFC 7541
14
+ # dynamic table), and is consulted only from the encode/decode
15
+ # serialization point inside `protocol-http2::Connection`. The framer,
16
+ # stream state machine, flow control, and HEADERS/CONTINUATION
17
+ # framing all stay in `protocol-http2` — Phase 10's scope is the
18
+ # HPACK byte-pump only. Replacing the framer in Rust is left for a
19
+ # future Phase 6d.
20
+ #
21
+ # When `Hyperion::H2Codec.available?` is false (no Rust toolchain at
22
+ # gem install, ABI mismatch, JRuby, etc.) callers MUST NOT
23
+ # construct `NativeHpackAdapter` — the substitution layer in
24
+ # `Http2Handler#build_server` skips installation in that case and
25
+ # the connection keeps using `protocol-http2`'s pure-Ruby
26
+ # Compressor/Decompressor.
27
+ #
28
+ # Headers are passed in/returned as `[[name_string, value_string], …]`
29
+ # — the same shape `protocol-http2` already uses internally, so the
30
+ # substitution is byte-for-byte transparent at the protocol-http2
31
+ # boundary.
32
+ class NativeHpackAdapter
33
+ # @raise [RuntimeError] if the native codec isn't loaded.
34
+ def initialize
35
+ unless Hyperion::H2Codec.available?
36
+ raise 'NativeHpackAdapter requires Hyperion::H2Codec.available? — guard at the call site'
37
+ end
38
+
39
+ @encoder = Hyperion::H2Codec::Encoder.new
40
+ @decoder = Hyperion::H2Codec::Decoder.new
41
+ end
42
+
43
+ # Encode a header block via the native HPACK encoder. The
44
+ # encoder's dynamic table persists across calls (HPACK is
45
+ # stateful per direction per connection), so two HEADERS frames
46
+ # encoded back-to-back on the same adapter share table state
47
+ # exactly as RFC 7541 requires.
48
+ #
49
+ # @param headers [Array<Array(String, String)>]
50
+ # @param buffer [String] optional output buffer; bytes are
51
+ # appended so the Rust encoder's output appends to whatever
52
+ # the caller already accumulated. Returned for chaining.
53
+ # @return [String] the buffer (with newly-encoded bytes appended).
54
+ def encode_headers(headers, buffer = String.new.b)
55
+ bytes = @encoder.encode(headers)
56
+ buffer << bytes
57
+ buffer
58
+ end
59
+
60
+ # Decode a HEADERS/CONTINUATION block via the native HPACK
61
+ # decoder. Updates the decoder's dynamic table.
62
+ #
63
+ # @param data [String] the wire bytes for one header block.
64
+ # @return [Array<Array(String, String)>]
65
+ def decode_headers(data)
66
+ @decoder.decode(data)
67
+ end
68
+ end
69
+ end
70
+ end
@@ -6,6 +6,8 @@ require 'protocol/http2/server'
6
6
  require 'protocol/http2/framer'
7
7
  require 'protocol/http2/stream'
8
8
 
9
+ require_relative 'http2/native_hpack_adapter'
10
+
9
11
  module Hyperion
10
12
  # Real HTTP/2 dispatch driven by `protocol-http2`.
11
13
  #
@@ -132,6 +134,11 @@ module Hyperion
132
134
  # Single instance per connection, lives for the lifetime of `serve`.
133
135
  class WriterContext
134
136
  attr_reader :encode_mutex
137
+ # 2.10-G — connection-lifecycle timing slots used by the optional h2
138
+ # latency-instrumentation path (gated by `HYPERION_H2_TIMING=1`).
139
+ # Each slot is a single CLOCK_MONOTONIC timestamp captured at most
140
+ # once per connection. nil = unset, set on first observation.
141
+ attr_accessor :t0_serve_entry, :t1_preface_done, :t2_first_encode, :t2_first_wire
135
142
 
136
143
  def initialize(max_pending_bytes: MAX_PER_CONN_PENDING_BYTES)
137
144
  @queue = ::Thread::Queue.new
@@ -142,6 +149,12 @@ module Hyperion
142
149
  @pending_bytes_lock = ::Mutex.new
143
150
  @max_pending_bytes = max_pending_bytes
144
151
  @writer_done = false
152
+ # 2.10-G timing slots, all initially nil so capture is a single
153
+ # `||=` write under the encode mutex / writer fiber.
154
+ @t0_serve_entry = nil
155
+ @t1_preface_done = nil
156
+ @t2_first_encode = nil
157
+ @t2_first_wire = nil
145
158
  end
146
159
 
147
160
  # Called by SendQueueIO#write on the calling (encoder) fiber. Enforces
@@ -412,12 +425,171 @@ module Hyperion
412
425
  # MAXIMUM_ALLOWED_WINDOW_SIZE).
413
426
  H2_MAX_WINDOW_SIZE = 0x7FFFFFFF
414
427
 
415
- def initialize(app:, thread_pool: nil, h2_settings: nil)
416
- @app = app
417
- @thread_pool = thread_pool
418
- @h2_settings = h2_settings
419
- @metrics = Hyperion.metrics
420
- @logger = Hyperion.logger
428
+ # 1.7.0 added kwargs:
429
+ # * `runtime:` `Hyperion::Runtime` for metrics/logger
430
+ # isolation (default `Runtime.default`).
431
+ # * `h2_admission:` — Optional `Hyperion::H2Admission` for the
432
+ # per-process stream cap (RFC A7). nil keeps
433
+ # the 1.6.x unbounded behaviour.
434
+ #
435
+ # 2.0.0 (Phase 6b) probed `Hyperion::H2Codec.available?` at
436
+ # construction so the handler knew whether the native HPACK path
437
+ # was operational, but the connection state machine still drove
438
+ # encode/decode through `protocol-http2`'s pure-Ruby Compressor /
439
+ # Decompressor.
440
+ #
441
+ # 2.2.0 (Phase 10 / RFC §3 Phase 6c) ships the wiring infrastructure:
442
+ # {Hyperion::Http2::NativeHpackAdapter} + {#install_native_hpack}
443
+ # replace the per-connection HPACK encode/decode boundary with
444
+ # the Rust crate when AND ONLY WHEN both:
445
+ # 1. `Hyperion::H2Codec.available?` is true (cdylib loaded), AND
446
+ # 2. `ENV['HYPERION_H2_NATIVE_HPACK']` is one of `1`/`true`/`yes`/`on`.
447
+ #
448
+ # The default is OFF because local h2load benchmarking on macOS
449
+ # showed the Fiddle FFI per-call marshalling overhead dominates
450
+ # for typical 3–8-header HEADERS frames — the standalone microbench's
451
+ # 3.26× encode win does not translate to wire wins until the FFI
452
+ # marshalling layer is rewritten to amortize allocation. Keeping the
453
+ # default OFF preserves 2.0.0/2.1.0 behavior; flipping the env var
454
+ # gives operators the swap they want to A/B test in their own env.
455
+ # The framer + stream state machine + flow control + HEADERS /
456
+ # CONTINUATION framing all stay in `protocol-http2`; only the
457
+ # HPACK byte-pump is replaced when the swap is enabled. Frame ser/de
458
+ # in Rust (Phase 6d) is a separate, larger lift.
459
+ def initialize(app:, thread_pool: nil, h2_settings: nil, runtime: nil, h2_admission: nil)
460
+ @app = app
461
+ @thread_pool = thread_pool
462
+ @h2_settings = h2_settings
463
+ if runtime
464
+ @runtime = runtime
465
+ @metrics = runtime.metrics
466
+ @logger = runtime.logger
467
+ else
468
+ # 1.6.x compat path — see Connection#initialize for rationale.
469
+ @runtime = Hyperion::Runtime.default
470
+ @metrics = Hyperion.metrics
471
+ @logger = Hyperion.logger
472
+ end
473
+ @h2_admission = h2_admission
474
+ @h2_codec_available = Hyperion::H2Codec.available?
475
+ # 2.5-B [breaking-default-change]: native HPACK now defaults to ON
476
+ # when the Rust crate is available. The 2026-04-30 Rails-shape
477
+ # bench (`bench/h2_rails_shape.ru`, 25 response headers) measured
478
+ # native v3 at 1,418 r/s vs Ruby fallback 1,201 r/s — **+18.0%**
479
+ # on a header-heavy workload, comfortably above the +15% flip
480
+ # threshold. 2.4-A's hello-shape bench saw parity because HPACK
481
+ # is <1% of per-stream CPU on a 2-header response.
482
+ #
483
+ # Operators who want the prior 2.4.x default (Ruby fallback, env
484
+ # var unset) can now set `HYPERION_H2_NATIVE_HPACK=off` (or
485
+ # `0`/`false`/`no`/`off`) explicitly. `HYPERION_H2_NATIVE_HPACK=1`
486
+ # still works for explicit opt-in.
487
+ #
488
+ # When OFF (env-overridden): `protocol-http2`'s pure-Ruby HPACK
489
+ # Compressor / Decompressor handles everything as in 2.0.0–2.4.x.
490
+ @h2_native_hpack_enabled = @h2_codec_available && resolve_h2_native_hpack_default
491
+ @h2_codec_native = @h2_native_hpack_enabled # back-compat ivar — preserved for codec_native? readers
492
+ # 2.10-G — opt-in connection-setup timing instrumentation. When set,
493
+ # `serve` captures four monotonic timestamps per connection:
494
+ #
495
+ # t0 — entry to `serve` (post-TLS, post-ALPN — the socket is already
496
+ # the negotiated h2 SSLSocket by the time the handler sees it)
497
+ # t1 — `read_connection_preface` returned (server-side SETTINGS
498
+ # encoded + handed to the framer; client preface fully read)
499
+ # t2_encode — first stream's HEADERS frame finished encoding (bytes
500
+ # sit in the writer queue)
501
+ # t2_wire — writer fiber finished its first `socket.write` (bytes
502
+ # on the wire)
503
+ #
504
+ # When the connection's first response completes, the handler emits
505
+ # a single `'h2 first-stream timing'` info line with t0→t1, t1→t2_encode,
506
+ # t2_encode→t2_wire deltas in milliseconds. Off by default (zero hot-path
507
+ # cost when disabled — a single ivar read per stream branch). Used by
508
+ # 2.10-G to root-cause Hyperion's flat ~40 ms first-stream max-latency.
509
+ @h2_timing_enabled = env_flag_enabled?('HYPERION_H2_TIMING')
510
+ record_codec_boot_state
511
+ end
512
+
513
+ # Read an env-var flag with the usual truthiness rules (any of
514
+ # 1/true/yes/on, case-insensitive). Anything else → false.
515
+ def env_flag_enabled?(name)
516
+ v = ENV[name]
517
+ return false if v.nil? || v.empty?
518
+
519
+ %w[1 true yes on].include?(v.downcase)
520
+ end
521
+
522
+ # Read an env-var flag with explicit OFF support. Used by
523
+ # `HYPERION_H2_NATIVE_HPACK` since 2.5-B flipped the default to ON.
524
+ # Returns true if the env var is unset / empty / explicitly truthy;
525
+ # returns false only when the operator sets it to a truthy-OFF
526
+ # value (0/false/no/off, case-insensitive). Anything else falls
527
+ # back to the default-on behavior so we don't surprise operators
528
+ # who set typo'd values.
529
+ def resolve_h2_native_hpack_default
530
+ v = ENV['HYPERION_H2_NATIVE_HPACK']
531
+ return true if v.nil? || v.empty?
532
+
533
+ lc = v.downcase
534
+ return false if %w[0 false no off].include?(lc)
535
+
536
+ true
537
+ end
538
+
539
+ # 2.0.0 Phase 6b: emit a single-shot boot log line per process
540
+ # describing the codec selection. Operators reading the boot log
541
+ # see whether the native HPACK path is in play. Idempotent across
542
+ # multiple Http2Handler constructions in the same process.
543
+ def record_codec_boot_state
544
+ return if Hyperion::Http2Handler.instance_variable_get(:@codec_state_logged)
545
+
546
+ Hyperion::Http2Handler.instance_variable_set(:@codec_state_logged, true)
547
+ cglue_active = @h2_native_hpack_enabled && Hyperion::H2Codec.cglue_available?
548
+ mode =
549
+ if @h2_native_hpack_enabled && cglue_active
550
+ 'native (Rust v3 / CGlue) — HPACK on hot path, no Fiddle per call'
551
+ elsif @h2_native_hpack_enabled
552
+ 'native (Rust v2 / Fiddle) — HPACK on hot path, Fiddle marshalling per call'
553
+ elsif @h2_codec_available
554
+ 'fallback (protocol-http2 / pure Ruby HPACK) — native available but opted out via HYPERION_H2_NATIVE_HPACK=off'
555
+ else
556
+ 'fallback (protocol-http2 / pure Ruby HPACK) — native unavailable'
557
+ end
558
+ @logger.info do
559
+ {
560
+ message: 'h2 codec selected',
561
+ mode: mode,
562
+ native_available: @h2_codec_available,
563
+ native_enabled: @h2_native_hpack_enabled,
564
+ cglue_active: cglue_active,
565
+ hpack_path: if @h2_native_hpack_enabled
566
+ cglue_active ? 'native-v3' : 'native-v2'
567
+ else
568
+ 'pure-ruby'
569
+ end
570
+ }
571
+ end
572
+ @metrics.increment(:h2_codec_native_selected) if @h2_native_hpack_enabled
573
+ @metrics.increment(:h2_codec_fallback_selected) unless @h2_native_hpack_enabled
574
+ end
575
+
576
+ # Read-only accessor used by tests + diagnostics. true = the
577
+ # `Hyperion::H2Codec` Rust extension loaded successfully AND
578
+ # `HYPERION_H2_NATIVE_HPACK=1` is set, so `build_server` will
579
+ # wire the native adapter onto every new connection's
580
+ # `encode_headers` / `decode_headers` boundary. The 2.2.0 default
581
+ # is false (opt-in) — see `#initialize` for the rationale and the
582
+ # bench numbers in CHANGELOG/docs that pinned the default off.
583
+ def codec_native?
584
+ @h2_native_hpack_enabled
585
+ end
586
+
587
+ # True when the Rust crate loaded successfully, regardless of
588
+ # whether the operator opted in to wiring it into the wire path.
589
+ # Useful for diagnostics/health endpoints that want to surface
590
+ # "native is available but currently disabled".
591
+ def codec_available?
592
+ @h2_codec_available
421
593
  end
422
594
 
423
595
  def serve(socket)
@@ -431,6 +603,11 @@ module Hyperion
431
603
  framer = ::Protocol::HTTP2::Framer.new(send_io)
432
604
  server = build_server(framer)
433
605
 
606
+ # 2.10-G — connection entry timestamp. Captured before any framing
607
+ # work so the t0→t1 delta isolates "preface exchange + initial
608
+ # SETTINGS round-trip" from any pre-handler scheduling delay.
609
+ writer_ctx.t0_serve_entry = monotonic_now if @h2_timing_enabled
610
+
434
611
  task = ::Async::Task.current
435
612
 
436
613
  # Spawn the dedicated writer fiber BEFORE the preface exchange.
@@ -442,6 +619,7 @@ module Hyperion
442
619
  writer_task = task.async { run_writer_loop(socket, writer_ctx) }
443
620
 
444
621
  server.read_connection_preface(initial_settings_payload)
622
+ writer_ctx.t1_preface_done = monotonic_now if @h2_timing_enabled
445
623
 
446
624
  # Extract once — the same TCP peer drives every stream on this conn.
447
625
  peer_addr = peer_address(socket)
@@ -504,6 +682,12 @@ module Hyperion
504
682
  rescue StandardError
505
683
  nil
506
684
  end
685
+ # 2.10-G — emit one info-level timing line per connection when the
686
+ # opt-in instrumentation is enabled and we collected a full set of
687
+ # samples (a connection that died before serving any stream lacks
688
+ # t2_first_encode / t2_first_wire and gets skipped — there's no
689
+ # first-stream signal to report).
690
+ log_h2_first_stream_timing(writer_ctx) if @h2_timing_enabled
507
691
  end
508
692
  @metrics.decrement(:connections_active)
509
693
  socket.close unless socket.closed?
@@ -576,6 +760,7 @@ module Hyperion
576
760
 
577
761
  def build_server(framer)
578
762
  server = ::Protocol::HTTP2::Server.new(framer)
763
+ install_native_hpack(server) if @h2_native_hpack_enabled
579
764
  server.define_singleton_method(:accept_stream) do |stream_id, &block|
580
765
  unless valid_remote_stream_id?(stream_id)
581
766
  raise ::Protocol::HTTP2::ProtocolError, "Invalid stream id: #{stream_id}"
@@ -590,6 +775,53 @@ module Hyperion
590
775
  server
591
776
  end
592
777
 
778
+ # Phase 10 (Phase 6c): swap the per-connection HPACK encode/decode
779
+ # entry points to route through the Rust crate. We replace
780
+ # `encode_headers` / `decode_headers` on the `Protocol::HTTP2::Server`
781
+ # instance via singleton methods — protocol-http2's framer + stream
782
+ # state machine call `connection.encode_headers(headers, buffer)` and
783
+ # `connection.decode_headers(data)` whenever HEADERS / CONTINUATION
784
+ # frames cross the wire, so this is exactly the boundary where the
785
+ # native codec slots in. The adapter holds one Encoder + one Decoder
786
+ # for this connection; their dynamic tables persist across all
787
+ # HEADERS frames in their respective directions, matching RFC 7541's
788
+ # per-direction HPACK context model.
789
+ #
790
+ # The Ruby `@encoder` / `@decoder` Context ivars on the
791
+ # `Protocol::HTTP2::Connection` superclass remain in place but are
792
+ # never consulted — the singleton-method overrides shortcut past
793
+ # them. That's safe: protocol-http2 only touches those Contexts
794
+ # through `encode_headers` / `decode_headers`, which we now own.
795
+ #
796
+ # If the substitution surface ever shifts in protocol-http2 (e.g.
797
+ # a future version inlines the call), this method becomes a no-op
798
+ # safely — `define_singleton_method` doesn't fail when the parent
799
+ # method is absent, but downstream calls would. The codec-boot log
800
+ # makes the substitution observable, so a regression would surface
801
+ # quickly via the integration spec.
802
+ def install_native_hpack(server)
803
+ adapter = Hyperion::Http2::NativeHpackAdapter.new
804
+ server.define_singleton_method(:encode_headers) do |headers, buffer = String.new.b|
805
+ adapter.encode_headers(headers, buffer)
806
+ end
807
+ server.define_singleton_method(:decode_headers) do |data|
808
+ adapter.decode_headers(data)
809
+ end
810
+ # Stash the adapter so introspection (and the encode-mutex synchronisation
811
+ # boundary, since adapter state is mutated under it) can reach it.
812
+ server.instance_variable_set(:@hyperion_native_hpack, adapter)
813
+ adapter
814
+ rescue StandardError => e
815
+ # Defence in depth: if the adapter ctor fails for any reason, log and
816
+ # fall back to protocol-http2's Ruby Compressor/Decompressor. Better
817
+ # than crashing the connection on first HEADERS frame.
818
+ @logger.warn do
819
+ { message: 'h2 native hpack install failed; falling back to Ruby HPACK',
820
+ error: e.class.name, detail: e.message }
821
+ end
822
+ nil
823
+ end
824
+
593
825
  def dispatch_stream(stream, writer_ctx, peer_addr = nil)
594
826
  # RFC 7540 §8.1.2 — header validation flagged this stream as malformed.
595
827
  # Send RST_STREAM PROTOCOL_ERROR instead of invoking the app.
@@ -608,6 +840,25 @@ module Hyperion
608
840
  return
609
841
  end
610
842
 
843
+ # RFC A7: process-wide stream admission control. nil admission =
844
+ # unbounded (current behaviour). When the cap is hit we send
845
+ # REFUSED_STREAM (RFC 7540 §11 / RFC 9113 §5.4.1) — the spec-
846
+ # defined response for "this stream cannot be processed; client
847
+ # may retry on a different stream id". Bumps a counter so
848
+ # operators can alert on sustained refusal volume.
849
+ if @h2_admission && !@h2_admission.admit
850
+ @metrics.increment(:h2_streams_refused)
851
+ begin
852
+ writer_ctx.encode_mutex.synchronize do
853
+ stream.send_reset_stream(::Protocol::HTTP2::Error::REFUSED_STREAM) unless stream.closed?
854
+ end
855
+ rescue StandardError
856
+ nil
857
+ end
858
+ return
859
+ end
860
+ @h2_admission.nil?
861
+
611
862
  pseudo, regular = partition_pseudo(stream.request_headers)
612
863
 
613
864
  method = pseudo[':method'] || 'GET'
@@ -630,11 +881,24 @@ module Hyperion
630
881
 
631
882
  @metrics.increment(:requests_total)
632
883
  @metrics.increment(:requests_in_flight)
884
+ # 2.1.0 (WS-1): HTTP/2 hijack is intentionally NOT plumbed here.
885
+ # Rack 3 hijack over HTTP/2 requires Extended CONNECT (RFC 8441 +
886
+ # RFC 9220) — a separate feature with its own SETTINGS handshake,
887
+ # :protocol pseudo-header, and stream lifetime semantics. The
888
+ # 2.1.0 scope is HTTP/1.1 hijack only (env['rack.hijack?'] returns
889
+ # false on h2 streams because we don't pass `connection:` here).
890
+ # If a Rack app keys on rack.hijack? to choose a transport, the h2
891
+ # branch will fall through to its non-hijack path. See WS-2..WS-5
892
+ # for the full WebSocket roadmap.
633
893
  status, response_headers, body_chunks = begin
634
894
  if @thread_pool
635
895
  @thread_pool.call(@app, request)
636
896
  else
637
- Hyperion::Adapter::Rack.call(@app, request)
897
+ # 2.5-C — pass the handler's Runtime so per-request hooks
898
+ # fire on h2 streams too. Multi-tenant deployments rely on
899
+ # this to keep tracing context per-server even on the h2
900
+ # path that doesn't go through Connection#call_app.
901
+ Hyperion::Adapter::Rack.call(@app, request, runtime: @runtime)
638
902
  end
639
903
  ensure
640
904
  @metrics.decrement(:requests_in_flight)
@@ -655,8 +919,27 @@ module Hyperion
655
919
  body_chunks.each { |c| payload << c.to_s }
656
920
  body_chunks.close if body_chunks.respond_to?(:close)
657
921
 
658
- writer_ctx.encode_mutex.synchronize { stream.send_headers(out_headers) }
659
- send_body(stream, payload, writer_ctx)
922
+ # Hotfix C2: empty-body responses (RFC 7230 §3.3.3 204/304 + HEAD)
923
+ # MUST NOT carry a DATA frame. Folding END_STREAM onto the HEADERS
924
+ # frame collapses the response to one encoder-mutex acquisition and
925
+ # one writer-fiber wakeup instead of two. Any body the app returned
926
+ # for HEAD is discarded here per spec (the bytes were already
927
+ # built — that's a Rack-app smell, not our problem to fix).
928
+ if body_suppressed?(method, status)
929
+ writer_ctx.encode_mutex.synchronize do
930
+ stream.send_headers(out_headers, ::Protocol::HTTP2::END_STREAM)
931
+ end
932
+ else
933
+ writer_ctx.encode_mutex.synchronize { stream.send_headers(out_headers) }
934
+ send_body(stream, payload, writer_ctx)
935
+ end
936
+ # 2.10-G — first stream's HEADERS+DATA encoded. Capture exactly once
937
+ # per connection (use ||= under the encode mutex's freshly-released
938
+ # write so concurrent stream fibers race lose-race once). For h2load
939
+ # `-c 1 -m 100 -n 5000` the first stream is stream id 1, the only
940
+ # one that pays the connection-setup cost; later streams skip this
941
+ # branch via the `||=`.
942
+ writer_ctx.t2_first_encode = monotonic_now if @h2_timing_enabled && writer_ctx.t2_first_encode.nil?
660
943
  @metrics.increment_status(status)
661
944
  rescue StandardError => e
662
945
  @metrics.increment(:app_errors)
@@ -675,6 +958,26 @@ module Hyperion
675
958
  rescue StandardError
676
959
  nil
677
960
  end
961
+ ensure
962
+ # Release the admission slot once the stream's served (success or
963
+ # error). h2_admitted is local-set above the slot acquisition, so
964
+ # the protocol-error / pre-admission early-returns above don't
965
+ # double-release.
966
+ @h2_admission.release if defined?(h2_admitted) && h2_admitted
967
+ end
968
+
969
+ # RFC 7230 §3.3.3: status codes that prohibit a response body, plus
970
+ # the HEAD method which always suppresses the body regardless of what
971
+ # the application returned. The h2 dispatch path uses this to fold
972
+ # END_STREAM onto the HEADERS frame and skip the DATA-frame write
973
+ # entirely (see Hotfix C2).
974
+ BODY_SUPPRESSED_STATUSES = [204, 304].freeze
975
+
976
+ def body_suppressed?(method, status)
977
+ return true if BODY_SUPPRESSED_STATUSES.include?(status)
978
+ return true if method == 'HEAD'
979
+
980
+ false
678
981
  end
679
982
 
680
983
  # Send the response body, respecting the peer's max frame size and
@@ -731,6 +1034,13 @@ module Hyperion
731
1034
  while (chunk = writer_ctx.try_pop)
732
1035
  begin
733
1036
  socket.write(chunk)
1037
+ # 2.10-G — first byte on the wire. Capture exactly once per
1038
+ # connection (the first chunk drained is the server's
1039
+ # connection-preface SETTINGS frame; we want the t1→t2_wire
1040
+ # delta to bracket "preface bytes encoded → preface bytes on
1041
+ # the socket". The expensive HEADERS+DATA enqueue happens
1042
+ # later under t2_first_encode.)
1043
+ writer_ctx.t2_first_wire = monotonic_now if @h2_timing_enabled && writer_ctx.t2_first_wire.nil?
734
1044
  rescue EOFError, Errno::ECONNRESET, Errno::EPIPE, IOError, OpenSSL::SSL::SSLError
735
1045
  # Peer hung up. Release THIS chunk's byte budget, then drain the
736
1046
  # rest of the queue (without writing) so backpressured encoders
@@ -775,6 +1085,55 @@ module Hyperion
775
1085
  end
776
1086
  end
777
1087
 
1088
+ # 2.10-G — small helper so the four timing call sites in `serve`,
1089
+ # `dispatch_stream`, and `run_writer_loop` agree on the clock source.
1090
+ # CLOCK_MONOTONIC is unaffected by NTP jumps and is what the rest of
1091
+ # the gem uses for elapsed-time math (see Connection#serve).
1092
+ def monotonic_now
1093
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
1094
+ end
1095
+
1096
+ # 2.10-G — assemble + emit the per-connection timing breakdown that
1097
+ # the bench harness greps for. Three deltas are reported in
1098
+ # milliseconds:
1099
+ #
1100
+ # t0_to_t1_ms — preface exchange (read client preface + write
1101
+ # server SETTINGS into the framer queue)
1102
+ # t1_to_t2_enc_ms — gap between preface complete and first stream's
1103
+ # HEADERS+DATA encoded. If this is the dominant
1104
+ # bucket, the framer-fiber priming / first-stream
1105
+ # scheduling is the suspect.
1106
+ # t2_enc_to_t2_wire_ms — encode-complete to writer drained first
1107
+ # chunk on the wire. Should be near-zero on
1108
+ # a healthy connection (writer fiber is
1109
+ # already running, parked on @send_notify).
1110
+ # A large value here = writer-fiber
1111
+ # starvation under the Async scheduler.
1112
+ #
1113
+ # Skipped when any timestamp is missing (connection died before
1114
+ # serving a stream / instrumentation was disabled mid-flight).
1115
+ def log_h2_first_stream_timing(writer_ctx)
1116
+ t0 = writer_ctx.t0_serve_entry
1117
+ t1 = writer_ctx.t1_preface_done
1118
+ t2_enc = writer_ctx.t2_first_encode
1119
+ t2_wire = writer_ctx.t2_first_wire
1120
+ return if t0.nil? || t1.nil? || t2_enc.nil? || t2_wire.nil?
1121
+
1122
+ @logger.info do
1123
+ {
1124
+ message: 'h2 first-stream timing',
1125
+ t0_to_t1_ms: ((t1 - t0) * 1000).round(3),
1126
+ t1_to_t2_enc_ms: ((t2_enc - t1) * 1000).round(3),
1127
+ t2_enc_to_t2_wire_ms: ((t2_wire - t2_enc) * 1000).round(3),
1128
+ t0_to_t2_wire_ms: ((t2_wire - t0) * 1000).round(3)
1129
+ }
1130
+ end
1131
+ rescue StandardError
1132
+ # Logging the timing breakdown must never crash the connection
1133
+ # teardown path — instrumentation is best-effort.
1134
+ nil
1135
+ end
1136
+
778
1137
  # Mirrors Connection#peer_address — see the comment there. SSLSocket
779
1138
  # wraps a TCPSocket; both expose #peeraddr after handshake.
780
1139
  def peer_address(socket)