quicsilver 0.2.0 → 0.3.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 (52) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +3 -4
  3. data/CHANGELOG.md +49 -0
  4. data/Gemfile.lock +8 -4
  5. data/README.md +7 -6
  6. data/Rakefile +29 -2
  7. data/benchmarks/components.rb +191 -0
  8. data/benchmarks/concurrent.rb +110 -0
  9. data/benchmarks/helpers.rb +88 -0
  10. data/benchmarks/quicsilver_server.rb +1 -1
  11. data/benchmarks/rails.rb +170 -0
  12. data/benchmarks/throughput.rb +113 -0
  13. data/ext/quicsilver/quicsilver.c +529 -181
  14. data/lib/quicsilver/client/client.rb +250 -0
  15. data/lib/quicsilver/client/request.rb +98 -0
  16. data/lib/quicsilver/{http3.rb → protocol/frames.rb} +133 -28
  17. data/lib/quicsilver/protocol/qpack/decoder.rb +165 -0
  18. data/lib/quicsilver/protocol/qpack/encoder.rb +189 -0
  19. data/lib/quicsilver/protocol/qpack/header_block_decoder.rb +125 -0
  20. data/lib/quicsilver/protocol/qpack/huffman.rb +459 -0
  21. data/lib/quicsilver/protocol/request_encoder.rb +47 -0
  22. data/lib/quicsilver/protocol/request_parser.rb +387 -0
  23. data/lib/quicsilver/protocol/response_encoder.rb +72 -0
  24. data/lib/quicsilver/protocol/response_parser.rb +249 -0
  25. data/lib/quicsilver/server/listener_data.rb +14 -0
  26. data/lib/quicsilver/server/request_handler.rb +86 -0
  27. data/lib/quicsilver/server/request_registry.rb +50 -0
  28. data/lib/quicsilver/server/server.rb +336 -0
  29. data/lib/quicsilver/transport/configuration.rb +132 -0
  30. data/lib/quicsilver/transport/connection.rb +350 -0
  31. data/lib/quicsilver/transport/event_loop.rb +38 -0
  32. data/lib/quicsilver/transport/inbound_stream.rb +33 -0
  33. data/lib/quicsilver/transport/stream.rb +28 -0
  34. data/lib/quicsilver/transport/stream_event.rb +26 -0
  35. data/lib/quicsilver/version.rb +1 -1
  36. data/lib/quicsilver.rb +31 -13
  37. data/lib/rackup/handler/quicsilver.rb +1 -2
  38. data/quicsilver.gemspec +3 -1
  39. metadata +58 -18
  40. data/benchmarks/benchmark.rb +0 -68
  41. data/lib/quicsilver/client.rb +0 -261
  42. data/lib/quicsilver/connection.rb +0 -42
  43. data/lib/quicsilver/event_loop.rb +0 -38
  44. data/lib/quicsilver/http3/request_encoder.rb +0 -133
  45. data/lib/quicsilver/http3/request_parser.rb +0 -176
  46. data/lib/quicsilver/http3/response_encoder.rb +0 -186
  47. data/lib/quicsilver/http3/response_parser.rb +0 -160
  48. data/lib/quicsilver/listener_data.rb +0 -29
  49. data/lib/quicsilver/quic_stream.rb +0 -36
  50. data/lib/quicsilver/request_registry.rb +0 -48
  51. data/lib/quicsilver/server.rb +0 -355
  52. data/lib/quicsilver/server_configuration.rb +0 -78
@@ -0,0 +1,250 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "timeout"
4
+
5
+ module Quicsilver
6
+ class Client
7
+ attr_reader :hostname, :port, :unsecure, :connection_timeout, :request_timeout
8
+
9
+ AlreadyConnectedError = Class.new(StandardError)
10
+ NotConnectedError = Class.new(StandardError)
11
+ StreamFailedToOpenError = 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
+ end
35
+
36
+ def connect
37
+ raise AlreadyConnectedError if @connected
38
+
39
+ Quicsilver.open_connection
40
+ config = Quicsilver.create_configuration(@unsecure)
41
+ raise ConnectionError, "Failed to create configuration" if config.nil?
42
+
43
+ start_connection(config)
44
+ @connected = true
45
+ @connection_start_time = Time.now
46
+ send_control_stream
47
+ Quicsilver.event_loop.start
48
+
49
+ self
50
+ rescue => e
51
+ cleanup_failed_connection
52
+ raise e.is_a?(ConnectionError) || e.is_a?(TimeoutError) ? e : ConnectionError.new("Connection failed: #{e.message}")
53
+ ensure
54
+ Quicsilver.close_configuration(config)
55
+ end
56
+
57
+ def disconnect
58
+ return unless @connection_data
59
+
60
+ @connected = false
61
+
62
+ # Fail pending requests
63
+ @mutex.synchronize do
64
+ @pending_requests.each_value { |req| req.fail(0, "Connection closed") }
65
+ @pending_requests.clear
66
+ @response_buffers.clear
67
+ end
68
+
69
+ Quicsilver.close_connection_handle(@connection_data) if @connection_data
70
+ @connection_data = nil
71
+ end
72
+
73
+ # HTTP methods - block gives you request control, no block returns response directly
74
+ #
75
+ # # Simple (blocking)
76
+ # response = client.get("/users")
77
+ #
78
+ # # With control
79
+ # client.get("/users") do |req|
80
+ # req.cancel if should_abort?
81
+ # req.response
82
+ # end
83
+ #
84
+ %i[get post patch delete head put].each do |method|
85
+ define_method(method) do |path, headers: {}, body: nil, &block|
86
+ req = build_request(method.to_s.upcase, path, headers: headers, body: body)
87
+ block ? block.call(req) : req.response
88
+ end
89
+ end
90
+
91
+ # Build and send request, returns Request object for lifecycle control
92
+ def build_request(method, path, headers: {}, body: nil)
93
+ raise NotConnectedError unless @connected
94
+
95
+ stream = open_stream
96
+ raise StreamFailedToOpenError unless stream
97
+
98
+ request = Request.new(self, stream)
99
+ @mutex.synchronize { @pending_requests[stream.handle] = request }
100
+
101
+ send_to_stream(stream, method, path, headers, body)
102
+
103
+ request
104
+ end
105
+
106
+ def connected?
107
+ @connected && @connection_data && connection_alive?
108
+ end
109
+
110
+ def connection_info
111
+ info = @connection_data ? Quicsilver.connection_status(@connection_data[1]) : {}
112
+ info.merge(hostname: @hostname, port: @port, uptime: connection_uptime)
113
+ end
114
+
115
+ def connection_uptime
116
+ return 0 unless @connection_start_time
117
+ Time.now - @connection_start_time
118
+ end
119
+
120
+ def authority
121
+ "#{@hostname}:#{@port}"
122
+ end
123
+
124
+ # Called directly by C extension via dispatch_to_ruby
125
+ def handle_stream_event(stream_id, event, data, _early_data) # :nodoc:
126
+ return unless FINISHED_EVENTS.include?(event)
127
+
128
+ @mutex.synchronize do
129
+ case event
130
+ when "RECEIVE"
131
+ (@response_buffers[stream_id] ||= StringIO.new("".b)).write(data)
132
+
133
+ when "RECEIVE_FIN"
134
+ event = Transport::StreamEvent.new(data, "RECEIVE_FIN")
135
+
136
+ buffer = @response_buffers.delete(stream_id)
137
+ full_data = (buffer&.string || "".b) + event.data
138
+
139
+ response_parser = Protocol::ResponseParser.new(full_data, max_body_size: @max_body_size,
140
+ max_header_size: @max_header_size)
141
+ response_parser.parse
142
+
143
+ response = {
144
+ status: response_parser.status,
145
+ headers: response_parser.headers,
146
+ body: response_parser.body.read
147
+ }
148
+
149
+ request = @pending_requests.delete(event.handle)
150
+ request&.complete(response)
151
+
152
+ when "STREAM_RESET"
153
+ event = Transport::StreamEvent.new(data, "STREAM_RESET")
154
+ request = @pending_requests.delete(event.handle)
155
+ request&.fail(event.error_code, "Stream reset by peer")
156
+
157
+ when "STOP_SENDING"
158
+ event = Transport::StreamEvent.new(data, "STOP_SENDING")
159
+ request = @pending_requests.delete(event.handle)
160
+ request&.fail(event.error_code, "Peer sent STOP_SENDING")
161
+ end
162
+ end
163
+ rescue => e
164
+ Quicsilver.logger.error("Error handling client stream: #{e.class} - #{e.message}")
165
+ Quicsilver.logger.debug(e.backtrace.first(5).join("\n"))
166
+ end
167
+
168
+ private
169
+
170
+ def start_connection(config)
171
+ connection_handle, context_handle = create_connection
172
+ unless Quicsilver.start_connection(connection_handle, config, @hostname, @port)
173
+ cleanup_failed_connection
174
+ raise ConnectionError, "Failed to start connection"
175
+ end
176
+
177
+ result = Quicsilver.wait_for_connection(context_handle, @connection_timeout)
178
+ handle_connection_result(result)
179
+ end
180
+
181
+ def create_connection
182
+ @connection_data = Quicsilver.create_connection(self)
183
+ raise ConnectionError, "Failed to create connection" if @connection_data.nil?
184
+
185
+ @connection_data
186
+ end
187
+
188
+ def cleanup_failed_connection
189
+ Quicsilver.close_connection_handle(@connection_data) if @connection_data
190
+ @connection_data = nil
191
+ @connected = false
192
+ end
193
+
194
+ def open_stream
195
+ handle = Quicsilver.open_stream(@connection_data, false)
196
+ Transport::Stream.new(handle)
197
+ end
198
+
199
+ def open_unidirectional_stream
200
+ handle = Quicsilver.open_stream(@connection_data, true)
201
+ Transport::Stream.new(handle)
202
+ end
203
+
204
+ def send_control_stream
205
+ @control_stream = open_unidirectional_stream
206
+ @control_stream.send(Protocol.build_control_stream)
207
+
208
+ # RFC 9204: QPACK encoder (0x02) and decoder (0x03) streams
209
+ [0x02, 0x03].each do |type|
210
+ stream = open_unidirectional_stream
211
+ stream.send([type].pack("C"))
212
+ end
213
+ end
214
+
215
+ def handle_connection_result(result)
216
+ if result.key?("error")
217
+ cleanup_failed_connection
218
+ raise ConnectionError, "Connection failed: status 0x#{result['status'].to_s(16)}, code: #{result['code']}"
219
+ elsif result.key?("timeout")
220
+ cleanup_failed_connection
221
+ raise TimeoutError, "Connection timed out after #{@connection_timeout}ms"
222
+ end
223
+ end
224
+
225
+ def connection_alive?
226
+ return false unless (info = Quicsilver.connection_status(@connection_data[1]))
227
+ info["connected"] && !info["failed"]
228
+ rescue
229
+ false
230
+ end
231
+
232
+ def send_to_stream(stream, method, path, headers, body)
233
+ encoded_response = Protocol::RequestEncoder.new(
234
+ method: method,
235
+ path: path,
236
+ scheme: "https",
237
+ authority: authority,
238
+ headers: headers,
239
+ body: body
240
+ ).encode
241
+
242
+ result = stream.send(encoded_response, fin: true)
243
+
244
+ unless result
245
+ @mutex.synchronize { @pending_requests.delete(stream.handle) }
246
+ raise Error, "Failed to send request"
247
+ end
248
+ end
249
+ end
250
+ 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
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Quicsilver
4
- module HTTP3
4
+ module Protocol
5
5
  # HTTP/3 Frame Types (RFC 9114)
6
6
  FRAME_DATA = 0x00
7
7
  FRAME_HEADERS = 0x01
@@ -11,6 +11,52 @@ module Quicsilver
11
11
  FRAME_GOAWAY = 0x07
12
12
  FRAME_MAX_PUSH_ID = 0x0d
13
13
 
14
+ # Frame types forbidden on request streams (RFC 9114 Section 7.2.4, 7.2.6, 7.2.7)
15
+ CONTROL_ONLY_FRAMES = [FRAME_CANCEL_PUSH, FRAME_SETTINGS, FRAME_GOAWAY, FRAME_MAX_PUSH_ID].freeze
16
+
17
+ # HTTP/3 Error Codes (RFC 9114 Section 8.1)
18
+ H3_NO_ERROR = 0x100
19
+ H3_GENERAL_PROTOCOL_ERROR = 0x101
20
+ H3_INTERNAL_ERROR = 0x102
21
+ H3_STREAM_CREATION_ERROR = 0x103
22
+ H3_CLOSED_CRITICAL_STREAM = 0x104
23
+ H3_FRAME_UNEXPECTED = 0x105
24
+ H3_FRAME_ERROR = 0x106
25
+ H3_EXCESSIVE_LOAD = 0x107
26
+ H3_ID_ERROR = 0x108
27
+ H3_SETTINGS_ERROR = 0x109
28
+ H3_MISSING_SETTINGS = 0x10a
29
+ H3_REQUEST_REJECTED = 0x10b
30
+ H3_REQUEST_CANCELLED = 0x10c
31
+ H3_REQUEST_INCOMPLETE = 0x10d
32
+ H3_MESSAGE_ERROR = 0x10e
33
+ H3_CONNECT_ERROR = 0x10f
34
+ H3_VERSION_FALLBACK = 0x110
35
+
36
+ # QPACK Error Codes (RFC 9204 Section 6)
37
+ QPACK_DECOMPRESSION_FAILED = 0x200
38
+ QPACK_ENCODER_STREAM_ERROR = 0x201
39
+ QPACK_DECODER_STREAM_ERROR = 0x202
40
+
41
+ # Protocol errors that carry an HTTP/3 error code for CONNECTION_CLOSE / RESET_STREAM.
42
+ # FrameError → connection error (CONNECTION_CLOSE)
43
+ # MessageError → stream error (RESET_STREAM) on request streams
44
+ class FrameError < StandardError
45
+ attr_reader :error_code
46
+ def initialize(msg = nil, error_code: H3_FRAME_UNEXPECTED)
47
+ @error_code = error_code
48
+ super(msg)
49
+ end
50
+ end
51
+
52
+ class MessageError < StandardError
53
+ attr_reader :error_code
54
+ def initialize(msg = nil, error_code: H3_MESSAGE_ERROR)
55
+ @error_code = error_code
56
+ super(msg)
57
+ end
58
+ end
59
+
14
60
  # QPACK Static Table Indices (RFC 9204 Appendix A)
15
61
  STATIC_TABLE = [
16
62
  [':authority', ''], # 0
@@ -70,8 +116,8 @@ module Quicsilver
70
116
  ['content-type', 'text/plain;charset=utf-8'], # 54
71
117
  ['range', 'bytes=0-'], # 55
72
118
  ['strict-transport-security', 'max-age=31536000'], # 56
73
- ['strict-transport-security', 'max-age=31536000; includesubdomains'], # 57
74
- ['strict-transport-security', 'max-age=31536000; includesubdomains; preload'], # 58
119
+ ['strict-transport-security', 'max-age=31536000; includeSubDomains'], # 57
120
+ ['strict-transport-security', 'max-age=31536000; includeSubDomains; preload'], # 58
75
121
  ['vary', 'accept-encoding'], # 59
76
122
  ['vary', 'origin'], # 60
77
123
  ['x-content-type-options', 'nosniff'], # 61
@@ -134,22 +180,40 @@ module Quicsilver
134
180
  QPACK_CONTENT_TYPE_JSON = 46
135
181
  QPACK_CONTENT_TYPE_PLAIN = 53
136
182
 
183
+ # Maximum stream ID for initial GOAWAY (2^62 - 4, per RFC 9114)
184
+ MAX_STREAM_ID = (2**62) - 4
185
+
137
186
  class << self
187
+ # Precomputed varint encodings for single-byte values (0-63)
188
+ VARINT_SMALL = Array.new(64) { |v| [v].pack('C').freeze }.freeze
189
+ VARINT_MED = Array.new(16384 - 64) { |i| v = i + 64; [0x40 | (v >> 8), v & 0xFF].pack('C*').freeze }.freeze
190
+
191
+ # Cache for large varint encodings (values >= 16384)
192
+ VARINT_LARGE_CACHE = {}
193
+ VARINT_LARGE_CACHE_MAX = 256
194
+
138
195
  # Encode variable-length integer
139
196
  def encode_varint(value)
140
- case value
141
- when 0..63
142
- [value].pack('C')
143
- when 64..16383
144
- [0x40 | (value >> 8), value & 0xFF].pack('C*')
145
- when 16384..1073741823
146
- [0x80 | (value >> 24), (value >> 16) & 0xFF,
147
- (value >> 8) & 0xFF, value & 0xFF].pack('C*')
197
+ if value < 64
198
+ VARINT_SMALL[value]
199
+ elsif value < 16384
200
+ VARINT_MED[value - 64]
148
201
  else
149
- [0xC0 | (value >> 56), (value >> 48) & 0xFF,
150
- (value >> 40) & 0xFF, (value >> 32) & 0xFF,
151
- (value >> 24) & 0xFF, (value >> 16) & 0xFF,
152
- (value >> 8) & 0xFF, value & 0xFF].pack('C*')
202
+ cached = VARINT_LARGE_CACHE[value]
203
+ return cached if cached
204
+
205
+ result = if value < 1073741824
206
+ [0x80 | (value >> 24), (value >> 16) & 0xFF,
207
+ (value >> 8) & 0xFF, value & 0xFF].pack('C*').freeze
208
+ else
209
+ [0xC0 | (value >> 56), (value >> 48) & 0xFF,
210
+ (value >> 40) & 0xFF, (value >> 32) & 0xFF,
211
+ (value >> 24) & 0xFF, (value >> 16) & 0xFF,
212
+ (value >> 8) & 0xFF, value & 0xFF].pack('C*').freeze
213
+ end
214
+
215
+ VARINT_LARGE_CACHE[value] = result if VARINT_LARGE_CACHE.size < VARINT_LARGE_CACHE_MAX
216
+ result
153
217
  end
154
218
  end
155
219
 
@@ -169,7 +233,10 @@ module Quicsilver
169
233
  # Build control stream data
170
234
  def build_control_stream
171
235
  stream_type = [0x00].pack('C') # Control stream type
172
- settings = build_settings_frame({})
236
+ settings = build_settings_frame({
237
+ 0x01 => 0, # QPACK_MAX_TABLE_CAPACITY = 0 (no dynamic table)
238
+ 0x07 => 0 # QPACK_BLOCKED_STREAMS = 0
239
+ })
173
240
 
174
241
  stream_type + settings
175
242
  end
@@ -184,32 +251,71 @@ module Quicsilver
184
251
  frame_type + frame_length + payload
185
252
  end
186
253
 
187
- # Maximum stream ID for initial GOAWAY (2^62 - 4, per RFC 9114)
188
- MAX_STREAM_ID = (2**62) - 4
254
+ # Cache for decode_varint_str: (object_id << 16 | offset) → [value, consumed]
255
+ VARINT_STR_CACHE = {}
256
+ VARINT_STR_CACHE_MAX = 256
257
+
258
+ # Decode variable-length integer from a String using getbyte (no array needed)
259
+ # Returns [value, bytes_consumed]
260
+ def decode_varint_str(str, offset = 0)
261
+ cache_key = (str.object_id << 16) | offset
262
+ cached = VARINT_STR_CACHE[cache_key]
263
+ return cached if cached
264
+
265
+ first = str.getbyte(offset)
266
+ return [0, 0] unless first
267
+
268
+ if first < 0x40
269
+ result = VARINT_DECODE_SMALL[first]
270
+ else
271
+ prefix = (first & 0xC0) >> 6
272
+ length = 1 << prefix
273
+
274
+ return [0, 0] if offset + length > str.bytesize
275
+
276
+ result = case prefix
277
+ when 0
278
+ [first & 0x3F, 1]
279
+ when 1
280
+ [(first & 0x3F) << 8 | str.getbyte(offset + 1), 2]
281
+ when 2
282
+ [(first & 0x3F) << 24 | str.getbyte(offset + 1) << 16 |
283
+ str.getbyte(offset + 2) << 8 | str.getbyte(offset + 3), 4]
284
+ else
285
+ [(first & 0x3F) << 56 | str.getbyte(offset + 1) << 48 |
286
+ str.getbyte(offset + 2) << 40 | str.getbyte(offset + 3) << 32 |
287
+ str.getbyte(offset + 4) << 24 | str.getbyte(offset + 5) << 16 |
288
+ str.getbyte(offset + 6) << 8 | str.getbyte(offset + 7), 8]
289
+ end
290
+ end
291
+
292
+ VARINT_STR_CACHE[cache_key] = result if VARINT_STR_CACHE.size < VARINT_STR_CACHE_MAX
293
+ result
294
+ end
295
+
296
+ # Precomputed decode results for single-byte varints (0-63)
297
+ VARINT_DECODE_SMALL = Array.new(64) { |v| [v, 1].freeze }.freeze
189
298
 
190
299
  # Decode variable-length integer (RFC 9000)
191
300
  # Returns [value, bytes_consumed]
192
301
  def decode_varint(bytes, offset = 0)
193
- return [0, 0] if offset >= bytes.size
194
-
195
302
  first = bytes[offset]
196
- return [0, 0] if first.nil?
303
+ return [0, 0] unless first
197
304
 
198
- prefix = (first & 0xC0) >> 6 # Extract 2 MSB
199
- length = 1 << prefix # 1, 2, 4, or 8 bytes
305
+ # Fast path for single-byte varints (most common: frame types, small lengths)
306
+ return VARINT_DECODE_SMALL[first] if first < 0x40
200
307
 
201
- # Check if we have enough bytes
308
+ prefix = (first & 0xC0) >> 6
309
+ length = 1 << prefix
202
310
  return [0, 0] if offset + length > bytes.size
203
311
 
204
312
  case prefix
205
- when 0
206
- [first & 0x3F, 1]
207
313
  when 1
208
314
  [(first & 0x3F) << 8 | bytes[offset + 1], 2]
209
315
  when 2
210
316
  [(first & 0x3F) << 24 | bytes[offset + 1] << 16 |
211
317
  bytes[offset + 2] << 8 | bytes[offset + 3], 4]
212
- else # when 3
318
+ else # 3
213
319
  [(first & 0x3F) << 56 | bytes[offset + 1] << 48 |
214
320
  bytes[offset + 2] << 40 | bytes[offset + 3] << 32 |
215
321
  bytes[offset + 4] << 24 | bytes[offset + 5] << 16 |
@@ -219,4 +325,3 @@ module Quicsilver
219
325
  end
220
326
  end
221
327
  end
222
-