quicsilver 0.4.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 +29 -5
- data/.gitignore +5 -0
- data/CHANGELOG.md +50 -0
- data/Gemfile.lock +8 -1
- data/README.md +128 -15
- data/Rakefile +10 -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/benchmark.rb +80 -0
- data/examples/connection_pool_demo.rb +1 -1
- data/examples/example_helper.rb +51 -0
- data/examples/feature_demo.rb +7 -7
- data/examples/grpc_style.rb +2 -2
- data/examples/latency_aware_routing.rb +382 -0
- data/examples/lifecycle_drain.rb +264 -0
- data/examples/priorities.rb +2 -2
- data/examples/rails_feature_test.rb +92 -72
- data/examples/server_stats.rb +117 -0
- data/examples/simple_client_test.rb +3 -3
- data/examples/trailers.rb +23 -23
- data/examples/webtransport_smoke.rb +546 -0
- data/examples/worker_load_balancing.rb +425 -0
- data/ext/quicsilver/extconf.rb +100 -15
- 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 +1640 -222
- data/lib/quicsilver/client/client.rb +419 -60
- data/lib/quicsilver/client/connection_pool.rb +122 -26
- 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 +41 -21
- 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 +12 -1
- data/lib/quicsilver/protocol/datagram.rb +40 -0
- data/lib/quicsilver/protocol/frame_parser.rb +5 -6
- data/lib/quicsilver/protocol/frame_reader.rb +31 -2
- data/lib/quicsilver/protocol/frames.rb +128 -12
- data/lib/quicsilver/protocol/priority.rb +9 -0
- data/lib/quicsilver/protocol/qpack/decoder.rb +4 -42
- data/lib/quicsilver/protocol/qpack/encoder.rb +10 -20
- 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 +16 -2
- data/lib/quicsilver/protocol/response_encoder.rb +18 -8
- data/lib/quicsilver/protocol/response_parser.rb +6 -0
- data/lib/quicsilver/protocol/stream_input.rb +3 -3
- 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 +24 -13
- 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 +655 -185
- 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 +103 -10
- data/lib/quicsilver/transport/connection.rb +302 -87
- 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 +22 -11
- data/lib/rackup/handler/quicsilver.rb +57 -38
- data/quicsilver.gemspec +10 -3
- metadata +54 -8
- 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/lib/quicsilver/quicsilver.bundle +0 -0
|
@@ -21,6 +21,9 @@ module Quicsilver
|
|
|
21
21
|
# Note: 0x08 (SETTINGS_ENABLE_CONNECT_PROTOCOL) is valid in HTTP/3 per RFC 9220
|
|
22
22
|
HTTP2_SETTINGS = [0x00, 0x02, 0x03, 0x04, 0x05].freeze
|
|
23
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.
|
|
24
27
|
def parse_control_frames(data)
|
|
25
28
|
first_frame = !@settings_received
|
|
26
29
|
|
|
@@ -59,7 +62,10 @@ module Quicsilver
|
|
|
59
62
|
error_code: Protocol::H3_SETTINGS_ERROR)
|
|
60
63
|
end
|
|
61
64
|
|
|
62
|
-
|
|
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
|
|
63
69
|
seen.add(id)
|
|
64
70
|
|
|
65
71
|
settings[id] = value
|
|
@@ -88,6 +94,7 @@ module Quicsilver
|
|
|
88
94
|
end
|
|
89
95
|
|
|
90
96
|
@peer_goaway_id = stream_id
|
|
97
|
+
on_goaway_received(stream_id)
|
|
91
98
|
end
|
|
92
99
|
|
|
93
100
|
private
|
|
@@ -96,6 +103,10 @@ module Quicsilver
|
|
|
96
103
|
def on_settings_received(settings)
|
|
97
104
|
end
|
|
98
105
|
|
|
106
|
+
# Override in includer to react to GOAWAY. Default: no-op.
|
|
107
|
+
def on_goaway_received(stream_id)
|
|
108
|
+
end
|
|
109
|
+
|
|
99
110
|
# Override in includer to handle additional frame types on the control stream.
|
|
100
111
|
# Server handles FORBIDDEN_ON_CONTROL and PRIORITY_UPDATE here.
|
|
101
112
|
# Client ignores unknown frames.
|
|
@@ -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
|
|
@@ -19,8 +19,6 @@ module Quicsilver
|
|
|
19
19
|
# Inject a custom decoder via decoder: kwarg for dynamic QPACK support.
|
|
20
20
|
DEFAULT_DECODER = Qpack::HeaderBlockDecoder.default
|
|
21
21
|
|
|
22
|
-
CONTROL_ONLY_SET = Protocol::CONTROL_ONLY_FRAMES.each_with_object({}) { |f, h| h[f] = true }.freeze
|
|
23
|
-
|
|
24
22
|
EMPTY_BODY = StringIO.new("".b).tap { |io| io.set_encoding(Encoding::ASCII_8BIT) }
|
|
25
23
|
|
|
26
24
|
attr_reader :headers, :trailers, :bytes_consumed
|
|
@@ -69,14 +67,15 @@ module Quicsilver
|
|
|
69
67
|
|
|
70
68
|
@bytes_consumed = FrameReader.each(buffer) do |type, payload|
|
|
71
69
|
if @max_frame_payload_size && payload.bytesize > @max_frame_payload_size
|
|
72
|
-
raise Protocol::FrameError
|
|
70
|
+
raise Protocol::FrameError.new(
|
|
71
|
+
"Frame payload #{payload.bytesize} exceeds limit #{@max_frame_payload_size}",
|
|
72
|
+
error_code: Protocol::H3_EXCESSIVE_LOAD
|
|
73
|
+
)
|
|
73
74
|
end
|
|
74
75
|
|
|
75
76
|
(@frames ||= []) << { type: type, length: payload.bytesize, payload: payload }
|
|
76
77
|
|
|
77
|
-
|
|
78
|
-
raise Protocol::FrameError, "Frame type 0x#{type.to_s(16)} not allowed on request streams"
|
|
79
|
-
end
|
|
78
|
+
Protocol.reject_unexpected_request_frame!(type)
|
|
80
79
|
|
|
81
80
|
case type
|
|
82
81
|
when 0x01 # HEADERS
|
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
module Quicsilver
|
|
4
4
|
module Protocol
|
|
5
5
|
# Extracts HTTP/3 frames from a byte buffer.
|
|
6
|
-
# Yields
|
|
6
|
+
# Yields (type, payload, offset) per frame. Returns total bytes consumed.
|
|
7
|
+
# Existing callers using |type, payload| are unaffected — offset is ignored.
|
|
7
8
|
#
|
|
8
9
|
# Usage:
|
|
9
10
|
# FrameReader.each(buffer) do |type, payload|
|
|
@@ -12,6 +13,10 @@ module Quicsilver
|
|
|
12
13
|
# when FRAME_DATA then ...
|
|
13
14
|
# end
|
|
14
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
|
|
15
20
|
module FrameReader
|
|
16
21
|
def self.each(buffer)
|
|
17
22
|
offset = 0
|
|
@@ -31,6 +36,7 @@ module Quicsilver
|
|
|
31
36
|
end
|
|
32
37
|
|
|
33
38
|
len_byte = buffer.getbyte(offset + type_len)
|
|
39
|
+
break unless len_byte
|
|
34
40
|
if len_byte < 0x40
|
|
35
41
|
length = len_byte
|
|
36
42
|
length_len = 1
|
|
@@ -45,11 +51,34 @@ module Quicsilver
|
|
|
45
51
|
payload = buffer.byteslice(offset + header_len, length)
|
|
46
52
|
offset += header_len + length
|
|
47
53
|
|
|
48
|
-
yield type, payload
|
|
54
|
+
yield type, payload, offset
|
|
49
55
|
end
|
|
50
56
|
|
|
51
57
|
offset
|
|
52
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
|
|
53
82
|
end
|
|
54
83
|
end
|
|
55
84
|
end
|
|
@@ -12,8 +12,51 @@ module Quicsilver
|
|
|
12
12
|
FRAME_MAX_PUSH_ID = 0x0d
|
|
13
13
|
FRAME_PRIORITY_UPDATE = 0xF0700 # RFC 9218 — request stream priority update
|
|
14
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
|
|
44
|
+
|
|
15
45
|
# Frame types forbidden on request streams (RFC 9114 Section 7.2.4, 7.2.6, 7.2.7)
|
|
16
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
|
|
17
60
|
|
|
18
61
|
# HTTP/3 Error Codes (RFC 9114 Section 8.1)
|
|
19
62
|
H3_NO_ERROR = 0x100
|
|
@@ -42,15 +85,20 @@ module Quicsilver
|
|
|
42
85
|
# Protocol errors that carry an HTTP/3 error code for CONNECTION_CLOSE / RESET_STREAM.
|
|
43
86
|
# FrameError → connection error (CONNECTION_CLOSE)
|
|
44
87
|
# MessageError → stream error (RESET_STREAM) on request streams
|
|
45
|
-
class FrameError <
|
|
88
|
+
class FrameError < Quicsilver::Error
|
|
46
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.
|
|
47
95
|
def initialize(msg = nil, error_code: H3_FRAME_UNEXPECTED)
|
|
48
96
|
@error_code = error_code
|
|
49
97
|
super(msg)
|
|
50
98
|
end
|
|
51
99
|
end
|
|
52
100
|
|
|
53
|
-
class MessageError <
|
|
101
|
+
class MessageError < Quicsilver::Error
|
|
54
102
|
attr_reader :error_code
|
|
55
103
|
def initialize(msg = nil, error_code: H3_MESSAGE_ERROR)
|
|
56
104
|
@error_code = error_code
|
|
@@ -185,6 +233,27 @@ module Quicsilver
|
|
|
185
233
|
MAX_STREAM_ID = (2**62) - 4
|
|
186
234
|
|
|
187
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
|
+
|
|
188
257
|
# Precomputed varint encodings for single-byte values (0-63)
|
|
189
258
|
VARINT_SMALL = Array.new(64) { |v| [v].pack('C').freeze }.freeze
|
|
190
259
|
VARINT_MED = Array.new(16384 - 64) { |i| v = i + 64; [0x40 | (v >> 8), v & 0xFF].pack('C*').freeze }.freeze
|
|
@@ -231,29 +300,76 @@ module Quicsilver
|
|
|
231
300
|
frame_type + frame_length + payload
|
|
232
301
|
end
|
|
233
302
|
|
|
234
|
-
# Generate a random GREASE ID (RFC
|
|
303
|
+
# Generate a random GREASE ID (RFC 9114 §7.2.4.1): 0x1f * N + 0x21
|
|
235
304
|
def grease_id
|
|
236
305
|
31 * rand(0..20) + 33
|
|
237
306
|
end
|
|
238
307
|
|
|
239
|
-
#
|
|
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
|
+
#
|
|
240
317
|
# @param max_field_section_size [Integer, nil] Advertise SETTINGS_MAX_FIELD_SECTION_SIZE (0x06)
|
|
241
318
|
# to the peer (RFC 9114 §4.2.2 / §7.2.4.1). nil = don't advertise.
|
|
242
|
-
def
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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
|
|
248
343
|
}
|
|
249
|
-
|
|
250
|
-
|
|
344
|
+
settings[SETTINGS_MAX_FIELD_SECTION_SIZE] = max_field_section_size if max_field_section_size
|
|
345
|
+
settings
|
|
346
|
+
end
|
|
347
|
+
|
|
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)
|
|
251
356
|
|
|
252
357
|
stream_type + build_settings_frame(settings_hash)
|
|
253
358
|
end
|
|
254
359
|
|
|
255
360
|
# Build GOAWAY frame (RFC 9114 Section 7.2.6)
|
|
256
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
|
+
|
|
257
373
|
def build_goaway_frame(stream_id)
|
|
258
374
|
frame_type = encode_varint(FRAME_GOAWAY)
|
|
259
375
|
payload = encode_varint(stream_id)
|
|
@@ -30,6 +30,15 @@ module Quicsilver
|
|
|
30
30
|
|
|
31
31
|
# Parse a priority header value (RFC 9218 §4, Structured Field Values).
|
|
32
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
|
+
|
|
33
42
|
def self.parse(value)
|
|
34
43
|
return new unless value && !value.empty?
|
|
35
44
|
|
|
@@ -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
|
|
|
@@ -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
|
|
@@ -142,8 +142,13 @@ module Quicsilver
|
|
|
142
142
|
raise Protocol::MessageError, "Request missing required pseudo-header :scheme" unless @headers[":scheme"]
|
|
143
143
|
raise Protocol::MessageError, "Request missing required pseudo-header :path" unless @headers[":path"]
|
|
144
144
|
|
|
145
|
-
# RFC 9114 §4.3.1:
|
|
145
|
+
# RFC 9114 §4.3.1: :path MUST NOT be empty for http or https URIs
|
|
146
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
|
|
147
152
|
if %w[http https].include?(scheme) && !@headers[":authority"] && !@headers["host"]
|
|
148
153
|
raise Protocol::MessageError, "Request with #{scheme} scheme must include :authority or host"
|
|
149
154
|
end
|
|
@@ -162,7 +167,11 @@ module Quicsilver
|
|
|
162
167
|
|
|
163
168
|
method = @headers[":method"]
|
|
164
169
|
|
|
165
|
-
if method == METHOD_CONNECT
|
|
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)
|
|
166
175
|
return nil unless @headers[":authority"]
|
|
167
176
|
else
|
|
168
177
|
return nil unless method && @headers[":scheme"] && @headers[":path"]
|
|
@@ -198,6 +207,11 @@ module Quicsilver
|
|
|
198
207
|
env["HTTP_HOST"] = @headers[":authority"]
|
|
199
208
|
end
|
|
200
209
|
|
|
210
|
+
# Extended CONNECT (RFC 9220) — expose :protocol as rack.protocol
|
|
211
|
+
if @headers[":protocol"]
|
|
212
|
+
env["rack.protocol"] = @headers[":protocol"]
|
|
213
|
+
end
|
|
214
|
+
|
|
201
215
|
@headers.each do |name, value|
|
|
202
216
|
next if name.start_with?(":")
|
|
203
217
|
key = name.upcase.tr("-", "_")
|
|
@@ -10,11 +10,7 @@ module Quicsilver
|
|
|
10
10
|
|
|
11
11
|
pairs = [[":status", status.to_s]]
|
|
12
12
|
headers.each { |name, value| pairs << [name.to_s.downcase, value.to_s] }
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
Protocol.encode_varint(FRAME_HEADERS) +
|
|
16
|
-
Protocol.encode_varint(encoded.bytesize) +
|
|
17
|
-
encoded
|
|
13
|
+
Protocol.build_headers_frame(pairs, encoder: encoder)
|
|
18
14
|
end
|
|
19
15
|
|
|
20
16
|
def initialize(status, headers, body, encoder: Qpack::Encoder.new, head_request: false, trailers: nil)
|
|
@@ -37,7 +33,8 @@ module Quicsilver
|
|
|
37
33
|
end
|
|
38
34
|
frames << build_frame(FRAME_HEADERS, @encoder.encode(trailer_headers)) if @trailers&.any?
|
|
39
35
|
@body.close if @body.respond_to?(:close)
|
|
40
|
-
|
|
36
|
+
# Frozen so send_stream can hand MsQuic the bytes without a copy.
|
|
37
|
+
frames.freeze
|
|
41
38
|
end
|
|
42
39
|
|
|
43
40
|
# Streaming encode - yields frames as they're ready
|
|
@@ -73,12 +70,26 @@ module Quicsilver
|
|
|
73
70
|
|
|
74
71
|
def all_headers
|
|
75
72
|
headers = [[":status", @status.to_s]]
|
|
73
|
+
has_content_length = false
|
|
76
74
|
@headers.each do |name, value|
|
|
77
75
|
downcased = name.to_s.downcase
|
|
78
76
|
next if downcased.start_with?("rack.")
|
|
79
77
|
next if FORBIDDEN_HEADERS.include?(downcased)
|
|
78
|
+
has_content_length = true if downcased == "content-length"
|
|
80
79
|
headers << [downcased, value.to_s]
|
|
81
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
|
+
|
|
82
93
|
headers
|
|
83
94
|
end
|
|
84
95
|
|
|
@@ -87,8 +98,7 @@ module Quicsilver
|
|
|
87
98
|
end
|
|
88
99
|
|
|
89
100
|
def build_frame(type, payload)
|
|
90
|
-
|
|
91
|
-
Protocol.encode_varint(type) + Protocol.encode_varint(payload.bytesize) + payload
|
|
101
|
+
Protocol.build_frame(type, payload)
|
|
92
102
|
end
|
|
93
103
|
|
|
94
104
|
|
|
@@ -7,6 +7,12 @@ module Quicsilver
|
|
|
7
7
|
class ResponseParser < FrameParser
|
|
8
8
|
attr_reader :status
|
|
9
9
|
|
|
10
|
+
# Validate response semantics per RFC 9114 §4.3.2.
|
|
11
|
+
# Call after parse to check :status is present.
|
|
12
|
+
def validate_response!
|
|
13
|
+
raise Protocol::MessageError, "Response missing required :status pseudo-header" unless @status
|
|
14
|
+
end
|
|
15
|
+
|
|
10
16
|
def initialize(data, **opts)
|
|
11
17
|
decoder = opts.delete(:decoder) || DEFAULT_DECODER
|
|
12
18
|
super(decoder: decoder, max_body_size: opts[:max_body_size],
|
|
@@ -21,12 +21,12 @@ module Quicsilver
|
|
|
21
21
|
# - Read timeout for slow client protection
|
|
22
22
|
#
|
|
23
23
|
class StreamInput < ::Protocol::HTTP::Body::Writable
|
|
24
|
-
class ReadTimeout <
|
|
24
|
+
class ReadTimeout < Quicsilver::Error; end
|
|
25
25
|
|
|
26
26
|
# @param length [Integer, nil] The content-length if known from headers.
|
|
27
27
|
# @param queue_size [Integer, nil] Maximum buffered chunks for back-pressure.
|
|
28
|
-
# nil (default) = unbounded. When bounded, write blocks if queue is full
|
|
29
|
-
#
|
|
28
|
+
# nil (default) = unbounded. When bounded, write blocks if queue is full.
|
|
29
|
+
# A bounded queue must not block the MsQuic poll thread.
|
|
30
30
|
# @param read_timeout [Numeric, nil] Seconds to wait for data before raising
|
|
31
31
|
# ReadTimeout. nil (default) = wait forever.
|
|
32
32
|
def initialize(length = nil, queue_size: nil, read_timeout: nil)
|