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.
Files changed (81) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +4 -5
  3. data/.github/workflows/cibuildgem.yaml +93 -0
  4. data/.gitignore +3 -1
  5. data/CHANGELOG.md +81 -0
  6. data/Gemfile.lock +26 -4
  7. data/README.md +95 -31
  8. data/Rakefile +95 -3
  9. data/benchmarks/components.rb +191 -0
  10. data/benchmarks/concurrent.rb +110 -0
  11. data/benchmarks/helpers.rb +88 -0
  12. data/benchmarks/quicsilver_server.rb +1 -1
  13. data/benchmarks/rails.rb +170 -0
  14. data/benchmarks/throughput.rb +113 -0
  15. data/examples/README.md +44 -91
  16. data/examples/benchmark.rb +111 -0
  17. data/examples/connection_pool_demo.rb +47 -0
  18. data/examples/example_helper.rb +18 -0
  19. data/examples/falcon_middleware.rb +44 -0
  20. data/examples/feature_demo.rb +125 -0
  21. data/examples/grpc_style.rb +97 -0
  22. data/examples/minimal_http3_server.rb +6 -18
  23. data/examples/priorities.rb +60 -0
  24. data/examples/protocol_http_server.rb +31 -0
  25. data/examples/rack_http3_server.rb +8 -20
  26. data/examples/rails_feature_test.rb +260 -0
  27. data/examples/simple_client_test.rb +2 -2
  28. data/examples/streaming_sse.rb +33 -0
  29. data/examples/trailers.rb +69 -0
  30. data/ext/quicsilver/extconf.rb +14 -0
  31. data/ext/quicsilver/quicsilver.c +568 -181
  32. data/lib/quicsilver/client/client.rb +349 -0
  33. data/lib/quicsilver/client/connection_pool.rb +106 -0
  34. data/lib/quicsilver/client/request.rb +98 -0
  35. data/lib/quicsilver/libmsquic.2.dylib +0 -0
  36. data/lib/quicsilver/protocol/adapter.rb +176 -0
  37. data/lib/quicsilver/protocol/control_stream_parser.rb +106 -0
  38. data/lib/quicsilver/protocol/frame_parser.rb +142 -0
  39. data/lib/quicsilver/protocol/frame_reader.rb +55 -0
  40. data/lib/quicsilver/{http3.rb → protocol/frames.rb} +146 -30
  41. data/lib/quicsilver/protocol/priority.rb +56 -0
  42. data/lib/quicsilver/protocol/qpack/decoder.rb +165 -0
  43. data/lib/quicsilver/protocol/qpack/encoder.rb +227 -0
  44. data/lib/quicsilver/protocol/qpack/header_block_decoder.rb +140 -0
  45. data/lib/quicsilver/protocol/qpack/huffman.rb +459 -0
  46. data/lib/quicsilver/protocol/request_encoder.rb +47 -0
  47. data/lib/quicsilver/protocol/request_parser.rb +275 -0
  48. data/lib/quicsilver/protocol/response_encoder.rb +97 -0
  49. data/lib/quicsilver/protocol/response_parser.rb +141 -0
  50. data/lib/quicsilver/protocol/stream_input.rb +98 -0
  51. data/lib/quicsilver/protocol/stream_output.rb +59 -0
  52. data/lib/quicsilver/quicsilver.bundle +0 -0
  53. data/lib/quicsilver/server/listener_data.rb +14 -0
  54. data/lib/quicsilver/server/request_handler.rb +138 -0
  55. data/lib/quicsilver/server/request_registry.rb +50 -0
  56. data/lib/quicsilver/server/server.rb +610 -0
  57. data/lib/quicsilver/transport/configuration.rb +141 -0
  58. data/lib/quicsilver/transport/connection.rb +379 -0
  59. data/lib/quicsilver/transport/event_loop.rb +38 -0
  60. data/lib/quicsilver/transport/inbound_stream.rb +33 -0
  61. data/lib/quicsilver/transport/stream.rb +28 -0
  62. data/lib/quicsilver/transport/stream_event.rb +26 -0
  63. data/lib/quicsilver/version.rb +1 -1
  64. data/lib/quicsilver.rb +55 -14
  65. data/lib/rackup/handler/quicsilver.rb +1 -2
  66. data/quicsilver.gemspec +13 -3
  67. metadata +125 -21
  68. data/benchmarks/benchmark.rb +0 -68
  69. data/examples/setup_certs.sh +0 -57
  70. data/lib/quicsilver/client.rb +0 -261
  71. data/lib/quicsilver/connection.rb +0 -42
  72. data/lib/quicsilver/event_loop.rb +0 -38
  73. data/lib/quicsilver/http3/request_encoder.rb +0 -133
  74. data/lib/quicsilver/http3/request_parser.rb +0 -176
  75. data/lib/quicsilver/http3/response_encoder.rb +0 -186
  76. data/lib/quicsilver/http3/response_parser.rb +0 -160
  77. data/lib/quicsilver/listener_data.rb +0 -29
  78. data/lib/quicsilver/quic_stream.rb +0 -36
  79. data/lib/quicsilver/request_registry.rb +0 -48
  80. data/lib/quicsilver/server.rb +0 -355
  81. 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