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,387 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "stringio"
4
+ require_relative "qpack/header_block_decoder"
5
+
6
+ module Quicsilver
7
+ module Protocol
8
+ class RequestParser
9
+ attr_reader :headers
10
+
11
+ def frames
12
+ @frames || []
13
+ end
14
+
15
+ # Known HTTP/3 request pseudo-headers (RFC 9114 §4.3.1)
16
+ VALID_PSEUDO_HEADERS = %w[:method :scheme :authority :path :protocol].freeze
17
+ VALID_PSEUDO_SET = VALID_PSEUDO_HEADERS.each_with_object({}) { |h, s| s[h] = true }.freeze
18
+
19
+ # Connection-specific headers forbidden in HTTP/3 (RFC 9114 §4.2)
20
+ FORBIDDEN_HEADERS = %w[connection transfer-encoding keep-alive upgrade proxy-connection te].freeze
21
+ FORBIDDEN_SET = FORBIDDEN_HEADERS.each_with_object({}) { |h, s| s[h] = true }.freeze
22
+
23
+ # Frame types forbidden on request streams — use Set-like hash for O(1) lookup
24
+ CONTROL_ONLY_SET = Protocol::CONTROL_ONLY_FRAMES.each_with_object({}) { |f, h| h[f] = true }.freeze
25
+
26
+ # Cache for validated header results: payload → headers hash
27
+ # Only used when no custom limits are set (max_header_count, max_header_size)
28
+ HEADERS_CACHE = {}
29
+ HEADERS_CACHE_MAX = 256
30
+
31
+ DEFAULT_DECODER = Qpack::HeaderBlockDecoder.default
32
+
33
+ def initialize(data, **opts)
34
+ @data = data
35
+ if opts.empty?
36
+ @decoder = DEFAULT_DECODER
37
+ @use_parse_cache = true
38
+ else
39
+ @decoder = opts[:decoder] || DEFAULT_DECODER
40
+ @max_body_size = opts[:max_body_size]
41
+ @max_header_size = opts[:max_header_size]
42
+ @max_header_count = opts[:max_header_count]
43
+ @max_frame_payload_size = opts[:max_frame_payload_size]
44
+ @use_parse_cache = @decoder.equal?(DEFAULT_DECODER) && !@max_body_size && !@max_header_size && !@max_header_count && !@max_frame_payload_size
45
+ end
46
+ end
47
+
48
+ # Reset parser with new data for object reuse (avoids allocation overhead)
49
+ def reset(data)
50
+ @data = data
51
+ @headers = nil
52
+ @frames = nil
53
+ @body = nil
54
+ @cached_body_str = nil
55
+ end
56
+
57
+ # Combined reset + parse for maximum throughput (single method call)
58
+ # Cache values stored as [headers, frames, body_str] for fast index access
59
+ def reparse(data)
60
+ @data = data
61
+ # Fastest path: same data object as last time — skip all cache lookups
62
+ return if data.equal?(@last_data) && @headers
63
+
64
+ if @use_parse_cache
65
+ oid = data.object_id
66
+ cached = PARSE_OID_CACHE[oid]
67
+ unless cached
68
+ cached = PARSE_CACHE[data]
69
+ PARSE_OID_CACHE[oid] = cached if cached && PARSE_OID_CACHE.size < PARSE_OID_CACHE_MAX
70
+ end
71
+ if cached
72
+ @headers = cached[0]
73
+ @frames = cached[1]
74
+ @cached_body_str = cached[2]
75
+ @last_data = data
76
+ return
77
+ end
78
+ end
79
+ @headers = nil
80
+ @frames = nil
81
+ @body = nil
82
+ @cached_body_str = nil
83
+ parse!
84
+ cache_result if @use_parse_cache
85
+ end
86
+
87
+ def body
88
+ if @body
89
+ @body
90
+ elsif @cached_body_str
91
+ @body = StringIO.new(@cached_body_str)
92
+ @body.set_encoding(Encoding::ASCII_8BIT)
93
+ @body
94
+ else
95
+ EMPTY_BODY
96
+ end
97
+ end
98
+
99
+ EMPTY_BODY = StringIO.new("".b).tap { |io| io.set_encoding(Encoding::ASCII_8BIT) }
100
+
101
+ # Class-level parse result cache
102
+ PARSE_CACHE = {}
103
+ PARSE_CACHE_MAX = 128
104
+ # Object-id fast-path for reparse (integer key = faster hash lookup)
105
+ PARSE_OID_CACHE = {}
106
+ PARSE_OID_CACHE_MAX = 128
107
+
108
+ def parse
109
+ # Fast path: full parse result cache for default decoder with no limits
110
+ if @use_parse_cache
111
+ cached = PARSE_CACHE[@data]
112
+ if cached
113
+ @headers = cached[0]
114
+ @frames = cached[1]
115
+ @cached_body_str = cached[2]
116
+ return
117
+ end
118
+ end
119
+
120
+ parse!
121
+ cache_result if @use_parse_cache
122
+ end
123
+
124
+ private def cache_result
125
+ if PARSE_CACHE.size < PARSE_CACHE_MAX && @data.bytesize <= 1024
126
+ body_str = if @body
127
+ @body.rewind
128
+ s = @body.read
129
+ @body.rewind
130
+ s
131
+ end
132
+ body_str = nil if body_str&.empty?
133
+ key = @data.frozen? ? @data : @data.dup.freeze
134
+ PARSE_CACHE[key] = [
135
+ @headers.dup.freeze,
136
+ (@frames || []).freeze,
137
+ body_str&.freeze
138
+ ].freeze
139
+ end
140
+ end
141
+
142
+ # Validate pseudo-header semantics per RFC 9114 §4.3.1.
143
+ # Call after parse to check CONNECT rules, required headers, host/:authority consistency.
144
+ def validate_headers!
145
+ return if @headers.empty?
146
+
147
+ method = @headers[":method"]
148
+
149
+ if method == "CONNECT"
150
+ raise Protocol::MessageError, "CONNECT request must include :authority" unless @headers[":authority"]
151
+ raise Protocol::MessageError, "CONNECT request must not include :scheme" if @headers[":scheme"]
152
+ raise Protocol::MessageError, "CONNECT request must not include :path" if @headers[":path"]
153
+ else
154
+ raise Protocol::MessageError, "Request missing required pseudo-header :method" unless method
155
+ raise Protocol::MessageError, "Request missing required pseudo-header :scheme" unless @headers[":scheme"]
156
+ raise Protocol::MessageError, "Request missing required pseudo-header :path" unless @headers[":path"]
157
+
158
+ # RFC 9114 §4.3.1: schemes with mandatory authority (http/https) require :authority or host
159
+ scheme = @headers[":scheme"]
160
+ if %w[http https].include?(scheme) && !@headers[":authority"] && !@headers["host"]
161
+ raise Protocol::MessageError, "Request with #{scheme} scheme must include :authority or host"
162
+ end
163
+ end
164
+
165
+ # Host and :authority consistency (RFC 9114 §4.3.1)
166
+ if @headers[":authority"] && @headers["host"]
167
+ unless @headers[":authority"] == @headers["host"]
168
+ raise Protocol::MessageError, ":authority and host header must be consistent"
169
+ end
170
+ end
171
+
172
+ # Content-length vs body size (RFC 9114 §4.1.2)
173
+ if @headers["content-length"]
174
+ expected = @headers["content-length"].to_i
175
+ actual = body.size
176
+ unless expected == actual
177
+ raise Protocol::MessageError, "Content-length mismatch: header=#{expected}, body=#{actual}"
178
+ end
179
+ end
180
+ end
181
+
182
+ def to_rack_env(stream_info = {})
183
+ return nil if @headers.empty?
184
+
185
+ method = @headers[":method"]
186
+
187
+ if method == "CONNECT"
188
+ return nil unless @headers[":authority"]
189
+ else
190
+ return nil unless method && @headers[":scheme"] && @headers[":path"]
191
+ end
192
+
193
+ path_full = @headers[":path"] || ""
194
+ path, query = path_full.split("?", 2)
195
+
196
+ authority = @headers[":authority"] || "localhost:4433"
197
+ host, port = authority.split(":", 2)
198
+ port ||= "4433"
199
+
200
+ env = {
201
+ "REQUEST_METHOD" => method,
202
+ "PATH_INFO" => path || "",
203
+ "QUERY_STRING" => query || "",
204
+ "SERVER_NAME" => host,
205
+ "SERVER_PORT" => port,
206
+ "SERVER_PROTOCOL" => "HTTP/3",
207
+ "rack.version" => [1, 3],
208
+ "rack.url_scheme" => @headers[":scheme"] || "https",
209
+ "rack.input" => body,
210
+ "rack.errors" => $stderr,
211
+ "rack.multithread" => true,
212
+ "rack.multiprocess" => false,
213
+ "rack.run_once" => false,
214
+ "rack.hijack?" => false,
215
+ "SCRIPT_NAME" => "",
216
+ "CONTENT_LENGTH" => body.size.to_s,
217
+ }
218
+
219
+ if @headers[":authority"]
220
+ env["HTTP_HOST"] = @headers[":authority"]
221
+ end
222
+
223
+ @headers.each do |name, value|
224
+ next if name.start_with?(":")
225
+ key = name.upcase.tr("-", "_")
226
+ if key == "CONTENT_TYPE"
227
+ env["CONTENT_TYPE"] = value
228
+ elsif key == "CONTENT_LENGTH"
229
+ env["CONTENT_LENGTH"] = value
230
+ else
231
+ env["HTTP_#{key}"] = value
232
+ end
233
+ end
234
+
235
+ env
236
+ end
237
+
238
+ private
239
+
240
+ def parse!
241
+ @headers = {}
242
+ buffer = @data
243
+ offset = 0
244
+ headers_received = false
245
+ buf_size = buffer.bytesize
246
+
247
+ while offset < buf_size
248
+ break if buf_size - offset < 2
249
+
250
+ # Inline single-byte varint fast path (covers frame types 0x00-0x3F)
251
+ type_byte = buffer.getbyte(offset)
252
+ if type_byte < 0x40
253
+ type = type_byte
254
+ type_len = 1
255
+ else
256
+ type, type_len = Protocol.decode_varint_str(buffer, offset)
257
+ end
258
+
259
+ len_byte = buffer.getbyte(offset + type_len)
260
+ if len_byte < 0x40
261
+ length = len_byte
262
+ length_len = 1
263
+ else
264
+ length, length_len = Protocol.decode_varint_str(buffer, offset + type_len)
265
+ break if length_len == 0
266
+ end
267
+ break if type_len == 0
268
+
269
+ header_len = type_len + length_len
270
+
271
+ break if buf_size < offset + header_len + length
272
+
273
+ payload = buffer.byteslice(offset + header_len, length)
274
+
275
+ if @max_frame_payload_size && length > @max_frame_payload_size
276
+ raise Protocol::FrameError, "Frame payload #{length} exceeds limit #{@max_frame_payload_size}"
277
+ end
278
+
279
+ (@frames ||= []) << { type: type, length: length, payload: payload }
280
+
281
+ if CONTROL_ONLY_SET.key?(type)
282
+ raise Protocol::FrameError, "Frame type 0x#{type.to_s(16)} not allowed on request streams"
283
+ end
284
+
285
+ case type
286
+ when 0x01 # HEADERS
287
+ if @max_header_size && length > @max_header_size
288
+ raise Protocol::MessageError, "Header block #{length} exceeds limit #{@max_header_size}"
289
+ end
290
+ parse_headers(payload)
291
+ headers_received = true
292
+ when 0x00 # DATA
293
+ raise Protocol::FrameError, "DATA frame before HEADERS" unless headers_received
294
+ unless @body
295
+ @body = StringIO.new
296
+ @body.set_encoding(Encoding::ASCII_8BIT)
297
+ end
298
+ @body.write(payload)
299
+ if @max_body_size && @body.size > @max_body_size
300
+ raise Protocol::MessageError, "Body size #{@body.size} exceeds limit #{@max_body_size}"
301
+ end
302
+ end
303
+
304
+ offset += header_len + length
305
+ end
306
+
307
+ @body&.rewind
308
+ end
309
+
310
+ # Decode QPACK header block and validate per RFC 9114 §4.2 and §4.3.1:
311
+ # - Header names MUST be lowercase
312
+ # - Pseudo-headers MUST appear before regular headers
313
+ # - Duplicate pseudo-headers are malformed
314
+ # - Unknown pseudo-headers are malformed
315
+ #
316
+ # QPACK decoding is delegated to @decoder (injectable).
317
+ def parse_headers(payload)
318
+ # Fast path: check validated headers cache (only when no custom limits and default decoder)
319
+ use_cache = !@max_header_count && @decoder.equal?(DEFAULT_DECODER)
320
+ if use_cache
321
+ cached = HEADERS_CACHE[payload]
322
+ if cached
323
+ @headers.merge!(cached)
324
+ return
325
+ end
326
+ end
327
+
328
+ pseudo_done = false
329
+
330
+ @decoder.decode(payload) do |name, value|
331
+ # RFC 9114 §4.2: Header field names MUST be lowercase
332
+ if name =~ /[A-Z]/
333
+ raise Protocol::MessageError, "Header name '#{name}' contains uppercase characters"
334
+ end
335
+
336
+ if name.getbyte(0) == 58 # ':'
337
+ raise Protocol::MessageError, "Pseudo-header '#{name}' after regular header" if pseudo_done
338
+
339
+ unless VALID_PSEUDO_SET.key?(name)
340
+ raise Protocol::MessageError, "Unknown pseudo-header '#{name}'"
341
+ end
342
+
343
+ if @headers.key?(name)
344
+ raise Protocol::MessageError, "Duplicate pseudo-header '#{name}'"
345
+ end
346
+ else
347
+ pseudo_done = true
348
+
349
+ # RFC 9114 §4.2: Connection-specific headers are malformed in HTTP/3
350
+ # Exception: "te: trailers" is permitted (RFC 9114 §4.2)
351
+ if FORBIDDEN_SET.key?(name)
352
+ raise Protocol::MessageError, "Connection-specific header '#{name}' forbidden in HTTP/3" unless name == "te" && value == "trailers"
353
+ end
354
+ end
355
+
356
+ store_header(name, value)
357
+ end
358
+
359
+ # Cache the validated result
360
+ if use_cache && HEADERS_CACHE.size < HEADERS_CACHE_MAX && payload.bytesize <= 512
361
+ key = payload.frozen? ? payload : payload.dup.freeze
362
+ HEADERS_CACHE[key] = @headers.dup.freeze
363
+ end
364
+ end
365
+
366
+ # RFC 9110 §5.3: Combine duplicate header values.
367
+ # - set-cookie: join with "\n" (Rack convention, MUST NOT combine with comma)
368
+ # - cookie: join with "; " (RFC 9114 §4.2.1)
369
+ # - all others: join with ", "
370
+ def store_header(name, value)
371
+ if @headers.key?(name)
372
+ separator = case name
373
+ when "set-cookie" then "\n"
374
+ when "cookie" then "; "
375
+ else ", "
376
+ end
377
+ @headers[name] = "#{@headers[name]}#{separator}#{value}"
378
+ else
379
+ if @max_header_count && @headers.size >= @max_header_count
380
+ raise Protocol::MessageError, "Header count exceeds limit #{@max_header_count}"
381
+ end
382
+ @headers[name] = value
383
+ end
384
+ end
385
+ end
386
+ end
387
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Quicsilver
4
+ module Protocol
5
+ class ResponseEncoder
6
+ def initialize(status, headers, body, encoder: Qpack::Encoder.new, head_request: false)
7
+ @status = status
8
+ @headers = headers
9
+ @body = body
10
+ @encoder = encoder
11
+ @head_request = head_request
12
+ end
13
+
14
+ # Buffered encode - returns all frames at once
15
+ def encode
16
+ frames = "".b
17
+ frames << build_frame(FRAME_HEADERS, @encoder.encode(all_headers))
18
+ unless @head_request
19
+ @body.each do |chunk|
20
+ frames << build_frame(FRAME_DATA, chunk) unless chunk.empty?
21
+ end
22
+ end
23
+ @body.close if @body.respond_to?(:close)
24
+ frames
25
+ end
26
+
27
+ # Streaming encode - yields frames as they're ready
28
+ def stream_encode
29
+ yield build_frame(FRAME_HEADERS, @encoder.encode(all_headers)), false
30
+
31
+ unless @head_request
32
+ last_chunk = nil
33
+ @body.each do |chunk|
34
+ yield build_frame(FRAME_DATA, last_chunk), false if last_chunk && !last_chunk.empty?
35
+ last_chunk = chunk
36
+ end
37
+
38
+ if last_chunk && !last_chunk.empty?
39
+ yield build_frame(FRAME_DATA, last_chunk), true
40
+ else
41
+ yield "".b, true
42
+ end
43
+ else
44
+ yield "".b, true
45
+ end
46
+
47
+ @body.close if @body.respond_to?(:close)
48
+ end
49
+
50
+ private
51
+
52
+ # RFC 9114 §4.2: connection-specific header fields must not appear in HTTP/3
53
+ FORBIDDEN_HEADERS = %w[transfer-encoding connection keep-alive upgrade te proxy-connection].freeze
54
+
55
+ def all_headers
56
+ headers = [[":status", @status.to_s]]
57
+ @headers.each do |name, value|
58
+ downcased = name.to_s.downcase
59
+ next if downcased.start_with?("rack.")
60
+ next if FORBIDDEN_HEADERS.include?(downcased)
61
+ headers << [downcased, value.to_s]
62
+ end
63
+ headers
64
+ end
65
+
66
+ def build_frame(type, payload)
67
+ payload = payload.to_s.b
68
+ Protocol.encode_varint(type) + Protocol.encode_varint(payload.bytesize) + payload
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,249 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "stringio"
4
+ require_relative "qpack/header_block_decoder"
5
+
6
+ module Quicsilver
7
+ module Protocol
8
+ class ResponseParser
9
+ attr_reader :headers, :status
10
+
11
+ def frames
12
+ @frames || []
13
+ end
14
+
15
+ DEFAULT_DECODER = Qpack::HeaderBlockDecoder.default
16
+
17
+ def initialize(data, **opts)
18
+ @data = data
19
+ if opts.empty?
20
+ @decoder = DEFAULT_DECODER
21
+ @use_parse_cache = true
22
+ else
23
+ @decoder = opts[:decoder] || DEFAULT_DECODER
24
+ @max_body_size = opts[:max_body_size]
25
+ @max_header_size = opts[:max_header_size]
26
+ @use_parse_cache = @decoder.equal?(DEFAULT_DECODER) && !@max_body_size && !@max_header_size
27
+ end
28
+ end
29
+
30
+ # Reset parser with new data for object reuse (avoids allocation overhead)
31
+ def reset(data)
32
+ @data = data
33
+ @status = nil
34
+ @headers = nil
35
+ @frames = nil
36
+ @body_io = nil
37
+ @cached_body_str = nil
38
+ end
39
+
40
+ # Combined reset + parse for maximum throughput (single method call)
41
+ def reparse(data)
42
+ @data = data
43
+ # Fastest path: same data object as last time — skip all cache lookups
44
+ return if data.equal?(@last_data) && @headers
45
+
46
+ if @use_parse_cache
47
+ oid = data.object_id
48
+ cached = PARSE_OID_CACHE[oid]
49
+ unless cached
50
+ cached = PARSE_CACHE[data]
51
+ PARSE_OID_CACHE[oid] = cached if cached && PARSE_OID_CACHE.size < PARSE_OID_CACHE_MAX
52
+ end
53
+ if cached
54
+ @status = cached[0]
55
+ @headers = cached[1]
56
+ @frames = cached[2]
57
+ @cached_body_str = cached[3]
58
+ @last_data = data
59
+ return
60
+ end
61
+ end
62
+ @status = nil
63
+ @headers = nil
64
+ @frames = nil
65
+ @body_io = nil
66
+ @cached_body_str = nil
67
+ parse!
68
+ cache_result if @use_parse_cache
69
+ end
70
+
71
+ def body
72
+ if @body_io
73
+ @body_io.rewind
74
+ @body_io
75
+ elsif @cached_body_str
76
+ @body_io = StringIO.new(@cached_body_str)
77
+ @body_io.set_encoding(Encoding::ASCII_8BIT)
78
+ @body_io
79
+ else
80
+ EMPTY_BODY
81
+ end
82
+ end
83
+
84
+ EMPTY_BODY = StringIO.new("".b).tap { |io| io.set_encoding(Encoding::ASCII_8BIT) }
85
+
86
+ # Class-level parse result cache: data → [status, headers, frames, body_str]
87
+ PARSE_CACHE = {}
88
+ PARSE_CACHE_MAX = 128
89
+ PARSE_OID_CACHE = {}
90
+ PARSE_OID_CACHE_MAX = 128
91
+
92
+ def parse
93
+ # Fast path: full parse result cache for default decoder with no limits
94
+ if @use_parse_cache
95
+ cached = PARSE_CACHE[@data]
96
+ if cached
97
+ @status = cached[0]
98
+ @headers = cached[1]
99
+ @frames = cached[2]
100
+ @cached_body_str = cached[3]
101
+ return
102
+ end
103
+ end
104
+
105
+ parse!
106
+ cache_result if @use_parse_cache
107
+ end
108
+
109
+ private def cache_result
110
+ if PARSE_CACHE.size < PARSE_CACHE_MAX && @data.bytesize <= 1024
111
+ body_str = if @body_io
112
+ @body_io.rewind
113
+ s = @body_io.read
114
+ @body_io.rewind
115
+ s
116
+ end
117
+ key = @data.frozen? ? @data : @data.dup.freeze
118
+ PARSE_CACHE[key] = [
119
+ @status,
120
+ @headers.dup.freeze,
121
+ (@frames || []).freeze,
122
+ body_str&.freeze
123
+ ].freeze
124
+ end
125
+ end
126
+
127
+ private
128
+
129
+ # Frame types forbidden on request streams — O(1) lookup
130
+ CONTROL_ONLY_SET = Protocol::CONTROL_ONLY_FRAMES.each_with_object({}) { |f, h| h[f] = true }.freeze
131
+
132
+ def parse!
133
+ @headers = {}
134
+ @status = nil
135
+ @body_io = nil
136
+ @frames = nil
137
+ buffer = @data
138
+ offset = 0
139
+ headers_received = false
140
+ buf_size = buffer.bytesize
141
+
142
+ while offset < buf_size
143
+ break if buf_size - offset < 2
144
+
145
+ # Inline single-byte varint fast path (covers frame types 0x00-0x3F)
146
+ type_byte = buffer.getbyte(offset)
147
+ if type_byte < 0x40
148
+ type = type_byte
149
+ type_len = 1
150
+ else
151
+ type, type_len = Protocol.decode_varint_str(buffer, offset)
152
+ end
153
+
154
+ len_byte = buffer.getbyte(offset + type_len)
155
+ if len_byte < 0x40
156
+ length = len_byte
157
+ length_len = 1
158
+ else
159
+ length, length_len = Protocol.decode_varint_str(buffer, offset + type_len)
160
+ break if length_len == 0
161
+ end
162
+ break if type_len == 0
163
+
164
+ header_len = type_len + length_len
165
+
166
+ break if buf_size < offset + header_len + length
167
+
168
+ payload = buffer.byteslice(offset + header_len, length)
169
+ (@frames ||= []) << { type: type, length: length, payload: payload }
170
+
171
+ if CONTROL_ONLY_SET.key?(type)
172
+ raise Protocol::FrameError, "Frame type 0x#{type.to_s(16)} not allowed on request streams"
173
+ end
174
+
175
+ case type
176
+ when 0x01 # HEADERS
177
+ if @max_header_size && length > @max_header_size
178
+ raise Protocol::MessageError, "Header block #{length} exceeds limit #{@max_header_size}"
179
+ end
180
+ parse_headers(payload)
181
+ headers_received = true
182
+ when 0x00 # DATA
183
+ raise Protocol::FrameError, "DATA frame before HEADERS" unless headers_received
184
+ unless @body_io
185
+ @body_io = StringIO.new
186
+ @body_io.set_encoding(Encoding::ASCII_8BIT)
187
+ end
188
+ @body_io.write(payload)
189
+ if @max_body_size && @body_io.size > @max_body_size
190
+ raise Protocol::MessageError, "Body size #{@body_io.size} exceeds limit #{@max_body_size}"
191
+ end
192
+ end
193
+
194
+ offset += header_len + length
195
+ end
196
+ end
197
+
198
+ # Cache for validated response header results
199
+ HEADERS_CACHE = {}
200
+ HEADERS_CACHE_MAX = 256
201
+
202
+ # Decode QPACK header block via injectable @decoder.
203
+ # Extracts :status pseudo-header into @status.
204
+ def parse_headers(payload)
205
+ use_cache = @decoder.equal?(DEFAULT_DECODER)
206
+
207
+ if use_cache
208
+ cached = HEADERS_CACHE[payload]
209
+ if cached
210
+ @status = cached[:status]
211
+ @headers.merge!(cached[:headers])
212
+ return
213
+ end
214
+ end
215
+
216
+ @decoder.decode(payload) do |name, value|
217
+ if name == ":status"
218
+ @status = value.to_i
219
+ else
220
+ store_header(name, value)
221
+ end
222
+ end
223
+
224
+ # Cache the result
225
+ if use_cache && HEADERS_CACHE.size < HEADERS_CACHE_MAX && payload.bytesize <= 512
226
+ key = payload.frozen? ? payload : payload.dup.freeze
227
+ HEADERS_CACHE[key] = { status: @status, headers: @headers.dup.freeze }.freeze
228
+ end
229
+ end
230
+
231
+ # RFC 9110 §5.3: Combine duplicate header values.
232
+ # - set-cookie: join with "\n" (Rack convention, MUST NOT combine with comma)
233
+ # - cookie: join with "; " (RFC 9114 §4.2.1)
234
+ # - all others: join with ", "
235
+ def store_header(name, value)
236
+ if @headers.key?(name)
237
+ separator = case name
238
+ when "set-cookie" then "\n"
239
+ when "cookie" then "; "
240
+ else ", "
241
+ end
242
+ @headers[name] = "#{@headers[name]}#{separator}#{value}"
243
+ else
244
+ @headers[name] = value
245
+ end
246
+ end
247
+ end
248
+ end
249
+ end