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.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yml +1 -1
- data/.github/workflows/cibuildgem.yaml +93 -0
- data/.gitignore +3 -1
- data/CHANGELOG.md +32 -0
- data/Gemfile.lock +20 -2
- data/README.md +92 -29
- data/Rakefile +67 -2
- data/benchmarks/concurrent.rb +2 -2
- data/benchmarks/rails.rb +3 -3
- data/benchmarks/throughput.rb +2 -2
- 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 +39 -0
- data/lib/quicsilver/client/client.rb +138 -39
- data/lib/quicsilver/client/connection_pool.rb +106 -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/protocol/frames.rb +18 -7
- data/lib/quicsilver/protocol/priority.rb +56 -0
- data/lib/quicsilver/protocol/qpack/encoder.rb +39 -1
- data/lib/quicsilver/protocol/qpack/header_block_decoder.rb +16 -1
- data/lib/quicsilver/protocol/request_parser.rb +28 -140
- data/lib/quicsilver/protocol/response_encoder.rb +27 -2
- data/lib/quicsilver/protocol/response_parser.rb +22 -130
- 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/request_handler.rb +96 -44
- data/lib/quicsilver/server/server.rb +316 -42
- data/lib/quicsilver/transport/configuration.rb +10 -1
- data/lib/quicsilver/transport/connection.rb +92 -63
- data/lib/quicsilver/version.rb +1 -1
- data/lib/quicsilver.rb +26 -3
- data/quicsilver.gemspec +10 -2
- metadata +69 -5
- data/examples/setup_certs.sh +0 -57
|
@@ -1,21 +1,21 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "timeout"
|
|
4
|
-
|
|
5
3
|
module Quicsilver
|
|
6
4
|
class Client
|
|
5
|
+
include Protocol::ControlStreamParser
|
|
6
|
+
|
|
7
7
|
attr_reader :hostname, :port, :unsecure, :connection_timeout, :request_timeout
|
|
8
|
+
attr_reader :peer_goaway_id, :peer_settings, :peer_max_field_section_size
|
|
8
9
|
|
|
9
|
-
AlreadyConnectedError = Class.new(StandardError)
|
|
10
|
-
NotConnectedError = Class.new(StandardError)
|
|
11
10
|
StreamFailedToOpenError = Class.new(StandardError)
|
|
11
|
+
GoAwayError = Class.new(StandardError)
|
|
12
12
|
|
|
13
13
|
FINISHED_EVENTS = %w[RECEIVE_FIN RECEIVE STREAM_RESET STOP_SENDING].freeze
|
|
14
14
|
|
|
15
15
|
DEFAULT_REQUEST_TIMEOUT = 30 # seconds
|
|
16
16
|
DEFAULT_CONNECTION_TIMEOUT = 5000 # ms
|
|
17
17
|
|
|
18
|
-
def initialize(hostname, port = 4433, options
|
|
18
|
+
def initialize(hostname, port = 4433, **options)
|
|
19
19
|
@hostname = hostname
|
|
20
20
|
@port = port
|
|
21
21
|
@unsecure = options.fetch(:unsecure, true)
|
|
@@ -31,55 +31,68 @@ module Quicsilver
|
|
|
31
31
|
@response_buffers = {}
|
|
32
32
|
@pending_requests = {} # handle => Request
|
|
33
33
|
@mutex = Mutex.new
|
|
34
|
+
|
|
35
|
+
# Server control stream state
|
|
36
|
+
@peer_settings = {}
|
|
37
|
+
@peer_max_field_section_size = nil
|
|
38
|
+
@peer_goaway_id = nil
|
|
39
|
+
@settings_received = false
|
|
40
|
+
@control_stream_id = nil
|
|
41
|
+
@uni_stream_types = {}
|
|
34
42
|
end
|
|
35
43
|
|
|
36
|
-
|
|
37
|
-
|
|
44
|
+
# --- Class-level API (automatic pooling) ---
|
|
45
|
+
#
|
|
46
|
+
# Quicsilver::Client.get("example.com", 4433, "/users")
|
|
47
|
+
# Quicsilver::Client.post("example.com", 4433, "/data", body: json)
|
|
48
|
+
#
|
|
49
|
+
class << self
|
|
50
|
+
attr_writer :pool
|
|
38
51
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
52
|
+
def pool
|
|
53
|
+
@pool ||= ConnectionPool.new
|
|
54
|
+
end
|
|
42
55
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
Quicsilver.event_loop.start
|
|
56
|
+
def close_pool
|
|
57
|
+
@pool&.close
|
|
58
|
+
@pool = nil
|
|
59
|
+
end
|
|
48
60
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
61
|
+
# Fire-and-forget HTTP methods with automatic pooling.
|
|
62
|
+
%i[get post patch delete head put].each do |method|
|
|
63
|
+
define_method(method) do |hostname, port, path, headers: {}, body: nil, **options, &block|
|
|
64
|
+
request(hostname, port, method, path, headers: headers, body: body, **options, &block)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def request(hostname, port, method, path, headers: {}, body: nil, **options, &block)
|
|
69
|
+
client = pool.checkout(hostname, port, **options)
|
|
70
|
+
client.public_send(method, path, headers: headers, body: body, &block)
|
|
71
|
+
ensure
|
|
72
|
+
pool.checkin(client) if client
|
|
73
|
+
end
|
|
55
74
|
end
|
|
56
75
|
|
|
76
|
+
# Disconnect and close the underlying QUIC connection.
|
|
57
77
|
def disconnect
|
|
58
|
-
return unless @
|
|
78
|
+
return unless @connected
|
|
59
79
|
|
|
60
80
|
@connected = false
|
|
61
81
|
|
|
62
|
-
# Fail pending requests
|
|
63
82
|
@mutex.synchronize do
|
|
64
83
|
@pending_requests.each_value { |req| req.fail(0, "Connection closed") }
|
|
65
84
|
@pending_requests.clear
|
|
66
85
|
@response_buffers.clear
|
|
67
86
|
end
|
|
68
87
|
|
|
69
|
-
|
|
70
|
-
@connection_data = nil
|
|
88
|
+
close_connection
|
|
71
89
|
end
|
|
72
90
|
|
|
73
|
-
# HTTP methods -
|
|
91
|
+
# Instance-level HTTP methods. Auto-connects on first use.
|
|
74
92
|
#
|
|
75
|
-
#
|
|
76
|
-
#
|
|
77
|
-
#
|
|
78
|
-
# # With control
|
|
79
|
-
# client.get("/users") do |req|
|
|
80
|
-
# req.cancel if should_abort?
|
|
81
|
-
# req.response
|
|
82
|
-
# end
|
|
93
|
+
# client = Quicsilver::Client.new("example.com", 4433)
|
|
94
|
+
# client.get("/users") # connects automatically
|
|
95
|
+
# client.post("/data", body: json)
|
|
83
96
|
#
|
|
84
97
|
%i[get post patch delete head put].each do |method|
|
|
85
98
|
define_method(method) do |path, headers: {}, body: nil, &block|
|
|
@@ -88,9 +101,23 @@ module Quicsilver
|
|
|
88
101
|
end
|
|
89
102
|
end
|
|
90
103
|
|
|
91
|
-
|
|
104
|
+
def draining?
|
|
105
|
+
!@peer_goaway_id.nil?
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def receive_control_data(stream_id, data) # :nodoc:
|
|
109
|
+
buf = @uni_stream_types.key?(stream_id) ? data : identify_and_strip_stream_type(stream_id, data)
|
|
110
|
+
return if buf.nil? || buf.empty?
|
|
111
|
+
|
|
112
|
+
case @uni_stream_types[stream_id]
|
|
113
|
+
when :control
|
|
114
|
+
parse_control_frames(buf)
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
92
118
|
def build_request(method, path, headers: {}, body: nil)
|
|
93
|
-
|
|
119
|
+
ensure_connected!
|
|
120
|
+
raise GoAwayError, "Connection is draining (GOAWAY received)" if draining?
|
|
94
121
|
|
|
95
122
|
stream = open_stream
|
|
96
123
|
raise StreamFailedToOpenError unless stream
|
|
@@ -121,20 +148,59 @@ module Quicsilver
|
|
|
121
148
|
"#{@hostname}:#{@port}"
|
|
122
149
|
end
|
|
123
150
|
|
|
151
|
+
# :nodoc:
|
|
152
|
+
def open_connection
|
|
153
|
+
return self if @connected
|
|
154
|
+
|
|
155
|
+
Quicsilver.open_connection
|
|
156
|
+
config = Quicsilver.create_configuration(@unsecure)
|
|
157
|
+
raise ConnectionError, "Failed to create configuration" if config.nil?
|
|
158
|
+
|
|
159
|
+
start_connection(config)
|
|
160
|
+
@connected = true
|
|
161
|
+
@connection_start_time = Time.now
|
|
162
|
+
send_control_stream
|
|
163
|
+
Quicsilver.event_loop.start
|
|
164
|
+
|
|
165
|
+
self
|
|
166
|
+
rescue => e
|
|
167
|
+
cleanup_failed_connection
|
|
168
|
+
raise e.is_a?(ConnectionError) || e.is_a?(TimeoutError) ? e : ConnectionError.new("Connection failed: #{e.message}")
|
|
169
|
+
ensure
|
|
170
|
+
Quicsilver.close_configuration(config) if config
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# :nodoc:
|
|
174
|
+
def close_connection
|
|
175
|
+
Quicsilver.close_connection_handle(@connection_data) if @connection_data
|
|
176
|
+
@connection_data = nil
|
|
177
|
+
@connected = false
|
|
178
|
+
end
|
|
179
|
+
|
|
124
180
|
# Called directly by C extension via dispatch_to_ruby
|
|
125
181
|
def handle_stream_event(stream_id, event, data, _early_data) # :nodoc:
|
|
126
182
|
return unless FINISHED_EVENTS.include?(event)
|
|
127
183
|
|
|
184
|
+
# Server unidirectional streams (control, QPACK) — process incrementally
|
|
185
|
+
if (stream_id & 0x02) != 0 && (event == "RECEIVE" || event == "RECEIVE_FIN")
|
|
186
|
+
begin
|
|
187
|
+
receive_control_data(stream_id, data)
|
|
188
|
+
rescue Protocol::FrameError => e
|
|
189
|
+
Quicsilver.logger.error("Control stream error: #{e.message} (0x#{e.error_code.to_s(16)})")
|
|
190
|
+
end
|
|
191
|
+
return
|
|
192
|
+
end
|
|
193
|
+
|
|
128
194
|
@mutex.synchronize do
|
|
129
195
|
case event
|
|
130
196
|
when "RECEIVE"
|
|
131
|
-
(@response_buffers[stream_id] ||=
|
|
197
|
+
(@response_buffers[stream_id] ||= "".b) << data
|
|
132
198
|
|
|
133
199
|
when "RECEIVE_FIN"
|
|
134
200
|
event = Transport::StreamEvent.new(data, "RECEIVE_FIN")
|
|
135
201
|
|
|
136
202
|
buffer = @response_buffers.delete(stream_id)
|
|
137
|
-
full_data = (buffer
|
|
203
|
+
full_data = (buffer || "".b) + event.data
|
|
138
204
|
|
|
139
205
|
response_parser = Protocol::ResponseParser.new(full_data, max_body_size: @max_body_size,
|
|
140
206
|
max_header_size: @max_header_size)
|
|
@@ -167,6 +233,11 @@ module Quicsilver
|
|
|
167
233
|
|
|
168
234
|
private
|
|
169
235
|
|
|
236
|
+
def ensure_connected!
|
|
237
|
+
return if @connected
|
|
238
|
+
open_connection
|
|
239
|
+
end
|
|
240
|
+
|
|
170
241
|
def start_connection(config)
|
|
171
242
|
connection_handle, context_handle = create_connection
|
|
172
243
|
unless Quicsilver.start_connection(connection_handle, config, @hostname, @port)
|
|
@@ -205,7 +276,6 @@ module Quicsilver
|
|
|
205
276
|
@control_stream = open_unidirectional_stream
|
|
206
277
|
@control_stream.send(Protocol.build_control_stream)
|
|
207
278
|
|
|
208
|
-
# RFC 9204: QPACK encoder (0x02) and decoder (0x03) streams
|
|
209
279
|
[0x02, 0x03].each do |type|
|
|
210
280
|
stream = open_unidirectional_stream
|
|
211
281
|
stream.send([type].pack("C"))
|
|
@@ -246,5 +316,34 @@ module Quicsilver
|
|
|
246
316
|
raise Error, "Failed to send request"
|
|
247
317
|
end
|
|
248
318
|
end
|
|
319
|
+
|
|
320
|
+
# Identify stream type from first byte(s), strip it, return remaining data.
|
|
321
|
+
# Returns nil for unknown/ignored stream types.
|
|
322
|
+
def identify_and_strip_stream_type(stream_id, data)
|
|
323
|
+
stream_type, type_len = Protocol.decode_varint(data.bytes, 0)
|
|
324
|
+
return nil if type_len == 0
|
|
325
|
+
|
|
326
|
+
case stream_type
|
|
327
|
+
when 0x00 # Control stream
|
|
328
|
+
raise Protocol::FrameError, "Duplicate control stream" if @control_stream_id
|
|
329
|
+
@control_stream_id = stream_id
|
|
330
|
+
@uni_stream_types[stream_id] = :control
|
|
331
|
+
when 0x02 # QPACK encoder stream
|
|
332
|
+
@uni_stream_types[stream_id] = :qpack_encoder
|
|
333
|
+
when 0x03 # QPACK decoder stream
|
|
334
|
+
@uni_stream_types[stream_id] = :qpack_decoder
|
|
335
|
+
else
|
|
336
|
+
# Unknown unidirectional stream types MUST be ignored (RFC 9114 §6.2)
|
|
337
|
+
@uni_stream_types[stream_id] = :unknown
|
|
338
|
+
return nil
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
data[type_len..] || "".b
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
def on_settings_received(settings)
|
|
345
|
+
@peer_settings.merge!(settings)
|
|
346
|
+
@peer_max_field_section_size = settings[0x06] if settings.key?(0x06)
|
|
347
|
+
end
|
|
249
348
|
end
|
|
250
349
|
end
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Quicsilver
|
|
4
|
+
class Client
|
|
5
|
+
# Thread-safe pool of connected Client instances, keyed by (host, port).
|
|
6
|
+
# Idle connections are reused automatically. Stale ones are evicted at checkout.
|
|
7
|
+
#
|
|
8
|
+
# Quicsilver::Client.get("example.com", 4433, "/users")
|
|
9
|
+
#
|
|
10
|
+
class ConnectionPool
|
|
11
|
+
attr_reader :max_size, :idle_timeout
|
|
12
|
+
|
|
13
|
+
DEFAULT_MAX_SIZE = 4
|
|
14
|
+
DEFAULT_IDLE_TIMEOUT = 60 # seconds
|
|
15
|
+
|
|
16
|
+
def initialize(max_size: DEFAULT_MAX_SIZE, idle_timeout: DEFAULT_IDLE_TIMEOUT)
|
|
17
|
+
@max_size = max_size
|
|
18
|
+
@idle_timeout = idle_timeout
|
|
19
|
+
@pools = {} # "host:port" => [{ client:, checked_out: }]
|
|
20
|
+
@mutex = Mutex.new
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Check out a connected Client. Reuses an idle one or creates a new one.
|
|
24
|
+
def checkout(hostname, port, **options)
|
|
25
|
+
key = "#{hostname}:#{port}"
|
|
26
|
+
|
|
27
|
+
@mutex.synchronize do
|
|
28
|
+
entries = @pools[key] ||= []
|
|
29
|
+
|
|
30
|
+
# Evict dead/stale/draining
|
|
31
|
+
entries.reject! do |e|
|
|
32
|
+
if !e[:checked_out] && (!e[:client].connected? || e[:client].draining? || e[:last_used] < Time.now - @idle_timeout)
|
|
33
|
+
e[:client].close_connection
|
|
34
|
+
true
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Reuse an idle client (skip draining ones)
|
|
39
|
+
idle = entries.find { |e| !e[:checked_out] && e[:client].connected? && !e[:client].draining? }
|
|
40
|
+
if idle
|
|
41
|
+
idle[:checked_out] = true
|
|
42
|
+
idle[:last_used] = Time.now
|
|
43
|
+
return idle[:client]
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
if entries.size >= @max_size
|
|
47
|
+
raise ConnectionError, "Connection pool full for #{key} (max: #{@max_size})"
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Create outside the lock (blocking I/O)
|
|
52
|
+
client = Client.new(hostname, port, **options)
|
|
53
|
+
client.open_connection
|
|
54
|
+
|
|
55
|
+
@mutex.synchronize do
|
|
56
|
+
(@pools[key] ||= []) << { client: client, checked_out: true, last_used: Time.now }
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
client
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Return a Client to the pool.
|
|
63
|
+
def checkin(client)
|
|
64
|
+
key = "#{client.hostname}:#{client.port}"
|
|
65
|
+
|
|
66
|
+
@mutex.synchronize do
|
|
67
|
+
entries = @pools[key]
|
|
68
|
+
return unless entries
|
|
69
|
+
|
|
70
|
+
entry = entries.find { |e| e[:client].equal?(client) }
|
|
71
|
+
return unless entry
|
|
72
|
+
|
|
73
|
+
if client.connected?
|
|
74
|
+
entry[:checked_out] = false
|
|
75
|
+
entry[:last_used] = Time.now
|
|
76
|
+
else
|
|
77
|
+
entries.delete(entry)
|
|
78
|
+
client.close_connection
|
|
79
|
+
@pools.delete(key) if entries.empty?
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Close all clients.
|
|
85
|
+
def close
|
|
86
|
+
@mutex.synchronize do
|
|
87
|
+
@pools.each_value do |entries|
|
|
88
|
+
entries.each { |e| e[:client].close_connection }
|
|
89
|
+
end
|
|
90
|
+
@pools.clear
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Total clients in the pool, optionally filtered by host:port.
|
|
95
|
+
def size(host = nil, port = nil)
|
|
96
|
+
@mutex.synchronize do
|
|
97
|
+
if host && port
|
|
98
|
+
(@pools["#{host}:#{port}"] || []).size
|
|
99
|
+
else
|
|
100
|
+
@pools.values.sum(&:size)
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
Binary file
|
|
@@ -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
|