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
@@ -3,13 +3,66 @@
3
3
  module Quicsilver
4
4
  module Transport
5
5
  class Connection
6
+ include Protocol::ControlStreamParser
7
+
8
+ # MsQuic QUIC_STATUS_INVALID_STATE (POSIX errno ETOOMANYREFS = 0x59).
9
+ # Stream already shut down by peer — raised by StreamSend when the
10
+ # client has reset or closed the stream.
11
+ MSQUIC_INVALID_STATE = "0x59"
12
+ # The peer tore the stream down before we finished responding.
13
+ STREAM_CLOSED = "QUIC stream is closed"
14
+
6
15
  attr_reader :handle, :data, :streams
7
16
  attr_reader :control_stream_id, :qpack_encoder_stream_id, :qpack_decoder_stream_id
8
17
  attr_reader :server_control_stream
18
+ attr_reader :peer_goaway_id, :local_goaway_id
19
+ attr_reader :stream_priorities
20
+ attr_reader :remote_address, :remote_port, :session_resumed
21
+
22
+ def settings_received?
23
+ @settings_received
24
+ end
25
+
26
+ def datagram_send_enabled?
27
+ Quicsilver.connection_datagram_send_enabled?(@handle)
28
+ end
29
+
30
+ def reliable_reset_enabled?
31
+ Quicsilver.connection_reliable_reset_enabled?(@handle)
32
+ end
9
33
 
10
- def initialize(handle, data)
34
+ # WebTransport session flow control is on only when both endpoints have
35
+ # declared intent by advertising a non-zero WT_INITIAL_MAX_* setting
36
+ # (draft-16 §5.1). Until it is, at most one session may share the
37
+ # connection and flow control capsules must be ignored, because the peer
38
+ # may have sent them before seeing our SETTINGS.
39
+ def wt_flow_control_enabled?
40
+ return false unless settings_received?
41
+
42
+ Protocol.wt_flow_control_opt_in?(local_settings) &&
43
+ Protocol.wt_flow_control_opt_in?(@settings)
44
+ end
45
+
46
+ # What we advertise to the peer. Derived rather than captured when the
47
+ # control stream opens, so there is one source for the promise we make.
48
+ def local_settings
49
+ Protocol.control_stream_settings(max_field_section_size: @max_header_size)
50
+ end
51
+
52
+ def webtransport_settings_valid?(protocol)
53
+ return false unless settings_received? && @settings[Protocol::SETTINGS_H3_DATAGRAM] == 1
54
+
55
+ # Compatibility path: the legacy token does not require the newer flag.
56
+ protocol == Protocol::WebTransport::PROTOCOL_LEGACY ||
57
+ (@settings[Protocol::SETTINGS_WT_ENABLED] == 1 && reliable_reset_enabled?)
58
+ end
59
+
60
+ def initialize(handle, data, max_header_size: nil, connection_id: nil, transport_server_id: nil)
11
61
  @handle = handle
12
62
  @data = data
63
+ @max_header_size = max_header_size
64
+ @connection_id = hex_string(connection_id)
65
+ @transport_server_id = transport_server_id
13
66
  @streams = {}
14
67
  @response_buffers = {}
15
68
  @mutex = Mutex.new
@@ -24,6 +77,58 @@ module Quicsilver
24
77
 
25
78
  @settings = {}
26
79
  @settings_received = false
80
+ @peer_goaway_id = nil
81
+ @local_goaway_id = nil
82
+ @stream_priorities = {}
83
+ @session_resumed = @data[2] == true
84
+ @remote_address = nil
85
+ @remote_port = 0
86
+ end
87
+
88
+ # Resolve peer address from MsQuic. Must be called before the connection closes.
89
+ def resolve_remote_address!
90
+ result = Quicsilver.connection_remote_address(@handle)
91
+ @remote_address = result&.first
92
+ @remote_port = result&.last&.to_i || 0
93
+ end
94
+
95
+ # Stable opaque connection identifier exposed to apps. The current MsQuic
96
+ # backing value is the original destination CID; consumers must not parse
97
+ # it as the server-generated routing CID.
98
+ attr_reader :connection_id
99
+
100
+ def active_streams
101
+ @streams.size
102
+ end
103
+
104
+ # HTTP/3 request streams are client-initiated bidirectional QUIC streams.
105
+ def active_request_streams
106
+ @streams.keys.count { |stream_id| StreamId.request?(stream_id) }
107
+ end
108
+
109
+ def request_context(stream_id: nil)
110
+ connection = connection_metadata
111
+ if stream_id
112
+ connection["stream_id"] = stream_id
113
+ connection["request_id"] = request_id_for(stream_id)
114
+ end
115
+
116
+ {
117
+ "connection" => connection
118
+ }
119
+ end
120
+
121
+ def to_h
122
+ connection_metadata.merge(
123
+ "remote_address" => @remote_address,
124
+ "remote_port" => @remote_port,
125
+ "session_resumed" => @session_resumed,
126
+ "streams" => {
127
+ "active" => active_streams,
128
+ "active_requests" => active_request_streams
129
+ },
130
+ "transport" => stats.to_h
131
+ )
27
132
  end
28
133
 
29
134
  # === Setup (called after connection established) ===
@@ -31,13 +136,17 @@ module Quicsilver
31
136
  def setup_http3_streams
32
137
  # Control stream (required)
33
138
  @server_control_stream = open_stream(unidirectional: true)
34
- @server_control_stream.send(Protocol.build_control_stream)
139
+ @server_control_stream.send(Protocol.build_control_stream(max_field_section_size: @max_header_size))
35
140
 
36
141
  # QPACK encoder/decoder streams
37
142
  [0x02, 0x03].each do |type|
38
143
  stream = open_stream(unidirectional: true)
39
144
  stream.send([type].pack("C"))
40
145
  end
146
+
147
+ # GREASE unidirectional stream (RFC 9114 §6.2)
148
+ stream = open_stream(unidirectional: true)
149
+ stream.send(Protocol.encode_varint(Protocol.grease_id) + "GREASE".b)
41
150
  end
42
151
 
43
152
  # === Stream Management ===
@@ -62,14 +171,14 @@ module Quicsilver
62
171
 
63
172
  def buffer_data(stream_id, data)
64
173
  @mutex.synchronize do
65
- (@response_buffers[stream_id] ||= StringIO.new("".b)).write(data)
174
+ (@response_buffers[stream_id] ||= "".b) << data
66
175
  end
67
176
  end
68
177
 
69
178
  def complete_stream(stream_id, final_data)
70
179
  @mutex.synchronize do
71
180
  buffer = @response_buffers.delete(stream_id)
72
- (buffer&.string || "".b) + (final_data || "".b)
181
+ (buffer || "".b) + (final_data || "".b)
73
182
  end
74
183
  end
75
184
 
@@ -78,36 +187,60 @@ module Quicsilver
78
187
  def send_goaway(stream_id = nil)
79
188
  return unless @server_control_stream
80
189
 
81
- stream_id ||= last_client_stream_id
190
+ stream_id ||= last_request_stream_id
191
+ validate_goaway_id!(stream_id)
192
+
82
193
  @server_control_stream.send(Protocol.build_goaway_frame(stream_id))
194
+ @local_goaway_id = stream_id
195
+ rescue ArgumentError
196
+ raise # Re-raise validation errors
83
197
  rescue => e
84
198
  Quicsilver.logger.error("Failed to send GOAWAY: #{e.message}")
85
199
  end
86
200
 
87
- def send_response(stream, status, headers, body, head_request: false)
88
- encoder = Protocol::ResponseEncoder.new(status, headers, body, head_request: head_request)
201
+ # RFC 9114 §5.2: GOAWAY IDs MUST NOT increase from a previous value.
202
+ def validate_goaway_id!(stream_id)
203
+ if @local_goaway_id && stream_id > @local_goaway_id
204
+ raise ArgumentError, "GOAWAY stream ID #{stream_id} exceeds previous #{@local_goaway_id}"
205
+ end
206
+ end
207
+
208
+ # Send an informational (1xx) response before the final response.
209
+ # RFC 9114 §4.1: encoded as a HEADERS frame, no FIN.
210
+ def send_informational(stream, status, headers)
211
+ data = Protocol::ResponseEncoder.encode_informational(status, headers)
212
+ stream.send(data, fin: false)
213
+ rescue RuntimeError, IOError => e
214
+ raise unless stream_send_error?(e)
215
+ end
216
+
217
+ def send_response(stream, status, headers, body, head_request: false, trailers: nil)
218
+ body = [] if body.nil?
219
+ encoder = Protocol::ResponseEncoder.new(status, headers, body, head_request: head_request, trailers: trailers)
89
220
 
90
221
  if body.respond_to?(:to_ary)
91
- Quicsilver.send_stream(stream.stream_handle, encoder.encode, true)
222
+ stream.send(encoder.encode, fin: true)
92
223
  else
93
224
  encoder.stream_encode do |frame_data, fin|
94
- Quicsilver.send_stream(stream.stream_handle, frame_data, fin) unless frame_data.empty? && !fin
225
+ stream.send(frame_data, fin: fin) unless frame_data.empty? && !fin
95
226
  end
96
227
  end
97
- rescue RuntimeError => e
98
- # Stream may have been reset by client - this is expected
99
- raise unless e.message.include?("0x59") || e.message.include?("StreamSend failed")
100
- Quicsilver.logger.debug("Stream send failed (client likely reset): #{e.message}")
228
+ rescue RuntimeError, IOError => e
229
+ raise unless stream_send_error?(e)
230
+ ensure
231
+ # RFC 9110: Always close the body to release resources (file handles, fibers, etc.)
232
+ body.close if body.respond_to?(:close)
101
233
  end
102
234
 
103
235
  def send_error(stream, status, message)
104
236
  body = ["#{status} #{message}"]
105
- encoder = Protocol::ResponseEncoder.new(status, { "content-type" => "text/plain" }, body)
106
- Quicsilver.send_stream(stream.stream_handle, encoder.encode, true)
107
- rescue RuntimeError => e
108
- # Stream may have been reset by client - this is expected
109
- raise unless e.message.include?("0x59") || e.message.include?("StreamSend failed")
110
- Quicsilver.logger.debug("Stream send failed (client likely reset): #{e.message}")
237
+ headers = { "content-type" => "text/plain" }
238
+ # RFC 9110 §15.6.4: 503 responses SHOULD include Retry-After
239
+ headers["retry-after"] = "1" if status == 503
240
+ encoder = Protocol::ResponseEncoder.new(status, headers, body)
241
+ stream.send(encoder.encode, fin: true)
242
+ rescue RuntimeError, IOError => e
243
+ raise unless stream_send_error?(e)
111
244
  end
112
245
 
113
246
  # === Control Stream Handling ===
@@ -116,61 +249,22 @@ module Quicsilver
116
249
  # Called on each RECEIVE event — control streams never send FIN.
117
250
  def receive_unidirectional_data(stream_id, data)
118
251
  @mutex.synchronize do
119
- (@response_buffers[stream_id] ||= StringIO.new("".b)).write(data)
252
+ (@response_buffers[stream_id] ||= "".b) << data
120
253
  end
121
254
 
122
- buf = @mutex.synchronize { @response_buffers[stream_id]&.string || "".b }
255
+ buf = @mutex.synchronize { @response_buffers[stream_id] || "".b }
123
256
  return if buf.empty?
124
257
 
125
- # First time seeing this stream: identify stream type
126
258
  unless @uni_stream_types&.key?(stream_id)
127
259
  @uni_stream_types ||= {}
128
- stream_type, type_len = Protocol.decode_varint(buf.bytes, 0)
129
- return if type_len == 0 # need more data
130
-
131
- case stream_type
132
- when 0x00 # Control stream
133
- raise Protocol::FrameError, "Duplicate control stream" if @control_stream_id
134
- @control_stream_id = stream_id
135
- @uni_stream_types[stream_id] = :control
136
- # Remove the stream type byte from the buffer
137
- @mutex.synchronize { @response_buffers[stream_id] = StringIO.new(buf[type_len..] || "".b) }
138
- when 0x01
139
- raise Protocol::FrameError, "Client must not send push streams"
140
- when 0x02 # QPACK encoder stream
141
- raise Protocol::FrameError, "Duplicate QPACK encoder stream" if @qpack_encoder_stream_id
142
- @qpack_encoder_stream_id = stream_id
143
- @uni_stream_types[stream_id] = :qpack_encoder
144
- @mutex.synchronize { @response_buffers[stream_id] = StringIO.new(buf[type_len..] || "".b) }
145
- when 0x03 # QPACK decoder stream
146
- raise Protocol::FrameError, "Duplicate QPACK decoder stream" if @qpack_decoder_stream_id
147
- @qpack_decoder_stream_id = stream_id
148
- @uni_stream_types[stream_id] = :qpack_decoder
149
- @mutex.synchronize { @response_buffers[stream_id] = StringIO.new(buf[type_len..] || "".b) }
150
- else
151
- # Unknown unidirectional stream types MUST be ignored (RFC 9114 §6.2)
152
- @uni_stream_types[stream_id] = :unknown
153
- return
154
- end
260
+ stream_type, payload = decode_unidirectional_stream(buf)
261
+ return unless stream_type
155
262
 
156
- buf = @mutex.synchronize { @response_buffers[stream_id]&.string || "".b }
263
+ register_unidirectional_stream(stream_id, stream_type, payload)
264
+ buf = @mutex.synchronize { @response_buffers[stream_id] || "".b }
157
265
  end
158
266
 
159
- stream_type = @uni_stream_types[stream_id]
160
- return if buf.empty?
161
-
162
- case stream_type
163
- when :control
164
- parse_control_frames(buf)
165
- # Clear parsed data from buffer
166
- @mutex.synchronize { @response_buffers[stream_id] = StringIO.new("".b) }
167
- when :qpack_encoder
168
- validate_qpack_encoder_data(buf)
169
- @mutex.synchronize { @response_buffers[stream_id] = StringIO.new("".b) }
170
- when :qpack_decoder
171
- validate_qpack_decoder_data(buf)
172
- @mutex.synchronize { @response_buffers[stream_id] = StringIO.new("".b) }
173
- end
267
+ process_unidirectional_payload(stream_id, buf)
174
268
  end
175
269
 
176
270
  def handle_unidirectional_stream(stream, fin: true)
@@ -184,35 +278,120 @@ module Quicsilver
184
278
  data = stream.data
185
279
  return if data.empty?
186
280
 
187
- stream_type, type_len = Protocol.decode_varint(data.bytes, 0)
188
- return if type_len == 0
189
- payload = data[type_len..-1]
281
+ stream_type, payload = decode_unidirectional_stream(data)
282
+ return unless stream_type
190
283
 
191
284
  case stream_type
192
- when 0x00
285
+ when Protocol::UnidirectionalStream::CONTROL
193
286
  set_control_stream(stream_id, payload)
194
287
  if fin
195
288
  raise Protocol::FrameError.new("Closure of critical stream", error_code: Protocol::H3_CLOSED_CRITICAL_STREAM)
196
289
  end
197
- when 0x01
198
- raise Protocol::FrameError, "Client must not send push streams"
199
- when 0x02
200
- raise Protocol::FrameError, "Duplicate QPACK encoder stream" if @qpack_encoder_stream_id
290
+ when Protocol::UnidirectionalStream::PUSH
291
+ raise Protocol::FrameError.new("Client must not send push streams",
292
+ error_code: Protocol::H3_STREAM_CREATION_ERROR)
293
+ when Protocol::UnidirectionalStream::QPACK_ENCODER
294
+ raise Protocol::FrameError.new("Duplicate QPACK encoder stream",
295
+ error_code: Protocol::H3_STREAM_CREATION_ERROR) if @qpack_encoder_stream_id
201
296
  @qpack_encoder_stream_id = stream_id
202
297
  if fin
203
298
  raise Protocol::FrameError.new("Closure of critical stream", error_code: Protocol::H3_CLOSED_CRITICAL_STREAM)
204
299
  end
205
- when 0x03
206
- raise Protocol::FrameError, "Duplicate QPACK decoder stream" if @qpack_decoder_stream_id
300
+ when Protocol::UnidirectionalStream::QPACK_DECODER
301
+ raise Protocol::FrameError.new("Duplicate QPACK decoder stream",
302
+ error_code: Protocol::H3_STREAM_CREATION_ERROR) if @qpack_decoder_stream_id
207
303
  @qpack_decoder_stream_id = stream_id
208
304
  if fin
209
305
  raise Protocol::FrameError.new("Closure of critical stream", error_code: Protocol::H3_CLOSED_CRITICAL_STREAM)
210
306
  end
307
+ when Protocol::WebTransport::UNI_STREAM_TYPE
308
+ @uni_stream_types ||= {}
309
+ @uni_stream_types[stream_id] = :webtransport_uni
310
+ [:webtransport_uni, payload || "".b]
311
+ end
312
+ end
313
+
314
+ def decode_unidirectional_stream(data)
315
+ stream_type, type_len = Protocol.decode_varint_str(data, 0)
316
+ return if type_len == 0
317
+
318
+ [stream_type, data[type_len..] || "".b]
319
+ end
320
+
321
+ def register_unidirectional_stream(stream_id, stream_type, payload)
322
+ case stream_type
323
+ when Protocol::UnidirectionalStream::CONTROL
324
+ raise Protocol::FrameError.new("Duplicate control stream",
325
+ error_code: Protocol::H3_STREAM_CREATION_ERROR) if @control_stream_id
326
+ @control_stream_id = stream_id
327
+ @uni_stream_types[stream_id] = :control
328
+ @mutex.synchronize { @response_buffers[stream_id] = payload }
329
+ when Protocol::UnidirectionalStream::PUSH
330
+ raise Protocol::FrameError.new("Client must not send push streams",
331
+ error_code: Protocol::H3_STREAM_CREATION_ERROR)
332
+ when Protocol::UnidirectionalStream::QPACK_ENCODER
333
+ raise Protocol::FrameError.new("Duplicate QPACK encoder stream",
334
+ error_code: Protocol::H3_STREAM_CREATION_ERROR) if @qpack_encoder_stream_id
335
+ @qpack_encoder_stream_id = stream_id
336
+ @uni_stream_types[stream_id] = :qpack_encoder
337
+ @mutex.synchronize { @response_buffers[stream_id] = payload }
338
+ when Protocol::UnidirectionalStream::QPACK_DECODER
339
+ raise Protocol::FrameError.new("Duplicate QPACK decoder stream",
340
+ error_code: Protocol::H3_STREAM_CREATION_ERROR) if @qpack_decoder_stream_id
341
+ @qpack_decoder_stream_id = stream_id
342
+ @uni_stream_types[stream_id] = :qpack_decoder
343
+ @mutex.synchronize { @response_buffers[stream_id] = payload }
344
+ when Protocol::WebTransport::UNI_STREAM_TYPE
345
+ @uni_stream_types[stream_id] = :webtransport_uni
346
+ @mutex.synchronize { @response_buffers[stream_id] = payload }
347
+ else
348
+ @uni_stream_types[stream_id] = :unknown
349
+ end
350
+ end
351
+
352
+ def process_unidirectional_payload(stream_id, payload)
353
+ return if payload.empty?
354
+
355
+ case @uni_stream_types[stream_id]
356
+ when :webtransport_uni
357
+ @mutex.synchronize { @response_buffers[stream_id] = "".b }
358
+ [:webtransport_uni, payload]
359
+ when :control
360
+ # Keep whatever did not form a whole frame. Clearing the buffer
361
+ # discarded the front of a frame that arrived split across two
362
+ # receives; its tail then parsed as a new frame, which fails the
363
+ # connection. Control streams never send FIN, so nothing recovers
364
+ # it later.
365
+ consume_unidirectional(stream_id, payload, parse_control_frames(payload))
366
+ when :qpack_encoder
367
+ # Unlike :control this validates the leading instruction byte rather
368
+ # than reassembling frames, so clearing the buffer loses nothing.
369
+ # An instruction split across receives could still be misread; not
370
+ # observed, and it would need incremental QPACK decoding to fix.
371
+ validate_qpack_encoder_data(payload)
372
+ @mutex.synchronize { @response_buffers[stream_id] = "".b }
373
+ when :qpack_decoder
374
+ validate_qpack_decoder_data(payload)
375
+ @mutex.synchronize { @response_buffers[stream_id] = "".b }
376
+ end
377
+ end
378
+
379
+ # Drop the bytes a parser consumed, keeping any partial frame. The buffer
380
+ # may have grown while we parsed, so only what we read is removed.
381
+ def consume_unidirectional(stream_id, payload, consumed)
382
+ @mutex.synchronize do
383
+ buffer = @response_buffers[stream_id]
384
+ next if buffer.nil?
385
+
386
+ remainder = (consumed >= payload.bytesize) ? "".b : payload.byteslice(consumed..)
387
+ @response_buffers[stream_id] =
388
+ (buffer.bytesize > payload.bytesize) ? remainder + buffer.byteslice(payload.bytesize..) : remainder
211
389
  end
212
390
  end
213
391
 
214
392
  def set_control_stream(stream_id, payload = nil)
215
- raise Protocol::FrameError, "Duplicate control stream" if @control_stream_id
393
+ raise Protocol::FrameError.new("Duplicate control stream",
394
+ error_code: Protocol::H3_STREAM_CREATION_ERROR) if @control_stream_id
216
395
  @control_stream_id = stream_id
217
396
  parse_control_frames(payload) if payload && !payload.empty?
218
397
  end
@@ -221,6 +400,29 @@ module Quicsilver
221
400
  @settings
222
401
  end
223
402
 
403
+ # Get the priority for a stream. Returns default Priority if not set.
404
+ def stream_priority(stream_id)
405
+ @stream_priorities[stream_id] || Protocol::Priority.new
406
+ end
407
+
408
+ # Apply priority to a QUIC stream via MsQuic.
409
+ # MsQuic: 0 = lowest, 0xFFFF = highest.
410
+ # HTTP urgency: 0 = highest, 7 = lowest.
411
+ # Maps urgency into evenly spaced bands across the uint16 range.
412
+ # The priority is queued and applied on the MsQuic event thread.
413
+ def apply_stream_priority(stream, priority)
414
+ handle = stream.respond_to?(:stream_handle) ? stream.stream_handle : nil
415
+ return unless handle
416
+ quic_priority = (7 - priority.urgency) * 0x2000
417
+ Quicsilver.set_stream_priority(handle, quic_priority)
418
+ rescue => e
419
+ Quicsilver.logger.debug("Failed to set stream priority: #{e.message}")
420
+ end
421
+
422
+ def uni_stream_type(stream_id)
423
+ @uni_stream_types&.dig(stream_id)
424
+ end
425
+
224
426
  def critical_stream?(stream_id)
225
427
  stream_id == @control_stream_id ||
226
428
  stream_id == @qpack_encoder_stream_id ||
@@ -234,83 +436,105 @@ module Quicsilver
234
436
  Quicsilver.connection_shutdown(@handle, error_code, false)
235
437
  end
236
438
 
237
- private
439
+ # Returns QUIC transport statistics for this connection.
440
+ def stats
441
+ ConnectionStats.from_hash(Quicsilver.connection_statistics(@handle))
442
+ end
238
443
 
239
444
  def open_stream(unidirectional: false)
240
445
  handle = Quicsilver.open_stream(@data, unidirectional)
241
446
  Stream.new(handle)
242
447
  end
243
448
 
244
- def last_client_stream_id
245
- @streams.keys.select { |id| (id & 0x02) == 0 }.max || 0
449
+ private
450
+
451
+ def hex_string(value)
452
+ value.unpack1("H*") if value
453
+ end
454
+
455
+ def connection_metadata
456
+ metadata = {}
457
+ metadata["connection_id"] = connection_id if connection_id
458
+ metadata
246
459
  end
247
460
 
248
- # RFC 9114 §7.2.4.1 / §11.2.2: HTTP/2 setting identifiers forbidden in HTTP/3
249
- # 0x00 = SETTINGS_HEADER_TABLE_SIZE (reserved), 0x02-0x05 = various HTTP/2 settings
250
- # Note: 0x08 (SETTINGS_ENABLE_CONNECT_PROTOCOL) is valid in HTTP/3 per RFC 9220
251
- HTTP2_SETTINGS = [0x00, 0x02, 0x03, 0x04, 0x05].freeze
461
+ def request_id_for(stream_id)
462
+ [@transport_server_id, connection_id, stream_id].compact.join(":")
463
+ end
252
464
 
253
- # Frame types forbidden on the control stream
465
+ # Stream may have been reset by client — expected during normal operation.
466
+ def stream_send_error?(error)
467
+ unless error.message.include?(MSQUIC_INVALID_STATE) ||
468
+ error.message.include?("StreamSend failed") ||
469
+ error.message.include?(STREAM_CLOSED)
470
+ return false
471
+ end
472
+ Quicsilver.logger.debug("Stream send failed (client likely reset): #{error.message}")
473
+ true
474
+ end
475
+
476
+ def last_request_stream_id
477
+ @streams.keys.select { |stream_id| StreamId.request?(stream_id) }.max || 0
478
+ end
479
+
480
+ # Frame types forbidden on the control stream (RFC 9114 §7.2.4)
481
+ # Note: CANCEL_PUSH (0x03) and MAX_PUSH_ID (0x0d) ARE allowed on control stream.
254
482
  FORBIDDEN_ON_CONTROL = [
255
483
  0x00, # DATA — request streams only
256
484
  0x01, # HEADERS — request streams only
257
- 0x02, # HTTP/2 PRIORITY (reserved)
485
+ 0x02, # HTTP/2 PRIORITY (reserved, §7.2.8)
258
486
  0x05, # PUSH_PROMISE — request streams only
259
- 0x06, # HTTP/2 PING (reserved)
260
- 0x08, # HTTP/2 WINDOW_UPDATE (reserved)
261
- 0x09, # HTTP/2 CONTINUATION (reserved)
487
+ 0x06, # HTTP/2 PING (reserved, §7.2.8)
488
+ 0x08, # HTTP/2 WINDOW_UPDATE (reserved, §7.2.8)
489
+ 0x09, # HTTP/2 CONTINUATION (reserved, §7.2.8)
262
490
  ].freeze
263
491
 
264
- def parse_control_frames(data)
265
- offset = 0
266
- first_frame = !@settings_received
267
-
268
- while offset < data.bytesize
269
- frame_type, type_len = Protocol.decode_varint(data.bytes, offset)
270
- frame_length, length_len = Protocol.decode_varint(data.bytes, offset + type_len)
271
- break if type_len == 0 || length_len == 0
272
-
273
- if first_frame && frame_type != Protocol::FRAME_SETTINGS
274
- raise Protocol::FrameError.new("First frame on control stream must be SETTINGS", error_code: Protocol::H3_MISSING_SETTINGS)
275
- end
276
- first_frame = false
492
+ def on_settings_received(settings)
493
+ @settings.merge!(settings)
494
+ end
277
495
 
278
- if FORBIDDEN_ON_CONTROL.include?(frame_type)
279
- raise Protocol::FrameError, "Frame type 0x#{frame_type.to_s(16)} not allowed on control stream"
280
- end
496
+ def handle_control_frame(type, payload)
497
+ # WT_STREAM belongs at the front of a request stream and nowhere else;
498
+ # on the control stream it is H3_FRAME_ERROR, not H3_FRAME_UNEXPECTED
499
+ # (draft-ietf-webtrans-http3-16 §4.3).
500
+ if type == Protocol::FRAME_WT_STREAM
501
+ raise Protocol::FrameError.new(
502
+ "WT_STREAM is not allowed on the control stream",
503
+ error_code: Protocol::H3_FRAME_ERROR
504
+ )
505
+ end
281
506
 
282
- if frame_type == Protocol::FRAME_SETTINGS
283
- raise Protocol::FrameError, "Duplicate SETTINGS frame on control stream" if @settings_received
284
- parse_settings(data[offset + type_len + length_len, frame_length])
285
- @settings_received = true
286
- end
507
+ if FORBIDDEN_ON_CONTROL.include?(type)
508
+ raise Protocol::FrameError.new(
509
+ "Frame type 0x#{type.to_s(16)} not allowed on control stream",
510
+ error_code: Protocol::H3_FRAME_UNEXPECTED
511
+ )
512
+ end
287
513
 
288
- offset += type_len + length_len + frame_length
514
+ case type
515
+ when Protocol::FRAME_PRIORITY_UPDATE
516
+ parse_priority_update(payload)
517
+ when Protocol::FRAME_CANCEL_PUSH, Protocol::FRAME_MAX_PUSH_ID
518
+ # No-op: we don't implement server push (RFC 9114 §7.2.3, §7.2.7).
519
+ # These are valid on the control stream — accept and ignore.
289
520
  end
290
521
  end
291
522
 
292
- def parse_settings(payload)
293
- offset = 0
294
- seen = Set.new
295
- while offset < payload.bytesize
296
- id, id_len = Protocol.decode_varint(payload.bytes, offset)
297
- value, value_len = Protocol.decode_varint(payload.bytes, offset + id_len)
298
- break if id_len == 0 || value_len == 0
299
-
300
- if HTTP2_SETTINGS.include?(id)
301
- raise Protocol::FrameError.new("HTTP/2 setting identifier 0x#{id.to_s(16)} not allowed in HTTP/3", error_code: Protocol::H3_SETTINGS_ERROR)
302
- end
303
-
304
- raise Protocol::FrameError, "Duplicate setting identifier 0x#{id.to_s(16)}" if seen.include?(id)
305
- seen.add(id)
306
-
307
- @settings[id] = value
308
- offset += id_len + value_len
309
- end
523
+ # RFC 9218 §7: Parse PRIORITY_UPDATE frame.
524
+ # Payload is a stream ID varint followed by a Priority Field Value string.
525
+ def parse_priority_update(payload)
526
+ stream_id, consumed = Protocol.decode_varint(payload.bytes, 0)
527
+ priority_value = payload[consumed..]
528
+ @stream_priorities[stream_id] = Protocol::Priority.parse(priority_value)
310
529
  end
530
+
311
531
  # RFC 9204 §4.1.3: Validate QPACK encoder stream instructions.
312
- # We advertise QPACK_MAX_TABLE_CAPACITY = 0, so any Set Dynamic Table Capacity
313
- # instruction with value > 0 is an error.
532
+ # We advertise QPACK_MAX_TABLE_CAPACITY = 0, so:
533
+ # - Set Dynamic Table Capacity to 0 is valid (RFC 9204 §3.2.2)
534
+ # - Set Dynamic Table Capacity > 0 is an error
535
+ # - Insert With Name Reference (1xxxxxxx) is an error (no table to insert into)
536
+ # - Insert With Literal Name (01xxxxxx) is an error (capacity is 0)
537
+ # - Duplicate (000xxxxx) is an error (table is empty)
314
538
  def validate_qpack_encoder_data(data)
315
539
  return if data.empty?
316
540
  byte = data.bytes[0]
@@ -319,9 +543,29 @@ module Quicsilver
319
543
  if (byte & 0xE0) == 0x20
320
544
  capacity, _ = Protocol.decode_varint(data.bytes, 0)
321
545
  capacity &= 0x1F # mask off the instruction prefix
322
- # We advertised capacity 0, any non-zero is an error
546
+ # Setting to 0 is valid; non-zero exceeds our advertised maximum.
547
+ if capacity > 0
548
+ raise Protocol::FrameError.new(
549
+ "Dynamic table capacity exceeds advertised maximum",
550
+ error_code: Protocol::QPACK_ENCODER_STREAM_ERROR
551
+ )
552
+ end
553
+ elsif (byte & 0x80) == 0x80
554
+ # Insert With Name Reference — cannot insert when capacity=0
555
+ raise Protocol::FrameError.new(
556
+ "Insert instruction received but dynamic table capacity is 0",
557
+ error_code: Protocol::QPACK_ENCODER_STREAM_ERROR
558
+ )
559
+ elsif (byte & 0xC0) == 0x40
560
+ # Insert With Literal Name — cannot insert when capacity=0
561
+ raise Protocol::FrameError.new(
562
+ "Insert instruction received but dynamic table capacity is 0",
563
+ error_code: Protocol::QPACK_ENCODER_STREAM_ERROR
564
+ )
565
+ elsif (byte & 0xE0) == 0x00
566
+ # Duplicate — table is empty, nothing to duplicate
323
567
  raise Protocol::FrameError.new(
324
- "Dynamic table capacity exceeds advertised maximum",
568
+ "Duplicate instruction received but dynamic table is empty",
325
569
  error_code: Protocol::QPACK_ENCODER_STREAM_ERROR
326
570
  )
327
571
  end