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
@@ -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
@@ -10,6 +10,53 @@ module Quicsilver
10
10
  FRAME_PUSH_PROMISE = 0x05
11
11
  FRAME_GOAWAY = 0x07
12
12
  FRAME_MAX_PUSH_ID = 0x0d
13
+ FRAME_PRIORITY_UPDATE = 0xF0700 # RFC 9218 — request stream priority update
14
+
15
+ # Frame types forbidden on request streams (RFC 9114 Section 7.2.4, 7.2.6, 7.2.7)
16
+ CONTROL_ONLY_FRAMES = [FRAME_CANCEL_PUSH, FRAME_SETTINGS, FRAME_GOAWAY, FRAME_MAX_PUSH_ID].freeze
17
+
18
+ # HTTP/3 Error Codes (RFC 9114 Section 8.1)
19
+ H3_NO_ERROR = 0x100
20
+ H3_GENERAL_PROTOCOL_ERROR = 0x101
21
+ H3_INTERNAL_ERROR = 0x102
22
+ H3_STREAM_CREATION_ERROR = 0x103
23
+ H3_CLOSED_CRITICAL_STREAM = 0x104
24
+ H3_FRAME_UNEXPECTED = 0x105
25
+ H3_FRAME_ERROR = 0x106
26
+ H3_EXCESSIVE_LOAD = 0x107
27
+ H3_ID_ERROR = 0x108
28
+ H3_SETTINGS_ERROR = 0x109
29
+ H3_MISSING_SETTINGS = 0x10a
30
+ H3_REQUEST_REJECTED = 0x10b
31
+ H3_REQUEST_CANCELLED = 0x10c
32
+ H3_REQUEST_INCOMPLETE = 0x10d
33
+ H3_MESSAGE_ERROR = 0x10e
34
+ H3_CONNECT_ERROR = 0x10f
35
+ H3_VERSION_FALLBACK = 0x110
36
+
37
+ # QPACK Error Codes (RFC 9204 Section 6)
38
+ QPACK_DECOMPRESSION_FAILED = 0x200
39
+ QPACK_ENCODER_STREAM_ERROR = 0x201
40
+ QPACK_DECODER_STREAM_ERROR = 0x202
41
+
42
+ # Protocol errors that carry an HTTP/3 error code for CONNECTION_CLOSE / RESET_STREAM.
43
+ # FrameError → connection error (CONNECTION_CLOSE)
44
+ # MessageError → stream error (RESET_STREAM) on request streams
45
+ class FrameError < StandardError
46
+ attr_reader :error_code
47
+ def initialize(msg = nil, error_code: H3_FRAME_UNEXPECTED)
48
+ @error_code = error_code
49
+ super(msg)
50
+ end
51
+ end
52
+
53
+ class MessageError < StandardError
54
+ attr_reader :error_code
55
+ def initialize(msg = nil, error_code: H3_MESSAGE_ERROR)
56
+ @error_code = error_code
57
+ super(msg)
58
+ end
59
+ end
13
60
 
14
61
  # QPACK Static Table Indices (RFC 9204 Appendix A)
15
62
  STATIC_TABLE = [
@@ -70,8 +117,8 @@ module Quicsilver
70
117
  ['content-type', 'text/plain;charset=utf-8'], # 54
71
118
  ['range', 'bytes=0-'], # 55
72
119
  ['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
120
+ ['strict-transport-security', 'max-age=31536000; includeSubDomains'], # 57
121
+ ['strict-transport-security', 'max-age=31536000; includeSubDomains; preload'], # 58
75
122
  ['vary', 'accept-encoding'], # 59
76
123
  ['vary', 'origin'], # 60
77
124
  ['x-content-type-options', 'nosniff'], # 61
@@ -134,22 +181,40 @@ module Quicsilver
134
181
  QPACK_CONTENT_TYPE_JSON = 46
135
182
  QPACK_CONTENT_TYPE_PLAIN = 53
136
183
 
184
+ # Maximum stream ID for initial GOAWAY (2^62 - 4, per RFC 9114)
185
+ MAX_STREAM_ID = (2**62) - 4
186
+
137
187
  class << self
188
+ # Precomputed varint encodings for single-byte values (0-63)
189
+ VARINT_SMALL = Array.new(64) { |v| [v].pack('C').freeze }.freeze
190
+ VARINT_MED = Array.new(16384 - 64) { |i| v = i + 64; [0x40 | (v >> 8), v & 0xFF].pack('C*').freeze }.freeze
191
+
192
+ # Cache for large varint encodings (values >= 16384)
193
+ VARINT_LARGE_CACHE = {}
194
+ VARINT_LARGE_CACHE_MAX = 256
195
+
138
196
  # Encode variable-length integer
139
197
  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*')
198
+ if value < 64
199
+ VARINT_SMALL[value]
200
+ elsif value < 16384
201
+ VARINT_MED[value - 64]
148
202
  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*')
203
+ cached = VARINT_LARGE_CACHE[value]
204
+ return cached if cached
205
+
206
+ result = if value < 1073741824
207
+ [0x80 | (value >> 24), (value >> 16) & 0xFF,
208
+ (value >> 8) & 0xFF, value & 0xFF].pack('C*').freeze
209
+ else
210
+ [0xC0 | (value >> 56), (value >> 48) & 0xFF,
211
+ (value >> 40) & 0xFF, (value >> 32) & 0xFF,
212
+ (value >> 24) & 0xFF, (value >> 16) & 0xFF,
213
+ (value >> 8) & 0xFF, value & 0xFF].pack('C*').freeze
214
+ end
215
+
216
+ VARINT_LARGE_CACHE[value] = result if VARINT_LARGE_CACHE.size < VARINT_LARGE_CACHE_MAX
217
+ result
153
218
  end
154
219
  end
155
220
 
@@ -166,12 +231,25 @@ module Quicsilver
166
231
  frame_type + frame_length + payload
167
232
  end
168
233
 
234
+ # Generate a random GREASE ID (RFC 9297): 31 * n + 33
235
+ def grease_id
236
+ 31 * rand(0..20) + 33
237
+ end
238
+
169
239
  # Build control stream data
170
- def build_control_stream
240
+ # @param max_field_section_size [Integer, nil] Advertise SETTINGS_MAX_FIELD_SECTION_SIZE (0x06)
241
+ # to the peer (RFC 9114 §4.2.2 / §7.2.4.1). nil = don't advertise.
242
+ def build_control_stream(max_field_section_size: nil)
171
243
  stream_type = [0x00].pack('C') # Control stream type
172
- settings = build_settings_frame({})
244
+ settings_hash = {
245
+ 0x01 => 0, # QPACK_MAX_TABLE_CAPACITY = 0 (no dynamic table)
246
+ 0x07 => 0, # QPACK_BLOCKED_STREAMS = 0
247
+ 0x08 => 1, # SETTINGS_ENABLE_CONNECT_PROTOCOL (RFC 9220)
248
+ }
249
+ settings_hash[0x06] = max_field_section_size if max_field_section_size # SETTINGS_MAX_FIELD_SECTION_SIZE
250
+ settings_hash[grease_id] = grease_id # GREASE setting (RFC 9297)
173
251
 
174
- stream_type + settings
252
+ stream_type + build_settings_frame(settings_hash)
175
253
  end
176
254
 
177
255
  # Build GOAWAY frame (RFC 9114 Section 7.2.6)
@@ -184,32 +262,71 @@ module Quicsilver
184
262
  frame_type + frame_length + payload
185
263
  end
186
264
 
187
- # Maximum stream ID for initial GOAWAY (2^62 - 4, per RFC 9114)
188
- MAX_STREAM_ID = (2**62) - 4
265
+ # Cache for decode_varint_str: (object_id << 16 | offset) → [value, consumed]
266
+ VARINT_STR_CACHE = {}
267
+ VARINT_STR_CACHE_MAX = 256
268
+
269
+ # Decode variable-length integer from a String using getbyte (no array needed)
270
+ # Returns [value, bytes_consumed]
271
+ def decode_varint_str(str, offset = 0)
272
+ cache_key = (str.object_id << 16) | offset
273
+ cached = VARINT_STR_CACHE[cache_key]
274
+ return cached if cached
275
+
276
+ first = str.getbyte(offset)
277
+ return [0, 0] unless first
278
+
279
+ if first < 0x40
280
+ result = VARINT_DECODE_SMALL[first]
281
+ else
282
+ prefix = (first & 0xC0) >> 6
283
+ length = 1 << prefix
284
+
285
+ return [0, 0] if offset + length > str.bytesize
286
+
287
+ result = case prefix
288
+ when 0
289
+ [first & 0x3F, 1]
290
+ when 1
291
+ [(first & 0x3F) << 8 | str.getbyte(offset + 1), 2]
292
+ when 2
293
+ [(first & 0x3F) << 24 | str.getbyte(offset + 1) << 16 |
294
+ str.getbyte(offset + 2) << 8 | str.getbyte(offset + 3), 4]
295
+ else
296
+ [(first & 0x3F) << 56 | str.getbyte(offset + 1) << 48 |
297
+ str.getbyte(offset + 2) << 40 | str.getbyte(offset + 3) << 32 |
298
+ str.getbyte(offset + 4) << 24 | str.getbyte(offset + 5) << 16 |
299
+ str.getbyte(offset + 6) << 8 | str.getbyte(offset + 7), 8]
300
+ end
301
+ end
302
+
303
+ VARINT_STR_CACHE[cache_key] = result if VARINT_STR_CACHE.size < VARINT_STR_CACHE_MAX
304
+ result
305
+ end
306
+
307
+ # Precomputed decode results for single-byte varints (0-63)
308
+ VARINT_DECODE_SMALL = Array.new(64) { |v| [v, 1].freeze }.freeze
189
309
 
190
310
  # Decode variable-length integer (RFC 9000)
191
311
  # Returns [value, bytes_consumed]
192
312
  def decode_varint(bytes, offset = 0)
193
- return [0, 0] if offset >= bytes.size
194
-
195
313
  first = bytes[offset]
196
- return [0, 0] if first.nil?
314
+ return [0, 0] unless first
197
315
 
198
- prefix = (first & 0xC0) >> 6 # Extract 2 MSB
199
- length = 1 << prefix # 1, 2, 4, or 8 bytes
316
+ # Fast path for single-byte varints (most common: frame types, small lengths)
317
+ return VARINT_DECODE_SMALL[first] if first < 0x40
200
318
 
201
- # Check if we have enough bytes
319
+ prefix = (first & 0xC0) >> 6
320
+ length = 1 << prefix
202
321
  return [0, 0] if offset + length > bytes.size
203
322
 
204
323
  case prefix
205
- when 0
206
- [first & 0x3F, 1]
207
324
  when 1
208
325
  [(first & 0x3F) << 8 | bytes[offset + 1], 2]
209
326
  when 2
210
327
  [(first & 0x3F) << 24 | bytes[offset + 1] << 16 |
211
328
  bytes[offset + 2] << 8 | bytes[offset + 3], 4]
212
- else # when 3
329
+ else # 3
213
330
  [(first & 0x3F) << 56 | bytes[offset + 1] << 48 |
214
331
  bytes[offset + 2] << 40 | bytes[offset + 3] << 32 |
215
332
  bytes[offset + 4] << 24 | bytes[offset + 5] << 16 |
@@ -219,4 +336,3 @@ module Quicsilver
219
336
  end
220
337
  end
221
338
  end
222
-
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Quicsilver
4
+ module Protocol
5
+ # HTTP Extensible Priorities (RFC 9218).
6
+ #
7
+ # Parses the `priority` header from HTTP requests. Urgency ranges from
8
+ # 0 (highest) to 7 (lowest), defaulting to 3. Incremental indicates
9
+ # whether partial data is useful to the client (e.g. progressive images).
10
+ #
11
+ # Usage:
12
+ # priority = Priority.parse("u=0, i")
13
+ # priority.urgency # => 0
14
+ # priority.incremental # => true
15
+ #
16
+ # priority = Priority.parse(nil) # default
17
+ # priority.urgency # => 3
18
+ # priority.incremental # => false
19
+ class Priority
20
+ DEFAULT_URGENCY = 3
21
+ MIN_URGENCY = 0
22
+ MAX_URGENCY = 7
23
+
24
+ attr_reader :urgency, :incremental
25
+
26
+ def initialize(urgency: DEFAULT_URGENCY, incremental: false)
27
+ @urgency = urgency.clamp(MIN_URGENCY, MAX_URGENCY)
28
+ @incremental = incremental
29
+ end
30
+
31
+ # Parse a priority header value (RFC 9218 §4, Structured Field Values).
32
+ # Returns a Priority with defaults for missing or invalid values.
33
+ def self.parse(value)
34
+ return new unless value && !value.empty?
35
+
36
+ urgency = DEFAULT_URGENCY
37
+ incremental = false
38
+
39
+ value.split(",").each do |param|
40
+ param = param.strip
41
+ if param.start_with?("u=")
42
+ urgency = param[2..].to_i
43
+ elsif param == "i"
44
+ incremental = true
45
+ elsif param == "i=?0"
46
+ incremental = false
47
+ elsif param == "i=?1"
48
+ incremental = true
49
+ end
50
+ end
51
+
52
+ new(urgency: urgency, incremental: incremental)
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,165 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "huffman"
4
+
5
+ module Quicsilver
6
+ module Protocol
7
+ module Qpack
8
+ module Decoder
9
+ # Decode a QPACK string literal (RFC 9204 Section 4.1.2)
10
+ # Returns [string, bytes_consumed]
11
+ # String-based variant: accepts a binary String instead of byte array
12
+ def decode_qpack_string_from_str(data, offset)
13
+ first = data.getbyte(offset)
14
+ huffman = (first & 0x80) != 0
15
+
16
+ length = first & 0x7F
17
+ len_bytes = 1
18
+ if length == 0x7F
19
+ multiplier = 1
20
+ while offset + len_bytes < data.bytesize
21
+ next_byte = data.getbyte(offset + len_bytes)
22
+ len_bytes += 1
23
+ length += (next_byte & 0x7F) * multiplier
24
+ break if (next_byte & 0x80) == 0
25
+ multiplier *= 128
26
+ end
27
+ end
28
+
29
+ data_offset = offset + len_bytes
30
+ raw = data.byteslice(data_offset, length)
31
+
32
+ str = if huffman
33
+ Huffman.decode(raw) || raw
34
+ else
35
+ raw
36
+ end
37
+
38
+ [str, len_bytes + length]
39
+ end
40
+
41
+ # Cache for decode_qpack_string
42
+ DQS_CACHE = {} # array-content → [str, consumed]
43
+ DQS_OID_CACHE = {} # object_id|offset → [str, consumed]
44
+ DQS_CACHE_MAX = 128
45
+
46
+ # 2-slot last-result cache for decode_qpack_string
47
+ DQS_LAST_A = [nil, nil, nil] # [bytes, offset, result]
48
+ DQS_LAST_B = [nil, nil, nil]
49
+
50
+ def decode_qpack_string(bytes, offset)
51
+ # 2-slot equal? fast path (covers alternating-object patterns)
52
+ return DQS_LAST_A[2] if bytes.equal?(DQS_LAST_A[0]) && offset == DQS_LAST_A[1]
53
+ return DQS_LAST_B[2] if bytes.equal?(DQS_LAST_B[0]) && offset == DQS_LAST_B[1]
54
+
55
+ # Object-id cache
56
+ oid_key = (bytes.object_id << 16) | offset
57
+ cached = DQS_OID_CACHE[oid_key]
58
+ if cached
59
+ # Rotate 2-slot cache
60
+ DQS_LAST_B[0], DQS_LAST_B[1], DQS_LAST_B[2] = DQS_LAST_A[0], DQS_LAST_A[1], DQS_LAST_A[2]
61
+ DQS_LAST_A[0], DQS_LAST_A[1], DQS_LAST_A[2] = bytes, offset, cached
62
+ return cached
63
+ end
64
+
65
+ # Dispatch to string variant if given a String
66
+ return decode_qpack_string_from_str(bytes, offset) if bytes.is_a?(String)
67
+
68
+ # Content-based cache for offset=0
69
+ if offset == 0
70
+ cached = DQS_CACHE[bytes]
71
+ if cached
72
+ DQS_OID_CACHE[oid_key] = cached
73
+ return cached
74
+ end
75
+ end
76
+
77
+ first = bytes[offset]
78
+ huffman = (first & 0x80) != 0
79
+
80
+ # Inline 7-bit prefix integer decode to avoid method call
81
+ length = first & 0x7F
82
+ len_bytes = 1
83
+ if length == 0x7F
84
+ multiplier = 1
85
+ while offset + len_bytes < bytes.size
86
+ next_byte = bytes[offset + len_bytes]
87
+ len_bytes += 1
88
+ length += (next_byte & 0x7F) * multiplier
89
+ break if (next_byte & 0x80) == 0
90
+ multiplier *= 128
91
+ end
92
+ end
93
+
94
+ data_offset = offset + len_bytes
95
+ raw = bytes[data_offset, length].pack("C*")
96
+
97
+ str = if huffman
98
+ Huffman.decode(raw) || raw
99
+ else
100
+ raw
101
+ end
102
+
103
+ result = [str, len_bytes + length].freeze
104
+
105
+ # Cache for offset=0 (common case: standalone decode)
106
+ if offset == 0 && DQS_CACHE.size < DQS_CACHE_MAX
107
+ DQS_CACHE[bytes.frozen? ? bytes : bytes.dup.freeze] = result
108
+ end
109
+ DQS_OID_CACHE[oid_key] = result if DQS_OID_CACHE.size < DQS_CACHE_MAX
110
+
111
+ result
112
+ end
113
+
114
+ # String-based prefix integer decoding
115
+ def decode_prefix_integer_str(data, offset, prefix_bits, pattern_mask)
116
+ max_prefix = (1 << prefix_bits) - 1
117
+ first_byte = data.getbyte(offset)
118
+ value = first_byte & max_prefix
119
+ bytes_consumed = 1
120
+
121
+ if value == max_prefix
122
+ multiplier = 1
123
+ loop do
124
+ return [value, bytes_consumed] if offset + bytes_consumed >= data.bytesize
125
+ next_byte = data.getbyte(offset + bytes_consumed)
126
+ bytes_consumed += 1
127
+ value += (next_byte & 0x7F) * multiplier
128
+ break if (next_byte & 0x80) == 0
129
+ multiplier *= 128
130
+ end
131
+ end
132
+
133
+ [value, bytes_consumed]
134
+ end
135
+
136
+ # RFC 7541 prefix integer decoding
137
+ # Returns [value, bytes_consumed]
138
+ def decode_prefix_integer(bytes, offset, prefix_bits, pattern_mask)
139
+ max_prefix = (1 << prefix_bits) - 1
140
+
141
+ first_byte = bytes[offset]
142
+ value = first_byte & max_prefix
143
+ bytes_consumed = 1
144
+
145
+ if value == max_prefix
146
+ multiplier = 1
147
+ loop do
148
+ return [value, bytes_consumed] if offset + bytes_consumed >= bytes.size
149
+
150
+ next_byte = bytes[offset + bytes_consumed]
151
+ bytes_consumed += 1
152
+
153
+ value += (next_byte & 0x7F) * multiplier
154
+ break if (next_byte & 0x80) == 0
155
+
156
+ multiplier *= 128
157
+ end
158
+ end
159
+
160
+ [value, bytes_consumed]
161
+ end
162
+ end
163
+ end
164
+ end
165
+ end
@@ -0,0 +1,227 @@
1
+ # frozen_string_literal: true
2
+ require_relative "huffman"
3
+
4
+ module Quicsilver
5
+ module Protocol
6
+ module Qpack
7
+ class Encoder
8
+ STATIC_TABLE = Protocol::STATIC_TABLE
9
+
10
+ # Pre-built hash for O(1) static table lookups
11
+ STATIC_LOOKUP_FULL = {} # "name\0value" => index
12
+ STATIC_LOOKUP_NAME = {} # name => first_index
13
+
14
+ STATIC_TABLE.each_with_index do |(tbl_name, tbl_value), idx|
15
+ STATIC_LOOKUP_FULL["#{tbl_name}\0#{tbl_value}".freeze] = idx
16
+ STATIC_LOOKUP_NAME[tbl_name] ||= idx
17
+ end
18
+ STATIC_LOOKUP_FULL.freeze
19
+ STATIC_LOOKUP_NAME.freeze
20
+
21
+ PREFIX = "\x00\x00".b.freeze
22
+
23
+ FIELD_CACHE_MAX = 512
24
+
25
+ def initialize(huffman: true)
26
+ @huffman = huffman
27
+ @field_cache = {}
28
+ @block_cache = {}
29
+ @oid_cache = {}
30
+ end
31
+
32
+ def encode(headers)
33
+ # Fastest path: exact same object as last call
34
+ return @last_result if headers.equal?(@last_headers)
35
+
36
+ # Fast path: check object_id cache (same array object reused)
37
+ oid = headers.object_id
38
+ cached = @oid_cache[oid]
39
+ if cached
40
+ @last_headers = headers
41
+ @last_result = cached
42
+ return cached
43
+ end
44
+
45
+ if headers.is_a?(Array) && headers.size <= 16
46
+ # Content-based caching for small header sets
47
+ block_key = headers.map { |n, v| "#{n}\0#{v}" }.join("\x01")
48
+ cached_block = @block_cache[block_key]
49
+ if cached_block
50
+ @oid_cache[oid] = cached_block if @oid_cache.size < BLOCK_CACHE_MAX
51
+ return cached_block
52
+ end
53
+
54
+ result = encode_fields(headers)
55
+ result_frozen = result.freeze
56
+ if @block_cache.size < BLOCK_CACHE_MAX
57
+ @block_cache[block_key.freeze] = result_frozen
58
+ end
59
+ @oid_cache[oid] = result_frozen if @oid_cache.size < BLOCK_CACHE_MAX
60
+ return result_frozen
61
+ end
62
+
63
+ encode_fields(headers)
64
+ end
65
+
66
+ BLOCK_CACHE_MAX = 128
67
+
68
+ private def encode_fields(headers)
69
+ out = encode_prefix
70
+ headers.each do |name, value|
71
+ name = name.to_s
72
+ value = value.to_s
73
+ # Downcase only if needed (most HTTP/3 headers are already lowercase)
74
+ name = name.downcase if name.match?(/[A-Z]/)
75
+
76
+ cache_key = "#{name}\0#{value}"
77
+
78
+ # Check field cache
79
+ cached = @field_cache[cache_key]
80
+ if cached
81
+ out << cached
82
+ next
83
+ end
84
+
85
+ field_start = out.bytesize
86
+
87
+ full_idx = STATIC_LOOKUP_FULL[cache_key]
88
+
89
+ if full_idx
90
+ # Indexed Field Line
91
+ out << encode_prefixed_int(full_idx, 6, 0xC0)
92
+ else
93
+ name_idx = STATIC_LOOKUP_NAME[name]
94
+ if name_idx
95
+ # Literal with Name Reference
96
+ out << encode_prefixed_int(name_idx, 4, 0x50)
97
+ encode_str_into(out, value)
98
+ else
99
+ # Literal with Literal Name
100
+ encode_literal_into(out, name, value)
101
+ end
102
+ end
103
+
104
+ # Cache the encoded field bytes
105
+ if @field_cache.size < FIELD_CACHE_MAX
106
+ @field_cache[cache_key.freeze] = out.byteslice(field_start..).freeze
107
+ end
108
+ end
109
+ out
110
+ end
111
+
112
+ def lookup(name, value)
113
+ name = name.to_s.downcase
114
+ value = value.to_s
115
+ full_idx = STATIC_LOOKUP_FULL["#{name}\0#{value}"]
116
+ return [full_idx, true] if full_idx
117
+ name_idx = STATIC_LOOKUP_NAME[name]
118
+ name_idx ? [name_idx, false] : nil
119
+ end
120
+
121
+ def encode_prefix
122
+ PREFIX.dup
123
+ end
124
+
125
+ # Public API for encoding a single string value (used by tests/external code)
126
+ def encode_str(value)
127
+ out = "".b
128
+ encode_str_into(out, value.to_s)
129
+ out
130
+ end
131
+
132
+ private
133
+
134
+ # Pattern 1: Indexed Field Line (1xxxxxxx) — kept for test compatibility
135
+ def encode_indexed(index)
136
+ encode_prefixed_int(index, 6, 0xC0)
137
+ end
138
+
139
+ def encode_literal_into(out, name, value)
140
+ name_b = name.b
141
+ if @huffman
142
+ huffman_name = Huffman.encode(name_b)
143
+ if huffman_name.bytesize < name_b.bytesize
144
+ out << encode_prefixed_int(huffman_name.bytesize, 3, 0x28)
145
+ out << huffman_name
146
+ encode_str_into(out, value)
147
+ return
148
+ end
149
+ end
150
+ out << encode_prefixed_int(name_b.bytesize, 3, 0x20)
151
+ out << name_b
152
+ encode_str_into(out, value)
153
+ end
154
+
155
+ def encode_str_into(out, value)
156
+ value_b = value.b
157
+ if @huffman
158
+ huffman = Huffman.encode(value_b)
159
+ if huffman.bytesize < value_b.bytesize
160
+ out << encode_prefixed_int(huffman.bytesize, 7, 0x80)
161
+ out << huffman
162
+ return
163
+ end
164
+ end
165
+ out << encode_prefixed_int(value_b.bytesize, 7, 0x00)
166
+ out << value_b
167
+ end
168
+
169
+ # Pre-computed prefix integer tables for hot encode paths.
170
+ # Key: [prefix_bits, pattern] => Array of frozen encoded strings indexed by value.
171
+ # Covers all values up to max_prefix (single-byte) and a range beyond.
172
+ PREFIXED_INT_CACHE = {}
173
+ [
174
+ [6, 0xC0], # Indexed Field Line
175
+ [4, 0x50], # Literal with Name Reference (static)
176
+ [7, 0x80], # Huffman string length
177
+ [7, 0x00], # Raw string length
178
+ [3, 0x28], # Huffman literal name length
179
+ [3, 0x20], # Raw literal name length
180
+ ].each do |prefix_bits, pattern|
181
+ max_prefix = (1 << prefix_bits) - 1
182
+ # Pre-compute single-byte range + a bit beyond for multi-byte
183
+ limit = [max_prefix + 64, 256].min
184
+ table = Array.new(limit) do |value|
185
+ if value < max_prefix
186
+ (pattern | value).chr(Encoding::BINARY).freeze
187
+ else
188
+ buf = (pattern | max_prefix).chr(Encoding::BINARY)
189
+ v = value - max_prefix
190
+ while v >= 128
191
+ buf << ((v & 0x7F) | 0x80).chr(Encoding::BINARY)
192
+ v >>= 7
193
+ end
194
+ buf << v.chr(Encoding::BINARY)
195
+ buf.freeze
196
+ end
197
+ end
198
+ PREFIXED_INT_CACHE[[prefix_bits, pattern]] = table.freeze
199
+ end
200
+ PREFIXED_INT_CACHE.freeze
201
+
202
+ # RFC 7541 prefix integer encoding
203
+ def encode_prefixed_int(value, prefix_bits, pattern)
204
+ table = PREFIXED_INT_CACHE[[prefix_bits, pattern]]
205
+ if table && value < table.size
206
+ return table[value]
207
+ end
208
+
209
+ max_prefix = (1 << prefix_bits) - 1
210
+
211
+ if value < max_prefix
212
+ (pattern | value).chr(Encoding::BINARY)
213
+ else
214
+ buf = (pattern | max_prefix).chr(Encoding::BINARY)
215
+ value -= max_prefix
216
+ while value >= 128
217
+ buf << ((value & 0x7F) | 0x80).chr(Encoding::BINARY)
218
+ value >>= 7
219
+ end
220
+ buf << value.chr(Encoding::BINARY)
221
+ buf
222
+ end
223
+ end
224
+ end
225
+ end
226
+ end
227
+ end