quicsilver 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +9 -1
  3. data/.github/workflows/cibuildgem.yaml +29 -5
  4. data/.gitignore +5 -0
  5. data/CHANGELOG.md +50 -0
  6. data/Gemfile.lock +8 -1
  7. data/README.md +128 -15
  8. data/Rakefile +10 -3
  9. data/benchmarks/baseline.rb +175 -0
  10. data/benchmarks/compare.rb +398 -0
  11. data/benchmarks/helpers.rb +138 -64
  12. data/benchmarks/latency.rb +128 -0
  13. data/benchmarks/loss.rb +193 -0
  14. data/benchmarks/mobile.rb +125 -0
  15. data/docs/connection_identity.md +141 -0
  16. data/examples/benchmark.rb +80 -0
  17. data/examples/connection_pool_demo.rb +1 -1
  18. data/examples/example_helper.rb +51 -0
  19. data/examples/feature_demo.rb +7 -7
  20. data/examples/grpc_style.rb +2 -2
  21. data/examples/latency_aware_routing.rb +382 -0
  22. data/examples/lifecycle_drain.rb +264 -0
  23. data/examples/priorities.rb +2 -2
  24. data/examples/rails_feature_test.rb +92 -72
  25. data/examples/server_stats.rb +117 -0
  26. data/examples/simple_client_test.rb +3 -3
  27. data/examples/trailers.rb +23 -23
  28. data/examples/webtransport_smoke.rb +546 -0
  29. data/examples/worker_load_balancing.rb +425 -0
  30. data/ext/quicsilver/extconf.rb +100 -15
  31. data/ext/quicsilver/include/msquic.h +2226 -0
  32. data/ext/quicsilver/include/msquic_posix.h +696 -0
  33. data/ext/quicsilver/include/quic_sal_stub.h +314 -0
  34. data/ext/quicsilver/msquic_patch.rb +68 -0
  35. data/ext/quicsilver/patches/reliable-reset.patch +723 -0
  36. data/ext/quicsilver/patches/stream-final-size.patch +47 -0
  37. data/ext/quicsilver/quicsilver.c +1640 -222
  38. data/lib/quicsilver/client/client.rb +419 -60
  39. data/lib/quicsilver/client/connection_pool.rb +122 -26
  40. data/lib/quicsilver/client/request.rb +106 -14
  41. data/lib/quicsilver/errors.rb +39 -0
  42. data/lib/quicsilver/libmsquic.2.dylib +0 -0
  43. data/lib/quicsilver/protocol/adapter.rb +41 -21
  44. data/lib/quicsilver/protocol/capsule.rb +38 -0
  45. data/lib/quicsilver/protocol/connect_stream_decoder.rb +75 -0
  46. data/lib/quicsilver/protocol/control_stream_parser.rb +12 -1
  47. data/lib/quicsilver/protocol/datagram.rb +40 -0
  48. data/lib/quicsilver/protocol/frame_parser.rb +5 -6
  49. data/lib/quicsilver/protocol/frame_reader.rb +31 -2
  50. data/lib/quicsilver/protocol/frames.rb +128 -12
  51. data/lib/quicsilver/protocol/priority.rb +9 -0
  52. data/lib/quicsilver/protocol/qpack/decoder.rb +4 -42
  53. data/lib/quicsilver/protocol/qpack/encoder.rb +10 -20
  54. data/lib/quicsilver/protocol/qpack/huffman.rb +4 -4
  55. data/lib/quicsilver/protocol/request_encoder.rb +6 -3
  56. data/lib/quicsilver/protocol/request_parser.rb +16 -2
  57. data/lib/quicsilver/protocol/response_encoder.rb +18 -8
  58. data/lib/quicsilver/protocol/response_parser.rb +6 -0
  59. data/lib/quicsilver/protocol/stream_input.rb +3 -3
  60. data/lib/quicsilver/protocol/unidirectional_stream.rb +12 -0
  61. data/lib/quicsilver/protocol/web_transport.rb +214 -0
  62. data/lib/quicsilver/rack/context.rb +33 -0
  63. data/lib/quicsilver/response.rb +40 -0
  64. data/lib/quicsilver/server/rack_adapter.rb +77 -0
  65. data/lib/quicsilver/server/receive_queue.rb +106 -0
  66. data/lib/quicsilver/server/request_handler.rb +24 -13
  67. data/lib/quicsilver/server/request_registry.rb +18 -5
  68. data/lib/quicsilver/server/scheduler.rb +50 -0
  69. data/lib/quicsilver/server/schedulers/fiber_scheduler.rb +79 -0
  70. data/lib/quicsilver/server/schedulers/thread_scheduler.rb +76 -0
  71. data/lib/quicsilver/server/server.rb +655 -185
  72. data/lib/quicsilver/server/web_transport_flow_control.rb +270 -0
  73. data/lib/quicsilver/server/web_transport_manager.rb +281 -0
  74. data/lib/quicsilver/server/web_transport_registry.rb +40 -0
  75. data/lib/quicsilver/server/web_transport_session.rb +812 -0
  76. data/lib/quicsilver/server/web_transport_stream.rb +326 -0
  77. data/lib/quicsilver/transport/configuration.rb +103 -10
  78. data/lib/quicsilver/transport/connection.rb +302 -87
  79. data/lib/quicsilver/transport/connection_stats.rb +53 -0
  80. data/lib/quicsilver/transport/event_loop.rb +36 -0
  81. data/lib/quicsilver/transport/inbound_stream.rb +21 -1
  82. data/lib/quicsilver/transport/stream.rb +18 -3
  83. data/lib/quicsilver/transport/stream_event.rb +25 -2
  84. data/lib/quicsilver/transport/stream_id.rb +26 -0
  85. data/lib/quicsilver/version.rb +1 -1
  86. data/lib/quicsilver.rb +22 -11
  87. data/lib/rackup/handler/quicsilver.rb +57 -38
  88. data/quicsilver.gemspec +10 -3
  89. metadata +54 -8
  90. data/benchmarks/concurrent.rb +0 -110
  91. data/benchmarks/quicsilver_server.rb +0 -46
  92. data/benchmarks/rails.rb +0 -170
  93. data/benchmarks/throughput.rb +0 -113
  94. data/lib/quicsilver/quicsilver.bundle +0 -0
@@ -4,32 +4,50 @@ module Quicsilver
4
4
  class Client
5
5
  include Protocol::ControlStreamParser
6
6
 
7
- attr_reader :hostname, :port, :unsecure, :connection_timeout, :request_timeout
7
+ attr_reader :hostname, :port, :unsecure, :connection_timeout, :request_timeout, :idle_timeout_ms
8
8
  attr_reader :peer_goaway_id, :peer_settings, :peer_max_field_section_size
9
9
 
10
- StreamFailedToOpenError = Class.new(StandardError)
11
- GoAwayError = Class.new(StandardError)
12
-
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
 
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
+
18
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
34
52
 
35
53
  # Server control stream state
@@ -39,6 +57,8 @@ module Quicsilver
39
57
  @settings_received = false
40
58
  @control_stream_id = nil
41
59
  @uni_stream_types = {}
60
+ @datagram_callback = nil
61
+ @resumption_ticket = nil # stored ticket for 0-RTT reconnection
42
62
  end
43
63
 
44
64
  # --- Class-level API (automatic pooling) ---
@@ -65,24 +85,33 @@ module Quicsilver
65
85
  end
66
86
  end
67
87
 
68
- def request(hostname, port, method, path, headers: {}, body: nil, **options, &block)
88
+ def request(hostname, port, method, path, headers: {}, body: nil, priority: nil, timeout: nil, **options, &block)
69
89
  client = pool.checkout(hostname, port, **options)
70
- client.public_send(method, path, headers: headers, body: body, &block)
90
+ client.public_send(method, path, headers: headers, body: body, priority: priority, timeout: timeout, &block)
71
91
  ensure
72
92
  pool.checkin(client) if client
73
93
  end
74
94
  end
75
95
 
76
96
  # Disconnect and close the underlying QUIC connection.
97
+ # Saves the resumption ticket for 0-RTT on the next connect.
77
98
  def disconnect
78
99
  return unless @connected
79
100
 
80
101
  @connected = false
81
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
+
82
109
  @mutex.synchronize do
83
- @pending_requests.each_value { |req| req.fail(0, "Connection closed") }
84
- @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
85
113
  @response_buffers.clear
114
+ @streaming.clear
86
115
  end
87
116
 
88
117
  close_connection
@@ -95,9 +124,9 @@ module Quicsilver
95
124
  # client.post("/data", body: json)
96
125
  #
97
126
  %i[get post patch delete head put].each do |method|
98
- define_method(method) do |path, headers: {}, body: nil, &block|
99
- req = build_request(method.to_s.upcase, path, headers: headers, body: body)
100
- 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)
101
130
  end
102
131
  end
103
132
 
@@ -105,6 +134,25 @@ module Quicsilver
105
134
  !@peer_goaway_id.nil?
106
135
  end
107
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
+
108
156
  def receive_control_data(stream_id, data) # :nodoc:
109
157
  buf = @uni_stream_types.key?(stream_id) ? data : identify_and_strip_stream_type(stream_id, data)
110
158
  return if buf.nil? || buf.empty?
@@ -115,7 +163,7 @@ module Quicsilver
115
163
  end
116
164
  end
117
165
 
118
- def build_request(method, path, headers: {}, body: nil)
166
+ def build_request(method, path, headers: {}, body: nil, priority: nil)
119
167
  ensure_connected!
120
168
  raise GoAwayError, "Connection is draining (GOAWAY received)" if draining?
121
169
 
@@ -123,9 +171,11 @@ module Quicsilver
123
171
  raise StreamFailedToOpenError unless stream
124
172
 
125
173
  request = Request.new(self, stream)
126
- @mutex.synchronize { @pending_requests[stream.handle] = request }
174
+ @mutex.synchronize do
175
+ @inflight[stream.handle] = { request: request, stream_id: nil }
176
+ end
127
177
 
128
- send_to_stream(stream, method, path, headers, body)
178
+ send_to_stream(stream, method, path, headers, body, priority: priority)
129
179
 
130
180
  request
131
181
  end
@@ -134,6 +184,15 @@ module Quicsilver
134
184
  @connected && @connection_data && connection_alive?
135
185
  end
136
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
+
137
196
  def connection_info
138
197
  info = @connection_data ? Quicsilver.connection_status(@connection_data[1]) : {}
139
198
  info.merge(hostname: @hostname, port: @port, uptime: connection_uptime)
@@ -153,14 +212,17 @@ module Quicsilver
153
212
  return self if @connected
154
213
 
155
214
  Quicsilver.open_connection
156
- config = Quicsilver.create_configuration(@unsecure)
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
157
220
  raise ConnectionError, "Failed to create configuration" if config.nil?
158
221
 
159
222
  start_connection(config)
160
223
  @connected = true
161
224
  @connection_start_time = Time.now
162
225
  send_control_stream
163
- Quicsilver.event_loop.start
164
226
 
165
227
  self
166
228
  rescue => e
@@ -182,7 +244,7 @@ module Quicsilver
182
244
  return unless FINISHED_EVENTS.include?(event)
183
245
 
184
246
  # Server unidirectional streams (control, QPACK) — process incrementally
185
- if (stream_id & 0x02) != 0 && (event == "RECEIVE" || event == "RECEIVE_FIN")
247
+ if Transport::StreamId.unidirectional?(stream_id) && (event == "RECEIVE" || event == "RECEIVE_FIN")
186
248
  begin
187
249
  receive_control_data(stream_id, data)
188
250
  rescue Protocol::FrameError => e
@@ -194,36 +256,55 @@ module Quicsilver
194
256
  @mutex.synchronize do
195
257
  case event
196
258
  when "RECEIVE"
197
- (@response_buffers[stream_id] ||= "".b) << data
198
-
259
+ handle_receive(stream_id, data)
199
260
  when "RECEIVE_FIN"
200
- event = Transport::StreamEvent.new(data, "RECEIVE_FIN")
201
-
202
- buffer = @response_buffers.delete(stream_id)
203
- full_data = (buffer || "".b) + event.data
204
-
205
- response_parser = Protocol::ResponseParser.new(full_data, max_body_size: @max_body_size,
206
- max_header_size: @max_header_size)
207
- response_parser.parse
208
-
209
- response = {
210
- status: response_parser.status,
211
- headers: response_parser.headers,
212
- body: response_parser.body.read
213
- }
214
-
215
- request = @pending_requests.delete(event.handle)
216
- request&.complete(response)
217
-
261
+ handle_receive_fin(stream_id, data)
218
262
  when "STREAM_RESET"
219
263
  event = Transport::StreamEvent.new(data, "STREAM_RESET")
220
- request = @pending_requests.delete(event.handle)
221
- request&.fail(event.error_code, "Stream reset by peer")
222
-
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
223
268
  when "STOP_SENDING"
224
269
  event = Transport::StreamEvent.new(data, "STOP_SENDING")
225
- request = @pending_requests.delete(event.handle)
226
- 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)")
227
308
  end
228
309
  end
229
310
  rescue => e
@@ -233,13 +314,29 @@ module Quicsilver
233
314
 
234
315
  private
235
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
+
236
322
  def ensure_connected!
237
323
  return if @connected
238
- open_connection
324
+ @mutex.synchronize do
325
+ return if @connected
326
+ open_connection
327
+ end
239
328
  end
240
329
 
241
330
  def start_connection(config)
242
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
+
243
340
  unless Quicsilver.start_connection(connection_handle, config, @hostname, @port)
244
341
  cleanup_failed_connection
245
342
  raise ConnectionError, "Failed to start connection"
@@ -256,6 +353,23 @@ module Quicsilver
256
353
  @connection_data
257
354
  end
258
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
+
259
373
  def cleanup_failed_connection
260
374
  Quicsilver.close_connection_handle(@connection_data) if @connection_data
261
375
  @connection_data = nil
@@ -265,6 +379,16 @@ module Quicsilver
265
379
  def open_stream
266
380
  handle = Quicsilver.open_stream(@connection_data, false)
267
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
268
392
  end
269
393
 
270
394
  def open_unidirectional_stream
@@ -274,7 +398,7 @@ module Quicsilver
274
398
 
275
399
  def send_control_stream
276
400
  @control_stream = open_unidirectional_stream
277
- @control_stream.send(Protocol.build_control_stream)
401
+ @control_stream.send(Protocol.build_control_stream(datagram_receive_enabled: @datagram_receive_enabled))
278
402
 
279
403
  [0x02, 0x03].each do |type|
280
404
  stream = open_unidirectional_stream
@@ -282,6 +406,20 @@ module Quicsilver
282
406
  end
283
407
  end
284
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
+
285
423
  def handle_connection_result(result)
286
424
  if result.key?("error")
287
425
  cleanup_failed_connection
@@ -299,24 +437,225 @@ module Quicsilver
299
437
  false
300
438
  end
301
439
 
302
- def send_to_stream(stream, method, path, headers, body)
303
- encoded_response = Protocol::RequestEncoder.new(
304
- method: method,
305
- path: path,
306
- scheme: "https",
307
- authority: authority,
308
- headers: headers,
309
- body: body
310
- ).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
311
449
 
312
- 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
313
465
 
314
466
  unless result
315
- @mutex.synchronize { @pending_requests.delete(stream.handle) }
467
+ @mutex.synchronize { @inflight.delete(stream.handle) }
316
468
  raise Error, "Failed to send request"
317
469
  end
318
470
  end
319
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
+
320
659
  # Identify stream type from first byte(s), strip it, return remaining data.
321
660
  # Returns nil for unknown/ignored stream types.
322
661
  def identify_and_strip_stream_type(stream_id, data)
@@ -325,7 +664,10 @@ module Quicsilver
325
664
 
326
665
  case stream_type
327
666
  when 0x00 # Control stream
328
- raise Protocol::FrameError, "Duplicate control stream" if @control_stream_id
667
+ if @control_stream_id
668
+ raise Protocol::FrameError.new("Duplicate control stream",
669
+ error_code: Protocol::H3_STREAM_CREATION_ERROR)
670
+ end
329
671
  @control_stream_id = stream_id
330
672
  @uni_stream_types[stream_id] = :control
331
673
  when 0x02 # QPACK encoder stream
@@ -345,5 +687,22 @@ module Quicsilver
345
687
  @peer_settings.merge!(settings)
346
688
  @peer_max_field_section_size = settings[0x06] if settings.key?(0x06)
347
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
348
707
  end
349
708
  end