quicsilver 0.3.0 → 0.4.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 (54) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +1 -1
  3. data/.github/workflows/cibuildgem.yaml +93 -0
  4. data/.gitignore +3 -1
  5. data/CHANGELOG.md +32 -0
  6. data/Gemfile.lock +20 -2
  7. data/README.md +92 -29
  8. data/Rakefile +67 -2
  9. data/benchmarks/concurrent.rb +2 -2
  10. data/benchmarks/rails.rb +3 -3
  11. data/benchmarks/throughput.rb +2 -2
  12. data/examples/README.md +44 -91
  13. data/examples/benchmark.rb +111 -0
  14. data/examples/connection_pool_demo.rb +47 -0
  15. data/examples/example_helper.rb +18 -0
  16. data/examples/falcon_middleware.rb +44 -0
  17. data/examples/feature_demo.rb +125 -0
  18. data/examples/grpc_style.rb +97 -0
  19. data/examples/minimal_http3_server.rb +6 -18
  20. data/examples/priorities.rb +60 -0
  21. data/examples/protocol_http_server.rb +31 -0
  22. data/examples/rack_http3_server.rb +8 -20
  23. data/examples/rails_feature_test.rb +260 -0
  24. data/examples/simple_client_test.rb +2 -2
  25. data/examples/streaming_sse.rb +33 -0
  26. data/examples/trailers.rb +69 -0
  27. data/ext/quicsilver/extconf.rb +14 -0
  28. data/ext/quicsilver/quicsilver.c +39 -0
  29. data/lib/quicsilver/client/client.rb +138 -39
  30. data/lib/quicsilver/client/connection_pool.rb +106 -0
  31. data/lib/quicsilver/libmsquic.2.dylib +0 -0
  32. data/lib/quicsilver/protocol/adapter.rb +176 -0
  33. data/lib/quicsilver/protocol/control_stream_parser.rb +106 -0
  34. data/lib/quicsilver/protocol/frame_parser.rb +142 -0
  35. data/lib/quicsilver/protocol/frame_reader.rb +55 -0
  36. data/lib/quicsilver/protocol/frames.rb +18 -7
  37. data/lib/quicsilver/protocol/priority.rb +56 -0
  38. data/lib/quicsilver/protocol/qpack/encoder.rb +39 -1
  39. data/lib/quicsilver/protocol/qpack/header_block_decoder.rb +16 -1
  40. data/lib/quicsilver/protocol/request_parser.rb +28 -140
  41. data/lib/quicsilver/protocol/response_encoder.rb +27 -2
  42. data/lib/quicsilver/protocol/response_parser.rb +22 -130
  43. data/lib/quicsilver/protocol/stream_input.rb +98 -0
  44. data/lib/quicsilver/protocol/stream_output.rb +59 -0
  45. data/lib/quicsilver/quicsilver.bundle +0 -0
  46. data/lib/quicsilver/server/request_handler.rb +96 -44
  47. data/lib/quicsilver/server/server.rb +316 -42
  48. data/lib/quicsilver/transport/configuration.rb +10 -1
  49. data/lib/quicsilver/transport/connection.rb +92 -63
  50. data/lib/quicsilver/version.rb +1 -1
  51. data/lib/quicsilver.rb +26 -3
  52. data/quicsilver.gemspec +10 -2
  53. metadata +69 -5
  54. data/examples/setup_certs.sh +0 -57
@@ -1,21 +1,21 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "timeout"
4
-
5
3
  module Quicsilver
6
4
  class Client
5
+ include Protocol::ControlStreamParser
6
+
7
7
  attr_reader :hostname, :port, :unsecure, :connection_timeout, :request_timeout
8
+ attr_reader :peer_goaway_id, :peer_settings, :peer_max_field_section_size
8
9
 
9
- AlreadyConnectedError = Class.new(StandardError)
10
- NotConnectedError = Class.new(StandardError)
11
10
  StreamFailedToOpenError = Class.new(StandardError)
11
+ GoAwayError = Class.new(StandardError)
12
12
 
13
13
  FINISHED_EVENTS = %w[RECEIVE_FIN RECEIVE STREAM_RESET STOP_SENDING].freeze
14
14
 
15
15
  DEFAULT_REQUEST_TIMEOUT = 30 # seconds
16
16
  DEFAULT_CONNECTION_TIMEOUT = 5000 # ms
17
17
 
18
- def initialize(hostname, port = 4433, options = {})
18
+ def initialize(hostname, port = 4433, **options)
19
19
  @hostname = hostname
20
20
  @port = port
21
21
  @unsecure = options.fetch(:unsecure, true)
@@ -31,55 +31,68 @@ module Quicsilver
31
31
  @response_buffers = {}
32
32
  @pending_requests = {} # handle => Request
33
33
  @mutex = Mutex.new
34
+
35
+ # Server control stream state
36
+ @peer_settings = {}
37
+ @peer_max_field_section_size = nil
38
+ @peer_goaway_id = nil
39
+ @settings_received = false
40
+ @control_stream_id = nil
41
+ @uni_stream_types = {}
34
42
  end
35
43
 
36
- def connect
37
- raise AlreadyConnectedError if @connected
44
+ # --- Class-level API (automatic pooling) ---
45
+ #
46
+ # Quicsilver::Client.get("example.com", 4433, "/users")
47
+ # Quicsilver::Client.post("example.com", 4433, "/data", body: json)
48
+ #
49
+ class << self
50
+ attr_writer :pool
38
51
 
39
- Quicsilver.open_connection
40
- config = Quicsilver.create_configuration(@unsecure)
41
- raise ConnectionError, "Failed to create configuration" if config.nil?
52
+ def pool
53
+ @pool ||= ConnectionPool.new
54
+ end
42
55
 
43
- start_connection(config)
44
- @connected = true
45
- @connection_start_time = Time.now
46
- send_control_stream
47
- Quicsilver.event_loop.start
56
+ def close_pool
57
+ @pool&.close
58
+ @pool = nil
59
+ end
48
60
 
49
- self
50
- rescue => e
51
- cleanup_failed_connection
52
- raise e.is_a?(ConnectionError) || e.is_a?(TimeoutError) ? e : ConnectionError.new("Connection failed: #{e.message}")
53
- ensure
54
- Quicsilver.close_configuration(config)
61
+ # Fire-and-forget HTTP methods with automatic pooling.
62
+ %i[get post patch delete head put].each do |method|
63
+ define_method(method) do |hostname, port, path, headers: {}, body: nil, **options, &block|
64
+ request(hostname, port, method, path, headers: headers, body: body, **options, &block)
65
+ end
66
+ end
67
+
68
+ def request(hostname, port, method, path, headers: {}, body: nil, **options, &block)
69
+ client = pool.checkout(hostname, port, **options)
70
+ client.public_send(method, path, headers: headers, body: body, &block)
71
+ ensure
72
+ pool.checkin(client) if client
73
+ end
55
74
  end
56
75
 
76
+ # Disconnect and close the underlying QUIC connection.
57
77
  def disconnect
58
- return unless @connection_data
78
+ return unless @connected
59
79
 
60
80
  @connected = false
61
81
 
62
- # Fail pending requests
63
82
  @mutex.synchronize do
64
83
  @pending_requests.each_value { |req| req.fail(0, "Connection closed") }
65
84
  @pending_requests.clear
66
85
  @response_buffers.clear
67
86
  end
68
87
 
69
- Quicsilver.close_connection_handle(@connection_data) if @connection_data
70
- @connection_data = nil
88
+ close_connection
71
89
  end
72
90
 
73
- # HTTP methods - block gives you request control, no block returns response directly
91
+ # Instance-level HTTP methods. Auto-connects on first use.
74
92
  #
75
- # # Simple (blocking)
76
- # response = client.get("/users")
77
- #
78
- # # With control
79
- # client.get("/users") do |req|
80
- # req.cancel if should_abort?
81
- # req.response
82
- # end
93
+ # client = Quicsilver::Client.new("example.com", 4433)
94
+ # client.get("/users") # connects automatically
95
+ # client.post("/data", body: json)
83
96
  #
84
97
  %i[get post patch delete head put].each do |method|
85
98
  define_method(method) do |path, headers: {}, body: nil, &block|
@@ -88,9 +101,23 @@ module Quicsilver
88
101
  end
89
102
  end
90
103
 
91
- # Build and send request, returns Request object for lifecycle control
104
+ def draining?
105
+ !@peer_goaway_id.nil?
106
+ end
107
+
108
+ def receive_control_data(stream_id, data) # :nodoc:
109
+ buf = @uni_stream_types.key?(stream_id) ? data : identify_and_strip_stream_type(stream_id, data)
110
+ return if buf.nil? || buf.empty?
111
+
112
+ case @uni_stream_types[stream_id]
113
+ when :control
114
+ parse_control_frames(buf)
115
+ end
116
+ end
117
+
92
118
  def build_request(method, path, headers: {}, body: nil)
93
- raise NotConnectedError unless @connected
119
+ ensure_connected!
120
+ raise GoAwayError, "Connection is draining (GOAWAY received)" if draining?
94
121
 
95
122
  stream = open_stream
96
123
  raise StreamFailedToOpenError unless stream
@@ -121,20 +148,59 @@ module Quicsilver
121
148
  "#{@hostname}:#{@port}"
122
149
  end
123
150
 
151
+ # :nodoc:
152
+ def open_connection
153
+ return self if @connected
154
+
155
+ Quicsilver.open_connection
156
+ config = Quicsilver.create_configuration(@unsecure)
157
+ raise ConnectionError, "Failed to create configuration" if config.nil?
158
+
159
+ start_connection(config)
160
+ @connected = true
161
+ @connection_start_time = Time.now
162
+ send_control_stream
163
+ Quicsilver.event_loop.start
164
+
165
+ self
166
+ rescue => e
167
+ cleanup_failed_connection
168
+ raise e.is_a?(ConnectionError) || e.is_a?(TimeoutError) ? e : ConnectionError.new("Connection failed: #{e.message}")
169
+ ensure
170
+ Quicsilver.close_configuration(config) if config
171
+ end
172
+
173
+ # :nodoc:
174
+ def close_connection
175
+ Quicsilver.close_connection_handle(@connection_data) if @connection_data
176
+ @connection_data = nil
177
+ @connected = false
178
+ end
179
+
124
180
  # Called directly by C extension via dispatch_to_ruby
125
181
  def handle_stream_event(stream_id, event, data, _early_data) # :nodoc:
126
182
  return unless FINISHED_EVENTS.include?(event)
127
183
 
184
+ # Server unidirectional streams (control, QPACK) — process incrementally
185
+ if (stream_id & 0x02) != 0 && (event == "RECEIVE" || event == "RECEIVE_FIN")
186
+ begin
187
+ receive_control_data(stream_id, data)
188
+ rescue Protocol::FrameError => e
189
+ Quicsilver.logger.error("Control stream error: #{e.message} (0x#{e.error_code.to_s(16)})")
190
+ end
191
+ return
192
+ end
193
+
128
194
  @mutex.synchronize do
129
195
  case event
130
196
  when "RECEIVE"
131
- (@response_buffers[stream_id] ||= StringIO.new("".b)).write(data)
197
+ (@response_buffers[stream_id] ||= "".b) << data
132
198
 
133
199
  when "RECEIVE_FIN"
134
200
  event = Transport::StreamEvent.new(data, "RECEIVE_FIN")
135
201
 
136
202
  buffer = @response_buffers.delete(stream_id)
137
- full_data = (buffer&.string || "".b) + event.data
203
+ full_data = (buffer || "".b) + event.data
138
204
 
139
205
  response_parser = Protocol::ResponseParser.new(full_data, max_body_size: @max_body_size,
140
206
  max_header_size: @max_header_size)
@@ -167,6 +233,11 @@ module Quicsilver
167
233
 
168
234
  private
169
235
 
236
+ def ensure_connected!
237
+ return if @connected
238
+ open_connection
239
+ end
240
+
170
241
  def start_connection(config)
171
242
  connection_handle, context_handle = create_connection
172
243
  unless Quicsilver.start_connection(connection_handle, config, @hostname, @port)
@@ -205,7 +276,6 @@ module Quicsilver
205
276
  @control_stream = open_unidirectional_stream
206
277
  @control_stream.send(Protocol.build_control_stream)
207
278
 
208
- # RFC 9204: QPACK encoder (0x02) and decoder (0x03) streams
209
279
  [0x02, 0x03].each do |type|
210
280
  stream = open_unidirectional_stream
211
281
  stream.send([type].pack("C"))
@@ -246,5 +316,34 @@ module Quicsilver
246
316
  raise Error, "Failed to send request"
247
317
  end
248
318
  end
319
+
320
+ # Identify stream type from first byte(s), strip it, return remaining data.
321
+ # Returns nil for unknown/ignored stream types.
322
+ def identify_and_strip_stream_type(stream_id, data)
323
+ stream_type, type_len = Protocol.decode_varint(data.bytes, 0)
324
+ return nil if type_len == 0
325
+
326
+ case stream_type
327
+ when 0x00 # Control stream
328
+ raise Protocol::FrameError, "Duplicate control stream" if @control_stream_id
329
+ @control_stream_id = stream_id
330
+ @uni_stream_types[stream_id] = :control
331
+ when 0x02 # QPACK encoder stream
332
+ @uni_stream_types[stream_id] = :qpack_encoder
333
+ when 0x03 # QPACK decoder stream
334
+ @uni_stream_types[stream_id] = :qpack_decoder
335
+ else
336
+ # Unknown unidirectional stream types MUST be ignored (RFC 9114 §6.2)
337
+ @uni_stream_types[stream_id] = :unknown
338
+ return nil
339
+ end
340
+
341
+ data[type_len..] || "".b
342
+ end
343
+
344
+ def on_settings_received(settings)
345
+ @peer_settings.merge!(settings)
346
+ @peer_max_field_section_size = settings[0x06] if settings.key?(0x06)
347
+ end
249
348
  end
250
349
  end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Quicsilver
4
+ class Client
5
+ # Thread-safe pool of connected Client instances, keyed by (host, port).
6
+ # Idle connections are reused automatically. Stale ones are evicted at checkout.
7
+ #
8
+ # Quicsilver::Client.get("example.com", 4433, "/users")
9
+ #
10
+ class ConnectionPool
11
+ attr_reader :max_size, :idle_timeout
12
+
13
+ DEFAULT_MAX_SIZE = 4
14
+ DEFAULT_IDLE_TIMEOUT = 60 # seconds
15
+
16
+ def initialize(max_size: DEFAULT_MAX_SIZE, idle_timeout: DEFAULT_IDLE_TIMEOUT)
17
+ @max_size = max_size
18
+ @idle_timeout = idle_timeout
19
+ @pools = {} # "host:port" => [{ client:, checked_out: }]
20
+ @mutex = Mutex.new
21
+ end
22
+
23
+ # Check out a connected Client. Reuses an idle one or creates a new one.
24
+ def checkout(hostname, port, **options)
25
+ key = "#{hostname}:#{port}"
26
+
27
+ @mutex.synchronize do
28
+ entries = @pools[key] ||= []
29
+
30
+ # Evict dead/stale/draining
31
+ entries.reject! do |e|
32
+ if !e[:checked_out] && (!e[:client].connected? || e[:client].draining? || e[:last_used] < Time.now - @idle_timeout)
33
+ e[:client].close_connection
34
+ true
35
+ end
36
+ end
37
+
38
+ # Reuse an idle client (skip draining ones)
39
+ idle = entries.find { |e| !e[:checked_out] && e[:client].connected? && !e[:client].draining? }
40
+ if idle
41
+ idle[:checked_out] = true
42
+ idle[:last_used] = Time.now
43
+ return idle[:client]
44
+ end
45
+
46
+ if entries.size >= @max_size
47
+ raise ConnectionError, "Connection pool full for #{key} (max: #{@max_size})"
48
+ end
49
+ end
50
+
51
+ # Create outside the lock (blocking I/O)
52
+ client = Client.new(hostname, port, **options)
53
+ client.open_connection
54
+
55
+ @mutex.synchronize do
56
+ (@pools[key] ||= []) << { client: client, checked_out: true, last_used: Time.now }
57
+ end
58
+
59
+ client
60
+ end
61
+
62
+ # Return a Client to the pool.
63
+ def checkin(client)
64
+ key = "#{client.hostname}:#{client.port}"
65
+
66
+ @mutex.synchronize do
67
+ entries = @pools[key]
68
+ return unless entries
69
+
70
+ entry = entries.find { |e| e[:client].equal?(client) }
71
+ return unless entry
72
+
73
+ if client.connected?
74
+ entry[:checked_out] = false
75
+ entry[:last_used] = Time.now
76
+ else
77
+ entries.delete(entry)
78
+ client.close_connection
79
+ @pools.delete(key) if entries.empty?
80
+ end
81
+ end
82
+ end
83
+
84
+ # Close all clients.
85
+ def close
86
+ @mutex.synchronize do
87
+ @pools.each_value do |entries|
88
+ entries.each { |e| e[:client].close_connection }
89
+ end
90
+ @pools.clear
91
+ end
92
+ end
93
+
94
+ # Total clients in the pool, optionally filtered by host:port.
95
+ def size(host = nil, port = nil)
96
+ @mutex.synchronize do
97
+ if host && port
98
+ (@pools["#{host}:#{port}"] || []).size
99
+ else
100
+ @pools.values.sum(&:size)
101
+ end
102
+ end
103
+ end
104
+ end
105
+ end
106
+ end
Binary file
@@ -0,0 +1,176 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "protocol/http/request"
4
+ require "protocol/http/response"
5
+ require "protocol/http/headers"
6
+ require_relative "stream_input"
7
+ require_relative "stream_output"
8
+
9
+ module Quicsilver
10
+ module Protocol
11
+ # Converts between QUIC/HTTP/3 frames and protocol-http Request/Response
12
+ # objects. This enables integration with Falcon and any other server
13
+ # built on protocol-http.
14
+ #
15
+ # Usage:
16
+ # adapter = Protocol::Adapter.new(app)
17
+ # request, body = adapter.build_request(parsed_headers)
18
+ # body.write(chunk) # feed body data
19
+ # body.close_write # signal end of body
20
+ # response = adapter.call(request)
21
+ # adapter.send_response(response, writer)
22
+ #
23
+ class Adapter
24
+ VERSION = "HTTP/3"
25
+
26
+ def initialize(app)
27
+ @app = app
28
+ @qpack_encoder = Quicsilver::Protocol::Qpack::Encoder.new
29
+ end
30
+
31
+ # Build a Protocol::HTTP::Request from parsed HTTP/3 headers.
32
+ #
33
+ # Returns [request, body] where body is a Protocol::StreamInput that
34
+ # the transport feeds RECEIVE chunks into.
35
+ #
36
+ # @param headers [Hash] Parsed headers from RequestParser (includes pseudo-headers).
37
+ # @return [Array(Protocol::HTTP::Request, Protocol::StreamInput)] request and body.
38
+ # Body is nil for bodyless methods (GET, HEAD, TRACE).
39
+ # Caller feeds RECEIVE data into body via write(), then close_write on FIN.
40
+ def build_request(headers)
41
+ method = headers[":method"]
42
+ scheme = headers[":scheme"] || "https"
43
+ authority = headers[":authority"]
44
+ path = headers[":path"]
45
+ protocol = headers[":protocol"]
46
+ content_length = headers["content-length"]&.to_i
47
+
48
+ protocol_headers = ::Protocol::HTTP::Headers.new
49
+ headers.each do |name, value|
50
+ next if name.start_with?(":")
51
+ protocol_headers.add(name, value)
52
+ end
53
+
54
+ body = unless bodyless_request?(method)
55
+ Protocol::StreamInput.new(content_length)
56
+ end
57
+
58
+ request = ::Protocol::HTTP::Request.new(
59
+ scheme, authority, method, path, VERSION,
60
+ protocol_headers, body, protocol
61
+ )
62
+
63
+ [request, body]
64
+ end
65
+
66
+ # Send a Protocol::HTTP::Response via a transport writer.
67
+ #
68
+ # Encodes the response headers as an HTTP/3 HEADERS frame, then streams
69
+ # the response body as DATA frames via Protocol::StreamOutput.
70
+ #
71
+ # @param response [Protocol::HTTP::Response] The response to send.
72
+ # @param writer [#call] Transport writer — accepts (data, fin) for sending bytes.
73
+ # @param head_request [Boolean] Whether this was a HEAD request.
74
+ # @return [void]
75
+ def send_response(response, writer, head_request: false)
76
+ status = response.status
77
+ headers = response.headers
78
+ trailers = extract_trailers(headers)
79
+ headers_hash = response_headers_hash(headers)
80
+ body = response.body
81
+
82
+ if body.nil? || head_request
83
+ send_headers_only(status, headers_hash, writer, trailers: trailers)
84
+ elsif body.respond_to?(:read)
85
+ stream_response(status, headers_hash, body, writer, trailers: trailers)
86
+ else
87
+ buffer_response(status, headers_hash, body, writer, trailers: trailers)
88
+ end
89
+ end
90
+
91
+ # Call the protocol-http application with the request.
92
+ #
93
+ # @param request [Protocol::HTTP::Request]
94
+ # @return [Protocol::HTTP::Response]
95
+ def call(request)
96
+ @app.call(request)
97
+ end
98
+
99
+ private
100
+
101
+ # Methods where a body has no defined semantics (RFC 9110 §9.3.1, §9.3.2, §9.3.8).
102
+ # GET and HEAD SHOULD NOT have a body. TRACE MUST NOT.
103
+ # DELETE, OPTIONS, CONNECT can all have meaningful bodies.
104
+ BODYLESS_METHODS = %w[GET HEAD TRACE].freeze
105
+
106
+ def bodyless_request?(method)
107
+ BODYLESS_METHODS.include?(method)
108
+ end
109
+
110
+ # No body — send HEADERS with FIN
111
+ def send_headers_only(status, headers, writer, trailers: nil)
112
+ encoder = Quicsilver::Protocol::ResponseEncoder.new(status, headers, [], trailers: trailers)
113
+ writer.call(encoder.encode, true)
114
+ end
115
+
116
+ # Streaming body (protocol-http Body::Readable) — send HEADERS, then
117
+ # stream DATA frames as chunks arrive. Used by Falcon mode.
118
+ def stream_response(status, headers, body, writer, trailers: nil)
119
+ has_trailers = trailers&.any?
120
+ writer.call(build_headers_frame(status, headers), false)
121
+ Protocol::StreamOutput.new(body, &writer).stream(send_fin: !has_trailers)
122
+ writer.call(build_trailer_frame(trailers), true) if has_trailers
123
+ end
124
+
125
+ # Buffered body (Rack array or enumerable) — encode everything and send.
126
+ def buffer_response(status, headers, body, writer, trailers: nil)
127
+ parts = body.respond_to?(:each) ? body : [body.to_s]
128
+ encoder = Quicsilver::Protocol::ResponseEncoder.new(status, headers, parts, trailers: trailers)
129
+ writer.call(encoder.encode, true)
130
+ ensure
131
+ body.close if body.respond_to?(:close)
132
+ end
133
+
134
+ # Extract trailers from Protocol::HTTP::Headers if present.
135
+ # Returns a Hash or nil.
136
+ def extract_trailers(headers)
137
+ return nil unless headers.respond_to?(:trailer?) && headers.trailer?
138
+
139
+ result = {}
140
+ headers.trailer.each do |name, value|
141
+ result[name] = value
142
+ end
143
+ result
144
+ end
145
+
146
+ # Convert Protocol::HTTP::Headers to a plain Hash for ResponseEncoder.
147
+ # Only includes headers, not trailers.
148
+ def response_headers_hash(headers)
149
+ return {} unless headers
150
+
151
+ headers.header.to_h
152
+ end
153
+
154
+ # Build an HTTP/3 HEADERS frame from key-value pairs
155
+ def build_qpack_frame(pairs)
156
+ encoded = @qpack_encoder.encode(pairs)
157
+ Quicsilver::Protocol.encode_varint(Quicsilver::Protocol::FRAME_HEADERS) +
158
+ Quicsilver::Protocol.encode_varint(encoded.bytesize) +
159
+ encoded
160
+ end
161
+
162
+ # Build a response HEADERS frame (with :status pseudo-header)
163
+ def build_headers_frame(status, headers)
164
+ pairs = [[":status", status.to_s]]
165
+ headers.each { |name, value| pairs << [name.to_s.downcase, value.to_s] }
166
+ build_qpack_frame(pairs)
167
+ end
168
+
169
+ # Build a trailer HEADERS frame
170
+ def build_trailer_frame(trailers)
171
+ pairs = trailers.map { |name, value| [name.to_s.downcase, value.to_s] }
172
+ build_qpack_frame(pairs)
173
+ end
174
+ end
175
+ end
176
+ end
@@ -0,0 +1,106 @@
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
+ def parse_control_frames(data)
25
+ first_frame = !@settings_received
26
+
27
+ Protocol::FrameReader.each(data) do |type, payload|
28
+ if first_frame && type != Protocol::FRAME_SETTINGS
29
+ raise Protocol::FrameError.new("First frame on control stream must be SETTINGS",
30
+ error_code: Protocol::H3_MISSING_SETTINGS)
31
+ end
32
+ first_frame = false
33
+
34
+ case type
35
+ when Protocol::FRAME_SETTINGS
36
+ raise Protocol::FrameError, "Duplicate SETTINGS frame on control stream" if @settings_received
37
+ parse_peer_settings(payload)
38
+ @settings_received = true
39
+ when Protocol::FRAME_GOAWAY
40
+ parse_peer_goaway(payload)
41
+ else
42
+ handle_control_frame(type, payload)
43
+ end
44
+ end
45
+ end
46
+
47
+ def parse_peer_settings(payload)
48
+ offset = 0
49
+ seen = Set.new
50
+ settings = {}
51
+
52
+ while offset < payload.bytesize
53
+ id, id_len = Protocol.decode_varint(payload.bytes, offset)
54
+ value, value_len = Protocol.decode_varint(payload.bytes, offset + id_len)
55
+ break if id_len == 0 || value_len == 0
56
+
57
+ if HTTP2_SETTINGS.include?(id)
58
+ raise Protocol::FrameError.new("HTTP/2 setting identifier 0x#{id.to_s(16)} not allowed in HTTP/3",
59
+ error_code: Protocol::H3_SETTINGS_ERROR)
60
+ end
61
+
62
+ raise Protocol::FrameError, "Duplicate setting identifier 0x#{id.to_s(16)}" if seen.include?(id)
63
+ seen.add(id)
64
+
65
+ settings[id] = value
66
+ offset += id_len + value_len
67
+ end
68
+
69
+ on_settings_received(settings)
70
+ end
71
+
72
+ # RFC 9114 §7.2.6: Validate incoming GOAWAY frame.
73
+ # Stream ID must be a client-initiated bidirectional stream ID (divisible by 4)
74
+ # and must not increase from a previous GOAWAY.
75
+ def parse_peer_goaway(payload)
76
+ stream_id, _ = Protocol.decode_varint(payload.bytes, 0)
77
+
78
+ unless stream_id % 4 == 0
79
+ raise Protocol::FrameError.new(
80
+ "GOAWAY stream ID #{stream_id} is not a client-initiated bidirectional stream ID",
81
+ error_code: Protocol::H3_ID_ERROR)
82
+ end
83
+
84
+ if @peer_goaway_id && stream_id > @peer_goaway_id
85
+ raise Protocol::FrameError.new(
86
+ "GOAWAY stream ID #{stream_id} exceeds previous #{@peer_goaway_id}",
87
+ error_code: Protocol::H3_ID_ERROR)
88
+ end
89
+
90
+ @peer_goaway_id = stream_id
91
+ end
92
+
93
+ private
94
+
95
+ # Override in includer to store settings. Default: no-op.
96
+ def on_settings_received(settings)
97
+ end
98
+
99
+ # Override in includer to handle additional frame types on the control stream.
100
+ # Server handles FORBIDDEN_ON_CONTROL and PRIORITY_UPDATE here.
101
+ # Client ignores unknown frames.
102
+ def handle_control_frame(type, payload)
103
+ end
104
+ end
105
+ end
106
+ end