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
@@ -1,39 +1,34 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "stringio"
4
- require_relative "qpack/header_block_decoder"
3
+ require_relative "frame_parser"
5
4
 
6
5
  module Quicsilver
7
6
  module Protocol
8
- class ResponseParser
9
- attr_reader :headers, :status
7
+ class ResponseParser < FrameParser
8
+ attr_reader :status
10
9
 
11
- def frames
12
- @frames || []
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
13
14
  end
14
15
 
15
- DEFAULT_DECODER = Qpack::HeaderBlockDecoder.default
16
-
17
16
  def initialize(data, **opts)
17
+ decoder = opts.delete(:decoder) || DEFAULT_DECODER
18
+ super(decoder: decoder, max_body_size: opts[:max_body_size],
19
+ max_header_size: opts[:max_header_size])
18
20
  @data = data
19
- if opts.empty?
20
- @decoder = DEFAULT_DECODER
21
- @use_parse_cache = true
22
- else
23
- @decoder = opts[:decoder] || DEFAULT_DECODER
24
- @max_body_size = opts[:max_body_size]
25
- @max_header_size = opts[:max_header_size]
26
- @use_parse_cache = @decoder.equal?(DEFAULT_DECODER) && !@max_body_size && !@max_header_size
27
- end
21
+ @use_parse_cache = @decoder.equal?(DEFAULT_DECODER) && !@max_body_size && !@max_header_size
28
22
  end
29
23
 
30
24
  # Reset parser with new data for object reuse (avoids allocation overhead)
31
25
  def reset(data)
32
26
  @data = data
33
27
  @status = nil
34
- @headers = nil
28
+ @headers = {}
29
+ @trailers = {}
35
30
  @frames = nil
36
- @body_io = nil
31
+ @body = nil
37
32
  @cached_body_str = nil
38
33
  end
39
34
 
@@ -55,35 +50,22 @@ module Quicsilver
55
50
  @headers = cached[1]
56
51
  @frames = cached[2]
57
52
  @cached_body_str = cached[3]
53
+ @trailers = cached[4] || {}
58
54
  @last_data = data
59
55
  return
60
56
  end
61
57
  end
62
58
  @status = nil
63
- @headers = nil
59
+ @headers = {}
60
+ @trailers = {}
64
61
  @frames = nil
65
- @body_io = nil
62
+ @body = nil
66
63
  @cached_body_str = nil
67
64
  parse!
68
65
  cache_result if @use_parse_cache
69
66
  end
70
67
 
71
- def body
72
- if @body_io
73
- @body_io.rewind
74
- @body_io
75
- elsif @cached_body_str
76
- @body_io = StringIO.new(@cached_body_str)
77
- @body_io.set_encoding(Encoding::ASCII_8BIT)
78
- @body_io
79
- else
80
- EMPTY_BODY
81
- end
82
- end
83
-
84
- EMPTY_BODY = StringIO.new("".b).tap { |io| io.set_encoding(Encoding::ASCII_8BIT) }
85
-
86
- # Class-level parse result cache: data → [status, headers, frames, body_str]
68
+ # Class-level parse result cache: data → [status, headers, frames, body_str, trailers]
87
69
  PARSE_CACHE = {}
88
70
  PARSE_CACHE_MAX = 128
89
71
  PARSE_OID_CACHE = {}
@@ -98,6 +80,7 @@ module Quicsilver
98
80
  @headers = cached[1]
99
81
  @frames = cached[2]
100
82
  @cached_body_str = cached[3]
83
+ @trailers = cached[4] || {}
101
84
  return
102
85
  end
103
86
  end
@@ -108,10 +91,10 @@ module Quicsilver
108
91
 
109
92
  private def cache_result
110
93
  if PARSE_CACHE.size < PARSE_CACHE_MAX && @data.bytesize <= 1024
111
- body_str = if @body_io
112
- @body_io.rewind
113
- s = @body_io.read
114
- @body_io.rewind
94
+ body_str = if @body
95
+ @body.rewind
96
+ s = @body.read
97
+ @body.rewind
115
98
  s
116
99
  end
117
100
  key = @data.frozen? ? @data : @data.dup.freeze
@@ -119,82 +102,14 @@ module Quicsilver
119
102
  @status,
120
103
  @headers.dup.freeze,
121
104
  (@frames || []).freeze,
122
- body_str&.freeze
105
+ body_str&.freeze,
106
+ (@trailers.empty? ? nil : @trailers.dup.freeze)
123
107
  ].freeze
124
108
  end
125
109
  end
126
110
 
127
111
  private
128
112
 
129
- # Frame types forbidden on request streams — O(1) lookup
130
- CONTROL_ONLY_SET = Protocol::CONTROL_ONLY_FRAMES.each_with_object({}) { |f, h| h[f] = true }.freeze
131
-
132
- def parse!
133
- @headers = {}
134
- @status = nil
135
- @body_io = nil
136
- @frames = nil
137
- buffer = @data
138
- offset = 0
139
- headers_received = false
140
- buf_size = buffer.bytesize
141
-
142
- while offset < buf_size
143
- break if buf_size - offset < 2
144
-
145
- # Inline single-byte varint fast path (covers frame types 0x00-0x3F)
146
- type_byte = buffer.getbyte(offset)
147
- if type_byte < 0x40
148
- type = type_byte
149
- type_len = 1
150
- else
151
- type, type_len = Protocol.decode_varint_str(buffer, offset)
152
- end
153
-
154
- len_byte = buffer.getbyte(offset + type_len)
155
- if len_byte < 0x40
156
- length = len_byte
157
- length_len = 1
158
- else
159
- length, length_len = Protocol.decode_varint_str(buffer, offset + type_len)
160
- break if length_len == 0
161
- end
162
- break if type_len == 0
163
-
164
- header_len = type_len + length_len
165
-
166
- break if buf_size < offset + header_len + length
167
-
168
- payload = buffer.byteslice(offset + header_len, length)
169
- (@frames ||= []) << { type: type, length: length, payload: payload }
170
-
171
- if CONTROL_ONLY_SET.key?(type)
172
- raise Protocol::FrameError, "Frame type 0x#{type.to_s(16)} not allowed on request streams"
173
- end
174
-
175
- case type
176
- when 0x01 # HEADERS
177
- if @max_header_size && length > @max_header_size
178
- raise Protocol::MessageError, "Header block #{length} exceeds limit #{@max_header_size}"
179
- end
180
- parse_headers(payload)
181
- headers_received = true
182
- when 0x00 # DATA
183
- raise Protocol::FrameError, "DATA frame before HEADERS" unless headers_received
184
- unless @body_io
185
- @body_io = StringIO.new
186
- @body_io.set_encoding(Encoding::ASCII_8BIT)
187
- end
188
- @body_io.write(payload)
189
- if @max_body_size && @body_io.size > @max_body_size
190
- raise Protocol::MessageError, "Body size #{@body_io.size} exceeds limit #{@max_body_size}"
191
- end
192
- end
193
-
194
- offset += header_len + length
195
- end
196
- end
197
-
198
113
  # Cache for validated response header results
199
114
  HEADERS_CACHE = {}
200
115
  HEADERS_CACHE_MAX = 256
@@ -227,23 +142,6 @@ module Quicsilver
227
142
  HEADERS_CACHE[key] = { status: @status, headers: @headers.dup.freeze }.freeze
228
143
  end
229
144
  end
230
-
231
- # RFC 9110 §5.3: Combine duplicate header values.
232
- # - set-cookie: join with "\n" (Rack convention, MUST NOT combine with comma)
233
- # - cookie: join with "; " (RFC 9114 §4.2.1)
234
- # - all others: join with ", "
235
- def store_header(name, value)
236
- if @headers.key?(name)
237
- separator = case name
238
- when "set-cookie" then "\n"
239
- when "cookie" then "; "
240
- else ", "
241
- end
242
- @headers[name] = "#{@headers[name]}#{separator}#{value}"
243
- else
244
- @headers[name] = value
245
- end
246
- end
247
145
  end
248
146
  end
249
147
  end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "protocol/http/body/writable"
4
+
5
+ module Quicsilver
6
+ module Protocol
7
+ # A streaming request body backed by Protocol::HTTP::Body::Writable.
8
+ #
9
+ # QUIC RECEIVE events push chunks via {write}, while the application
10
+ # reads them via {read}. This enables concurrent streaming — the app
11
+ # can start processing before the full body arrives.
12
+ #
13
+ # Follows the protocol-http Body::Writable contract:
14
+ # - write(chunk) — called by the QUIC transport on RECEIVE events
15
+ # - close_write — called on RECEIVE_FIN to signal end of body
16
+ # - read — called by the application (blocks until data available)
17
+ # - close — called to abort (e.g., stream reset)
18
+ #
19
+ # Optional features:
20
+ # - Back-pressure via Thread::SizedQueue (bounded buffer)
21
+ # - Read timeout for slow client protection
22
+ #
23
+ class StreamInput < ::Protocol::HTTP::Body::Writable
24
+ class ReadTimeout < Quicsilver::Error; end
25
+
26
+ # @param length [Integer, nil] The content-length if known from headers.
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
+ # A bounded queue must not block the MsQuic poll thread.
30
+ # @param read_timeout [Numeric, nil] Seconds to wait for data before raising
31
+ # ReadTimeout. nil (default) = wait forever.
32
+ def initialize(length = nil, queue_size: nil, read_timeout: nil)
33
+ queue = if queue_size
34
+ Thread::SizedQueue.new(queue_size)
35
+ else
36
+ Thread::Queue.new
37
+ end
38
+
39
+ super(length, queue: queue)
40
+ @read_timeout = read_timeout
41
+ @bytes_written = 0
42
+ end
43
+
44
+ # @attribute [Numeric, nil] Read timeout in seconds.
45
+ attr_reader :read_timeout
46
+
47
+ # Track bytes written for content-length validation.
48
+ def write(chunk)
49
+ @bytes_written += chunk.bytesize
50
+ super
51
+ end
52
+
53
+ # Signal that no more data will be written.
54
+ # Validates content-length if declared (RFC 9114 §4.1.2) — raises
55
+ # MessageError if total bytes written don't match.
56
+ def close_write(error = nil)
57
+ if @length && @bytes_written != @length
58
+ raise Protocol::MessageError, "Content-length mismatch: header=#{@length}, body=#{@bytes_written}"
59
+ end
60
+ super
61
+ end
62
+
63
+ # Read the next available chunk, with optional timeout.
64
+ #
65
+ # @returns [String | Nil] The next chunk, or nil if the body is finished.
66
+ # @raises [ReadTimeout] If no data arrives within the timeout.
67
+ # @raises [Exception] If the body was closed due to an error.
68
+ def read
69
+ if @read_timeout
70
+ read_with_timeout
71
+ else
72
+ super
73
+ end
74
+ end
75
+
76
+ private
77
+
78
+ def read_with_timeout
79
+ raise @error if @error
80
+
81
+ # Thread::Queue#pop(timeout:) blocks efficiently (no busy-wait).
82
+ # Returns nil on timeout OR on closed empty queue — disambiguate below.
83
+ chunk = @queue.pop(timeout: @read_timeout)
84
+
85
+ raise @error if @error
86
+
87
+ # nil means either timeout or body finished (queue closed + empty).
88
+ # If queue is closed, the body is done — return nil (EOF).
89
+ # If queue is still open, we timed out waiting for data.
90
+ if chunk.nil? && !@queue.closed?
91
+ raise ReadTimeout, "No data received within #{@read_timeout}s"
92
+ end
93
+
94
+ chunk
95
+ end
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+
4
+ module Quicsilver
5
+ module Protocol
6
+ # Reads from a Protocol::HTTP::Body::Readable response body and writes
7
+ # HTTP/3 DATA frames to the transport.
8
+ #
9
+ # Transport-agnostic: takes a writer block/callable that handles the actual
10
+ # sending. Works with msquic, kernel QUIC sockets, or any transport that
11
+ # can send bytes with a FIN flag.
12
+ #
13
+ class StreamOutput
14
+ # @param body [Protocol::HTTP::Body::Readable] The response body to stream.
15
+ # @param writer [#call] A callable that accepts (data, fin) — sends bytes
16
+ # to the transport. `fin: true` signals end of stream.
17
+ def initialize(body, &writer)
18
+ @body = body
19
+ @writer = writer
20
+ end
21
+
22
+ # Stream all chunks from the response body as HTTP/3 DATA frames.
23
+ #
24
+ # Each chunk is wrapped in an HTTP/3 DATA frame (type 0x00) and sent
25
+ # via the writer. The final chunk is sent with fin=true.
26
+ #
27
+ # @param send_fin [Boolean] Whether to send FIN after the last chunk.
28
+ # Set to false when trailers will follow.
29
+ # @return [void]
30
+ def stream(send_fin: true)
31
+ last_chunk = nil
32
+
33
+ while (chunk = @body.read)
34
+ if last_chunk
35
+ @writer.call(build_data_frame(last_chunk), false)
36
+ end
37
+ last_chunk = chunk
38
+ end
39
+
40
+ if last_chunk
41
+ @writer.call(build_data_frame(last_chunk), send_fin)
42
+ elsif send_fin
43
+ @writer.call("".b, true)
44
+ end
45
+ ensure
46
+ @body.close if @body.respond_to?(:close)
47
+ end
48
+
49
+ private
50
+
51
+ def build_data_frame(payload)
52
+ payload = payload.b
53
+ Quicsilver::Protocol.encode_varint(Quicsilver::Protocol::FRAME_DATA) +
54
+ Quicsilver::Protocol.encode_varint(payload.bytesize) +
55
+ payload
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Quicsilver
4
+ module Protocol
5
+ module UnidirectionalStream
6
+ CONTROL = 0x00
7
+ PUSH = 0x01
8
+ QPACK_ENCODER = 0x02
9
+ QPACK_DECODER = 0x03
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,214 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Quicsilver
4
+ module Protocol
5
+ module WebTransport
6
+ SESSION_GONE = 0x170d7b68
7
+ BUFFERED_STREAM_REJECTED = 0x3994bd84
8
+
9
+ BIDI_STREAM_TYPE = 0x41
10
+ UNI_STREAM_TYPE = 0x54
11
+ CLOSE_SESSION_CAPSULE = 0x2843
12
+
13
+ # draft-ietf-webtrans-http3-16 §4.7. Advisory: either endpoint asks the
14
+ # other to wind the session down, but the session stays usable until it
15
+ # is actually closed.
16
+ DRAIN_SESSION_CAPSULE = 0x78ae
17
+
18
+ # Session-level flow control (§5.6). These sit above QUIC's own limits:
19
+ # QUIC bounds the connection and each stream, these bound one session
20
+ # across all of its streams.
21
+ #
22
+ # MAX_* raise a limit. *_BLOCKED are advisory, saying a limit was hit;
23
+ # §5.6 forbids waiting for one before raising a limit.
24
+ MAX_DATA_CAPSULE = 0x190B4D3D
25
+ MAX_STREAMS_BIDI_CAPSULE = 0x190B4D3F
26
+ MAX_STREAMS_UNI_CAPSULE = 0x190B4D40
27
+ DATA_BLOCKED_CAPSULE = 0x190B4D41
28
+ STREAMS_BLOCKED_BIDI_CAPSULE = 0x190B4D43
29
+ STREAMS_BLOCKED_UNI_CAPSULE = 0x190B4D44
30
+
31
+ # A flow control violation closes the session, not the connection (§5.6).
32
+ FLOW_CONTROL_ERROR = 0x045d4487
33
+
34
+ # Per-stream data limits belong to the HTTP/2 binding. Over HTTP/3 every
35
+ # WebTransport stream is a real QUIC stream, so QUIC provides them
36
+ # natively and these two capsules are prohibited: receipt is a session
37
+ # error (§5.4).
38
+ #
39
+ # Defined in draft-ietf-webtrans-http2-14 §6.6 and §6.9, which the HTTP/3
40
+ # draft cites without restating the code points. They fill the two gaps
41
+ # in the 0x190B4D3D..0x190B4D44 block the §9.6 registry allocates.
42
+ #
43
+ # That draft is not in docs/specs, so these are the only values here
44
+ # without a local citation. Re-verify them when it is vendored.
45
+ MAX_STREAM_DATA_CAPSULE = 0x190B4D3E
46
+ STREAM_DATA_BLOCKED_CAPSULE = 0x190B4D42
47
+
48
+ PROHIBITED_CAPSULES = [MAX_STREAM_DATA_CAPSULE, STREAM_DATA_BLOCKED_CAPSULE].freeze
49
+
50
+ # Prohibited whether or not flow control was negotiated. §5.1 says to
51
+ # ignore "flow control capsules" when flow control is off, but §5.6
52
+ # defines that term as MAX_DATA, MAX_STREAMS, DATA_BLOCKED and
53
+ # STREAMS_BLOCKED only. These two are never legal over HTTP/3.
54
+ def self.prohibited_capsule?(type)
55
+ PROHIBITED_CAPSULES.include?(type)
56
+ end
57
+
58
+ # Stream limits cannot exceed 2^60: a larger count could not be encoded
59
+ # as a stream ID (§5.6.2, §5.6.3).
60
+ MAX_STREAMS_LIMIT = 1 << 60
61
+
62
+ # Largest value a QUIC variable-length integer can carry (RFC 9000 §16).
63
+ VARINT_MAX = (1 << 62) - 1
64
+
65
+ # Raised for a capsule whose contents break a flow control rule, as
66
+ # opposed to one we cannot parse. The session is closed with
67
+ # FLOW_CONTROL_ERROR; the connection survives.
68
+ class FlowControlError < StandardError
69
+ def error_code = FLOW_CONTROL_ERROR
70
+ end
71
+
72
+ # We wanted to send but the peer's session limit forbids it (§5.3,
73
+ # §5.4). Local and recoverable: nothing is wrong on the wire, there is
74
+ # simply no credit. Distinct from FlowControlError, which means the peer
75
+ # broke a rule and the session must close.
76
+ class SendBlocked < StandardError
77
+ attr_reader :limit, :needed
78
+
79
+ def initialize(message, limit:, needed:)
80
+ super(message)
81
+ @limit = limit
82
+ @needed = needed
83
+ end
84
+ end
85
+
86
+ # What a flow control capsule says. `limit` is cumulative for MAX_DATA
87
+ # and MAX_STREAMS, and for the BLOCKED capsules it is the limit that was
88
+ # in force when the sender was blocked.
89
+ Limit = Data.define(:kind, :direction, :limit) do
90
+ def max? = kind == :max_data || kind == :max_streams
91
+ def blocked? = !max?
92
+ end
93
+
94
+ # kind and direction for each flow control capsule type. Direction is nil
95
+ # where the capsule covers the whole session rather than one stream type.
96
+ FLOW_CONTROL_CAPSULES = {
97
+ MAX_DATA_CAPSULE => [:max_data, nil],
98
+ MAX_STREAMS_BIDI_CAPSULE => [:max_streams, :bidi],
99
+ MAX_STREAMS_UNI_CAPSULE => [:max_streams, :uni],
100
+ DATA_BLOCKED_CAPSULE => [:data_blocked, nil],
101
+ STREAMS_BLOCKED_BIDI_CAPSULE => [:streams_blocked, :bidi],
102
+ STREAMS_BLOCKED_UNI_CAPSULE => [:streams_blocked, :uni]
103
+ }.freeze
104
+
105
+ def self.flow_control_capsule?(type)
106
+ FLOW_CONTROL_CAPSULES.key?(type)
107
+ end
108
+
109
+ # Decode a flow control capsule payload: a single varint (§5.6.2-5.6.5).
110
+ #
111
+ # Raises Capsule::ParseError when the payload is not exactly one varint,
112
+ # and FlowControlError when a stream count exceeds what a stream ID can
113
+ # encode. Those are different outcomes on the wire: the first is a
114
+ # malformed message, the second a flow control violation.
115
+ def self.parse_flow_control_capsule(type, payload)
116
+ kind, direction = FLOW_CONTROL_CAPSULES.fetch(type) do
117
+ raise ArgumentError, "Not a flow control capsule: 0x#{type.to_s(16)}"
118
+ end
119
+
120
+ value, length = Protocol.decode_varint_str(payload, 0)
121
+ raise Capsule::ParseError, "Truncated #{kind} capsule" if length == 0
122
+ raise Capsule::ParseError, "Trailing bytes in #{kind} capsule" unless length == payload.bytesize
123
+
124
+ if (kind == :max_streams || kind == :streams_blocked) && value > MAX_STREAMS_LIMIT
125
+ raise FlowControlError, "#{kind} of #{value} exceeds the 2^60 stream limit"
126
+ end
127
+
128
+ Limit.new(kind: kind, direction: direction, limit: value)
129
+ end
130
+
131
+ def self.build_max_data(limit)
132
+ Capsule.encode(MAX_DATA_CAPSULE, Protocol.encode_varint(validate_limit!(limit)))
133
+ end
134
+
135
+ def self.build_data_blocked(limit)
136
+ Capsule.encode(DATA_BLOCKED_CAPSULE, Protocol.encode_varint(validate_limit!(limit)))
137
+ end
138
+
139
+ def self.build_max_streams(direction, limit)
140
+ type = (direction == :bidi) ? MAX_STREAMS_BIDI_CAPSULE : MAX_STREAMS_UNI_CAPSULE
141
+ Capsule.encode(type, Protocol.encode_varint(validate_stream_limit!(direction, limit)))
142
+ end
143
+
144
+ def self.build_streams_blocked(direction, limit)
145
+ type = (direction == :bidi) ? STREAMS_BLOCKED_BIDI_CAPSULE : STREAMS_BLOCKED_UNI_CAPSULE
146
+ Capsule.encode(type, Protocol.encode_varint(validate_stream_limit!(direction, limit)))
147
+ end
148
+
149
+ # Building a capsule the peer would have to reject is our bug, so these
150
+ # raise ArgumentError rather than a protocol error.
151
+ def self.validate_limit!(limit)
152
+ unless limit.is_a?(Integer) && limit >= 0 && limit <= VARINT_MAX
153
+ raise ArgumentError, "Limit must be a varint-encodable non-negative integer"
154
+ end
155
+
156
+ limit
157
+ end
158
+
159
+ def self.validate_stream_limit!(direction, limit)
160
+ unless direction == :bidi || direction == :uni
161
+ raise ArgumentError, "Direction must be :bidi or :uni"
162
+ end
163
+ validate_limit!(limit)
164
+ raise ArgumentError, "Stream limit #{limit} exceeds 2^60" if limit > MAX_STREAMS_LIMIT
165
+
166
+ limit
167
+ end
168
+
169
+ # Extended CONNECT :protocol values.
170
+ #
171
+ # draft-ietf-webtrans-http3-16 §3.2 requires "webtransport-h3". The bare
172
+ # "webtransport" token identifies the capsule-based HTTP/2 binding
173
+ # (§2.1.2) and was what pre-15 drafts used over HTTP/3. We retain it
174
+ # for legacy interoperability; the token alone does not identify a
175
+ # browser version or prove support for every feature of an older draft.
176
+ PROTOCOL = "webtransport-h3"
177
+ PROTOCOL_LEGACY = "webtransport"
178
+
179
+ def self.protocol?(value)
180
+ value == PROTOCOL || value == PROTOCOL_LEGACY
181
+ end
182
+
183
+ # WebTransport shares the HTTP/3 error space, so 32-bit application error
184
+ # codes are mapped into a reserved range (§4.4, §9.5). Codepoints of the
185
+ # form 0x1f * N + 0x21 are reserved by HTTP/3 §8.1 and skipped, which is
186
+ # why this is not a plain offset.
187
+ APPLICATION_ERROR_FIRST = 0x52e4a40fa8db
188
+ APPLICATION_ERROR_LAST = 0x52e5ac983162
189
+
190
+ RESERVED_STRIDE = 0x1f
191
+ RESERVED_OFFSET = 0x21
192
+
193
+ # Application code -> HTTP/3 code, stepping over reserved codepoints.
194
+ def self.application_error_to_http(code)
195
+ APPLICATION_ERROR_FIRST + code + (code / 0x1e)
196
+ end
197
+
198
+ # HTTP/3 code -> application code. nil when the code is outside the
199
+ # reserved range or lands on a reserved codepoint, neither of which a
200
+ # peer should ever send.
201
+ def self.http_to_application_error(code)
202
+ return nil unless code.between?(APPLICATION_ERROR_FIRST, APPLICATION_ERROR_LAST)
203
+ return nil if reserved_codepoint?(code)
204
+
205
+ shifted = code - APPLICATION_ERROR_FIRST
206
+ shifted - (shifted / RESERVED_STRIDE)
207
+ end
208
+
209
+ def self.reserved_codepoint?(code)
210
+ (code - RESERVED_OFFSET) % RESERVED_STRIDE == 0
211
+ end
212
+ end
213
+ end
214
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Quicsilver
4
+ module Rack
5
+ # Rack-visible Quicsilver transport context.
6
+ #
7
+ # This is the single env capability object for HTTP/3/QUIC-specific state.
8
+ # Normal Rack apps can ignore it; Quicsilver-aware apps can use it to opt into
9
+ # transport features like WebTransport.
10
+ class Context
11
+ attr_reader :stream_id, :webtransport
12
+
13
+ def initialize(stream_id: nil, early_data: false, webtransport: nil, metadata: {})
14
+ @stream_id = stream_id
15
+ @early_data = early_data
16
+ @webtransport = webtransport
17
+ @metadata = metadata || {}
18
+ end
19
+
20
+ def early_data?
21
+ !!@early_data
22
+ end
23
+
24
+ def webtransport?
25
+ !!@webtransport
26
+ end
27
+
28
+ def [](key)
29
+ @metadata[key]
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Quicsilver
4
+ # Unified HTTP/3 response object returned by the client.
5
+ #
6
+ # Used for both buffered and streaming responses:
7
+ # - Buffered: body is a String
8
+ # - Streaming: body is a StreamInput (call body.read in a loop)
9
+ #
10
+ # response = client.get("/users")
11
+ # response.status # => 200
12
+ # response.headers # => {"content-type" => "application/json"}
13
+ # response.body # => '{"users":["alice"]}'
14
+ # response.trailers # => {}
15
+ #
16
+ # # Streaming
17
+ # req = client.build_request("GET", "/stream")
18
+ # response = req.streaming_response(timeout: 10)
19
+ # response.status # => 200
20
+ # while (chunk = response.body.read)
21
+ # process(chunk)
22
+ # end
23
+ #
24
+ class Response
25
+ attr_reader :status, :headers, :body, :trailers
26
+
27
+ def initialize(status:, headers: {}, body: nil, trailers: {})
28
+ @status = status
29
+ @headers = headers
30
+ @body = body
31
+ @trailers = trailers
32
+ end
33
+
34
+ def ok? = status == 200
35
+ def success? = status >= 200 && status < 300
36
+ def redirect? = status >= 300 && status < 400
37
+ def client_error? = status >= 400 && status < 500
38
+ def server_error? = status >= 500 && status < 600
39
+ end
40
+ end