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
@@ -9,17 +9,60 @@ module Quicsilver
9
9
  # Stream already shut down by peer — raised by StreamSend when the
10
10
  # client has reset or closed the stream.
11
11
  MSQUIC_INVALID_STATE = "0x59"
12
+ # The peer tore the stream down before we finished responding.
13
+ STREAM_CLOSED = "QUIC stream is closed"
12
14
 
13
15
  attr_reader :handle, :data, :streams
14
16
  attr_reader :control_stream_id, :qpack_encoder_stream_id, :qpack_decoder_stream_id
15
17
  attr_reader :server_control_stream
16
18
  attr_reader :peer_goaway_id, :local_goaway_id
17
19
  attr_reader :stream_priorities
20
+ attr_reader :remote_address, :remote_port, :session_resumed
18
21
 
19
- def initialize(handle, data, max_header_size: nil)
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)
20
61
  @handle = handle
21
62
  @data = data
22
63
  @max_header_size = max_header_size
64
+ @connection_id = hex_string(connection_id)
65
+ @transport_server_id = transport_server_id
23
66
  @streams = {}
24
67
  @response_buffers = {}
25
68
  @mutex = Mutex.new
@@ -37,6 +80,55 @@ module Quicsilver
37
80
  @peer_goaway_id = nil
38
81
  @local_goaway_id = nil
39
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
+ )
40
132
  end
41
133
 
42
134
  # === Setup (called after connection established) ===
@@ -52,7 +144,7 @@ module Quicsilver
52
144
  stream.send([type].pack("C"))
53
145
  end
54
146
 
55
- # GREASE unidirectional stream (RFC 9297)
147
+ # GREASE unidirectional stream (RFC 9114 §6.2)
56
148
  stream = open_stream(unidirectional: true)
57
149
  stream.send(Protocol.encode_varint(Protocol.grease_id) + "GREASE".b)
58
150
  end
@@ -95,7 +187,7 @@ module Quicsilver
95
187
  def send_goaway(stream_id = nil)
96
188
  return unless @server_control_stream
97
189
 
98
- stream_id ||= last_client_stream_id
190
+ stream_id ||= last_request_stream_id
99
191
  validate_goaway_id!(stream_id)
100
192
 
101
193
  @server_control_stream.send(Protocol.build_goaway_frame(stream_id))
@@ -117,10 +209,9 @@ module Quicsilver
117
209
  # RFC 9114 §4.1: encoded as a HEADERS frame, no FIN.
118
210
  def send_informational(stream, status, headers)
119
211
  data = Protocol::ResponseEncoder.encode_informational(status, headers)
120
- Quicsilver.send_stream(stream.stream_handle, data, false)
121
- rescue RuntimeError => e
122
- raise unless e.message.include?(MSQUIC_INVALID_STATE) || e.message.include?("StreamSend failed")
123
- Quicsilver.logger.debug("Stream send failed (client likely reset): #{e.message}")
212
+ stream.send(data, fin: false)
213
+ rescue RuntimeError, IOError => e
214
+ raise unless stream_send_error?(e)
124
215
  end
125
216
 
126
217
  def send_response(stream, status, headers, body, head_request: false, trailers: nil)
@@ -128,26 +219,28 @@ module Quicsilver
128
219
  encoder = Protocol::ResponseEncoder.new(status, headers, body, head_request: head_request, trailers: trailers)
129
220
 
130
221
  if body.respond_to?(:to_ary)
131
- Quicsilver.send_stream(stream.stream_handle, encoder.encode, true)
222
+ stream.send(encoder.encode, fin: true)
132
223
  else
133
224
  encoder.stream_encode do |frame_data, fin|
134
- 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
135
226
  end
136
227
  end
137
- rescue RuntimeError => e
138
- # Stream may have been reset by client - this is expected
139
- raise unless e.message.include?(MSQUIC_INVALID_STATE) || e.message.include?("StreamSend failed")
140
- 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)
141
233
  end
142
234
 
143
235
  def send_error(stream, status, message)
144
236
  body = ["#{status} #{message}"]
145
- encoder = Protocol::ResponseEncoder.new(status, { "content-type" => "text/plain" }, body)
146
- Quicsilver.send_stream(stream.stream_handle, encoder.encode, true)
147
- rescue RuntimeError => e
148
- # Stream may have been reset by client - this is expected
149
- raise unless e.message.include?(MSQUIC_INVALID_STATE) || e.message.include?("StreamSend failed")
150
- 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)
151
244
  end
152
245
 
153
246
  # === Control Stream Handling ===
@@ -162,55 +255,16 @@ module Quicsilver
162
255
  buf = @mutex.synchronize { @response_buffers[stream_id] || "".b }
163
256
  return if buf.empty?
164
257
 
165
- # First time seeing this stream: identify stream type
166
258
  unless @uni_stream_types&.key?(stream_id)
167
259
  @uni_stream_types ||= {}
168
- stream_type, type_len = Protocol.decode_varint(buf.bytes, 0)
169
- return if type_len == 0 # need more data
170
-
171
- case stream_type
172
- when 0x00 # Control stream
173
- raise Protocol::FrameError, "Duplicate control stream" if @control_stream_id
174
- @control_stream_id = stream_id
175
- @uni_stream_types[stream_id] = :control
176
- # Remove the stream type byte from the buffer
177
- @mutex.synchronize { @response_buffers[stream_id] = (buf[type_len..] || "".b) }
178
- when 0x01
179
- raise Protocol::FrameError, "Client must not send push streams"
180
- when 0x02 # QPACK encoder stream
181
- raise Protocol::FrameError, "Duplicate QPACK encoder stream" if @qpack_encoder_stream_id
182
- @qpack_encoder_stream_id = stream_id
183
- @uni_stream_types[stream_id] = :qpack_encoder
184
- @mutex.synchronize { @response_buffers[stream_id] = (buf[type_len..] || "".b) }
185
- when 0x03 # QPACK decoder stream
186
- raise Protocol::FrameError, "Duplicate QPACK decoder stream" if @qpack_decoder_stream_id
187
- @qpack_decoder_stream_id = stream_id
188
- @uni_stream_types[stream_id] = :qpack_decoder
189
- @mutex.synchronize { @response_buffers[stream_id] = (buf[type_len..] || "".b) }
190
- else
191
- # Unknown unidirectional stream types MUST be ignored (RFC 9114 §6.2)
192
- @uni_stream_types[stream_id] = :unknown
193
- return
194
- end
260
+ stream_type, payload = decode_unidirectional_stream(buf)
261
+ return unless stream_type
195
262
 
263
+ register_unidirectional_stream(stream_id, stream_type, payload)
196
264
  buf = @mutex.synchronize { @response_buffers[stream_id] || "".b }
197
265
  end
198
266
 
199
- stream_type = @uni_stream_types[stream_id]
200
- return if buf.empty?
201
-
202
- case stream_type
203
- when :control
204
- parse_control_frames(buf)
205
- # Clear parsed data from buffer
206
- @mutex.synchronize { @response_buffers[stream_id] = "".b }
207
- when :qpack_encoder
208
- validate_qpack_encoder_data(buf)
209
- @mutex.synchronize { @response_buffers[stream_id] = "".b }
210
- when :qpack_decoder
211
- validate_qpack_decoder_data(buf)
212
- @mutex.synchronize { @response_buffers[stream_id] = "".b }
213
- end
267
+ process_unidirectional_payload(stream_id, buf)
214
268
  end
215
269
 
216
270
  def handle_unidirectional_stream(stream, fin: true)
@@ -224,35 +278,120 @@ module Quicsilver
224
278
  data = stream.data
225
279
  return if data.empty?
226
280
 
227
- stream_type, type_len = Protocol.decode_varint(data.bytes, 0)
228
- return if type_len == 0
229
- payload = data[type_len..-1]
281
+ stream_type, payload = decode_unidirectional_stream(data)
282
+ return unless stream_type
230
283
 
231
284
  case stream_type
232
- when 0x00
285
+ when Protocol::UnidirectionalStream::CONTROL
233
286
  set_control_stream(stream_id, payload)
234
287
  if fin
235
288
  raise Protocol::FrameError.new("Closure of critical stream", error_code: Protocol::H3_CLOSED_CRITICAL_STREAM)
236
289
  end
237
- when 0x01
238
- raise Protocol::FrameError, "Client must not send push streams"
239
- when 0x02
240
- 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
241
296
  @qpack_encoder_stream_id = stream_id
242
297
  if fin
243
298
  raise Protocol::FrameError.new("Closure of critical stream", error_code: Protocol::H3_CLOSED_CRITICAL_STREAM)
244
299
  end
245
- when 0x03
246
- 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
247
303
  @qpack_decoder_stream_id = stream_id
248
304
  if fin
249
305
  raise Protocol::FrameError.new("Closure of critical stream", error_code: Protocol::H3_CLOSED_CRITICAL_STREAM)
250
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
251
389
  end
252
390
  end
253
391
 
254
392
  def set_control_stream(stream_id, payload = nil)
255
- 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
256
395
  @control_stream_id = stream_id
257
396
  parse_control_frames(payload) if payload && !payload.empty?
258
397
  end
@@ -280,6 +419,10 @@ module Quicsilver
280
419
  Quicsilver.logger.debug("Failed to set stream priority: #{e.message}")
281
420
  end
282
421
 
422
+ def uni_stream_type(stream_id)
423
+ @uni_stream_types&.dig(stream_id)
424
+ end
425
+
283
426
  def critical_stream?(stream_id)
284
427
  stream_id == @control_stream_id ||
285
428
  stream_id == @qpack_encoder_stream_id ||
@@ -293,26 +436,57 @@ module Quicsilver
293
436
  Quicsilver.connection_shutdown(@handle, error_code, false)
294
437
  end
295
438
 
296
- private
439
+ # Returns QUIC transport statistics for this connection.
440
+ def stats
441
+ ConnectionStats.from_hash(Quicsilver.connection_statistics(@handle))
442
+ end
297
443
 
298
444
  def open_stream(unidirectional: false)
299
445
  handle = Quicsilver.open_stream(@data, unidirectional)
300
446
  Stream.new(handle)
301
447
  end
302
448
 
303
- def last_client_stream_id
304
- @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
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
305
478
  end
306
479
 
307
- # Frame types forbidden on the control stream
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.
308
482
  FORBIDDEN_ON_CONTROL = [
309
483
  0x00, # DATA — request streams only
310
484
  0x01, # HEADERS — request streams only
311
- 0x02, # HTTP/2 PRIORITY (reserved)
485
+ 0x02, # HTTP/2 PRIORITY (reserved, §7.2.8)
312
486
  0x05, # PUSH_PROMISE — request streams only
313
- 0x06, # HTTP/2 PING (reserved)
314
- 0x08, # HTTP/2 WINDOW_UPDATE (reserved)
315
- 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)
316
490
  ].freeze
317
491
 
318
492
  def on_settings_received(settings)
@@ -320,12 +494,29 @@ module Quicsilver
320
494
  end
321
495
 
322
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
+
323
507
  if FORBIDDEN_ON_CONTROL.include?(type)
324
- raise Protocol::FrameError, "Frame type 0x#{type.to_s(16)} not allowed on control stream"
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
+ )
325
512
  end
326
513
 
327
- if type == Protocol::FRAME_PRIORITY_UPDATE
514
+ case type
515
+ when Protocol::FRAME_PRIORITY_UPDATE
328
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.
329
520
  end
330
521
  end
331
522
 
@@ -338,8 +529,12 @@ module Quicsilver
338
529
  end
339
530
 
340
531
  # RFC 9204 §4.1.3: Validate QPACK encoder stream instructions.
341
- # We advertise QPACK_MAX_TABLE_CAPACITY = 0, so any Set Dynamic Table Capacity
342
- # 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)
343
538
  def validate_qpack_encoder_data(data)
344
539
  return if data.empty?
345
540
  byte = data.bytes[0]
@@ -348,9 +543,29 @@ module Quicsilver
348
543
  if (byte & 0xE0) == 0x20
349
544
  capacity, _ = Protocol.decode_varint(data.bytes, 0)
350
545
  capacity &= 0x1F # mask off the instruction prefix
351
- # 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
352
567
  raise Protocol::FrameError.new(
353
- "Dynamic table capacity exceeds advertised maximum",
568
+ "Duplicate instruction received but dynamic table is empty",
354
569
  error_code: Protocol::QPACK_ENCODER_STREAM_ERROR
355
570
  )
356
571
  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
@@ -2,10 +2,33 @@
2
2
 
3
3
  module Quicsilver
4
4
  module Transport
5
+ # Two threads: one drives MsQuic, one delivers its events to Ruby.
6
+ #
7
+ # The poll thread runs Quicsilver.poll, which releases the GVL for the
8
+ # whole MsQuic iteration: decryption, loss recovery, stream reassembly and
9
+ # the callbacks that queue events for Ruby all happen without the lock.
10
+ # The dispatcher thread runs Quicsilver.dispatch_events, which waits on
11
+ # that queue without the GVL, then takes it to call Server.handle_stream
12
+ # (or Client#handle_stream_event) for each event in order.
13
+ #
14
+ # Before, a single thread did both, and every byte MsQuic processed was
15
+ # processed while holding the GVL, because the callback at the end of it
16
+ # called into Ruby. Splitting them lets the datapath run in parallel with
17
+ # the dispatcher and the worker pool.
18
+ #
19
+ # Event order is preserved: the queue is FIFO and there is one consumer.
5
20
  class EventLoop
21
+ # Events delivered per GVL hold. Bounds how long a batch runs; the
22
+ # dispatcher yields early only when the server's admission queue is
23
+ # under pressure (Server#enqueue_work). QUICSILVER_DISPATCH_BATCH
24
+ # overrides it for measurement.
25
+ DISPATCH_BATCH = Integer(ENV.fetch("QUICSILVER_DISPATCH_BATCH", "64"))
26
+ DISPATCH_WAIT_MS = 1000
27
+
6
28
  def initialize
7
29
  @running = false
8
30
  @thread = nil
31
+ @dispatcher = nil
9
32
  @mutex = Mutex.new
10
33
  end
11
34
 
@@ -14,16 +37,29 @@ module Quicsilver
14
37
  return if @running
15
38
 
16
39
  @running = true
40
+ Quicsilver.open_event_queue
41
+ @dispatcher = Thread.new do
42
+ Thread.current.name = "quicsilver-dispatch"
43
+ loop do
44
+ delivered = Quicsilver.dispatch_events(DISPATCH_BATCH, DISPATCH_WAIT_MS)
45
+ break if delivered.nil? # queue closed and drained
46
+ end
47
+ end
17
48
  @thread = Thread.new do
49
+ Thread.current.name = "quicsilver-poll"
18
50
  Quicsilver.poll while @running
19
51
  end
20
52
  end
21
53
  end
22
54
 
55
+ # Stop the poll thread first so nothing else is queued, then close the
56
+ # queue so the dispatcher exits once it has delivered what is there.
23
57
  def stop
24
58
  @running = false
25
59
  Quicsilver.wake
26
60
  @thread&.join(2)
61
+ Quicsilver.close_event_queue
62
+ @dispatcher&.join(2)
27
63
  end
28
64
 
29
65
  def join