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
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Quicsilver
|
|
4
|
-
module
|
|
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;
|
|
74
|
-
['strict-transport-security', 'max-age=31536000;
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 +
|
|
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
|
-
#
|
|
188
|
-
|
|
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]
|
|
314
|
+
return [0, 0] unless first
|
|
197
315
|
|
|
198
|
-
|
|
199
|
-
|
|
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
|
-
|
|
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 #
|
|
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
|