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.
Files changed (102) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +9 -1
  3. data/.github/workflows/cibuildgem.yaml +117 -0
  4. data/.gitignore +8 -1
  5. data/CHANGELOG.md +82 -0
  6. data/Gemfile.lock +27 -2
  7. data/README.md +205 -29
  8. data/Rakefile +75 -3
  9. data/benchmarks/baseline.rb +175 -0
  10. data/benchmarks/compare.rb +398 -0
  11. data/benchmarks/helpers.rb +138 -64
  12. data/benchmarks/latency.rb +128 -0
  13. data/benchmarks/loss.rb +193 -0
  14. data/benchmarks/mobile.rb +125 -0
  15. data/docs/connection_identity.md +141 -0
  16. data/examples/README.md +44 -91
  17. data/examples/benchmark.rb +191 -0
  18. data/examples/connection_pool_demo.rb +47 -0
  19. data/examples/example_helper.rb +69 -0
  20. data/examples/falcon_middleware.rb +44 -0
  21. data/examples/feature_demo.rb +125 -0
  22. data/examples/grpc_style.rb +97 -0
  23. data/examples/latency_aware_routing.rb +382 -0
  24. data/examples/lifecycle_drain.rb +264 -0
  25. data/examples/minimal_http3_server.rb +6 -18
  26. data/examples/priorities.rb +60 -0
  27. data/examples/protocol_http_server.rb +31 -0
  28. data/examples/rack_http3_server.rb +8 -20
  29. data/examples/rails_feature_test.rb +280 -0
  30. data/examples/server_stats.rb +117 -0
  31. data/examples/simple_client_test.rb +5 -5
  32. data/examples/streaming_sse.rb +33 -0
  33. data/examples/trailers.rb +69 -0
  34. data/examples/webtransport_smoke.rb +546 -0
  35. data/examples/worker_load_balancing.rb +425 -0
  36. data/ext/quicsilver/extconf.rb +111 -12
  37. data/ext/quicsilver/include/msquic.h +2226 -0
  38. data/ext/quicsilver/include/msquic_posix.h +696 -0
  39. data/ext/quicsilver/include/quic_sal_stub.h +314 -0
  40. data/ext/quicsilver/msquic_patch.rb +68 -0
  41. data/ext/quicsilver/patches/reliable-reset.patch +723 -0
  42. data/ext/quicsilver/patches/stream-final-size.patch +47 -0
  43. data/ext/quicsilver/quicsilver.c +1646 -189
  44. data/lib/quicsilver/client/client.rb +546 -88
  45. data/lib/quicsilver/client/connection_pool.rb +202 -0
  46. data/lib/quicsilver/client/request.rb +106 -14
  47. data/lib/quicsilver/errors.rb +39 -0
  48. data/lib/quicsilver/libmsquic.2.dylib +0 -0
  49. data/lib/quicsilver/protocol/adapter.rb +196 -0
  50. data/lib/quicsilver/protocol/capsule.rb +38 -0
  51. data/lib/quicsilver/protocol/connect_stream_decoder.rb +75 -0
  52. data/lib/quicsilver/protocol/control_stream_parser.rb +117 -0
  53. data/lib/quicsilver/protocol/datagram.rb +40 -0
  54. data/lib/quicsilver/protocol/frame_parser.rb +141 -0
  55. data/lib/quicsilver/protocol/frame_reader.rb +84 -0
  56. data/lib/quicsilver/protocol/frames.rb +137 -10
  57. data/lib/quicsilver/protocol/priority.rb +65 -0
  58. data/lib/quicsilver/protocol/qpack/decoder.rb +4 -42
  59. data/lib/quicsilver/protocol/qpack/encoder.rb +49 -21
  60. data/lib/quicsilver/protocol/qpack/header_block_decoder.rb +16 -1
  61. data/lib/quicsilver/protocol/qpack/huffman.rb +4 -4
  62. data/lib/quicsilver/protocol/request_encoder.rb +6 -3
  63. data/lib/quicsilver/protocol/request_parser.rb +43 -141
  64. data/lib/quicsilver/protocol/response_encoder.rb +40 -5
  65. data/lib/quicsilver/protocol/response_parser.rb +26 -128
  66. data/lib/quicsilver/protocol/stream_input.rb +98 -0
  67. data/lib/quicsilver/protocol/stream_output.rb +59 -0
  68. data/lib/quicsilver/protocol/unidirectional_stream.rb +12 -0
  69. data/lib/quicsilver/protocol/web_transport.rb +214 -0
  70. data/lib/quicsilver/rack/context.rb +33 -0
  71. data/lib/quicsilver/response.rb +40 -0
  72. data/lib/quicsilver/server/rack_adapter.rb +77 -0
  73. data/lib/quicsilver/server/receive_queue.rb +106 -0
  74. data/lib/quicsilver/server/request_handler.rb +111 -48
  75. data/lib/quicsilver/server/request_registry.rb +18 -5
  76. data/lib/quicsilver/server/scheduler.rb +50 -0
  77. data/lib/quicsilver/server/schedulers/fiber_scheduler.rb +79 -0
  78. data/lib/quicsilver/server/schedulers/thread_scheduler.rb +76 -0
  79. data/lib/quicsilver/server/server.rb +848 -104
  80. data/lib/quicsilver/server/web_transport_flow_control.rb +270 -0
  81. data/lib/quicsilver/server/web_transport_manager.rb +281 -0
  82. data/lib/quicsilver/server/web_transport_registry.rb +40 -0
  83. data/lib/quicsilver/server/web_transport_session.rb +812 -0
  84. data/lib/quicsilver/server/web_transport_stream.rb +326 -0
  85. data/lib/quicsilver/transport/configuration.rb +113 -11
  86. data/lib/quicsilver/transport/connection.rb +376 -132
  87. data/lib/quicsilver/transport/connection_stats.rb +53 -0
  88. data/lib/quicsilver/transport/event_loop.rb +36 -0
  89. data/lib/quicsilver/transport/inbound_stream.rb +21 -1
  90. data/lib/quicsilver/transport/stream.rb +18 -3
  91. data/lib/quicsilver/transport/stream_event.rb +25 -2
  92. data/lib/quicsilver/transport/stream_id.rb +26 -0
  93. data/lib/quicsilver/version.rb +1 -1
  94. data/lib/quicsilver.rb +45 -11
  95. data/lib/rackup/handler/quicsilver.rb +57 -38
  96. data/quicsilver.gemspec +17 -2
  97. metadata +120 -10
  98. data/benchmarks/concurrent.rb +0 -110
  99. data/benchmarks/quicsilver_server.rb +0 -46
  100. data/benchmarks/rails.rb +0 -170
  101. data/benchmarks/throughput.rb +0 -113
  102. 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 < StandardError
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 < StandardError
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
- # Build control stream data
234
- def build_control_stream
235
- stream_type = [0x00].pack('C') # Control stream type
236
- settings = build_settings_frame({
237
- 0x01 => 0, # QPACK_MAX_TABLE_CAPACITY = 0 (no dynamic table)
238
- 0x07 => 0 # QPACK_BLOCKED_STREAMS = 0
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
- stream_type + settings
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)