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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +4570 -0
- data/README.md +212 -13
- data/ext/hyperion_h2_codec/Cargo.lock +7 -0
- data/ext/hyperion_h2_codec/Cargo.toml +33 -0
- data/ext/hyperion_h2_codec/extconf.rb +73 -0
- data/ext/hyperion_h2_codec/src/frames.rs +140 -0
- data/ext/hyperion_h2_codec/src/hpack/huffman.rs +161 -0
- data/ext/hyperion_h2_codec/src/hpack.rs +457 -0
- data/ext/hyperion_h2_codec/src/lib.rs +296 -0
- data/ext/hyperion_http/extconf.rb +28 -0
- data/ext/hyperion_http/h2_codec_glue.c +408 -0
- data/ext/hyperion_http/page_cache.c +1125 -0
- data/ext/hyperion_http/parser.c +473 -38
- data/ext/hyperion_http/sendfile.c +982 -0
- data/ext/hyperion_http/websocket.c +493 -0
- data/ext/hyperion_io_uring/Cargo.lock +33 -0
- data/ext/hyperion_io_uring/Cargo.toml +34 -0
- data/ext/hyperion_io_uring/extconf.rb +74 -0
- data/ext/hyperion_io_uring/src/lib.rs +316 -0
- data/lib/hyperion/adapter/rack.rb +370 -42
- data/lib/hyperion/admin_listener.rb +207 -0
- data/lib/hyperion/admin_middleware.rb +36 -7
- data/lib/hyperion/cli.rb +310 -11
- data/lib/hyperion/config.rb +440 -14
- data/lib/hyperion/connection.rb +679 -22
- data/lib/hyperion/deprecations.rb +81 -0
- data/lib/hyperion/dispatch_mode.rb +165 -0
- data/lib/hyperion/fiber_local.rb +75 -13
- data/lib/hyperion/h2_admission.rb +77 -0
- data/lib/hyperion/h2_codec.rb +452 -0
- data/lib/hyperion/http/page_cache.rb +122 -0
- data/lib/hyperion/http/sendfile.rb +696 -0
- data/lib/hyperion/http2/native_hpack_adapter.rb +70 -0
- data/lib/hyperion/http2_handler.rb +368 -9
- data/lib/hyperion/io_uring.rb +317 -0
- data/lib/hyperion/lint_wrapper_pool.rb +126 -0
- data/lib/hyperion/master.rb +96 -9
- data/lib/hyperion/metrics/path_templater.rb +68 -0
- data/lib/hyperion/metrics.rb +256 -0
- data/lib/hyperion/prometheus_exporter.rb +150 -0
- data/lib/hyperion/request.rb +13 -0
- data/lib/hyperion/response_writer.rb +477 -16
- data/lib/hyperion/runtime.rb +195 -0
- data/lib/hyperion/server/route_table.rb +179 -0
- data/lib/hyperion/server.rb +519 -55
- data/lib/hyperion/static_preload.rb +133 -0
- data/lib/hyperion/thread_pool.rb +61 -7
- data/lib/hyperion/tls.rb +343 -1
- data/lib/hyperion/version.rb +1 -1
- data/lib/hyperion/websocket/close_codes.rb +71 -0
- data/lib/hyperion/websocket/connection.rb +876 -0
- data/lib/hyperion/websocket/frame.rb +356 -0
- data/lib/hyperion/websocket/handshake.rb +525 -0
- data/lib/hyperion/worker.rb +111 -9
- data/lib/hyperion.rb +137 -3
- 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
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
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
|
-
|
|
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
|
-
|
|
659
|
-
|
|
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)
|