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
|
@@ -0,0 +1,275 @@
|
|
|
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: schemes with mandatory authority (http/https) require :authority or host
|
|
146
|
+
scheme = @headers[":scheme"]
|
|
147
|
+
if %w[http https].include?(scheme) && !@headers[":authority"] && !@headers["host"]
|
|
148
|
+
raise Protocol::MessageError, "Request with #{scheme} scheme must include :authority or host"
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Host and :authority consistency (RFC 9114 §4.3.1)
|
|
153
|
+
if @headers[":authority"] && @headers["host"]
|
|
154
|
+
unless @headers[":authority"] == @headers["host"]
|
|
155
|
+
raise Protocol::MessageError, ":authority and host header must be consistent"
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def to_rack_env(stream_info = {})
|
|
161
|
+
return nil if @headers.empty?
|
|
162
|
+
|
|
163
|
+
method = @headers[":method"]
|
|
164
|
+
|
|
165
|
+
if method == METHOD_CONNECT
|
|
166
|
+
return nil unless @headers[":authority"]
|
|
167
|
+
else
|
|
168
|
+
return nil unless method && @headers[":scheme"] && @headers[":path"]
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
path_full = @headers[":path"] || ""
|
|
172
|
+
path, query = path_full.split("?", 2)
|
|
173
|
+
|
|
174
|
+
authority = @headers[":authority"] || "localhost:4433"
|
|
175
|
+
host, port = authority.split(":", 2)
|
|
176
|
+
port ||= "4433"
|
|
177
|
+
|
|
178
|
+
env = {
|
|
179
|
+
"REQUEST_METHOD" => method,
|
|
180
|
+
"PATH_INFO" => path || "",
|
|
181
|
+
"QUERY_STRING" => query || "",
|
|
182
|
+
"SERVER_NAME" => host,
|
|
183
|
+
"SERVER_PORT" => port,
|
|
184
|
+
"SERVER_PROTOCOL" => "HTTP/3",
|
|
185
|
+
"rack.version" => [1, 3],
|
|
186
|
+
"rack.url_scheme" => @headers[":scheme"] || "https",
|
|
187
|
+
"rack.input" => body,
|
|
188
|
+
"rack.errors" => $stderr,
|
|
189
|
+
"rack.multithread" => true,
|
|
190
|
+
"rack.multiprocess" => false,
|
|
191
|
+
"rack.run_once" => false,
|
|
192
|
+
"rack.hijack?" => false,
|
|
193
|
+
"SCRIPT_NAME" => "",
|
|
194
|
+
"CONTENT_LENGTH" => body.size.to_s,
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if @headers[":authority"]
|
|
198
|
+
env["HTTP_HOST"] = @headers[":authority"]
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
@headers.each do |name, value|
|
|
202
|
+
next if name.start_with?(":")
|
|
203
|
+
key = name.upcase.tr("-", "_")
|
|
204
|
+
if key == "CONTENT_TYPE"
|
|
205
|
+
env["CONTENT_TYPE"] = value
|
|
206
|
+
elsif key == "CONTENT_LENGTH"
|
|
207
|
+
env["CONTENT_LENGTH"] = value
|
|
208
|
+
else
|
|
209
|
+
env["HTTP_#{key}"] = value
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
env
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
private
|
|
217
|
+
|
|
218
|
+
# Decode QPACK header block and validate per RFC 9114 §4.2 and §4.3.1:
|
|
219
|
+
# - Header names MUST be lowercase
|
|
220
|
+
# - Pseudo-headers MUST appear before regular headers
|
|
221
|
+
# - Duplicate pseudo-headers are malformed
|
|
222
|
+
# - Unknown pseudo-headers are malformed
|
|
223
|
+
#
|
|
224
|
+
# QPACK decoding is delegated to @decoder (injectable).
|
|
225
|
+
def parse_headers(payload)
|
|
226
|
+
# Fast path: check validated headers cache (only when no custom limits and default decoder)
|
|
227
|
+
use_cache = !@max_header_count && @decoder.equal?(DEFAULT_DECODER)
|
|
228
|
+
if use_cache
|
|
229
|
+
cached = HEADERS_CACHE[payload]
|
|
230
|
+
if cached
|
|
231
|
+
@headers.merge!(cached)
|
|
232
|
+
return
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
pseudo_done = false
|
|
237
|
+
|
|
238
|
+
@decoder.decode(payload) do |name, value|
|
|
239
|
+
# RFC 9114 §4.2: Header field names MUST be lowercase
|
|
240
|
+
if name.match?(/[A-Z]/)
|
|
241
|
+
raise Protocol::MessageError, "Header name '#{name}' contains uppercase characters"
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
if name.getbyte(0) == 58 # ':'
|
|
245
|
+
raise Protocol::MessageError, "Pseudo-header '#{name}' after regular header" if pseudo_done
|
|
246
|
+
|
|
247
|
+
unless VALID_PSEUDO_SET.key?(name)
|
|
248
|
+
raise Protocol::MessageError, "Unknown pseudo-header '#{name}'"
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
if @headers.key?(name)
|
|
252
|
+
raise Protocol::MessageError, "Duplicate pseudo-header '#{name}'"
|
|
253
|
+
end
|
|
254
|
+
else
|
|
255
|
+
pseudo_done = true
|
|
256
|
+
|
|
257
|
+
# RFC 9114 §4.2: Connection-specific headers are malformed in HTTP/3
|
|
258
|
+
# Exception: "te: trailers" is permitted (RFC 9114 §4.2)
|
|
259
|
+
if FORBIDDEN_SET.key?(name)
|
|
260
|
+
raise Protocol::MessageError, "Connection-specific header '#{name}' forbidden in HTTP/3" unless name == "te" && value == "trailers"
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
store_header(name, value)
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
# Cache the validated result
|
|
268
|
+
if use_cache && HEADERS_CACHE.size < HEADERS_CACHE_MAX && payload.bytesize <= 512
|
|
269
|
+
key = payload.frozen? ? payload : payload.dup.freeze
|
|
270
|
+
HEADERS_CACHE[key] = @headers.dup.freeze
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
end
|
|
@@ -0,0 +1,97 @@
|
|
|
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
|
+
encoded = encoder.encode(pairs)
|
|
14
|
+
|
|
15
|
+
Protocol.encode_varint(FRAME_HEADERS) +
|
|
16
|
+
Protocol.encode_varint(encoded.bytesize) +
|
|
17
|
+
encoded
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def initialize(status, headers, body, encoder: Qpack::Encoder.new, head_request: false, trailers: nil)
|
|
21
|
+
@status = status
|
|
22
|
+
@headers = headers
|
|
23
|
+
@body = body
|
|
24
|
+
@encoder = encoder
|
|
25
|
+
@head_request = head_request
|
|
26
|
+
@trailers = trailers
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Buffered encode - returns all frames at once
|
|
30
|
+
def encode
|
|
31
|
+
frames = "".b
|
|
32
|
+
frames << build_frame(FRAME_HEADERS, @encoder.encode(all_headers))
|
|
33
|
+
unless @head_request
|
|
34
|
+
@body.each do |chunk|
|
|
35
|
+
frames << build_frame(FRAME_DATA, chunk) unless chunk.empty?
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
frames << build_frame(FRAME_HEADERS, @encoder.encode(trailer_headers)) if @trailers&.any?
|
|
39
|
+
@body.close if @body.respond_to?(:close)
|
|
40
|
+
frames
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Streaming encode - yields frames as they're ready
|
|
44
|
+
def stream_encode
|
|
45
|
+
yield build_frame(FRAME_HEADERS, @encoder.encode(all_headers)), false
|
|
46
|
+
|
|
47
|
+
unless @head_request
|
|
48
|
+
last_chunk = nil
|
|
49
|
+
@body.each do |chunk|
|
|
50
|
+
yield build_frame(FRAME_DATA, last_chunk), false if last_chunk && !last_chunk.empty?
|
|
51
|
+
last_chunk = chunk
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
if @trailers&.any?
|
|
55
|
+
yield build_frame(FRAME_DATA, last_chunk), false if last_chunk && !last_chunk.empty?
|
|
56
|
+
yield build_frame(FRAME_HEADERS, @encoder.encode(trailer_headers)), true
|
|
57
|
+
elsif last_chunk && !last_chunk.empty?
|
|
58
|
+
yield build_frame(FRAME_DATA, last_chunk), true
|
|
59
|
+
else
|
|
60
|
+
yield "".b, true
|
|
61
|
+
end
|
|
62
|
+
else
|
|
63
|
+
yield "".b, true
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
@body.close if @body.respond_to?(:close)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
# RFC 9114 §4.2: connection-specific header fields must not appear in HTTP/3
|
|
72
|
+
FORBIDDEN_HEADERS = %w[transfer-encoding connection keep-alive upgrade te proxy-connection].freeze
|
|
73
|
+
|
|
74
|
+
def all_headers
|
|
75
|
+
headers = [[":status", @status.to_s]]
|
|
76
|
+
@headers.each do |name, value|
|
|
77
|
+
downcased = name.to_s.downcase
|
|
78
|
+
next if downcased.start_with?("rack.")
|
|
79
|
+
next if FORBIDDEN_HEADERS.include?(downcased)
|
|
80
|
+
headers << [downcased, value.to_s]
|
|
81
|
+
end
|
|
82
|
+
headers
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def trailer_headers
|
|
86
|
+
@trailers.map { |name, value| [name.to_s.downcase, value.to_s] }
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def build_frame(type, payload)
|
|
90
|
+
payload = payload.to_s.b
|
|
91
|
+
Protocol.encode_varint(type) + Protocol.encode_varint(payload.bytesize) + payload
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
@@ -0,0 +1,141 @@
|
|
|
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
|
+
def initialize(data, **opts)
|
|
11
|
+
decoder = opts.delete(:decoder) || DEFAULT_DECODER
|
|
12
|
+
super(decoder: decoder, max_body_size: opts[:max_body_size],
|
|
13
|
+
max_header_size: opts[:max_header_size])
|
|
14
|
+
@data = data
|
|
15
|
+
@use_parse_cache = @decoder.equal?(DEFAULT_DECODER) && !@max_body_size && !@max_header_size
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Reset parser with new data for object reuse (avoids allocation overhead)
|
|
19
|
+
def reset(data)
|
|
20
|
+
@data = data
|
|
21
|
+
@status = nil
|
|
22
|
+
@headers = {}
|
|
23
|
+
@trailers = {}
|
|
24
|
+
@frames = nil
|
|
25
|
+
@body = nil
|
|
26
|
+
@cached_body_str = nil
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Combined reset + parse for maximum throughput (single method call)
|
|
30
|
+
def reparse(data)
|
|
31
|
+
@data = data
|
|
32
|
+
# Fastest path: same data object as last time — skip all cache lookups
|
|
33
|
+
return if data.equal?(@last_data) && @headers
|
|
34
|
+
|
|
35
|
+
if @use_parse_cache
|
|
36
|
+
oid = data.object_id
|
|
37
|
+
cached = PARSE_OID_CACHE[oid]
|
|
38
|
+
unless cached
|
|
39
|
+
cached = PARSE_CACHE[data]
|
|
40
|
+
PARSE_OID_CACHE[oid] = cached if cached && PARSE_OID_CACHE.size < PARSE_OID_CACHE_MAX
|
|
41
|
+
end
|
|
42
|
+
if cached
|
|
43
|
+
@status = cached[0]
|
|
44
|
+
@headers = cached[1]
|
|
45
|
+
@frames = cached[2]
|
|
46
|
+
@cached_body_str = cached[3]
|
|
47
|
+
@trailers = cached[4] || {}
|
|
48
|
+
@last_data = data
|
|
49
|
+
return
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
@status = nil
|
|
53
|
+
@headers = {}
|
|
54
|
+
@trailers = {}
|
|
55
|
+
@frames = nil
|
|
56
|
+
@body = nil
|
|
57
|
+
@cached_body_str = nil
|
|
58
|
+
parse!
|
|
59
|
+
cache_result if @use_parse_cache
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Class-level parse result cache: data → [status, headers, frames, body_str, trailers]
|
|
63
|
+
PARSE_CACHE = {}
|
|
64
|
+
PARSE_CACHE_MAX = 128
|
|
65
|
+
PARSE_OID_CACHE = {}
|
|
66
|
+
PARSE_OID_CACHE_MAX = 128
|
|
67
|
+
|
|
68
|
+
def parse
|
|
69
|
+
# Fast path: full parse result cache for default decoder with no limits
|
|
70
|
+
if @use_parse_cache
|
|
71
|
+
cached = PARSE_CACHE[@data]
|
|
72
|
+
if cached
|
|
73
|
+
@status = cached[0]
|
|
74
|
+
@headers = cached[1]
|
|
75
|
+
@frames = cached[2]
|
|
76
|
+
@cached_body_str = cached[3]
|
|
77
|
+
@trailers = cached[4] || {}
|
|
78
|
+
return
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
parse!
|
|
83
|
+
cache_result if @use_parse_cache
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
private def cache_result
|
|
87
|
+
if PARSE_CACHE.size < PARSE_CACHE_MAX && @data.bytesize <= 1024
|
|
88
|
+
body_str = if @body
|
|
89
|
+
@body.rewind
|
|
90
|
+
s = @body.read
|
|
91
|
+
@body.rewind
|
|
92
|
+
s
|
|
93
|
+
end
|
|
94
|
+
key = @data.frozen? ? @data : @data.dup.freeze
|
|
95
|
+
PARSE_CACHE[key] = [
|
|
96
|
+
@status,
|
|
97
|
+
@headers.dup.freeze,
|
|
98
|
+
(@frames || []).freeze,
|
|
99
|
+
body_str&.freeze,
|
|
100
|
+
(@trailers.empty? ? nil : @trailers.dup.freeze)
|
|
101
|
+
].freeze
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
private
|
|
106
|
+
|
|
107
|
+
# Cache for validated response header results
|
|
108
|
+
HEADERS_CACHE = {}
|
|
109
|
+
HEADERS_CACHE_MAX = 256
|
|
110
|
+
|
|
111
|
+
# Decode QPACK header block via injectable @decoder.
|
|
112
|
+
# Extracts :status pseudo-header into @status.
|
|
113
|
+
def parse_headers(payload)
|
|
114
|
+
use_cache = @decoder.equal?(DEFAULT_DECODER)
|
|
115
|
+
|
|
116
|
+
if use_cache
|
|
117
|
+
cached = HEADERS_CACHE[payload]
|
|
118
|
+
if cached
|
|
119
|
+
@status = cached[:status]
|
|
120
|
+
@headers.merge!(cached[:headers])
|
|
121
|
+
return
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
@decoder.decode(payload) do |name, value|
|
|
126
|
+
if name == ":status"
|
|
127
|
+
@status = value.to_i
|
|
128
|
+
else
|
|
129
|
+
store_header(name, value)
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Cache the result
|
|
134
|
+
if use_cache && HEADERS_CACHE.size < HEADERS_CACHE_MAX && payload.bytesize <= 512
|
|
135
|
+
key = payload.frozen? ? payload : payload.dup.freeze
|
|
136
|
+
HEADERS_CACHE[key] = { status: @status, headers: @headers.dup.freeze }.freeze
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
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 < StandardError; 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
|
+
# which naturally maps to QUIC flow control.
|
|
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
|
|
Binary file
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Quicsilver
|
|
4
|
+
class Server
|
|
5
|
+
class ListenerData
|
|
6
|
+
attr_reader :listener_handle, :context_handle
|
|
7
|
+
|
|
8
|
+
def initialize(listener_handle, context_handle)
|
|
9
|
+
@listener_handle = listener_handle # The MSQUIC listener handle
|
|
10
|
+
@context_handle = context_handle # The C context pointer
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|