quicsilver 0.1.0 → 0.2.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.
@@ -1,37 +1,50 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'http3/request_encoder'
4
+ require_relative 'http3/response_parser'
5
+ require_relative "event_loop"
6
+ require "timeout"
7
+
3
8
  module Quicsilver
4
9
  class Client
5
10
  attr_reader :hostname, :port, :unsecure, :connection_timeout
6
-
11
+
12
+ AlreadyConnectedError = Class.new(StandardError)
13
+ NotConnectedError = Class.new(StandardError)
14
+ StreamFailedToOpenError = Class.new(StandardError)
15
+
16
+ FINISHED_EVENTS = %w[RECEIVE_FIN RECEIVE].freeze
17
+
7
18
  def initialize(hostname, port = 4433, options = {})
8
19
  @hostname = hostname
9
20
  @port = port
10
- @unsecure = options[:unsecure] || true
11
- @connection_timeout = options[:connection_timeout] || 5000
12
-
21
+ @unsecure = options.fetch(:unsecure, true)
22
+ @connection_timeout = options.fetch(:connection_timeout, 5000)
23
+
13
24
  @connection_data = nil
14
25
  @connected = false
15
26
  @connection_start_time = nil
27
+
28
+ @response_buffers = {} # stream_id => accumulated data
29
+ @pending_requests = {}
30
+ @mutex = Mutex.new
16
31
  end
17
32
 
18
33
  def connect
19
- raise Error, "Already connected" if @connected
34
+ raise AlreadyConnectedError if @connected
20
35
 
21
- # Initialize MSQUIC if not already done
22
- result = Quicsilver.open_connection
36
+ Quicsilver.open_connection
23
37
 
24
- # Create configuration
25
38
  config = Quicsilver.create_configuration(@unsecure)
26
39
  raise ConnectionError, "Failed to create configuration" if config.nil?
27
-
40
+
28
41
  # Create connection (returns [handle, context])
29
- @connection_data = Quicsilver.create_connection
42
+ # Pass self so C extension can route callbacks to this instance
43
+ @connection_data = Quicsilver.create_connection(self)
30
44
  raise ConnectionError, "Failed to create connection" if @connection_data.nil?
31
45
 
32
- connection_handle = @connection_data[0]
33
- context_handle = @connection_data[1]
34
-
46
+ connection_handle, context_handle = @connection_data
47
+
35
48
  # Start the connection
36
49
  success = Quicsilver.start_connection(connection_handle, config, @hostname, @port)
37
50
  unless success
@@ -40,30 +53,17 @@ module Quicsilver
40
53
  raise ConnectionError, "Failed to start connection"
41
54
  end
42
55
 
43
- # Wait for connection to establish or fail
44
56
  result = Quicsilver.wait_for_connection(context_handle, @connection_timeout)
45
-
46
- if result.key?("error")
47
- error_status = result["status"]
48
- error_code = result["code"]
49
- Quicsilver.close_configuration(config)
50
- cleanup_failed_connection
51
- error_msg = "Connection failed with status: 0x#{error_status.to_s(16)}, code: #{error_code}"
52
- raise ConnectionError, error_msg
53
- elsif result.key?("timeout")
54
- Quicsilver.close_configuration(config)
55
- cleanup_failed_connection
56
- error_msg = "Connection timed out after #{@connection_timeout}ms"
57
- raise TimeoutError, error_msg
58
- end
57
+ handle_connection_result(result, config)
59
58
 
60
59
  @connected = true
61
60
  @connection_start_time = Time.now
62
-
61
+
63
62
  send_control_stream
64
-
65
- # Clean up config since connection is established
66
- Quicsilver.close_configuration(config)
63
+ Quicsilver.close_configuration(config) # Clean up config since connection is established
64
+
65
+ Quicsilver.event_loop.start
66
+ self
67
67
  rescue => e
68
68
  cleanup_failed_connection
69
69
 
@@ -75,68 +75,86 @@ module Quicsilver
75
75
  end
76
76
 
77
77
  def disconnect
78
- return unless @connected || @connection_data
79
-
80
- begin
81
- if @connection_data
82
- Quicsilver.close_connection_handle(@connection_data)
83
- @connection_data = nil
84
- end
85
- rescue
86
- # Ignore disconnect errors
87
- ensure
88
- @connected = false
89
- @connection_start_time = nil
78
+ return unless @connection_data
79
+
80
+ @connected = false
81
+
82
+ # Wake up pending requests
83
+ @mutex.synchronize do
84
+ @pending_requests.each_value { |q| q.push(nil) }
85
+ @pending_requests.clear
86
+ @response_buffers.clear
90
87
  end
88
+
89
+ Quicsilver.close_connection_handle(@connection_data) if @connection_data
90
+ @connection_data = nil
91
91
  end
92
-
93
- def connected?
94
- return false unless @connected && @connection_data
95
-
96
- # Get connection status from the C extension
97
- context_handle = @connection_data[1]
98
- info = Quicsilver.connection_status(context_handle)
99
- if info && info.key?("connected")
100
- is_connected = info["connected"] && !info["failed"]
101
-
102
- # If C extension says we're disconnected, update our state
103
- if !is_connected && @connected
104
- @connected = false
105
- end
106
-
107
- is_connected
108
- else
109
- # If we can't get status, assume disconnected
110
- if @connected
111
- @connected = false
112
- end
113
- false
92
+
93
+ def get(path, **opts)
94
+ request("GET", path, **opts)
95
+ end
96
+
97
+ def post(path, **opts)
98
+ request("POST", path, **opts)
99
+ end
100
+
101
+ def patch(path, **opts)
102
+ request("PATCH", path, **opts)
103
+ end
104
+
105
+ def delete(path, **opts)
106
+ request("DELETE", path, **opts)
107
+ end
108
+
109
+ def head(path, **opts)
110
+ request("HEAD", path, **opts)
111
+ end
112
+
113
+ def request(method, path, headers: {}, body: nil, timeout: 5000)
114
+ raise NotConnectedError unless @connected
115
+ response_queue = Queue.new
116
+
117
+ request = HTTP3::RequestEncoder.new(
118
+ method: method,
119
+ path: path,
120
+ scheme: "https",
121
+ authority: authority,
122
+ headers: headers,
123
+ body: body
124
+ )
125
+
126
+ stream = open_stream
127
+ raise StreamFailedToOpenError unless stream
128
+
129
+ @mutex.synchronize do
130
+ @pending_requests[stream] = response_queue
114
131
  end
115
- rescue
116
- # If there's an error checking status, assume disconnected
117
- if @connected
118
- @connected = false
132
+
133
+ # Send data with FIN flag
134
+ result = Quicsilver.send_stream(stream, request.encode, true)
135
+
136
+ unless result
137
+ @mutex.synchronize { @pending_requests.delete(stream) }
138
+ raise Error, "Failed to send request"
119
139
  end
120
- false
140
+
141
+ response = response_queue.pop(timeout: timeout / 1000.0)
142
+
143
+ raise ConnectionError, "Connection closed" if response.nil? && !@connected
144
+ raise TimeoutError, "Request timeout after #{timeout}ms" if response.nil?
145
+
146
+ response
147
+ rescue Timeout::Error
148
+ @mutex.synchronize { @pending_requests.delete(stream) } if stream
149
+ end
150
+
151
+ def connected?
152
+ @connected && @connection_data && connection_alive?
121
153
  end
122
154
 
123
155
  def connection_info
124
- base_info = if @connection_data
125
- begin
126
- context_handle = @connection_data[1]
127
- Quicsilver.connection_status(context_handle) || {}
128
- rescue
129
- {}
130
- end
131
- else
132
- {}
133
- end
134
-
135
- base_info.merge({
136
- hostname: @hostname,
137
- port: @port,
138
- uptime: connection_uptime
139
- })
156
+ info = @connection_data ? Quicsilver.connection_status(@connection_data[1]) : {}
157
+ info.merge(hostname: @hostname, port: @port, uptime: connection_uptime)
140
158
  end
141
159
 
142
160
  def connection_uptime
@@ -144,21 +162,50 @@ module Quicsilver
144
162
  Time.now - @connection_start_time
145
163
  end
146
164
 
147
- def send_data(data)
148
- raise Error, "Not connected" unless @connected
165
+ def authority
166
+ "#{@hostname}:#{@port}"
167
+ end
149
168
 
150
- stream = open_stream
151
- unless stream
152
- puts "❌ Failed to open stream"
153
- return false
154
- end
169
+ # Called directly by C extension via process_events
170
+ # C extension routes to this instance based on client_obj stored in connection context
171
+ # Clients should never call this method directly.
172
+ def handle_stream_event(stream_id, event, data)
173
+ return unless FINISHED_EVENTS.include?(event)
174
+
175
+ @mutex.synchronize do
176
+ case event
177
+ when "RECEIVE"
178
+ @response_buffers[stream_id] ||= StringIO.new
179
+ @response_buffers[stream_id].write(data) # Buffer incoming response data
180
+ when "RECEIVE_FIN"
181
+ stream_handle = data[0, 8].unpack1('Q') if data.bytesize >= 8
182
+ actual_data = data[8..-1] || ""
183
+
184
+ # Get all buffered data
185
+ buffer = @response_buffers.delete(stream_id)
186
+ full_data = ( buffer&.string || "") + actual_data
155
187
 
156
- result = Quicsilver.send_stream(stream, data, true)
157
- puts "✅ Sent #{data.bytesize} bytes"
158
- result
188
+ # TODO: needed for streaming later
189
+ @stream_handles ||= {}
190
+ @stream_handles[stream_id] = stream_handle if stream_handle
191
+
192
+ response_parser = Quicsilver::HTTP3::ResponseParser.new(full_data)
193
+ response_parser.parse
194
+
195
+ # Store complete response with body as string
196
+ response = {
197
+ status: response_parser.status,
198
+ headers: response_parser.headers,
199
+ body: response_parser.body.read
200
+ }
201
+
202
+ queue = @pending_requests.delete(stream_handle)
203
+ queue&.push(response) # Unblocks request
204
+ end
205
+ end
159
206
  rescue => e
160
- puts " Send data error: #{e.class} - #{e.message}"
161
- false
207
+ Quicsilver.logger.error("Error handling client stream: #{e.class} - #{e.message}")
208
+ Quicsilver.logger.debug(e.backtrace.first(5).join("\n"))
162
209
  end
163
210
 
164
211
  private
@@ -170,11 +217,11 @@ module Quicsilver
170
217
  end
171
218
 
172
219
  def open_stream
173
- Quicsilver.open_stream(@connection_data[0], false)
220
+ Quicsilver.open_stream(@connection_data, false)
174
221
  end
175
222
 
176
223
  def open_unidirectional_stream
177
- Quicsilver.open_stream(@connection_data[0], true)
224
+ Quicsilver.open_stream(@connection_data, true)
178
225
  end
179
226
 
180
227
  def send_control_stream
@@ -187,5 +234,28 @@ module Quicsilver
187
234
 
188
235
  @control_stream = stream
189
236
  end
237
+
238
+ def handle_connection_result(result, config)
239
+ if result.key?("error")
240
+ error_status = result["status"]
241
+ error_code = result["code"]
242
+ Quicsilver.close_configuration(config)
243
+ cleanup_failed_connection
244
+ error_msg = "Connection failed with status: 0x#{error_status.to_s(16)}, code: #{error_code}"
245
+ raise ConnectionError, error_msg
246
+ elsif result.key?("timeout")
247
+ Quicsilver.close_configuration(config)
248
+ cleanup_failed_connection
249
+ error_msg = "Connection timed out after #{@connection_timeout}ms"
250
+ raise TimeoutError, error_msg
251
+ end
252
+ end
253
+
254
+ def connection_alive?
255
+ return false unless (info = Quicsilver.connection_status(@connection_data[1]))
256
+ info["connected"] && !info["failed"]
257
+ rescue
258
+ false
259
+ end
190
260
  end
191
261
  end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Quicsilver
4
+ class Connection
5
+ attr_reader :handle, :data, :control_stream_id, :qpack_encoder_stream_id, :qpack_decoder_stream_id
6
+ attr_reader :streams
7
+ attr_accessor :server_control_stream # Handle for server's outbound control stream (used to send GOAWAY)
8
+
9
+ def initialize(handle, data)
10
+ @handle = handle
11
+ @data = data
12
+ @streams = {}
13
+ @control_stream_id = nil
14
+ @qpack_encoder_stream_id = nil
15
+ @qpack_decoder_stream_id = nil
16
+ end
17
+
18
+ def set_qpack_encoder_stream(stream_id)
19
+ @qpack_encoder_stream_id = stream_id
20
+ end
21
+
22
+ def set_qpack_decoder_stream(stream_id)
23
+ @qpack_decoder_stream_id = stream_id
24
+ end
25
+
26
+ def set_control_stream(stream_id)
27
+ @control_stream_id = stream_id
28
+ end
29
+
30
+ def add_stream(stream)
31
+ @streams[stream.stream_id] = stream
32
+ end
33
+
34
+ def get_stream(stream_id)
35
+ @streams[stream_id]
36
+ end
37
+
38
+ def remove_stream(stream_id)
39
+ @streams.delete(stream_id)
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Quicsilver
4
+ class EventLoop
5
+ def initialize
6
+ @running = false
7
+ @thread = nil
8
+ @mutex = Mutex.new
9
+ end
10
+
11
+ def start
12
+ @mutex.synchronize do
13
+ return if @running
14
+
15
+ @running = true
16
+ @thread = Thread.new do
17
+ while @running
18
+ processed = Quicsilver.process_events
19
+ processed == 0 ? sleep(0.001) : Thread.pass
20
+ end
21
+ end
22
+ end
23
+ end
24
+
25
+ def stop
26
+ @running = false
27
+ @thread&.join(2)
28
+ end
29
+
30
+ def join
31
+ @thread&.join
32
+ end
33
+ end
34
+
35
+ def self.event_loop
36
+ @event_loop ||= EventLoop.new.tap(&:start)
37
+ end
38
+ end
@@ -17,12 +17,12 @@ module Quicsilver
17
17
 
18
18
  # Build HEADERS frame
19
19
  headers_payload = encode_headers
20
- frames << build_frame(0x01, headers_payload) # 0x01 = HEADERS frame
20
+ frames << build_frame(HTTP3::FRAME_HEADERS, headers_payload)
21
21
 
22
22
  # Build DATA frame if body present
23
23
  if @body && !@body.empty?
24
24
  body_data = @body.is_a?(String) ? @body : @body.join
25
- frames << build_frame(0x00, body_data) # 0x00 = DATA frame
25
+ frames << build_frame(HTTP3::FRAME_DATA, body_data)
26
26
  end
27
27
 
28
28
  frames.join.force_encoding(Encoding::BINARY)
@@ -69,30 +69,51 @@ module Quicsilver
69
69
  # Literal field line with literal name for pseudo-headers
70
70
  # Pattern: 0x50 (indexed name from static table) + value
71
71
  def encode_literal_pseudo_header(name, value)
72
- # For pseudo-headers, use indexed name reference from static table
73
- # with literal value (pattern: 0101xxxx where xxxx = static table index)
74
- static_index = case name
75
- when ':authority' then 0
76
- when ':path' then 1
77
- when ':method' then (@method == 'GET' ? 17 : 20) # GET=17, POST=20
78
- when ':scheme' then (@scheme == 'http' ? 22 : 23)
79
- else nil
80
- end
72
+ result = "".b
81
73
 
82
- if static_index
83
- # Use indexed field line (0x40 | index)
84
- result = "".b
85
- result += [0x40 | static_index].pack('C')
86
- # For non-exact matches, append literal value
87
- if name == ':authority' || name == ':path'
74
+ case name
75
+ when ':method'
76
+ # Check if exact match in static table
77
+ index = case @method
78
+ when 'GET' then HTTP3::QPACK_METHOD_GET
79
+ when 'POST' then HTTP3::QPACK_METHOD_POST
80
+ when 'PUT' then HTTP3::QPACK_METHOD_PUT
81
+ when 'DELETE' then HTTP3::QPACK_METHOD_DELETE
82
+ when 'CONNECT' then HTTP3::QPACK_METHOD_CONNECT
83
+ when 'HEAD' then HTTP3::QPACK_METHOD_HEAD
84
+ when 'OPTIONS' then HTTP3::QPACK_METHOD_OPTIONS
85
+ else nil
86
+ end
87
+
88
+ if index
89
+ # Exact match - use indexed field line (0x80 | index)
90
+ result += [0x80 | index].pack('C')
91
+ else
92
+ # No exact match - use literal with name reference
93
+ result += [0x40 | HTTP3::QPACK_METHOD_GET].pack('C') # Use any :method index for name
88
94
  result += HTTP3.encode_varint(value.bytesize)
89
- result += value.to_s.b
95
+ result += value.b
90
96
  end
91
- result
97
+
98
+ when ':scheme'
99
+ # Check if exact match
100
+ index = (@scheme == 'https' ? HTTP3::QPACK_SCHEME_HTTPS : HTTP3::QPACK_SCHEME_HTTP)
101
+ # Exact match - use indexed field line
102
+ result += [0x80 | index].pack('C')
103
+
104
+ when ':authority', ':path'
105
+ # Name in static table, but value is custom - use literal with name reference
106
+ index = (name == ':authority' ? HTTP3::QPACK_AUTHORITY : HTTP3::QPACK_PATH)
107
+ result += [0x40 | index].pack('C')
108
+ result += HTTP3.encode_varint(value.bytesize)
109
+ result += value.b
110
+
92
111
  else
93
112
  # Fallback to literal name
94
- encode_literal_header(name, value)
113
+ return encode_literal_header(name, value)
95
114
  end
115
+
116
+ result
96
117
  end
97
118
 
98
119
  # Literal field line with literal name
@@ -12,6 +12,7 @@ module Quicsilver
12
12
  @frames = []
13
13
  @headers = {}
14
14
  @body = StringIO.new
15
+ @body.set_encoding(Encoding::ASCII_8BIT)
15
16
  end
16
17
 
17
18
  def parse
@@ -49,10 +50,21 @@ module Quicsilver
49
50
  'CONTENT_LENGTH' => @body.size.to_s,
50
51
  }
51
52
 
52
- # Add regular headers as HTTP_*
53
+ # Add HTTP_HOST from :authority pseudo-header
54
+ if @headers[':authority']
55
+ env['HTTP_HOST'] = @headers[':authority']
56
+ end
57
+
53
58
  @headers.each do |name, value|
54
59
  next if name.start_with?(':')
55
- env["HTTP_#{name.upcase.tr('-', '_')}"] = value
60
+ key = name.upcase.tr('-', '_')
61
+ if key == 'CONTENT_TYPE'
62
+ env['CONTENT_TYPE'] = value
63
+ elsif key == 'CONTENT_LENGTH'
64
+ env['CONTENT_LENGTH'] = value
65
+ else
66
+ env["HTTP_#{key}"] = value
67
+ end
56
68
  end
57
69
 
58
70
  env
@@ -97,26 +109,35 @@ module Quicsilver
97
109
  while offset < payload.bytesize
98
110
  byte = payload.bytes[offset]
99
111
 
100
- # Indexed field line (static table, starts with 0x4X or 0x5X)
101
- if (byte & 0xC0) == 0x40 || (byte & 0xC0) == 0x80
112
+ # Pattern 1: Indexed Field Line (1Txxxxxx)
113
+ # Use both name AND value from static table
114
+ if (byte & 0x80) == 0x80
102
115
  index = byte & 0x3F
103
116
  offset += 1
104
117
 
105
- # Check if this is a complete field (name+value) or just name
106
118
  field = decode_static_table_field(index)
107
-
108
119
  if field.is_a?(Hash)
109
- # Complete field with both name and value (e.g., :method GET)
110
120
  @headers.merge!(field)
111
- elsif field
112
- # Name-only entry, value follows
121
+ end
122
+ # Pattern 3: Literal with Name Reference (01NTxxxx)
123
+ # Use name from static table, but value is provided as literal
124
+ elsif (byte & 0xC0) == 0x40
125
+ index = byte & 0x3F
126
+ offset += 1
127
+
128
+ # Get the name from static table
129
+ entry = HTTP3::STATIC_TABLE[index] if index < HTTP3::STATIC_TABLE.size
130
+ name = entry ? entry[0] : nil
131
+
132
+ if name
133
+ # Read literal value that follows
113
134
  value_len, len_bytes = HTTP3.decode_varint(payload.bytes, offset)
114
135
  offset += len_bytes
115
136
  value = payload[offset, value_len]
116
137
  offset += value_len
117
- @headers[field] = value
138
+ @headers[name] = value
118
139
  end
119
- # Literal with literal name (starts with 0x2X)
140
+ # Pattern 5: Literal with literal name (001NHxxx)
120
141
  elsif (byte & 0xE0) == 0x20
121
142
  name_len = byte & 0x1F
122
143
  offset += 1
@@ -138,19 +159,16 @@ module Quicsilver
138
159
  # QPACK static table decoder (RFC 9204 Appendix A)
139
160
  # Returns Hash for complete fields, String for name-only fields
140
161
  def decode_static_table_field(index)
141
- case index
142
- when 0 then ':authority' # Name only
143
- when 1 then ':path' # Name only
144
- when 15 then {':method' => 'CONNECT'}
145
- when 16 then {':method' => 'DELETE'}
146
- when 17 then {':method' => 'GET'}
147
- when 18 then {':method' => 'HEAD'}
148
- when 19 then {':method' => 'OPTIONS'}
149
- when 20 then {':method' => 'POST'}
150
- when 21 then {':method' => 'PUT'}
151
- when 22 then {':scheme' => 'http'}
152
- when 23 then {':scheme' => 'https'}
153
- else nil
162
+ return nil if index >= HTTP3::STATIC_TABLE.size
163
+
164
+ name, value = HTTP3::STATIC_TABLE[index]
165
+
166
+ # If value is empty, return just the name (caller provides value)
167
+ # Otherwise return complete field as hash
168
+ if value.empty?
169
+ name
170
+ else
171
+ {name => value}
154
172
  end
155
173
  end
156
174
  end