quicsilver 0.5.0-x86_64-linux

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 (107) hide show
  1. checksums.yaml +7 -0
  2. data/.github/workflows/ci.yml +49 -0
  3. data/.github/workflows/cibuildgem.yaml +117 -0
  4. data/.gitignore +21 -0
  5. data/.gitmodules +3 -0
  6. data/.ruby-version +1 -0
  7. data/CHANGELOG.md +175 -0
  8. data/Gemfile +6 -0
  9. data/Gemfile.lock +67 -0
  10. data/LICENSE +21 -0
  11. data/README.md +261 -0
  12. data/Rakefile +130 -0
  13. data/benchmarks/baseline.rb +175 -0
  14. data/benchmarks/compare.rb +398 -0
  15. data/benchmarks/components.rb +191 -0
  16. data/benchmarks/helpers.rb +162 -0
  17. data/benchmarks/latency.rb +128 -0
  18. data/benchmarks/loss.rb +193 -0
  19. data/benchmarks/mobile.rb +125 -0
  20. data/bin/console +14 -0
  21. data/bin/setup +8 -0
  22. data/docs/connection_identity.md +141 -0
  23. data/examples/README.md +58 -0
  24. data/examples/benchmark.rb +191 -0
  25. data/examples/connection_pool_demo.rb +47 -0
  26. data/examples/example_helper.rb +69 -0
  27. data/examples/falcon_middleware.rb +44 -0
  28. data/examples/feature_demo.rb +125 -0
  29. data/examples/grpc_style.rb +97 -0
  30. data/examples/latency_aware_routing.rb +382 -0
  31. data/examples/lifecycle_drain.rb +264 -0
  32. data/examples/minimal_http3_server.rb +14 -0
  33. data/examples/priorities.rb +60 -0
  34. data/examples/protocol_http_server.rb +31 -0
  35. data/examples/rack_http3_server.rb +42 -0
  36. data/examples/rails_feature_test.rb +280 -0
  37. data/examples/server_stats.rb +117 -0
  38. data/examples/simple_client_test.rb +26 -0
  39. data/examples/streaming_sse.rb +33 -0
  40. data/examples/trailers.rb +69 -0
  41. data/examples/webtransport_smoke.rb +546 -0
  42. data/examples/worker_load_balancing.rb +425 -0
  43. data/ext/quicsilver/extconf.rb +124 -0
  44. data/ext/quicsilver/include/msquic.h +2226 -0
  45. data/ext/quicsilver/include/msquic_posix.h +696 -0
  46. data/ext/quicsilver/include/quic_sal_stub.h +314 -0
  47. data/ext/quicsilver/msquic_patch.rb +68 -0
  48. data/ext/quicsilver/patches/reliable-reset.patch +723 -0
  49. data/ext/quicsilver/patches/stream-final-size.patch +47 -0
  50. data/ext/quicsilver/quicsilver.c +2741 -0
  51. data/lib/quicsilver/3.4/quicsilver.so +0 -0
  52. data/lib/quicsilver/4.0/quicsilver.so +0 -0
  53. data/lib/quicsilver/client/client.rb +708 -0
  54. data/lib/quicsilver/client/connection_pool.rb +202 -0
  55. data/lib/quicsilver/client/request.rb +190 -0
  56. data/lib/quicsilver/errors.rb +39 -0
  57. data/lib/quicsilver/libmsquic.so.2 +0 -0
  58. data/lib/quicsilver/protocol/adapter.rb +196 -0
  59. data/lib/quicsilver/protocol/capsule.rb +38 -0
  60. data/lib/quicsilver/protocol/connect_stream_decoder.rb +75 -0
  61. data/lib/quicsilver/protocol/control_stream_parser.rb +117 -0
  62. data/lib/quicsilver/protocol/datagram.rb +40 -0
  63. data/lib/quicsilver/protocol/frame_parser.rb +141 -0
  64. data/lib/quicsilver/protocol/frame_reader.rb +84 -0
  65. data/lib/quicsilver/protocol/frames.rb +454 -0
  66. data/lib/quicsilver/protocol/priority.rb +65 -0
  67. data/lib/quicsilver/protocol/qpack/decoder.rb +127 -0
  68. data/lib/quicsilver/protocol/qpack/encoder.rb +217 -0
  69. data/lib/quicsilver/protocol/qpack/header_block_decoder.rb +140 -0
  70. data/lib/quicsilver/protocol/qpack/huffman.rb +459 -0
  71. data/lib/quicsilver/protocol/request_encoder.rb +50 -0
  72. data/lib/quicsilver/protocol/request_parser.rb +289 -0
  73. data/lib/quicsilver/protocol/response_encoder.rb +107 -0
  74. data/lib/quicsilver/protocol/response_parser.rb +147 -0
  75. data/lib/quicsilver/protocol/stream_input.rb +98 -0
  76. data/lib/quicsilver/protocol/stream_output.rb +59 -0
  77. data/lib/quicsilver/protocol/unidirectional_stream.rb +12 -0
  78. data/lib/quicsilver/protocol/web_transport.rb +214 -0
  79. data/lib/quicsilver/rack/context.rb +33 -0
  80. data/lib/quicsilver/response.rb +40 -0
  81. data/lib/quicsilver/server/listener_data.rb +14 -0
  82. data/lib/quicsilver/server/rack_adapter.rb +77 -0
  83. data/lib/quicsilver/server/receive_queue.rb +106 -0
  84. data/lib/quicsilver/server/request_handler.rb +149 -0
  85. data/lib/quicsilver/server/request_registry.rb +63 -0
  86. data/lib/quicsilver/server/scheduler.rb +50 -0
  87. data/lib/quicsilver/server/schedulers/fiber_scheduler.rb +79 -0
  88. data/lib/quicsilver/server/schedulers/thread_scheduler.rb +76 -0
  89. data/lib/quicsilver/server/server.rb +1080 -0
  90. data/lib/quicsilver/server/web_transport_flow_control.rb +270 -0
  91. data/lib/quicsilver/server/web_transport_manager.rb +281 -0
  92. data/lib/quicsilver/server/web_transport_registry.rb +40 -0
  93. data/lib/quicsilver/server/web_transport_session.rb +812 -0
  94. data/lib/quicsilver/server/web_transport_stream.rb +326 -0
  95. data/lib/quicsilver/transport/configuration.rb +234 -0
  96. data/lib/quicsilver/transport/connection.rb +594 -0
  97. data/lib/quicsilver/transport/connection_stats.rb +53 -0
  98. data/lib/quicsilver/transport/event_loop.rb +74 -0
  99. data/lib/quicsilver/transport/inbound_stream.rb +53 -0
  100. data/lib/quicsilver/transport/stream.rb +43 -0
  101. data/lib/quicsilver/transport/stream_event.rb +49 -0
  102. data/lib/quicsilver/transport/stream_id.rb +26 -0
  103. data/lib/quicsilver/version.rb +3 -0
  104. data/lib/quicsilver.rb +96 -0
  105. data/lib/rackup/handler/quicsilver.rb +96 -0
  106. data/quicsilver.gemspec +66 -0
  107. metadata +362 -0
@@ -0,0 +1,289 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "frame_parser"
4
+ require_relative "priority"
5
+
6
+ module Quicsilver
7
+ module Protocol
8
+ class RequestParser < FrameParser
9
+
10
+ # The parsed priority from the `priority` header (RFC 9218).
11
+ # Returns a Priority with defaults if no header present.
12
+ def priority
13
+ @priority ||= Priority.parse(@headers["priority"])
14
+ end
15
+
16
+ METHOD_CONNECT = "CONNECT"
17
+
18
+ # Known HTTP/3 request pseudo-headers (RFC 9114 §4.3.1)
19
+ VALID_PSEUDO_HEADERS = %w[:method :scheme :authority :path :protocol].freeze
20
+ VALID_PSEUDO_SET = VALID_PSEUDO_HEADERS.each_with_object({}) { |h, s| s[h] = true }.freeze
21
+
22
+ # Connection-specific headers forbidden in HTTP/3 (RFC 9114 §4.2)
23
+ FORBIDDEN_HEADERS = %w[connection transfer-encoding keep-alive upgrade proxy-connection te].freeze
24
+ FORBIDDEN_SET = FORBIDDEN_HEADERS.each_with_object({}) { |h, s| s[h] = 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
+ def initialize(data, **opts)
32
+ decoder = opts.delete(:decoder) || DEFAULT_DECODER
33
+ super(decoder: decoder, max_body_size: opts[:max_body_size],
34
+ max_header_size: opts[:max_header_size],
35
+ max_header_count: opts[:max_header_count],
36
+ max_frame_payload_size: opts[:max_frame_payload_size])
37
+ @data = data
38
+ @use_parse_cache = @decoder.equal?(DEFAULT_DECODER) && !@max_body_size && !@max_header_size && !@max_header_count && !@max_frame_payload_size
39
+ end
40
+
41
+ # Reset parser with new data for object reuse (avoids allocation overhead)
42
+ def reset(data)
43
+ @data = data
44
+ @headers = {}
45
+ @trailers = {}
46
+ @frames = nil
47
+ @body = nil
48
+ @cached_body_str = nil
49
+ end
50
+
51
+ # Combined reset + parse for maximum throughput (single method call)
52
+ # Cache values stored as [headers, frames, body_str] for fast index access
53
+ def reparse(data)
54
+ @data = data
55
+ # Fastest path: same data object as last time — skip all cache lookups
56
+ return if data.equal?(@last_data) && @headers
57
+
58
+ if @use_parse_cache
59
+ oid = data.object_id
60
+ cached = PARSE_OID_CACHE[oid]
61
+ unless cached
62
+ cached = PARSE_CACHE[data]
63
+ PARSE_OID_CACHE[oid] = cached if cached && PARSE_OID_CACHE.size < PARSE_OID_CACHE_MAX
64
+ end
65
+ if cached
66
+ @headers = cached[0]
67
+ @frames = cached[1]
68
+ @cached_body_str = cached[2]
69
+ @last_data = data
70
+ return
71
+ end
72
+ end
73
+ @headers = {}
74
+ @trailers = {}
75
+ @frames = nil
76
+ @body = nil
77
+ @cached_body_str = nil
78
+ parse!
79
+ cache_result if @use_parse_cache
80
+ end
81
+
82
+ # Class-level parse result cache
83
+ PARSE_CACHE = {}
84
+ PARSE_CACHE_MAX = 128
85
+ # Object-id fast-path for reparse (integer key = faster hash lookup)
86
+ PARSE_OID_CACHE = {}
87
+ PARSE_OID_CACHE_MAX = 128
88
+
89
+ def parse
90
+ # Fast path: full parse result cache for default decoder with no limits
91
+ if @use_parse_cache
92
+ cached = PARSE_CACHE[@data]
93
+ if cached
94
+ @headers = cached[0]
95
+ @frames = cached[1]
96
+ @cached_body_str = cached[2]
97
+ return
98
+ end
99
+ end
100
+
101
+ parse!
102
+ cache_result if @use_parse_cache
103
+ end
104
+
105
+ private def cache_result
106
+ if PARSE_CACHE.size < PARSE_CACHE_MAX && @data.bytesize <= 1024
107
+ body_str = if @body
108
+ @body.rewind
109
+ s = @body.read
110
+ @body.rewind
111
+ s
112
+ end
113
+ body_str = nil if body_str&.empty?
114
+ key = @data.frozen? ? @data : @data.dup.freeze
115
+ PARSE_CACHE[key] = [
116
+ @headers.dup.freeze,
117
+ (@frames || []).freeze,
118
+ body_str&.freeze
119
+ ].freeze
120
+ end
121
+ end
122
+
123
+ # Validate pseudo-header semantics per RFC 9114 §4.3.1.
124
+ # Call after parse to check CONNECT rules, required headers, host/:authority consistency.
125
+ def validate_headers!
126
+ return if @headers.empty?
127
+
128
+ method = @headers[":method"]
129
+
130
+ if method == METHOD_CONNECT && @headers[":protocol"]
131
+ # Extended CONNECT (RFC 9220) — requires :scheme, :path, :authority, :protocol
132
+ raise Protocol::MessageError, "Extended CONNECT must include :scheme" unless @headers[":scheme"]
133
+ raise Protocol::MessageError, "Extended CONNECT must include :path" unless @headers[":path"]
134
+ raise Protocol::MessageError, "Extended CONNECT must include :authority" unless @headers[":authority"]
135
+ elsif method == METHOD_CONNECT
136
+ # Regular CONNECT (RFC 9114 §4.4)
137
+ raise Protocol::MessageError, "CONNECT request must include :authority" unless @headers[":authority"]
138
+ raise Protocol::MessageError, "CONNECT request must not include :scheme" if @headers[":scheme"]
139
+ raise Protocol::MessageError, "CONNECT request must not include :path" if @headers[":path"]
140
+ else
141
+ raise Protocol::MessageError, "Request missing required pseudo-header :method" unless method
142
+ raise Protocol::MessageError, "Request missing required pseudo-header :scheme" unless @headers[":scheme"]
143
+ raise Protocol::MessageError, "Request missing required pseudo-header :path" unless @headers[":path"]
144
+
145
+ # RFC 9114 §4.3.1: :path MUST NOT be empty for http or https URIs
146
+ scheme = @headers[":scheme"]
147
+ if %w[http https].include?(scheme) && @headers[":path"].empty?
148
+ raise Protocol::MessageError, ":path must not be empty for #{scheme} requests"
149
+ end
150
+
151
+ # RFC 9114 §4.3.1: schemes with mandatory authority (http/https) require :authority or host
152
+ if %w[http https].include?(scheme) && !@headers[":authority"] && !@headers["host"]
153
+ raise Protocol::MessageError, "Request with #{scheme} scheme must include :authority or host"
154
+ end
155
+ end
156
+
157
+ # Host and :authority consistency (RFC 9114 §4.3.1)
158
+ if @headers[":authority"] && @headers["host"]
159
+ unless @headers[":authority"] == @headers["host"]
160
+ raise Protocol::MessageError, ":authority and host header must be consistent"
161
+ end
162
+ end
163
+ end
164
+
165
+ def to_rack_env(stream_info = {})
166
+ return nil if @headers.empty?
167
+
168
+ method = @headers[":method"]
169
+
170
+ if method == METHOD_CONNECT && @headers[":protocol"]
171
+ # Extended CONNECT (RFC 9220) — has :scheme, :path, :authority, :protocol
172
+ return nil unless @headers[":scheme"] && @headers[":path"] && @headers[":authority"]
173
+ elsif method == METHOD_CONNECT
174
+ # Regular CONNECT (RFC 9114 §4.4)
175
+ return nil unless @headers[":authority"]
176
+ else
177
+ return nil unless method && @headers[":scheme"] && @headers[":path"]
178
+ end
179
+
180
+ path_full = @headers[":path"] || ""
181
+ path, query = path_full.split("?", 2)
182
+
183
+ authority = @headers[":authority"] || "localhost:4433"
184
+ host, port = authority.split(":", 2)
185
+ port ||= "4433"
186
+
187
+ env = {
188
+ "REQUEST_METHOD" => method,
189
+ "PATH_INFO" => path || "",
190
+ "QUERY_STRING" => query || "",
191
+ "SERVER_NAME" => host,
192
+ "SERVER_PORT" => port,
193
+ "SERVER_PROTOCOL" => "HTTP/3",
194
+ "rack.version" => [1, 3],
195
+ "rack.url_scheme" => @headers[":scheme"] || "https",
196
+ "rack.input" => body,
197
+ "rack.errors" => $stderr,
198
+ "rack.multithread" => true,
199
+ "rack.multiprocess" => false,
200
+ "rack.run_once" => false,
201
+ "rack.hijack?" => false,
202
+ "SCRIPT_NAME" => "",
203
+ "CONTENT_LENGTH" => body.size.to_s,
204
+ }
205
+
206
+ if @headers[":authority"]
207
+ env["HTTP_HOST"] = @headers[":authority"]
208
+ end
209
+
210
+ # Extended CONNECT (RFC 9220) — expose :protocol as rack.protocol
211
+ if @headers[":protocol"]
212
+ env["rack.protocol"] = @headers[":protocol"]
213
+ end
214
+
215
+ @headers.each do |name, value|
216
+ next if name.start_with?(":")
217
+ key = name.upcase.tr("-", "_")
218
+ if key == "CONTENT_TYPE"
219
+ env["CONTENT_TYPE"] = value
220
+ elsif key == "CONTENT_LENGTH"
221
+ env["CONTENT_LENGTH"] = value
222
+ else
223
+ env["HTTP_#{key}"] = value
224
+ end
225
+ end
226
+
227
+ env
228
+ end
229
+
230
+ private
231
+
232
+ # Decode QPACK header block and validate per RFC 9114 §4.2 and §4.3.1:
233
+ # - Header names MUST be lowercase
234
+ # - Pseudo-headers MUST appear before regular headers
235
+ # - Duplicate pseudo-headers are malformed
236
+ # - Unknown pseudo-headers are malformed
237
+ #
238
+ # QPACK decoding is delegated to @decoder (injectable).
239
+ def parse_headers(payload)
240
+ # Fast path: check validated headers cache (only when no custom limits and default decoder)
241
+ use_cache = !@max_header_count && @decoder.equal?(DEFAULT_DECODER)
242
+ if use_cache
243
+ cached = HEADERS_CACHE[payload]
244
+ if cached
245
+ @headers.merge!(cached)
246
+ return
247
+ end
248
+ end
249
+
250
+ pseudo_done = false
251
+
252
+ @decoder.decode(payload) do |name, value|
253
+ # RFC 9114 §4.2: Header field names MUST be lowercase
254
+ if name.match?(/[A-Z]/)
255
+ raise Protocol::MessageError, "Header name '#{name}' contains uppercase characters"
256
+ end
257
+
258
+ if name.getbyte(0) == 58 # ':'
259
+ raise Protocol::MessageError, "Pseudo-header '#{name}' after regular header" if pseudo_done
260
+
261
+ unless VALID_PSEUDO_SET.key?(name)
262
+ raise Protocol::MessageError, "Unknown pseudo-header '#{name}'"
263
+ end
264
+
265
+ if @headers.key?(name)
266
+ raise Protocol::MessageError, "Duplicate pseudo-header '#{name}'"
267
+ end
268
+ else
269
+ pseudo_done = true
270
+
271
+ # RFC 9114 §4.2: Connection-specific headers are malformed in HTTP/3
272
+ # Exception: "te: trailers" is permitted (RFC 9114 §4.2)
273
+ if FORBIDDEN_SET.key?(name)
274
+ raise Protocol::MessageError, "Connection-specific header '#{name}' forbidden in HTTP/3" unless name == "te" && value == "trailers"
275
+ end
276
+ end
277
+
278
+ store_header(name, value)
279
+ end
280
+
281
+ # Cache the validated result
282
+ if use_cache && HEADERS_CACHE.size < HEADERS_CACHE_MAX && payload.bytesize <= 512
283
+ key = payload.frozen? ? payload : payload.dup.freeze
284
+ HEADERS_CACHE[key] = @headers.dup.freeze
285
+ end
286
+ end
287
+ end
288
+ end
289
+ end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Quicsilver
4
+ module Protocol
5
+ class ResponseEncoder
6
+ # Encode an informational (1xx) response as a single HEADERS frame.
7
+ # RFC 9114 §4.1: informational responses are encoded as HEADERS with no body.
8
+ def self.encode_informational(status, headers, encoder: Qpack::Encoder.new)
9
+ raise ArgumentError, "Informational status must be 1xx, got #{status}" unless (100..199).include?(status)
10
+
11
+ pairs = [[":status", status.to_s]]
12
+ headers.each { |name, value| pairs << [name.to_s.downcase, value.to_s] }
13
+ Protocol.build_headers_frame(pairs, encoder: encoder)
14
+ end
15
+
16
+ def initialize(status, headers, body, encoder: Qpack::Encoder.new, head_request: false, trailers: nil)
17
+ @status = status
18
+ @headers = headers
19
+ @body = body
20
+ @encoder = encoder
21
+ @head_request = head_request
22
+ @trailers = trailers
23
+ end
24
+
25
+ # Buffered encode - returns all frames at once
26
+ def encode
27
+ frames = "".b
28
+ frames << build_frame(FRAME_HEADERS, @encoder.encode(all_headers))
29
+ unless @head_request
30
+ @body.each do |chunk|
31
+ frames << build_frame(FRAME_DATA, chunk) unless chunk.empty?
32
+ end
33
+ end
34
+ frames << build_frame(FRAME_HEADERS, @encoder.encode(trailer_headers)) if @trailers&.any?
35
+ @body.close if @body.respond_to?(:close)
36
+ # Frozen so send_stream can hand MsQuic the bytes without a copy.
37
+ frames.freeze
38
+ end
39
+
40
+ # Streaming encode - yields frames as they're ready
41
+ def stream_encode
42
+ yield build_frame(FRAME_HEADERS, @encoder.encode(all_headers)), false
43
+
44
+ unless @head_request
45
+ last_chunk = nil
46
+ @body.each do |chunk|
47
+ yield build_frame(FRAME_DATA, last_chunk), false if last_chunk && !last_chunk.empty?
48
+ last_chunk = chunk
49
+ end
50
+
51
+ if @trailers&.any?
52
+ yield build_frame(FRAME_DATA, last_chunk), false if last_chunk && !last_chunk.empty?
53
+ yield build_frame(FRAME_HEADERS, @encoder.encode(trailer_headers)), true
54
+ elsif last_chunk && !last_chunk.empty?
55
+ yield build_frame(FRAME_DATA, last_chunk), true
56
+ else
57
+ yield "".b, true
58
+ end
59
+ else
60
+ yield "".b, true
61
+ end
62
+
63
+ @body.close if @body.respond_to?(:close)
64
+ end
65
+
66
+ private
67
+
68
+ # RFC 9114 §4.2: connection-specific header fields must not appear in HTTP/3
69
+ FORBIDDEN_HEADERS = %w[transfer-encoding connection keep-alive upgrade te proxy-connection].freeze
70
+
71
+ def all_headers
72
+ headers = [[":status", @status.to_s]]
73
+ has_content_length = false
74
+ @headers.each do |name, value|
75
+ downcased = name.to_s.downcase
76
+ next if downcased.start_with?("rack.")
77
+ next if FORBIDDEN_HEADERS.include?(downcased)
78
+ has_content_length = true if downcased == "content-length"
79
+ headers << [downcased, value.to_s]
80
+ end
81
+
82
+ # RFC 9110 §8.6: Auto-set content-length for known-size bodies when not already present.
83
+ # Skip for 1xx, 204, 304 (no body) and HEAD responses.
84
+ if !has_content_length && !@head_request && @status >= 200 && @status != 204 && @status != 304
85
+ if @body.respond_to?(:to_ary)
86
+ length = @body.to_ary.sum { |chunk| chunk.to_s.bytesize }
87
+ headers << ["content-length", length.to_s] if length > 0
88
+ elsif @body.respond_to?(:length) && @body.length
89
+ headers << ["content-length", @body.length.to_s]
90
+ end
91
+ end
92
+
93
+ headers
94
+ end
95
+
96
+ def trailer_headers
97
+ @trailers.map { |name, value| [name.to_s.downcase, value.to_s] }
98
+ end
99
+
100
+ def build_frame(type, payload)
101
+ Protocol.build_frame(type, payload)
102
+ end
103
+
104
+
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,147 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "frame_parser"
4
+
5
+ module Quicsilver
6
+ module Protocol
7
+ class ResponseParser < FrameParser
8
+ attr_reader :status
9
+
10
+ # Validate response semantics per RFC 9114 §4.3.2.
11
+ # Call after parse to check :status is present.
12
+ def validate_response!
13
+ raise Protocol::MessageError, "Response missing required :status pseudo-header" unless @status
14
+ end
15
+
16
+ def initialize(data, **opts)
17
+ decoder = opts.delete(:decoder) || DEFAULT_DECODER
18
+ super(decoder: decoder, max_body_size: opts[:max_body_size],
19
+ max_header_size: opts[:max_header_size])
20
+ @data = data
21
+ @use_parse_cache = @decoder.equal?(DEFAULT_DECODER) && !@max_body_size && !@max_header_size
22
+ end
23
+
24
+ # Reset parser with new data for object reuse (avoids allocation overhead)
25
+ def reset(data)
26
+ @data = data
27
+ @status = nil
28
+ @headers = {}
29
+ @trailers = {}
30
+ @frames = nil
31
+ @body = nil
32
+ @cached_body_str = nil
33
+ end
34
+
35
+ # Combined reset + parse for maximum throughput (single method call)
36
+ def reparse(data)
37
+ @data = data
38
+ # Fastest path: same data object as last time — skip all cache lookups
39
+ return if data.equal?(@last_data) && @headers
40
+
41
+ if @use_parse_cache
42
+ oid = data.object_id
43
+ cached = PARSE_OID_CACHE[oid]
44
+ unless cached
45
+ cached = PARSE_CACHE[data]
46
+ PARSE_OID_CACHE[oid] = cached if cached && PARSE_OID_CACHE.size < PARSE_OID_CACHE_MAX
47
+ end
48
+ if cached
49
+ @status = cached[0]
50
+ @headers = cached[1]
51
+ @frames = cached[2]
52
+ @cached_body_str = cached[3]
53
+ @trailers = cached[4] || {}
54
+ @last_data = data
55
+ return
56
+ end
57
+ end
58
+ @status = nil
59
+ @headers = {}
60
+ @trailers = {}
61
+ @frames = nil
62
+ @body = nil
63
+ @cached_body_str = nil
64
+ parse!
65
+ cache_result if @use_parse_cache
66
+ end
67
+
68
+ # Class-level parse result cache: data → [status, headers, frames, body_str, trailers]
69
+ PARSE_CACHE = {}
70
+ PARSE_CACHE_MAX = 128
71
+ PARSE_OID_CACHE = {}
72
+ PARSE_OID_CACHE_MAX = 128
73
+
74
+ def parse
75
+ # Fast path: full parse result cache for default decoder with no limits
76
+ if @use_parse_cache
77
+ cached = PARSE_CACHE[@data]
78
+ if cached
79
+ @status = cached[0]
80
+ @headers = cached[1]
81
+ @frames = cached[2]
82
+ @cached_body_str = cached[3]
83
+ @trailers = cached[4] || {}
84
+ return
85
+ end
86
+ end
87
+
88
+ parse!
89
+ cache_result if @use_parse_cache
90
+ end
91
+
92
+ private def cache_result
93
+ if PARSE_CACHE.size < PARSE_CACHE_MAX && @data.bytesize <= 1024
94
+ body_str = if @body
95
+ @body.rewind
96
+ s = @body.read
97
+ @body.rewind
98
+ s
99
+ end
100
+ key = @data.frozen? ? @data : @data.dup.freeze
101
+ PARSE_CACHE[key] = [
102
+ @status,
103
+ @headers.dup.freeze,
104
+ (@frames || []).freeze,
105
+ body_str&.freeze,
106
+ (@trailers.empty? ? nil : @trailers.dup.freeze)
107
+ ].freeze
108
+ end
109
+ end
110
+
111
+ private
112
+
113
+ # Cache for validated response header results
114
+ HEADERS_CACHE = {}
115
+ HEADERS_CACHE_MAX = 256
116
+
117
+ # Decode QPACK header block via injectable @decoder.
118
+ # Extracts :status pseudo-header into @status.
119
+ def parse_headers(payload)
120
+ use_cache = @decoder.equal?(DEFAULT_DECODER)
121
+
122
+ if use_cache
123
+ cached = HEADERS_CACHE[payload]
124
+ if cached
125
+ @status = cached[:status]
126
+ @headers.merge!(cached[:headers])
127
+ return
128
+ end
129
+ end
130
+
131
+ @decoder.decode(payload) do |name, value|
132
+ if name == ":status"
133
+ @status = value.to_i
134
+ else
135
+ store_header(name, value)
136
+ end
137
+ end
138
+
139
+ # Cache the result
140
+ if use_cache && HEADERS_CACHE.size < HEADERS_CACHE_MAX && payload.bytesize <= 512
141
+ key = payload.frozen? ? payload : payload.dup.freeze
142
+ HEADERS_CACHE[key] = { status: @status, headers: @headers.dup.freeze }.freeze
143
+ end
144
+ end
145
+ end
146
+ end
147
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "protocol/http/body/writable"
4
+
5
+ module Quicsilver
6
+ module Protocol
7
+ # A streaming request body backed by Protocol::HTTP::Body::Writable.
8
+ #
9
+ # QUIC RECEIVE events push chunks via {write}, while the application
10
+ # reads them via {read}. This enables concurrent streaming — the app
11
+ # can start processing before the full body arrives.
12
+ #
13
+ # Follows the protocol-http Body::Writable contract:
14
+ # - write(chunk) — called by the QUIC transport on RECEIVE events
15
+ # - close_write — called on RECEIVE_FIN to signal end of body
16
+ # - read — called by the application (blocks until data available)
17
+ # - close — called to abort (e.g., stream reset)
18
+ #
19
+ # Optional features:
20
+ # - Back-pressure via Thread::SizedQueue (bounded buffer)
21
+ # - Read timeout for slow client protection
22
+ #
23
+ class StreamInput < ::Protocol::HTTP::Body::Writable
24
+ class ReadTimeout < Quicsilver::Error; end
25
+
26
+ # @param length [Integer, nil] The content-length if known from headers.
27
+ # @param queue_size [Integer, nil] Maximum buffered chunks for back-pressure.
28
+ # nil (default) = unbounded. When bounded, write blocks if queue is full.
29
+ # A bounded queue must not block the MsQuic poll thread.
30
+ # @param read_timeout [Numeric, nil] Seconds to wait for data before raising
31
+ # ReadTimeout. nil (default) = wait forever.
32
+ def initialize(length = nil, queue_size: nil, read_timeout: nil)
33
+ queue = if queue_size
34
+ Thread::SizedQueue.new(queue_size)
35
+ else
36
+ Thread::Queue.new
37
+ end
38
+
39
+ super(length, queue: queue)
40
+ @read_timeout = read_timeout
41
+ @bytes_written = 0
42
+ end
43
+
44
+ # @attribute [Numeric, nil] Read timeout in seconds.
45
+ attr_reader :read_timeout
46
+
47
+ # Track bytes written for content-length validation.
48
+ def write(chunk)
49
+ @bytes_written += chunk.bytesize
50
+ super
51
+ end
52
+
53
+ # Signal that no more data will be written.
54
+ # Validates content-length if declared (RFC 9114 §4.1.2) — raises
55
+ # MessageError if total bytes written don't match.
56
+ def close_write(error = nil)
57
+ if @length && @bytes_written != @length
58
+ raise Protocol::MessageError, "Content-length mismatch: header=#{@length}, body=#{@bytes_written}"
59
+ end
60
+ super
61
+ end
62
+
63
+ # Read the next available chunk, with optional timeout.
64
+ #
65
+ # @returns [String | Nil] The next chunk, or nil if the body is finished.
66
+ # @raises [ReadTimeout] If no data arrives within the timeout.
67
+ # @raises [Exception] If the body was closed due to an error.
68
+ def read
69
+ if @read_timeout
70
+ read_with_timeout
71
+ else
72
+ super
73
+ end
74
+ end
75
+
76
+ private
77
+
78
+ def read_with_timeout
79
+ raise @error if @error
80
+
81
+ # Thread::Queue#pop(timeout:) blocks efficiently (no busy-wait).
82
+ # Returns nil on timeout OR on closed empty queue — disambiguate below.
83
+ chunk = @queue.pop(timeout: @read_timeout)
84
+
85
+ raise @error if @error
86
+
87
+ # nil means either timeout or body finished (queue closed + empty).
88
+ # If queue is closed, the body is done — return nil (EOF).
89
+ # If queue is still open, we timed out waiting for data.
90
+ if chunk.nil? && !@queue.closed?
91
+ raise ReadTimeout, "No data received within #{@read_timeout}s"
92
+ end
93
+
94
+ chunk
95
+ end
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+
4
+ module Quicsilver
5
+ module Protocol
6
+ # Reads from a Protocol::HTTP::Body::Readable response body and writes
7
+ # HTTP/3 DATA frames to the transport.
8
+ #
9
+ # Transport-agnostic: takes a writer block/callable that handles the actual
10
+ # sending. Works with msquic, kernel QUIC sockets, or any transport that
11
+ # can send bytes with a FIN flag.
12
+ #
13
+ class StreamOutput
14
+ # @param body [Protocol::HTTP::Body::Readable] The response body to stream.
15
+ # @param writer [#call] A callable that accepts (data, fin) — sends bytes
16
+ # to the transport. `fin: true` signals end of stream.
17
+ def initialize(body, &writer)
18
+ @body = body
19
+ @writer = writer
20
+ end
21
+
22
+ # Stream all chunks from the response body as HTTP/3 DATA frames.
23
+ #
24
+ # Each chunk is wrapped in an HTTP/3 DATA frame (type 0x00) and sent
25
+ # via the writer. The final chunk is sent with fin=true.
26
+ #
27
+ # @param send_fin [Boolean] Whether to send FIN after the last chunk.
28
+ # Set to false when trailers will follow.
29
+ # @return [void]
30
+ def stream(send_fin: true)
31
+ last_chunk = nil
32
+
33
+ while (chunk = @body.read)
34
+ if last_chunk
35
+ @writer.call(build_data_frame(last_chunk), false)
36
+ end
37
+ last_chunk = chunk
38
+ end
39
+
40
+ if last_chunk
41
+ @writer.call(build_data_frame(last_chunk), send_fin)
42
+ elsif send_fin
43
+ @writer.call("".b, true)
44
+ end
45
+ ensure
46
+ @body.close if @body.respond_to?(:close)
47
+ end
48
+
49
+ private
50
+
51
+ def build_data_frame(payload)
52
+ payload = payload.b
53
+ Quicsilver::Protocol.encode_varint(Quicsilver::Protocol::FRAME_DATA) +
54
+ Quicsilver::Protocol.encode_varint(payload.bytesize) +
55
+ payload
56
+ end
57
+ end
58
+ end
59
+ end