quicsilver 0.5.0-x86_64-linux
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 +7 -0
- data/.github/workflows/ci.yml +49 -0
- data/.github/workflows/cibuildgem.yaml +117 -0
- data/.gitignore +21 -0
- data/.gitmodules +3 -0
- data/.ruby-version +1 -0
- data/CHANGELOG.md +175 -0
- data/Gemfile +6 -0
- data/Gemfile.lock +67 -0
- data/LICENSE +21 -0
- data/README.md +261 -0
- data/Rakefile +130 -0
- data/benchmarks/baseline.rb +175 -0
- data/benchmarks/compare.rb +398 -0
- data/benchmarks/components.rb +191 -0
- data/benchmarks/helpers.rb +162 -0
- data/benchmarks/latency.rb +128 -0
- data/benchmarks/loss.rb +193 -0
- data/benchmarks/mobile.rb +125 -0
- data/bin/console +14 -0
- data/bin/setup +8 -0
- data/docs/connection_identity.md +141 -0
- data/examples/README.md +58 -0
- data/examples/benchmark.rb +191 -0
- data/examples/connection_pool_demo.rb +47 -0
- data/examples/example_helper.rb +69 -0
- data/examples/falcon_middleware.rb +44 -0
- data/examples/feature_demo.rb +125 -0
- data/examples/grpc_style.rb +97 -0
- data/examples/latency_aware_routing.rb +382 -0
- data/examples/lifecycle_drain.rb +264 -0
- data/examples/minimal_http3_server.rb +14 -0
- data/examples/priorities.rb +60 -0
- data/examples/protocol_http_server.rb +31 -0
- data/examples/rack_http3_server.rb +42 -0
- data/examples/rails_feature_test.rb +280 -0
- data/examples/server_stats.rb +117 -0
- data/examples/simple_client_test.rb +26 -0
- data/examples/streaming_sse.rb +33 -0
- data/examples/trailers.rb +69 -0
- data/examples/webtransport_smoke.rb +546 -0
- data/examples/worker_load_balancing.rb +425 -0
- data/ext/quicsilver/extconf.rb +124 -0
- 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 +2741 -0
- data/lib/quicsilver/3.4/quicsilver.so +0 -0
- data/lib/quicsilver/4.0/quicsilver.so +0 -0
- data/lib/quicsilver/client/client.rb +708 -0
- data/lib/quicsilver/client/connection_pool.rb +202 -0
- data/lib/quicsilver/client/request.rb +190 -0
- data/lib/quicsilver/errors.rb +39 -0
- data/lib/quicsilver/libmsquic.so.2 +0 -0
- data/lib/quicsilver/protocol/adapter.rb +196 -0
- 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 +117 -0
- data/lib/quicsilver/protocol/datagram.rb +40 -0
- data/lib/quicsilver/protocol/frame_parser.rb +141 -0
- data/lib/quicsilver/protocol/frame_reader.rb +84 -0
- data/lib/quicsilver/protocol/frames.rb +454 -0
- data/lib/quicsilver/protocol/priority.rb +65 -0
- data/lib/quicsilver/protocol/qpack/decoder.rb +127 -0
- data/lib/quicsilver/protocol/qpack/encoder.rb +217 -0
- data/lib/quicsilver/protocol/qpack/header_block_decoder.rb +140 -0
- data/lib/quicsilver/protocol/qpack/huffman.rb +459 -0
- data/lib/quicsilver/protocol/request_encoder.rb +50 -0
- data/lib/quicsilver/protocol/request_parser.rb +289 -0
- data/lib/quicsilver/protocol/response_encoder.rb +107 -0
- data/lib/quicsilver/protocol/response_parser.rb +147 -0
- data/lib/quicsilver/protocol/stream_input.rb +98 -0
- data/lib/quicsilver/protocol/stream_output.rb +59 -0
- 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/listener_data.rb +14 -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 +149 -0
- data/lib/quicsilver/server/request_registry.rb +63 -0
- 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 +1080 -0
- 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 +234 -0
- data/lib/quicsilver/transport/connection.rb +594 -0
- data/lib/quicsilver/transport/connection_stats.rb +53 -0
- data/lib/quicsilver/transport/event_loop.rb +74 -0
- data/lib/quicsilver/transport/inbound_stream.rb +53 -0
- data/lib/quicsilver/transport/stream.rb +43 -0
- data/lib/quicsilver/transport/stream_event.rb +49 -0
- data/lib/quicsilver/transport/stream_id.rb +26 -0
- data/lib/quicsilver/version.rb +3 -0
- data/lib/quicsilver.rb +96 -0
- data/lib/rackup/handler/quicsilver.rb +96 -0
- data/quicsilver.gemspec +66 -0
- metadata +362 -0
|
@@ -0,0 +1,202 @@
|
|
|
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, :mode
|
|
12
|
+
|
|
13
|
+
DEFAULT_MAX_SIZE = 4
|
|
14
|
+
DEFAULT_IDLE_TIMEOUT = 60 # seconds
|
|
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)
|
|
23
|
+
@max_size = max_size
|
|
24
|
+
@idle_timeout = idle_timeout
|
|
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
|
|
29
|
+
@mutex = Mutex.new
|
|
30
|
+
@condition = ConditionVariable.new
|
|
31
|
+
end
|
|
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
|
+
|
|
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).
|
|
47
|
+
def checkout(hostname, port, **options)
|
|
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
|
|
54
|
+
|
|
55
|
+
@mutex.synchronize do
|
|
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
|
|
66
|
+
|
|
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]
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Room to create a new connection
|
|
76
|
+
break if entries.size < @max_size
|
|
77
|
+
|
|
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)
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Create outside the lock (blocking I/O)
|
|
89
|
+
client = Client.new(hostname, port, **options)
|
|
90
|
+
client.open_connection
|
|
91
|
+
|
|
92
|
+
@mutex.synchronize do
|
|
93
|
+
(@pools[key] ||= []) << { client: client, checked_out: true, last_used: Time.now }
|
|
94
|
+
@keys[client] = key
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
client
|
|
98
|
+
end
|
|
99
|
+
|
|
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.
|
|
146
|
+
def checkin(client)
|
|
147
|
+
return if @mode == :shared
|
|
148
|
+
|
|
149
|
+
@mutex.synchronize do
|
|
150
|
+
key = @keys[client]
|
|
151
|
+
entries = key && @pools[key]
|
|
152
|
+
return unless entries
|
|
153
|
+
|
|
154
|
+
entry = entries.find { |e| e[:client].equal?(client) }
|
|
155
|
+
return unless entry
|
|
156
|
+
|
|
157
|
+
if client.connected?
|
|
158
|
+
entry[:checked_out] = false
|
|
159
|
+
entry[:last_used] = Time.now
|
|
160
|
+
else
|
|
161
|
+
entries.delete(entry)
|
|
162
|
+
@keys.delete(client)
|
|
163
|
+
client.close_connection
|
|
164
|
+
@pools.delete(key) if entries.empty?
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
@condition.broadcast # wake threads waiting for a connection
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Close all clients.
|
|
172
|
+
def close
|
|
173
|
+
@mutex.synchronize do
|
|
174
|
+
@pools.each_value do |entries|
|
|
175
|
+
entries.each { |e| e[:client].close_connection }
|
|
176
|
+
end
|
|
177
|
+
@pools.clear
|
|
178
|
+
@keys.clear
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# Total clients in the pool, optionally filtered by host and port across
|
|
183
|
+
# every option set pooled for them.
|
|
184
|
+
def size(host = nil, port = nil)
|
|
185
|
+
@mutex.synchronize do
|
|
186
|
+
if host && port
|
|
187
|
+
prefix = "#{host}:#{port}\0"
|
|
188
|
+
@pools.sum { |key, entries| key.start_with?(prefix) ? entries.size : 0 }
|
|
189
|
+
else
|
|
190
|
+
@pools.values.sum(&:size)
|
|
191
|
+
end
|
|
192
|
+
end
|
|
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
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
end
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Quicsilver
|
|
4
|
+
class Client
|
|
5
|
+
class Request
|
|
6
|
+
attr_reader :stream, :status
|
|
7
|
+
|
|
8
|
+
class ResetError < Quicsilver::Error
|
|
9
|
+
attr_reader :error_code
|
|
10
|
+
|
|
11
|
+
def initialize(message, error_code = nil)
|
|
12
|
+
super(message)
|
|
13
|
+
@error_code = error_code
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def initialize(client, stream)
|
|
18
|
+
@client = client
|
|
19
|
+
@stream = stream
|
|
20
|
+
@status = :pending
|
|
21
|
+
@queue = Queue.new
|
|
22
|
+
@streaming_queue = Queue.new
|
|
23
|
+
@mutex = Mutex.new
|
|
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
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Block until response arrives or timeout
|
|
61
|
+
# Returns response hash { status:, headers:, body: }
|
|
62
|
+
# Raises TimeoutError, CancelledError, or ResetError
|
|
63
|
+
def response(timeout: nil)
|
|
64
|
+
timeout ||= @client.request_timeout
|
|
65
|
+
return @response if @status == :completed
|
|
66
|
+
|
|
67
|
+
result = @queue.pop(timeout: timeout)
|
|
68
|
+
|
|
69
|
+
@mutex.synchronize do
|
|
70
|
+
case result
|
|
71
|
+
when nil
|
|
72
|
+
raise Quicsilver::TimeoutError, "Request timeout after #{timeout}s"
|
|
73
|
+
when Response
|
|
74
|
+
@status = :completed
|
|
75
|
+
@response = result
|
|
76
|
+
when Hash
|
|
77
|
+
# Error hash from #fail
|
|
78
|
+
@status = :error
|
|
79
|
+
raise ResetError.new(result[:message] || "Stream reset by peer", result[:error_code])
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
@response
|
|
84
|
+
end
|
|
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
|
+
|
|
115
|
+
# Cancel the request (sends RESET_STREAM + STOP_SENDING to server)
|
|
116
|
+
# error_code defaults to H3_REQUEST_CANCELLED (0x10c)
|
|
117
|
+
def cancel(error_code: Protocol::H3_REQUEST_CANCELLED)
|
|
118
|
+
@mutex.synchronize do
|
|
119
|
+
return false unless @status == :pending
|
|
120
|
+
|
|
121
|
+
@stream.abort(error_code)
|
|
122
|
+
@status = :cancelled
|
|
123
|
+
end
|
|
124
|
+
true
|
|
125
|
+
rescue => e
|
|
126
|
+
Quicsilver.logger.error("Failed to cancel request: #{e.message}")
|
|
127
|
+
false
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def pending?
|
|
131
|
+
@status == :pending
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def completed?
|
|
135
|
+
@status == :completed
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def cancelled?
|
|
139
|
+
@status == :cancelled
|
|
140
|
+
end
|
|
141
|
+
|
|
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
|
|
169
|
+
def complete(response) # :nodoc:
|
|
170
|
+
@queue.push(response)
|
|
171
|
+
end
|
|
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
|
+
|
|
178
|
+
# Called by Client on stream reset from peer or connection close
|
|
179
|
+
def fail(error_code, message = nil) # :nodoc:
|
|
180
|
+
@mutex.synchronize do
|
|
181
|
+
return if @status == :cancelled
|
|
182
|
+
@status = :error
|
|
183
|
+
end
|
|
184
|
+
error = { error: true, error_code: error_code, message: message }
|
|
185
|
+
@queue.push(error)
|
|
186
|
+
@streaming_queue.push(error)
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
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
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "protocol/http/request"
|
|
4
|
+
require "protocol/http/response"
|
|
5
|
+
require "protocol/http/headers"
|
|
6
|
+
require "protocol/http/peer"
|
|
7
|
+
require_relative "stream_input"
|
|
8
|
+
require_relative "stream_output"
|
|
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
|
|
24
|
+
module Protocol
|
|
25
|
+
# Converts between QUIC/HTTP/3 frames and protocol-http Request/Response
|
|
26
|
+
# objects. This enables integration with Falcon and any other server
|
|
27
|
+
# built on protocol-http.
|
|
28
|
+
#
|
|
29
|
+
# Usage:
|
|
30
|
+
# adapter = Protocol::Adapter.new(app)
|
|
31
|
+
# request, body = adapter.build_request(parsed_headers)
|
|
32
|
+
# body.write(chunk) # feed body data
|
|
33
|
+
# body.close_write # signal end of body
|
|
34
|
+
# response = adapter.call(request)
|
|
35
|
+
# adapter.send_response(response, writer)
|
|
36
|
+
#
|
|
37
|
+
class Adapter
|
|
38
|
+
VERSION = "HTTP/3"
|
|
39
|
+
|
|
40
|
+
def initialize(app)
|
|
41
|
+
@app = app
|
|
42
|
+
@qpack_encoder = Quicsilver::Protocol::Qpack::Encoder.new
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Build a Protocol::HTTP::Request from parsed HTTP/3 headers.
|
|
46
|
+
#
|
|
47
|
+
# Returns [request, body] where body is a Protocol::StreamInput that
|
|
48
|
+
# the transport feeds RECEIVE chunks into.
|
|
49
|
+
#
|
|
50
|
+
# @param headers [Hash] Parsed headers from RequestParser (includes pseudo-headers).
|
|
51
|
+
# @return [Array(Protocol::HTTP::Request, Protocol::StreamInput)] request and body.
|
|
52
|
+
# Body is nil for bodyless methods (GET, HEAD, TRACE).
|
|
53
|
+
# Caller feeds RECEIVE data into body via write(), then close_write on FIN.
|
|
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)
|
|
59
|
+
method = headers[":method"]
|
|
60
|
+
scheme = headers[":scheme"] || "https"
|
|
61
|
+
authority = headers[":authority"]
|
|
62
|
+
path = headers[":path"]
|
|
63
|
+
protocol = headers[":protocol"]
|
|
64
|
+
content_length = headers["content-length"]&.to_i
|
|
65
|
+
|
|
66
|
+
protocol_headers = ::Protocol::HTTP::Headers.new
|
|
67
|
+
headers.each do |name, value|
|
|
68
|
+
next if name.start_with?(":")
|
|
69
|
+
protocol_headers.add(name, value)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
body = unless bodyless_request?(method)
|
|
73
|
+
Protocol::StreamInput.new(content_length)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
peer = if remote_address
|
|
77
|
+
::Protocol::HTTP::Peer.new(Addrinfo.tcp(remote_address, remote_port))
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
request = Request.new(
|
|
81
|
+
scheme, authority, method, path, VERSION,
|
|
82
|
+
protocol_headers, body, protocol,
|
|
83
|
+
peer: peer,
|
|
84
|
+
transport_context: transport_context,
|
|
85
|
+
rack_context: rack_context
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
[request, body]
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Send a Protocol::HTTP::Response via a transport writer.
|
|
92
|
+
#
|
|
93
|
+
# Encodes the response headers as an HTTP/3 HEADERS frame, then streams
|
|
94
|
+
# the response body as DATA frames via Protocol::StreamOutput.
|
|
95
|
+
#
|
|
96
|
+
# @param response [Protocol::HTTP::Response] The response to send.
|
|
97
|
+
# @param writer [#call] Transport writer — accepts (data, fin) for sending bytes.
|
|
98
|
+
# @param head_request [Boolean] Whether this was a HEAD request.
|
|
99
|
+
# @return [void]
|
|
100
|
+
def send_response(response, writer, head_request: false)
|
|
101
|
+
status = response.status
|
|
102
|
+
headers = response.headers
|
|
103
|
+
trailers = extract_trailers(headers)
|
|
104
|
+
headers_hash = response_header_fields(headers)
|
|
105
|
+
body = response.body
|
|
106
|
+
|
|
107
|
+
if body.nil? || head_request
|
|
108
|
+
send_headers_only(status, headers_hash, writer, trailers: trailers)
|
|
109
|
+
elsif body.respond_to?(:read)
|
|
110
|
+
stream_response(status, headers_hash, body, writer, trailers: trailers)
|
|
111
|
+
else
|
|
112
|
+
buffer_response(status, headers_hash, body, writer, trailers: trailers)
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Call the protocol-http application with the request.
|
|
117
|
+
#
|
|
118
|
+
# @param request [Protocol::HTTP::Request]
|
|
119
|
+
# @return [Protocol::HTTP::Response]
|
|
120
|
+
def call(request)
|
|
121
|
+
@app.call(request)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
private
|
|
125
|
+
|
|
126
|
+
# Methods where a body has no defined semantics (RFC 9110 §9.3.1, §9.3.2, §9.3.8).
|
|
127
|
+
# GET and HEAD SHOULD NOT have a body. TRACE MUST NOT.
|
|
128
|
+
# DELETE, OPTIONS, CONNECT can all have meaningful bodies.
|
|
129
|
+
BODYLESS_METHODS = %w[GET HEAD TRACE].freeze
|
|
130
|
+
|
|
131
|
+
def bodyless_request?(method)
|
|
132
|
+
BODYLESS_METHODS.include?(method)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# No body — send HEADERS with FIN
|
|
136
|
+
def send_headers_only(status, headers, writer, trailers: nil)
|
|
137
|
+
encoder = Quicsilver::Protocol::ResponseEncoder.new(status, headers, [], trailers: trailers)
|
|
138
|
+
writer.call(encoder.encode, true)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Streaming body (protocol-http Body::Readable) — send HEADERS, then
|
|
142
|
+
# stream DATA frames as chunks arrive. Used by Falcon mode.
|
|
143
|
+
def stream_response(status, headers, body, writer, trailers: nil)
|
|
144
|
+
has_trailers = trailers&.any?
|
|
145
|
+
writer.call(build_headers_frame(status, headers), false)
|
|
146
|
+
Protocol::StreamOutput.new(body, &writer).stream(send_fin: !has_trailers)
|
|
147
|
+
writer.call(build_trailer_frame(trailers), true) if has_trailers
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Buffered body (Rack array or enumerable) — encode everything and send.
|
|
151
|
+
def buffer_response(status, headers, body, writer, trailers: nil)
|
|
152
|
+
parts = body.respond_to?(:each) ? body : [body.to_s]
|
|
153
|
+
encoder = Quicsilver::Protocol::ResponseEncoder.new(status, headers, parts, trailers: trailers)
|
|
154
|
+
writer.call(encoder.encode, true)
|
|
155
|
+
ensure
|
|
156
|
+
body.close if body.respond_to?(:close)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Extract trailers from Protocol::HTTP::Headers if present.
|
|
160
|
+
# Returns a Hash or nil.
|
|
161
|
+
def extract_trailers(headers)
|
|
162
|
+
return nil unless headers.respond_to?(:trailer?) && headers.trailer?
|
|
163
|
+
|
|
164
|
+
result = {}
|
|
165
|
+
headers.trailer.each do |name, value|
|
|
166
|
+
result[name] = value
|
|
167
|
+
end
|
|
168
|
+
result
|
|
169
|
+
end
|
|
170
|
+
|
|
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
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# Build an HTTP/3 HEADERS frame from key-value pairs
|
|
184
|
+
def build_headers_frame(status, headers)
|
|
185
|
+
pairs = [[":status", status.to_s]]
|
|
186
|
+
headers.each { |name, value| pairs << [name.to_s.downcase, value.to_s] }
|
|
187
|
+
Protocol.build_headers_frame(pairs, encoder: @qpack_encoder)
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def build_trailer_frame(trailers)
|
|
191
|
+
pairs = trailers.map { |name, value| [name.to_s.downcase, value.to_s] }
|
|
192
|
+
Protocol.build_headers_frame(pairs, encoder: @qpack_encoder)
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
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
|