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,104 +1,181 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "timeout"
4
-
5
3
  module Quicsilver
6
4
  class Client
7
- attr_reader :hostname, :port, :unsecure, :connection_timeout, :request_timeout
5
+ include Protocol::ControlStreamParser
8
6
 
9
- AlreadyConnectedError = Class.new(StandardError)
10
- NotConnectedError = Class.new(StandardError)
11
- StreamFailedToOpenError = Class.new(StandardError)
7
+ attr_reader :hostname, :port, :unsecure, :connection_timeout, :request_timeout, :idle_timeout_ms
8
+ attr_reader :peer_goaway_id, :peer_settings, :peer_max_field_section_size
12
9
 
13
- FINISHED_EVENTS = %w[RECEIVE_FIN RECEIVE STREAM_RESET STOP_SENDING].freeze
10
+ FINISHED_EVENTS = %w[RECEIVE_FIN RECEIVE STREAM_RESET STOP_SENDING DATAGRAM_RECEIVED STREAM_START_COMPLETE STREAM_PEER_ACCEPTED].freeze
14
11
 
15
12
  DEFAULT_REQUEST_TIMEOUT = 30 # seconds
16
13
  DEFAULT_CONNECTION_TIMEOUT = 5000 # ms
17
14
 
18
- def initialize(hostname, port = 4433, options = {})
15
+ # Options that shape the QUIC connection rather than a single request.
16
+ # ConnectionPool keys pooled connections on these, so a connection built
17
+ # with one set is never reused for a caller asking for another.
18
+ DEFAULT_CONNECTION_OPTIONS = {
19
+ unsecure: false,
20
+ datagram_receive_enabled: true,
21
+ reliable_reset_enabled: true,
22
+ transport_cibir_id: nil,
23
+ idle_timeout_ms: Transport::Configuration::DEFAULT_IDLE_TIMEOUT_MS
24
+ }.freeze
25
+
26
+ def initialize(hostname, port = 4433, **options)
19
27
  @hostname = hostname
20
28
  @port = port
21
- @unsecure = options.fetch(:unsecure, true)
29
+ @unsecure = options.fetch(:unsecure, DEFAULT_CONNECTION_OPTIONS[:unsecure])
30
+ @datagram_receive_enabled = options.fetch(:datagram_receive_enabled,
31
+ DEFAULT_CONNECTION_OPTIONS[:datagram_receive_enabled])
32
+ @reliable_reset_enabled = options.fetch(:reliable_reset_enabled,
33
+ DEFAULT_CONNECTION_OPTIONS[:reliable_reset_enabled])
22
34
  @connection_timeout = options.fetch(:connection_timeout, DEFAULT_CONNECTION_TIMEOUT)
35
+ @idle_timeout_ms = options.fetch(:idle_timeout_ms, DEFAULT_CONNECTION_OPTIONS[:idle_timeout_ms])
23
36
  @request_timeout = options.fetch(:request_timeout, DEFAULT_REQUEST_TIMEOUT)
24
37
  @max_body_size = options[:max_body_size]
25
38
  @max_header_size = options[:max_header_size]
26
39
 
40
+ # MsQuic CIBIR bytes for connecting to a CIBIR-configured listener.
41
+ # Must be set before ConnectionStart; MsQuic currently supports offset 0 only.
42
+ @transport_cibir_id = normalize_transport_cibir_id(options[:transport_cibir_id])
43
+
27
44
  @connection_data = nil
28
45
  @connected = false
29
46
  @connection_start_time = nil
30
47
 
31
- @response_buffers = {}
32
- @pending_requests = {} # handle => Request
48
+ @response_buffers = {} # stream_id => binary data
49
+ @streaming = {} # stream_id => { body:, frame_buffer: }
50
+ @inflight = {} # handle => { request:, stream_id: }
33
51
  @mutex = Mutex.new
52
+
53
+ # Server control stream state
54
+ @peer_settings = {}
55
+ @peer_max_field_section_size = nil
56
+ @peer_goaway_id = nil
57
+ @settings_received = false
58
+ @control_stream_id = nil
59
+ @uni_stream_types = {}
60
+ @datagram_callback = nil
61
+ @resumption_ticket = nil # stored ticket for 0-RTT reconnection
34
62
  end
35
63
 
36
- def connect
37
- raise AlreadyConnectedError if @connected
64
+ # --- Class-level API (automatic pooling) ---
65
+ #
66
+ # Quicsilver::Client.get("example.com", 4433, "/users")
67
+ # Quicsilver::Client.post("example.com", 4433, "/data", body: json)
68
+ #
69
+ class << self
70
+ attr_writer :pool
38
71
 
39
- Quicsilver.open_connection
40
- config = Quicsilver.create_configuration(@unsecure)
41
- raise ConnectionError, "Failed to create configuration" if config.nil?
72
+ def pool
73
+ @pool ||= ConnectionPool.new
74
+ end
42
75
 
43
- start_connection(config)
44
- @connected = true
45
- @connection_start_time = Time.now
46
- send_control_stream
47
- Quicsilver.event_loop.start
76
+ def close_pool
77
+ @pool&.close
78
+ @pool = nil
79
+ end
48
80
 
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)
81
+ # Fire-and-forget HTTP methods with automatic pooling.
82
+ %i[get post patch delete head put].each do |method|
83
+ define_method(method) do |hostname, port, path, headers: {}, body: nil, **options, &block|
84
+ request(hostname, port, method, path, headers: headers, body: body, **options, &block)
85
+ end
86
+ end
87
+
88
+ def request(hostname, port, method, path, headers: {}, body: nil, priority: nil, timeout: nil, **options, &block)
89
+ client = pool.checkout(hostname, port, **options)
90
+ client.public_send(method, path, headers: headers, body: body, priority: priority, timeout: timeout, &block)
91
+ ensure
92
+ pool.checkin(client) if client
93
+ end
55
94
  end
56
95
 
96
+ # Disconnect and close the underlying QUIC connection.
97
+ # Saves the resumption ticket for 0-RTT on the next connect.
57
98
  def disconnect
58
- return unless @connection_data
99
+ return unless @connected
59
100
 
60
101
  @connected = false
61
102
 
62
- # Fail pending requests
103
+ # Save resumption ticket before closing (for 0-RTT reconnection)
104
+ if @connection_data
105
+ ticket = Quicsilver.get_resumption_ticket(@connection_data[1])
106
+ @resumption_ticket = ticket if ticket
107
+ end
108
+
63
109
  @mutex.synchronize do
64
- @pending_requests.each_value { |req| req.fail(0, "Connection closed") }
65
- @pending_requests.clear
110
+ @inflight.each_value { |entry| entry[:request].fail(0, "Connection closed") }
111
+ @streaming.each_value { |state| state[:body]&.close(RuntimeError.new("Connection closed")) }
112
+ @inflight.clear
66
113
  @response_buffers.clear
114
+ @streaming.clear
67
115
  end
68
116
 
69
- Quicsilver.close_connection_handle(@connection_data) if @connection_data
70
- @connection_data = nil
117
+ close_connection
71
118
  end
72
119
 
73
- # HTTP methods - block gives you request control, no block returns response directly
74
- #
75
- # # Simple (blocking)
76
- # response = client.get("/users")
120
+ # Instance-level HTTP methods. Auto-connects on first use.
77
121
  #
78
- # # With control
79
- # client.get("/users") do |req|
80
- # req.cancel if should_abort?
81
- # req.response
82
- # end
122
+ # client = Quicsilver::Client.new("example.com", 4433)
123
+ # client.get("/users") # connects automatically
124
+ # client.post("/data", body: json)
83
125
  #
84
126
  %i[get post patch delete head put].each do |method|
85
- define_method(method) do |path, headers: {}, body: nil, &block|
86
- req = build_request(method.to_s.upcase, path, headers: headers, body: body)
87
- block ? block.call(req) : req.response
127
+ define_method(method) do |path, headers: {}, body: nil, priority: nil, timeout: nil, &block|
128
+ req = build_request(method.to_s.upcase, path, headers: headers, body: body, priority: priority)
129
+ block ? block.call(req) : req.response(timeout: timeout)
130
+ end
131
+ end
132
+
133
+ def draining?
134
+ !@peer_goaway_id.nil?
135
+ end
136
+
137
+ # Send an unreliable datagram (RFC 9297 / QUIC RFC 9221).
138
+ # Data must fit in a single QUIC packet (~1200 bytes).
139
+ # Not retransmitted — best effort delivery.
140
+ #
141
+ # client.datagram_send("real-time-update")
142
+ #
143
+ def datagram_send(data)
144
+ ensure_connected!
145
+ Quicsilver.datagram_send(@connection_data, data.to_s.b)
146
+ end
147
+
148
+ # Register a callback for received datagrams.
149
+ #
150
+ # client.on_datagram { |data| puts "Got: #{data}" }
151
+ #
152
+ def on_datagram(&block)
153
+ @datagram_callback = block
154
+ end
155
+
156
+ def receive_control_data(stream_id, data) # :nodoc:
157
+ buf = @uni_stream_types.key?(stream_id) ? data : identify_and_strip_stream_type(stream_id, data)
158
+ return if buf.nil? || buf.empty?
159
+
160
+ case @uni_stream_types[stream_id]
161
+ when :control
162
+ parse_control_frames(buf)
88
163
  end
89
164
  end
90
165
 
91
- # Build and send request, returns Request object for lifecycle control
92
- def build_request(method, path, headers: {}, body: nil)
93
- raise NotConnectedError unless @connected
166
+ def build_request(method, path, headers: {}, body: nil, priority: nil)
167
+ ensure_connected!
168
+ raise GoAwayError, "Connection is draining (GOAWAY received)" if draining?
94
169
 
95
170
  stream = open_stream
96
171
  raise StreamFailedToOpenError unless stream
97
172
 
98
173
  request = Request.new(self, stream)
99
- @mutex.synchronize { @pending_requests[stream.handle] = request }
174
+ @mutex.synchronize do
175
+ @inflight[stream.handle] = { request: request, stream_id: nil }
176
+ end
100
177
 
101
- send_to_stream(stream, method, path, headers, body)
178
+ send_to_stream(stream, method, path, headers, body, priority: priority)
102
179
 
103
180
  request
104
181
  end
@@ -107,6 +184,15 @@ module Quicsilver
107
184
  @connected && @connection_data && connection_alive?
108
185
  end
109
186
 
187
+ # Returns QUIC transport statistics (RTT, packet counts, congestion, etc.)
188
+ # from MsQuic's QUIC_STATISTICS_V2. Returns nil when not connected.
189
+ def stats
190
+ return nil unless @connected && @connection_data
191
+ Transport::ConnectionStats.from_hash(Quicsilver.connection_statistics(@connection_data[0]))
192
+ rescue
193
+ nil
194
+ end
195
+
110
196
  def connection_info
111
197
  info = @connection_data ? Quicsilver.connection_status(@connection_data[1]) : {}
112
198
  info.merge(hostname: @hostname, port: @port, uptime: connection_uptime)
@@ -121,43 +207,104 @@ module Quicsilver
121
207
  "#{@hostname}:#{@port}"
122
208
  end
123
209
 
210
+ # :nodoc:
211
+ def open_connection
212
+ return self if @connected
213
+
214
+ Quicsilver.open_connection
215
+ # The poll thread is the only thing that drives MsQuic, including this
216
+ # handshake; wait_for_connection only watches for the result. In a
217
+ # process with no server the loop is not running yet, so start it first.
218
+ Quicsilver.event_loop.start
219
+ config = create_configuration
220
+ raise ConnectionError, "Failed to create configuration" if config.nil?
221
+
222
+ start_connection(config)
223
+ @connected = true
224
+ @connection_start_time = Time.now
225
+ send_control_stream
226
+
227
+ self
228
+ rescue => e
229
+ cleanup_failed_connection
230
+ raise e.is_a?(ConnectionError) || e.is_a?(TimeoutError) ? e : ConnectionError.new("Connection failed: #{e.message}")
231
+ ensure
232
+ Quicsilver.close_configuration(config) if config
233
+ end
234
+
235
+ # :nodoc:
236
+ def close_connection
237
+ Quicsilver.close_connection_handle(@connection_data) if @connection_data
238
+ @connection_data = nil
239
+ @connected = false
240
+ end
241
+
124
242
  # Called directly by C extension via dispatch_to_ruby
125
243
  def handle_stream_event(stream_id, event, data, _early_data) # :nodoc:
126
244
  return unless FINISHED_EVENTS.include?(event)
127
245
 
246
+ # Server unidirectional streams (control, QPACK) — process incrementally
247
+ if Transport::StreamId.unidirectional?(stream_id) && (event == "RECEIVE" || event == "RECEIVE_FIN")
248
+ begin
249
+ receive_control_data(stream_id, data)
250
+ rescue Protocol::FrameError => e
251
+ Quicsilver.logger.error("Control stream error: #{e.message} (0x#{e.error_code.to_s(16)})")
252
+ end
253
+ return
254
+ end
255
+
128
256
  @mutex.synchronize do
129
257
  case event
130
258
  when "RECEIVE"
131
- (@response_buffers[stream_id] ||= StringIO.new("".b)).write(data)
132
-
259
+ handle_receive(stream_id, data)
133
260
  when "RECEIVE_FIN"
134
- event = Transport::StreamEvent.new(data, "RECEIVE_FIN")
135
-
136
- buffer = @response_buffers.delete(stream_id)
137
- full_data = (buffer&.string || "".b) + event.data
138
-
139
- response_parser = Protocol::ResponseParser.new(full_data, max_body_size: @max_body_size,
140
- max_header_size: @max_header_size)
141
- response_parser.parse
142
-
143
- response = {
144
- status: response_parser.status,
145
- headers: response_parser.headers,
146
- body: response_parser.body.read
147
- }
148
-
149
- request = @pending_requests.delete(event.handle)
150
- request&.complete(response)
151
-
261
+ handle_receive_fin(stream_id, data)
152
262
  when "STREAM_RESET"
153
263
  event = Transport::StreamEvent.new(data, "STREAM_RESET")
154
- request = @pending_requests.delete(event.handle)
155
- request&.fail(event.error_code, "Stream reset by peer")
156
-
264
+ state = @streaming.delete(stream_id)
265
+ state&.dig(:body)&.close(RuntimeError.new("Stream reset by peer"))
266
+ entry = @inflight.delete(event.handle)
267
+ entry[:request]&.fail(event.error_code, "Stream reset by peer") if entry
157
268
  when "STOP_SENDING"
158
269
  event = Transport::StreamEvent.new(data, "STOP_SENDING")
159
- request = @pending_requests.delete(event.handle)
160
- request&.fail(event.error_code, "Peer sent STOP_SENDING")
270
+ state = @streaming.delete(stream_id)
271
+ state&.dig(:body)&.close(RuntimeError.new("Peer sent STOP_SENDING"))
272
+ entry = @inflight.delete(event.handle)
273
+ entry[:request]&.fail(event.error_code, "Peer sent STOP_SENDING") if entry
274
+ when "DATAGRAM_RECEIVED"
275
+ @datagram_callback&.call(data)
276
+ when "STREAM_START_COMPLETE"
277
+ # QUIC stream concurrency limit (MAX_STREAMS, RFC 9000 §4.6):
278
+ #
279
+ # The peer advertises how many streams can be open simultaneously
280
+ # on this connection via the initial_max_streams_bidi transport
281
+ # parameter. Controlled server-side by max_concurrent_requests
282
+ # (default 100, matches quic-go and Chromium).
283
+ #
284
+ # When the limit is hit, MsQuic queues new streams locally and
285
+ # sends a STREAMS_BLOCKED frame to the peer (RFC 9000 §19.14,
286
+ # SHOULD send, useful for debugging — not a flow control
287
+ # mechanism). The peer independently raises the limit by
288
+ # sending MAX_STREAMS as existing streams close.
289
+ #
290
+ # peer_accepted=true: stream is flowing immediately.
291
+ # peer_accepted=false: stream queued, connection is saturated.
292
+ #
293
+ # When saturated, the shared pool could open a second connection
294
+ # to the same host. Each QUIC connection has independent stream
295
+ # capacity, congestion control, and TLS state — so a saturated
296
+ # connection doesn't block a fresh one. This scales to
297
+ # N connections × 100 streams = N×100 concurrent requests.
298
+ # The ceiling is the server's max_connections (default 100).
299
+ #
300
+ # Rotation can be useful for long-lived streams:
301
+ # SSE, WebTransport data channels, gRPC streaming RPCs.
302
+ accepted = data.getbyte(8) == 1
303
+ Quicsilver.logger.debug("Stream #{stream_id} start complete (peer_accepted=#{accepted})")
304
+ when "STREAM_PEER_ACCEPTED"
305
+ # Previously queued stream now accepted — peer sent MAX_STREAMS
306
+ # (existing streams closed, freeing stream concurrency capacity).
307
+ Quicsilver.logger.debug("Stream #{stream_id} accepted by peer (MAX_STREAMS raised)")
161
308
  end
162
309
  end
163
310
  rescue => e
@@ -167,8 +314,29 @@ module Quicsilver
167
314
 
168
315
  private
169
316
 
317
+ def create_configuration
318
+ Quicsilver.create_configuration(@unsecure, @datagram_receive_enabled, @reliable_reset_enabled,
319
+ nil, nil, @idle_timeout_ms)
320
+ end
321
+
322
+ def ensure_connected!
323
+ return if @connected
324
+ @mutex.synchronize do
325
+ return if @connected
326
+ open_connection
327
+ end
328
+ end
329
+
170
330
  def start_connection(config)
171
331
  connection_handle, context_handle = create_connection
332
+
333
+ configure_cibir(connection_handle)
334
+
335
+ # Apply saved resumption ticket for 0-RTT reconnection
336
+ if @resumption_ticket
337
+ Quicsilver.set_resumption_ticket(connection_handle, @resumption_ticket)
338
+ end
339
+
172
340
  unless Quicsilver.start_connection(connection_handle, config, @hostname, @port)
173
341
  cleanup_failed_connection
174
342
  raise ConnectionError, "Failed to start connection"
@@ -185,6 +353,23 @@ module Quicsilver
185
353
  @connection_data
186
354
  end
187
355
 
356
+ def configure_cibir(connection_handle)
357
+ return unless @transport_cibir_id
358
+
359
+ Quicsilver.configure_connection_cibir(connection_handle, [@transport_cibir_id].pack("H*"))
360
+ end
361
+
362
+ def normalize_transport_cibir_id(value)
363
+ return if value.nil?
364
+
365
+ cibir_id = value.to_s
366
+ unless cibir_id.match?(/\A[[:xdigit:]]+\z/) && cibir_id.bytesize.even? && cibir_id.bytesize.between?(2, 12)
367
+ raise ArgumentError, "transport_cibir_id must be 1..6 bytes encoded as an even-length hex string"
368
+ end
369
+
370
+ cibir_id.downcase
371
+ end
372
+
188
373
  def cleanup_failed_connection
189
374
  Quicsilver.close_connection_handle(@connection_data) if @connection_data
190
375
  @connection_data = nil
@@ -194,6 +379,16 @@ module Quicsilver
194
379
  def open_stream
195
380
  handle = Quicsilver.open_stream(@connection_data, false)
196
381
  Transport::Stream.new(handle)
382
+ rescue RuntimeError => e
383
+ status = TransportError.parse_status(e.message)
384
+ case e.message
385
+ when /\AStreamOpen failed/, /\AStreamStart failed/
386
+ raise StreamFailedToOpenError.new(e.message, status: status)
387
+ when /\AFailed to allocate/
388
+ raise TransportError.new(e.message, status: status)
389
+ else
390
+ raise
391
+ end
197
392
  end
198
393
 
199
394
  def open_unidirectional_stream
@@ -203,15 +398,28 @@ module Quicsilver
203
398
 
204
399
  def send_control_stream
205
400
  @control_stream = open_unidirectional_stream
206
- @control_stream.send(Protocol.build_control_stream)
401
+ @control_stream.send(Protocol.build_control_stream(datagram_receive_enabled: @datagram_receive_enabled))
207
402
 
208
- # RFC 9204: QPACK encoder (0x02) and decoder (0x03) streams
209
403
  [0x02, 0x03].each do |type|
210
404
  stream = open_unidirectional_stream
211
405
  stream.send([type].pack("C"))
212
406
  end
213
407
  end
214
408
 
409
+ # RFC 9218 §7: Send PRIORITY_UPDATE frame on the control stream.
410
+ # Reprioritises an in-flight stream after it was opened.
411
+ def send_priority_update(stream_id, priority)
412
+ return unless @control_stream && priority
413
+
414
+ field_value = priority.to_s
415
+ payload = Protocol.encode_varint(stream_id) + field_value.b
416
+ frame = Protocol.encode_varint(Protocol::FRAME_PRIORITY_UPDATE) +
417
+ Protocol.encode_varint(payload.bytesize) + payload
418
+ @control_stream.send(frame, fin: false)
419
+ rescue => e
420
+ Quicsilver.logger.debug("Failed to send PRIORITY_UPDATE: #{e.message}")
421
+ end
422
+
215
423
  def handle_connection_result(result)
216
424
  if result.key?("error")
217
425
  cleanup_failed_connection
@@ -229,22 +437,272 @@ module Quicsilver
229
437
  false
230
438
  end
231
439
 
232
- def send_to_stream(stream, method, path, headers, body)
233
- encoded_response = Protocol::RequestEncoder.new(
234
- method: method,
235
- path: path,
236
- scheme: "https",
237
- authority: authority,
238
- headers: headers,
239
- body: body
240
- ).encode
440
+ def send_to_stream(stream, method, path, headers, body, priority: nil)
441
+ # RFC 9114 §4.2.2: Enforce server's SETTINGS_MAX_FIELD_SECTION_SIZE
442
+ if @peer_max_field_section_size
443
+ header_size = estimate_header_size(method, path, headers)
444
+ if header_size > @peer_max_field_section_size
445
+ @mutex.synchronize { @inflight.delete(stream.handle) }
446
+ raise Error, "Request headers (#{header_size} bytes) exceed server's max field section size (#{@peer_max_field_section_size})"
447
+ end
448
+ end
241
449
 
242
- result = stream.send(encoded_response, fin: true)
450
+ if body == :stream
451
+ # Streaming mode: send HEADERS only, body sent later via Request#stream_body
452
+ encoded = Protocol::RequestEncoder.new(
453
+ method: method, path: path, scheme: "https",
454
+ authority: authority, headers: headers, body: nil, priority: priority
455
+ ).encode
456
+ result = stream.send(encoded, fin: false)
457
+ else
458
+ # Buffered mode: send HEADERS + body + FIN
459
+ encoded = Protocol::RequestEncoder.new(
460
+ method: method, path: path, scheme: "https",
461
+ authority: authority, headers: headers, body: body, priority: priority
462
+ ).encode
463
+ result = stream.send(encoded, fin: true)
464
+ end
243
465
 
244
466
  unless result
245
- @mutex.synchronize { @pending_requests.delete(stream.handle) }
467
+ @mutex.synchronize { @inflight.delete(stream.handle) }
246
468
  raise Error, "Failed to send request"
247
469
  end
248
470
  end
471
+
472
+ # RFC 9114 §4.2.2: Estimate header field section size.
473
+ # Each field: name length + value length + 32 bytes overhead.
474
+ def handle_receive(stream_id, data)
475
+ # Data is prepended with [stream_handle(8)] — extract handle and payload
476
+ event_obj = Transport::StreamEvent.new(data, "RECEIVE")
477
+ if (entry = @inflight[event_obj.handle])
478
+ entry[:stream_id] ||= stream_id
479
+ end
480
+
481
+ state = @streaming[stream_id]
482
+ if state
483
+ # Already streaming — feed DATA frames to the body
484
+ state[:frame_buffer] << event_obj.data
485
+ drain_streaming_data(state)
486
+ else
487
+ (@response_buffers[stream_id] ||= "".b) << event_obj.data
488
+ strip_informational_frames!(stream_id)
489
+ # Only transition to streaming when the caller opted in via streaming_response.
490
+ # Buffered callers (.response) are unaffected.
491
+ if entry && entry[:request].streaming_requested?
492
+ try_start_streaming(stream_id, event_obj.handle)
493
+ end
494
+ end
495
+ end
496
+
497
+ def handle_receive_fin(stream_id, data)
498
+ event = Transport::StreamEvent.new(data, "RECEIVE_FIN")
499
+ state = @streaming.delete(stream_id)
500
+
501
+ if state
502
+ # Streaming mode: feed final data, close body
503
+ if event.data && !event.data.empty?
504
+ state[:frame_buffer] << event.data
505
+ drain_streaming_data(state)
506
+ end
507
+ state[:body].close_write
508
+
509
+ # Complete for .response() callers — they get the full body retroactively
510
+ entry = @inflight.delete(event.handle)
511
+ return unless entry
512
+ body_str = state[:body].rewind ? state[:body].read : ""
513
+ entry[:request].complete(Response.new(
514
+ status: state[:status], headers: state[:headers],
515
+ body: body_str, trailers: state[:trailers] || {}
516
+ ))
517
+ else
518
+ # Buffered mode: parse everything at once
519
+ buffer = @response_buffers.delete(stream_id)
520
+ full_data = (buffer || "".b) + event.data
521
+ full_data = strip_informational_data(full_data)
522
+
523
+ response_parser = Protocol::ResponseParser.new(full_data, max_body_size: @max_body_size,
524
+ max_header_size: @max_header_size)
525
+ response_parser.parse
526
+ response_parser.validate_response!
527
+
528
+ body_str = response_parser.body&.read || ""
529
+ response = Response.new(
530
+ status: response_parser.status, headers: response_parser.headers,
531
+ body: body_str, trailers: response_parser.trailers || {}
532
+ )
533
+
534
+ entry = @inflight.delete(event.handle)
535
+ if entry
536
+ # Deliver as streaming response if caller opted in but data arrived
537
+ # before streaming mode could be activated (race condition fallback).
538
+ if entry[:request].streaming_requested?
539
+ streaming_body = Protocol::StreamInput.new
540
+ streaming_body.write(body_str) unless body_str.empty?
541
+ streaming_body.close_write
542
+ entry[:request].deliver_streaming(Response.new(
543
+ status: response_parser.status, headers: response_parser.headers,
544
+ body: streaming_body, trailers: response_parser.trailers || {}
545
+ ))
546
+ end
547
+ entry[:request].complete(response)
548
+ end
549
+ end
550
+ end
551
+
552
+ # Try to parse HEADERS from buffered data and start streaming.
553
+ def try_start_streaming(stream_id, handle)
554
+ buf = @response_buffers[stream_id]
555
+ return unless buf && buf.bytesize >= 2
556
+
557
+ # Use FrameReader to find the first HEADERS frame
558
+ status = nil
559
+ headers = {}
560
+ headers_end = 0
561
+
562
+ Protocol::FrameReader.each(buf) do |type, payload, offset|
563
+ if type == Protocol::FRAME_HEADERS
564
+ s = peek_status(payload)
565
+ if s && s >= 200
566
+ status = s
567
+ PEEK_DECODER.decode(payload) do |name, value|
568
+ headers[name] = value unless name == ":status"
569
+ end
570
+ headers_end = offset
571
+ end
572
+ end
573
+ break # only look at the first frame
574
+ end
575
+
576
+ return unless status # No final HEADERS yet
577
+
578
+ # Transition from buffered to streaming
579
+ remaining = buf.byteslice(headers_end..-1) || "".b
580
+ @response_buffers.delete(stream_id)
581
+
582
+ body = Protocol::StreamInput.new
583
+ state = { body: body, frame_buffer: remaining, status: status, headers: headers, trailers: {} }
584
+ @streaming[stream_id] = state
585
+
586
+ # Drain any DATA frames that arrived with the HEADERS
587
+ drain_streaming_data(state)
588
+
589
+ # Deliver StreamingResponse to streaming_response() callers
590
+ entry = @inflight[handle]
591
+ entry[:request].deliver_streaming(Response.new(
592
+ status: status, headers: headers, body: body
593
+ )) if entry
594
+ end
595
+
596
+ # Extract complete DATA frame payloads from frame_buffer and write to body.
597
+ def drain_streaming_data(state)
598
+ buf = state[:frame_buffer]
599
+ consumed = 0
600
+
601
+ Protocol::FrameReader.each(buf) do |type, payload, offset|
602
+ if type == Protocol::FRAME_DATA
603
+ state[:body].write(payload)
604
+ elsif type == Protocol::FRAME_HEADERS && state[:status]
605
+ # Trailing HEADERS frame — decode as trailers
606
+ trailers = {}
607
+ PEEK_DECODER.decode(payload) { |name, value| trailers[name] = value }
608
+ state[:trailers] = trailers
609
+ end
610
+ consumed = offset
611
+ end
612
+
613
+ if consumed > 0
614
+ state[:frame_buffer] = buf.byteslice(consumed..-1) || "".b
615
+ end
616
+ end
617
+
618
+ def estimate_header_size(method, path, headers)
619
+ size = 0
620
+ # Pseudo-headers
621
+ size += ":method".bytesize + method.to_s.bytesize + 32
622
+ size += ":path".bytesize + path.to_s.bytesize + 32
623
+ size += ":scheme".bytesize + 5 + 32 # "https"
624
+ size += ":authority".bytesize + authority.bytesize + 32
625
+ # Regular headers
626
+ headers.each { |name, value| size += name.to_s.bytesize + value.to_s.bytesize + 32 }
627
+ size
628
+ end
629
+
630
+ # Strip leading 1xx informational HEADERS frames from data.
631
+ # RFC 9114 §4.1: Interim responses (1xx) precede the final response.
632
+ def strip_informational_data(data)
633
+ skip = Protocol::FrameReader.skip_while(data) do |type, payload|
634
+ type == Protocol::FRAME_HEADERS &&
635
+ (status = peek_status(payload)) && status >= 100 && status < 200
636
+ end
637
+ skip > 0 ? data.byteslice(skip..-1) || "".b : data
638
+ end
639
+
640
+ def strip_informational_frames!(stream_id)
641
+ buf = @response_buffers[stream_id]
642
+ return unless buf && buf.bytesize >= 2
643
+
644
+ @response_buffers[stream_id] = strip_informational_data(buf)
645
+ end
646
+
647
+ # Decode just the :status pseudo-header from a QPACK header block.
648
+ PEEK_DECODER = Protocol::Qpack::HeaderBlockDecoder.new
649
+
650
+ def peek_status(qpack_payload)
651
+ PEEK_DECODER.decode(qpack_payload) do |name, value|
652
+ return value.to_i if name == ":status"
653
+ end
654
+ nil
655
+ rescue
656
+ nil
657
+ end
658
+
659
+ # Identify stream type from first byte(s), strip it, return remaining data.
660
+ # Returns nil for unknown/ignored stream types.
661
+ def identify_and_strip_stream_type(stream_id, data)
662
+ stream_type, type_len = Protocol.decode_varint(data.bytes, 0)
663
+ return nil if type_len == 0
664
+
665
+ case stream_type
666
+ when 0x00 # Control stream
667
+ if @control_stream_id
668
+ raise Protocol::FrameError.new("Duplicate control stream",
669
+ error_code: Protocol::H3_STREAM_CREATION_ERROR)
670
+ end
671
+ @control_stream_id = stream_id
672
+ @uni_stream_types[stream_id] = :control
673
+ when 0x02 # QPACK encoder stream
674
+ @uni_stream_types[stream_id] = :qpack_encoder
675
+ when 0x03 # QPACK decoder stream
676
+ @uni_stream_types[stream_id] = :qpack_decoder
677
+ else
678
+ # Unknown unidirectional stream types MUST be ignored (RFC 9114 §6.2)
679
+ @uni_stream_types[stream_id] = :unknown
680
+ return nil
681
+ end
682
+
683
+ data[type_len..] || "".b
684
+ end
685
+
686
+ def on_settings_received(settings)
687
+ @peer_settings.merge!(settings)
688
+ @peer_max_field_section_size = settings[0x06] if settings.key?(0x06)
689
+ end
690
+
691
+ # RFC 9114 §5.2: After receiving GOAWAY, fail any in-flight requests
692
+ # on streams at or above the GOAWAY stream ID — the server won't process them.
693
+ def on_goaway_received(goaway_stream_id)
694
+ fail_requests_above_goaway(goaway_stream_id)
695
+ end
696
+
697
+ def fail_requests_above_goaway(goaway_stream_id)
698
+ @mutex.synchronize do
699
+ @inflight.each do |handle, entry|
700
+ sid = entry[:stream_id]
701
+ next unless sid && sid >= goaway_stream_id
702
+ @inflight.delete(handle)
703
+ entry[:request]&.fail(0, "GOAWAY: server will not process stream #{sid}")
704
+ end
705
+ end
706
+ end
249
707
  end
250
708
  end