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,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Quicsilver
|
|
4
|
+
module Protocol
|
|
5
|
+
# HTTP Extensible Priorities (RFC 9218).
|
|
6
|
+
#
|
|
7
|
+
# Parses the `priority` header from HTTP requests. Urgency ranges from
|
|
8
|
+
# 0 (highest) to 7 (lowest), defaulting to 3. Incremental indicates
|
|
9
|
+
# whether partial data is useful to the client (e.g. progressive images).
|
|
10
|
+
#
|
|
11
|
+
# Usage:
|
|
12
|
+
# priority = Priority.parse("u=0, i")
|
|
13
|
+
# priority.urgency # => 0
|
|
14
|
+
# priority.incremental # => true
|
|
15
|
+
#
|
|
16
|
+
# priority = Priority.parse(nil) # default
|
|
17
|
+
# priority.urgency # => 3
|
|
18
|
+
# priority.incremental # => false
|
|
19
|
+
class Priority
|
|
20
|
+
DEFAULT_URGENCY = 3
|
|
21
|
+
MIN_URGENCY = 0
|
|
22
|
+
MAX_URGENCY = 7
|
|
23
|
+
|
|
24
|
+
attr_reader :urgency, :incremental
|
|
25
|
+
|
|
26
|
+
def initialize(urgency: DEFAULT_URGENCY, incremental: false)
|
|
27
|
+
@urgency = urgency.clamp(MIN_URGENCY, MAX_URGENCY)
|
|
28
|
+
@incremental = incremental
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Parse a priority header value (RFC 9218 §4, Structured Field Values).
|
|
32
|
+
# Returns a Priority with defaults for missing or invalid values.
|
|
33
|
+
# RFC 9218 §4: Encode as Structured Field Value.
|
|
34
|
+
# e.g. "u=0, i" or "u=5" or "u=3" (default)
|
|
35
|
+
def to_s
|
|
36
|
+
parts = []
|
|
37
|
+
parts << "u=#{@urgency}" if @urgency != DEFAULT_URGENCY
|
|
38
|
+
parts << "i" if @incremental
|
|
39
|
+
parts.empty? ? "u=#{DEFAULT_URGENCY}" : parts.join(", ")
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def self.parse(value)
|
|
43
|
+
return new unless value && !value.empty?
|
|
44
|
+
|
|
45
|
+
urgency = DEFAULT_URGENCY
|
|
46
|
+
incremental = false
|
|
47
|
+
|
|
48
|
+
value.split(",").each do |param|
|
|
49
|
+
param = param.strip
|
|
50
|
+
if param.start_with?("u=")
|
|
51
|
+
urgency = param[2..].to_i
|
|
52
|
+
elsif param == "i"
|
|
53
|
+
incremental = true
|
|
54
|
+
elsif param == "i=?0"
|
|
55
|
+
incremental = false
|
|
56
|
+
elsif param == "i=?1"
|
|
57
|
+
incremental = true
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
new(urgency: urgency, incremental: incremental)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -38,42 +38,12 @@ module Quicsilver
|
|
|
38
38
|
[str, len_bytes + length]
|
|
39
39
|
end
|
|
40
40
|
|
|
41
|
-
#
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
DQS_CACHE_MAX = 128
|
|
45
|
-
|
|
46
|
-
# 2-slot last-result cache for decode_qpack_string
|
|
47
|
-
DQS_LAST_A = [nil, nil, nil] # [bytes, offset, result]
|
|
48
|
-
DQS_LAST_B = [nil, nil, nil]
|
|
49
|
-
|
|
41
|
+
# Byte-array variant. Results are not cached: the input is mutable, so
|
|
42
|
+
# keying on identity or object_id returns a stale decode after an
|
|
43
|
+
# in-place edit. Production decoding uses the String variant above.
|
|
50
44
|
def decode_qpack_string(bytes, offset)
|
|
51
|
-
# 2-slot equal? fast path (covers alternating-object patterns)
|
|
52
|
-
return DQS_LAST_A[2] if bytes.equal?(DQS_LAST_A[0]) && offset == DQS_LAST_A[1]
|
|
53
|
-
return DQS_LAST_B[2] if bytes.equal?(DQS_LAST_B[0]) && offset == DQS_LAST_B[1]
|
|
54
|
-
|
|
55
|
-
# Object-id cache
|
|
56
|
-
oid_key = (bytes.object_id << 16) | offset
|
|
57
|
-
cached = DQS_OID_CACHE[oid_key]
|
|
58
|
-
if cached
|
|
59
|
-
# Rotate 2-slot cache
|
|
60
|
-
DQS_LAST_B[0], DQS_LAST_B[1], DQS_LAST_B[2] = DQS_LAST_A[0], DQS_LAST_A[1], DQS_LAST_A[2]
|
|
61
|
-
DQS_LAST_A[0], DQS_LAST_A[1], DQS_LAST_A[2] = bytes, offset, cached
|
|
62
|
-
return cached
|
|
63
|
-
end
|
|
64
|
-
|
|
65
|
-
# Dispatch to string variant if given a String
|
|
66
45
|
return decode_qpack_string_from_str(bytes, offset) if bytes.is_a?(String)
|
|
67
46
|
|
|
68
|
-
# Content-based cache for offset=0
|
|
69
|
-
if offset == 0
|
|
70
|
-
cached = DQS_CACHE[bytes]
|
|
71
|
-
if cached
|
|
72
|
-
DQS_OID_CACHE[oid_key] = cached
|
|
73
|
-
return cached
|
|
74
|
-
end
|
|
75
|
-
end
|
|
76
|
-
|
|
77
47
|
first = bytes[offset]
|
|
78
48
|
huffman = (first & 0x80) != 0
|
|
79
49
|
|
|
@@ -100,15 +70,7 @@ module Quicsilver
|
|
|
100
70
|
raw
|
|
101
71
|
end
|
|
102
72
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
# Cache for offset=0 (common case: standalone decode)
|
|
106
|
-
if offset == 0 && DQS_CACHE.size < DQS_CACHE_MAX
|
|
107
|
-
DQS_CACHE[bytes.frozen? ? bytes : bytes.dup.freeze] = result
|
|
108
|
-
end
|
|
109
|
-
DQS_OID_CACHE[oid_key] = result if DQS_OID_CACHE.size < DQS_CACHE_MAX
|
|
110
|
-
|
|
111
|
-
result
|
|
73
|
+
[str, len_bytes + length].freeze
|
|
112
74
|
end
|
|
113
75
|
|
|
114
76
|
# String-based prefix integer decoding
|
|
@@ -26,37 +26,27 @@ module Quicsilver
|
|
|
26
26
|
@huffman = huffman
|
|
27
27
|
@field_cache = {}
|
|
28
28
|
@block_cache = {}
|
|
29
|
-
@oid_cache = {}
|
|
30
29
|
end
|
|
31
30
|
|
|
31
|
+
# Caches are keyed by header content, never by identity or object_id:
|
|
32
|
+
# callers may reuse and mutate the same array, and an identity hit
|
|
33
|
+
# would return the previous encoding.
|
|
34
|
+
#
|
|
35
|
+
# Caching a whole field block is only sound because encoding is
|
|
36
|
+
# static-table-only: Required Insert Count is always zero, so the bytes
|
|
37
|
+
# never depend on connection state. Implementing the dynamic table
|
|
38
|
+
# would invalidate @block_cache, since indices are relative to a Base.
|
|
32
39
|
def encode(headers)
|
|
33
|
-
# Fastest path: exact same object as last call
|
|
34
|
-
return @last_result if headers.equal?(@last_headers)
|
|
35
|
-
|
|
36
|
-
# Fast path: check object_id cache (same array object reused)
|
|
37
|
-
oid = headers.object_id
|
|
38
|
-
cached = @oid_cache[oid]
|
|
39
|
-
if cached
|
|
40
|
-
@last_headers = headers
|
|
41
|
-
@last_result = cached
|
|
42
|
-
return cached
|
|
43
|
-
end
|
|
44
|
-
|
|
45
40
|
if headers.is_a?(Array) && headers.size <= 16
|
|
46
41
|
# Content-based caching for small header sets
|
|
47
42
|
block_key = headers.map { |n, v| "#{n}\0#{v}" }.join("\x01")
|
|
48
43
|
cached_block = @block_cache[block_key]
|
|
49
|
-
if cached_block
|
|
50
|
-
@oid_cache[oid] = cached_block if @oid_cache.size < BLOCK_CACHE_MAX
|
|
51
|
-
return cached_block
|
|
52
|
-
end
|
|
44
|
+
return cached_block if cached_block
|
|
53
45
|
|
|
54
|
-
|
|
55
|
-
result_frozen = result.freeze
|
|
46
|
+
result_frozen = encode_fields(headers).freeze
|
|
56
47
|
if @block_cache.size < BLOCK_CACHE_MAX
|
|
57
48
|
@block_cache[block_key.freeze] = result_frozen
|
|
58
49
|
end
|
|
59
|
-
@oid_cache[oid] = result_frozen if @oid_cache.size < BLOCK_CACHE_MAX
|
|
60
50
|
return result_frozen
|
|
61
51
|
end
|
|
62
52
|
|
|
@@ -71,7 +61,7 @@ module Quicsilver
|
|
|
71
61
|
name = name.to_s
|
|
72
62
|
value = value.to_s
|
|
73
63
|
# Downcase only if needed (most HTTP/3 headers are already lowercase)
|
|
74
|
-
name = name.downcase if name
|
|
64
|
+
name = name.downcase if name.match?(/[A-Z]/)
|
|
75
65
|
|
|
76
66
|
cache_key = "#{name}\0#{value}"
|
|
77
67
|
|
|
@@ -166,8 +156,46 @@ module Quicsilver
|
|
|
166
156
|
out << value_b
|
|
167
157
|
end
|
|
168
158
|
|
|
159
|
+
# Pre-computed prefix integer tables for hot encode paths.
|
|
160
|
+
# Key: [prefix_bits, pattern] => Array of frozen encoded strings indexed by value.
|
|
161
|
+
# Covers all values up to max_prefix (single-byte) and a range beyond.
|
|
162
|
+
PREFIXED_INT_CACHE = {}
|
|
163
|
+
[
|
|
164
|
+
[6, 0xC0], # Indexed Field Line
|
|
165
|
+
[4, 0x50], # Literal with Name Reference (static)
|
|
166
|
+
[7, 0x80], # Huffman string length
|
|
167
|
+
[7, 0x00], # Raw string length
|
|
168
|
+
[3, 0x28], # Huffman literal name length
|
|
169
|
+
[3, 0x20], # Raw literal name length
|
|
170
|
+
].each do |prefix_bits, pattern|
|
|
171
|
+
max_prefix = (1 << prefix_bits) - 1
|
|
172
|
+
# Pre-compute single-byte range + a bit beyond for multi-byte
|
|
173
|
+
limit = [max_prefix + 64, 256].min
|
|
174
|
+
table = Array.new(limit) do |value|
|
|
175
|
+
if value < max_prefix
|
|
176
|
+
(pattern | value).chr(Encoding::BINARY).freeze
|
|
177
|
+
else
|
|
178
|
+
buf = (pattern | max_prefix).chr(Encoding::BINARY)
|
|
179
|
+
v = value - max_prefix
|
|
180
|
+
while v >= 128
|
|
181
|
+
buf << ((v & 0x7F) | 0x80).chr(Encoding::BINARY)
|
|
182
|
+
v >>= 7
|
|
183
|
+
end
|
|
184
|
+
buf << v.chr(Encoding::BINARY)
|
|
185
|
+
buf.freeze
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
PREFIXED_INT_CACHE[[prefix_bits, pattern]] = table.freeze
|
|
189
|
+
end
|
|
190
|
+
PREFIXED_INT_CACHE.freeze
|
|
191
|
+
|
|
169
192
|
# RFC 7541 prefix integer encoding
|
|
170
193
|
def encode_prefixed_int(value, prefix_bits, pattern)
|
|
194
|
+
table = PREFIXED_INT_CACHE[[prefix_bits, pattern]]
|
|
195
|
+
if table && value < table.size
|
|
196
|
+
return table[value]
|
|
197
|
+
end
|
|
198
|
+
|
|
171
199
|
max_prefix = (1 << prefix_bits) - 1
|
|
172
200
|
|
|
173
201
|
if value < max_prefix
|
|
@@ -51,7 +51,22 @@ module Quicsilver
|
|
|
51
51
|
end
|
|
52
52
|
|
|
53
53
|
headers = []
|
|
54
|
-
|
|
54
|
+
|
|
55
|
+
# Decode Required Insert Count (RFC 9204 §4.5.1) — 8-bit prefix integer
|
|
56
|
+
required_insert_count, ric_bytes = decode_prefix_integer_str(payload, 0, 8, 0x00)
|
|
57
|
+
offset = ric_bytes
|
|
58
|
+
|
|
59
|
+
# Decode Delta Base (RFC 9204 §4.5.1) — 7-bit prefix integer with sign bit
|
|
60
|
+
delta_base, db_bytes = decode_prefix_integer_str(payload, offset, 7, 0x80)
|
|
61
|
+
offset += db_bytes
|
|
62
|
+
|
|
63
|
+
# Static-only mode: Required Insert Count and Delta Base must be 0
|
|
64
|
+
if required_insert_count != 0 || delta_base != 0
|
|
65
|
+
raise Protocol::FrameError.new(
|
|
66
|
+
"Dynamic QPACK table not supported (required_insert_count=#{required_insert_count}, delta_base=#{delta_base})",
|
|
67
|
+
error_code: Protocol::QPACK_DECOMPRESSION_FAILED
|
|
68
|
+
)
|
|
69
|
+
end
|
|
55
70
|
|
|
56
71
|
while offset < payload.bytesize
|
|
57
72
|
byte = payload.getbyte(offset)
|
|
@@ -364,8 +364,8 @@ module Quicsilver
|
|
|
364
364
|
|
|
365
365
|
result = encode_uncached(data)
|
|
366
366
|
|
|
367
|
-
# Cache
|
|
368
|
-
if
|
|
367
|
+
# Cache encoded results (frozen) — includes large headers like CSP
|
|
368
|
+
if HUFF_ENCODE_CACHE.size < CACHE_MAX
|
|
369
369
|
HUFF_ENCODE_MUTEX.synchronize do
|
|
370
370
|
HUFF_ENCODE_CACHE[data.frozen? ? data : data.dup.freeze] = result.freeze if HUFF_ENCODE_CACHE.size < CACHE_MAX
|
|
371
371
|
end
|
|
@@ -410,8 +410,8 @@ module Quicsilver
|
|
|
410
410
|
|
|
411
411
|
result = decode_uncached(encoded_data)
|
|
412
412
|
|
|
413
|
-
# Cache
|
|
414
|
-
if
|
|
413
|
+
# Cache decoded results (frozen)
|
|
414
|
+
if result && HUFF_DECODE_CACHE.size < CACHE_MAX
|
|
415
415
|
HUFF_DECODE_MUTEX.synchronize do
|
|
416
416
|
key = encoded_data.frozen? ? encoded_data : encoded_data.dup.freeze
|
|
417
417
|
HUFF_DECODE_CACHE[key] = result.freeze if HUFF_DECODE_CACHE.size < CACHE_MAX
|
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
module Quicsilver
|
|
4
4
|
module Protocol
|
|
5
5
|
class RequestEncoder
|
|
6
|
-
def initialize(method:, path:, scheme: "https", authority: "localhost:4433", headers: {}, body: nil, encoder: Qpack::Encoder.new)
|
|
6
|
+
def initialize(method:, path:, scheme: "https", authority: "localhost:4433", headers: {}, body: nil, priority: nil, encoder: Qpack::Encoder.new)
|
|
7
|
+
@priority = priority
|
|
7
8
|
@method = method.upcase
|
|
8
9
|
@path = path
|
|
9
10
|
@scheme = scheme
|
|
@@ -36,11 +37,13 @@ module Quicsilver
|
|
|
36
37
|
headers << [":authority", @authority]
|
|
37
38
|
headers << [":path", @path]
|
|
38
39
|
end
|
|
39
|
-
headers + @headers.map { |k, v| [k.to_s, v.to_s] }
|
|
40
|
+
pairs = headers + @headers.map { |k, v| [k.to_s, v.to_s] }
|
|
41
|
+
pairs << ["priority", @priority.to_s] if @priority
|
|
42
|
+
pairs
|
|
40
43
|
end
|
|
41
44
|
|
|
42
45
|
def build_frame(type, payload)
|
|
43
|
-
Protocol.
|
|
46
|
+
Protocol.build_frame(type, payload)
|
|
44
47
|
end
|
|
45
48
|
end
|
|
46
49
|
end
|
|
@@ -1,17 +1,20 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
require_relative "
|
|
3
|
+
require_relative "frame_parser"
|
|
4
|
+
require_relative "priority"
|
|
5
5
|
|
|
6
6
|
module Quicsilver
|
|
7
7
|
module Protocol
|
|
8
|
-
class RequestParser
|
|
9
|
-
attr_reader :headers
|
|
8
|
+
class RequestParser < FrameParser
|
|
10
9
|
|
|
11
|
-
|
|
12
|
-
|
|
10
|
+
# The parsed priority from the `priority` header (RFC 9218).
|
|
11
|
+
# Returns a Priority with defaults if no header present.
|
|
12
|
+
def priority
|
|
13
|
+
@priority ||= Priority.parse(@headers["priority"])
|
|
13
14
|
end
|
|
14
15
|
|
|
16
|
+
METHOD_CONNECT = "CONNECT"
|
|
17
|
+
|
|
15
18
|
# Known HTTP/3 request pseudo-headers (RFC 9114 §4.3.1)
|
|
16
19
|
VALID_PSEUDO_HEADERS = %w[:method :scheme :authority :path :protocol].freeze
|
|
17
20
|
VALID_PSEUDO_SET = VALID_PSEUDO_HEADERS.each_with_object({}) { |h, s| s[h] = true }.freeze
|
|
@@ -20,35 +23,26 @@ module Quicsilver
|
|
|
20
23
|
FORBIDDEN_HEADERS = %w[connection transfer-encoding keep-alive upgrade proxy-connection te].freeze
|
|
21
24
|
FORBIDDEN_SET = FORBIDDEN_HEADERS.each_with_object({}) { |h, s| s[h] = true }.freeze
|
|
22
25
|
|
|
23
|
-
# Frame types forbidden on request streams — use Set-like hash for O(1) lookup
|
|
24
|
-
CONTROL_ONLY_SET = Protocol::CONTROL_ONLY_FRAMES.each_with_object({}) { |f, h| h[f] = true }.freeze
|
|
25
|
-
|
|
26
26
|
# Cache for validated header results: payload → headers hash
|
|
27
27
|
# Only used when no custom limits are set (max_header_count, max_header_size)
|
|
28
28
|
HEADERS_CACHE = {}
|
|
29
29
|
HEADERS_CACHE_MAX = 256
|
|
30
30
|
|
|
31
|
-
DEFAULT_DECODER = Qpack::HeaderBlockDecoder.default
|
|
32
|
-
|
|
33
31
|
def initialize(data, **opts)
|
|
32
|
+
decoder = opts.delete(:decoder) || DEFAULT_DECODER
|
|
33
|
+
super(decoder: decoder, max_body_size: opts[:max_body_size],
|
|
34
|
+
max_header_size: opts[:max_header_size],
|
|
35
|
+
max_header_count: opts[:max_header_count],
|
|
36
|
+
max_frame_payload_size: opts[:max_frame_payload_size])
|
|
34
37
|
@data = data
|
|
35
|
-
|
|
36
|
-
@decoder = DEFAULT_DECODER
|
|
37
|
-
@use_parse_cache = true
|
|
38
|
-
else
|
|
39
|
-
@decoder = opts[:decoder] || DEFAULT_DECODER
|
|
40
|
-
@max_body_size = opts[:max_body_size]
|
|
41
|
-
@max_header_size = opts[:max_header_size]
|
|
42
|
-
@max_header_count = opts[:max_header_count]
|
|
43
|
-
@max_frame_payload_size = opts[:max_frame_payload_size]
|
|
44
|
-
@use_parse_cache = @decoder.equal?(DEFAULT_DECODER) && !@max_body_size && !@max_header_size && !@max_header_count && !@max_frame_payload_size
|
|
45
|
-
end
|
|
38
|
+
@use_parse_cache = @decoder.equal?(DEFAULT_DECODER) && !@max_body_size && !@max_header_size && !@max_header_count && !@max_frame_payload_size
|
|
46
39
|
end
|
|
47
40
|
|
|
48
41
|
# Reset parser with new data for object reuse (avoids allocation overhead)
|
|
49
42
|
def reset(data)
|
|
50
43
|
@data = data
|
|
51
|
-
@headers =
|
|
44
|
+
@headers = {}
|
|
45
|
+
@trailers = {}
|
|
52
46
|
@frames = nil
|
|
53
47
|
@body = nil
|
|
54
48
|
@cached_body_str = nil
|
|
@@ -76,7 +70,8 @@ module Quicsilver
|
|
|
76
70
|
return
|
|
77
71
|
end
|
|
78
72
|
end
|
|
79
|
-
@headers =
|
|
73
|
+
@headers = {}
|
|
74
|
+
@trailers = {}
|
|
80
75
|
@frames = nil
|
|
81
76
|
@body = nil
|
|
82
77
|
@cached_body_str = nil
|
|
@@ -84,20 +79,6 @@ module Quicsilver
|
|
|
84
79
|
cache_result if @use_parse_cache
|
|
85
80
|
end
|
|
86
81
|
|
|
87
|
-
def body
|
|
88
|
-
if @body
|
|
89
|
-
@body
|
|
90
|
-
elsif @cached_body_str
|
|
91
|
-
@body = StringIO.new(@cached_body_str)
|
|
92
|
-
@body.set_encoding(Encoding::ASCII_8BIT)
|
|
93
|
-
@body
|
|
94
|
-
else
|
|
95
|
-
EMPTY_BODY
|
|
96
|
-
end
|
|
97
|
-
end
|
|
98
|
-
|
|
99
|
-
EMPTY_BODY = StringIO.new("".b).tap { |io| io.set_encoding(Encoding::ASCII_8BIT) }
|
|
100
|
-
|
|
101
82
|
# Class-level parse result cache
|
|
102
83
|
PARSE_CACHE = {}
|
|
103
84
|
PARSE_CACHE_MAX = 128
|
|
@@ -146,7 +127,13 @@ module Quicsilver
|
|
|
146
127
|
|
|
147
128
|
method = @headers[":method"]
|
|
148
129
|
|
|
149
|
-
if method == "
|
|
130
|
+
if method == METHOD_CONNECT && @headers[":protocol"]
|
|
131
|
+
# Extended CONNECT (RFC 9220) — requires :scheme, :path, :authority, :protocol
|
|
132
|
+
raise Protocol::MessageError, "Extended CONNECT must include :scheme" unless @headers[":scheme"]
|
|
133
|
+
raise Protocol::MessageError, "Extended CONNECT must include :path" unless @headers[":path"]
|
|
134
|
+
raise Protocol::MessageError, "Extended CONNECT must include :authority" unless @headers[":authority"]
|
|
135
|
+
elsif method == METHOD_CONNECT
|
|
136
|
+
# Regular CONNECT (RFC 9114 §4.4)
|
|
150
137
|
raise Protocol::MessageError, "CONNECT request must include :authority" unless @headers[":authority"]
|
|
151
138
|
raise Protocol::MessageError, "CONNECT request must not include :scheme" if @headers[":scheme"]
|
|
152
139
|
raise Protocol::MessageError, "CONNECT request must not include :path" if @headers[":path"]
|
|
@@ -155,8 +142,13 @@ module Quicsilver
|
|
|
155
142
|
raise Protocol::MessageError, "Request missing required pseudo-header :scheme" unless @headers[":scheme"]
|
|
156
143
|
raise Protocol::MessageError, "Request missing required pseudo-header :path" unless @headers[":path"]
|
|
157
144
|
|
|
158
|
-
# RFC 9114 §4.3.1:
|
|
145
|
+
# RFC 9114 §4.3.1: :path MUST NOT be empty for http or https URIs
|
|
159
146
|
scheme = @headers[":scheme"]
|
|
147
|
+
if %w[http https].include?(scheme) && @headers[":path"].empty?
|
|
148
|
+
raise Protocol::MessageError, ":path must not be empty for #{scheme} requests"
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# RFC 9114 §4.3.1: schemes with mandatory authority (http/https) require :authority or host
|
|
160
152
|
if %w[http https].include?(scheme) && !@headers[":authority"] && !@headers["host"]
|
|
161
153
|
raise Protocol::MessageError, "Request with #{scheme} scheme must include :authority or host"
|
|
162
154
|
end
|
|
@@ -168,15 +160,6 @@ module Quicsilver
|
|
|
168
160
|
raise Protocol::MessageError, ":authority and host header must be consistent"
|
|
169
161
|
end
|
|
170
162
|
end
|
|
171
|
-
|
|
172
|
-
# Content-length vs body size (RFC 9114 §4.1.2)
|
|
173
|
-
if @headers["content-length"]
|
|
174
|
-
expected = @headers["content-length"].to_i
|
|
175
|
-
actual = body.size
|
|
176
|
-
unless expected == actual
|
|
177
|
-
raise Protocol::MessageError, "Content-length mismatch: header=#{expected}, body=#{actual}"
|
|
178
|
-
end
|
|
179
|
-
end
|
|
180
163
|
end
|
|
181
164
|
|
|
182
165
|
def to_rack_env(stream_info = {})
|
|
@@ -184,7 +167,11 @@ module Quicsilver
|
|
|
184
167
|
|
|
185
168
|
method = @headers[":method"]
|
|
186
169
|
|
|
187
|
-
if method == "
|
|
170
|
+
if method == METHOD_CONNECT && @headers[":protocol"]
|
|
171
|
+
# Extended CONNECT (RFC 9220) — has :scheme, :path, :authority, :protocol
|
|
172
|
+
return nil unless @headers[":scheme"] && @headers[":path"] && @headers[":authority"]
|
|
173
|
+
elsif method == METHOD_CONNECT
|
|
174
|
+
# Regular CONNECT (RFC 9114 §4.4)
|
|
188
175
|
return nil unless @headers[":authority"]
|
|
189
176
|
else
|
|
190
177
|
return nil unless method && @headers[":scheme"] && @headers[":path"]
|
|
@@ -220,6 +207,11 @@ module Quicsilver
|
|
|
220
207
|
env["HTTP_HOST"] = @headers[":authority"]
|
|
221
208
|
end
|
|
222
209
|
|
|
210
|
+
# Extended CONNECT (RFC 9220) — expose :protocol as rack.protocol
|
|
211
|
+
if @headers[":protocol"]
|
|
212
|
+
env["rack.protocol"] = @headers[":protocol"]
|
|
213
|
+
end
|
|
214
|
+
|
|
223
215
|
@headers.each do |name, value|
|
|
224
216
|
next if name.start_with?(":")
|
|
225
217
|
key = name.upcase.tr("-", "_")
|
|
@@ -237,76 +229,6 @@ module Quicsilver
|
|
|
237
229
|
|
|
238
230
|
private
|
|
239
231
|
|
|
240
|
-
def parse!
|
|
241
|
-
@headers = {}
|
|
242
|
-
buffer = @data
|
|
243
|
-
offset = 0
|
|
244
|
-
headers_received = false
|
|
245
|
-
buf_size = buffer.bytesize
|
|
246
|
-
|
|
247
|
-
while offset < buf_size
|
|
248
|
-
break if buf_size - offset < 2
|
|
249
|
-
|
|
250
|
-
# Inline single-byte varint fast path (covers frame types 0x00-0x3F)
|
|
251
|
-
type_byte = buffer.getbyte(offset)
|
|
252
|
-
if type_byte < 0x40
|
|
253
|
-
type = type_byte
|
|
254
|
-
type_len = 1
|
|
255
|
-
else
|
|
256
|
-
type, type_len = Protocol.decode_varint_str(buffer, offset)
|
|
257
|
-
end
|
|
258
|
-
|
|
259
|
-
len_byte = buffer.getbyte(offset + type_len)
|
|
260
|
-
if len_byte < 0x40
|
|
261
|
-
length = len_byte
|
|
262
|
-
length_len = 1
|
|
263
|
-
else
|
|
264
|
-
length, length_len = Protocol.decode_varint_str(buffer, offset + type_len)
|
|
265
|
-
break if length_len == 0
|
|
266
|
-
end
|
|
267
|
-
break if type_len == 0
|
|
268
|
-
|
|
269
|
-
header_len = type_len + length_len
|
|
270
|
-
|
|
271
|
-
break if buf_size < offset + header_len + length
|
|
272
|
-
|
|
273
|
-
payload = buffer.byteslice(offset + header_len, length)
|
|
274
|
-
|
|
275
|
-
if @max_frame_payload_size && length > @max_frame_payload_size
|
|
276
|
-
raise Protocol::FrameError, "Frame payload #{length} exceeds limit #{@max_frame_payload_size}"
|
|
277
|
-
end
|
|
278
|
-
|
|
279
|
-
(@frames ||= []) << { type: type, length: length, payload: payload }
|
|
280
|
-
|
|
281
|
-
if CONTROL_ONLY_SET.key?(type)
|
|
282
|
-
raise Protocol::FrameError, "Frame type 0x#{type.to_s(16)} not allowed on request streams"
|
|
283
|
-
end
|
|
284
|
-
|
|
285
|
-
case type
|
|
286
|
-
when 0x01 # HEADERS
|
|
287
|
-
if @max_header_size && length > @max_header_size
|
|
288
|
-
raise Protocol::MessageError, "Header block #{length} exceeds limit #{@max_header_size}"
|
|
289
|
-
end
|
|
290
|
-
parse_headers(payload)
|
|
291
|
-
headers_received = true
|
|
292
|
-
when 0x00 # DATA
|
|
293
|
-
raise Protocol::FrameError, "DATA frame before HEADERS" unless headers_received
|
|
294
|
-
unless @body
|
|
295
|
-
@body = StringIO.new
|
|
296
|
-
@body.set_encoding(Encoding::ASCII_8BIT)
|
|
297
|
-
end
|
|
298
|
-
@body.write(payload)
|
|
299
|
-
if @max_body_size && @body.size > @max_body_size
|
|
300
|
-
raise Protocol::MessageError, "Body size #{@body.size} exceeds limit #{@max_body_size}"
|
|
301
|
-
end
|
|
302
|
-
end
|
|
303
|
-
|
|
304
|
-
offset += header_len + length
|
|
305
|
-
end
|
|
306
|
-
|
|
307
|
-
@body&.rewind
|
|
308
|
-
end
|
|
309
|
-
|
|
310
232
|
# Decode QPACK header block and validate per RFC 9114 §4.2 and §4.3.1:
|
|
311
233
|
# - Header names MUST be lowercase
|
|
312
234
|
# - Pseudo-headers MUST appear before regular headers
|
|
@@ -329,7 +251,7 @@ module Quicsilver
|
|
|
329
251
|
|
|
330
252
|
@decoder.decode(payload) do |name, value|
|
|
331
253
|
# RFC 9114 §4.2: Header field names MUST be lowercase
|
|
332
|
-
if name
|
|
254
|
+
if name.match?(/[A-Z]/)
|
|
333
255
|
raise Protocol::MessageError, "Header name '#{name}' contains uppercase characters"
|
|
334
256
|
end
|
|
335
257
|
|
|
@@ -362,26 +284,6 @@ module Quicsilver
|
|
|
362
284
|
HEADERS_CACHE[key] = @headers.dup.freeze
|
|
363
285
|
end
|
|
364
286
|
end
|
|
365
|
-
|
|
366
|
-
# RFC 9110 §5.3: Combine duplicate header values.
|
|
367
|
-
# - set-cookie: join with "\n" (Rack convention, MUST NOT combine with comma)
|
|
368
|
-
# - cookie: join with "; " (RFC 9114 §4.2.1)
|
|
369
|
-
# - all others: join with ", "
|
|
370
|
-
def store_header(name, value)
|
|
371
|
-
if @headers.key?(name)
|
|
372
|
-
separator = case name
|
|
373
|
-
when "set-cookie" then "\n"
|
|
374
|
-
when "cookie" then "; "
|
|
375
|
-
else ", "
|
|
376
|
-
end
|
|
377
|
-
@headers[name] = "#{@headers[name]}#{separator}#{value}"
|
|
378
|
-
else
|
|
379
|
-
if @max_header_count && @headers.size >= @max_header_count
|
|
380
|
-
raise Protocol::MessageError, "Header count exceeds limit #{@max_header_count}"
|
|
381
|
-
end
|
|
382
|
-
@headers[name] = value
|
|
383
|
-
end
|
|
384
|
-
end
|
|
385
287
|
end
|
|
386
288
|
end
|
|
387
289
|
end
|
|
@@ -3,12 +3,23 @@
|
|
|
3
3
|
module Quicsilver
|
|
4
4
|
module Protocol
|
|
5
5
|
class ResponseEncoder
|
|
6
|
-
|
|
6
|
+
# Encode an informational (1xx) response as a single HEADERS frame.
|
|
7
|
+
# RFC 9114 §4.1: informational responses are encoded as HEADERS with no body.
|
|
8
|
+
def self.encode_informational(status, headers, encoder: Qpack::Encoder.new)
|
|
9
|
+
raise ArgumentError, "Informational status must be 1xx, got #{status}" unless (100..199).include?(status)
|
|
10
|
+
|
|
11
|
+
pairs = [[":status", status.to_s]]
|
|
12
|
+
headers.each { |name, value| pairs << [name.to_s.downcase, value.to_s] }
|
|
13
|
+
Protocol.build_headers_frame(pairs, encoder: encoder)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def initialize(status, headers, body, encoder: Qpack::Encoder.new, head_request: false, trailers: nil)
|
|
7
17
|
@status = status
|
|
8
18
|
@headers = headers
|
|
9
19
|
@body = body
|
|
10
20
|
@encoder = encoder
|
|
11
21
|
@head_request = head_request
|
|
22
|
+
@trailers = trailers
|
|
12
23
|
end
|
|
13
24
|
|
|
14
25
|
# Buffered encode - returns all frames at once
|
|
@@ -20,8 +31,10 @@ module Quicsilver
|
|
|
20
31
|
frames << build_frame(FRAME_DATA, chunk) unless chunk.empty?
|
|
21
32
|
end
|
|
22
33
|
end
|
|
34
|
+
frames << build_frame(FRAME_HEADERS, @encoder.encode(trailer_headers)) if @trailers&.any?
|
|
23
35
|
@body.close if @body.respond_to?(:close)
|
|
24
|
-
|
|
36
|
+
# Frozen so send_stream can hand MsQuic the bytes without a copy.
|
|
37
|
+
frames.freeze
|
|
25
38
|
end
|
|
26
39
|
|
|
27
40
|
# Streaming encode - yields frames as they're ready
|
|
@@ -35,7 +48,10 @@ module Quicsilver
|
|
|
35
48
|
last_chunk = chunk
|
|
36
49
|
end
|
|
37
50
|
|
|
38
|
-
if
|
|
51
|
+
if @trailers&.any?
|
|
52
|
+
yield build_frame(FRAME_DATA, last_chunk), false if last_chunk && !last_chunk.empty?
|
|
53
|
+
yield build_frame(FRAME_HEADERS, @encoder.encode(trailer_headers)), true
|
|
54
|
+
elsif last_chunk && !last_chunk.empty?
|
|
39
55
|
yield build_frame(FRAME_DATA, last_chunk), true
|
|
40
56
|
else
|
|
41
57
|
yield "".b, true
|
|
@@ -54,19 +70,38 @@ module Quicsilver
|
|
|
54
70
|
|
|
55
71
|
def all_headers
|
|
56
72
|
headers = [[":status", @status.to_s]]
|
|
73
|
+
has_content_length = false
|
|
57
74
|
@headers.each do |name, value|
|
|
58
75
|
downcased = name.to_s.downcase
|
|
59
76
|
next if downcased.start_with?("rack.")
|
|
60
77
|
next if FORBIDDEN_HEADERS.include?(downcased)
|
|
78
|
+
has_content_length = true if downcased == "content-length"
|
|
61
79
|
headers << [downcased, value.to_s]
|
|
62
80
|
end
|
|
81
|
+
|
|
82
|
+
# RFC 9110 §8.6: Auto-set content-length for known-size bodies when not already present.
|
|
83
|
+
# Skip for 1xx, 204, 304 (no body) and HEAD responses.
|
|
84
|
+
if !has_content_length && !@head_request && @status >= 200 && @status != 204 && @status != 304
|
|
85
|
+
if @body.respond_to?(:to_ary)
|
|
86
|
+
length = @body.to_ary.sum { |chunk| chunk.to_s.bytesize }
|
|
87
|
+
headers << ["content-length", length.to_s] if length > 0
|
|
88
|
+
elsif @body.respond_to?(:length) && @body.length
|
|
89
|
+
headers << ["content-length", @body.length.to_s]
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
63
93
|
headers
|
|
64
94
|
end
|
|
65
95
|
|
|
96
|
+
def trailer_headers
|
|
97
|
+
@trailers.map { |name, value| [name.to_s.downcase, value.to_s] }
|
|
98
|
+
end
|
|
99
|
+
|
|
66
100
|
def build_frame(type, payload)
|
|
67
|
-
|
|
68
|
-
Protocol.encode_varint(type) + Protocol.encode_varint(payload.bytesize) + payload
|
|
101
|
+
Protocol.build_frame(type, payload)
|
|
69
102
|
end
|
|
103
|
+
|
|
104
|
+
|
|
70
105
|
end
|
|
71
106
|
end
|
|
72
107
|
end
|