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,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
- # Cache for decode_qpack_string
42
- DQS_CACHE = {} # array-content → [str, consumed]
43
- DQS_OID_CACHE = {} # object_id|offset → [str, consumed]
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
- result = [str, len_bytes + length].freeze
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
- result = encode_fields(headers)
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 =~ /[A-Z]/
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
- offset = 2 # skip required insert count + delta base prefix
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 short strings (frozen)
368
- if data.bytesize <= 128 && HUFF_ENCODE_CACHE.size < CACHE_MAX
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 short encoded data (frozen)
414
- if encoded_data.bytesize <= 128 && result && HUFF_DECODE_CACHE.size < CACHE_MAX
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.encode_varint(type) + Protocol.encode_varint(payload.bytesize) + payload
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
- require "stringio"
4
- require_relative "qpack/header_block_decoder"
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
- def frames
12
- @frames || []
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
- if opts.empty?
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 = nil
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 = nil
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 == "CONNECT"
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: schemes with mandatory authority (http/https) require :authority or host
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 == "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)
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 =~ /[A-Z]/
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
- def initialize(status, headers, body, encoder: Qpack::Encoder.new, head_request: false)
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
- frames
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 last_chunk && !last_chunk.empty?
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
- payload = payload.to_s.b
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