quicsilver 0.1.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 (53) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +41 -0
  3. data/.gitignore +3 -1
  4. data/CHANGELOG.md +76 -5
  5. data/Gemfile.lock +18 -4
  6. data/LICENSE +21 -0
  7. data/README.md +33 -53
  8. data/Rakefile +29 -2
  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 +46 -0
  13. data/benchmarks/rails.rb +170 -0
  14. data/benchmarks/throughput.rb +113 -0
  15. data/examples/minimal_http3_server.rb +0 -6
  16. data/examples/rack_http3_server.rb +0 -6
  17. data/examples/simple_client_test.rb +26 -0
  18. data/ext/quicsilver/quicsilver.c +615 -138
  19. data/lib/quicsilver/client/client.rb +250 -0
  20. data/lib/quicsilver/client/request.rb +98 -0
  21. data/lib/quicsilver/protocol/frames.rb +327 -0
  22. data/lib/quicsilver/protocol/qpack/decoder.rb +165 -0
  23. data/lib/quicsilver/protocol/qpack/encoder.rb +189 -0
  24. data/lib/quicsilver/protocol/qpack/header_block_decoder.rb +125 -0
  25. data/lib/quicsilver/protocol/qpack/huffman.rb +459 -0
  26. data/lib/quicsilver/protocol/request_encoder.rb +47 -0
  27. data/lib/quicsilver/protocol/request_parser.rb +387 -0
  28. data/lib/quicsilver/protocol/response_encoder.rb +72 -0
  29. data/lib/quicsilver/protocol/response_parser.rb +249 -0
  30. data/lib/quicsilver/server/listener_data.rb +14 -0
  31. data/lib/quicsilver/server/request_handler.rb +86 -0
  32. data/lib/quicsilver/server/request_registry.rb +50 -0
  33. data/lib/quicsilver/server/server.rb +336 -0
  34. data/lib/quicsilver/transport/configuration.rb +132 -0
  35. data/lib/quicsilver/transport/connection.rb +350 -0
  36. data/lib/quicsilver/transport/event_loop.rb +38 -0
  37. data/lib/quicsilver/transport/inbound_stream.rb +33 -0
  38. data/lib/quicsilver/transport/stream.rb +28 -0
  39. data/lib/quicsilver/transport/stream_event.rb +26 -0
  40. data/lib/quicsilver/version.rb +1 -1
  41. data/lib/quicsilver.rb +49 -9
  42. data/lib/rackup/handler/quicsilver.rb +77 -0
  43. data/quicsilver.gemspec +10 -3
  44. metadata +122 -17
  45. data/examples/minimal_http3_client.rb +0 -89
  46. data/lib/quicsilver/client.rb +0 -191
  47. data/lib/quicsilver/http3/request_encoder.rb +0 -112
  48. data/lib/quicsilver/http3/request_parser.rb +0 -158
  49. data/lib/quicsilver/http3/response_encoder.rb +0 -73
  50. data/lib/quicsilver/http3.rb +0 -68
  51. data/lib/quicsilver/listener_data.rb +0 -29
  52. data/lib/quicsilver/server.rb +0 -258
  53. data/lib/quicsilver/server_configuration.rb +0 -49
@@ -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,189 @@
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 =~ /[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
+ # RFC 7541 prefix integer encoding
170
+ def encode_prefixed_int(value, prefix_bits, pattern)
171
+ max_prefix = (1 << prefix_bits) - 1
172
+
173
+ if value < max_prefix
174
+ (pattern | value).chr(Encoding::BINARY)
175
+ else
176
+ buf = (pattern | max_prefix).chr(Encoding::BINARY)
177
+ value -= max_prefix
178
+ while value >= 128
179
+ buf << ((value & 0x7F) | 0x80).chr(Encoding::BINARY)
180
+ value >>= 7
181
+ end
182
+ buf << value.chr(Encoding::BINARY)
183
+ buf
184
+ end
185
+ end
186
+ end
187
+ end
188
+ end
189
+ end
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "decoder"
4
+ require_relative "huffman"
5
+
6
+ module Quicsilver
7
+ module Protocol
8
+ module Qpack
9
+ # Decodes a QPACK header block into [name, value] pairs.
10
+ #
11
+ # Default implementation uses the static table only (no dynamic table).
12
+ # To add dynamic table support, implement a class with the same #decode interface:
13
+ #
14
+ # class MyDynamicDecoder
15
+ # def decode(payload)
16
+ # # parse QPACK field lines from payload
17
+ # # yield [name, value] for each decoded header
18
+ # end
19
+ # end
20
+ #
21
+ # Then inject it:
22
+ # RequestParser.new(data, decoder: MyDynamicDecoder.new)
23
+ # ResponseParser.new(data, decoder: MyDynamicDecoder.new)
24
+ #
25
+ class HeaderBlockDecoder
26
+ include Decoder
27
+
28
+ DECODE_CACHE_MAX = 256
29
+
30
+ # Shared default instance for parsers that don't need custom decoders
31
+ def self.default
32
+ @default ||= new
33
+ end
34
+
35
+ def initialize
36
+ @decode_cache = {}
37
+ end
38
+
39
+ # Decode a QPACK header block payload (RFC 9204 §4.5).
40
+ # Yields [name, value] for each decoded field line.
41
+ def decode(payload)
42
+ return if payload.nil? || payload.bytesize < 2
43
+
44
+ # Check cache for previously decoded payloads
45
+ if payload.bytesize <= 256
46
+ cached = @decode_cache[payload]
47
+ if cached
48
+ cached.each { |name, value| yield name, value }
49
+ return
50
+ end
51
+ end
52
+
53
+ headers = []
54
+ offset = 2 # skip required insert count + delta base prefix
55
+
56
+ while offset < payload.bytesize
57
+ byte = payload.getbyte(offset)
58
+
59
+ # Indexed Field Line (1Txxxxxx) — name + value from static table
60
+ if (byte & 0x80) == 0x80
61
+ index, bytes_consumed = decode_prefix_integer_str(payload, offset, 6, 0xC0)
62
+ offset += bytes_consumed
63
+
64
+ if index < Protocol::STATIC_TABLE.size
65
+ name, value = Protocol::STATIC_TABLE[index]
66
+ headers << [name, value]
67
+ yield name, value
68
+ else
69
+ raise Protocol::FrameError.new(
70
+ "Invalid QPACK static table index #{index}",
71
+ error_code: Protocol::QPACK_DECOMPRESSION_FAILED
72
+ )
73
+ end
74
+
75
+ # Literal with Name Reference (01NTxxxx) — name from static table, literal value
76
+ elsif (byte & 0xC0) == 0x40
77
+ index, bytes_consumed = decode_prefix_integer_str(payload, offset, 4, 0xF0)
78
+ offset += bytes_consumed
79
+
80
+ if index >= Protocol::STATIC_TABLE.size
81
+ raise Protocol::FrameError.new(
82
+ "Invalid QPACK static table index #{index}",
83
+ error_code: Protocol::QPACK_DECOMPRESSION_FAILED
84
+ )
85
+ end
86
+
87
+ name = Protocol::STATIC_TABLE[index][0]
88
+ value, consumed = decode_qpack_string_from_str(payload, offset)
89
+ offset += consumed
90
+ headers << [name, value]
91
+ yield name, value
92
+
93
+ # Literal with Literal Name (001NHxxx) — both name and value are literals
94
+ elsif (byte & 0xE0) == 0x20
95
+ huffman_name = (byte & 0x08) != 0
96
+ name_len, name_len_bytes = decode_prefix_integer_str(payload, offset, 3, 0x28)
97
+ offset += name_len_bytes
98
+ raw_name = payload.byteslice(offset, name_len)
99
+ name = if huffman_name
100
+ Huffman.decode(raw_name) || raw_name
101
+ else
102
+ raw_name
103
+ end
104
+ offset += name_len
105
+
106
+ value, consumed = decode_qpack_string_from_str(payload, offset)
107
+ offset += consumed
108
+
109
+ headers << [name, value]
110
+ yield name, value
111
+ else
112
+ break
113
+ end
114
+ end
115
+
116
+ # Cache the result
117
+ if payload.bytesize <= 256 && @decode_cache.size < DECODE_CACHE_MAX
118
+ key = payload.frozen? ? payload : payload.dup.freeze
119
+ @decode_cache[key] = headers.freeze
120
+ end
121
+ end
122
+ end
123
+ end
124
+ end
125
+ end