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
|
@@ -4,32 +4,50 @@ module Quicsilver
|
|
|
4
4
|
class Client
|
|
5
5
|
include Protocol::ControlStreamParser
|
|
6
6
|
|
|
7
|
-
attr_reader :hostname, :port, :unsecure, :connection_timeout, :request_timeout
|
|
7
|
+
attr_reader :hostname, :port, :unsecure, :connection_timeout, :request_timeout, :idle_timeout_ms
|
|
8
8
|
attr_reader :peer_goaway_id, :peer_settings, :peer_max_field_section_size
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
GoAwayError = Class.new(StandardError)
|
|
12
|
-
|
|
13
|
-
FINISHED_EVENTS = %w[RECEIVE_FIN RECEIVE STREAM_RESET STOP_SENDING].freeze
|
|
10
|
+
FINISHED_EVENTS = %w[RECEIVE_FIN RECEIVE STREAM_RESET STOP_SENDING DATAGRAM_RECEIVED STREAM_START_COMPLETE STREAM_PEER_ACCEPTED].freeze
|
|
14
11
|
|
|
15
12
|
DEFAULT_REQUEST_TIMEOUT = 30 # seconds
|
|
16
13
|
DEFAULT_CONNECTION_TIMEOUT = 5000 # ms
|
|
17
14
|
|
|
15
|
+
# Options that shape the QUIC connection rather than a single request.
|
|
16
|
+
# ConnectionPool keys pooled connections on these, so a connection built
|
|
17
|
+
# with one set is never reused for a caller asking for another.
|
|
18
|
+
DEFAULT_CONNECTION_OPTIONS = {
|
|
19
|
+
unsecure: false,
|
|
20
|
+
datagram_receive_enabled: true,
|
|
21
|
+
reliable_reset_enabled: true,
|
|
22
|
+
transport_cibir_id: nil,
|
|
23
|
+
idle_timeout_ms: Transport::Configuration::DEFAULT_IDLE_TIMEOUT_MS
|
|
24
|
+
}.freeze
|
|
25
|
+
|
|
18
26
|
def initialize(hostname, port = 4433, **options)
|
|
19
27
|
@hostname = hostname
|
|
20
28
|
@port = port
|
|
21
|
-
@unsecure = options.fetch(:unsecure,
|
|
29
|
+
@unsecure = options.fetch(:unsecure, DEFAULT_CONNECTION_OPTIONS[:unsecure])
|
|
30
|
+
@datagram_receive_enabled = options.fetch(:datagram_receive_enabled,
|
|
31
|
+
DEFAULT_CONNECTION_OPTIONS[:datagram_receive_enabled])
|
|
32
|
+
@reliable_reset_enabled = options.fetch(:reliable_reset_enabled,
|
|
33
|
+
DEFAULT_CONNECTION_OPTIONS[:reliable_reset_enabled])
|
|
22
34
|
@connection_timeout = options.fetch(:connection_timeout, DEFAULT_CONNECTION_TIMEOUT)
|
|
35
|
+
@idle_timeout_ms = options.fetch(:idle_timeout_ms, DEFAULT_CONNECTION_OPTIONS[:idle_timeout_ms])
|
|
23
36
|
@request_timeout = options.fetch(:request_timeout, DEFAULT_REQUEST_TIMEOUT)
|
|
24
37
|
@max_body_size = options[:max_body_size]
|
|
25
38
|
@max_header_size = options[:max_header_size]
|
|
26
39
|
|
|
40
|
+
# MsQuic CIBIR bytes for connecting to a CIBIR-configured listener.
|
|
41
|
+
# Must be set before ConnectionStart; MsQuic currently supports offset 0 only.
|
|
42
|
+
@transport_cibir_id = normalize_transport_cibir_id(options[:transport_cibir_id])
|
|
43
|
+
|
|
27
44
|
@connection_data = nil
|
|
28
45
|
@connected = false
|
|
29
46
|
@connection_start_time = nil
|
|
30
47
|
|
|
31
|
-
@response_buffers = {}
|
|
32
|
-
@
|
|
48
|
+
@response_buffers = {} # stream_id => binary data
|
|
49
|
+
@streaming = {} # stream_id => { body:, frame_buffer: }
|
|
50
|
+
@inflight = {} # handle => { request:, stream_id: }
|
|
33
51
|
@mutex = Mutex.new
|
|
34
52
|
|
|
35
53
|
# Server control stream state
|
|
@@ -39,6 +57,8 @@ module Quicsilver
|
|
|
39
57
|
@settings_received = false
|
|
40
58
|
@control_stream_id = nil
|
|
41
59
|
@uni_stream_types = {}
|
|
60
|
+
@datagram_callback = nil
|
|
61
|
+
@resumption_ticket = nil # stored ticket for 0-RTT reconnection
|
|
42
62
|
end
|
|
43
63
|
|
|
44
64
|
# --- Class-level API (automatic pooling) ---
|
|
@@ -65,24 +85,33 @@ module Quicsilver
|
|
|
65
85
|
end
|
|
66
86
|
end
|
|
67
87
|
|
|
68
|
-
def request(hostname, port, method, path, headers: {}, body: nil, **options, &block)
|
|
88
|
+
def request(hostname, port, method, path, headers: {}, body: nil, priority: nil, timeout: nil, **options, &block)
|
|
69
89
|
client = pool.checkout(hostname, port, **options)
|
|
70
|
-
client.public_send(method, path, headers: headers, body: body, &block)
|
|
90
|
+
client.public_send(method, path, headers: headers, body: body, priority: priority, timeout: timeout, &block)
|
|
71
91
|
ensure
|
|
72
92
|
pool.checkin(client) if client
|
|
73
93
|
end
|
|
74
94
|
end
|
|
75
95
|
|
|
76
96
|
# Disconnect and close the underlying QUIC connection.
|
|
97
|
+
# Saves the resumption ticket for 0-RTT on the next connect.
|
|
77
98
|
def disconnect
|
|
78
99
|
return unless @connected
|
|
79
100
|
|
|
80
101
|
@connected = false
|
|
81
102
|
|
|
103
|
+
# Save resumption ticket before closing (for 0-RTT reconnection)
|
|
104
|
+
if @connection_data
|
|
105
|
+
ticket = Quicsilver.get_resumption_ticket(@connection_data[1])
|
|
106
|
+
@resumption_ticket = ticket if ticket
|
|
107
|
+
end
|
|
108
|
+
|
|
82
109
|
@mutex.synchronize do
|
|
83
|
-
@
|
|
84
|
-
@
|
|
110
|
+
@inflight.each_value { |entry| entry[:request].fail(0, "Connection closed") }
|
|
111
|
+
@streaming.each_value { |state| state[:body]&.close(RuntimeError.new("Connection closed")) }
|
|
112
|
+
@inflight.clear
|
|
85
113
|
@response_buffers.clear
|
|
114
|
+
@streaming.clear
|
|
86
115
|
end
|
|
87
116
|
|
|
88
117
|
close_connection
|
|
@@ -95,9 +124,9 @@ module Quicsilver
|
|
|
95
124
|
# client.post("/data", body: json)
|
|
96
125
|
#
|
|
97
126
|
%i[get post patch delete head put].each do |method|
|
|
98
|
-
define_method(method) do |path, headers: {}, body: nil, &block|
|
|
99
|
-
req = build_request(method.to_s.upcase, path, headers: headers, body: body)
|
|
100
|
-
block ? block.call(req) : req.response
|
|
127
|
+
define_method(method) do |path, headers: {}, body: nil, priority: nil, timeout: nil, &block|
|
|
128
|
+
req = build_request(method.to_s.upcase, path, headers: headers, body: body, priority: priority)
|
|
129
|
+
block ? block.call(req) : req.response(timeout: timeout)
|
|
101
130
|
end
|
|
102
131
|
end
|
|
103
132
|
|
|
@@ -105,6 +134,25 @@ module Quicsilver
|
|
|
105
134
|
!@peer_goaway_id.nil?
|
|
106
135
|
end
|
|
107
136
|
|
|
137
|
+
# Send an unreliable datagram (RFC 9297 / QUIC RFC 9221).
|
|
138
|
+
# Data must fit in a single QUIC packet (~1200 bytes).
|
|
139
|
+
# Not retransmitted — best effort delivery.
|
|
140
|
+
#
|
|
141
|
+
# client.datagram_send("real-time-update")
|
|
142
|
+
#
|
|
143
|
+
def datagram_send(data)
|
|
144
|
+
ensure_connected!
|
|
145
|
+
Quicsilver.datagram_send(@connection_data, data.to_s.b)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Register a callback for received datagrams.
|
|
149
|
+
#
|
|
150
|
+
# client.on_datagram { |data| puts "Got: #{data}" }
|
|
151
|
+
#
|
|
152
|
+
def on_datagram(&block)
|
|
153
|
+
@datagram_callback = block
|
|
154
|
+
end
|
|
155
|
+
|
|
108
156
|
def receive_control_data(stream_id, data) # :nodoc:
|
|
109
157
|
buf = @uni_stream_types.key?(stream_id) ? data : identify_and_strip_stream_type(stream_id, data)
|
|
110
158
|
return if buf.nil? || buf.empty?
|
|
@@ -115,7 +163,7 @@ module Quicsilver
|
|
|
115
163
|
end
|
|
116
164
|
end
|
|
117
165
|
|
|
118
|
-
def build_request(method, path, headers: {}, body: nil)
|
|
166
|
+
def build_request(method, path, headers: {}, body: nil, priority: nil)
|
|
119
167
|
ensure_connected!
|
|
120
168
|
raise GoAwayError, "Connection is draining (GOAWAY received)" if draining?
|
|
121
169
|
|
|
@@ -123,9 +171,11 @@ module Quicsilver
|
|
|
123
171
|
raise StreamFailedToOpenError unless stream
|
|
124
172
|
|
|
125
173
|
request = Request.new(self, stream)
|
|
126
|
-
@mutex.synchronize
|
|
174
|
+
@mutex.synchronize do
|
|
175
|
+
@inflight[stream.handle] = { request: request, stream_id: nil }
|
|
176
|
+
end
|
|
127
177
|
|
|
128
|
-
send_to_stream(stream, method, path, headers, body)
|
|
178
|
+
send_to_stream(stream, method, path, headers, body, priority: priority)
|
|
129
179
|
|
|
130
180
|
request
|
|
131
181
|
end
|
|
@@ -134,6 +184,15 @@ module Quicsilver
|
|
|
134
184
|
@connected && @connection_data && connection_alive?
|
|
135
185
|
end
|
|
136
186
|
|
|
187
|
+
# Returns QUIC transport statistics (RTT, packet counts, congestion, etc.)
|
|
188
|
+
# from MsQuic's QUIC_STATISTICS_V2. Returns nil when not connected.
|
|
189
|
+
def stats
|
|
190
|
+
return nil unless @connected && @connection_data
|
|
191
|
+
Transport::ConnectionStats.from_hash(Quicsilver.connection_statistics(@connection_data[0]))
|
|
192
|
+
rescue
|
|
193
|
+
nil
|
|
194
|
+
end
|
|
195
|
+
|
|
137
196
|
def connection_info
|
|
138
197
|
info = @connection_data ? Quicsilver.connection_status(@connection_data[1]) : {}
|
|
139
198
|
info.merge(hostname: @hostname, port: @port, uptime: connection_uptime)
|
|
@@ -153,14 +212,17 @@ module Quicsilver
|
|
|
153
212
|
return self if @connected
|
|
154
213
|
|
|
155
214
|
Quicsilver.open_connection
|
|
156
|
-
|
|
215
|
+
# The poll thread is the only thing that drives MsQuic, including this
|
|
216
|
+
# handshake; wait_for_connection only watches for the result. In a
|
|
217
|
+
# process with no server the loop is not running yet, so start it first.
|
|
218
|
+
Quicsilver.event_loop.start
|
|
219
|
+
config = create_configuration
|
|
157
220
|
raise ConnectionError, "Failed to create configuration" if config.nil?
|
|
158
221
|
|
|
159
222
|
start_connection(config)
|
|
160
223
|
@connected = true
|
|
161
224
|
@connection_start_time = Time.now
|
|
162
225
|
send_control_stream
|
|
163
|
-
Quicsilver.event_loop.start
|
|
164
226
|
|
|
165
227
|
self
|
|
166
228
|
rescue => e
|
|
@@ -182,7 +244,7 @@ module Quicsilver
|
|
|
182
244
|
return unless FINISHED_EVENTS.include?(event)
|
|
183
245
|
|
|
184
246
|
# Server unidirectional streams (control, QPACK) — process incrementally
|
|
185
|
-
if (stream_id
|
|
247
|
+
if Transport::StreamId.unidirectional?(stream_id) && (event == "RECEIVE" || event == "RECEIVE_FIN")
|
|
186
248
|
begin
|
|
187
249
|
receive_control_data(stream_id, data)
|
|
188
250
|
rescue Protocol::FrameError => e
|
|
@@ -194,36 +256,55 @@ module Quicsilver
|
|
|
194
256
|
@mutex.synchronize do
|
|
195
257
|
case event
|
|
196
258
|
when "RECEIVE"
|
|
197
|
-
(
|
|
198
|
-
|
|
259
|
+
handle_receive(stream_id, data)
|
|
199
260
|
when "RECEIVE_FIN"
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
buffer = @response_buffers.delete(stream_id)
|
|
203
|
-
full_data = (buffer || "".b) + event.data
|
|
204
|
-
|
|
205
|
-
response_parser = Protocol::ResponseParser.new(full_data, max_body_size: @max_body_size,
|
|
206
|
-
max_header_size: @max_header_size)
|
|
207
|
-
response_parser.parse
|
|
208
|
-
|
|
209
|
-
response = {
|
|
210
|
-
status: response_parser.status,
|
|
211
|
-
headers: response_parser.headers,
|
|
212
|
-
body: response_parser.body.read
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
request = @pending_requests.delete(event.handle)
|
|
216
|
-
request&.complete(response)
|
|
217
|
-
|
|
261
|
+
handle_receive_fin(stream_id, data)
|
|
218
262
|
when "STREAM_RESET"
|
|
219
263
|
event = Transport::StreamEvent.new(data, "STREAM_RESET")
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
264
|
+
state = @streaming.delete(stream_id)
|
|
265
|
+
state&.dig(:body)&.close(RuntimeError.new("Stream reset by peer"))
|
|
266
|
+
entry = @inflight.delete(event.handle)
|
|
267
|
+
entry[:request]&.fail(event.error_code, "Stream reset by peer") if entry
|
|
223
268
|
when "STOP_SENDING"
|
|
224
269
|
event = Transport::StreamEvent.new(data, "STOP_SENDING")
|
|
225
|
-
|
|
226
|
-
|
|
270
|
+
state = @streaming.delete(stream_id)
|
|
271
|
+
state&.dig(:body)&.close(RuntimeError.new("Peer sent STOP_SENDING"))
|
|
272
|
+
entry = @inflight.delete(event.handle)
|
|
273
|
+
entry[:request]&.fail(event.error_code, "Peer sent STOP_SENDING") if entry
|
|
274
|
+
when "DATAGRAM_RECEIVED"
|
|
275
|
+
@datagram_callback&.call(data)
|
|
276
|
+
when "STREAM_START_COMPLETE"
|
|
277
|
+
# QUIC stream concurrency limit (MAX_STREAMS, RFC 9000 §4.6):
|
|
278
|
+
#
|
|
279
|
+
# The peer advertises how many streams can be open simultaneously
|
|
280
|
+
# on this connection via the initial_max_streams_bidi transport
|
|
281
|
+
# parameter. Controlled server-side by max_concurrent_requests
|
|
282
|
+
# (default 100, matches quic-go and Chromium).
|
|
283
|
+
#
|
|
284
|
+
# When the limit is hit, MsQuic queues new streams locally and
|
|
285
|
+
# sends a STREAMS_BLOCKED frame to the peer (RFC 9000 §19.14,
|
|
286
|
+
# SHOULD send, useful for debugging — not a flow control
|
|
287
|
+
# mechanism). The peer independently raises the limit by
|
|
288
|
+
# sending MAX_STREAMS as existing streams close.
|
|
289
|
+
#
|
|
290
|
+
# peer_accepted=true: stream is flowing immediately.
|
|
291
|
+
# peer_accepted=false: stream queued, connection is saturated.
|
|
292
|
+
#
|
|
293
|
+
# When saturated, the shared pool could open a second connection
|
|
294
|
+
# to the same host. Each QUIC connection has independent stream
|
|
295
|
+
# capacity, congestion control, and TLS state — so a saturated
|
|
296
|
+
# connection doesn't block a fresh one. This scales to
|
|
297
|
+
# N connections × 100 streams = N×100 concurrent requests.
|
|
298
|
+
# The ceiling is the server's max_connections (default 100).
|
|
299
|
+
#
|
|
300
|
+
# Rotation can be useful for long-lived streams:
|
|
301
|
+
# SSE, WebTransport data channels, gRPC streaming RPCs.
|
|
302
|
+
accepted = data.getbyte(8) == 1
|
|
303
|
+
Quicsilver.logger.debug("Stream #{stream_id} start complete (peer_accepted=#{accepted})")
|
|
304
|
+
when "STREAM_PEER_ACCEPTED"
|
|
305
|
+
# Previously queued stream now accepted — peer sent MAX_STREAMS
|
|
306
|
+
# (existing streams closed, freeing stream concurrency capacity).
|
|
307
|
+
Quicsilver.logger.debug("Stream #{stream_id} accepted by peer (MAX_STREAMS raised)")
|
|
227
308
|
end
|
|
228
309
|
end
|
|
229
310
|
rescue => e
|
|
@@ -233,13 +314,29 @@ module Quicsilver
|
|
|
233
314
|
|
|
234
315
|
private
|
|
235
316
|
|
|
317
|
+
def create_configuration
|
|
318
|
+
Quicsilver.create_configuration(@unsecure, @datagram_receive_enabled, @reliable_reset_enabled,
|
|
319
|
+
nil, nil, @idle_timeout_ms)
|
|
320
|
+
end
|
|
321
|
+
|
|
236
322
|
def ensure_connected!
|
|
237
323
|
return if @connected
|
|
238
|
-
|
|
324
|
+
@mutex.synchronize do
|
|
325
|
+
return if @connected
|
|
326
|
+
open_connection
|
|
327
|
+
end
|
|
239
328
|
end
|
|
240
329
|
|
|
241
330
|
def start_connection(config)
|
|
242
331
|
connection_handle, context_handle = create_connection
|
|
332
|
+
|
|
333
|
+
configure_cibir(connection_handle)
|
|
334
|
+
|
|
335
|
+
# Apply saved resumption ticket for 0-RTT reconnection
|
|
336
|
+
if @resumption_ticket
|
|
337
|
+
Quicsilver.set_resumption_ticket(connection_handle, @resumption_ticket)
|
|
338
|
+
end
|
|
339
|
+
|
|
243
340
|
unless Quicsilver.start_connection(connection_handle, config, @hostname, @port)
|
|
244
341
|
cleanup_failed_connection
|
|
245
342
|
raise ConnectionError, "Failed to start connection"
|
|
@@ -256,6 +353,23 @@ module Quicsilver
|
|
|
256
353
|
@connection_data
|
|
257
354
|
end
|
|
258
355
|
|
|
356
|
+
def configure_cibir(connection_handle)
|
|
357
|
+
return unless @transport_cibir_id
|
|
358
|
+
|
|
359
|
+
Quicsilver.configure_connection_cibir(connection_handle, [@transport_cibir_id].pack("H*"))
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
def normalize_transport_cibir_id(value)
|
|
363
|
+
return if value.nil?
|
|
364
|
+
|
|
365
|
+
cibir_id = value.to_s
|
|
366
|
+
unless cibir_id.match?(/\A[[:xdigit:]]+\z/) && cibir_id.bytesize.even? && cibir_id.bytesize.between?(2, 12)
|
|
367
|
+
raise ArgumentError, "transport_cibir_id must be 1..6 bytes encoded as an even-length hex string"
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
cibir_id.downcase
|
|
371
|
+
end
|
|
372
|
+
|
|
259
373
|
def cleanup_failed_connection
|
|
260
374
|
Quicsilver.close_connection_handle(@connection_data) if @connection_data
|
|
261
375
|
@connection_data = nil
|
|
@@ -265,6 +379,16 @@ module Quicsilver
|
|
|
265
379
|
def open_stream
|
|
266
380
|
handle = Quicsilver.open_stream(@connection_data, false)
|
|
267
381
|
Transport::Stream.new(handle)
|
|
382
|
+
rescue RuntimeError => e
|
|
383
|
+
status = TransportError.parse_status(e.message)
|
|
384
|
+
case e.message
|
|
385
|
+
when /\AStreamOpen failed/, /\AStreamStart failed/
|
|
386
|
+
raise StreamFailedToOpenError.new(e.message, status: status)
|
|
387
|
+
when /\AFailed to allocate/
|
|
388
|
+
raise TransportError.new(e.message, status: status)
|
|
389
|
+
else
|
|
390
|
+
raise
|
|
391
|
+
end
|
|
268
392
|
end
|
|
269
393
|
|
|
270
394
|
def open_unidirectional_stream
|
|
@@ -274,7 +398,7 @@ module Quicsilver
|
|
|
274
398
|
|
|
275
399
|
def send_control_stream
|
|
276
400
|
@control_stream = open_unidirectional_stream
|
|
277
|
-
@control_stream.send(Protocol.build_control_stream)
|
|
401
|
+
@control_stream.send(Protocol.build_control_stream(datagram_receive_enabled: @datagram_receive_enabled))
|
|
278
402
|
|
|
279
403
|
[0x02, 0x03].each do |type|
|
|
280
404
|
stream = open_unidirectional_stream
|
|
@@ -282,6 +406,20 @@ module Quicsilver
|
|
|
282
406
|
end
|
|
283
407
|
end
|
|
284
408
|
|
|
409
|
+
# RFC 9218 §7: Send PRIORITY_UPDATE frame on the control stream.
|
|
410
|
+
# Reprioritises an in-flight stream after it was opened.
|
|
411
|
+
def send_priority_update(stream_id, priority)
|
|
412
|
+
return unless @control_stream && priority
|
|
413
|
+
|
|
414
|
+
field_value = priority.to_s
|
|
415
|
+
payload = Protocol.encode_varint(stream_id) + field_value.b
|
|
416
|
+
frame = Protocol.encode_varint(Protocol::FRAME_PRIORITY_UPDATE) +
|
|
417
|
+
Protocol.encode_varint(payload.bytesize) + payload
|
|
418
|
+
@control_stream.send(frame, fin: false)
|
|
419
|
+
rescue => e
|
|
420
|
+
Quicsilver.logger.debug("Failed to send PRIORITY_UPDATE: #{e.message}")
|
|
421
|
+
end
|
|
422
|
+
|
|
285
423
|
def handle_connection_result(result)
|
|
286
424
|
if result.key?("error")
|
|
287
425
|
cleanup_failed_connection
|
|
@@ -299,24 +437,225 @@ module Quicsilver
|
|
|
299
437
|
false
|
|
300
438
|
end
|
|
301
439
|
|
|
302
|
-
def send_to_stream(stream, method, path, headers, body)
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
440
|
+
def send_to_stream(stream, method, path, headers, body, priority: nil)
|
|
441
|
+
# RFC 9114 §4.2.2: Enforce server's SETTINGS_MAX_FIELD_SECTION_SIZE
|
|
442
|
+
if @peer_max_field_section_size
|
|
443
|
+
header_size = estimate_header_size(method, path, headers)
|
|
444
|
+
if header_size > @peer_max_field_section_size
|
|
445
|
+
@mutex.synchronize { @inflight.delete(stream.handle) }
|
|
446
|
+
raise Error, "Request headers (#{header_size} bytes) exceed server's max field section size (#{@peer_max_field_section_size})"
|
|
447
|
+
end
|
|
448
|
+
end
|
|
311
449
|
|
|
312
|
-
|
|
450
|
+
if body == :stream
|
|
451
|
+
# Streaming mode: send HEADERS only, body sent later via Request#stream_body
|
|
452
|
+
encoded = Protocol::RequestEncoder.new(
|
|
453
|
+
method: method, path: path, scheme: "https",
|
|
454
|
+
authority: authority, headers: headers, body: nil, priority: priority
|
|
455
|
+
).encode
|
|
456
|
+
result = stream.send(encoded, fin: false)
|
|
457
|
+
else
|
|
458
|
+
# Buffered mode: send HEADERS + body + FIN
|
|
459
|
+
encoded = Protocol::RequestEncoder.new(
|
|
460
|
+
method: method, path: path, scheme: "https",
|
|
461
|
+
authority: authority, headers: headers, body: body, priority: priority
|
|
462
|
+
).encode
|
|
463
|
+
result = stream.send(encoded, fin: true)
|
|
464
|
+
end
|
|
313
465
|
|
|
314
466
|
unless result
|
|
315
|
-
@mutex.synchronize { @
|
|
467
|
+
@mutex.synchronize { @inflight.delete(stream.handle) }
|
|
316
468
|
raise Error, "Failed to send request"
|
|
317
469
|
end
|
|
318
470
|
end
|
|
319
471
|
|
|
472
|
+
# RFC 9114 §4.2.2: Estimate header field section size.
|
|
473
|
+
# Each field: name length + value length + 32 bytes overhead.
|
|
474
|
+
def handle_receive(stream_id, data)
|
|
475
|
+
# Data is prepended with [stream_handle(8)] — extract handle and payload
|
|
476
|
+
event_obj = Transport::StreamEvent.new(data, "RECEIVE")
|
|
477
|
+
if (entry = @inflight[event_obj.handle])
|
|
478
|
+
entry[:stream_id] ||= stream_id
|
|
479
|
+
end
|
|
480
|
+
|
|
481
|
+
state = @streaming[stream_id]
|
|
482
|
+
if state
|
|
483
|
+
# Already streaming — feed DATA frames to the body
|
|
484
|
+
state[:frame_buffer] << event_obj.data
|
|
485
|
+
drain_streaming_data(state)
|
|
486
|
+
else
|
|
487
|
+
(@response_buffers[stream_id] ||= "".b) << event_obj.data
|
|
488
|
+
strip_informational_frames!(stream_id)
|
|
489
|
+
# Only transition to streaming when the caller opted in via streaming_response.
|
|
490
|
+
# Buffered callers (.response) are unaffected.
|
|
491
|
+
if entry && entry[:request].streaming_requested?
|
|
492
|
+
try_start_streaming(stream_id, event_obj.handle)
|
|
493
|
+
end
|
|
494
|
+
end
|
|
495
|
+
end
|
|
496
|
+
|
|
497
|
+
def handle_receive_fin(stream_id, data)
|
|
498
|
+
event = Transport::StreamEvent.new(data, "RECEIVE_FIN")
|
|
499
|
+
state = @streaming.delete(stream_id)
|
|
500
|
+
|
|
501
|
+
if state
|
|
502
|
+
# Streaming mode: feed final data, close body
|
|
503
|
+
if event.data && !event.data.empty?
|
|
504
|
+
state[:frame_buffer] << event.data
|
|
505
|
+
drain_streaming_data(state)
|
|
506
|
+
end
|
|
507
|
+
state[:body].close_write
|
|
508
|
+
|
|
509
|
+
# Complete for .response() callers — they get the full body retroactively
|
|
510
|
+
entry = @inflight.delete(event.handle)
|
|
511
|
+
return unless entry
|
|
512
|
+
body_str = state[:body].rewind ? state[:body].read : ""
|
|
513
|
+
entry[:request].complete(Response.new(
|
|
514
|
+
status: state[:status], headers: state[:headers],
|
|
515
|
+
body: body_str, trailers: state[:trailers] || {}
|
|
516
|
+
))
|
|
517
|
+
else
|
|
518
|
+
# Buffered mode: parse everything at once
|
|
519
|
+
buffer = @response_buffers.delete(stream_id)
|
|
520
|
+
full_data = (buffer || "".b) + event.data
|
|
521
|
+
full_data = strip_informational_data(full_data)
|
|
522
|
+
|
|
523
|
+
response_parser = Protocol::ResponseParser.new(full_data, max_body_size: @max_body_size,
|
|
524
|
+
max_header_size: @max_header_size)
|
|
525
|
+
response_parser.parse
|
|
526
|
+
response_parser.validate_response!
|
|
527
|
+
|
|
528
|
+
body_str = response_parser.body&.read || ""
|
|
529
|
+
response = Response.new(
|
|
530
|
+
status: response_parser.status, headers: response_parser.headers,
|
|
531
|
+
body: body_str, trailers: response_parser.trailers || {}
|
|
532
|
+
)
|
|
533
|
+
|
|
534
|
+
entry = @inflight.delete(event.handle)
|
|
535
|
+
if entry
|
|
536
|
+
# Deliver as streaming response if caller opted in but data arrived
|
|
537
|
+
# before streaming mode could be activated (race condition fallback).
|
|
538
|
+
if entry[:request].streaming_requested?
|
|
539
|
+
streaming_body = Protocol::StreamInput.new
|
|
540
|
+
streaming_body.write(body_str) unless body_str.empty?
|
|
541
|
+
streaming_body.close_write
|
|
542
|
+
entry[:request].deliver_streaming(Response.new(
|
|
543
|
+
status: response_parser.status, headers: response_parser.headers,
|
|
544
|
+
body: streaming_body, trailers: response_parser.trailers || {}
|
|
545
|
+
))
|
|
546
|
+
end
|
|
547
|
+
entry[:request].complete(response)
|
|
548
|
+
end
|
|
549
|
+
end
|
|
550
|
+
end
|
|
551
|
+
|
|
552
|
+
# Try to parse HEADERS from buffered data and start streaming.
|
|
553
|
+
def try_start_streaming(stream_id, handle)
|
|
554
|
+
buf = @response_buffers[stream_id]
|
|
555
|
+
return unless buf && buf.bytesize >= 2
|
|
556
|
+
|
|
557
|
+
# Use FrameReader to find the first HEADERS frame
|
|
558
|
+
status = nil
|
|
559
|
+
headers = {}
|
|
560
|
+
headers_end = 0
|
|
561
|
+
|
|
562
|
+
Protocol::FrameReader.each(buf) do |type, payload, offset|
|
|
563
|
+
if type == Protocol::FRAME_HEADERS
|
|
564
|
+
s = peek_status(payload)
|
|
565
|
+
if s && s >= 200
|
|
566
|
+
status = s
|
|
567
|
+
PEEK_DECODER.decode(payload) do |name, value|
|
|
568
|
+
headers[name] = value unless name == ":status"
|
|
569
|
+
end
|
|
570
|
+
headers_end = offset
|
|
571
|
+
end
|
|
572
|
+
end
|
|
573
|
+
break # only look at the first frame
|
|
574
|
+
end
|
|
575
|
+
|
|
576
|
+
return unless status # No final HEADERS yet
|
|
577
|
+
|
|
578
|
+
# Transition from buffered to streaming
|
|
579
|
+
remaining = buf.byteslice(headers_end..-1) || "".b
|
|
580
|
+
@response_buffers.delete(stream_id)
|
|
581
|
+
|
|
582
|
+
body = Protocol::StreamInput.new
|
|
583
|
+
state = { body: body, frame_buffer: remaining, status: status, headers: headers, trailers: {} }
|
|
584
|
+
@streaming[stream_id] = state
|
|
585
|
+
|
|
586
|
+
# Drain any DATA frames that arrived with the HEADERS
|
|
587
|
+
drain_streaming_data(state)
|
|
588
|
+
|
|
589
|
+
# Deliver StreamingResponse to streaming_response() callers
|
|
590
|
+
entry = @inflight[handle]
|
|
591
|
+
entry[:request].deliver_streaming(Response.new(
|
|
592
|
+
status: status, headers: headers, body: body
|
|
593
|
+
)) if entry
|
|
594
|
+
end
|
|
595
|
+
|
|
596
|
+
# Extract complete DATA frame payloads from frame_buffer and write to body.
|
|
597
|
+
def drain_streaming_data(state)
|
|
598
|
+
buf = state[:frame_buffer]
|
|
599
|
+
consumed = 0
|
|
600
|
+
|
|
601
|
+
Protocol::FrameReader.each(buf) do |type, payload, offset|
|
|
602
|
+
if type == Protocol::FRAME_DATA
|
|
603
|
+
state[:body].write(payload)
|
|
604
|
+
elsif type == Protocol::FRAME_HEADERS && state[:status]
|
|
605
|
+
# Trailing HEADERS frame — decode as trailers
|
|
606
|
+
trailers = {}
|
|
607
|
+
PEEK_DECODER.decode(payload) { |name, value| trailers[name] = value }
|
|
608
|
+
state[:trailers] = trailers
|
|
609
|
+
end
|
|
610
|
+
consumed = offset
|
|
611
|
+
end
|
|
612
|
+
|
|
613
|
+
if consumed > 0
|
|
614
|
+
state[:frame_buffer] = buf.byteslice(consumed..-1) || "".b
|
|
615
|
+
end
|
|
616
|
+
end
|
|
617
|
+
|
|
618
|
+
def estimate_header_size(method, path, headers)
|
|
619
|
+
size = 0
|
|
620
|
+
# Pseudo-headers
|
|
621
|
+
size += ":method".bytesize + method.to_s.bytesize + 32
|
|
622
|
+
size += ":path".bytesize + path.to_s.bytesize + 32
|
|
623
|
+
size += ":scheme".bytesize + 5 + 32 # "https"
|
|
624
|
+
size += ":authority".bytesize + authority.bytesize + 32
|
|
625
|
+
# Regular headers
|
|
626
|
+
headers.each { |name, value| size += name.to_s.bytesize + value.to_s.bytesize + 32 }
|
|
627
|
+
size
|
|
628
|
+
end
|
|
629
|
+
|
|
630
|
+
# Strip leading 1xx informational HEADERS frames from data.
|
|
631
|
+
# RFC 9114 §4.1: Interim responses (1xx) precede the final response.
|
|
632
|
+
def strip_informational_data(data)
|
|
633
|
+
skip = Protocol::FrameReader.skip_while(data) do |type, payload|
|
|
634
|
+
type == Protocol::FRAME_HEADERS &&
|
|
635
|
+
(status = peek_status(payload)) && status >= 100 && status < 200
|
|
636
|
+
end
|
|
637
|
+
skip > 0 ? data.byteslice(skip..-1) || "".b : data
|
|
638
|
+
end
|
|
639
|
+
|
|
640
|
+
def strip_informational_frames!(stream_id)
|
|
641
|
+
buf = @response_buffers[stream_id]
|
|
642
|
+
return unless buf && buf.bytesize >= 2
|
|
643
|
+
|
|
644
|
+
@response_buffers[stream_id] = strip_informational_data(buf)
|
|
645
|
+
end
|
|
646
|
+
|
|
647
|
+
# Decode just the :status pseudo-header from a QPACK header block.
|
|
648
|
+
PEEK_DECODER = Protocol::Qpack::HeaderBlockDecoder.new
|
|
649
|
+
|
|
650
|
+
def peek_status(qpack_payload)
|
|
651
|
+
PEEK_DECODER.decode(qpack_payload) do |name, value|
|
|
652
|
+
return value.to_i if name == ":status"
|
|
653
|
+
end
|
|
654
|
+
nil
|
|
655
|
+
rescue
|
|
656
|
+
nil
|
|
657
|
+
end
|
|
658
|
+
|
|
320
659
|
# Identify stream type from first byte(s), strip it, return remaining data.
|
|
321
660
|
# Returns nil for unknown/ignored stream types.
|
|
322
661
|
def identify_and_strip_stream_type(stream_id, data)
|
|
@@ -325,7 +664,10 @@ module Quicsilver
|
|
|
325
664
|
|
|
326
665
|
case stream_type
|
|
327
666
|
when 0x00 # Control stream
|
|
328
|
-
|
|
667
|
+
if @control_stream_id
|
|
668
|
+
raise Protocol::FrameError.new("Duplicate control stream",
|
|
669
|
+
error_code: Protocol::H3_STREAM_CREATION_ERROR)
|
|
670
|
+
end
|
|
329
671
|
@control_stream_id = stream_id
|
|
330
672
|
@uni_stream_types[stream_id] = :control
|
|
331
673
|
when 0x02 # QPACK encoder stream
|
|
@@ -345,5 +687,22 @@ module Quicsilver
|
|
|
345
687
|
@peer_settings.merge!(settings)
|
|
346
688
|
@peer_max_field_section_size = settings[0x06] if settings.key?(0x06)
|
|
347
689
|
end
|
|
690
|
+
|
|
691
|
+
# RFC 9114 §5.2: After receiving GOAWAY, fail any in-flight requests
|
|
692
|
+
# on streams at or above the GOAWAY stream ID — the server won't process them.
|
|
693
|
+
def on_goaway_received(goaway_stream_id)
|
|
694
|
+
fail_requests_above_goaway(goaway_stream_id)
|
|
695
|
+
end
|
|
696
|
+
|
|
697
|
+
def fail_requests_above_goaway(goaway_stream_id)
|
|
698
|
+
@mutex.synchronize do
|
|
699
|
+
@inflight.each do |handle, entry|
|
|
700
|
+
sid = entry[:stream_id]
|
|
701
|
+
next unless sid && sid >= goaway_stream_id
|
|
702
|
+
@inflight.delete(handle)
|
|
703
|
+
entry[:request]&.fail(0, "GOAWAY: server will not process stream #{sid}")
|
|
704
|
+
end
|
|
705
|
+
end
|
|
706
|
+
end
|
|
348
707
|
end
|
|
349
708
|
end
|