quicsilver 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yml +9 -1
- data/.github/workflows/cibuildgem.yaml +117 -0
- data/.gitignore +8 -1
- data/CHANGELOG.md +82 -0
- data/Gemfile.lock +27 -2
- data/README.md +205 -29
- data/Rakefile +75 -3
- data/benchmarks/baseline.rb +175 -0
- data/benchmarks/compare.rb +398 -0
- data/benchmarks/helpers.rb +138 -64
- data/benchmarks/latency.rb +128 -0
- data/benchmarks/loss.rb +193 -0
- data/benchmarks/mobile.rb +125 -0
- data/docs/connection_identity.md +141 -0
- data/examples/README.md +44 -91
- data/examples/benchmark.rb +191 -0
- data/examples/connection_pool_demo.rb +47 -0
- data/examples/example_helper.rb +69 -0
- data/examples/falcon_middleware.rb +44 -0
- data/examples/feature_demo.rb +125 -0
- data/examples/grpc_style.rb +97 -0
- data/examples/latency_aware_routing.rb +382 -0
- data/examples/lifecycle_drain.rb +264 -0
- data/examples/minimal_http3_server.rb +6 -18
- data/examples/priorities.rb +60 -0
- data/examples/protocol_http_server.rb +31 -0
- data/examples/rack_http3_server.rb +8 -20
- data/examples/rails_feature_test.rb +280 -0
- data/examples/server_stats.rb +117 -0
- data/examples/simple_client_test.rb +5 -5
- data/examples/streaming_sse.rb +33 -0
- data/examples/trailers.rb +69 -0
- data/examples/webtransport_smoke.rb +546 -0
- data/examples/worker_load_balancing.rb +425 -0
- data/ext/quicsilver/extconf.rb +111 -12
- data/ext/quicsilver/include/msquic.h +2226 -0
- data/ext/quicsilver/include/msquic_posix.h +696 -0
- data/ext/quicsilver/include/quic_sal_stub.h +314 -0
- data/ext/quicsilver/msquic_patch.rb +68 -0
- data/ext/quicsilver/patches/reliable-reset.patch +723 -0
- data/ext/quicsilver/patches/stream-final-size.patch +47 -0
- data/ext/quicsilver/quicsilver.c +1646 -189
- data/lib/quicsilver/client/client.rb +546 -88
- data/lib/quicsilver/client/connection_pool.rb +202 -0
- data/lib/quicsilver/client/request.rb +106 -14
- data/lib/quicsilver/errors.rb +39 -0
- data/lib/quicsilver/libmsquic.2.dylib +0 -0
- data/lib/quicsilver/protocol/adapter.rb +196 -0
- data/lib/quicsilver/protocol/capsule.rb +38 -0
- data/lib/quicsilver/protocol/connect_stream_decoder.rb +75 -0
- data/lib/quicsilver/protocol/control_stream_parser.rb +117 -0
- data/lib/quicsilver/protocol/datagram.rb +40 -0
- data/lib/quicsilver/protocol/frame_parser.rb +141 -0
- data/lib/quicsilver/protocol/frame_reader.rb +84 -0
- data/lib/quicsilver/protocol/frames.rb +137 -10
- data/lib/quicsilver/protocol/priority.rb +65 -0
- data/lib/quicsilver/protocol/qpack/decoder.rb +4 -42
- data/lib/quicsilver/protocol/qpack/encoder.rb +49 -21
- data/lib/quicsilver/protocol/qpack/header_block_decoder.rb +16 -1
- data/lib/quicsilver/protocol/qpack/huffman.rb +4 -4
- data/lib/quicsilver/protocol/request_encoder.rb +6 -3
- data/lib/quicsilver/protocol/request_parser.rb +43 -141
- data/lib/quicsilver/protocol/response_encoder.rb +40 -5
- data/lib/quicsilver/protocol/response_parser.rb +26 -128
- data/lib/quicsilver/protocol/stream_input.rb +98 -0
- data/lib/quicsilver/protocol/stream_output.rb +59 -0
- data/lib/quicsilver/protocol/unidirectional_stream.rb +12 -0
- data/lib/quicsilver/protocol/web_transport.rb +214 -0
- data/lib/quicsilver/rack/context.rb +33 -0
- data/lib/quicsilver/response.rb +40 -0
- data/lib/quicsilver/server/rack_adapter.rb +77 -0
- data/lib/quicsilver/server/receive_queue.rb +106 -0
- data/lib/quicsilver/server/request_handler.rb +111 -48
- data/lib/quicsilver/server/request_registry.rb +18 -5
- data/lib/quicsilver/server/scheduler.rb +50 -0
- data/lib/quicsilver/server/schedulers/fiber_scheduler.rb +79 -0
- data/lib/quicsilver/server/schedulers/thread_scheduler.rb +76 -0
- data/lib/quicsilver/server/server.rb +848 -104
- data/lib/quicsilver/server/web_transport_flow_control.rb +270 -0
- data/lib/quicsilver/server/web_transport_manager.rb +281 -0
- data/lib/quicsilver/server/web_transport_registry.rb +40 -0
- data/lib/quicsilver/server/web_transport_session.rb +812 -0
- data/lib/quicsilver/server/web_transport_stream.rb +326 -0
- data/lib/quicsilver/transport/configuration.rb +113 -11
- data/lib/quicsilver/transport/connection.rb +376 -132
- data/lib/quicsilver/transport/connection_stats.rb +53 -0
- data/lib/quicsilver/transport/event_loop.rb +36 -0
- data/lib/quicsilver/transport/inbound_stream.rb +21 -1
- data/lib/quicsilver/transport/stream.rb +18 -3
- data/lib/quicsilver/transport/stream_event.rb +25 -2
- data/lib/quicsilver/transport/stream_id.rb +26 -0
- data/lib/quicsilver/version.rb +1 -1
- data/lib/quicsilver.rb +45 -11
- data/lib/rackup/handler/quicsilver.rb +57 -38
- data/quicsilver.gemspec +17 -2
- metadata +120 -10
- data/benchmarks/concurrent.rb +0 -110
- data/benchmarks/quicsilver_server.rb +0 -46
- data/benchmarks/rails.rb +0 -170
- data/benchmarks/throughput.rb +0 -113
- data/examples/setup_certs.sh +0 -57
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "frame_parser"
|
|
4
|
+
|
|
5
|
+
module Quicsilver
|
|
6
|
+
module Protocol
|
|
7
|
+
# Incrementally yields CONNECT DATA payloads without waiting for whole
|
|
8
|
+
# frames. Unknown frame payloads are skipped as bytes arrive.
|
|
9
|
+
class ConnectStreamDecoder
|
|
10
|
+
def initialize
|
|
11
|
+
@buffer = "".b
|
|
12
|
+
@frame_type = nil
|
|
13
|
+
@frame_remaining = nil
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# A caller may break after a terminal capsule. Unprocessed bytes remain
|
|
17
|
+
# available to buffered? so it can reject data following that capsule.
|
|
18
|
+
def each(data)
|
|
19
|
+
@buffer << data if data
|
|
20
|
+
until @buffer.empty?
|
|
21
|
+
break unless @frame_remaining || read_frame_header
|
|
22
|
+
|
|
23
|
+
chunk = consume_payload
|
|
24
|
+
yield chunk if @frame_type == FRAME_DATA
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def buffered?
|
|
29
|
+
!@buffer.empty?
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def finish!
|
|
33
|
+
if buffered? || @frame_remaining
|
|
34
|
+
raise FrameError.new("Truncated HTTP/3 frame", error_code: H3_FRAME_ERROR)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def clear
|
|
39
|
+
@buffer.clear
|
|
40
|
+
@frame_remaining = nil
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def read_frame_header
|
|
46
|
+
type, type_length = Protocol.decode_varint_str(@buffer, 0)
|
|
47
|
+
return false if type_length == 0
|
|
48
|
+
length, length_length = Protocol.decode_varint_str(@buffer, type_length)
|
|
49
|
+
return false if length_length == 0
|
|
50
|
+
|
|
51
|
+
# RFC 9114 §4.4 forbids known non-DATA frames after CONNECT,
|
|
52
|
+
# including HEADERS trailers; these are connection errors.
|
|
53
|
+
if CONTROL_ONLY_FRAME_SET.key?(type) ||
|
|
54
|
+
HTTP2_RESERVED_FRAME_SET.key?(type) ||
|
|
55
|
+
[FRAME_HEADERS, FRAME_PUSH_PROMISE, FRAME_PRIORITY_UPDATE].include?(type)
|
|
56
|
+
raise FrameError, "Frame not allowed on CONNECT"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
@frame_type = type
|
|
60
|
+
@frame_remaining = length
|
|
61
|
+
@buffer = @buffer.byteslice(type_length + length_length..) || "".b
|
|
62
|
+
true
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def consume_payload
|
|
66
|
+
count = [@frame_remaining, @buffer.bytesize].min
|
|
67
|
+
chunk = @buffer.byteslice(0, count)
|
|
68
|
+
@buffer = @buffer.byteslice(count..) || "".b
|
|
69
|
+
@frame_remaining -= count
|
|
70
|
+
@frame_remaining = nil if @frame_remaining.zero?
|
|
71
|
+
chunk
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Quicsilver
|
|
4
|
+
module Protocol
|
|
5
|
+
# Shared control stream parsing for both server Connection and Client.
|
|
6
|
+
#
|
|
7
|
+
# RFC 9114 §7.2.4: Both endpoints MUST send and process SETTINGS.
|
|
8
|
+
# RFC 9114 §7.2.6: Both endpoints MUST validate incoming GOAWAY.
|
|
9
|
+
#
|
|
10
|
+
# Includer must provide:
|
|
11
|
+
# @settings_received — boolean, initially false
|
|
12
|
+
# @peer_goaway_id — nil initially
|
|
13
|
+
#
|
|
14
|
+
# Includer may override:
|
|
15
|
+
# on_settings_received(settings_hash) — called after SETTINGS parsed
|
|
16
|
+
# on_goaway_received(stream_id) — called after GOAWAY parsed
|
|
17
|
+
# handle_control_frame(type, payload) — called for non-SETTINGS/GOAWAY frames
|
|
18
|
+
module ControlStreamParser
|
|
19
|
+
# RFC 9114 §7.2.4.1 / §11.2.2: HTTP/2 setting identifiers forbidden in HTTP/3
|
|
20
|
+
# 0x00 = SETTINGS_HEADER_TABLE_SIZE (reserved), 0x02-0x05 = various HTTP/2 settings
|
|
21
|
+
# Note: 0x08 (SETTINGS_ENABLE_CONNECT_PROTOCOL) is valid in HTTP/3 per RFC 9220
|
|
22
|
+
HTTP2_SETTINGS = [0x00, 0x02, 0x03, 0x04, 0x05].freeze
|
|
23
|
+
|
|
24
|
+
# Returns the number of bytes consumed. A frame split across receives
|
|
25
|
+
# leaves its bytes unconsumed, and the caller must keep them buffered:
|
|
26
|
+
# control streams never send FIN, so a partial frame is normal.
|
|
27
|
+
def parse_control_frames(data)
|
|
28
|
+
first_frame = !@settings_received
|
|
29
|
+
|
|
30
|
+
Protocol::FrameReader.each(data) do |type, payload|
|
|
31
|
+
if first_frame && type != Protocol::FRAME_SETTINGS
|
|
32
|
+
raise Protocol::FrameError.new("First frame on control stream must be SETTINGS",
|
|
33
|
+
error_code: Protocol::H3_MISSING_SETTINGS)
|
|
34
|
+
end
|
|
35
|
+
first_frame = false
|
|
36
|
+
|
|
37
|
+
case type
|
|
38
|
+
when Protocol::FRAME_SETTINGS
|
|
39
|
+
raise Protocol::FrameError, "Duplicate SETTINGS frame on control stream" if @settings_received
|
|
40
|
+
parse_peer_settings(payload)
|
|
41
|
+
@settings_received = true
|
|
42
|
+
when Protocol::FRAME_GOAWAY
|
|
43
|
+
parse_peer_goaway(payload)
|
|
44
|
+
else
|
|
45
|
+
handle_control_frame(type, payload)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def parse_peer_settings(payload)
|
|
51
|
+
offset = 0
|
|
52
|
+
seen = Set.new
|
|
53
|
+
settings = {}
|
|
54
|
+
|
|
55
|
+
while offset < payload.bytesize
|
|
56
|
+
id, id_len = Protocol.decode_varint(payload.bytes, offset)
|
|
57
|
+
value, value_len = Protocol.decode_varint(payload.bytes, offset + id_len)
|
|
58
|
+
break if id_len == 0 || value_len == 0
|
|
59
|
+
|
|
60
|
+
if HTTP2_SETTINGS.include?(id)
|
|
61
|
+
raise Protocol::FrameError.new("HTTP/2 setting identifier 0x#{id.to_s(16)} not allowed in HTTP/3",
|
|
62
|
+
error_code: Protocol::H3_SETTINGS_ERROR)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
if seen.include?(id)
|
|
66
|
+
raise Protocol::FrameError.new("Duplicate setting identifier 0x#{id.to_s(16)}",
|
|
67
|
+
error_code: Protocol::H3_SETTINGS_ERROR)
|
|
68
|
+
end
|
|
69
|
+
seen.add(id)
|
|
70
|
+
|
|
71
|
+
settings[id] = value
|
|
72
|
+
offset += id_len + value_len
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
on_settings_received(settings)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# RFC 9114 §7.2.6: Validate incoming GOAWAY frame.
|
|
79
|
+
# Stream ID must be a client-initiated bidirectional stream ID (divisible by 4)
|
|
80
|
+
# and must not increase from a previous GOAWAY.
|
|
81
|
+
def parse_peer_goaway(payload)
|
|
82
|
+
stream_id, _ = Protocol.decode_varint(payload.bytes, 0)
|
|
83
|
+
|
|
84
|
+
unless stream_id % 4 == 0
|
|
85
|
+
raise Protocol::FrameError.new(
|
|
86
|
+
"GOAWAY stream ID #{stream_id} is not a client-initiated bidirectional stream ID",
|
|
87
|
+
error_code: Protocol::H3_ID_ERROR)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
if @peer_goaway_id && stream_id > @peer_goaway_id
|
|
91
|
+
raise Protocol::FrameError.new(
|
|
92
|
+
"GOAWAY stream ID #{stream_id} exceeds previous #{@peer_goaway_id}",
|
|
93
|
+
error_code: Protocol::H3_ID_ERROR)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
@peer_goaway_id = stream_id
|
|
97
|
+
on_goaway_received(stream_id)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
private
|
|
101
|
+
|
|
102
|
+
# Override in includer to store settings. Default: no-op.
|
|
103
|
+
def on_settings_received(settings)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Override in includer to react to GOAWAY. Default: no-op.
|
|
107
|
+
def on_goaway_received(stream_id)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Override in includer to handle additional frame types on the control stream.
|
|
111
|
+
# Server handles FORBIDDEN_ON_CONTROL and PRIORITY_UPDATE here.
|
|
112
|
+
# Client ignores unknown frames.
|
|
113
|
+
def handle_control_frame(type, payload)
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Quicsilver
|
|
4
|
+
module Protocol
|
|
5
|
+
# HTTP/3 DATAGRAM payload framing (RFC 9297).
|
|
6
|
+
#
|
|
7
|
+
# The DATAGRAM frame payload starts with a varint quarter stream ID, followed
|
|
8
|
+
# by the application payload. The quarter stream ID is the HTTP/3 stream ID
|
|
9
|
+
# divided by four, matching the client-initiated bidirectional request stream
|
|
10
|
+
# numbering pattern (0, 4, 8, ...).
|
|
11
|
+
module Datagram
|
|
12
|
+
STREAM_ID_DIVISOR = 4
|
|
13
|
+
|
|
14
|
+
class << self
|
|
15
|
+
def encode(stream_id, payload)
|
|
16
|
+
Protocol.encode_varint(quarter_stream_id(stream_id)) + payload.to_s.b
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def decode(datagram)
|
|
20
|
+
quarter_stream_id, prefix_length = Protocol.decode_varint_str(datagram, 0)
|
|
21
|
+
return if prefix_length == 0
|
|
22
|
+
|
|
23
|
+
[stream_id(quarter_stream_id), payload(datagram, prefix_length)]
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def quarter_stream_id(stream_id)
|
|
27
|
+
stream_id / STREAM_ID_DIVISOR
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def stream_id(quarter_stream_id)
|
|
31
|
+
quarter_stream_id * STREAM_ID_DIVISOR
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def payload(datagram, prefix_length)
|
|
35
|
+
datagram.byteslice(prefix_length..-1) || "".b
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "stringio"
|
|
4
|
+
require_relative "frame_reader"
|
|
5
|
+
require_relative "qpack/header_block_decoder"
|
|
6
|
+
|
|
7
|
+
module Quicsilver
|
|
8
|
+
module Protocol
|
|
9
|
+
# Base class for HTTP/3 request and response frame parsing.
|
|
10
|
+
#
|
|
11
|
+
# Handles the shared frame walking loop, HEADERS→DATA→HEADERS ordering,
|
|
12
|
+
# trailer parsing, body accumulation, and size limit enforcement.
|
|
13
|
+
#
|
|
14
|
+
# Subclasses implement:
|
|
15
|
+
# - parse_headers(payload) — decode the first HEADERS frame
|
|
16
|
+
class FrameParser
|
|
17
|
+
# Frame types forbidden on request streams — use hash for O(1) lookup
|
|
18
|
+
# Static-only QPACK decoder (no dynamic table). Used by default.
|
|
19
|
+
# Inject a custom decoder via decoder: kwarg for dynamic QPACK support.
|
|
20
|
+
DEFAULT_DECODER = Qpack::HeaderBlockDecoder.default
|
|
21
|
+
|
|
22
|
+
EMPTY_BODY = StringIO.new("".b).tap { |io| io.set_encoding(Encoding::ASCII_8BIT) }
|
|
23
|
+
|
|
24
|
+
attr_reader :headers, :trailers, :bytes_consumed
|
|
25
|
+
|
|
26
|
+
def frames
|
|
27
|
+
@frames || []
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def initialize(decoder:, max_body_size: nil, max_header_size: nil, max_header_count: nil, max_frame_payload_size: nil)
|
|
31
|
+
@decoder = decoder
|
|
32
|
+
@max_body_size = max_body_size
|
|
33
|
+
@max_header_size = max_header_size
|
|
34
|
+
@max_header_count = max_header_count
|
|
35
|
+
@max_frame_payload_size = max_frame_payload_size
|
|
36
|
+
@headers = {}
|
|
37
|
+
@trailers = {}
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def body
|
|
41
|
+
if @body
|
|
42
|
+
@body.rewind
|
|
43
|
+
@body
|
|
44
|
+
elsif @cached_body_str
|
|
45
|
+
@body = StringIO.new(@cached_body_str)
|
|
46
|
+
@body.set_encoding(Encoding::ASCII_8BIT)
|
|
47
|
+
@body
|
|
48
|
+
else
|
|
49
|
+
EMPTY_BODY
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def parse!
|
|
56
|
+
walk_frames(@data)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def walk_frames(buffer)
|
|
60
|
+
@headers = {}
|
|
61
|
+
@trailers = {}
|
|
62
|
+
@body = nil
|
|
63
|
+
@frames = nil
|
|
64
|
+
@bytes_consumed = 0
|
|
65
|
+
headers_received = false
|
|
66
|
+
trailers_received = false
|
|
67
|
+
|
|
68
|
+
@bytes_consumed = FrameReader.each(buffer) do |type, payload|
|
|
69
|
+
if @max_frame_payload_size && payload.bytesize > @max_frame_payload_size
|
|
70
|
+
raise Protocol::FrameError.new(
|
|
71
|
+
"Frame payload #{payload.bytesize} exceeds limit #{@max_frame_payload_size}",
|
|
72
|
+
error_code: Protocol::H3_EXCESSIVE_LOAD
|
|
73
|
+
)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
(@frames ||= []) << { type: type, length: payload.bytesize, payload: payload }
|
|
77
|
+
|
|
78
|
+
Protocol.reject_unexpected_request_frame!(type)
|
|
79
|
+
|
|
80
|
+
case type
|
|
81
|
+
when 0x01 # HEADERS
|
|
82
|
+
if @max_header_size && payload.bytesize > @max_header_size
|
|
83
|
+
raise Protocol::MessageError, "Header block #{payload.bytesize} exceeds limit #{@max_header_size}"
|
|
84
|
+
end
|
|
85
|
+
if trailers_received
|
|
86
|
+
raise Protocol::FrameError, "HEADERS frame after trailers"
|
|
87
|
+
elsif headers_received
|
|
88
|
+
parse_trailers(payload)
|
|
89
|
+
trailers_received = true
|
|
90
|
+
else
|
|
91
|
+
parse_headers(payload)
|
|
92
|
+
headers_received = true
|
|
93
|
+
end
|
|
94
|
+
when 0x00 # DATA
|
|
95
|
+
raise Protocol::FrameError, "DATA frame before HEADERS" unless headers_received
|
|
96
|
+
raise Protocol::FrameError, "DATA frame after trailers" if trailers_received
|
|
97
|
+
unless @body
|
|
98
|
+
@body = StringIO.new
|
|
99
|
+
@body.set_encoding(Encoding::ASCII_8BIT)
|
|
100
|
+
end
|
|
101
|
+
@body.write(payload)
|
|
102
|
+
if @max_body_size && @body.size > @max_body_size
|
|
103
|
+
raise Protocol::MessageError, "Body size #{@body.size} exceeds limit #{@max_body_size}"
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
@body&.rewind
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# RFC 9110 §5.3: Combine duplicate header values.
|
|
112
|
+
# - set-cookie: join with "\n" (Rack convention, MUST NOT combine with comma)
|
|
113
|
+
# - cookie: join with "; " (RFC 9114 §4.2.1)
|
|
114
|
+
# - all others: join with ", "
|
|
115
|
+
def store_header(name, value)
|
|
116
|
+
if @headers.key?(name)
|
|
117
|
+
separator = case name
|
|
118
|
+
when "set-cookie" then "\n"
|
|
119
|
+
when "cookie" then "; "
|
|
120
|
+
else ", "
|
|
121
|
+
end
|
|
122
|
+
@headers[name] = "#{@headers[name]}#{separator}#{value}"
|
|
123
|
+
else
|
|
124
|
+
if @max_header_count && @headers.size >= @max_header_count
|
|
125
|
+
raise Protocol::MessageError, "Header count exceeds limit #{@max_header_count}"
|
|
126
|
+
end
|
|
127
|
+
@headers[name] = value
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def parse_trailers(payload)
|
|
132
|
+
@decoder.decode(payload) do |name, value|
|
|
133
|
+
if name.start_with?(":")
|
|
134
|
+
raise Protocol::MessageError, "Pseudo-header '#{name}' in trailers"
|
|
135
|
+
end
|
|
136
|
+
@trailers[name] = value
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Quicsilver
|
|
4
|
+
module Protocol
|
|
5
|
+
# Extracts HTTP/3 frames from a byte buffer.
|
|
6
|
+
# Yields (type, payload, offset) per frame. Returns total bytes consumed.
|
|
7
|
+
# Existing callers using |type, payload| are unaffected — offset is ignored.
|
|
8
|
+
#
|
|
9
|
+
# Usage:
|
|
10
|
+
# FrameReader.each(buffer) do |type, payload|
|
|
11
|
+
# case type
|
|
12
|
+
# when FRAME_HEADERS then ...
|
|
13
|
+
# when FRAME_DATA then ...
|
|
14
|
+
# end
|
|
15
|
+
# end
|
|
16
|
+
#
|
|
17
|
+
# With offset (for early exit via throw/catch):
|
|
18
|
+
# strip = FrameReader.skip_while(data) { |type, payload| ... }
|
|
19
|
+
# data = data.byteslice(strip..) if strip > 0
|
|
20
|
+
module FrameReader
|
|
21
|
+
def self.each(buffer)
|
|
22
|
+
offset = 0
|
|
23
|
+
buf_size = buffer.bytesize
|
|
24
|
+
|
|
25
|
+
while offset < buf_size
|
|
26
|
+
break if buf_size - offset < 2
|
|
27
|
+
|
|
28
|
+
# Inline single-byte varint fast path (covers frame types 0x00-0x3F)
|
|
29
|
+
type_byte = buffer.getbyte(offset)
|
|
30
|
+
if type_byte < 0x40
|
|
31
|
+
type = type_byte
|
|
32
|
+
type_len = 1
|
|
33
|
+
else
|
|
34
|
+
type, type_len = Protocol.decode_varint_str(buffer, offset)
|
|
35
|
+
break if type_len == 0
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
len_byte = buffer.getbyte(offset + type_len)
|
|
39
|
+
break unless len_byte
|
|
40
|
+
if len_byte < 0x40
|
|
41
|
+
length = len_byte
|
|
42
|
+
length_len = 1
|
|
43
|
+
else
|
|
44
|
+
length, length_len = Protocol.decode_varint_str(buffer, offset + type_len)
|
|
45
|
+
break if length_len == 0
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
header_len = type_len + length_len
|
|
49
|
+
break if buf_size < offset + header_len + length
|
|
50
|
+
|
|
51
|
+
payload = buffer.byteslice(offset + header_len, length)
|
|
52
|
+
offset += header_len + length
|
|
53
|
+
|
|
54
|
+
yield type, payload, offset
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
offset
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Skip leading frames while the block returns true.
|
|
61
|
+
# Returns the byte offset of the first frame that returned false (or 0).
|
|
62
|
+
#
|
|
63
|
+
# skip = FrameReader.skip_while(data) do |type, payload|
|
|
64
|
+
# type == FRAME_HEADERS && informational?(payload)
|
|
65
|
+
# end
|
|
66
|
+
# data = data.byteslice(skip..) if skip > 0
|
|
67
|
+
def self.skip_while(buffer)
|
|
68
|
+
skip_to = 0
|
|
69
|
+
|
|
70
|
+
catch(:stop) do
|
|
71
|
+
each(buffer) do |type, payload, offset|
|
|
72
|
+
if yield(type, payload)
|
|
73
|
+
skip_to = offset
|
|
74
|
+
else
|
|
75
|
+
throw :stop
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
skip_to
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -10,9 +10,53 @@ module Quicsilver
|
|
|
10
10
|
FRAME_PUSH_PROMISE = 0x05
|
|
11
11
|
FRAME_GOAWAY = 0x07
|
|
12
12
|
FRAME_MAX_PUSH_ID = 0x0d
|
|
13
|
+
FRAME_PRIORITY_UPDATE = 0xF0700 # RFC 9218 — request stream priority update
|
|
14
|
+
|
|
15
|
+
# Settings identifiers
|
|
16
|
+
SETTINGS_QPACK_MAX_TABLE_CAPACITY = 0x01
|
|
17
|
+
SETTINGS_QPACK_BLOCKED_STREAMS = 0x07
|
|
18
|
+
SETTINGS_ENABLE_CONNECT_PROTOCOL = 0x08 # RFC 9220
|
|
19
|
+
SETTINGS_H3_DATAGRAM = 0x33 # RFC 9297
|
|
20
|
+
SETTINGS_ENABLE_WEBTRANSPORT = 0x2b603742 # WebTransport draft-06
|
|
21
|
+
SETTINGS_WT_ENABLED = 0x2c7cf000 # WebTransport over HTTP/3
|
|
22
|
+
SETTINGS_WT_MAX_SESSIONS = 0x14e9cd29 # Legacy WebTransport session limit
|
|
23
|
+
SETTINGS_WT_INITIAL_MAX_DATA = 0x2b61
|
|
24
|
+
SETTINGS_WT_INITIAL_MAX_STREAMS_UNI = 0x2b64
|
|
25
|
+
SETTINGS_WT_INITIAL_MAX_STREAMS_BIDI = 0x2b65
|
|
26
|
+
|
|
27
|
+
# Declaring a non-zero value for any of these is how an endpoint opts into
|
|
28
|
+
# WebTransport session flow control (draft-16 §5.1). It is a promise to
|
|
29
|
+
# implement the whole of §5, so do not advertise one until the capsules
|
|
30
|
+
# and accounting exist.
|
|
31
|
+
# The session flow control window we advertise. 16 MiB leaves room for
|
|
32
|
+
# many children above the 1 MiB each queues, and 100 streams per direction
|
|
33
|
+
# bounds concurrency without being a limit an application trips over.
|
|
34
|
+
# Both are our choice; the draft only defines the mechanism.
|
|
35
|
+
WT_INITIAL_MAX_DATA = 16 * 1024 * 1024
|
|
36
|
+
WT_INITIAL_MAX_STREAMS = 100
|
|
37
|
+
|
|
38
|
+
WT_FLOW_CONTROL_SETTINGS = [
|
|
39
|
+
SETTINGS_WT_INITIAL_MAX_DATA,
|
|
40
|
+
SETTINGS_WT_INITIAL_MAX_STREAMS_UNI,
|
|
41
|
+
SETTINGS_WT_INITIAL_MAX_STREAMS_BIDI
|
|
42
|
+
].freeze
|
|
43
|
+
SETTINGS_MAX_FIELD_SECTION_SIZE = 0x06 # RFC 9114 §7.2.4.1
|
|
13
44
|
|
|
14
45
|
# Frame types forbidden on request streams (RFC 9114 Section 7.2.4, 7.2.6, 7.2.7)
|
|
15
46
|
CONTROL_ONLY_FRAMES = [FRAME_CANCEL_PUSH, FRAME_SETTINGS, FRAME_GOAWAY, FRAME_MAX_PUSH_ID].freeze
|
|
47
|
+
CONTROL_ONLY_FRAME_SET = CONTROL_ONLY_FRAMES.to_h { |type| [type, true] }.freeze
|
|
48
|
+
|
|
49
|
+
# HTTP/2 frame types, reserved in HTTP/3 so that an HTTP/2 frame arriving
|
|
50
|
+
# here is rejected rather than mistaken for an extension (RFC 9114 7.2.8)
|
|
51
|
+
HTTP2_RESERVED_FRAME_SET = {0x02 => true, 0x06 => true, 0x08 => true, 0x09 => true}.freeze
|
|
52
|
+
|
|
53
|
+
# WT_STREAM is registered as a frame type to reserve the code point, but it
|
|
54
|
+
# carries no length and is only legal as the very first bytes of a request
|
|
55
|
+
# stream, where the server classifies it before any frame parsing. Anywhere
|
|
56
|
+
# else it is a connection error (draft-ietf-webtrans-http3-16 §4.3).
|
|
57
|
+
FRAME_WT_STREAM = 0x41
|
|
58
|
+
# HTTP/3 Datagram Error Code (RFC 9297 Section 5.2)
|
|
59
|
+
H3_DATAGRAM_ERROR = 0x33
|
|
16
60
|
|
|
17
61
|
# HTTP/3 Error Codes (RFC 9114 Section 8.1)
|
|
18
62
|
H3_NO_ERROR = 0x100
|
|
@@ -41,15 +85,20 @@ module Quicsilver
|
|
|
41
85
|
# Protocol errors that carry an HTTP/3 error code for CONNECTION_CLOSE / RESET_STREAM.
|
|
42
86
|
# FrameError → connection error (CONNECTION_CLOSE)
|
|
43
87
|
# MessageError → stream error (RESET_STREAM) on request streams
|
|
44
|
-
class FrameError <
|
|
88
|
+
class FrameError < Quicsilver::Error
|
|
45
89
|
attr_reader :error_code
|
|
90
|
+
# The default suits a frame that is valid but arrived somewhere it is not
|
|
91
|
+
# allowed (RFC 9114 4.1, 7.2.4), which is the common case. Anything else
|
|
92
|
+
# must pass its own code: callers that leave this implicit are asserting
|
|
93
|
+
# H3_FRAME_UNEXPECTED is correct, and the handler turns that into a
|
|
94
|
+
# connection error.
|
|
46
95
|
def initialize(msg = nil, error_code: H3_FRAME_UNEXPECTED)
|
|
47
96
|
@error_code = error_code
|
|
48
97
|
super(msg)
|
|
49
98
|
end
|
|
50
99
|
end
|
|
51
100
|
|
|
52
|
-
class MessageError <
|
|
101
|
+
class MessageError < Quicsilver::Error
|
|
53
102
|
attr_reader :error_code
|
|
54
103
|
def initialize(msg = nil, error_code: H3_MESSAGE_ERROR)
|
|
55
104
|
@error_code = error_code
|
|
@@ -184,6 +233,27 @@ module Quicsilver
|
|
|
184
233
|
MAX_STREAM_ID = (2**62) - 4
|
|
185
234
|
|
|
186
235
|
class << self
|
|
236
|
+
# Frame types a request stream may carry. Unknown types are extensions and
|
|
237
|
+
# must be ignored (RFC 9114 9), so only the two reserved sets raise.
|
|
238
|
+
# Both the buffered parser and the streaming body reader validate here.
|
|
239
|
+
def reject_unexpected_request_frame!(type)
|
|
240
|
+
if CONTROL_ONLY_FRAME_SET.key?(type)
|
|
241
|
+
raise FrameError, "Frame type 0x#{type.to_s(16)} not allowed on request streams"
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
if HTTP2_RESERVED_FRAME_SET.key?(type)
|
|
245
|
+
raise FrameError.new("Reserved HTTP/2 frame type 0x#{type.to_s(16)} not allowed in HTTP/3",
|
|
246
|
+
error_code: H3_FRAME_UNEXPECTED)
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# Reaching here means the stream already parsed as HTTP/3, so this
|
|
250
|
+
# WT_STREAM is not at the front of the stream (draft-16 §4.3).
|
|
251
|
+
if type == FRAME_WT_STREAM
|
|
252
|
+
raise FrameError.new("WT_STREAM is only allowed as the first bytes of a request stream",
|
|
253
|
+
error_code: H3_FRAME_ERROR)
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
|
|
187
257
|
# Precomputed varint encodings for single-byte values (0-63)
|
|
188
258
|
VARINT_SMALL = Array.new(64) { |v| [v].pack('C').freeze }.freeze
|
|
189
259
|
VARINT_MED = Array.new(16384 - 64) { |i| v = i + 64; [0x40 | (v >> 8), v & 0xFF].pack('C*').freeze }.freeze
|
|
@@ -230,19 +300,76 @@ module Quicsilver
|
|
|
230
300
|
frame_type + frame_length + payload
|
|
231
301
|
end
|
|
232
302
|
|
|
233
|
-
#
|
|
234
|
-
def
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
303
|
+
# Generate a random GREASE ID (RFC 9114 §7.2.4.1): 0x1f * N + 0x21
|
|
304
|
+
def grease_id
|
|
305
|
+
31 * rand(0..20) + 33
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
# Has this endpoint declared intent to use session flow control? Both
|
|
309
|
+
# endpoints must, for it to be enabled.
|
|
310
|
+
def wt_flow_control_opt_in?(settings)
|
|
311
|
+
WT_FLOW_CONTROL_SETTINGS.any? { |id| settings[id].to_i > 0 }
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
# The SETTINGS we advertise. Kept separate from the frame so a connection
|
|
315
|
+
# can ask what it promised the peer without re-parsing the wire.
|
|
316
|
+
#
|
|
317
|
+
# @param max_field_section_size [Integer, nil] Advertise SETTINGS_MAX_FIELD_SECTION_SIZE (0x06)
|
|
318
|
+
# to the peer (RFC 9114 §4.2.2 / §7.2.4.1). nil = don't advertise.
|
|
319
|
+
def control_stream_settings(max_field_section_size: nil, datagram_receive_enabled: true)
|
|
320
|
+
settings = {
|
|
321
|
+
SETTINGS_QPACK_MAX_TABLE_CAPACITY => 0,
|
|
322
|
+
SETTINGS_QPACK_BLOCKED_STREAMS => 0,
|
|
323
|
+
SETTINGS_ENABLE_CONNECT_PROTOCOL => 1,
|
|
324
|
+
SETTINGS_H3_DATAGRAM => datagram_receive_enabled ? 1 : 0,
|
|
325
|
+
SETTINGS_ENABLE_WEBTRANSPORT => 1,
|
|
326
|
+
SETTINGS_WT_ENABLED => 1,
|
|
327
|
+
# Declaring these enables session flow control when the peer also
|
|
328
|
+
# does (§5.1): the initial limits a session grants its peer, raised
|
|
329
|
+
# by capsules as we consume data and close streams.
|
|
330
|
+
#
|
|
331
|
+
# The data window sits well above the 1 MiB a single child stream
|
|
332
|
+
# queues, so one stream cannot exhaust a session's credit, in the
|
|
333
|
+
# same spirit as QUIC keeping its connection window above any one
|
|
334
|
+
# stream's. Both values are ours to choose; the draft defines only
|
|
335
|
+
# the mechanism.
|
|
336
|
+
SETTINGS_WT_INITIAL_MAX_DATA => WT_INITIAL_MAX_DATA,
|
|
337
|
+
SETTINGS_WT_INITIAL_MAX_STREAMS_BIDI => WT_INITIAL_MAX_STREAMS,
|
|
338
|
+
SETTINGS_WT_INITIAL_MAX_STREAMS_UNI => WT_INITIAL_MAX_STREAMS,
|
|
339
|
+
# Still one session per connection. Flow control now permits more
|
|
340
|
+
# (§5.1), but sharing a connection between sessions is a separate
|
|
341
|
+
# change.
|
|
342
|
+
SETTINGS_WT_MAX_SESSIONS => 1
|
|
343
|
+
}
|
|
344
|
+
settings[SETTINGS_MAX_FIELD_SECTION_SIZE] = max_field_section_size if max_field_section_size
|
|
345
|
+
settings
|
|
346
|
+
end
|
|
240
347
|
|
|
241
|
-
|
|
348
|
+
# Build control stream data.
|
|
349
|
+
def build_control_stream(max_field_section_size: nil, datagram_receive_enabled: true)
|
|
350
|
+
stream_type = [0x00].pack("C") # Control stream type
|
|
351
|
+
settings_hash = control_stream_settings(
|
|
352
|
+
max_field_section_size: max_field_section_size,
|
|
353
|
+
datagram_receive_enabled: datagram_receive_enabled
|
|
354
|
+
)
|
|
355
|
+
settings_hash[grease_id] = grease_id # GREASE setting (RFC 9114 §7.2.4.1)
|
|
356
|
+
|
|
357
|
+
stream_type + build_settings_frame(settings_hash)
|
|
242
358
|
end
|
|
243
359
|
|
|
244
360
|
# Build GOAWAY frame (RFC 9114 Section 7.2.6)
|
|
245
361
|
# stream_id: The last client-initiated bidirectional stream ID the server will process
|
|
362
|
+
# Build an HTTP/3 frame: varint type + varint length + payload.
|
|
363
|
+
def build_frame(type, payload)
|
|
364
|
+
payload = payload.to_s.b
|
|
365
|
+
encode_varint(type) + encode_varint(payload.bytesize) + payload
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
# Build a HEADERS frame from key-value pairs via QPACK.
|
|
369
|
+
def build_headers_frame(pairs, encoder: Qpack::Encoder.new)
|
|
370
|
+
build_frame(FRAME_HEADERS, encoder.encode(pairs))
|
|
371
|
+
end
|
|
372
|
+
|
|
246
373
|
def build_goaway_frame(stream_id)
|
|
247
374
|
frame_type = encode_varint(FRAME_GOAWAY)
|
|
248
375
|
payload = encode_varint(stream_id)
|