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,176 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "protocol/http/request"
4
+ require "protocol/http/response"
5
+ require "protocol/http/headers"
6
+ require_relative "stream_input"
7
+ require_relative "stream_output"
8
+
9
+ module Quicsilver
10
+ module Protocol
11
+ # Converts between QUIC/HTTP/3 frames and protocol-http Request/Response
12
+ # objects. This enables integration with Falcon and any other server
13
+ # built on protocol-http.
14
+ #
15
+ # Usage:
16
+ # adapter = Protocol::Adapter.new(app)
17
+ # request, body = adapter.build_request(parsed_headers)
18
+ # body.write(chunk) # feed body data
19
+ # body.close_write # signal end of body
20
+ # response = adapter.call(request)
21
+ # adapter.send_response(response, writer)
22
+ #
23
+ class Adapter
24
+ VERSION = "HTTP/3"
25
+
26
+ def initialize(app)
27
+ @app = app
28
+ @qpack_encoder = Quicsilver::Protocol::Qpack::Encoder.new
29
+ end
30
+
31
+ # Build a Protocol::HTTP::Request from parsed HTTP/3 headers.
32
+ #
33
+ # Returns [request, body] where body is a Protocol::StreamInput that
34
+ # the transport feeds RECEIVE chunks into.
35
+ #
36
+ # @param headers [Hash] Parsed headers from RequestParser (includes pseudo-headers).
37
+ # @return [Array(Protocol::HTTP::Request, Protocol::StreamInput)] request and body.
38
+ # Body is nil for bodyless methods (GET, HEAD, TRACE).
39
+ # Caller feeds RECEIVE data into body via write(), then close_write on FIN.
40
+ def build_request(headers)
41
+ method = headers[":method"]
42
+ scheme = headers[":scheme"] || "https"
43
+ authority = headers[":authority"]
44
+ path = headers[":path"]
45
+ protocol = headers[":protocol"]
46
+ content_length = headers["content-length"]&.to_i
47
+
48
+ protocol_headers = ::Protocol::HTTP::Headers.new
49
+ headers.each do |name, value|
50
+ next if name.start_with?(":")
51
+ protocol_headers.add(name, value)
52
+ end
53
+
54
+ body = unless bodyless_request?(method)
55
+ Protocol::StreamInput.new(content_length)
56
+ end
57
+
58
+ request = ::Protocol::HTTP::Request.new(
59
+ scheme, authority, method, path, VERSION,
60
+ protocol_headers, body, protocol
61
+ )
62
+
63
+ [request, body]
64
+ end
65
+
66
+ # Send a Protocol::HTTP::Response via a transport writer.
67
+ #
68
+ # Encodes the response headers as an HTTP/3 HEADERS frame, then streams
69
+ # the response body as DATA frames via Protocol::StreamOutput.
70
+ #
71
+ # @param response [Protocol::HTTP::Response] The response to send.
72
+ # @param writer [#call] Transport writer — accepts (data, fin) for sending bytes.
73
+ # @param head_request [Boolean] Whether this was a HEAD request.
74
+ # @return [void]
75
+ def send_response(response, writer, head_request: false)
76
+ status = response.status
77
+ headers = response.headers
78
+ trailers = extract_trailers(headers)
79
+ headers_hash = response_headers_hash(headers)
80
+ body = response.body
81
+
82
+ if body.nil? || head_request
83
+ send_headers_only(status, headers_hash, writer, trailers: trailers)
84
+ elsif body.respond_to?(:read)
85
+ stream_response(status, headers_hash, body, writer, trailers: trailers)
86
+ else
87
+ buffer_response(status, headers_hash, body, writer, trailers: trailers)
88
+ end
89
+ end
90
+
91
+ # Call the protocol-http application with the request.
92
+ #
93
+ # @param request [Protocol::HTTP::Request]
94
+ # @return [Protocol::HTTP::Response]
95
+ def call(request)
96
+ @app.call(request)
97
+ end
98
+
99
+ private
100
+
101
+ # Methods where a body has no defined semantics (RFC 9110 §9.3.1, §9.3.2, §9.3.8).
102
+ # GET and HEAD SHOULD NOT have a body. TRACE MUST NOT.
103
+ # DELETE, OPTIONS, CONNECT can all have meaningful bodies.
104
+ BODYLESS_METHODS = %w[GET HEAD TRACE].freeze
105
+
106
+ def bodyless_request?(method)
107
+ BODYLESS_METHODS.include?(method)
108
+ end
109
+
110
+ # No body — send HEADERS with FIN
111
+ def send_headers_only(status, headers, writer, trailers: nil)
112
+ encoder = Quicsilver::Protocol::ResponseEncoder.new(status, headers, [], trailers: trailers)
113
+ writer.call(encoder.encode, true)
114
+ end
115
+
116
+ # Streaming body (protocol-http Body::Readable) — send HEADERS, then
117
+ # stream DATA frames as chunks arrive. Used by Falcon mode.
118
+ def stream_response(status, headers, body, writer, trailers: nil)
119
+ has_trailers = trailers&.any?
120
+ writer.call(build_headers_frame(status, headers), false)
121
+ Protocol::StreamOutput.new(body, &writer).stream(send_fin: !has_trailers)
122
+ writer.call(build_trailer_frame(trailers), true) if has_trailers
123
+ end
124
+
125
+ # Buffered body (Rack array or enumerable) — encode everything and send.
126
+ def buffer_response(status, headers, body, writer, trailers: nil)
127
+ parts = body.respond_to?(:each) ? body : [body.to_s]
128
+ encoder = Quicsilver::Protocol::ResponseEncoder.new(status, headers, parts, trailers: trailers)
129
+ writer.call(encoder.encode, true)
130
+ ensure
131
+ body.close if body.respond_to?(:close)
132
+ end
133
+
134
+ # Extract trailers from Protocol::HTTP::Headers if present.
135
+ # Returns a Hash or nil.
136
+ def extract_trailers(headers)
137
+ return nil unless headers.respond_to?(:trailer?) && headers.trailer?
138
+
139
+ result = {}
140
+ headers.trailer.each do |name, value|
141
+ result[name] = value
142
+ end
143
+ result
144
+ end
145
+
146
+ # Convert Protocol::HTTP::Headers to a plain Hash for ResponseEncoder.
147
+ # Only includes headers, not trailers.
148
+ def response_headers_hash(headers)
149
+ return {} unless headers
150
+
151
+ headers.header.to_h
152
+ end
153
+
154
+ # Build an HTTP/3 HEADERS frame from key-value pairs
155
+ def build_qpack_frame(pairs)
156
+ encoded = @qpack_encoder.encode(pairs)
157
+ Quicsilver::Protocol.encode_varint(Quicsilver::Protocol::FRAME_HEADERS) +
158
+ Quicsilver::Protocol.encode_varint(encoded.bytesize) +
159
+ encoded
160
+ end
161
+
162
+ # Build a response HEADERS frame (with :status pseudo-header)
163
+ def build_headers_frame(status, headers)
164
+ pairs = [[":status", status.to_s]]
165
+ headers.each { |name, value| pairs << [name.to_s.downcase, value.to_s] }
166
+ build_qpack_frame(pairs)
167
+ end
168
+
169
+ # Build a trailer HEADERS frame
170
+ def build_trailer_frame(trailers)
171
+ pairs = trailers.map { |name, value| [name.to_s.downcase, value.to_s] }
172
+ build_qpack_frame(pairs)
173
+ end
174
+ end
175
+ end
176
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Quicsilver
4
+ module Protocol
5
+ # Shared control stream parsing for both server Connection and Client.
6
+ #
7
+ # RFC 9114 §7.2.4: Both endpoints MUST send and process SETTINGS.
8
+ # RFC 9114 §7.2.6: Both endpoints MUST validate incoming GOAWAY.
9
+ #
10
+ # Includer must provide:
11
+ # @settings_received — boolean, initially false
12
+ # @peer_goaway_id — nil initially
13
+ #
14
+ # Includer may override:
15
+ # on_settings_received(settings_hash) — called after SETTINGS parsed
16
+ # on_goaway_received(stream_id) — called after GOAWAY parsed
17
+ # handle_control_frame(type, payload) — called for non-SETTINGS/GOAWAY frames
18
+ module ControlStreamParser
19
+ # RFC 9114 §7.2.4.1 / §11.2.2: HTTP/2 setting identifiers forbidden in HTTP/3
20
+ # 0x00 = SETTINGS_HEADER_TABLE_SIZE (reserved), 0x02-0x05 = various HTTP/2 settings
21
+ # Note: 0x08 (SETTINGS_ENABLE_CONNECT_PROTOCOL) is valid in HTTP/3 per RFC 9220
22
+ HTTP2_SETTINGS = [0x00, 0x02, 0x03, 0x04, 0x05].freeze
23
+
24
+ def parse_control_frames(data)
25
+ first_frame = !@settings_received
26
+
27
+ Protocol::FrameReader.each(data) do |type, payload|
28
+ if first_frame && type != Protocol::FRAME_SETTINGS
29
+ raise Protocol::FrameError.new("First frame on control stream must be SETTINGS",
30
+ error_code: Protocol::H3_MISSING_SETTINGS)
31
+ end
32
+ first_frame = false
33
+
34
+ case type
35
+ when Protocol::FRAME_SETTINGS
36
+ raise Protocol::FrameError, "Duplicate SETTINGS frame on control stream" if @settings_received
37
+ parse_peer_settings(payload)
38
+ @settings_received = true
39
+ when Protocol::FRAME_GOAWAY
40
+ parse_peer_goaway(payload)
41
+ else
42
+ handle_control_frame(type, payload)
43
+ end
44
+ end
45
+ end
46
+
47
+ def parse_peer_settings(payload)
48
+ offset = 0
49
+ seen = Set.new
50
+ settings = {}
51
+
52
+ while offset < payload.bytesize
53
+ id, id_len = Protocol.decode_varint(payload.bytes, offset)
54
+ value, value_len = Protocol.decode_varint(payload.bytes, offset + id_len)
55
+ break if id_len == 0 || value_len == 0
56
+
57
+ if HTTP2_SETTINGS.include?(id)
58
+ raise Protocol::FrameError.new("HTTP/2 setting identifier 0x#{id.to_s(16)} not allowed in HTTP/3",
59
+ error_code: Protocol::H3_SETTINGS_ERROR)
60
+ end
61
+
62
+ raise Protocol::FrameError, "Duplicate setting identifier 0x#{id.to_s(16)}" if seen.include?(id)
63
+ seen.add(id)
64
+
65
+ settings[id] = value
66
+ offset += id_len + value_len
67
+ end
68
+
69
+ on_settings_received(settings)
70
+ end
71
+
72
+ # RFC 9114 §7.2.6: Validate incoming GOAWAY frame.
73
+ # Stream ID must be a client-initiated bidirectional stream ID (divisible by 4)
74
+ # and must not increase from a previous GOAWAY.
75
+ def parse_peer_goaway(payload)
76
+ stream_id, _ = Protocol.decode_varint(payload.bytes, 0)
77
+
78
+ unless stream_id % 4 == 0
79
+ raise Protocol::FrameError.new(
80
+ "GOAWAY stream ID #{stream_id} is not a client-initiated bidirectional stream ID",
81
+ error_code: Protocol::H3_ID_ERROR)
82
+ end
83
+
84
+ if @peer_goaway_id && stream_id > @peer_goaway_id
85
+ raise Protocol::FrameError.new(
86
+ "GOAWAY stream ID #{stream_id} exceeds previous #{@peer_goaway_id}",
87
+ error_code: Protocol::H3_ID_ERROR)
88
+ end
89
+
90
+ @peer_goaway_id = stream_id
91
+ end
92
+
93
+ private
94
+
95
+ # Override in includer to store settings. Default: no-op.
96
+ def on_settings_received(settings)
97
+ end
98
+
99
+ # Override in includer to handle additional frame types on the control stream.
100
+ # Server handles FORBIDDEN_ON_CONTROL and PRIORITY_UPDATE here.
101
+ # Client ignores unknown frames.
102
+ def handle_control_frame(type, payload)
103
+ end
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,142 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "stringio"
4
+ require_relative "frame_reader"
5
+ require_relative "qpack/header_block_decoder"
6
+
7
+ module Quicsilver
8
+ module Protocol
9
+ # Base class for HTTP/3 request and response frame parsing.
10
+ #
11
+ # Handles the shared frame walking loop, HEADERS→DATA→HEADERS ordering,
12
+ # trailer parsing, body accumulation, and size limit enforcement.
13
+ #
14
+ # Subclasses implement:
15
+ # - parse_headers(payload) — decode the first HEADERS frame
16
+ class FrameParser
17
+ # Frame types forbidden on request streams — use hash for O(1) lookup
18
+ # Static-only QPACK decoder (no dynamic table). Used by default.
19
+ # Inject a custom decoder via decoder: kwarg for dynamic QPACK support.
20
+ DEFAULT_DECODER = Qpack::HeaderBlockDecoder.default
21
+
22
+ CONTROL_ONLY_SET = Protocol::CONTROL_ONLY_FRAMES.each_with_object({}) { |f, h| h[f] = true }.freeze
23
+
24
+ EMPTY_BODY = StringIO.new("".b).tap { |io| io.set_encoding(Encoding::ASCII_8BIT) }
25
+
26
+ attr_reader :headers, :trailers, :bytes_consumed
27
+
28
+ def frames
29
+ @frames || []
30
+ end
31
+
32
+ def initialize(decoder:, max_body_size: nil, max_header_size: nil, max_header_count: nil, max_frame_payload_size: nil)
33
+ @decoder = decoder
34
+ @max_body_size = max_body_size
35
+ @max_header_size = max_header_size
36
+ @max_header_count = max_header_count
37
+ @max_frame_payload_size = max_frame_payload_size
38
+ @headers = {}
39
+ @trailers = {}
40
+ end
41
+
42
+ def body
43
+ if @body
44
+ @body.rewind
45
+ @body
46
+ elsif @cached_body_str
47
+ @body = StringIO.new(@cached_body_str)
48
+ @body.set_encoding(Encoding::ASCII_8BIT)
49
+ @body
50
+ else
51
+ EMPTY_BODY
52
+ end
53
+ end
54
+
55
+ private
56
+
57
+ def parse!
58
+ walk_frames(@data)
59
+ end
60
+
61
+ def walk_frames(buffer)
62
+ @headers = {}
63
+ @trailers = {}
64
+ @body = nil
65
+ @frames = nil
66
+ @bytes_consumed = 0
67
+ headers_received = false
68
+ trailers_received = false
69
+
70
+ @bytes_consumed = FrameReader.each(buffer) do |type, payload|
71
+ if @max_frame_payload_size && payload.bytesize > @max_frame_payload_size
72
+ raise Protocol::FrameError, "Frame payload #{payload.bytesize} exceeds limit #{@max_frame_payload_size}"
73
+ end
74
+
75
+ (@frames ||= []) << { type: type, length: payload.bytesize, payload: payload }
76
+
77
+ if CONTROL_ONLY_SET.key?(type)
78
+ raise Protocol::FrameError, "Frame type 0x#{type.to_s(16)} not allowed on request streams"
79
+ end
80
+
81
+ case type
82
+ when 0x01 # HEADERS
83
+ if @max_header_size && payload.bytesize > @max_header_size
84
+ raise Protocol::MessageError, "Header block #{payload.bytesize} exceeds limit #{@max_header_size}"
85
+ end
86
+ if trailers_received
87
+ raise Protocol::FrameError, "HEADERS frame after trailers"
88
+ elsif headers_received
89
+ parse_trailers(payload)
90
+ trailers_received = true
91
+ else
92
+ parse_headers(payload)
93
+ headers_received = true
94
+ end
95
+ when 0x00 # DATA
96
+ raise Protocol::FrameError, "DATA frame before HEADERS" unless headers_received
97
+ raise Protocol::FrameError, "DATA frame after trailers" if trailers_received
98
+ unless @body
99
+ @body = StringIO.new
100
+ @body.set_encoding(Encoding::ASCII_8BIT)
101
+ end
102
+ @body.write(payload)
103
+ if @max_body_size && @body.size > @max_body_size
104
+ raise Protocol::MessageError, "Body size #{@body.size} exceeds limit #{@max_body_size}"
105
+ end
106
+ end
107
+ end
108
+
109
+ @body&.rewind
110
+ end
111
+
112
+ # RFC 9110 §5.3: Combine duplicate header values.
113
+ # - set-cookie: join with "\n" (Rack convention, MUST NOT combine with comma)
114
+ # - cookie: join with "; " (RFC 9114 §4.2.1)
115
+ # - all others: join with ", "
116
+ def store_header(name, value)
117
+ if @headers.key?(name)
118
+ separator = case name
119
+ when "set-cookie" then "\n"
120
+ when "cookie" then "; "
121
+ else ", "
122
+ end
123
+ @headers[name] = "#{@headers[name]}#{separator}#{value}"
124
+ else
125
+ if @max_header_count && @headers.size >= @max_header_count
126
+ raise Protocol::MessageError, "Header count exceeds limit #{@max_header_count}"
127
+ end
128
+ @headers[name] = value
129
+ end
130
+ end
131
+
132
+ def parse_trailers(payload)
133
+ @decoder.decode(payload) do |name, value|
134
+ if name.start_with?(":")
135
+ raise Protocol::MessageError, "Pseudo-header '#{name}' in trailers"
136
+ end
137
+ @trailers[name] = value
138
+ end
139
+ end
140
+ end
141
+ end
142
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Quicsilver
4
+ module Protocol
5
+ # Extracts HTTP/3 frames from a byte buffer.
6
+ # Yields [type, payload] pairs. No protocol semantics — just byte-level extraction.
7
+ #
8
+ # Usage:
9
+ # FrameReader.each(buffer) do |type, payload|
10
+ # case type
11
+ # when FRAME_HEADERS then ...
12
+ # when FRAME_DATA then ...
13
+ # end
14
+ # end
15
+ module FrameReader
16
+ def self.each(buffer)
17
+ offset = 0
18
+ buf_size = buffer.bytesize
19
+
20
+ while offset < buf_size
21
+ break if buf_size - offset < 2
22
+
23
+ # Inline single-byte varint fast path (covers frame types 0x00-0x3F)
24
+ type_byte = buffer.getbyte(offset)
25
+ if type_byte < 0x40
26
+ type = type_byte
27
+ type_len = 1
28
+ else
29
+ type, type_len = Protocol.decode_varint_str(buffer, offset)
30
+ break if type_len == 0
31
+ end
32
+
33
+ len_byte = buffer.getbyte(offset + type_len)
34
+ if len_byte < 0x40
35
+ length = len_byte
36
+ length_len = 1
37
+ else
38
+ length, length_len = Protocol.decode_varint_str(buffer, offset + type_len)
39
+ break if length_len == 0
40
+ end
41
+
42
+ header_len = type_len + length_len
43
+ break if buf_size < offset + header_len + length
44
+
45
+ payload = buffer.byteslice(offset + header_len, length)
46
+ offset += header_len + length
47
+
48
+ yield type, payload
49
+ end
50
+
51
+ offset
52
+ end
53
+ end
54
+ end
55
+ end