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.
- checksums.yaml +7 -0
- data/.github/workflows/ci.yml +49 -0
- data/.github/workflows/cibuildgem.yaml +117 -0
- data/.gitignore +21 -0
- data/.gitmodules +3 -0
- data/.ruby-version +1 -0
- data/CHANGELOG.md +175 -0
- data/Gemfile +6 -0
- data/Gemfile.lock +67 -0
- data/LICENSE +21 -0
- data/README.md +261 -0
- data/Rakefile +130 -0
- data/benchmarks/baseline.rb +175 -0
- data/benchmarks/compare.rb +398 -0
- data/benchmarks/components.rb +191 -0
- data/benchmarks/helpers.rb +162 -0
- data/benchmarks/latency.rb +128 -0
- data/benchmarks/loss.rb +193 -0
- data/benchmarks/mobile.rb +125 -0
- data/bin/console +14 -0
- data/bin/setup +8 -0
- data/docs/connection_identity.md +141 -0
- data/examples/README.md +58 -0
- data/examples/benchmark.rb +191 -0
- data/examples/connection_pool_demo.rb +47 -0
- data/examples/example_helper.rb +69 -0
- data/examples/falcon_middleware.rb +44 -0
- data/examples/feature_demo.rb +125 -0
- data/examples/grpc_style.rb +97 -0
- data/examples/latency_aware_routing.rb +382 -0
- data/examples/lifecycle_drain.rb +264 -0
- data/examples/minimal_http3_server.rb +14 -0
- data/examples/priorities.rb +60 -0
- data/examples/protocol_http_server.rb +31 -0
- data/examples/rack_http3_server.rb +42 -0
- data/examples/rails_feature_test.rb +280 -0
- data/examples/server_stats.rb +117 -0
- data/examples/simple_client_test.rb +26 -0
- data/examples/streaming_sse.rb +33 -0
- data/examples/trailers.rb +69 -0
- data/examples/webtransport_smoke.rb +546 -0
- data/examples/worker_load_balancing.rb +425 -0
- data/ext/quicsilver/extconf.rb +124 -0
- 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 +2741 -0
- data/lib/quicsilver/3.4/quicsilver.so +0 -0
- data/lib/quicsilver/4.0/quicsilver.so +0 -0
- data/lib/quicsilver/client/client.rb +708 -0
- data/lib/quicsilver/client/connection_pool.rb +202 -0
- data/lib/quicsilver/client/request.rb +190 -0
- data/lib/quicsilver/errors.rb +39 -0
- data/lib/quicsilver/libmsquic.so.2 +0 -0
- data/lib/quicsilver/protocol/adapter.rb +196 -0
- 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 +117 -0
- data/lib/quicsilver/protocol/datagram.rb +40 -0
- data/lib/quicsilver/protocol/frame_parser.rb +141 -0
- data/lib/quicsilver/protocol/frame_reader.rb +84 -0
- data/lib/quicsilver/protocol/frames.rb +454 -0
- data/lib/quicsilver/protocol/priority.rb +65 -0
- data/lib/quicsilver/protocol/qpack/decoder.rb +127 -0
- data/lib/quicsilver/protocol/qpack/encoder.rb +217 -0
- data/lib/quicsilver/protocol/qpack/header_block_decoder.rb +140 -0
- data/lib/quicsilver/protocol/qpack/huffman.rb +459 -0
- data/lib/quicsilver/protocol/request_encoder.rb +50 -0
- data/lib/quicsilver/protocol/request_parser.rb +289 -0
- data/lib/quicsilver/protocol/response_encoder.rb +107 -0
- data/lib/quicsilver/protocol/response_parser.rb +147 -0
- data/lib/quicsilver/protocol/stream_input.rb +98 -0
- data/lib/quicsilver/protocol/stream_output.rb +59 -0
- 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/listener_data.rb +14 -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 +149 -0
- data/lib/quicsilver/server/request_registry.rb +63 -0
- 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 +1080 -0
- 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 +234 -0
- data/lib/quicsilver/transport/connection.rb +594 -0
- data/lib/quicsilver/transport/connection_stats.rb +53 -0
- data/lib/quicsilver/transport/event_loop.rb +74 -0
- data/lib/quicsilver/transport/inbound_stream.rb +53 -0
- data/lib/quicsilver/transport/stream.rb +43 -0
- data/lib/quicsilver/transport/stream_event.rb +49 -0
- data/lib/quicsilver/transport/stream_id.rb +26 -0
- data/lib/quicsilver/version.rb +3 -0
- data/lib/quicsilver.rb +96 -0
- data/lib/rackup/handler/quicsilver.rb +96 -0
- data/quicsilver.gemspec +66 -0
- 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
|