Class: Hyperion::Http2Handler

Inherits:
Object
  • Object
show all
Defined in:
lib/hyperion/http2_handler.rb

Overview

Real HTTP/2 dispatch driven by ‘protocol-http2`.

Each TLS connection that negotiated ‘h2` via ALPN ends up here. We frame the socket, read the connection preface, and then drive a frame loop on the connection’s fiber: it reads one frame at a time and lets ‘protocol-http2` update its connection/stream state machines. As soon as a client stream finishes its request half (state `:half_closed_remote` via `end_stream?`), we hand the stream off to a sibling fiber for dispatch — slow handlers no longer block other streams on the same connection.

## Outbound write architecture (1.6.0+)

Pre-1.6.0 every framer write (HEADERS / DATA / RST_STREAM / GOAWAY) ran under one connection-scoped ‘Mutex#synchronize { socket.write(…) }`. That capped per-connection h2 throughput to “one socket-write at a time” regardless of stream count: a slow socket (kernel send buffer full, remote peer reading slowly) blocked every other stream’s writes too.

1.6.0 splits the path:

* The HPACK encode + frame format step is fast (microseconds, in-memory)
  and remains serialized on the calling fiber via `@encode_mutex`. HPACK
  state is stateful across HEADERS frames per connection, and frames for
  a single stream must be wire-ordered (HEADERS → DATA → END_STREAM).
  Holding the encode mutex across a `send_*` call accomplishes both.
* The framer writes through a `SendQueueIO` wrapper (wraps the real
  socket). `SendQueueIO#write(bytes)` enqueues onto a connection-wide
  `@send_queue` and signals `@send_notify`; it never touches the real
  socket.
* A dedicated **writer fiber** owns the real socket. It pops byte chunks
  off the queue, writes them, and parks on `@send_notify` when empty.
  Only this fiber ever calls `socket.write` — the SSLSocket cross-fiber
  unsafety constraint is satisfied.

Net effect: the slow-socket case no longer serializes encode work across streams. A stream that has bytes ready to encode can encode and enqueue while the writer is mid-flush of an earlier chunk. The mutex hold time drops from “until the kernel accepts the write” to “until the bytes are appended to the in-memory queue.”

Backpressure: pathological clients (slow-read h2) could otherwise let the queue grow without bound. We track ‘@pending_bytes`; once it exceeds `MAX_PER_CONN_PENDING_BYTES`, encoding fibers wait on `@drained_notify` before enqueueing more. The writer signals `@drained_notify` after each drain pass.

Flow control: ‘RequestStream#window_updated` overrides the protocol-http2 default to fan a notification out to any fiber blocked in `send_body` waiting for the remote peer’s flow-control window to grow. The body writer chunks the response payload by the per-stream available frame size and yields on the notification when the window is exhausted, so large bodies never trip a FlowControlError.

Defined Under Namespace

Classes: RequestStream, SendQueueIO, WriterContext

Constant Summary collapse

MAX_PER_CONN_PENDING_BYTES =

Cap on bytes that may sit in a connection’s send queue waiting for the writer fiber to drain. Slow-read h2 clients can otherwise let an encoder fiber pile arbitrary bytes into RAM. 16 MiB matches the upper bound a well-behaved peer will buffer — anything beyond that is the writer being starved, and the right answer is to backpressure the encoder rather than allocate more.

16 * 1024 * 1024
SETTINGS_KEY_MAP =

Maps Hyperion-friendly setting names to the integer SETTINGS_* identifiers protocol-http2 uses on the wire. See RFC 7540 §6.5.2 — these are the only four parameters Hyperion exposes; the rest of the SETTINGS frame (HEADER_TABLE_SIZE, ENABLE_PUSH, etc.) keeps protocol-http2’s default.

{
  max_concurrent_streams: ::Protocol::HTTP2::Settings::MAXIMUM_CONCURRENT_STREAMS,
  initial_window_size: ::Protocol::HTTP2::Settings::INITIAL_WINDOW_SIZE,
  max_frame_size: ::Protocol::HTTP2::Settings::MAXIMUM_FRAME_SIZE,
  max_header_list_size: ::Protocol::HTTP2::Settings::MAXIMUM_HEADER_LIST_SIZE
}.freeze
H2_MIN_FRAME_SIZE =

RFC 7540 §6.5.2 floor for SETTINGS_MAX_FRAME_SIZE. protocol-http2 raises ProtocolError on values below this; we clamp + warn instead so a misconfigured operator gets a working server, not a boot-time crash.

0x4000
H2_MAX_FRAME_SIZE =

RFC 7540 §6.5.2 ceiling for SETTINGS_MAX_FRAME_SIZE.

0xFFFFFF
H2_MAX_WINDOW_SIZE =

RFC 7540 §6.9.2 — INITIAL_WINDOW_SIZE has the same 31-bit max as the WINDOW_UPDATE frame’s Window Size Increment (see protocol-http2’s MAXIMUM_ALLOWED_WINDOW_SIZE).

0x7FFFFFFF

Instance Method Summary collapse

Constructor Details

#initialize(app:, thread_pool: nil, h2_settings: nil, runtime: nil, h2_admission: nil) ⇒ Http2Handler

1.7.0 added kwargs:

* `runtime:`      — `Hyperion::Runtime` for metrics/logger
                    isolation (default `Runtime.default`).
* `h2_admission:` — Optional `Hyperion::H2Admission` for the
                    per-process stream cap (RFC A7). nil keeps
                    the 1.6.x unbounded behaviour.

2.0.0 (Phase 6b) probed ‘Hyperion::H2Codec.available?` at construction so the handler knew whether the native HPACK path was operational, but the connection state machine still drove encode/decode through `protocol-http2`’s pure-Ruby Compressor / Decompressor.

2.2.0 (Phase 10 / RFC §3 Phase 6c) ships the wiring infrastructure: Hyperion::Http2::NativeHpackAdapter + #install_native_hpack replace the per-connection HPACK encode/decode boundary with the Rust crate when AND ONLY WHEN both:

1. `Hyperion::H2Codec.available?` is true (cdylib loaded), AND
2. `ENV['HYPERION_H2_NATIVE_HPACK']` is one of `1`/`true`/`yes`/`on`.

The default is OFF because local h2load benchmarking on macOS showed the Fiddle FFI per-call marshalling overhead dominates for typical 3–8-header HEADERS frames — the standalone microbench’s 3.26× encode win does not translate to wire wins until the FFI marshalling layer is rewritten to amortize allocation. Keeping the default OFF preserves 2.0.0/2.1.0 behavior; flipping the env var gives operators the swap they want to A/B test in their own env. The framer + stream state machine + flow control + HEADERS / CONTINUATION framing all stay in ‘protocol-http2`; only the HPACK byte-pump is replaced when the swap is enabled. Frame ser/de in Rust (Phase 6d) is a separate, larger lift.



459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
# File 'lib/hyperion/http2_handler.rb', line 459

def initialize(app:, thread_pool: nil, h2_settings: nil, runtime: nil, h2_admission: nil)
  @app          = app
  @thread_pool  = thread_pool
  @h2_settings  = h2_settings
  if runtime
    @runtime = runtime
    @metrics = runtime.metrics
    @logger  = runtime.logger
  else
    # 1.6.x compat path — see Connection#initialize for rationale.
    @runtime = Hyperion::Runtime.default
    @metrics = Hyperion.metrics
    @logger  = Hyperion.logger
  end
  @h2_admission       = h2_admission
  @h2_codec_available = Hyperion::H2Codec.available?
  # 2.5-B [breaking-default-change]: native HPACK now defaults to ON
  # when the Rust crate is available. The 2026-04-30 Rails-shape
  # bench (`bench/h2_rails_shape.ru`, 25 response headers) measured
  # native v3 at 1,418 r/s vs Ruby fallback 1,201 r/s — **+18.0%**
  # on a header-heavy workload, comfortably above the +15% flip
  # threshold. 2.4-A's hello-shape bench saw parity because HPACK
  # is <1% of per-stream CPU on a 2-header response.
  #
  # Operators who want the prior 2.4.x default (Ruby fallback, env
  # var unset) can now set `HYPERION_H2_NATIVE_HPACK=off` (or
  # `0`/`false`/`no`/`off`) explicitly. `HYPERION_H2_NATIVE_HPACK=1`
  # still works for explicit opt-in.
  #
  # When OFF (env-overridden): `protocol-http2`'s pure-Ruby HPACK
  # Compressor / Decompressor handles everything as in 2.0.0–2.4.x.
  @h2_native_hpack_enabled = @h2_codec_available && resolve_h2_native_hpack_default
  @h2_codec_native = @h2_native_hpack_enabled # back-compat ivar — preserved for codec_native? readers
  # 2.10-G — opt-in connection-setup timing instrumentation. When set,
  # `serve` captures four monotonic timestamps per connection:
  #
  #   t0 — entry to `serve` (post-TLS, post-ALPN — the socket is already
  #        the negotiated h2 SSLSocket by the time the handler sees it)
  #   t1 — `read_connection_preface` returned (server-side SETTINGS
  #        encoded + handed to the framer; client preface fully read)
  #   t2_encode — first stream's HEADERS frame finished encoding (bytes
  #               sit in the writer queue)
  #   t2_wire   — writer fiber finished its first `socket.write` (bytes
  #               on the wire)
  #
  # When the connection's first response completes, the handler emits
  # a single `'h2 first-stream timing'` info line with t0→t1, t1→t2_encode,
  # t2_encode→t2_wire deltas in milliseconds. Off by default (zero hot-path
  # cost when disabled — a single ivar read per stream branch). Used by
  # 2.10-G to root-cause Hyperion's flat ~40 ms first-stream max-latency.
  @h2_timing_enabled = env_flag_enabled?('HYPERION_H2_TIMING')
  record_codec_boot_state
end

Instance Method Details

#codec_available?Boolean

True when the Rust crate loaded successfully, regardless of whether the operator opted in to wiring it into the wire path. Useful for diagnostics/health endpoints that want to surface “native is available but currently disabled”.

Returns:

  • (Boolean)


591
592
593
# File 'lib/hyperion/http2_handler.rb', line 591

def codec_available?
  @h2_codec_available
end

#codec_native?Boolean

Read-only accessor used by tests + diagnostics. true = the ‘Hyperion::H2Codec` Rust extension loaded successfully AND `HYPERION_H2_NATIVE_HPACK=1` is set, so `build_server` will wire the native adapter onto every new connection’s ‘encode_headers` / `decode_headers` boundary. The 2.2.0 default is false (opt-in) — see `#initialize` for the rationale and the bench numbers in CHANGELOG/docs that pinned the default off.

Returns:

  • (Boolean)


583
584
585
# File 'lib/hyperion/http2_handler.rb', line 583

def codec_native?
  @h2_native_hpack_enabled
end

#env_flag_enabled?(name) ⇒ Boolean

Read an env-var flag with the usual truthiness rules (any of 1/true/yes/on, case-insensitive). Anything else → false.

Returns:

  • (Boolean)


515
516
517
518
519
520
# File 'lib/hyperion/http2_handler.rb', line 515

def env_flag_enabled?(name)
  v = ENV[name]
  return false if v.nil? || v.empty?

  %w[1 true yes on].include?(v.downcase)
end

#record_codec_boot_stateObject

2.0.0 Phase 6b: emit a single-shot boot log line per process describing the codec selection. Operators reading the boot log see whether the native HPACK path is in play. Idempotent across multiple Http2Handler constructions in the same process.



543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
# File 'lib/hyperion/http2_handler.rb', line 543

def record_codec_boot_state
  return if Hyperion::Http2Handler.instance_variable_get(:@codec_state_logged)

  Hyperion::Http2Handler.instance_variable_set(:@codec_state_logged, true)
  cglue_active = @h2_native_hpack_enabled && Hyperion::H2Codec.cglue_available?
  mode =
    if @h2_native_hpack_enabled && cglue_active
      'native (Rust v3 / CGlue) — HPACK on hot path, no Fiddle per call'
    elsif @h2_native_hpack_enabled
      'native (Rust v2 / Fiddle) — HPACK on hot path, Fiddle marshalling per call'
    elsif @h2_codec_available
      'fallback (protocol-http2 / pure Ruby HPACK) — native available but opted out via HYPERION_H2_NATIVE_HPACK=off'
    else
      'fallback (protocol-http2 / pure Ruby HPACK) — native unavailable'
    end
  @logger.info do
    {
      message: 'h2 codec selected',
      mode: mode,
      native_available: @h2_codec_available,
      native_enabled: @h2_native_hpack_enabled,
      cglue_active: cglue_active,
      hpack_path: if @h2_native_hpack_enabled
                    cglue_active ? 'native-v3' : 'native-v2'
                  else
                    'pure-ruby'
                  end
    }
  end
  @metrics.increment(:h2_codec_native_selected) if @h2_native_hpack_enabled
  @metrics.increment(:h2_codec_fallback_selected) unless @h2_native_hpack_enabled
end

#resolve_h2_native_hpack_defaultObject

Read an env-var flag with explicit OFF support. Used by ‘HYPERION_H2_NATIVE_HPACK` since 2.5-B flipped the default to ON. Returns true if the env var is unset / empty / explicitly truthy; returns false only when the operator sets it to a truthy-OFF value (0/false/no/off, case-insensitive). Anything else falls back to the default-on behavior so we don’t surprise operators who set typo’d values.



529
530
531
532
533
534
535
536
537
# File 'lib/hyperion/http2_handler.rb', line 529

def resolve_h2_native_hpack_default
  v = ENV['HYPERION_H2_NATIVE_HPACK']
  return true if v.nil? || v.empty?

  lc = v.downcase
  return false if %w[0 false no off].include?(lc)

  true
end

#serve(socket) ⇒ Object



595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
# File 'lib/hyperion/http2_handler.rb', line 595

def serve(socket)
  @metrics.increment(:connections_accepted)
  @metrics.increment(:connections_active)

  # Per-connection outbound coordination. Encoder fibers enqueue bytes;
  # the writer fiber owns the real socket and drains. See class docstring.
  writer_ctx   = WriterContext.new
  send_io      = SendQueueIO.new(socket, writer_ctx)
  framer       = ::Protocol::HTTP2::Framer.new(send_io)
  server       = build_server(framer)

  # 2.10-G — connection entry timestamp. Captured before any framing
  # work so the t0→t1 delta isolates "preface exchange + initial
  # SETTINGS round-trip" from any pre-handler scheduling delay.
  writer_ctx.t0_serve_entry = monotonic_now if @h2_timing_enabled

  task = ::Async::Task.current

  # Spawn the dedicated writer fiber BEFORE the preface exchange.
  # `Server#read_connection_preface` writes the server's SETTINGS frame
  # via the framer; if the writer isn't running, those bytes sit in the
  # queue. Spawning first guarantees they flush as soon as the scheduler
  # ticks, avoiding any pathological deadlock where a client implementation
  # waits for our SETTINGS before sending more frames.
  writer_task = task.async { run_writer_loop(socket, writer_ctx) }

  server.read_connection_preface(initial_settings_payload)
  writer_ctx.t1_preface_done = monotonic_now if @h2_timing_enabled

  # Extract once — the same TCP peer drives every stream on this conn.
  peer_addr = peer_address(socket)

  # Track in-flight per-stream dispatch fibers so we can drain them on
  # connection close.
  stream_tasks = []

  until server.closed?
    ready_ids = []
    server.read_frame do |frame|
      ready_ids << frame.stream_id if frame.stream_id.positive?
    end

    ready_ids.uniq.each do |sid|
      stream = server.streams[sid]
      next unless stream.is_a?(RequestStream)
      next unless stream.request_complete
      next if stream.closed?
      next if stream.instance_variable_get(:@hyperion_dispatched)

      # Mark before spawning so we never dispatch the same stream twice
      # if subsequent frames (e.g. RST_STREAM races) arrive.
      stream.instance_variable_set(:@hyperion_dispatched, true)

      stream_tasks << task.async do
        dispatch_stream(stream, writer_ctx, peer_addr)
      end
    end
  end

  # Drain in-flight stream dispatches before we close the socket.
  stream_tasks.each do |t|
    t.wait
  rescue StandardError
    nil
  end
rescue EOFError, Errno::ECONNRESET, Errno::EPIPE, IOError, OpenSSL::SSL::SSLError
  # Peer disconnect — nothing to do.
rescue ::Protocol::HTTP2::GoawayError, ::Protocol::HTTP2::ProtocolError, ::Protocol::HTTP2::HandshakeError
  # Protocol-level error — protocol-http2 has already emitted GOAWAY.
rescue StandardError => e
  @logger.error do
    {
      message: 'h2 connection error',
      error: e.message,
      error_class: e.class.name,
      backtrace: (e.backtrace || []).first(10).join(' | ')
    }
  end
ensure
  # Coordinated shutdown: flag the writer, signal it, wait for the final
  # drain, then close the real socket. Order matters — closing the
  # socket before the writer drains would discard final RST_STREAM /
  # GOAWAY / END_STREAM frames in the queue.
  if writer_ctx
    writer_ctx.shutdown!
    begin
      writer_task&.wait
    rescue StandardError
      nil
    end
    # 2.10-G — emit one info-level timing line per connection when the
    # opt-in instrumentation is enabled and we collected a full set of
    # samples (a connection that died before serving any stream lacks
    # t2_first_encode / t2_first_wire and gets skipped — there's no
    # first-stream signal to report).
    log_h2_first_stream_timing(writer_ctx) if @h2_timing_enabled
  end
  @metrics.decrement(:connections_active)
  socket.close unless socket.closed?
end