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.
Files changed (81) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +4 -5
  3. data/.github/workflows/cibuildgem.yaml +93 -0
  4. data/.gitignore +3 -1
  5. data/CHANGELOG.md +81 -0
  6. data/Gemfile.lock +26 -4
  7. data/README.md +95 -31
  8. data/Rakefile +95 -3
  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 +1 -1
  13. data/benchmarks/rails.rb +170 -0
  14. data/benchmarks/throughput.rb +113 -0
  15. data/examples/README.md +44 -91
  16. data/examples/benchmark.rb +111 -0
  17. data/examples/connection_pool_demo.rb +47 -0
  18. data/examples/example_helper.rb +18 -0
  19. data/examples/falcon_middleware.rb +44 -0
  20. data/examples/feature_demo.rb +125 -0
  21. data/examples/grpc_style.rb +97 -0
  22. data/examples/minimal_http3_server.rb +6 -18
  23. data/examples/priorities.rb +60 -0
  24. data/examples/protocol_http_server.rb +31 -0
  25. data/examples/rack_http3_server.rb +8 -20
  26. data/examples/rails_feature_test.rb +260 -0
  27. data/examples/simple_client_test.rb +2 -2
  28. data/examples/streaming_sse.rb +33 -0
  29. data/examples/trailers.rb +69 -0
  30. data/ext/quicsilver/extconf.rb +14 -0
  31. data/ext/quicsilver/quicsilver.c +568 -181
  32. data/lib/quicsilver/client/client.rb +349 -0
  33. data/lib/quicsilver/client/connection_pool.rb +106 -0
  34. data/lib/quicsilver/client/request.rb +98 -0
  35. data/lib/quicsilver/libmsquic.2.dylib +0 -0
  36. data/lib/quicsilver/protocol/adapter.rb +176 -0
  37. data/lib/quicsilver/protocol/control_stream_parser.rb +106 -0
  38. data/lib/quicsilver/protocol/frame_parser.rb +142 -0
  39. data/lib/quicsilver/protocol/frame_reader.rb +55 -0
  40. data/lib/quicsilver/{http3.rb → protocol/frames.rb} +146 -30
  41. data/lib/quicsilver/protocol/priority.rb +56 -0
  42. data/lib/quicsilver/protocol/qpack/decoder.rb +165 -0
  43. data/lib/quicsilver/protocol/qpack/encoder.rb +227 -0
  44. data/lib/quicsilver/protocol/qpack/header_block_decoder.rb +140 -0
  45. data/lib/quicsilver/protocol/qpack/huffman.rb +459 -0
  46. data/lib/quicsilver/protocol/request_encoder.rb +47 -0
  47. data/lib/quicsilver/protocol/request_parser.rb +275 -0
  48. data/lib/quicsilver/protocol/response_encoder.rb +97 -0
  49. data/lib/quicsilver/protocol/response_parser.rb +141 -0
  50. data/lib/quicsilver/protocol/stream_input.rb +98 -0
  51. data/lib/quicsilver/protocol/stream_output.rb +59 -0
  52. data/lib/quicsilver/quicsilver.bundle +0 -0
  53. data/lib/quicsilver/server/listener_data.rb +14 -0
  54. data/lib/quicsilver/server/request_handler.rb +138 -0
  55. data/lib/quicsilver/server/request_registry.rb +50 -0
  56. data/lib/quicsilver/server/server.rb +610 -0
  57. data/lib/quicsilver/transport/configuration.rb +141 -0
  58. data/lib/quicsilver/transport/connection.rb +379 -0
  59. data/lib/quicsilver/transport/event_loop.rb +38 -0
  60. data/lib/quicsilver/transport/inbound_stream.rb +33 -0
  61. data/lib/quicsilver/transport/stream.rb +28 -0
  62. data/lib/quicsilver/transport/stream_event.rb +26 -0
  63. data/lib/quicsilver/version.rb +1 -1
  64. data/lib/quicsilver.rb +55 -14
  65. data/lib/rackup/handler/quicsilver.rb +1 -2
  66. data/quicsilver.gemspec +13 -3
  67. metadata +125 -21
  68. data/benchmarks/benchmark.rb +0 -68
  69. data/examples/setup_certs.sh +0 -57
  70. data/lib/quicsilver/client.rb +0 -261
  71. data/lib/quicsilver/connection.rb +0 -42
  72. data/lib/quicsilver/event_loop.rb +0 -38
  73. data/lib/quicsilver/http3/request_encoder.rb +0 -133
  74. data/lib/quicsilver/http3/request_parser.rb +0 -176
  75. data/lib/quicsilver/http3/response_encoder.rb +0 -186
  76. data/lib/quicsilver/http3/response_parser.rb +0 -160
  77. data/lib/quicsilver/listener_data.rb +0 -29
  78. data/lib/quicsilver/quic_stream.rb +0 -36
  79. data/lib/quicsilver/request_registry.rb +0 -48
  80. data/lib/quicsilver/server.rb +0 -355
  81. 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