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,594 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Quicsilver
4
+ module Transport
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
+
15
+ attr_reader :handle, :data, :streams
16
+ attr_reader :control_stream_id, :qpack_encoder_stream_id, :qpack_decoder_stream_id
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
33
+
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)
61
+ @handle = handle
62
+ @data = data
63
+ @max_header_size = max_header_size
64
+ @connection_id = hex_string(connection_id)
65
+ @transport_server_id = transport_server_id
66
+ @streams = {}
67
+ @response_buffers = {}
68
+ @mutex = Mutex.new
69
+
70
+ # Client's control streams (received)
71
+ @control_stream_id = nil
72
+ @qpack_encoder_stream_id = nil
73
+ @qpack_decoder_stream_id = nil
74
+
75
+ # Server's control stream (sent)
76
+ @server_control_stream = nil
77
+
78
+ @settings = {}
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
+ )
132
+ end
133
+
134
+ # === Setup (called after connection established) ===
135
+
136
+ def setup_http3_streams
137
+ # Control stream (required)
138
+ @server_control_stream = open_stream(unidirectional: true)
139
+ @server_control_stream.send(Protocol.build_control_stream(max_field_section_size: @max_header_size))
140
+
141
+ # QPACK encoder/decoder streams
142
+ [0x02, 0x03].each do |type|
143
+ stream = open_stream(unidirectional: true)
144
+ stream.send([type].pack("C"))
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)
150
+ end
151
+
152
+ # === Stream Management ===
153
+
154
+ def add_stream(stream)
155
+ @streams[stream.stream_id] = stream
156
+ end
157
+
158
+ def get_stream(stream_id)
159
+ @streams[stream_id]
160
+ end
161
+
162
+ def remove_stream(stream_id)
163
+ @streams.delete(stream_id)
164
+ end
165
+
166
+ def track_client_stream(stream_id)
167
+ @streams[stream_id] = true
168
+ end
169
+
170
+ # === Data Handling ===
171
+
172
+ def buffer_data(stream_id, data)
173
+ @mutex.synchronize do
174
+ (@response_buffers[stream_id] ||= "".b) << data
175
+ end
176
+ end
177
+
178
+ def complete_stream(stream_id, final_data)
179
+ @mutex.synchronize do
180
+ buffer = @response_buffers.delete(stream_id)
181
+ (buffer || "".b) + (final_data || "".b)
182
+ end
183
+ end
184
+
185
+ # === HTTP/3 Frames ===
186
+
187
+ def send_goaway(stream_id = nil)
188
+ return unless @server_control_stream
189
+
190
+ stream_id ||= last_request_stream_id
191
+ validate_goaway_id!(stream_id)
192
+
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
197
+ rescue => e
198
+ Quicsilver.logger.error("Failed to send GOAWAY: #{e.message}")
199
+ end
200
+
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)
220
+
221
+ if body.respond_to?(:to_ary)
222
+ stream.send(encoder.encode, fin: true)
223
+ else
224
+ encoder.stream_encode do |frame_data, fin|
225
+ stream.send(frame_data, fin: fin) unless frame_data.empty? && !fin
226
+ end
227
+ end
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)
233
+ end
234
+
235
+ def send_error(stream, status, message)
236
+ body = ["#{status} #{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)
244
+ end
245
+
246
+ # === Control Stream Handling ===
247
+
248
+ # Process incoming data on a unidirectional stream incrementally.
249
+ # Called on each RECEIVE event — control streams never send FIN.
250
+ def receive_unidirectional_data(stream_id, data)
251
+ @mutex.synchronize do
252
+ (@response_buffers[stream_id] ||= "".b) << data
253
+ end
254
+
255
+ buf = @mutex.synchronize { @response_buffers[stream_id] || "".b }
256
+ return if buf.empty?
257
+
258
+ unless @uni_stream_types&.key?(stream_id)
259
+ @uni_stream_types ||= {}
260
+ stream_type, payload = decode_unidirectional_stream(buf)
261
+ return unless stream_type
262
+
263
+ register_unidirectional_stream(stream_id, stream_type, payload)
264
+ buf = @mutex.synchronize { @response_buffers[stream_id] || "".b }
265
+ end
266
+
267
+ process_unidirectional_payload(stream_id, buf)
268
+ end
269
+
270
+ def handle_unidirectional_stream(stream, fin: true)
271
+ stream_id = stream.stream_id
272
+
273
+ # Already known as critical stream — closure via FIN is an error
274
+ if fin && critical_stream?(stream_id)
275
+ raise Protocol::FrameError.new("Closure of critical stream", error_code: Protocol::H3_CLOSED_CRITICAL_STREAM)
276
+ end
277
+
278
+ data = stream.data
279
+ return if data.empty?
280
+
281
+ stream_type, payload = decode_unidirectional_stream(data)
282
+ return unless stream_type
283
+
284
+ case stream_type
285
+ when Protocol::UnidirectionalStream::CONTROL
286
+ set_control_stream(stream_id, payload)
287
+ if fin
288
+ raise Protocol::FrameError.new("Closure of critical stream", error_code: Protocol::H3_CLOSED_CRITICAL_STREAM)
289
+ end
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
296
+ @qpack_encoder_stream_id = stream_id
297
+ if fin
298
+ raise Protocol::FrameError.new("Closure of critical stream", error_code: Protocol::H3_CLOSED_CRITICAL_STREAM)
299
+ end
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
303
+ @qpack_decoder_stream_id = stream_id
304
+ if fin
305
+ raise Protocol::FrameError.new("Closure of critical stream", error_code: Protocol::H3_CLOSED_CRITICAL_STREAM)
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
389
+ end
390
+ end
391
+
392
+ def set_control_stream(stream_id, payload = nil)
393
+ raise Protocol::FrameError.new("Duplicate control stream",
394
+ error_code: Protocol::H3_STREAM_CREATION_ERROR) if @control_stream_id
395
+ @control_stream_id = stream_id
396
+ parse_control_frames(payload) if payload && !payload.empty?
397
+ end
398
+
399
+ def settings
400
+ @settings
401
+ end
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
+
426
+ def critical_stream?(stream_id)
427
+ stream_id == @control_stream_id ||
428
+ stream_id == @qpack_encoder_stream_id ||
429
+ stream_id == @qpack_decoder_stream_id
430
+ end
431
+
432
+ # === Shutdown ===
433
+
434
+ def shutdown(error_code = 0)
435
+ send_goaway
436
+ Quicsilver.connection_shutdown(@handle, error_code, false)
437
+ end
438
+
439
+ # Returns QUIC transport statistics for this connection.
440
+ def stats
441
+ ConnectionStats.from_hash(Quicsilver.connection_statistics(@handle))
442
+ end
443
+
444
+ def open_stream(unidirectional: false)
445
+ handle = Quicsilver.open_stream(@data, unidirectional)
446
+ Stream.new(handle)
447
+ end
448
+
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
459
+ end
460
+
461
+ def request_id_for(stream_id)
462
+ [@transport_server_id, connection_id, stream_id].compact.join(":")
463
+ end
464
+
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.
482
+ FORBIDDEN_ON_CONTROL = [
483
+ 0x00, # DATA — request streams only
484
+ 0x01, # HEADERS — request streams only
485
+ 0x02, # HTTP/2 PRIORITY (reserved, §7.2.8)
486
+ 0x05, # PUSH_PROMISE — request streams only
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)
490
+ ].freeze
491
+
492
+ def on_settings_received(settings)
493
+ @settings.merge!(settings)
494
+ end
495
+
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
506
+
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
513
+
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.
520
+ end
521
+ end
522
+
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)
529
+ end
530
+
531
+ # RFC 9204 §4.1.3: Validate QPACK encoder stream instructions.
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)
538
+ def validate_qpack_encoder_data(data)
539
+ return if data.empty?
540
+ byte = data.bytes[0]
541
+
542
+ # Set Dynamic Table Capacity (001xxxxx)
543
+ if (byte & 0xE0) == 0x20
544
+ capacity, _ = Protocol.decode_varint(data.bytes, 0)
545
+ capacity &= 0x1F # mask off the instruction prefix
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
567
+ raise Protocol::FrameError.new(
568
+ "Duplicate instruction received but dynamic table is empty",
569
+ error_code: Protocol::QPACK_ENCODER_STREAM_ERROR
570
+ )
571
+ end
572
+ end
573
+
574
+ # RFC 9204 §4.4.3: Validate QPACK decoder stream instructions.
575
+ # Insert Count Increment of 0 is a decoder stream error.
576
+ def validate_qpack_decoder_data(data)
577
+ return if data.empty?
578
+ byte = data.bytes[0]
579
+
580
+ # Insert Count Increment (00xxxxxx)
581
+ if (byte & 0xC0) == 0x00
582
+ increment, _ = Protocol.decode_varint(data.bytes, 0)
583
+ increment &= 0x3F # mask off prefix bits
584
+ if increment == 0
585
+ raise Protocol::FrameError.new(
586
+ "Insert Count Increment of 0 on decoder stream",
587
+ error_code: Protocol::QPACK_DECODER_STREAM_ERROR
588
+ )
589
+ end
590
+ end
591
+ end
592
+ end
593
+ end
594
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Quicsilver
4
+ module Transport
5
+ # Immutable snapshot of QUIC transport statistics from MsQuic's QUIC_STATISTICS_V2.
6
+ #
7
+ # stats = client.stats
8
+ # stats.rtt # => 1234 (microseconds)
9
+ # stats.send_total_packets # => 42
10
+ # stats.recv_total_bytes # => 98765
11
+ #
12
+ ConnectionStats = Data.define(
13
+ # RTT (microseconds)
14
+ :rtt, :min_rtt, :max_rtt,
15
+
16
+ # Handshake
17
+ :resumption_attempted, :resumption_succeeded,
18
+
19
+ # Send
20
+ :send_path_mtu,
21
+ :send_total_packets, :send_retransmittable_packets,
22
+ :send_suspected_lost_packets, :send_spurious_lost_packets,
23
+ :send_total_bytes, :send_total_stream_bytes,
24
+ :send_congestion_count, :send_persistent_congestion_count,
25
+ :send_congestion_window,
26
+
27
+ # Recv
28
+ :recv_total_packets, :recv_reordered_packets,
29
+ :recv_dropped_packets, :recv_duplicate_packets,
30
+ :recv_total_bytes, :recv_total_stream_bytes,
31
+ :recv_decryption_failures, :recv_valid_ack_frames,
32
+
33
+ # Misc
34
+ :key_update_count
35
+ ) do
36
+ # Build from the hash returned by the C extension.
37
+ def self.from_hash(hash)
38
+ return nil unless hash
39
+
40
+ new(**hash.transform_keys(&:to_sym))
41
+ end
42
+
43
+ def resumed?
44
+ resumption_succeeded
45
+ end
46
+
47
+ def packet_loss_rate
48
+ return 0.0 if send_total_packets == 0
49
+ send_suspected_lost_packets.to_f / send_total_packets
50
+ end
51
+ end
52
+ end
53
+ end