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.
Files changed (94) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +9 -1
  3. data/.github/workflows/cibuildgem.yaml +29 -5
  4. data/.gitignore +5 -0
  5. data/CHANGELOG.md +50 -0
  6. data/Gemfile.lock +8 -1
  7. data/README.md +128 -15
  8. data/Rakefile +10 -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/benchmark.rb +80 -0
  17. data/examples/connection_pool_demo.rb +1 -1
  18. data/examples/example_helper.rb +51 -0
  19. data/examples/feature_demo.rb +7 -7
  20. data/examples/grpc_style.rb +2 -2
  21. data/examples/latency_aware_routing.rb +382 -0
  22. data/examples/lifecycle_drain.rb +264 -0
  23. data/examples/priorities.rb +2 -2
  24. data/examples/rails_feature_test.rb +92 -72
  25. data/examples/server_stats.rb +117 -0
  26. data/examples/simple_client_test.rb +3 -3
  27. data/examples/trailers.rb +23 -23
  28. data/examples/webtransport_smoke.rb +546 -0
  29. data/examples/worker_load_balancing.rb +425 -0
  30. data/ext/quicsilver/extconf.rb +100 -15
  31. data/ext/quicsilver/include/msquic.h +2226 -0
  32. data/ext/quicsilver/include/msquic_posix.h +696 -0
  33. data/ext/quicsilver/include/quic_sal_stub.h +314 -0
  34. data/ext/quicsilver/msquic_patch.rb +68 -0
  35. data/ext/quicsilver/patches/reliable-reset.patch +723 -0
  36. data/ext/quicsilver/patches/stream-final-size.patch +47 -0
  37. data/ext/quicsilver/quicsilver.c +1640 -222
  38. data/lib/quicsilver/client/client.rb +419 -60
  39. data/lib/quicsilver/client/connection_pool.rb +122 -26
  40. data/lib/quicsilver/client/request.rb +106 -14
  41. data/lib/quicsilver/errors.rb +39 -0
  42. data/lib/quicsilver/libmsquic.2.dylib +0 -0
  43. data/lib/quicsilver/protocol/adapter.rb +41 -21
  44. data/lib/quicsilver/protocol/capsule.rb +38 -0
  45. data/lib/quicsilver/protocol/connect_stream_decoder.rb +75 -0
  46. data/lib/quicsilver/protocol/control_stream_parser.rb +12 -1
  47. data/lib/quicsilver/protocol/datagram.rb +40 -0
  48. data/lib/quicsilver/protocol/frame_parser.rb +5 -6
  49. data/lib/quicsilver/protocol/frame_reader.rb +31 -2
  50. data/lib/quicsilver/protocol/frames.rb +128 -12
  51. data/lib/quicsilver/protocol/priority.rb +9 -0
  52. data/lib/quicsilver/protocol/qpack/decoder.rb +4 -42
  53. data/lib/quicsilver/protocol/qpack/encoder.rb +10 -20
  54. data/lib/quicsilver/protocol/qpack/huffman.rb +4 -4
  55. data/lib/quicsilver/protocol/request_encoder.rb +6 -3
  56. data/lib/quicsilver/protocol/request_parser.rb +16 -2
  57. data/lib/quicsilver/protocol/response_encoder.rb +18 -8
  58. data/lib/quicsilver/protocol/response_parser.rb +6 -0
  59. data/lib/quicsilver/protocol/stream_input.rb +3 -3
  60. data/lib/quicsilver/protocol/unidirectional_stream.rb +12 -0
  61. data/lib/quicsilver/protocol/web_transport.rb +214 -0
  62. data/lib/quicsilver/rack/context.rb +33 -0
  63. data/lib/quicsilver/response.rb +40 -0
  64. data/lib/quicsilver/server/rack_adapter.rb +77 -0
  65. data/lib/quicsilver/server/receive_queue.rb +106 -0
  66. data/lib/quicsilver/server/request_handler.rb +24 -13
  67. data/lib/quicsilver/server/request_registry.rb +18 -5
  68. data/lib/quicsilver/server/scheduler.rb +50 -0
  69. data/lib/quicsilver/server/schedulers/fiber_scheduler.rb +79 -0
  70. data/lib/quicsilver/server/schedulers/thread_scheduler.rb +76 -0
  71. data/lib/quicsilver/server/server.rb +655 -185
  72. data/lib/quicsilver/server/web_transport_flow_control.rb +270 -0
  73. data/lib/quicsilver/server/web_transport_manager.rb +281 -0
  74. data/lib/quicsilver/server/web_transport_registry.rb +40 -0
  75. data/lib/quicsilver/server/web_transport_session.rb +812 -0
  76. data/lib/quicsilver/server/web_transport_stream.rb +326 -0
  77. data/lib/quicsilver/transport/configuration.rb +103 -10
  78. data/lib/quicsilver/transport/connection.rb +302 -87
  79. data/lib/quicsilver/transport/connection_stats.rb +53 -0
  80. data/lib/quicsilver/transport/event_loop.rb +36 -0
  81. data/lib/quicsilver/transport/inbound_stream.rb +21 -1
  82. data/lib/quicsilver/transport/stream.rb +18 -3
  83. data/lib/quicsilver/transport/stream_event.rb +25 -2
  84. data/lib/quicsilver/transport/stream_id.rb +26 -0
  85. data/lib/quicsilver/version.rb +1 -1
  86. data/lib/quicsilver.rb +22 -11
  87. data/lib/rackup/handler/quicsilver.rb +57 -38
  88. data/quicsilver.gemspec +10 -3
  89. metadata +54 -8
  90. data/benchmarks/concurrent.rb +0 -110
  91. data/benchmarks/quicsilver_server.rb +0 -46
  92. data/benchmarks/rails.rb +0 -170
  93. data/benchmarks/throughput.rb +0 -113
  94. 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
- raise Protocol::FrameError, "Duplicate setting identifier 0x#{id.to_s(16)}" if seen.include?(id)
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, "Frame payload #{payload.bytesize} exceeds limit #{@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
+ )
73
74
  end
74
75
 
75
76
  (@frames ||= []) << { type: type, length: payload.bytesize, payload: payload }
76
77
 
77
- if CONTROL_ONLY_SET.key?(type)
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 [type, payload] pairs. No protocol semantics — just byte-level extraction.
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 < StandardError
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 < StandardError
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 9297): 31 * n + 33
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
- # Build control stream data
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 build_control_stream(max_field_section_size: nil)
243
- stream_type = [0x00].pack('C') # Control stream type
244
- settings_hash = {
245
- 0x01 => 0, # QPACK_MAX_TABLE_CAPACITY = 0 (no dynamic table)
246
- 0x07 => 0, # QPACK_BLOCKED_STREAMS = 0
247
- 0x08 => 1, # SETTINGS_ENABLE_CONNECT_PROTOCOL (RFC 9220)
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
- settings_hash[0x06] = max_field_section_size if max_field_section_size # SETTINGS_MAX_FIELD_SECTION_SIZE
250
- settings_hash[grease_id] = grease_id # GREASE setting (RFC 9297)
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
- # 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
 
@@ -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
@@ -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: 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
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
- encoded = encoder.encode(pairs)
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
- frames
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
- payload = payload.to_s.b
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 < StandardError; end
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
- # which naturally maps to QUIC flow control.
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)