quicsilver 0.5.0-x86_64-linux

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 (107) hide show
  1. checksums.yaml +7 -0
  2. data/.github/workflows/ci.yml +49 -0
  3. data/.github/workflows/cibuildgem.yaml +117 -0
  4. data/.gitignore +21 -0
  5. data/.gitmodules +3 -0
  6. data/.ruby-version +1 -0
  7. data/CHANGELOG.md +175 -0
  8. data/Gemfile +6 -0
  9. data/Gemfile.lock +67 -0
  10. data/LICENSE +21 -0
  11. data/README.md +261 -0
  12. data/Rakefile +130 -0
  13. data/benchmarks/baseline.rb +175 -0
  14. data/benchmarks/compare.rb +398 -0
  15. data/benchmarks/components.rb +191 -0
  16. data/benchmarks/helpers.rb +162 -0
  17. data/benchmarks/latency.rb +128 -0
  18. data/benchmarks/loss.rb +193 -0
  19. data/benchmarks/mobile.rb +125 -0
  20. data/bin/console +14 -0
  21. data/bin/setup +8 -0
  22. data/docs/connection_identity.md +141 -0
  23. data/examples/README.md +58 -0
  24. data/examples/benchmark.rb +191 -0
  25. data/examples/connection_pool_demo.rb +47 -0
  26. data/examples/example_helper.rb +69 -0
  27. data/examples/falcon_middleware.rb +44 -0
  28. data/examples/feature_demo.rb +125 -0
  29. data/examples/grpc_style.rb +97 -0
  30. data/examples/latency_aware_routing.rb +382 -0
  31. data/examples/lifecycle_drain.rb +264 -0
  32. data/examples/minimal_http3_server.rb +14 -0
  33. data/examples/priorities.rb +60 -0
  34. data/examples/protocol_http_server.rb +31 -0
  35. data/examples/rack_http3_server.rb +42 -0
  36. data/examples/rails_feature_test.rb +280 -0
  37. data/examples/server_stats.rb +117 -0
  38. data/examples/simple_client_test.rb +26 -0
  39. data/examples/streaming_sse.rb +33 -0
  40. data/examples/trailers.rb +69 -0
  41. data/examples/webtransport_smoke.rb +546 -0
  42. data/examples/worker_load_balancing.rb +425 -0
  43. data/ext/quicsilver/extconf.rb +124 -0
  44. data/ext/quicsilver/include/msquic.h +2226 -0
  45. data/ext/quicsilver/include/msquic_posix.h +696 -0
  46. data/ext/quicsilver/include/quic_sal_stub.h +314 -0
  47. data/ext/quicsilver/msquic_patch.rb +68 -0
  48. data/ext/quicsilver/patches/reliable-reset.patch +723 -0
  49. data/ext/quicsilver/patches/stream-final-size.patch +47 -0
  50. data/ext/quicsilver/quicsilver.c +2741 -0
  51. data/lib/quicsilver/3.4/quicsilver.so +0 -0
  52. data/lib/quicsilver/4.0/quicsilver.so +0 -0
  53. data/lib/quicsilver/client/client.rb +708 -0
  54. data/lib/quicsilver/client/connection_pool.rb +202 -0
  55. data/lib/quicsilver/client/request.rb +190 -0
  56. data/lib/quicsilver/errors.rb +39 -0
  57. data/lib/quicsilver/libmsquic.so.2 +0 -0
  58. data/lib/quicsilver/protocol/adapter.rb +196 -0
  59. data/lib/quicsilver/protocol/capsule.rb +38 -0
  60. data/lib/quicsilver/protocol/connect_stream_decoder.rb +75 -0
  61. data/lib/quicsilver/protocol/control_stream_parser.rb +117 -0
  62. data/lib/quicsilver/protocol/datagram.rb +40 -0
  63. data/lib/quicsilver/protocol/frame_parser.rb +141 -0
  64. data/lib/quicsilver/protocol/frame_reader.rb +84 -0
  65. data/lib/quicsilver/protocol/frames.rb +454 -0
  66. data/lib/quicsilver/protocol/priority.rb +65 -0
  67. data/lib/quicsilver/protocol/qpack/decoder.rb +127 -0
  68. data/lib/quicsilver/protocol/qpack/encoder.rb +217 -0
  69. data/lib/quicsilver/protocol/qpack/header_block_decoder.rb +140 -0
  70. data/lib/quicsilver/protocol/qpack/huffman.rb +459 -0
  71. data/lib/quicsilver/protocol/request_encoder.rb +50 -0
  72. data/lib/quicsilver/protocol/request_parser.rb +289 -0
  73. data/lib/quicsilver/protocol/response_encoder.rb +107 -0
  74. data/lib/quicsilver/protocol/response_parser.rb +147 -0
  75. data/lib/quicsilver/protocol/stream_input.rb +98 -0
  76. data/lib/quicsilver/protocol/stream_output.rb +59 -0
  77. data/lib/quicsilver/protocol/unidirectional_stream.rb +12 -0
  78. data/lib/quicsilver/protocol/web_transport.rb +214 -0
  79. data/lib/quicsilver/rack/context.rb +33 -0
  80. data/lib/quicsilver/response.rb +40 -0
  81. data/lib/quicsilver/server/listener_data.rb +14 -0
  82. data/lib/quicsilver/server/rack_adapter.rb +77 -0
  83. data/lib/quicsilver/server/receive_queue.rb +106 -0
  84. data/lib/quicsilver/server/request_handler.rb +149 -0
  85. data/lib/quicsilver/server/request_registry.rb +63 -0
  86. data/lib/quicsilver/server/scheduler.rb +50 -0
  87. data/lib/quicsilver/server/schedulers/fiber_scheduler.rb +79 -0
  88. data/lib/quicsilver/server/schedulers/thread_scheduler.rb +76 -0
  89. data/lib/quicsilver/server/server.rb +1080 -0
  90. data/lib/quicsilver/server/web_transport_flow_control.rb +270 -0
  91. data/lib/quicsilver/server/web_transport_manager.rb +281 -0
  92. data/lib/quicsilver/server/web_transport_registry.rb +40 -0
  93. data/lib/quicsilver/server/web_transport_session.rb +812 -0
  94. data/lib/quicsilver/server/web_transport_stream.rb +326 -0
  95. data/lib/quicsilver/transport/configuration.rb +234 -0
  96. data/lib/quicsilver/transport/connection.rb +594 -0
  97. data/lib/quicsilver/transport/connection_stats.rb +53 -0
  98. data/lib/quicsilver/transport/event_loop.rb +74 -0
  99. data/lib/quicsilver/transport/inbound_stream.rb +53 -0
  100. data/lib/quicsilver/transport/stream.rb +43 -0
  101. data/lib/quicsilver/transport/stream_event.rb +49 -0
  102. data/lib/quicsilver/transport/stream_id.rb +26 -0
  103. data/lib/quicsilver/version.rb +3 -0
  104. data/lib/quicsilver.rb +96 -0
  105. data/lib/rackup/handler/quicsilver.rb +96 -0
  106. data/quicsilver.gemspec +66 -0
  107. metadata +362 -0
@@ -0,0 +1,708 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Quicsilver
4
+ class Client
5
+ include Protocol::ControlStreamParser
6
+
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
9
+
10
+ FINISHED_EVENTS = %w[RECEIVE_FIN RECEIVE STREAM_RESET STOP_SENDING DATAGRAM_RECEIVED STREAM_START_COMPLETE STREAM_PEER_ACCEPTED].freeze
11
+
12
+ DEFAULT_REQUEST_TIMEOUT = 30 # seconds
13
+ DEFAULT_CONNECTION_TIMEOUT = 5000 # ms
14
+
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)
27
+ @hostname = hostname
28
+ @port = port
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])
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])
36
+ @request_timeout = options.fetch(:request_timeout, DEFAULT_REQUEST_TIMEOUT)
37
+ @max_body_size = options[:max_body_size]
38
+ @max_header_size = options[:max_header_size]
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
+
44
+ @connection_data = nil
45
+ @connected = false
46
+ @connection_start_time = nil
47
+
48
+ @response_buffers = {} # stream_id => binary data
49
+ @streaming = {} # stream_id => { body:, frame_buffer: }
50
+ @inflight = {} # handle => { request:, stream_id: }
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
62
+ end
63
+
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
71
+
72
+ def pool
73
+ @pool ||= ConnectionPool.new
74
+ end
75
+
76
+ def close_pool
77
+ @pool&.close
78
+ @pool = nil
79
+ end
80
+
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
94
+ end
95
+
96
+ # Disconnect and close the underlying QUIC connection.
97
+ # Saves the resumption ticket for 0-RTT on the next connect.
98
+ def disconnect
99
+ return unless @connected
100
+
101
+ @connected = false
102
+
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
+
109
+ @mutex.synchronize do
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
113
+ @response_buffers.clear
114
+ @streaming.clear
115
+ end
116
+
117
+ close_connection
118
+ end
119
+
120
+ # Instance-level HTTP methods. Auto-connects on first use.
121
+ #
122
+ # client = Quicsilver::Client.new("example.com", 4433)
123
+ # client.get("/users") # connects automatically
124
+ # client.post("/data", body: json)
125
+ #
126
+ %i[get post patch delete head put].each do |method|
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)
163
+ end
164
+ end
165
+
166
+ def build_request(method, path, headers: {}, body: nil, priority: nil)
167
+ ensure_connected!
168
+ raise GoAwayError, "Connection is draining (GOAWAY received)" if draining?
169
+
170
+ stream = open_stream
171
+ raise StreamFailedToOpenError unless stream
172
+
173
+ request = Request.new(self, stream)
174
+ @mutex.synchronize do
175
+ @inflight[stream.handle] = { request: request, stream_id: nil }
176
+ end
177
+
178
+ send_to_stream(stream, method, path, headers, body, priority: priority)
179
+
180
+ request
181
+ end
182
+
183
+ def connected?
184
+ @connected && @connection_data && connection_alive?
185
+ end
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
+
196
+ def connection_info
197
+ info = @connection_data ? Quicsilver.connection_status(@connection_data[1]) : {}
198
+ info.merge(hostname: @hostname, port: @port, uptime: connection_uptime)
199
+ end
200
+
201
+ def connection_uptime
202
+ return 0 unless @connection_start_time
203
+ Time.now - @connection_start_time
204
+ end
205
+
206
+ def authority
207
+ "#{@hostname}:#{@port}"
208
+ end
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
+
242
+ # Called directly by C extension via dispatch_to_ruby
243
+ def handle_stream_event(stream_id, event, data, _early_data) # :nodoc:
244
+ return unless FINISHED_EVENTS.include?(event)
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
+
256
+ @mutex.synchronize do
257
+ case event
258
+ when "RECEIVE"
259
+ handle_receive(stream_id, data)
260
+ when "RECEIVE_FIN"
261
+ handle_receive_fin(stream_id, data)
262
+ when "STREAM_RESET"
263
+ event = Transport::StreamEvent.new(data, "STREAM_RESET")
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
268
+ when "STOP_SENDING"
269
+ event = Transport::StreamEvent.new(data, "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)")
308
+ end
309
+ end
310
+ rescue => e
311
+ Quicsilver.logger.error("Error handling client stream: #{e.class} - #{e.message}")
312
+ Quicsilver.logger.debug(e.backtrace.first(5).join("\n"))
313
+ end
314
+
315
+ private
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
+
330
+ def start_connection(config)
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
+
340
+ unless Quicsilver.start_connection(connection_handle, config, @hostname, @port)
341
+ cleanup_failed_connection
342
+ raise ConnectionError, "Failed to start connection"
343
+ end
344
+
345
+ result = Quicsilver.wait_for_connection(context_handle, @connection_timeout)
346
+ handle_connection_result(result)
347
+ end
348
+
349
+ def create_connection
350
+ @connection_data = Quicsilver.create_connection(self)
351
+ raise ConnectionError, "Failed to create connection" if @connection_data.nil?
352
+
353
+ @connection_data
354
+ end
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
+
373
+ def cleanup_failed_connection
374
+ Quicsilver.close_connection_handle(@connection_data) if @connection_data
375
+ @connection_data = nil
376
+ @connected = false
377
+ end
378
+
379
+ def open_stream
380
+ handle = Quicsilver.open_stream(@connection_data, false)
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
392
+ end
393
+
394
+ def open_unidirectional_stream
395
+ handle = Quicsilver.open_stream(@connection_data, true)
396
+ Transport::Stream.new(handle)
397
+ end
398
+
399
+ def send_control_stream
400
+ @control_stream = open_unidirectional_stream
401
+ @control_stream.send(Protocol.build_control_stream(datagram_receive_enabled: @datagram_receive_enabled))
402
+
403
+ [0x02, 0x03].each do |type|
404
+ stream = open_unidirectional_stream
405
+ stream.send([type].pack("C"))
406
+ end
407
+ end
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
+
423
+ def handle_connection_result(result)
424
+ if result.key?("error")
425
+ cleanup_failed_connection
426
+ raise ConnectionError, "Connection failed: status 0x#{result['status'].to_s(16)}, code: #{result['code']}"
427
+ elsif result.key?("timeout")
428
+ cleanup_failed_connection
429
+ raise TimeoutError, "Connection timed out after #{@connection_timeout}ms"
430
+ end
431
+ end
432
+
433
+ def connection_alive?
434
+ return false unless (info = Quicsilver.connection_status(@connection_data[1]))
435
+ info["connected"] && !info["failed"]
436
+ rescue
437
+ false
438
+ end
439
+
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
449
+
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
465
+
466
+ unless result
467
+ @mutex.synchronize { @inflight.delete(stream.handle) }
468
+ raise Error, "Failed to send request"
469
+ end
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
707
+ end
708
+ end