quicsilver 0.4.0 → 0.5.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 +9 -1
- data/.github/workflows/cibuildgem.yaml +29 -5
- data/.gitignore +5 -0
- data/CHANGELOG.md +50 -0
- data/Gemfile.lock +8 -1
- data/README.md +128 -15
- data/Rakefile +10 -3
- data/benchmarks/baseline.rb +175 -0
- data/benchmarks/compare.rb +398 -0
- data/benchmarks/helpers.rb +138 -64
- data/benchmarks/latency.rb +128 -0
- data/benchmarks/loss.rb +193 -0
- data/benchmarks/mobile.rb +125 -0
- data/docs/connection_identity.md +141 -0
- data/examples/benchmark.rb +80 -0
- data/examples/connection_pool_demo.rb +1 -1
- data/examples/example_helper.rb +51 -0
- data/examples/feature_demo.rb +7 -7
- data/examples/grpc_style.rb +2 -2
- data/examples/latency_aware_routing.rb +382 -0
- data/examples/lifecycle_drain.rb +264 -0
- data/examples/priorities.rb +2 -2
- data/examples/rails_feature_test.rb +92 -72
- data/examples/server_stats.rb +117 -0
- data/examples/simple_client_test.rb +3 -3
- data/examples/trailers.rb +23 -23
- data/examples/webtransport_smoke.rb +546 -0
- data/examples/worker_load_balancing.rb +425 -0
- data/ext/quicsilver/extconf.rb +100 -15
- data/ext/quicsilver/include/msquic.h +2226 -0
- data/ext/quicsilver/include/msquic_posix.h +696 -0
- data/ext/quicsilver/include/quic_sal_stub.h +314 -0
- data/ext/quicsilver/msquic_patch.rb +68 -0
- data/ext/quicsilver/patches/reliable-reset.patch +723 -0
- data/ext/quicsilver/patches/stream-final-size.patch +47 -0
- data/ext/quicsilver/quicsilver.c +1640 -222
- data/lib/quicsilver/client/client.rb +419 -60
- data/lib/quicsilver/client/connection_pool.rb +122 -26
- data/lib/quicsilver/client/request.rb +106 -14
- data/lib/quicsilver/errors.rb +39 -0
- data/lib/quicsilver/libmsquic.2.dylib +0 -0
- data/lib/quicsilver/protocol/adapter.rb +41 -21
- data/lib/quicsilver/protocol/capsule.rb +38 -0
- data/lib/quicsilver/protocol/connect_stream_decoder.rb +75 -0
- data/lib/quicsilver/protocol/control_stream_parser.rb +12 -1
- data/lib/quicsilver/protocol/datagram.rb +40 -0
- data/lib/quicsilver/protocol/frame_parser.rb +5 -6
- data/lib/quicsilver/protocol/frame_reader.rb +31 -2
- data/lib/quicsilver/protocol/frames.rb +128 -12
- data/lib/quicsilver/protocol/priority.rb +9 -0
- data/lib/quicsilver/protocol/qpack/decoder.rb +4 -42
- data/lib/quicsilver/protocol/qpack/encoder.rb +10 -20
- data/lib/quicsilver/protocol/qpack/huffman.rb +4 -4
- data/lib/quicsilver/protocol/request_encoder.rb +6 -3
- data/lib/quicsilver/protocol/request_parser.rb +16 -2
- data/lib/quicsilver/protocol/response_encoder.rb +18 -8
- data/lib/quicsilver/protocol/response_parser.rb +6 -0
- data/lib/quicsilver/protocol/stream_input.rb +3 -3
- data/lib/quicsilver/protocol/unidirectional_stream.rb +12 -0
- data/lib/quicsilver/protocol/web_transport.rb +214 -0
- data/lib/quicsilver/rack/context.rb +33 -0
- data/lib/quicsilver/response.rb +40 -0
- data/lib/quicsilver/server/rack_adapter.rb +77 -0
- data/lib/quicsilver/server/receive_queue.rb +106 -0
- data/lib/quicsilver/server/request_handler.rb +24 -13
- data/lib/quicsilver/server/request_registry.rb +18 -5
- data/lib/quicsilver/server/scheduler.rb +50 -0
- data/lib/quicsilver/server/schedulers/fiber_scheduler.rb +79 -0
- data/lib/quicsilver/server/schedulers/thread_scheduler.rb +76 -0
- data/lib/quicsilver/server/server.rb +655 -185
- data/lib/quicsilver/server/web_transport_flow_control.rb +270 -0
- data/lib/quicsilver/server/web_transport_manager.rb +281 -0
- data/lib/quicsilver/server/web_transport_registry.rb +40 -0
- data/lib/quicsilver/server/web_transport_session.rb +812 -0
- data/lib/quicsilver/server/web_transport_stream.rb +326 -0
- data/lib/quicsilver/transport/configuration.rb +103 -10
- data/lib/quicsilver/transport/connection.rb +302 -87
- data/lib/quicsilver/transport/connection_stats.rb +53 -0
- data/lib/quicsilver/transport/event_loop.rb +36 -0
- data/lib/quicsilver/transport/inbound_stream.rb +21 -1
- data/lib/quicsilver/transport/stream.rb +18 -3
- data/lib/quicsilver/transport/stream_event.rb +25 -2
- data/lib/quicsilver/transport/stream_id.rb +26 -0
- data/lib/quicsilver/version.rb +1 -1
- data/lib/quicsilver.rb +22 -11
- data/lib/rackup/handler/quicsilver.rb +57 -38
- data/quicsilver.gemspec +10 -3
- metadata +54 -8
- data/benchmarks/concurrent.rb +0 -110
- data/benchmarks/quicsilver_server.rb +0 -46
- data/benchmarks/rails.rb +0 -170
- data/benchmarks/throughput.rb +0 -113
- data/lib/quicsilver/quicsilver.bundle +0 -0
|
@@ -8,43 +8,80 @@ module Quicsilver
|
|
|
8
8
|
# Quicsilver::Client.get("example.com", 4433, "/users")
|
|
9
9
|
#
|
|
10
10
|
class ConnectionPool
|
|
11
|
-
attr_reader :max_size, :idle_timeout
|
|
11
|
+
attr_reader :max_size, :idle_timeout, :mode
|
|
12
12
|
|
|
13
13
|
DEFAULT_MAX_SIZE = 4
|
|
14
14
|
DEFAULT_IDLE_TIMEOUT = 60 # seconds
|
|
15
|
-
|
|
16
|
-
|
|
15
|
+
DEFAULT_CHECKOUT_TIMEOUT = 5 # seconds
|
|
16
|
+
|
|
17
|
+
# @param mode [:exclusive, :shared] Pool strategy.
|
|
18
|
+
# :shared (default) — one connection per host, all threads share it via
|
|
19
|
+
# QUIC stream multiplexing. 5x faster, one TLS handshake per host.
|
|
20
|
+
# :exclusive — one connection per checkout, like ActiveRecord. Use for
|
|
21
|
+
# maintenance tasks that need isolation or servers with low stream limits.
|
|
22
|
+
def initialize(max_size: DEFAULT_MAX_SIZE, idle_timeout: DEFAULT_IDLE_TIMEOUT, checkout_timeout: DEFAULT_CHECKOUT_TIMEOUT, mode: :shared)
|
|
17
23
|
@max_size = max_size
|
|
18
24
|
@idle_timeout = idle_timeout
|
|
19
|
-
@
|
|
25
|
+
@checkout_timeout = checkout_timeout
|
|
26
|
+
@mode = mode
|
|
27
|
+
@pools = {} # connection key => [{ client:, checked_out: }]
|
|
28
|
+
@keys = {}.compare_by_identity # client => the key it was pooled under
|
|
20
29
|
@mutex = Mutex.new
|
|
30
|
+
@condition = ConditionVariable.new
|
|
21
31
|
end
|
|
22
32
|
|
|
33
|
+
# Options that change the connection itself, so a pooled connection built
|
|
34
|
+
# with one set must never be handed to a caller asking for another.
|
|
35
|
+
# unsecure is the important one: it disables certificate validation.
|
|
36
|
+
CONNECTION_OPTIONS = %i[
|
|
37
|
+
unsecure
|
|
38
|
+
datagram_receive_enabled
|
|
39
|
+
reliable_reset_enabled
|
|
40
|
+
transport_cibir_id
|
|
41
|
+
idle_timeout_ms
|
|
42
|
+
].freeze
|
|
43
|
+
|
|
23
44
|
# Check out a connected Client. Reuses an idle one or creates a new one.
|
|
45
|
+
# In :exclusive mode, blocks with timeout if pool is full.
|
|
46
|
+
# In :shared mode, returns the shared connection (all threads use one connection).
|
|
24
47
|
def checkout(hostname, port, **options)
|
|
25
|
-
|
|
48
|
+
@mode == :shared ? checkout_shared(hostname, port, **options) : checkout_exclusive(hostname, port, **options)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private def checkout_exclusive(hostname, port, **options)
|
|
52
|
+
key = connection_key(hostname, port, options)
|
|
53
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + @checkout_timeout
|
|
26
54
|
|
|
27
55
|
@mutex.synchronize do
|
|
28
|
-
|
|
56
|
+
loop do
|
|
57
|
+
entries = @pools[key] ||= []
|
|
58
|
+
|
|
59
|
+
# Evict dead/stale/draining
|
|
60
|
+
entries.reject! do |e|
|
|
61
|
+
if !e[:checked_out] && (!e[:client].connected? || e[:client].draining? || e[:last_used] < Time.now - @idle_timeout)
|
|
62
|
+
e[:client].close_connection
|
|
63
|
+
true
|
|
64
|
+
end
|
|
65
|
+
end
|
|
29
66
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
if
|
|
33
|
-
|
|
34
|
-
|
|
67
|
+
# Reuse an idle client (skip draining ones)
|
|
68
|
+
idle = entries.find { |e| !e[:checked_out] && e[:client].connected? && !e[:client].draining? }
|
|
69
|
+
if idle
|
|
70
|
+
idle[:checked_out] = true
|
|
71
|
+
idle[:last_used] = Time.now
|
|
72
|
+
return idle[:client]
|
|
35
73
|
end
|
|
36
|
-
end
|
|
37
74
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
if idle
|
|
41
|
-
idle[:checked_out] = true
|
|
42
|
-
idle[:last_used] = Time.now
|
|
43
|
-
return idle[:client]
|
|
44
|
-
end
|
|
75
|
+
# Room to create a new connection
|
|
76
|
+
break if entries.size < @max_size
|
|
45
77
|
|
|
46
|
-
|
|
47
|
-
|
|
78
|
+
# Pool full — wait for a checkin
|
|
79
|
+
remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
80
|
+
if remaining <= 0
|
|
81
|
+
raise ConnectionError,
|
|
82
|
+
"Connection pool full for #{hostname}:#{port} (waited #{@checkout_timeout}s, max: #{@max_size})"
|
|
83
|
+
end
|
|
84
|
+
@condition.wait(@mutex, remaining)
|
|
48
85
|
end
|
|
49
86
|
end
|
|
50
87
|
|
|
@@ -54,17 +91,64 @@ module Quicsilver
|
|
|
54
91
|
|
|
55
92
|
@mutex.synchronize do
|
|
56
93
|
(@pools[key] ||= []) << { client: client, checked_out: true, last_used: Time.now }
|
|
94
|
+
@keys[client] = key
|
|
57
95
|
end
|
|
58
96
|
|
|
59
97
|
client
|
|
60
98
|
end
|
|
61
99
|
|
|
62
|
-
#
|
|
100
|
+
# Shared mode: one connection per host, all threads share it.
|
|
101
|
+
# Each thread opens its own QUIC stream on the shared connection.
|
|
102
|
+
# No checkout/checkin semantics — the connection is never "owned".
|
|
103
|
+
private def checkout_shared(hostname, port, **options)
|
|
104
|
+
key = connection_key(hostname, port, options)
|
|
105
|
+
|
|
106
|
+
@mutex.synchronize do
|
|
107
|
+
entry = @pools[key]&.first
|
|
108
|
+
if entry && entry[:client].connected? && !entry[:client].draining?
|
|
109
|
+
entry[:last_used] = Time.now
|
|
110
|
+
return entry[:client]
|
|
111
|
+
end
|
|
112
|
+
# Remove stale entry
|
|
113
|
+
@pools.delete(key) if entry
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Create outside the lock (blocking I/O)
|
|
117
|
+
client = Client.new(hostname, port, **options)
|
|
118
|
+
client.open_connection
|
|
119
|
+
|
|
120
|
+
@mutex.synchronize do
|
|
121
|
+
# Double-check — another thread may have created one while we were connecting
|
|
122
|
+
existing = @pools[key]&.first
|
|
123
|
+
if existing && existing[:client].connected? && !existing[:client].draining?
|
|
124
|
+
client.close_connection
|
|
125
|
+
existing[:last_used] = Time.now
|
|
126
|
+
return existing[:client]
|
|
127
|
+
end
|
|
128
|
+
@pools[key] = [{ client: client, checked_out: false, last_used: Time.now }]
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
client
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Block-based checkout — auto-checkin on completion or error.
|
|
135
|
+
#
|
|
136
|
+
# pool.with("example.com", 443) { |client| client.get("/") }
|
|
137
|
+
#
|
|
138
|
+
def with(hostname, port, **options)
|
|
139
|
+
client = checkout(hostname, port, **options)
|
|
140
|
+
yield client
|
|
141
|
+
ensure
|
|
142
|
+
checkin(client) if client
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Return a Client to the pool. No-op in shared mode.
|
|
63
146
|
def checkin(client)
|
|
64
|
-
|
|
147
|
+
return if @mode == :shared
|
|
65
148
|
|
|
66
149
|
@mutex.synchronize do
|
|
67
|
-
|
|
150
|
+
key = @keys[client]
|
|
151
|
+
entries = key && @pools[key]
|
|
68
152
|
return unless entries
|
|
69
153
|
|
|
70
154
|
entry = entries.find { |e| e[:client].equal?(client) }
|
|
@@ -75,9 +159,12 @@ module Quicsilver
|
|
|
75
159
|
entry[:last_used] = Time.now
|
|
76
160
|
else
|
|
77
161
|
entries.delete(entry)
|
|
162
|
+
@keys.delete(client)
|
|
78
163
|
client.close_connection
|
|
79
164
|
@pools.delete(key) if entries.empty?
|
|
80
165
|
end
|
|
166
|
+
|
|
167
|
+
@condition.broadcast # wake threads waiting for a connection
|
|
81
168
|
end
|
|
82
169
|
end
|
|
83
170
|
|
|
@@ -88,19 +175,28 @@ module Quicsilver
|
|
|
88
175
|
entries.each { |e| e[:client].close_connection }
|
|
89
176
|
end
|
|
90
177
|
@pools.clear
|
|
178
|
+
@keys.clear
|
|
91
179
|
end
|
|
92
180
|
end
|
|
93
181
|
|
|
94
|
-
# Total clients in the pool, optionally filtered by host
|
|
182
|
+
# Total clients in the pool, optionally filtered by host and port across
|
|
183
|
+
# every option set pooled for them.
|
|
95
184
|
def size(host = nil, port = nil)
|
|
96
185
|
@mutex.synchronize do
|
|
97
186
|
if host && port
|
|
98
|
-
|
|
187
|
+
prefix = "#{host}:#{port}\0"
|
|
188
|
+
@pools.sum { |key, entries| key.start_with?(prefix) ? entries.size : 0 }
|
|
99
189
|
else
|
|
100
190
|
@pools.values.sum(&:size)
|
|
101
191
|
end
|
|
102
192
|
end
|
|
103
193
|
end
|
|
194
|
+
|
|
195
|
+
private def connection_key(hostname, port, options)
|
|
196
|
+
defaults = Client::DEFAULT_CONNECTION_OPTIONS
|
|
197
|
+
identity = CONNECTION_OPTIONS.map { |name| options.fetch(name, defaults[name]).inspect }
|
|
198
|
+
"#{hostname}:#{port}\0#{identity.join("\0")}"
|
|
199
|
+
end
|
|
104
200
|
end
|
|
105
201
|
end
|
|
106
202
|
end
|
|
@@ -5,9 +5,7 @@ module Quicsilver
|
|
|
5
5
|
class Request
|
|
6
6
|
attr_reader :stream, :status
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
class ResetError < StandardError
|
|
8
|
+
class ResetError < Quicsilver::Error
|
|
11
9
|
attr_reader :error_code
|
|
12
10
|
|
|
13
11
|
def initialize(message, error_code = nil)
|
|
@@ -21,8 +19,42 @@ module Quicsilver
|
|
|
21
19
|
@stream = stream
|
|
22
20
|
@status = :pending
|
|
23
21
|
@queue = Queue.new
|
|
22
|
+
@streaming_queue = Queue.new
|
|
24
23
|
@mutex = Mutex.new
|
|
25
24
|
@response = nil
|
|
25
|
+
@streaming_response = nil
|
|
26
|
+
@streaming_requested = false
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Whether the caller has opted into streaming via streaming_response.
|
|
30
|
+
def streaming_requested?
|
|
31
|
+
@streaming_requested
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Block until streaming response headers arrive.
|
|
35
|
+
# Returns a StreamingResponse with status, headers, and a readable body.
|
|
36
|
+
# Body data arrives incrementally — call body.read in a loop.
|
|
37
|
+
#
|
|
38
|
+
# streaming = request.streaming_response(timeout: 10)
|
|
39
|
+
# streaming.status # => 200
|
|
40
|
+
# streaming.headers # => {"content-type" => "video/mp4"}
|
|
41
|
+
# while (chunk = streaming.body.read)
|
|
42
|
+
# file.write(chunk)
|
|
43
|
+
# end
|
|
44
|
+
#
|
|
45
|
+
def streaming_response(timeout: nil)
|
|
46
|
+
timeout ||= @client.request_timeout
|
|
47
|
+
@streaming_requested = true
|
|
48
|
+
return @streaming_response if @streaming_response
|
|
49
|
+
|
|
50
|
+
result = @streaming_queue.pop(timeout: timeout)
|
|
51
|
+
raise Quicsilver::TimeoutError, "Streaming response timeout after #{timeout}s" if result.nil?
|
|
52
|
+
|
|
53
|
+
if result.is_a?(Hash) && result[:error]
|
|
54
|
+
raise ResetError.new(result[:message] || "Stream reset", result[:error_code])
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
@streaming_response = result
|
|
26
58
|
end
|
|
27
59
|
|
|
28
60
|
# Block until response arrives or timeout
|
|
@@ -38,28 +70,55 @@ module Quicsilver
|
|
|
38
70
|
case result
|
|
39
71
|
when nil
|
|
40
72
|
raise Quicsilver::TimeoutError, "Request timeout after #{timeout}s"
|
|
73
|
+
when Response
|
|
74
|
+
@status = :completed
|
|
75
|
+
@response = result
|
|
41
76
|
when Hash
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
else
|
|
46
|
-
@status = :completed
|
|
47
|
-
@response = result
|
|
48
|
-
end
|
|
77
|
+
# Error hash from #fail
|
|
78
|
+
@status = :error
|
|
79
|
+
raise ResetError.new(result[:message] || "Stream reset by peer", result[:error_code])
|
|
49
80
|
end
|
|
50
81
|
end
|
|
51
82
|
|
|
52
83
|
@response
|
|
53
84
|
end
|
|
54
85
|
|
|
86
|
+
# Stream request body in chunks. Only valid when build_request was
|
|
87
|
+
# called with body: :stream.
|
|
88
|
+
#
|
|
89
|
+
# req = client.build_request('POST', '/upload', body: :stream)
|
|
90
|
+
# req.stream_body do |writer|
|
|
91
|
+
# File.open('big.mp4') do |f|
|
|
92
|
+
# while (chunk = f.read(16_384))
|
|
93
|
+
# writer.write(chunk)
|
|
94
|
+
# end
|
|
95
|
+
# end
|
|
96
|
+
# end
|
|
97
|
+
# req.response # wait for server response
|
|
98
|
+
#
|
|
99
|
+
def stream_body
|
|
100
|
+
writer = BodyWriter.new(@stream)
|
|
101
|
+
yield writer
|
|
102
|
+
writer.finish
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Reprioritise this stream (RFC 9218). Sends PRIORITY_UPDATE on the
|
|
106
|
+
# control stream. Can be called while the response is in flight.
|
|
107
|
+
#
|
|
108
|
+
# req = client.build_request("GET", "/video", priority: Priority.new(urgency: 2))
|
|
109
|
+
# req.update_priority(Priority.new(urgency: 6)) # deprioritise
|
|
110
|
+
#
|
|
111
|
+
def update_priority(priority)
|
|
112
|
+
@client.send_priority_update(@stream.stream_id, priority)
|
|
113
|
+
end
|
|
114
|
+
|
|
55
115
|
# Cancel the request (sends RESET_STREAM + STOP_SENDING to server)
|
|
56
116
|
# error_code defaults to H3_REQUEST_CANCELLED (0x10c)
|
|
57
117
|
def cancel(error_code: Protocol::H3_REQUEST_CANCELLED)
|
|
58
118
|
@mutex.synchronize do
|
|
59
119
|
return false unless @status == :pending
|
|
60
120
|
|
|
61
|
-
@stream.
|
|
62
|
-
@stream.stop_sending(error_code)
|
|
121
|
+
@stream.abort(error_code)
|
|
63
122
|
@status = :cancelled
|
|
64
123
|
end
|
|
65
124
|
true
|
|
@@ -80,18 +139,51 @@ module Quicsilver
|
|
|
80
139
|
@status == :cancelled
|
|
81
140
|
end
|
|
82
141
|
|
|
83
|
-
#
|
|
142
|
+
# Writes request body DATA frames to a QUIC stream.
|
|
143
|
+
class BodyWriter
|
|
144
|
+
def initialize(stream)
|
|
145
|
+
@stream = stream
|
|
146
|
+
@finished = false
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Write a chunk as an HTTP/3 DATA frame.
|
|
150
|
+
def write(chunk)
|
|
151
|
+
raise "Body already finished" if @finished
|
|
152
|
+
payload = chunk.b
|
|
153
|
+
data = Protocol.encode_varint(Protocol::FRAME_DATA) +
|
|
154
|
+
Protocol.encode_varint(payload.bytesize) +
|
|
155
|
+
payload
|
|
156
|
+
@stream.send(data, fin: false)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Send FIN to close the request body. Called automatically
|
|
160
|
+
# at the end of stream_body.
|
|
161
|
+
def finish
|
|
162
|
+
return if @finished
|
|
163
|
+
@finished = true
|
|
164
|
+
@stream.send("".b, fin: true)
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# Called by Client when buffered response arrives
|
|
84
169
|
def complete(response) # :nodoc:
|
|
85
170
|
@queue.push(response)
|
|
86
171
|
end
|
|
87
172
|
|
|
173
|
+
# Called by Client when streaming headers are parsed
|
|
174
|
+
def deliver_streaming(streaming_response) # :nodoc:
|
|
175
|
+
@streaming_queue.push(streaming_response)
|
|
176
|
+
end
|
|
177
|
+
|
|
88
178
|
# Called by Client on stream reset from peer or connection close
|
|
89
179
|
def fail(error_code, message = nil) # :nodoc:
|
|
90
180
|
@mutex.synchronize do
|
|
91
181
|
return if @status == :cancelled
|
|
92
182
|
@status = :error
|
|
93
183
|
end
|
|
94
|
-
|
|
184
|
+
error = { error: true, error_code: error_code, message: message }
|
|
185
|
+
@queue.push(error)
|
|
186
|
+
@streaming_queue.push(error)
|
|
95
187
|
end
|
|
96
188
|
end
|
|
97
189
|
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Quicsilver
|
|
4
|
+
class Error < StandardError; end
|
|
5
|
+
|
|
6
|
+
# Transport errors from MsQuic. The C extension raises RuntimeError
|
|
7
|
+
# which Ruby call sites wrap into typed TransportError subclasses.
|
|
8
|
+
# Carries the MsQuic QUIC_STATUS code parsed from the error message.
|
|
9
|
+
#
|
|
10
|
+
# rescue Quicsilver::TransportError => e
|
|
11
|
+
# e.message # => "StreamOpen failed, 0x1!"
|
|
12
|
+
# e.status # => 1 (QUIC_STATUS_INVALID_STATE / EPERM)
|
|
13
|
+
#
|
|
14
|
+
class TransportError < Error
|
|
15
|
+
attr_reader :status
|
|
16
|
+
|
|
17
|
+
def initialize(message = nil, status: 0)
|
|
18
|
+
@status = status
|
|
19
|
+
super(message)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Parse MsQuic hex status from C extension error messages.
|
|
23
|
+
# "StreamOpen failed, 0x1!" => 0x1 (EPERM / INVALID_STATE)
|
|
24
|
+
def self.parse_status(message)
|
|
25
|
+
message&.match(/0x([0-9a-fA-F]+)/) { |m| m[1].to_i(16) } || 0
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
class ServerIsRunningError < Error; end
|
|
30
|
+
class ServerConfigurationError < Error; end
|
|
31
|
+
class ServerListenerError < Error; end
|
|
32
|
+
class ServerError < Error; end
|
|
33
|
+
class ConnectionError < Error; end
|
|
34
|
+
class TimeoutError < Error; end
|
|
35
|
+
class GoAwayError < Error; end
|
|
36
|
+
class StreamFailedToOpenError < TransportError; end
|
|
37
|
+
class CancelledError < Error; end
|
|
38
|
+
|
|
39
|
+
end
|
|
Binary file
|
|
@@ -3,10 +3,24 @@
|
|
|
3
3
|
require "protocol/http/request"
|
|
4
4
|
require "protocol/http/response"
|
|
5
5
|
require "protocol/http/headers"
|
|
6
|
+
require "protocol/http/peer"
|
|
6
7
|
require_relative "stream_input"
|
|
7
8
|
require_relative "stream_output"
|
|
8
9
|
|
|
9
10
|
module Quicsilver
|
|
11
|
+
module Protocol
|
|
12
|
+
# HTTP/3 request with peer address from the QUIC connection.
|
|
13
|
+
class Request < ::Protocol::HTTP::Request
|
|
14
|
+
attr_reader :peer, :transport_context, :rack_context
|
|
15
|
+
|
|
16
|
+
def initialize(*args, peer: nil, transport_context: nil, rack_context: nil, **kwargs)
|
|
17
|
+
super(*args, **kwargs)
|
|
18
|
+
@peer = peer
|
|
19
|
+
@transport_context = transport_context
|
|
20
|
+
@rack_context = rack_context
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
10
24
|
module Protocol
|
|
11
25
|
# Converts between QUIC/HTTP/3 frames and protocol-http Request/Response
|
|
12
26
|
# objects. This enables integration with Falcon and any other server
|
|
@@ -37,7 +51,11 @@ module Quicsilver
|
|
|
37
51
|
# @return [Array(Protocol::HTTP::Request, Protocol::StreamInput)] request and body.
|
|
38
52
|
# Body is nil for bodyless methods (GET, HEAD, TRACE).
|
|
39
53
|
# Caller feeds RECEIVE data into body via write(), then close_write on FIN.
|
|
40
|
-
|
|
54
|
+
# @param headers [Hash] Parsed headers from RequestParser (includes pseudo-headers).
|
|
55
|
+
# @param remote_address [String, nil] Peer IP from the QUIC connection (e.g. "127.0.0.1").
|
|
56
|
+
# @param remote_port [Integer] Peer port from the QUIC connection.
|
|
57
|
+
# @param transport_context [Hash, nil] Quicsilver transport metadata for request debugging/log tags.
|
|
58
|
+
def build_request(headers, remote_address: nil, remote_port: 0, transport_context: nil, rack_context: nil)
|
|
41
59
|
method = headers[":method"]
|
|
42
60
|
scheme = headers[":scheme"] || "https"
|
|
43
61
|
authority = headers[":authority"]
|
|
@@ -55,9 +73,16 @@ module Quicsilver
|
|
|
55
73
|
Protocol::StreamInput.new(content_length)
|
|
56
74
|
end
|
|
57
75
|
|
|
58
|
-
|
|
76
|
+
peer = if remote_address
|
|
77
|
+
::Protocol::HTTP::Peer.new(Addrinfo.tcp(remote_address, remote_port))
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
request = Request.new(
|
|
59
81
|
scheme, authority, method, path, VERSION,
|
|
60
|
-
protocol_headers, body, protocol
|
|
82
|
+
protocol_headers, body, protocol,
|
|
83
|
+
peer: peer,
|
|
84
|
+
transport_context: transport_context,
|
|
85
|
+
rack_context: rack_context
|
|
61
86
|
)
|
|
62
87
|
|
|
63
88
|
[request, body]
|
|
@@ -76,7 +101,7 @@ module Quicsilver
|
|
|
76
101
|
status = response.status
|
|
77
102
|
headers = response.headers
|
|
78
103
|
trailers = extract_trailers(headers)
|
|
79
|
-
headers_hash =
|
|
104
|
+
headers_hash = response_header_fields(headers)
|
|
80
105
|
body = response.body
|
|
81
106
|
|
|
82
107
|
if body.nil? || head_request
|
|
@@ -143,33 +168,28 @@ module Quicsilver
|
|
|
143
168
|
result
|
|
144
169
|
end
|
|
145
170
|
|
|
146
|
-
#
|
|
147
|
-
#
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
171
|
+
# Response header name/value pairs for ResponseEncoder. Only headers, not trailers.
|
|
172
|
+
#
|
|
173
|
+
# Pairs rather than a Hash because a field can legitimately repeat, and set-cookie MUST NOT be
|
|
174
|
+
# combined (RFC 9110 §5.3). Hashing drops every repeat but the last, which silently discarded
|
|
175
|
+
# the session cookie of any Rails response that set more than one — the app still worked, it
|
|
176
|
+
# just never stayed signed in.
|
|
177
|
+
def response_header_fields(headers)
|
|
178
|
+
return [] unless headers
|
|
179
|
+
|
|
180
|
+
headers.header.each.to_a
|
|
152
181
|
end
|
|
153
182
|
|
|
154
183
|
# 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
184
|
def build_headers_frame(status, headers)
|
|
164
185
|
pairs = [[":status", status.to_s]]
|
|
165
186
|
headers.each { |name, value| pairs << [name.to_s.downcase, value.to_s] }
|
|
166
|
-
|
|
187
|
+
Protocol.build_headers_frame(pairs, encoder: @qpack_encoder)
|
|
167
188
|
end
|
|
168
189
|
|
|
169
|
-
# Build a trailer HEADERS frame
|
|
170
190
|
def build_trailer_frame(trailers)
|
|
171
191
|
pairs = trailers.map { |name, value| [name.to_s.downcase, value.to_s] }
|
|
172
|
-
|
|
192
|
+
Protocol.build_headers_frame(pairs, encoder: @qpack_encoder)
|
|
173
193
|
end
|
|
174
194
|
end
|
|
175
195
|
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Quicsilver
|
|
4
|
+
module Protocol
|
|
5
|
+
module Capsule
|
|
6
|
+
DATAGRAM = 0x00
|
|
7
|
+
MAX_PAYLOAD_SIZE = 1_048_576
|
|
8
|
+
|
|
9
|
+
ParseError = Class.new(StandardError)
|
|
10
|
+
PayloadTooLarge = Class.new(ParseError)
|
|
11
|
+
|
|
12
|
+
class << self
|
|
13
|
+
def encode(type, payload)
|
|
14
|
+
payload = payload.to_s.b
|
|
15
|
+
Protocol.encode_varint(type) + Protocol.encode_varint(payload.bytesize) + payload
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def parse(buffer, max_payload_size: MAX_PAYLOAD_SIZE)
|
|
19
|
+
type, type_length = Protocol.decode_varint_str(buffer, 0)
|
|
20
|
+
return unless type_length > 0
|
|
21
|
+
|
|
22
|
+
length, length_length = Protocol.decode_varint_str(buffer, type_length)
|
|
23
|
+
return unless length_length > 0
|
|
24
|
+
|
|
25
|
+
raise PayloadTooLarge, "Capsule payload too large" if length > max_payload_size
|
|
26
|
+
|
|
27
|
+
header_length = type_length + length_length
|
|
28
|
+
return if buffer.bytesize < header_length + length
|
|
29
|
+
|
|
30
|
+
payload = buffer.byteslice(header_length, length) || "".b
|
|
31
|
+
remainder = buffer.byteslice(header_length + length..-1) || "".b
|
|
32
|
+
|
|
33
|
+
[type, payload, remainder]
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "frame_parser"
|
|
4
|
+
|
|
5
|
+
module Quicsilver
|
|
6
|
+
module Protocol
|
|
7
|
+
# Incrementally yields CONNECT DATA payloads without waiting for whole
|
|
8
|
+
# frames. Unknown frame payloads are skipped as bytes arrive.
|
|
9
|
+
class ConnectStreamDecoder
|
|
10
|
+
def initialize
|
|
11
|
+
@buffer = "".b
|
|
12
|
+
@frame_type = nil
|
|
13
|
+
@frame_remaining = nil
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# A caller may break after a terminal capsule. Unprocessed bytes remain
|
|
17
|
+
# available to buffered? so it can reject data following that capsule.
|
|
18
|
+
def each(data)
|
|
19
|
+
@buffer << data if data
|
|
20
|
+
until @buffer.empty?
|
|
21
|
+
break unless @frame_remaining || read_frame_header
|
|
22
|
+
|
|
23
|
+
chunk = consume_payload
|
|
24
|
+
yield chunk if @frame_type == FRAME_DATA
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def buffered?
|
|
29
|
+
!@buffer.empty?
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def finish!
|
|
33
|
+
if buffered? || @frame_remaining
|
|
34
|
+
raise FrameError.new("Truncated HTTP/3 frame", error_code: H3_FRAME_ERROR)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def clear
|
|
39
|
+
@buffer.clear
|
|
40
|
+
@frame_remaining = nil
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def read_frame_header
|
|
46
|
+
type, type_length = Protocol.decode_varint_str(@buffer, 0)
|
|
47
|
+
return false if type_length == 0
|
|
48
|
+
length, length_length = Protocol.decode_varint_str(@buffer, type_length)
|
|
49
|
+
return false if length_length == 0
|
|
50
|
+
|
|
51
|
+
# RFC 9114 §4.4 forbids known non-DATA frames after CONNECT,
|
|
52
|
+
# including HEADERS trailers; these are connection errors.
|
|
53
|
+
if CONTROL_ONLY_FRAME_SET.key?(type) ||
|
|
54
|
+
HTTP2_RESERVED_FRAME_SET.key?(type) ||
|
|
55
|
+
[FRAME_HEADERS, FRAME_PUSH_PROMISE, FRAME_PRIORITY_UPDATE].include?(type)
|
|
56
|
+
raise FrameError, "Frame not allowed on CONNECT"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
@frame_type = type
|
|
60
|
+
@frame_remaining = length
|
|
61
|
+
@buffer = @buffer.byteslice(type_length + length_length..) || "".b
|
|
62
|
+
true
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def consume_payload
|
|
66
|
+
count = [@frame_remaining, @buffer.bytesize].min
|
|
67
|
+
chunk = @buffer.byteslice(0, count)
|
|
68
|
+
@buffer = @buffer.byteslice(count..) || "".b
|
|
69
|
+
@frame_remaining -= count
|
|
70
|
+
@frame_remaining = nil if @frame_remaining.zero?
|
|
71
|
+
chunk
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|