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,708 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Quicsilver
|
|
4
|
+
class Client
|
|
5
|
+
include Protocol::ControlStreamParser
|
|
6
|
+
|
|
7
|
+
attr_reader :hostname, :port, :unsecure, :connection_timeout, :request_timeout, :idle_timeout_ms
|
|
8
|
+
attr_reader :peer_goaway_id, :peer_settings, :peer_max_field_section_size
|
|
9
|
+
|
|
10
|
+
FINISHED_EVENTS = %w[RECEIVE_FIN RECEIVE STREAM_RESET STOP_SENDING DATAGRAM_RECEIVED STREAM_START_COMPLETE STREAM_PEER_ACCEPTED].freeze
|
|
11
|
+
|
|
12
|
+
DEFAULT_REQUEST_TIMEOUT = 30 # seconds
|
|
13
|
+
DEFAULT_CONNECTION_TIMEOUT = 5000 # ms
|
|
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
|
+
|
|
26
|
+
def initialize(hostname, port = 4433, **options)
|
|
27
|
+
@hostname = hostname
|
|
28
|
+
@port = port
|
|
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])
|
|
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])
|
|
36
|
+
@request_timeout = options.fetch(:request_timeout, DEFAULT_REQUEST_TIMEOUT)
|
|
37
|
+
@max_body_size = options[:max_body_size]
|
|
38
|
+
@max_header_size = options[:max_header_size]
|
|
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
|
+
|
|
44
|
+
@connection_data = nil
|
|
45
|
+
@connected = false
|
|
46
|
+
@connection_start_time = nil
|
|
47
|
+
|
|
48
|
+
@response_buffers = {} # stream_id => binary data
|
|
49
|
+
@streaming = {} # stream_id => { body:, frame_buffer: }
|
|
50
|
+
@inflight = {} # handle => { request:, stream_id: }
|
|
51
|
+
@mutex = Mutex.new
|
|
52
|
+
|
|
53
|
+
# Server control stream state
|
|
54
|
+
@peer_settings = {}
|
|
55
|
+
@peer_max_field_section_size = nil
|
|
56
|
+
@peer_goaway_id = nil
|
|
57
|
+
@settings_received = false
|
|
58
|
+
@control_stream_id = nil
|
|
59
|
+
@uni_stream_types = {}
|
|
60
|
+
@datagram_callback = nil
|
|
61
|
+
@resumption_ticket = nil # stored ticket for 0-RTT reconnection
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# --- Class-level API (automatic pooling) ---
|
|
65
|
+
#
|
|
66
|
+
# Quicsilver::Client.get("example.com", 4433, "/users")
|
|
67
|
+
# Quicsilver::Client.post("example.com", 4433, "/data", body: json)
|
|
68
|
+
#
|
|
69
|
+
class << self
|
|
70
|
+
attr_writer :pool
|
|
71
|
+
|
|
72
|
+
def pool
|
|
73
|
+
@pool ||= ConnectionPool.new
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def close_pool
|
|
77
|
+
@pool&.close
|
|
78
|
+
@pool = nil
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Fire-and-forget HTTP methods with automatic pooling.
|
|
82
|
+
%i[get post patch delete head put].each do |method|
|
|
83
|
+
define_method(method) do |hostname, port, path, headers: {}, body: nil, **options, &block|
|
|
84
|
+
request(hostname, port, method, path, headers: headers, body: body, **options, &block)
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def request(hostname, port, method, path, headers: {}, body: nil, priority: nil, timeout: nil, **options, &block)
|
|
89
|
+
client = pool.checkout(hostname, port, **options)
|
|
90
|
+
client.public_send(method, path, headers: headers, body: body, priority: priority, timeout: timeout, &block)
|
|
91
|
+
ensure
|
|
92
|
+
pool.checkin(client) if client
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Disconnect and close the underlying QUIC connection.
|
|
97
|
+
# Saves the resumption ticket for 0-RTT on the next connect.
|
|
98
|
+
def disconnect
|
|
99
|
+
return unless @connected
|
|
100
|
+
|
|
101
|
+
@connected = false
|
|
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
|
+
|
|
109
|
+
@mutex.synchronize do
|
|
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
|
|
113
|
+
@response_buffers.clear
|
|
114
|
+
@streaming.clear
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
close_connection
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Instance-level HTTP methods. Auto-connects on first use.
|
|
121
|
+
#
|
|
122
|
+
# client = Quicsilver::Client.new("example.com", 4433)
|
|
123
|
+
# client.get("/users") # connects automatically
|
|
124
|
+
# client.post("/data", body: json)
|
|
125
|
+
#
|
|
126
|
+
%i[get post patch delete head put].each do |method|
|
|
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)
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def draining?
|
|
134
|
+
!@peer_goaway_id.nil?
|
|
135
|
+
end
|
|
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
|
+
|
|
156
|
+
def receive_control_data(stream_id, data) # :nodoc:
|
|
157
|
+
buf = @uni_stream_types.key?(stream_id) ? data : identify_and_strip_stream_type(stream_id, data)
|
|
158
|
+
return if buf.nil? || buf.empty?
|
|
159
|
+
|
|
160
|
+
case @uni_stream_types[stream_id]
|
|
161
|
+
when :control
|
|
162
|
+
parse_control_frames(buf)
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def build_request(method, path, headers: {}, body: nil, priority: nil)
|
|
167
|
+
ensure_connected!
|
|
168
|
+
raise GoAwayError, "Connection is draining (GOAWAY received)" if draining?
|
|
169
|
+
|
|
170
|
+
stream = open_stream
|
|
171
|
+
raise StreamFailedToOpenError unless stream
|
|
172
|
+
|
|
173
|
+
request = Request.new(self, stream)
|
|
174
|
+
@mutex.synchronize do
|
|
175
|
+
@inflight[stream.handle] = { request: request, stream_id: nil }
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
send_to_stream(stream, method, path, headers, body, priority: priority)
|
|
179
|
+
|
|
180
|
+
request
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def connected?
|
|
184
|
+
@connected && @connection_data && connection_alive?
|
|
185
|
+
end
|
|
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
|
+
|
|
196
|
+
def connection_info
|
|
197
|
+
info = @connection_data ? Quicsilver.connection_status(@connection_data[1]) : {}
|
|
198
|
+
info.merge(hostname: @hostname, port: @port, uptime: connection_uptime)
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def connection_uptime
|
|
202
|
+
return 0 unless @connection_start_time
|
|
203
|
+
Time.now - @connection_start_time
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def authority
|
|
207
|
+
"#{@hostname}:#{@port}"
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# :nodoc:
|
|
211
|
+
def open_connection
|
|
212
|
+
return self if @connected
|
|
213
|
+
|
|
214
|
+
Quicsilver.open_connection
|
|
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
|
|
220
|
+
raise ConnectionError, "Failed to create configuration" if config.nil?
|
|
221
|
+
|
|
222
|
+
start_connection(config)
|
|
223
|
+
@connected = true
|
|
224
|
+
@connection_start_time = Time.now
|
|
225
|
+
send_control_stream
|
|
226
|
+
|
|
227
|
+
self
|
|
228
|
+
rescue => e
|
|
229
|
+
cleanup_failed_connection
|
|
230
|
+
raise e.is_a?(ConnectionError) || e.is_a?(TimeoutError) ? e : ConnectionError.new("Connection failed: #{e.message}")
|
|
231
|
+
ensure
|
|
232
|
+
Quicsilver.close_configuration(config) if config
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
# :nodoc:
|
|
236
|
+
def close_connection
|
|
237
|
+
Quicsilver.close_connection_handle(@connection_data) if @connection_data
|
|
238
|
+
@connection_data = nil
|
|
239
|
+
@connected = false
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# Called directly by C extension via dispatch_to_ruby
|
|
243
|
+
def handle_stream_event(stream_id, event, data, _early_data) # :nodoc:
|
|
244
|
+
return unless FINISHED_EVENTS.include?(event)
|
|
245
|
+
|
|
246
|
+
# Server unidirectional streams (control, QPACK) — process incrementally
|
|
247
|
+
if Transport::StreamId.unidirectional?(stream_id) && (event == "RECEIVE" || event == "RECEIVE_FIN")
|
|
248
|
+
begin
|
|
249
|
+
receive_control_data(stream_id, data)
|
|
250
|
+
rescue Protocol::FrameError => e
|
|
251
|
+
Quicsilver.logger.error("Control stream error: #{e.message} (0x#{e.error_code.to_s(16)})")
|
|
252
|
+
end
|
|
253
|
+
return
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
@mutex.synchronize do
|
|
257
|
+
case event
|
|
258
|
+
when "RECEIVE"
|
|
259
|
+
handle_receive(stream_id, data)
|
|
260
|
+
when "RECEIVE_FIN"
|
|
261
|
+
handle_receive_fin(stream_id, data)
|
|
262
|
+
when "STREAM_RESET"
|
|
263
|
+
event = Transport::StreamEvent.new(data, "STREAM_RESET")
|
|
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
|
|
268
|
+
when "STOP_SENDING"
|
|
269
|
+
event = Transport::StreamEvent.new(data, "STOP_SENDING")
|
|
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)")
|
|
308
|
+
end
|
|
309
|
+
end
|
|
310
|
+
rescue => e
|
|
311
|
+
Quicsilver.logger.error("Error handling client stream: #{e.class} - #{e.message}")
|
|
312
|
+
Quicsilver.logger.debug(e.backtrace.first(5).join("\n"))
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
private
|
|
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
|
+
|
|
322
|
+
def ensure_connected!
|
|
323
|
+
return if @connected
|
|
324
|
+
@mutex.synchronize do
|
|
325
|
+
return if @connected
|
|
326
|
+
open_connection
|
|
327
|
+
end
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
def start_connection(config)
|
|
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
|
+
|
|
340
|
+
unless Quicsilver.start_connection(connection_handle, config, @hostname, @port)
|
|
341
|
+
cleanup_failed_connection
|
|
342
|
+
raise ConnectionError, "Failed to start connection"
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
result = Quicsilver.wait_for_connection(context_handle, @connection_timeout)
|
|
346
|
+
handle_connection_result(result)
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
def create_connection
|
|
350
|
+
@connection_data = Quicsilver.create_connection(self)
|
|
351
|
+
raise ConnectionError, "Failed to create connection" if @connection_data.nil?
|
|
352
|
+
|
|
353
|
+
@connection_data
|
|
354
|
+
end
|
|
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
|
+
|
|
373
|
+
def cleanup_failed_connection
|
|
374
|
+
Quicsilver.close_connection_handle(@connection_data) if @connection_data
|
|
375
|
+
@connection_data = nil
|
|
376
|
+
@connected = false
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
def open_stream
|
|
380
|
+
handle = Quicsilver.open_stream(@connection_data, false)
|
|
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
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
def open_unidirectional_stream
|
|
395
|
+
handle = Quicsilver.open_stream(@connection_data, true)
|
|
396
|
+
Transport::Stream.new(handle)
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
def send_control_stream
|
|
400
|
+
@control_stream = open_unidirectional_stream
|
|
401
|
+
@control_stream.send(Protocol.build_control_stream(datagram_receive_enabled: @datagram_receive_enabled))
|
|
402
|
+
|
|
403
|
+
[0x02, 0x03].each do |type|
|
|
404
|
+
stream = open_unidirectional_stream
|
|
405
|
+
stream.send([type].pack("C"))
|
|
406
|
+
end
|
|
407
|
+
end
|
|
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
|
+
|
|
423
|
+
def handle_connection_result(result)
|
|
424
|
+
if result.key?("error")
|
|
425
|
+
cleanup_failed_connection
|
|
426
|
+
raise ConnectionError, "Connection failed: status 0x#{result['status'].to_s(16)}, code: #{result['code']}"
|
|
427
|
+
elsif result.key?("timeout")
|
|
428
|
+
cleanup_failed_connection
|
|
429
|
+
raise TimeoutError, "Connection timed out after #{@connection_timeout}ms"
|
|
430
|
+
end
|
|
431
|
+
end
|
|
432
|
+
|
|
433
|
+
def connection_alive?
|
|
434
|
+
return false unless (info = Quicsilver.connection_status(@connection_data[1]))
|
|
435
|
+
info["connected"] && !info["failed"]
|
|
436
|
+
rescue
|
|
437
|
+
false
|
|
438
|
+
end
|
|
439
|
+
|
|
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
|
|
449
|
+
|
|
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
|
|
465
|
+
|
|
466
|
+
unless result
|
|
467
|
+
@mutex.synchronize { @inflight.delete(stream.handle) }
|
|
468
|
+
raise Error, "Failed to send request"
|
|
469
|
+
end
|
|
470
|
+
end
|
|
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
|
+
|
|
659
|
+
# Identify stream type from first byte(s), strip it, return remaining data.
|
|
660
|
+
# Returns nil for unknown/ignored stream types.
|
|
661
|
+
def identify_and_strip_stream_type(stream_id, data)
|
|
662
|
+
stream_type, type_len = Protocol.decode_varint(data.bytes, 0)
|
|
663
|
+
return nil if type_len == 0
|
|
664
|
+
|
|
665
|
+
case stream_type
|
|
666
|
+
when 0x00 # Control stream
|
|
667
|
+
if @control_stream_id
|
|
668
|
+
raise Protocol::FrameError.new("Duplicate control stream",
|
|
669
|
+
error_code: Protocol::H3_STREAM_CREATION_ERROR)
|
|
670
|
+
end
|
|
671
|
+
@control_stream_id = stream_id
|
|
672
|
+
@uni_stream_types[stream_id] = :control
|
|
673
|
+
when 0x02 # QPACK encoder stream
|
|
674
|
+
@uni_stream_types[stream_id] = :qpack_encoder
|
|
675
|
+
when 0x03 # QPACK decoder stream
|
|
676
|
+
@uni_stream_types[stream_id] = :qpack_decoder
|
|
677
|
+
else
|
|
678
|
+
# Unknown unidirectional stream types MUST be ignored (RFC 9114 §6.2)
|
|
679
|
+
@uni_stream_types[stream_id] = :unknown
|
|
680
|
+
return nil
|
|
681
|
+
end
|
|
682
|
+
|
|
683
|
+
data[type_len..] || "".b
|
|
684
|
+
end
|
|
685
|
+
|
|
686
|
+
def on_settings_received(settings)
|
|
687
|
+
@peer_settings.merge!(settings)
|
|
688
|
+
@peer_max_field_section_size = settings[0x06] if settings.key?(0x06)
|
|
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
|
|
707
|
+
end
|
|
708
|
+
end
|