quicsilver 0.2.0 → 0.4.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 +4 -5
- data/.github/workflows/cibuildgem.yaml +93 -0
- data/.gitignore +3 -1
- data/CHANGELOG.md +81 -0
- data/Gemfile.lock +26 -4
- data/README.md +95 -31
- data/Rakefile +95 -3
- data/benchmarks/components.rb +191 -0
- data/benchmarks/concurrent.rb +110 -0
- data/benchmarks/helpers.rb +88 -0
- data/benchmarks/quicsilver_server.rb +1 -1
- data/benchmarks/rails.rb +170 -0
- data/benchmarks/throughput.rb +113 -0
- data/examples/README.md +44 -91
- data/examples/benchmark.rb +111 -0
- data/examples/connection_pool_demo.rb +47 -0
- data/examples/example_helper.rb +18 -0
- data/examples/falcon_middleware.rb +44 -0
- data/examples/feature_demo.rb +125 -0
- data/examples/grpc_style.rb +97 -0
- data/examples/minimal_http3_server.rb +6 -18
- data/examples/priorities.rb +60 -0
- data/examples/protocol_http_server.rb +31 -0
- data/examples/rack_http3_server.rb +8 -20
- data/examples/rails_feature_test.rb +260 -0
- data/examples/simple_client_test.rb +2 -2
- data/examples/streaming_sse.rb +33 -0
- data/examples/trailers.rb +69 -0
- data/ext/quicsilver/extconf.rb +14 -0
- data/ext/quicsilver/quicsilver.c +568 -181
- data/lib/quicsilver/client/client.rb +349 -0
- data/lib/quicsilver/client/connection_pool.rb +106 -0
- data/lib/quicsilver/client/request.rb +98 -0
- data/lib/quicsilver/libmsquic.2.dylib +0 -0
- data/lib/quicsilver/protocol/adapter.rb +176 -0
- data/lib/quicsilver/protocol/control_stream_parser.rb +106 -0
- data/lib/quicsilver/protocol/frame_parser.rb +142 -0
- data/lib/quicsilver/protocol/frame_reader.rb +55 -0
- data/lib/quicsilver/{http3.rb → protocol/frames.rb} +146 -30
- data/lib/quicsilver/protocol/priority.rb +56 -0
- data/lib/quicsilver/protocol/qpack/decoder.rb +165 -0
- data/lib/quicsilver/protocol/qpack/encoder.rb +227 -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 +47 -0
- data/lib/quicsilver/protocol/request_parser.rb +275 -0
- data/lib/quicsilver/protocol/response_encoder.rb +97 -0
- data/lib/quicsilver/protocol/response_parser.rb +141 -0
- data/lib/quicsilver/protocol/stream_input.rb +98 -0
- data/lib/quicsilver/protocol/stream_output.rb +59 -0
- data/lib/quicsilver/quicsilver.bundle +0 -0
- data/lib/quicsilver/server/listener_data.rb +14 -0
- data/lib/quicsilver/server/request_handler.rb +138 -0
- data/lib/quicsilver/server/request_registry.rb +50 -0
- data/lib/quicsilver/server/server.rb +610 -0
- data/lib/quicsilver/transport/configuration.rb +141 -0
- data/lib/quicsilver/transport/connection.rb +379 -0
- data/lib/quicsilver/transport/event_loop.rb +38 -0
- data/lib/quicsilver/transport/inbound_stream.rb +33 -0
- data/lib/quicsilver/transport/stream.rb +28 -0
- data/lib/quicsilver/transport/stream_event.rb +26 -0
- data/lib/quicsilver/version.rb +1 -1
- data/lib/quicsilver.rb +55 -14
- data/lib/rackup/handler/quicsilver.rb +1 -2
- data/quicsilver.gemspec +13 -3
- metadata +125 -21
- data/benchmarks/benchmark.rb +0 -68
- data/examples/setup_certs.sh +0 -57
- data/lib/quicsilver/client.rb +0 -261
- data/lib/quicsilver/connection.rb +0 -42
- data/lib/quicsilver/event_loop.rb +0 -38
- data/lib/quicsilver/http3/request_encoder.rb +0 -133
- data/lib/quicsilver/http3/request_parser.rb +0 -176
- data/lib/quicsilver/http3/response_encoder.rb +0 -186
- data/lib/quicsilver/http3/response_parser.rb +0 -160
- data/lib/quicsilver/listener_data.rb +0 -29
- data/lib/quicsilver/quic_stream.rb +0 -36
- data/lib/quicsilver/request_registry.rb +0 -48
- data/lib/quicsilver/server.rb +0 -355
- data/lib/quicsilver/server_configuration.rb +0 -78
|
@@ -0,0 +1,349 @@
|
|
|
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
|
|
8
|
+
attr_reader :peer_goaway_id, :peer_settings, :peer_max_field_section_size
|
|
9
|
+
|
|
10
|
+
StreamFailedToOpenError = Class.new(StandardError)
|
|
11
|
+
GoAwayError = Class.new(StandardError)
|
|
12
|
+
|
|
13
|
+
FINISHED_EVENTS = %w[RECEIVE_FIN RECEIVE STREAM_RESET STOP_SENDING].freeze
|
|
14
|
+
|
|
15
|
+
DEFAULT_REQUEST_TIMEOUT = 30 # seconds
|
|
16
|
+
DEFAULT_CONNECTION_TIMEOUT = 5000 # ms
|
|
17
|
+
|
|
18
|
+
def initialize(hostname, port = 4433, **options)
|
|
19
|
+
@hostname = hostname
|
|
20
|
+
@port = port
|
|
21
|
+
@unsecure = options.fetch(:unsecure, true)
|
|
22
|
+
@connection_timeout = options.fetch(:connection_timeout, DEFAULT_CONNECTION_TIMEOUT)
|
|
23
|
+
@request_timeout = options.fetch(:request_timeout, DEFAULT_REQUEST_TIMEOUT)
|
|
24
|
+
@max_body_size = options[:max_body_size]
|
|
25
|
+
@max_header_size = options[:max_header_size]
|
|
26
|
+
|
|
27
|
+
@connection_data = nil
|
|
28
|
+
@connected = false
|
|
29
|
+
@connection_start_time = nil
|
|
30
|
+
|
|
31
|
+
@response_buffers = {}
|
|
32
|
+
@pending_requests = {} # handle => Request
|
|
33
|
+
@mutex = Mutex.new
|
|
34
|
+
|
|
35
|
+
# Server control stream state
|
|
36
|
+
@peer_settings = {}
|
|
37
|
+
@peer_max_field_section_size = nil
|
|
38
|
+
@peer_goaway_id = nil
|
|
39
|
+
@settings_received = false
|
|
40
|
+
@control_stream_id = nil
|
|
41
|
+
@uni_stream_types = {}
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# --- Class-level API (automatic pooling) ---
|
|
45
|
+
#
|
|
46
|
+
# Quicsilver::Client.get("example.com", 4433, "/users")
|
|
47
|
+
# Quicsilver::Client.post("example.com", 4433, "/data", body: json)
|
|
48
|
+
#
|
|
49
|
+
class << self
|
|
50
|
+
attr_writer :pool
|
|
51
|
+
|
|
52
|
+
def pool
|
|
53
|
+
@pool ||= ConnectionPool.new
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def close_pool
|
|
57
|
+
@pool&.close
|
|
58
|
+
@pool = nil
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Fire-and-forget HTTP methods with automatic pooling.
|
|
62
|
+
%i[get post patch delete head put].each do |method|
|
|
63
|
+
define_method(method) do |hostname, port, path, headers: {}, body: nil, **options, &block|
|
|
64
|
+
request(hostname, port, method, path, headers: headers, body: body, **options, &block)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def request(hostname, port, method, path, headers: {}, body: nil, **options, &block)
|
|
69
|
+
client = pool.checkout(hostname, port, **options)
|
|
70
|
+
client.public_send(method, path, headers: headers, body: body, &block)
|
|
71
|
+
ensure
|
|
72
|
+
pool.checkin(client) if client
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Disconnect and close the underlying QUIC connection.
|
|
77
|
+
def disconnect
|
|
78
|
+
return unless @connected
|
|
79
|
+
|
|
80
|
+
@connected = false
|
|
81
|
+
|
|
82
|
+
@mutex.synchronize do
|
|
83
|
+
@pending_requests.each_value { |req| req.fail(0, "Connection closed") }
|
|
84
|
+
@pending_requests.clear
|
|
85
|
+
@response_buffers.clear
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
close_connection
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Instance-level HTTP methods. Auto-connects on first use.
|
|
92
|
+
#
|
|
93
|
+
# client = Quicsilver::Client.new("example.com", 4433)
|
|
94
|
+
# client.get("/users") # connects automatically
|
|
95
|
+
# client.post("/data", body: json)
|
|
96
|
+
#
|
|
97
|
+
%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
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def draining?
|
|
105
|
+
!@peer_goaway_id.nil?
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def receive_control_data(stream_id, data) # :nodoc:
|
|
109
|
+
buf = @uni_stream_types.key?(stream_id) ? data : identify_and_strip_stream_type(stream_id, data)
|
|
110
|
+
return if buf.nil? || buf.empty?
|
|
111
|
+
|
|
112
|
+
case @uni_stream_types[stream_id]
|
|
113
|
+
when :control
|
|
114
|
+
parse_control_frames(buf)
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def build_request(method, path, headers: {}, body: nil)
|
|
119
|
+
ensure_connected!
|
|
120
|
+
raise GoAwayError, "Connection is draining (GOAWAY received)" if draining?
|
|
121
|
+
|
|
122
|
+
stream = open_stream
|
|
123
|
+
raise StreamFailedToOpenError unless stream
|
|
124
|
+
|
|
125
|
+
request = Request.new(self, stream)
|
|
126
|
+
@mutex.synchronize { @pending_requests[stream.handle] = request }
|
|
127
|
+
|
|
128
|
+
send_to_stream(stream, method, path, headers, body)
|
|
129
|
+
|
|
130
|
+
request
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def connected?
|
|
134
|
+
@connected && @connection_data && connection_alive?
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def connection_info
|
|
138
|
+
info = @connection_data ? Quicsilver.connection_status(@connection_data[1]) : {}
|
|
139
|
+
info.merge(hostname: @hostname, port: @port, uptime: connection_uptime)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def connection_uptime
|
|
143
|
+
return 0 unless @connection_start_time
|
|
144
|
+
Time.now - @connection_start_time
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def authority
|
|
148
|
+
"#{@hostname}:#{@port}"
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# :nodoc:
|
|
152
|
+
def open_connection
|
|
153
|
+
return self if @connected
|
|
154
|
+
|
|
155
|
+
Quicsilver.open_connection
|
|
156
|
+
config = Quicsilver.create_configuration(@unsecure)
|
|
157
|
+
raise ConnectionError, "Failed to create configuration" if config.nil?
|
|
158
|
+
|
|
159
|
+
start_connection(config)
|
|
160
|
+
@connected = true
|
|
161
|
+
@connection_start_time = Time.now
|
|
162
|
+
send_control_stream
|
|
163
|
+
Quicsilver.event_loop.start
|
|
164
|
+
|
|
165
|
+
self
|
|
166
|
+
rescue => e
|
|
167
|
+
cleanup_failed_connection
|
|
168
|
+
raise e.is_a?(ConnectionError) || e.is_a?(TimeoutError) ? e : ConnectionError.new("Connection failed: #{e.message}")
|
|
169
|
+
ensure
|
|
170
|
+
Quicsilver.close_configuration(config) if config
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# :nodoc:
|
|
174
|
+
def close_connection
|
|
175
|
+
Quicsilver.close_connection_handle(@connection_data) if @connection_data
|
|
176
|
+
@connection_data = nil
|
|
177
|
+
@connected = false
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Called directly by C extension via dispatch_to_ruby
|
|
181
|
+
def handle_stream_event(stream_id, event, data, _early_data) # :nodoc:
|
|
182
|
+
return unless FINISHED_EVENTS.include?(event)
|
|
183
|
+
|
|
184
|
+
# Server unidirectional streams (control, QPACK) — process incrementally
|
|
185
|
+
if (stream_id & 0x02) != 0 && (event == "RECEIVE" || event == "RECEIVE_FIN")
|
|
186
|
+
begin
|
|
187
|
+
receive_control_data(stream_id, data)
|
|
188
|
+
rescue Protocol::FrameError => e
|
|
189
|
+
Quicsilver.logger.error("Control stream error: #{e.message} (0x#{e.error_code.to_s(16)})")
|
|
190
|
+
end
|
|
191
|
+
return
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
@mutex.synchronize do
|
|
195
|
+
case event
|
|
196
|
+
when "RECEIVE"
|
|
197
|
+
(@response_buffers[stream_id] ||= "".b) << data
|
|
198
|
+
|
|
199
|
+
when "RECEIVE_FIN"
|
|
200
|
+
event = Transport::StreamEvent.new(data, "RECEIVE_FIN")
|
|
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
|
+
|
|
218
|
+
when "STREAM_RESET"
|
|
219
|
+
event = Transport::StreamEvent.new(data, "STREAM_RESET")
|
|
220
|
+
request = @pending_requests.delete(event.handle)
|
|
221
|
+
request&.fail(event.error_code, "Stream reset by peer")
|
|
222
|
+
|
|
223
|
+
when "STOP_SENDING"
|
|
224
|
+
event = Transport::StreamEvent.new(data, "STOP_SENDING")
|
|
225
|
+
request = @pending_requests.delete(event.handle)
|
|
226
|
+
request&.fail(event.error_code, "Peer sent STOP_SENDING")
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
rescue => e
|
|
230
|
+
Quicsilver.logger.error("Error handling client stream: #{e.class} - #{e.message}")
|
|
231
|
+
Quicsilver.logger.debug(e.backtrace.first(5).join("\n"))
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
private
|
|
235
|
+
|
|
236
|
+
def ensure_connected!
|
|
237
|
+
return if @connected
|
|
238
|
+
open_connection
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def start_connection(config)
|
|
242
|
+
connection_handle, context_handle = create_connection
|
|
243
|
+
unless Quicsilver.start_connection(connection_handle, config, @hostname, @port)
|
|
244
|
+
cleanup_failed_connection
|
|
245
|
+
raise ConnectionError, "Failed to start connection"
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
result = Quicsilver.wait_for_connection(context_handle, @connection_timeout)
|
|
249
|
+
handle_connection_result(result)
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def create_connection
|
|
253
|
+
@connection_data = Quicsilver.create_connection(self)
|
|
254
|
+
raise ConnectionError, "Failed to create connection" if @connection_data.nil?
|
|
255
|
+
|
|
256
|
+
@connection_data
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
def cleanup_failed_connection
|
|
260
|
+
Quicsilver.close_connection_handle(@connection_data) if @connection_data
|
|
261
|
+
@connection_data = nil
|
|
262
|
+
@connected = false
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def open_stream
|
|
266
|
+
handle = Quicsilver.open_stream(@connection_data, false)
|
|
267
|
+
Transport::Stream.new(handle)
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def open_unidirectional_stream
|
|
271
|
+
handle = Quicsilver.open_stream(@connection_data, true)
|
|
272
|
+
Transport::Stream.new(handle)
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
def send_control_stream
|
|
276
|
+
@control_stream = open_unidirectional_stream
|
|
277
|
+
@control_stream.send(Protocol.build_control_stream)
|
|
278
|
+
|
|
279
|
+
[0x02, 0x03].each do |type|
|
|
280
|
+
stream = open_unidirectional_stream
|
|
281
|
+
stream.send([type].pack("C"))
|
|
282
|
+
end
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
def handle_connection_result(result)
|
|
286
|
+
if result.key?("error")
|
|
287
|
+
cleanup_failed_connection
|
|
288
|
+
raise ConnectionError, "Connection failed: status 0x#{result['status'].to_s(16)}, code: #{result['code']}"
|
|
289
|
+
elsif result.key?("timeout")
|
|
290
|
+
cleanup_failed_connection
|
|
291
|
+
raise TimeoutError, "Connection timed out after #{@connection_timeout}ms"
|
|
292
|
+
end
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
def connection_alive?
|
|
296
|
+
return false unless (info = Quicsilver.connection_status(@connection_data[1]))
|
|
297
|
+
info["connected"] && !info["failed"]
|
|
298
|
+
rescue
|
|
299
|
+
false
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
def send_to_stream(stream, method, path, headers, body)
|
|
303
|
+
encoded_response = Protocol::RequestEncoder.new(
|
|
304
|
+
method: method,
|
|
305
|
+
path: path,
|
|
306
|
+
scheme: "https",
|
|
307
|
+
authority: authority,
|
|
308
|
+
headers: headers,
|
|
309
|
+
body: body
|
|
310
|
+
).encode
|
|
311
|
+
|
|
312
|
+
result = stream.send(encoded_response, fin: true)
|
|
313
|
+
|
|
314
|
+
unless result
|
|
315
|
+
@mutex.synchronize { @pending_requests.delete(stream.handle) }
|
|
316
|
+
raise Error, "Failed to send request"
|
|
317
|
+
end
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
# Identify stream type from first byte(s), strip it, return remaining data.
|
|
321
|
+
# Returns nil for unknown/ignored stream types.
|
|
322
|
+
def identify_and_strip_stream_type(stream_id, data)
|
|
323
|
+
stream_type, type_len = Protocol.decode_varint(data.bytes, 0)
|
|
324
|
+
return nil if type_len == 0
|
|
325
|
+
|
|
326
|
+
case stream_type
|
|
327
|
+
when 0x00 # Control stream
|
|
328
|
+
raise Protocol::FrameError, "Duplicate control stream" if @control_stream_id
|
|
329
|
+
@control_stream_id = stream_id
|
|
330
|
+
@uni_stream_types[stream_id] = :control
|
|
331
|
+
when 0x02 # QPACK encoder stream
|
|
332
|
+
@uni_stream_types[stream_id] = :qpack_encoder
|
|
333
|
+
when 0x03 # QPACK decoder stream
|
|
334
|
+
@uni_stream_types[stream_id] = :qpack_decoder
|
|
335
|
+
else
|
|
336
|
+
# Unknown unidirectional stream types MUST be ignored (RFC 9114 §6.2)
|
|
337
|
+
@uni_stream_types[stream_id] = :unknown
|
|
338
|
+
return nil
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
data[type_len..] || "".b
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
def on_settings_received(settings)
|
|
345
|
+
@peer_settings.merge!(settings)
|
|
346
|
+
@peer_max_field_section_size = settings[0x06] if settings.key?(0x06)
|
|
347
|
+
end
|
|
348
|
+
end
|
|
349
|
+
end
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Quicsilver
|
|
4
|
+
class Client
|
|
5
|
+
# Thread-safe pool of connected Client instances, keyed by (host, port).
|
|
6
|
+
# Idle connections are reused automatically. Stale ones are evicted at checkout.
|
|
7
|
+
#
|
|
8
|
+
# Quicsilver::Client.get("example.com", 4433, "/users")
|
|
9
|
+
#
|
|
10
|
+
class ConnectionPool
|
|
11
|
+
attr_reader :max_size, :idle_timeout
|
|
12
|
+
|
|
13
|
+
DEFAULT_MAX_SIZE = 4
|
|
14
|
+
DEFAULT_IDLE_TIMEOUT = 60 # seconds
|
|
15
|
+
|
|
16
|
+
def initialize(max_size: DEFAULT_MAX_SIZE, idle_timeout: DEFAULT_IDLE_TIMEOUT)
|
|
17
|
+
@max_size = max_size
|
|
18
|
+
@idle_timeout = idle_timeout
|
|
19
|
+
@pools = {} # "host:port" => [{ client:, checked_out: }]
|
|
20
|
+
@mutex = Mutex.new
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Check out a connected Client. Reuses an idle one or creates a new one.
|
|
24
|
+
def checkout(hostname, port, **options)
|
|
25
|
+
key = "#{hostname}:#{port}"
|
|
26
|
+
|
|
27
|
+
@mutex.synchronize do
|
|
28
|
+
entries = @pools[key] ||= []
|
|
29
|
+
|
|
30
|
+
# Evict dead/stale/draining
|
|
31
|
+
entries.reject! do |e|
|
|
32
|
+
if !e[:checked_out] && (!e[:client].connected? || e[:client].draining? || e[:last_used] < Time.now - @idle_timeout)
|
|
33
|
+
e[:client].close_connection
|
|
34
|
+
true
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Reuse an idle client (skip draining ones)
|
|
39
|
+
idle = entries.find { |e| !e[:checked_out] && e[:client].connected? && !e[:client].draining? }
|
|
40
|
+
if idle
|
|
41
|
+
idle[:checked_out] = true
|
|
42
|
+
idle[:last_used] = Time.now
|
|
43
|
+
return idle[:client]
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
if entries.size >= @max_size
|
|
47
|
+
raise ConnectionError, "Connection pool full for #{key} (max: #{@max_size})"
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Create outside the lock (blocking I/O)
|
|
52
|
+
client = Client.new(hostname, port, **options)
|
|
53
|
+
client.open_connection
|
|
54
|
+
|
|
55
|
+
@mutex.synchronize do
|
|
56
|
+
(@pools[key] ||= []) << { client: client, checked_out: true, last_used: Time.now }
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
client
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Return a Client to the pool.
|
|
63
|
+
def checkin(client)
|
|
64
|
+
key = "#{client.hostname}:#{client.port}"
|
|
65
|
+
|
|
66
|
+
@mutex.synchronize do
|
|
67
|
+
entries = @pools[key]
|
|
68
|
+
return unless entries
|
|
69
|
+
|
|
70
|
+
entry = entries.find { |e| e[:client].equal?(client) }
|
|
71
|
+
return unless entry
|
|
72
|
+
|
|
73
|
+
if client.connected?
|
|
74
|
+
entry[:checked_out] = false
|
|
75
|
+
entry[:last_used] = Time.now
|
|
76
|
+
else
|
|
77
|
+
entries.delete(entry)
|
|
78
|
+
client.close_connection
|
|
79
|
+
@pools.delete(key) if entries.empty?
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Close all clients.
|
|
85
|
+
def close
|
|
86
|
+
@mutex.synchronize do
|
|
87
|
+
@pools.each_value do |entries|
|
|
88
|
+
entries.each { |e| e[:client].close_connection }
|
|
89
|
+
end
|
|
90
|
+
@pools.clear
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Total clients in the pool, optionally filtered by host:port.
|
|
95
|
+
def size(host = nil, port = nil)
|
|
96
|
+
@mutex.synchronize do
|
|
97
|
+
if host && port
|
|
98
|
+
(@pools["#{host}:#{port}"] || []).size
|
|
99
|
+
else
|
|
100
|
+
@pools.values.sum(&:size)
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Quicsilver
|
|
4
|
+
class Client
|
|
5
|
+
class Request
|
|
6
|
+
attr_reader :stream, :status
|
|
7
|
+
|
|
8
|
+
CancelledError = Class.new(StandardError)
|
|
9
|
+
|
|
10
|
+
class ResetError < StandardError
|
|
11
|
+
attr_reader :error_code
|
|
12
|
+
|
|
13
|
+
def initialize(message, error_code = nil)
|
|
14
|
+
super(message)
|
|
15
|
+
@error_code = error_code
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def initialize(client, stream)
|
|
20
|
+
@client = client
|
|
21
|
+
@stream = stream
|
|
22
|
+
@status = :pending
|
|
23
|
+
@queue = Queue.new
|
|
24
|
+
@mutex = Mutex.new
|
|
25
|
+
@response = nil
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Block until response arrives or timeout
|
|
29
|
+
# Returns response hash { status:, headers:, body: }
|
|
30
|
+
# Raises TimeoutError, CancelledError, or ResetError
|
|
31
|
+
def response(timeout: nil)
|
|
32
|
+
timeout ||= @client.request_timeout
|
|
33
|
+
return @response if @status == :completed
|
|
34
|
+
|
|
35
|
+
result = @queue.pop(timeout: timeout)
|
|
36
|
+
|
|
37
|
+
@mutex.synchronize do
|
|
38
|
+
case result
|
|
39
|
+
when nil
|
|
40
|
+
raise Quicsilver::TimeoutError, "Request timeout after #{timeout}s"
|
|
41
|
+
when Hash
|
|
42
|
+
if result[:error]
|
|
43
|
+
@status = :error
|
|
44
|
+
raise ResetError.new(result[:message] || "Stream reset by peer", result[:error_code])
|
|
45
|
+
else
|
|
46
|
+
@status = :completed
|
|
47
|
+
@response = result
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
@response
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Cancel the request (sends RESET_STREAM + STOP_SENDING to server)
|
|
56
|
+
# error_code defaults to H3_REQUEST_CANCELLED (0x10c)
|
|
57
|
+
def cancel(error_code: Protocol::H3_REQUEST_CANCELLED)
|
|
58
|
+
@mutex.synchronize do
|
|
59
|
+
return false unless @status == :pending
|
|
60
|
+
|
|
61
|
+
@stream.reset(error_code)
|
|
62
|
+
@stream.stop_sending(error_code)
|
|
63
|
+
@status = :cancelled
|
|
64
|
+
end
|
|
65
|
+
true
|
|
66
|
+
rescue => e
|
|
67
|
+
Quicsilver.logger.error("Failed to cancel request: #{e.message}")
|
|
68
|
+
false
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def pending?
|
|
72
|
+
@status == :pending
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def completed?
|
|
76
|
+
@status == :completed
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def cancelled?
|
|
80
|
+
@status == :cancelled
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Called by Client when response arrives
|
|
84
|
+
def complete(response) # :nodoc:
|
|
85
|
+
@queue.push(response)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Called by Client on stream reset from peer or connection close
|
|
89
|
+
def fail(error_code, message = nil) # :nodoc:
|
|
90
|
+
@mutex.synchronize do
|
|
91
|
+
return if @status == :cancelled
|
|
92
|
+
@status = :error
|
|
93
|
+
end
|
|
94
|
+
@queue.push({ error: true, error_code: error_code, message: message })
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
Binary file
|