quicsilver 0.3.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 (54) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +1 -1
  3. data/.github/workflows/cibuildgem.yaml +93 -0
  4. data/.gitignore +3 -1
  5. data/CHANGELOG.md +32 -0
  6. data/Gemfile.lock +20 -2
  7. data/README.md +92 -29
  8. data/Rakefile +67 -2
  9. data/benchmarks/concurrent.rb +2 -2
  10. data/benchmarks/rails.rb +3 -3
  11. data/benchmarks/throughput.rb +2 -2
  12. data/examples/README.md +44 -91
  13. data/examples/benchmark.rb +111 -0
  14. data/examples/connection_pool_demo.rb +47 -0
  15. data/examples/example_helper.rb +18 -0
  16. data/examples/falcon_middleware.rb +44 -0
  17. data/examples/feature_demo.rb +125 -0
  18. data/examples/grpc_style.rb +97 -0
  19. data/examples/minimal_http3_server.rb +6 -18
  20. data/examples/priorities.rb +60 -0
  21. data/examples/protocol_http_server.rb +31 -0
  22. data/examples/rack_http3_server.rb +8 -20
  23. data/examples/rails_feature_test.rb +260 -0
  24. data/examples/simple_client_test.rb +2 -2
  25. data/examples/streaming_sse.rb +33 -0
  26. data/examples/trailers.rb +69 -0
  27. data/ext/quicsilver/extconf.rb +14 -0
  28. data/ext/quicsilver/quicsilver.c +39 -0
  29. data/lib/quicsilver/client/client.rb +138 -39
  30. data/lib/quicsilver/client/connection_pool.rb +106 -0
  31. data/lib/quicsilver/libmsquic.2.dylib +0 -0
  32. data/lib/quicsilver/protocol/adapter.rb +176 -0
  33. data/lib/quicsilver/protocol/control_stream_parser.rb +106 -0
  34. data/lib/quicsilver/protocol/frame_parser.rb +142 -0
  35. data/lib/quicsilver/protocol/frame_reader.rb +55 -0
  36. data/lib/quicsilver/protocol/frames.rb +18 -7
  37. data/lib/quicsilver/protocol/priority.rb +56 -0
  38. data/lib/quicsilver/protocol/qpack/encoder.rb +39 -1
  39. data/lib/quicsilver/protocol/qpack/header_block_decoder.rb +16 -1
  40. data/lib/quicsilver/protocol/request_parser.rb +28 -140
  41. data/lib/quicsilver/protocol/response_encoder.rb +27 -2
  42. data/lib/quicsilver/protocol/response_parser.rb +22 -130
  43. data/lib/quicsilver/protocol/stream_input.rb +98 -0
  44. data/lib/quicsilver/protocol/stream_output.rb +59 -0
  45. data/lib/quicsilver/quicsilver.bundle +0 -0
  46. data/lib/quicsilver/server/request_handler.rb +96 -44
  47. data/lib/quicsilver/server/server.rb +316 -42
  48. data/lib/quicsilver/transport/configuration.rb +10 -1
  49. data/lib/quicsilver/transport/connection.rb +92 -63
  50. data/lib/quicsilver/version.rb +1 -1
  51. data/lib/quicsilver.rb +26 -3
  52. data/quicsilver.gemspec +10 -2
  53. metadata +69 -5
  54. data/examples/setup_certs.sh +0 -57
@@ -1,39 +1,28 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "stringio"
4
- require_relative "qpack/header_block_decoder"
3
+ require_relative "frame_parser"
5
4
 
6
5
  module Quicsilver
7
6
  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
7
+ class ResponseParser < FrameParser
8
+ attr_reader :status
16
9
 
17
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])
18
14
  @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
15
+ @use_parse_cache = @decoder.equal?(DEFAULT_DECODER) && !@max_body_size && !@max_header_size
28
16
  end
29
17
 
30
18
  # Reset parser with new data for object reuse (avoids allocation overhead)
31
19
  def reset(data)
32
20
  @data = data
33
21
  @status = nil
34
- @headers = nil
22
+ @headers = {}
23
+ @trailers = {}
35
24
  @frames = nil
36
- @body_io = nil
25
+ @body = nil
37
26
  @cached_body_str = nil
38
27
  end
39
28
 
@@ -55,35 +44,22 @@ module Quicsilver
55
44
  @headers = cached[1]
56
45
  @frames = cached[2]
57
46
  @cached_body_str = cached[3]
47
+ @trailers = cached[4] || {}
58
48
  @last_data = data
59
49
  return
60
50
  end
61
51
  end
62
52
  @status = nil
63
- @headers = nil
53
+ @headers = {}
54
+ @trailers = {}
64
55
  @frames = nil
65
- @body_io = nil
56
+ @body = nil
66
57
  @cached_body_str = nil
67
58
  parse!
68
59
  cache_result if @use_parse_cache
69
60
  end
70
61
 
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]
62
+ # Class-level parse result cache: data → [status, headers, frames, body_str, trailers]
87
63
  PARSE_CACHE = {}
88
64
  PARSE_CACHE_MAX = 128
89
65
  PARSE_OID_CACHE = {}
@@ -98,6 +74,7 @@ module Quicsilver
98
74
  @headers = cached[1]
99
75
  @frames = cached[2]
100
76
  @cached_body_str = cached[3]
77
+ @trailers = cached[4] || {}
101
78
  return
102
79
  end
103
80
  end
@@ -108,10 +85,10 @@ module Quicsilver
108
85
 
109
86
  private def cache_result
110
87
  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
88
+ body_str = if @body
89
+ @body.rewind
90
+ s = @body.read
91
+ @body.rewind
115
92
  s
116
93
  end
117
94
  key = @data.frozen? ? @data : @data.dup.freeze
@@ -119,82 +96,14 @@ module Quicsilver
119
96
  @status,
120
97
  @headers.dup.freeze,
121
98
  (@frames || []).freeze,
122
- body_str&.freeze
99
+ body_str&.freeze,
100
+ (@trailers.empty? ? nil : @trailers.dup.freeze)
123
101
  ].freeze
124
102
  end
125
103
  end
126
104
 
127
105
  private
128
106
 
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
107
  # Cache for validated response header results
199
108
  HEADERS_CACHE = {}
200
109
  HEADERS_CACHE_MAX = 256
@@ -227,23 +136,6 @@ module Quicsilver
227
136
  HEADERS_CACHE[key] = { status: @status, headers: @headers.dup.freeze }.freeze
228
137
  end
229
138
  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
139
  end
248
140
  end
249
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
@@ -3,68 +3,31 @@
3
3
  module Quicsilver
4
4
  class Server
5
5
  class RequestHandler
6
- # Safe HTTP methods allowed in 0-RTT early data (RFC 9110 §9.2.1)
7
6
  SAFE_METHODS = %w[GET HEAD OPTIONS].freeze
8
7
 
8
+ attr_reader :adapter
9
+
9
10
  def initialize(app:, configuration:, request_registry:, cancelled_streams:, cancelled_mutex:)
10
- @app = app
11
11
  @configuration = configuration
12
12
  @request_registry = request_registry
13
13
  @cancelled_streams = cancelled_streams
14
14
  @cancelled_mutex = cancelled_mutex
15
+ @adapter = Protocol::Adapter.new(app)
15
16
  end
16
17
 
17
18
  def call(connection, stream, early_data: false)
18
- parser = Protocol::RequestParser.new(
19
- stream.data,
20
- max_body_size: @configuration.max_body_size,
21
- max_header_size: @configuration.max_header_size,
22
- max_header_count: @configuration.max_header_count,
23
- max_frame_payload_size: @configuration.max_frame_payload_size
24
- )
25
- parser.parse
26
- parser.validate_headers! # raises MessageError for missing/invalid pseudo-headers
27
- env = parser.to_rack_env
28
-
29
- if env && @app
30
- env["quicsilver.early_data"] = early_data
31
-
32
- # RFC 8470: reject unsafe methods on 0-RTT unless app opted in
33
- if @configuration.early_data_policy == :reject &&
34
- early_data && !SAFE_METHODS.include?(env["REQUEST_METHOD"])
35
- connection.send_error(stream, 425, "Too Early") if stream.writable?
36
- return
37
- end
38
-
39
- @request_registry.track(
40
- stream.stream_id,
41
- connection.handle,
42
- path: env["PATH_INFO"] || "/",
43
- method: env["REQUEST_METHOD"] || "GET"
44
- )
45
-
46
- status, headers, body = @app.call(env)
47
-
48
- if cancelled_stream?(stream.stream_id)
49
- Quicsilver.logger.debug("Skipping response for cancelled stream #{stream.stream_id}")
50
- return
51
- end
19
+ request = parse_request(connection, stream, early_data: early_data)
20
+ return unless request
52
21
 
53
- raise "Stream handle not found for stream #{stream.stream_id}" unless stream.writable?
22
+ response = @adapter.call(request)
54
23
 
55
- connection.send_response(stream, status, headers, body, head_request: env["REQUEST_METHOD"] == "HEAD")
56
- @request_registry.complete(stream.stream_id)
57
- else
58
- connection.send_error(stream, 400, "Bad Request") if stream.writable?
59
- end
24
+ send_response(connection, stream, request, response)
60
25
  rescue Server::DrainTimeoutError
61
26
  Quicsilver.logger.debug("Request interrupted by drain: stream #{stream.stream_id}")
62
27
  rescue Protocol::FrameError => e
63
- # Frame errors are connection-level: signal via CONNECTION_CLOSE with H3 error code
64
28
  Quicsilver.logger.error("Frame error: #{e.message} (0x#{e.error_code.to_s(16)})")
65
29
  Quicsilver.connection_shutdown(connection.handle, e.error_code, false) rescue nil
66
30
  rescue Protocol::MessageError => e
67
- # Message errors are stream-level: signal via RESET_STREAM with H3 error code
68
31
  Quicsilver.logger.error("Message error: #{e.message} (0x#{e.error_code.to_s(16)})")
69
32
  Quicsilver.stream_reset(stream.stream_handle, e.error_code) if stream.writable?
70
33
  rescue => e
@@ -78,6 +41,95 @@ module Quicsilver
78
41
 
79
42
  private
80
43
 
44
+ def parse_request(connection, stream, early_data: false)
45
+ parser = Protocol::RequestParser.new(
46
+ stream.data,
47
+ max_body_size: @configuration.max_body_size,
48
+ max_header_size: @configuration.max_header_size,
49
+ max_header_count: @configuration.max_header_count,
50
+ max_frame_payload_size: @configuration.max_frame_payload_size
51
+ )
52
+ parser.parse
53
+ parser.validate_headers!
54
+
55
+ headers = parser.headers
56
+ unless headers && !headers.empty?
57
+ connection.send_error(stream, 400, "Bad Request") if stream.writable?
58
+ return
59
+ end
60
+
61
+ method = headers[":method"]
62
+
63
+ if @configuration.early_data_policy == :reject &&
64
+ early_data && !SAFE_METHODS.include?(method)
65
+ connection.send_error(stream, 425, "Too Early") if stream.writable?
66
+ return
67
+ end
68
+
69
+ request, body = @adapter.build_request(headers)
70
+ request.headers.add("quicsilver-early-data", early_data.to_s)
71
+
72
+ # Wire interim_response so apps can send 103 Early Hints.
73
+ # Falcon mode: app calls request.send_interim_response(103, headers)
74
+ # Rack mode: bridged to rack.early_hints via EarlyHintsMiddleware
75
+ request.interim_response = ->(status, headers) {
76
+ connection.send_informational(stream, status, headers)
77
+ }
78
+
79
+ if body && parser.body && parser.body.size > 0
80
+ parser.body.rewind
81
+ body_data = parser.body.read
82
+ body.write(body_data) unless body_data.empty?
83
+ end
84
+ body&.close_write
85
+
86
+ connection.apply_stream_priority(stream, parser.priority)
87
+
88
+ @request_registry.track(
89
+ stream.stream_id, connection.handle,
90
+ path: headers[":path"] || "/", method: method || "GET"
91
+ )
92
+
93
+ request
94
+ end
95
+
96
+ def send_response(connection, stream, request, response)
97
+ if cancelled_stream?(stream.stream_id)
98
+ Quicsilver.logger.debug("Skipping response for cancelled stream #{stream.stream_id}")
99
+ return
100
+ end
101
+
102
+ raise "Stream handle not found for stream #{stream.stream_id}" unless stream.writable?
103
+
104
+ headers = response.headers
105
+
106
+ # Extract trailers before flattening (Protocol::HTTP::Headers tracks
107
+ # trailer! state that a plain Hash would lose — needed for gRPC).
108
+ trailers = if headers.respond_to?(:trailer?) && headers.trailer?
109
+ trailer_hash = {}
110
+ headers.trailer.each { |name, value| trailer_hash[name] = value }
111
+ trailer_hash
112
+ end
113
+
114
+ response_headers = {}
115
+ if headers.respond_to?(:header)
116
+ headers.header.each { |name, value| response_headers[name] = value }
117
+ else
118
+ headers&.each { |name, value| response_headers[name] = value }
119
+ end
120
+
121
+ # Protocol-rack moves content-length from headers to body.length —
122
+ # re-add it so the HTTP/3 response includes the header.
123
+ if !response_headers.key?("content-length") && response.body&.length
124
+ response_headers["content-length"] = response.body.length.to_s
125
+ end
126
+
127
+ body = response.body || []
128
+ connection.send_response(stream, response.status, response_headers, body,
129
+ head_request: request.head?, trailers: trailers)
130
+ @request_registry.complete(stream.stream_id)
131
+ end
132
+
81
133
  def cancelled_stream?(stream_id)
82
134
  @cancelled_mutex.synchronize { @cancelled_streams.include?(stream_id) }
83
135
  end