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.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yml +9 -1
- data/.github/workflows/cibuildgem.yaml +29 -5
- data/.gitignore +5 -0
- data/CHANGELOG.md +50 -0
- data/Gemfile.lock +8 -1
- data/README.md +128 -15
- data/Rakefile +10 -3
- data/benchmarks/baseline.rb +175 -0
- data/benchmarks/compare.rb +398 -0
- data/benchmarks/helpers.rb +138 -64
- data/benchmarks/latency.rb +128 -0
- data/benchmarks/loss.rb +193 -0
- data/benchmarks/mobile.rb +125 -0
- data/docs/connection_identity.md +141 -0
- data/examples/benchmark.rb +80 -0
- data/examples/connection_pool_demo.rb +1 -1
- data/examples/example_helper.rb +51 -0
- data/examples/feature_demo.rb +7 -7
- data/examples/grpc_style.rb +2 -2
- data/examples/latency_aware_routing.rb +382 -0
- data/examples/lifecycle_drain.rb +264 -0
- data/examples/priorities.rb +2 -2
- data/examples/rails_feature_test.rb +92 -72
- data/examples/server_stats.rb +117 -0
- data/examples/simple_client_test.rb +3 -3
- data/examples/trailers.rb +23 -23
- data/examples/webtransport_smoke.rb +546 -0
- data/examples/worker_load_balancing.rb +425 -0
- data/ext/quicsilver/extconf.rb +100 -15
- data/ext/quicsilver/include/msquic.h +2226 -0
- data/ext/quicsilver/include/msquic_posix.h +696 -0
- data/ext/quicsilver/include/quic_sal_stub.h +314 -0
- data/ext/quicsilver/msquic_patch.rb +68 -0
- data/ext/quicsilver/patches/reliable-reset.patch +723 -0
- data/ext/quicsilver/patches/stream-final-size.patch +47 -0
- data/ext/quicsilver/quicsilver.c +1640 -222
- data/lib/quicsilver/client/client.rb +419 -60
- data/lib/quicsilver/client/connection_pool.rb +122 -26
- data/lib/quicsilver/client/request.rb +106 -14
- data/lib/quicsilver/errors.rb +39 -0
- data/lib/quicsilver/libmsquic.2.dylib +0 -0
- data/lib/quicsilver/protocol/adapter.rb +41 -21
- data/lib/quicsilver/protocol/capsule.rb +38 -0
- data/lib/quicsilver/protocol/connect_stream_decoder.rb +75 -0
- data/lib/quicsilver/protocol/control_stream_parser.rb +12 -1
- data/lib/quicsilver/protocol/datagram.rb +40 -0
- data/lib/quicsilver/protocol/frame_parser.rb +5 -6
- data/lib/quicsilver/protocol/frame_reader.rb +31 -2
- data/lib/quicsilver/protocol/frames.rb +128 -12
- data/lib/quicsilver/protocol/priority.rb +9 -0
- data/lib/quicsilver/protocol/qpack/decoder.rb +4 -42
- data/lib/quicsilver/protocol/qpack/encoder.rb +10 -20
- data/lib/quicsilver/protocol/qpack/huffman.rb +4 -4
- data/lib/quicsilver/protocol/request_encoder.rb +6 -3
- data/lib/quicsilver/protocol/request_parser.rb +16 -2
- data/lib/quicsilver/protocol/response_encoder.rb +18 -8
- data/lib/quicsilver/protocol/response_parser.rb +6 -0
- data/lib/quicsilver/protocol/stream_input.rb +3 -3
- data/lib/quicsilver/protocol/unidirectional_stream.rb +12 -0
- data/lib/quicsilver/protocol/web_transport.rb +214 -0
- data/lib/quicsilver/rack/context.rb +33 -0
- data/lib/quicsilver/response.rb +40 -0
- data/lib/quicsilver/server/rack_adapter.rb +77 -0
- data/lib/quicsilver/server/receive_queue.rb +106 -0
- data/lib/quicsilver/server/request_handler.rb +24 -13
- data/lib/quicsilver/server/request_registry.rb +18 -5
- data/lib/quicsilver/server/scheduler.rb +50 -0
- data/lib/quicsilver/server/schedulers/fiber_scheduler.rb +79 -0
- data/lib/quicsilver/server/schedulers/thread_scheduler.rb +76 -0
- data/lib/quicsilver/server/server.rb +655 -185
- data/lib/quicsilver/server/web_transport_flow_control.rb +270 -0
- data/lib/quicsilver/server/web_transport_manager.rb +281 -0
- data/lib/quicsilver/server/web_transport_registry.rb +40 -0
- data/lib/quicsilver/server/web_transport_session.rb +812 -0
- data/lib/quicsilver/server/web_transport_stream.rb +326 -0
- data/lib/quicsilver/transport/configuration.rb +103 -10
- data/lib/quicsilver/transport/connection.rb +302 -87
- data/lib/quicsilver/transport/connection_stats.rb +53 -0
- data/lib/quicsilver/transport/event_loop.rb +36 -0
- data/lib/quicsilver/transport/inbound_stream.rb +21 -1
- data/lib/quicsilver/transport/stream.rb +18 -3
- data/lib/quicsilver/transport/stream_event.rb +25 -2
- data/lib/quicsilver/transport/stream_id.rb +26 -0
- data/lib/quicsilver/version.rb +1 -1
- data/lib/quicsilver.rb +22 -11
- data/lib/rackup/handler/quicsilver.rb +57 -38
- data/quicsilver.gemspec +10 -3
- metadata +54 -8
- data/benchmarks/concurrent.rb +0 -110
- data/benchmarks/quicsilver_server.rb +0 -46
- data/benchmarks/rails.rb +0 -170
- data/benchmarks/throughput.rb +0 -113
- 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
|
|
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
|
|
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 ||=
|
|
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
|
-
|
|
121
|
-
rescue RuntimeError => e
|
|
122
|
-
raise unless
|
|
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
|
-
|
|
222
|
+
stream.send(encoder.encode, fin: true)
|
|
132
223
|
else
|
|
133
224
|
encoder.stream_encode do |frame_data, fin|
|
|
134
|
-
|
|
225
|
+
stream.send(frame_data, fin: fin) unless frame_data.empty? && !fin
|
|
135
226
|
end
|
|
136
227
|
end
|
|
137
|
-
rescue RuntimeError => e
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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,
|
|
169
|
-
return
|
|
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
|
-
|
|
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,
|
|
228
|
-
return
|
|
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
|
|
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
|
|
238
|
-
raise Protocol::FrameError
|
|
239
|
-
|
|
240
|
-
|
|
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
|
|
246
|
-
raise Protocol::FrameError
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
304
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
342
|
-
#
|
|
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
|
-
#
|
|
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
|
-
"
|
|
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
|