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,1080 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "scheduler"
|
|
4
|
+
require_relative "schedulers/thread_scheduler"
|
|
5
|
+
require_relative "web_transport_registry"
|
|
6
|
+
require_relative "web_transport_session"
|
|
7
|
+
require_relative "web_transport_stream"
|
|
8
|
+
|
|
9
|
+
module Quicsilver
|
|
10
|
+
class Server
|
|
11
|
+
attr_reader :address, :port, :server_configuration, :running, :connections, :request_registry, :shutting_down, :max_queue_size, :max_connections, :scheduler
|
|
12
|
+
|
|
13
|
+
DEFAULT_THREAD_POOL_SIZE = 5
|
|
14
|
+
DEFAULT_QUEUE_MULTIPLIER = 4
|
|
15
|
+
DEFAULT_MAX_CONNECTIONS = 100
|
|
16
|
+
STREAM_EVENT_RECEIVE = "RECEIVE"
|
|
17
|
+
STREAM_EVENT_RECEIVE_FIN = "RECEIVE_FIN"
|
|
18
|
+
STREAM_EVENT_CONNECTION_ESTABLISHED = "CONNECTION_ESTABLISHED"
|
|
19
|
+
STREAM_EVENT_SEND_COMPLETE = "SEND_COMPLETE"
|
|
20
|
+
STREAM_EVENT_CONNECTION_CLOSED = "CONNECTION_CLOSED"
|
|
21
|
+
STREAM_EVENT_STREAM_RESET = "STREAM_RESET"
|
|
22
|
+
STREAM_EVENT_STOP_SENDING = "STOP_SENDING"
|
|
23
|
+
STREAM_EVENT_START_COMPLETE = "STREAM_START_COMPLETE"
|
|
24
|
+
STREAM_EVENT_PEER_ACCEPTED = "STREAM_PEER_ACCEPTED"
|
|
25
|
+
STREAM_EVENT_SHUTDOWN_COMPLETE = "STREAM_SHUTDOWN_COMPLETE"
|
|
26
|
+
|
|
27
|
+
ServerStopError = Class.new(Quicsilver::Error)
|
|
28
|
+
DrainTimeoutError = Class.new(Quicsilver::Error)
|
|
29
|
+
|
|
30
|
+
# Tracks an in-flight streaming request between RECEIVE and RECEIVE_FIN.
|
|
31
|
+
# The stream handle arrives at RECEIVE_FIN; the worker thread waits for it.
|
|
32
|
+
PendingStream = Struct.new(:connection, :body, :request, :stream_id, :stream_handle, :handle_ready, :frame_buffer, :priority, keyword_init: true) do
|
|
33
|
+
def initialize(**)
|
|
34
|
+
super
|
|
35
|
+
self.handle_ready = Queue.new
|
|
36
|
+
self.frame_buffer = "".b
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Called by RECEIVE_FIN handler to provide the stream handle
|
|
40
|
+
def complete(handle)
|
|
41
|
+
self.stream_handle = handle
|
|
42
|
+
handle_ready.push(true)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Called by worker thread to wait for the stream handle
|
|
46
|
+
def wait_for_handle(timeout: 30)
|
|
47
|
+
handle_ready.pop(timeout: timeout)
|
|
48
|
+
stream_handle
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
class << self
|
|
53
|
+
attr_accessor :instance
|
|
54
|
+
|
|
55
|
+
# Callback from C extension - delegates to server instance
|
|
56
|
+
def handle_stream(connection_data, stream_id, event, data, early_data)
|
|
57
|
+
instance&.handle_stream_event(connection_data, stream_id, event, data, early_data)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Default bind address is 0.0.0.0 (IPv4).
|
|
62
|
+
#
|
|
63
|
+
# ⚠️ IPv4 vs IPv6 matters for QUIC!
|
|
64
|
+
# MsQuic creates either an AF_INET or AF_INET6 socket based on the address.
|
|
65
|
+
# On macOS, MsQuic sets IPV6_V6ONLY=1 by default (no dual-stack), so:
|
|
66
|
+
# - address: "::" → IPv6 only (drops IPv4 UDP packets silently)
|
|
67
|
+
# - address: "0.0.0.0" → IPv4 only (most /etc/hosts map to 127.0.0.1)
|
|
68
|
+
#
|
|
69
|
+
# For HTTP/3 Alt-Svc upgrades to work, the browser must be able to reach
|
|
70
|
+
# the QUIC server at the same IP family it used for the TCP connection.
|
|
71
|
+
# Since most local setups map hostnames to 127.0.0.1 in /etc/hosts,
|
|
72
|
+
# defaulting to 0.0.0.0 (IPv4) is the safest choice.
|
|
73
|
+
#
|
|
74
|
+
# If you need IPv6, either:
|
|
75
|
+
# 1. Add "::1 your-hostname" to /etc/hosts, OR
|
|
76
|
+
# 2. Run two server instances (one IPv4, one IPv6) like Caddy/ngtcp2
|
|
77
|
+
def initialize(port = 4433, address: "0.0.0.0", app: nil, server_configuration: nil, threads: DEFAULT_THREAD_POOL_SIZE, max_queue_size: nil, max_connections: DEFAULT_MAX_CONNECTIONS, scheduler: nil)
|
|
78
|
+
@port = port
|
|
79
|
+
@address = address
|
|
80
|
+
@app = app || default_rack_app
|
|
81
|
+
@server_configuration = server_configuration || Transport::Configuration.new
|
|
82
|
+
@running = false
|
|
83
|
+
@shutting_down = false
|
|
84
|
+
@listener_data = nil
|
|
85
|
+
@config_handle = nil
|
|
86
|
+
@connections = {}
|
|
87
|
+
@request_registry = RequestRegistry.new
|
|
88
|
+
@thread_pool_size = threads
|
|
89
|
+
@max_queue_size = max_queue_size || threads * DEFAULT_QUEUE_MULTIPLIER
|
|
90
|
+
@yield_threshold = [@max_queue_size / 2, 1].max
|
|
91
|
+
# Tell peers how many streams we can serve, not how many we would like
|
|
92
|
+
# to. In flight plus queued is the honest ceiling; past that a request
|
|
93
|
+
# would be shed, and QUIC can decline the stream instead.
|
|
94
|
+
@server_configuration.limit_concurrent_requests!(@thread_pool_size + @max_queue_size)
|
|
95
|
+
@scheduler = build_scheduler(scheduler)
|
|
96
|
+
@max_connections = max_connections
|
|
97
|
+
@cancelled_streams = Set.new
|
|
98
|
+
@cancelled_mutex = Mutex.new
|
|
99
|
+
@pending_streams = {}
|
|
100
|
+
@pending_mutex = Mutex.new
|
|
101
|
+
@datagram_callback = nil
|
|
102
|
+
@connection_callback = nil
|
|
103
|
+
@connection_closed_callback = nil
|
|
104
|
+
@connection_migrated_callback = nil
|
|
105
|
+
@connection_error_callback = nil
|
|
106
|
+
@webtransport = WebTransportRegistry.new
|
|
107
|
+
|
|
108
|
+
protocol_app = wrap_app(@app, @server_configuration.mode)
|
|
109
|
+
|
|
110
|
+
@request_handler = RequestHandler.new(
|
|
111
|
+
app: protocol_app,
|
|
112
|
+
configuration: @server_configuration,
|
|
113
|
+
request_registry: @request_registry,
|
|
114
|
+
cancelled_streams: @cancelled_streams,
|
|
115
|
+
cancelled_mutex: @cancelled_mutex
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
self.class.instance = self
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Send an unreliable datagram to a connection (RFC 9297 / QUIC RFC 9221).
|
|
122
|
+
# Must fit in a single QUIC packet (~1200 bytes).
|
|
123
|
+
# Requires peer to have advertised SETTINGS_H3_DATAGRAM=1.
|
|
124
|
+
#
|
|
125
|
+
# server.datagram_send(connection, "real-time-update")
|
|
126
|
+
#
|
|
127
|
+
def datagram_send(connection, data)
|
|
128
|
+
unless connection.settings[Protocol::SETTINGS_H3_DATAGRAM]
|
|
129
|
+
raise Error, "Peer did not advertise SETTINGS_H3_DATAGRAM support"
|
|
130
|
+
end
|
|
131
|
+
Quicsilver.datagram_send(connection.data, data.to_s.b)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Register a callback for received datagrams.
|
|
135
|
+
#
|
|
136
|
+
# server.on_datagram { |connection, data| puts "Got: #{data}" }
|
|
137
|
+
#
|
|
138
|
+
def on_datagram(&block)
|
|
139
|
+
@datagram_callback = block
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Register a callback for new QUIC connections.
|
|
143
|
+
#
|
|
144
|
+
# server.on_connection { |conn|
|
|
145
|
+
# puts "New connection from #{conn.remote_address} (resumed: #{conn.session_resumed})"
|
|
146
|
+
# }
|
|
147
|
+
#
|
|
148
|
+
def on_connection(&block)
|
|
149
|
+
@connection_callback = block
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Register a callback for closed connections.
|
|
153
|
+
#
|
|
154
|
+
# server.on_connection_closed { |conn| StatsD.decrement("quic.connections") }
|
|
155
|
+
#
|
|
156
|
+
def on_connection_closed(&block)
|
|
157
|
+
@connection_closed_callback = block
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Register a callback for connection migration (client IP changed).
|
|
161
|
+
# This event only exists in QUIC — TCP connections die on IP change.
|
|
162
|
+
#
|
|
163
|
+
# server.on_connection_migrated { |conn, old_address, new_address|
|
|
164
|
+
# Rails.logger.info("Client migrated #{old_address} → #{new_address}")
|
|
165
|
+
# }
|
|
166
|
+
#
|
|
167
|
+
def on_connection_migrated(&block)
|
|
168
|
+
@connection_migrated_callback = block
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Register a callback for connection errors (transport failure or peer shutdown).
|
|
172
|
+
#
|
|
173
|
+
# server.on_connection_error { |conn, error_code, reason|
|
|
174
|
+
# # reason: :transport — network/protocol failure
|
|
175
|
+
# # reason: :peer — client sent CONNECTION_CLOSE
|
|
176
|
+
# # error_code: QUIC/HTTP/3 error code (e.g. H3_NO_ERROR = 0x100)
|
|
177
|
+
# Envoy.drain(conn) if reason == :transport
|
|
178
|
+
# }
|
|
179
|
+
#
|
|
180
|
+
def on_connection_error(&block)
|
|
181
|
+
@connection_error_callback = block
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def start
|
|
185
|
+
raise ServerIsRunningError, "Server is already running" if @running
|
|
186
|
+
|
|
187
|
+
configure_transport_server_id
|
|
188
|
+
Quicsilver.open_connection
|
|
189
|
+
@config_handle = Quicsilver.create_server_configuration(@server_configuration.to_h)
|
|
190
|
+
raise ServerConfigurationError, "Failed to create server configuration" unless @config_handle
|
|
191
|
+
|
|
192
|
+
create_listener
|
|
193
|
+
configure_listener
|
|
194
|
+
start_listener
|
|
195
|
+
|
|
196
|
+
@running = true
|
|
197
|
+
|
|
198
|
+
setup_signal_handlers
|
|
199
|
+
@scheduler.start
|
|
200
|
+
Quicsilver.event_loop.start
|
|
201
|
+
Quicsilver.event_loop.join # Block until shutdown
|
|
202
|
+
rescue ServerConfigurationError, ServerListenerError => e
|
|
203
|
+
cleanup_failed_server
|
|
204
|
+
@running = false
|
|
205
|
+
raise e
|
|
206
|
+
rescue => e
|
|
207
|
+
cleanup_failed_server
|
|
208
|
+
@running = false
|
|
209
|
+
|
|
210
|
+
error_msg = case e.message
|
|
211
|
+
when /0x16/
|
|
212
|
+
"Invalid parameter error - check certificate files and network configuration"
|
|
213
|
+
when /0x30/
|
|
214
|
+
"Address already in use - port #{@port} may be occupied"
|
|
215
|
+
else
|
|
216
|
+
e.message
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
raise ServerError, "Server start failed: #{error_msg}"
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def stop
|
|
223
|
+
return unless @running
|
|
224
|
+
|
|
225
|
+
drain
|
|
226
|
+
|
|
227
|
+
if @listener_data && @listener_data.listener_handle
|
|
228
|
+
Quicsilver.stop_listener(@listener_data.listener_handle)
|
|
229
|
+
Quicsilver.close_listener([@listener_data.listener_handle, @listener_data.context_handle])
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
if @config_handle
|
|
233
|
+
Quicsilver.close_configuration(@config_handle)
|
|
234
|
+
@config_handle = nil
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
Quicsilver.event_loop.stop
|
|
238
|
+
@running = false
|
|
239
|
+
@listener_data = nil
|
|
240
|
+
rescue => e
|
|
241
|
+
@listener_data = nil
|
|
242
|
+
@running = false
|
|
243
|
+
raise ServerStopError, "Failed to stop server: #{e.message}"
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def running?
|
|
247
|
+
@running
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def draining?
|
|
251
|
+
@shutting_down
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def ready?
|
|
255
|
+
@running && !draining? && !@scheduler.full?
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# Return a point-in-time snapshot of app-server and transport state.
|
|
259
|
+
#
|
|
260
|
+
# The top-level server values are Quicsilver-owned facts: listener state,
|
|
261
|
+
# Ruby connection/request counts, and scheduler queue pressure. The
|
|
262
|
+
# "transport" entry exposes process-wide QUIC transport counters, so it may
|
|
263
|
+
# include activity from other Quicsilver servers/clients in the same process.
|
|
264
|
+
def stats
|
|
265
|
+
{
|
|
266
|
+
"cibir" => configured_cibir,
|
|
267
|
+
"running" => @running,
|
|
268
|
+
"ready" => ready?,
|
|
269
|
+
"draining" => draining?,
|
|
270
|
+
"shutting_down" => @shutting_down,
|
|
271
|
+
"connections" => {
|
|
272
|
+
"active" => @connections.size,
|
|
273
|
+
"max" => @max_connections
|
|
274
|
+
},
|
|
275
|
+
"requests" => {
|
|
276
|
+
"active" => @request_registry.active_count
|
|
277
|
+
},
|
|
278
|
+
"scheduler" => {
|
|
279
|
+
"threads" => @thread_pool_size,
|
|
280
|
+
"pending" => @scheduler.pending,
|
|
281
|
+
"max_queue_size" => @max_queue_size,
|
|
282
|
+
"full" => @scheduler.full?
|
|
283
|
+
},
|
|
284
|
+
"transport" => transport_counters,
|
|
285
|
+
"datagrams" => {
|
|
286
|
+
# Dropped before Ruby saw them because the event queue's datagram
|
|
287
|
+
# bound was reached. Datagrams are best-effort (RFC 9221); this is
|
|
288
|
+
# how you find out the dispatcher fell behind.
|
|
289
|
+
"dropped" => Quicsilver.dropped_datagrams
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
def connection_snapshots
|
|
295
|
+
@connections.values.map(&:to_h)
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
def cancelled_stream?(stream_id, connection_handle)
|
|
299
|
+
@cancelled_mutex.synchronize { @cancelled_streams.include?([connection_handle, stream_id]) }
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
# Wait for work queue to drain, then shut down the scheduler
|
|
303
|
+
def drain(timeout: 5)
|
|
304
|
+
Quicsilver.logger.debug("Draining work queue (#{@scheduler.pending} pending)")
|
|
305
|
+
@scheduler.drain(timeout: timeout)
|
|
306
|
+
@scheduler.stop
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
# Graceful shutdown: send GOAWAY, drain requests, then stop
|
|
310
|
+
def shutdown(timeout: 30)
|
|
311
|
+
return unless @running
|
|
312
|
+
return if @shutting_down
|
|
313
|
+
|
|
314
|
+
@shutting_down = true
|
|
315
|
+
Quicsilver.logger.info("Initiating graceful shutdown (timeout: #{timeout}s)")
|
|
316
|
+
|
|
317
|
+
# Phase 1: Send GOAWAY - tell clients to stop sending new requests
|
|
318
|
+
@connections.each_value { |c| c.send_goaway(Protocol::MAX_STREAM_ID) }
|
|
319
|
+
|
|
320
|
+
# Phase 2: Drain in-flight requests
|
|
321
|
+
drain(timeout: timeout)
|
|
322
|
+
|
|
323
|
+
# Phase 2b: Send final GOAWAY with actual last processed stream ID (RFC 9114 §5.2)
|
|
324
|
+
@connections.each_value do |c|
|
|
325
|
+
c.send_goaway
|
|
326
|
+
rescue => e
|
|
327
|
+
Quicsilver.logger.debug("Second GOAWAY failed: #{e.message}")
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
# Grace period: let pending responses reach clients
|
|
331
|
+
sleep [0.5, timeout * 0.1].min
|
|
332
|
+
|
|
333
|
+
# Log any requests that didn't complete
|
|
334
|
+
unless @request_registry.empty?
|
|
335
|
+
@request_registry.active_requests.each do |request_id, req|
|
|
336
|
+
stream_id = req[:stream_id] || request_id
|
|
337
|
+
elapsed = Time.now - req[:started_at]
|
|
338
|
+
Quicsilver.logger.warn("Force-closing request: #{req[:method]} #{req[:path]} (stream: #{stream_id}, elapsed: #{elapsed.round(2)}s)")
|
|
339
|
+
end
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
# Phase 3: Shutdown connections
|
|
343
|
+
@connections.each_value(&:shutdown)
|
|
344
|
+
sleep [0.1, timeout * 0.05].min
|
|
345
|
+
|
|
346
|
+
# Phase 4: Hard stop
|
|
347
|
+
stop
|
|
348
|
+
@shutting_down = false
|
|
349
|
+
|
|
350
|
+
Quicsilver.logger.info("Graceful shutdown complete")
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
def handle_stream_event(connection_data, stream_id, event, data, early_data) # :nodoc:
|
|
354
|
+
connection_handle = connection_data[0]
|
|
355
|
+
|
|
356
|
+
case event
|
|
357
|
+
when STREAM_EVENT_CONNECTION_ESTABLISHED
|
|
358
|
+
if @connections.size >= @max_connections
|
|
359
|
+
Quicsilver.logger.warn("Connection limit reached (#{@max_connections}), rejecting connection")
|
|
360
|
+
Quicsilver.connection_shutdown(connection_handle, Protocol::H3_EXCESSIVE_LOAD, false)
|
|
361
|
+
return
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
quic_connection_ids = Quicsilver.connection_ids(connection_handle)
|
|
365
|
+
connection_id = quic_connection_ids["original_destination_connection_id"]
|
|
366
|
+
connection = Transport::Connection.new(
|
|
367
|
+
connection_handle,
|
|
368
|
+
connection_data,
|
|
369
|
+
max_header_size: @server_configuration.max_header_size,
|
|
370
|
+
connection_id: connection_id,
|
|
371
|
+
transport_server_id: @server_configuration.transport_server_id
|
|
372
|
+
)
|
|
373
|
+
connection.resolve_remote_address!
|
|
374
|
+
@connections[connection_handle] = connection
|
|
375
|
+
connection.setup_http3_streams
|
|
376
|
+
@connection_callback&.call(connection)
|
|
377
|
+
when STREAM_EVENT_CONNECTION_CLOSED
|
|
378
|
+
handle_connection_closed(connection_handle)
|
|
379
|
+
when STREAM_EVENT_SEND_COMPLETE
|
|
380
|
+
# Buffer cleanup handled in C extension
|
|
381
|
+
when STREAM_EVENT_SHUTDOWN_COMPLETE
|
|
382
|
+
return unless (connection = @connections[connection_handle])
|
|
383
|
+
handle_stream_shutdown(connection, stream_id, Transport::StreamEvent.new(data, event).handle)
|
|
384
|
+
when STREAM_EVENT_RECEIVE
|
|
385
|
+
return unless (connection = @connections[connection_handle])
|
|
386
|
+
handle_receive(connection, connection_handle, stream_id, data, early_data: early_data)
|
|
387
|
+
when STREAM_EVENT_RECEIVE_FIN
|
|
388
|
+
return unless (connection = @connections[connection_handle])
|
|
389
|
+
handle_receive_fin(connection, connection_handle, stream_id, data, early_data: early_data)
|
|
390
|
+
when STREAM_EVENT_STREAM_RESET
|
|
391
|
+
return unless (connection = @connections[connection_handle])
|
|
392
|
+
event = Transport::StreamEvent.new(data, "STREAM_RESET")
|
|
393
|
+
Quicsilver.logger.debug("Stream #{stream_id} reset by peer with error code: 0x#{event.error_code.to_s(16)}")
|
|
394
|
+
|
|
395
|
+
# Closing a critical unidirectional stream is a connection error (RFC 9114 §6.2.1)
|
|
396
|
+
if connection.critical_stream?(stream_id)
|
|
397
|
+
Quicsilver.logger.error("Critical stream #{stream_id} reset by peer")
|
|
398
|
+
Quicsilver.connection_shutdown(connection_handle, Protocol::H3_CLOSED_CRITICAL_STREAM, false) rescue nil
|
|
399
|
+
elsif (wt = @webtransport.for(connection_handle).unregister(stream_id))
|
|
400
|
+
wt.notify_close
|
|
401
|
+
connection.remove_stream(stream_id)
|
|
402
|
+
elsif (wt_session = @webtransport.for(connection_handle).session_for_stream(stream_id))
|
|
403
|
+
wt_session.stream(stream_id)&.notify_peer_reset(event.error_code, event.final_size)
|
|
404
|
+
elsif !@webtransport.for(connection_handle).known_stream?(stream_id)
|
|
405
|
+
cancel_stream(connection, stream_id)
|
|
406
|
+
end
|
|
407
|
+
when STREAM_EVENT_STOP_SENDING
|
|
408
|
+
return unless (connection = @connections[connection_handle])
|
|
409
|
+
event = Transport::StreamEvent.new(data, "STOP_SENDING")
|
|
410
|
+
Quicsilver.logger.debug("Stream #{stream_id} stop sending requested with error code: 0x#{event.error_code.to_s(16)}")
|
|
411
|
+
return if @webtransport.for(connection_handle).cancel_pending_connect(stream_id, event.handle)
|
|
412
|
+
|
|
413
|
+
# §4.4: STOP_SENDING on a WebTransport stream is delivered to the
|
|
414
|
+
# application, like RESET_STREAM. Without this it falls through to the
|
|
415
|
+
# HTTP/3 cancel below, which answers with the wrong code and runs
|
|
416
|
+
# request bookkeeping for a stream that is not a request.
|
|
417
|
+
if (wt_session = @webtransport.for(connection_handle).session_for_stream(stream_id))
|
|
418
|
+
wt_session.stream(stream_id)&.notify_peer_stop_sending(event.error_code)
|
|
419
|
+
elsif !@webtransport.for(connection_handle).known_stream?(stream_id)
|
|
420
|
+
Quicsilver.stream_reset(event.handle, Protocol::H3_REQUEST_CANCELLED)
|
|
421
|
+
cancel_stream(connection, stream_id)
|
|
422
|
+
end
|
|
423
|
+
when STREAM_EVENT_START_COMPLETE
|
|
424
|
+
return unless @connections.key?(connection_handle)
|
|
425
|
+
@webtransport.for(connection_handle).stream_started(stream_id, Transport::StreamEvent.new(data, event).handle)
|
|
426
|
+
# peer_accepted=true: stream is flowing. false: queued at peer's limit.
|
|
427
|
+
# Server-side: this fires for outbound streams (control, QPACK).
|
|
428
|
+
# Frequent false here means the client's stream limit is too low.
|
|
429
|
+
accepted = data.getbyte(8) == 1
|
|
430
|
+
Quicsilver.logger.debug("Stream #{stream_id} start complete (peer_accepted=#{accepted})")
|
|
431
|
+
when STREAM_EVENT_PEER_ACCEPTED
|
|
432
|
+
# Queued stream now accepted — client sent MAX_STREAMS.
|
|
433
|
+
Quicsilver.logger.debug("Stream #{stream_id} accepted by peer (MAX_STREAMS raised)")
|
|
434
|
+
when "CONNECTION_ERROR"
|
|
435
|
+
return unless (connection = @connections[connection_handle])
|
|
436
|
+
if @connection_error_callback && data.bytesize >= 13
|
|
437
|
+
error_code = data.unpack1("Q<")
|
|
438
|
+
reason = data.getbyte(12) == 0 ? :transport : :peer
|
|
439
|
+
@connection_error_callback.call(connection, error_code, reason)
|
|
440
|
+
end
|
|
441
|
+
when "CONNECTION_MIGRATED"
|
|
442
|
+
return unless (connection = @connections[connection_handle])
|
|
443
|
+
old_address = connection.remote_address
|
|
444
|
+
new_address = data.force_encoding(Encoding::UTF_8)
|
|
445
|
+
connection.instance_variable_set(:@remote_address, new_address)
|
|
446
|
+
@connection_migrated_callback&.call(connection, old_address, new_address)
|
|
447
|
+
when "DATAGRAM_RECEIVED"
|
|
448
|
+
return unless (connection = @connections[connection_handle])
|
|
449
|
+
unless @webtransport.for(connection_handle).receive_datagram(data)
|
|
450
|
+
@datagram_callback&.call(connection, data)
|
|
451
|
+
end
|
|
452
|
+
end
|
|
453
|
+
end
|
|
454
|
+
|
|
455
|
+
private
|
|
456
|
+
|
|
457
|
+
def configure_transport_server_id
|
|
458
|
+
return unless @server_configuration.transport_server_id
|
|
459
|
+
|
|
460
|
+
Quicsilver.apply_msquic_server_id([@server_configuration.transport_server_id].pack("H*"))
|
|
461
|
+
rescue RuntimeError => e
|
|
462
|
+
raise TransportError.new(e.message, status: TransportError.parse_status(e.message))
|
|
463
|
+
end
|
|
464
|
+
|
|
465
|
+
def create_listener
|
|
466
|
+
result = Quicsilver.create_listener(@config_handle)
|
|
467
|
+
@listener_data = ListenerData.new(result[0], result[1])
|
|
468
|
+
raise ServerListenerError, "Failed to create listener #{@address}:#{@port}" unless @listener_data
|
|
469
|
+
end
|
|
470
|
+
|
|
471
|
+
# Configure listener-level CIBIR routing bytes before starting the listener.
|
|
472
|
+
def configure_listener
|
|
473
|
+
if @server_configuration.cibir_id
|
|
474
|
+
Quicsilver.configure_listener_cibir(
|
|
475
|
+
@listener_data.listener_handle,
|
|
476
|
+
@server_configuration.cibir_bytes
|
|
477
|
+
)
|
|
478
|
+
end
|
|
479
|
+
end
|
|
480
|
+
|
|
481
|
+
def handle_stream_shutdown(connection, stream_id, handle)
|
|
482
|
+
return unless @webtransport.for(connection.handle).stream_shutdown_complete(stream_id, handle: handle)
|
|
483
|
+
|
|
484
|
+
connection.remove_stream(stream_id)
|
|
485
|
+
rescue StandardError
|
|
486
|
+
connection.remove_stream(stream_id)
|
|
487
|
+
raise
|
|
488
|
+
end
|
|
489
|
+
|
|
490
|
+
def handle_connection_closed(connection_handle)
|
|
491
|
+
connection = @connections.delete(connection_handle)
|
|
492
|
+
begin
|
|
493
|
+
close_webtransport_sessions(connection_handle)
|
|
494
|
+
@connection_closed_callback&.call(connection) if connection
|
|
495
|
+
ensure
|
|
496
|
+
connection&.streams&.clear
|
|
497
|
+
Quicsilver.close_server_connection(connection_handle)
|
|
498
|
+
end
|
|
499
|
+
end
|
|
500
|
+
|
|
501
|
+
def close_webtransport_sessions(connection_handle)
|
|
502
|
+
@webtransport.drop(connection_handle).each do |session|
|
|
503
|
+
session.notify_close
|
|
504
|
+
rescue StandardError => error
|
|
505
|
+
Quicsilver.logger.error("WebTransport session #{session.stream_id} close failed: #{error.class}: #{error.message}")
|
|
506
|
+
end
|
|
507
|
+
end
|
|
508
|
+
|
|
509
|
+
def start_listener
|
|
510
|
+
return if Quicsilver.start_listener(@listener_data.listener_handle, @address, @port, @server_configuration.alpn)
|
|
511
|
+
|
|
512
|
+
Quicsilver.close_configuration(@config_handle)
|
|
513
|
+
@config_handle = nil
|
|
514
|
+
cleanup_failed_server
|
|
515
|
+
raise ServerListenerError, "Failed to start listener on #{@address}:#{@port}"
|
|
516
|
+
end
|
|
517
|
+
|
|
518
|
+
def configured_cibir
|
|
519
|
+
if @server_configuration.cibir_id
|
|
520
|
+
{
|
|
521
|
+
"id" => @server_configuration.cibir_id,
|
|
522
|
+
"offset" => 0
|
|
523
|
+
}
|
|
524
|
+
end
|
|
525
|
+
end
|
|
526
|
+
|
|
527
|
+
def transport_counters
|
|
528
|
+
Quicsilver.transport_counters
|
|
529
|
+
rescue RuntimeError => error
|
|
530
|
+
raise unless error.message.include?("QUIC transport not initialized")
|
|
531
|
+
|
|
532
|
+
nil
|
|
533
|
+
end
|
|
534
|
+
|
|
535
|
+
# A request answered without reading its body. Later data and the FIN are
|
|
536
|
+
# dropped rather than treated as a new request.
|
|
537
|
+
def answer_without_body(connection, stream_id)
|
|
538
|
+
@cancelled_mutex.synchronize { @cancelled_streams.add([connection.handle, stream_id]) }
|
|
539
|
+
end
|
|
540
|
+
|
|
541
|
+
def cancel_stream(connection, stream_id)
|
|
542
|
+
@cancelled_mutex.synchronize { @cancelled_streams.add([connection.handle, stream_id]) }
|
|
543
|
+
pending = @pending_mutex.synchronize { @pending_streams.delete([connection.handle, stream_id]) }
|
|
544
|
+
pending&.body&.close(RuntimeError.new("Stream #{stream_id} cancelled"))
|
|
545
|
+
@request_registry.complete(stream_id, connection.handle)
|
|
546
|
+
connection.remove_stream(stream_id)
|
|
547
|
+
end
|
|
548
|
+
|
|
549
|
+
# Wrap the user's app for the configured mode.
|
|
550
|
+
# Rack mode: inject rack.early_hints support, then wrap with protocol-rack.
|
|
551
|
+
# Falcon mode: pass through as-is (native protocol-http app).
|
|
552
|
+
def wrap_app(app, mode)
|
|
553
|
+
case mode
|
|
554
|
+
when :falcon then app
|
|
555
|
+
else Server::RackAdapter.new(app)
|
|
556
|
+
end
|
|
557
|
+
end
|
|
558
|
+
|
|
559
|
+
def setup_signal_handlers
|
|
560
|
+
%w[INT TERM].each do |signal|
|
|
561
|
+
trap(signal) { Thread.new { shutdown } }
|
|
562
|
+
end
|
|
563
|
+
end
|
|
564
|
+
|
|
565
|
+
def default_rack_app
|
|
566
|
+
->(env) {
|
|
567
|
+
[200,
|
|
568
|
+
{"Content-Type" => "text/plain"},
|
|
569
|
+
["Hello from Quicsilver!\nMethod: #{env['REQUEST_METHOD']}\nPath: #{env['PATH_INFO']}\n"]]
|
|
570
|
+
}
|
|
571
|
+
end
|
|
572
|
+
|
|
573
|
+
def cleanup_failed_server
|
|
574
|
+
if @listener_data
|
|
575
|
+
begin
|
|
576
|
+
Quicsilver.stop_listener(@listener_data.listener_handle) if @listener_data.listener_handle
|
|
577
|
+
Quicsilver.close_listener([@listener_data.listener_handle, @listener_data.context_handle]) if @listener_data.listener_handle
|
|
578
|
+
rescue
|
|
579
|
+
# Ignore cleanup errors
|
|
580
|
+
ensure
|
|
581
|
+
@listener_data = nil
|
|
582
|
+
end
|
|
583
|
+
end
|
|
584
|
+
end
|
|
585
|
+
|
|
586
|
+
attr_reader :work_queue
|
|
587
|
+
|
|
588
|
+
# RECEIVE data is always [stream_handle(8)][payload...] from C.
|
|
589
|
+
HANDLE_SIZE = 8
|
|
590
|
+
|
|
591
|
+
def handle_receive(connection, connection_handle, stream_id, data, early_data: false)
|
|
592
|
+
stream_handle = data.byteslice(0, HANDLE_SIZE)&.unpack1("Q<")
|
|
593
|
+
payload = data.byteslice(HANDLE_SIZE..-1) || "".b
|
|
594
|
+
return if @webtransport.for(connection_handle).route_owned_stream(stream_id, stream_handle, payload)
|
|
595
|
+
|
|
596
|
+
# Unidirectional streams (control, QPACK) must be processed incrementally —
|
|
597
|
+
# they never send FIN, so waiting for RECEIVE_FIN would mean never parsing.
|
|
598
|
+
if Transport::StreamId.unidirectional?(stream_id)
|
|
599
|
+
begin
|
|
600
|
+
stream_type, stream_payload = connection.receive_unidirectional_data(stream_id, payload)
|
|
601
|
+
if stream_type == :webtransport_uni
|
|
602
|
+
route_wt_uni_stream(connection_handle, stream_id, stream_handle, stream_payload)
|
|
603
|
+
end
|
|
604
|
+
if connection.settings_received? && (request = @webtransport.for(connection_handle).take_pending_connect)
|
|
605
|
+
establish_webtransport(connection, request)
|
|
606
|
+
end
|
|
607
|
+
rescue Protocol::FrameError => e
|
|
608
|
+
Quicsilver.logger.error("Control stream error: #{e.message} (0x#{e.error_code.to_s(16)})")
|
|
609
|
+
Quicsilver.connection_shutdown(connection_handle, e.error_code, false) rescue nil
|
|
610
|
+
end
|
|
611
|
+
else
|
|
612
|
+
handle_bidi_receive(connection, connection_handle, stream_id, stream_handle, payload, early_data: early_data)
|
|
613
|
+
end
|
|
614
|
+
end
|
|
615
|
+
|
|
616
|
+
def handle_bidi_receive(connection, connection_handle, stream_id, stream_handle, payload, early_data: false)
|
|
617
|
+
return if cancelled_stream?(stream_id, connection_handle)
|
|
618
|
+
|
|
619
|
+
manager = @webtransport.for(connection_handle)
|
|
620
|
+
|
|
621
|
+
pending = @pending_mutex.synchronize { @pending_streams[[connection_handle, stream_id]] }
|
|
622
|
+
payload = connection.complete_stream(stream_id, payload) unless pending
|
|
623
|
+
if pending
|
|
624
|
+
pending.frame_buffer << payload
|
|
625
|
+
drain_pending_frames(pending)
|
|
626
|
+
elsif (wt_payload = manager.pending_payload(stream_id, stream_handle, payload))
|
|
627
|
+
accept_webtransport_stream(connection_handle, stream_id, stream_handle, wt_payload)
|
|
628
|
+
elsif manager.pending_stream?(stream_id)
|
|
629
|
+
elsif contains_headers_frame?(payload)
|
|
630
|
+
dispatch_streaming(connection, connection_handle, stream_id, payload, stream_handle: stream_handle, early_data: early_data)
|
|
631
|
+
else
|
|
632
|
+
connection.buffer_data(stream_id, payload)
|
|
633
|
+
end
|
|
634
|
+
end
|
|
635
|
+
|
|
636
|
+
def handle_receive_fin(connection, connection_handle, stream_id, data, early_data: false)
|
|
637
|
+
return if cancelled_stream?(stream_id, connection_handle)
|
|
638
|
+
|
|
639
|
+
event = Transport::StreamEvent.new(data, "RECEIVE_FIN")
|
|
640
|
+
manager = @webtransport.for(connection_handle)
|
|
641
|
+
return if manager.route_owned_stream(stream_id, event.handle, event.data, fin: true)
|
|
642
|
+
|
|
643
|
+
if connection.uni_stream_type(stream_id) == :webtransport_uni
|
|
644
|
+
# receive_fin closes the read side only once the FIN's data is fully
|
|
645
|
+
# accepted; under backpressure a deferred suffix is still outstanding.
|
|
646
|
+
route_wt_uni_stream(connection_handle, stream_id, event.handle, event.data, fin: true)
|
|
647
|
+
return
|
|
648
|
+
end
|
|
649
|
+
|
|
650
|
+
pending = @pending_mutex.synchronize { @pending_streams[[connection_handle, stream_id]] }
|
|
651
|
+
if pending
|
|
652
|
+
complete_streaming_request(pending, event)
|
|
653
|
+
elsif Transport::StreamId.bidirectional?(stream_id) &&
|
|
654
|
+
(wt_payload = manager.pending_payload(stream_id, event.handle, event.data))
|
|
655
|
+
accept_webtransport_stream(connection_handle, stream_id, event.handle, wt_payload, fin: true)
|
|
656
|
+
elsif manager.pending_stream?(stream_id)
|
|
657
|
+
manager.reject_stream(stream_id, event.handle)
|
|
658
|
+
else
|
|
659
|
+
complete_buffered_request(connection, connection_handle, stream_id, event, early_data: early_data)
|
|
660
|
+
end
|
|
661
|
+
end
|
|
662
|
+
|
|
663
|
+
def complete_streaming_request(pending, event)
|
|
664
|
+
if event.data && !event.data.empty?
|
|
665
|
+
pending.frame_buffer << event.data
|
|
666
|
+
drain_pending_frames(pending)
|
|
667
|
+
end
|
|
668
|
+
pending.body.close_write
|
|
669
|
+
pending.complete(event.handle)
|
|
670
|
+
end
|
|
671
|
+
|
|
672
|
+
def complete_buffered_request(connection, connection_handle, stream_id, event, early_data: false)
|
|
673
|
+
full_data = connection.complete_stream(stream_id, event.data)
|
|
674
|
+
stream = Transport::InboundStream.new(stream_id)
|
|
675
|
+
stream.stream_handle = event.handle
|
|
676
|
+
stream.append_data(full_data)
|
|
677
|
+
|
|
678
|
+
if stream.bidirectional?
|
|
679
|
+
connection.track_client_stream(stream_id)
|
|
680
|
+
dispatch_streaming(connection, connection_handle, stream_id, full_data,
|
|
681
|
+
stream_handle: event.handle, early_data: early_data, completed_stream: stream)
|
|
682
|
+
else
|
|
683
|
+
begin
|
|
684
|
+
stream_type, payload = connection.handle_unidirectional_stream(stream)
|
|
685
|
+
if stream_type == :webtransport_uni
|
|
686
|
+
route_wt_uni_stream(connection_handle, stream_id, stream.stream_handle, payload, fin: true)
|
|
687
|
+
end
|
|
688
|
+
rescue Protocol::FrameError => e
|
|
689
|
+
Quicsilver.logger.error("Control stream error: #{e.message} (0x#{e.error_code.to_s(16)})")
|
|
690
|
+
Quicsilver.connection_shutdown(connection_handle, e.error_code, false) rescue nil
|
|
691
|
+
end
|
|
692
|
+
end
|
|
693
|
+
end
|
|
694
|
+
|
|
695
|
+
# Replayable early data may only carry safe methods when the policy is
|
|
696
|
+
# :reject. There is no stream handle here to answer 425 with, so the
|
|
697
|
+
# request is dropped.
|
|
698
|
+
def reject_early_data?(method, early_data, stream_id)
|
|
699
|
+
return false unless @server_configuration.early_data_policy == :reject
|
|
700
|
+
return false unless early_data
|
|
701
|
+
return false if RequestHandler::SAFE_METHODS.include?(method)
|
|
702
|
+
|
|
703
|
+
Quicsilver.logger.debug("Rejected 0-RTT #{method} on stream #{stream_id} (no stream handle to send 425)")
|
|
704
|
+
true
|
|
705
|
+
end
|
|
706
|
+
|
|
707
|
+
def dispatch_request(connection, stream, early_data: false)
|
|
708
|
+
if @scheduler.full?
|
|
709
|
+
Quicsilver.logger.warn("Work queue full (#{@max_queue_size}), rejecting request")
|
|
710
|
+
connection.send_error(stream, 503, "Service Unavailable") if stream.writable?
|
|
711
|
+
else
|
|
712
|
+
enqueue_work([connection, stream, early_data])
|
|
713
|
+
end
|
|
714
|
+
end
|
|
715
|
+
|
|
716
|
+
# The dispatcher thread holds the GVL while it delivers a batch of events,
|
|
717
|
+
# and the workers cannot drain the scheduler queue until it lets go. A
|
|
718
|
+
# burst larger than the queue's headroom would then be shed by admission
|
|
719
|
+
# control even though the workers are idle. So the dispatcher yields once
|
|
720
|
+
# the queue is half full. The trigger is pressure on the admission budget,
|
|
721
|
+
# which is the only reason to interrupt a batch; a fixed batch size would
|
|
722
|
+
# have to be smaller than every possible max_queue_size to be safe.
|
|
723
|
+
def enqueue_work(work)
|
|
724
|
+
@scheduler.enqueue(work)
|
|
725
|
+
Thread.pass if @scheduler.pending >= @yield_threshold
|
|
726
|
+
end
|
|
727
|
+
|
|
728
|
+
# Send an error response on a stream we only hold a raw handle for.
|
|
729
|
+
# Without a handle the peer has not given us anywhere to write yet, so the
|
|
730
|
+
# stream is left for the normal reset/close path.
|
|
731
|
+
def send_stream_error(connection, stream_id, stream_handle, status, message)
|
|
732
|
+
return unless stream_handle
|
|
733
|
+
|
|
734
|
+
stream = Transport::InboundStream.new(stream_id)
|
|
735
|
+
stream.stream_handle = stream_handle
|
|
736
|
+
connection.send_error(stream, status, message) if stream.writable?
|
|
737
|
+
end
|
|
738
|
+
|
|
739
|
+
def build_scheduler(scheduler_class)
|
|
740
|
+
klass = scheduler_class || Schedulers::ThreadScheduler
|
|
741
|
+
|
|
742
|
+
klass.new(
|
|
743
|
+
concurrency: @thread_pool_size,
|
|
744
|
+
max_queue_size: @max_queue_size
|
|
745
|
+
) do |work|
|
|
746
|
+
if work.is_a?(Array) && work[0] == :streaming
|
|
747
|
+
handle_streaming_request(work[1])
|
|
748
|
+
else
|
|
749
|
+
connection, stream, early_data = work
|
|
750
|
+
@request_handler.call(connection, stream, early_data: early_data)
|
|
751
|
+
end
|
|
752
|
+
end
|
|
753
|
+
end
|
|
754
|
+
|
|
755
|
+
# Admit requests when headers arrive, whether or not FIN accompanies them.
|
|
756
|
+
# Completed HTTP requests keep their normal worker dispatch.
|
|
757
|
+
def dispatch_streaming(connection, connection_handle, stream_id, data, stream_handle: nil, early_data: false, completed_stream: nil)
|
|
758
|
+
parser = Protocol::RequestParser.new(
|
|
759
|
+
data,
|
|
760
|
+
max_header_size: @server_configuration.max_header_size,
|
|
761
|
+
max_header_count: @server_configuration.max_header_count,
|
|
762
|
+
max_frame_payload_size: @server_configuration.max_frame_payload_size
|
|
763
|
+
)
|
|
764
|
+
parser.parse
|
|
765
|
+
parser.validate_headers!
|
|
766
|
+
|
|
767
|
+
headers = parser.headers
|
|
768
|
+
if headers.empty?
|
|
769
|
+
if completed_stream
|
|
770
|
+
dispatch_request(connection, completed_stream, early_data: early_data)
|
|
771
|
+
else
|
|
772
|
+
connection.buffer_data(stream_id, data)
|
|
773
|
+
end
|
|
774
|
+
return
|
|
775
|
+
end
|
|
776
|
+
|
|
777
|
+
# RFC 9114 §5.2: Reject requests on streams at or above the GOAWAY stream ID
|
|
778
|
+
if connection.local_goaway_id && stream_id >= connection.local_goaway_id
|
|
779
|
+
Quicsilver.logger.debug("Rejecting stream #{stream_id} after GOAWAY (#{connection.local_goaway_id})")
|
|
780
|
+
return
|
|
781
|
+
end
|
|
782
|
+
|
|
783
|
+
method = headers[":method"]
|
|
784
|
+
|
|
785
|
+
Quicsilver.logger.debug(
|
|
786
|
+
"HTTP/3 request stream=#{stream_id} method=#{method.inspect} " \
|
|
787
|
+
"path=#{headers[":path"].inspect} protocol=#{headers[":protocol"].inspect} " \
|
|
788
|
+
"authority=#{headers[":authority"].inspect} headers=#{headers.inspect}"
|
|
789
|
+
)
|
|
790
|
+
|
|
791
|
+
if method == "CONNECT" && Protocol::WebTransport.protocol?(headers[":protocol"])
|
|
792
|
+
# The 0-RTT policy has to be applied here too. A WebTransport CONNECT
|
|
793
|
+
# is not a safe method, and this branch runs before the check in
|
|
794
|
+
# enqueue_streaming_request, so replayable session setup was reaching
|
|
795
|
+
# the application while an ordinary CONNECT was refused.
|
|
796
|
+
return if reject_early_data?(method, early_data, stream_id)
|
|
797
|
+
|
|
798
|
+
dispatch_webtransport_connect(connection, stream_id, headers, data,
|
|
799
|
+
stream_handle: stream_handle, early_data: early_data, fin: !completed_stream.nil?)
|
|
800
|
+
elsif completed_stream
|
|
801
|
+
dispatch_request(connection, completed_stream, early_data: early_data)
|
|
802
|
+
else
|
|
803
|
+
enqueue_streaming_request(connection, stream_id, parser, data,
|
|
804
|
+
stream_handle: stream_handle, early_data: early_data)
|
|
805
|
+
end
|
|
806
|
+
rescue Protocol::FrameError => e
|
|
807
|
+
Quicsilver.logger.error("Frame error: #{e.message}")
|
|
808
|
+
Quicsilver.connection_shutdown(connection_handle, e.error_code, false) rescue nil
|
|
809
|
+
rescue Protocol::MessageError => e
|
|
810
|
+
if completed_stream
|
|
811
|
+
dispatch_request(connection, completed_stream, early_data: early_data)
|
|
812
|
+
else
|
|
813
|
+
Quicsilver.logger.error("Message error on stream #{stream_id}: #{e.message}")
|
|
814
|
+
end
|
|
815
|
+
rescue => e
|
|
816
|
+
Quicsilver.logger.error("Error in streaming dispatch: #{e.class} - #{e.message}")
|
|
817
|
+
end
|
|
818
|
+
|
|
819
|
+
def dispatch_webtransport_connect(connection, stream_id, headers, data, stream_handle:, early_data:, fin: false)
|
|
820
|
+
# Validated HEADERS exist. Preserve following bytes, including partial
|
|
821
|
+
# frames, for the session's incremental CONNECT decoder.
|
|
822
|
+
header_end = Protocol::FrameReader.each(data) do |type, _payload, offset|
|
|
823
|
+
break offset if type == Protocol::FRAME_HEADERS
|
|
824
|
+
end
|
|
825
|
+
request = WebTransportManager::ConnectRequest.new(
|
|
826
|
+
stream_id: stream_id, stream_handle: stream_handle, headers: headers,
|
|
827
|
+
data: data.byteslice(header_end..), early_data: early_data, fin: fin
|
|
828
|
+
)
|
|
829
|
+
if connection.settings_received?
|
|
830
|
+
establish_webtransport(connection, request)
|
|
831
|
+
else
|
|
832
|
+
@webtransport.for(connection.handle).defer_connect(request)
|
|
833
|
+
end
|
|
834
|
+
end
|
|
835
|
+
|
|
836
|
+
def establish_webtransport(connection, request)
|
|
837
|
+
unless request.headers[":scheme"] == "https" &&
|
|
838
|
+
connection.webtransport_settings_valid?(request.headers[":protocol"]) && connection.datagram_send_enabled?
|
|
839
|
+
@webtransport.for(connection.handle).reject_stream(
|
|
840
|
+
request.stream_id, request.stream_handle, error_code: Protocol::H3_MESSAGE_ERROR
|
|
841
|
+
)
|
|
842
|
+
return
|
|
843
|
+
end
|
|
844
|
+
|
|
845
|
+
session = accept_webtransport(connection, connection.handle, request.stream_id,
|
|
846
|
+
request.stream_handle, request.headers, early_data: request.early_data)
|
|
847
|
+
session&.receive_connect_stream_data(request.data, fin: request.fin)
|
|
848
|
+
end
|
|
849
|
+
|
|
850
|
+
def enqueue_streaming_request(connection, stream_id, parser, data, stream_handle:, early_data:)
|
|
851
|
+
connection_handle = connection.handle
|
|
852
|
+
headers = parser.headers
|
|
853
|
+
method = headers[":method"]
|
|
854
|
+
|
|
855
|
+
return if reject_early_data?(method, early_data, stream_id)
|
|
856
|
+
|
|
857
|
+
# Reject before tracking the request so overload cannot leave orphaned state.
|
|
858
|
+
if @scheduler.full?
|
|
859
|
+
Quicsilver.logger.warn("Work queue full (#{@max_queue_size}), shedding stream #{stream_id}")
|
|
860
|
+
# The response is already complete, so the rest of the request body is
|
|
861
|
+
# of no interest. Record that before sending, so body that arrives
|
|
862
|
+
# while we answer is discarded rather than buffered: its HEADERS have
|
|
863
|
+
# been consumed, so parsing what is left would report "DATA frame
|
|
864
|
+
# before HEADERS" and take down the whole connection.
|
|
865
|
+
answer_without_body(connection, stream_id)
|
|
866
|
+
send_stream_error(connection, stream_id, stream_handle, 503, "Service Unavailable")
|
|
867
|
+
return
|
|
868
|
+
end
|
|
869
|
+
|
|
870
|
+
request, body = @request_handler.adapter.build_request(
|
|
871
|
+
headers,
|
|
872
|
+
remote_address: connection.remote_address,
|
|
873
|
+
remote_port: connection.remote_port,
|
|
874
|
+
transport_context: connection.request_context(stream_id: stream_id)
|
|
875
|
+
)
|
|
876
|
+
request.headers.add("quicsilver-early-data", early_data.to_s)
|
|
877
|
+
|
|
878
|
+
# Copy complete DATA payloads; partial frames stay in the pending buffer.
|
|
879
|
+
if body && parser.body.size > 0
|
|
880
|
+
body.write(parser.body.read)
|
|
881
|
+
end
|
|
882
|
+
|
|
883
|
+
pending = PendingStream.new(
|
|
884
|
+
connection: connection,
|
|
885
|
+
body: body,
|
|
886
|
+
request: request,
|
|
887
|
+
stream_id: stream_id,
|
|
888
|
+
priority: parser.priority
|
|
889
|
+
)
|
|
890
|
+
|
|
891
|
+
# Unconsumed bytes go into the frame buffer for incremental parsing
|
|
892
|
+
remainder = data.byteslice(parser.bytes_consumed..-1)
|
|
893
|
+
if remainder && remainder.bytesize > 0
|
|
894
|
+
pending.frame_buffer << remainder
|
|
895
|
+
drain_pending_frames(pending)
|
|
896
|
+
end
|
|
897
|
+
@pending_mutex.synchronize { @pending_streams[[connection_handle, stream_id]] = pending }
|
|
898
|
+
|
|
899
|
+
connection.track_client_stream(stream_id)
|
|
900
|
+
@request_registry.track(stream_id, connection_handle,
|
|
901
|
+
path: headers[":path"] || "/", method: method || "GET")
|
|
902
|
+
|
|
903
|
+
@scheduler.enqueue([:streaming, pending])
|
|
904
|
+
end
|
|
905
|
+
|
|
906
|
+
def handle_streaming_request(pending)
|
|
907
|
+
response = @request_handler.adapter.call(pending.request)
|
|
908
|
+
|
|
909
|
+
# Wait for RECEIVE_FIN to provide the stream handle
|
|
910
|
+
stream_handle = pending.wait_for_handle(timeout: 30)
|
|
911
|
+
unless stream_handle
|
|
912
|
+
Quicsilver.logger.error("Timed out waiting for stream handle on stream #{pending.stream_id}")
|
|
913
|
+
return
|
|
914
|
+
end
|
|
915
|
+
|
|
916
|
+
return if cancelled_stream?(pending.stream_id, pending.connection.handle)
|
|
917
|
+
|
|
918
|
+
headers = response.headers
|
|
919
|
+
|
|
920
|
+
trailers = if headers.respond_to?(:trailer?) && headers.trailer?
|
|
921
|
+
trailer_hash = {}
|
|
922
|
+
headers.trailer.each { |name, value| trailer_hash[name] = value }
|
|
923
|
+
trailer_hash
|
|
924
|
+
end
|
|
925
|
+
|
|
926
|
+
# Pairs rather than a Hash, for the set-cookie reason in RequestHandler#send_response.
|
|
927
|
+
response_headers = []
|
|
928
|
+
if headers.respond_to?(:header)
|
|
929
|
+
headers.header.each { |name, value| response_headers << [ name, value ] }
|
|
930
|
+
else
|
|
931
|
+
headers&.each { |name, value| response_headers << [ name, value ] }
|
|
932
|
+
end
|
|
933
|
+
|
|
934
|
+
unless response_headers.any? { |name, _| name == "content-length" }
|
|
935
|
+
response_headers << [ "content-length", response.body.length.to_s ] if response.body&.length
|
|
936
|
+
end
|
|
937
|
+
|
|
938
|
+
stream = Transport::InboundStream.new(pending.stream_id)
|
|
939
|
+
stream.stream_handle = stream_handle
|
|
940
|
+
|
|
941
|
+
pending.connection.apply_stream_priority(stream, pending.priority)
|
|
942
|
+
pending.connection.send_response(stream, response.status, response_headers, response.body,
|
|
943
|
+
head_request: pending.request.method == "HEAD", trailers: trailers)
|
|
944
|
+
@request_registry.complete(pending.stream_id, pending.connection.handle)
|
|
945
|
+
rescue => e
|
|
946
|
+
Quicsilver.logger.error("Streaming request error: #{e.class} - #{e.message}")
|
|
947
|
+
if pending.stream_handle
|
|
948
|
+
stream = Transport::InboundStream.new(pending.stream_id)
|
|
949
|
+
stream.stream_handle = pending.stream_handle
|
|
950
|
+
pending.connection.send_error(stream, 500, "Internal Server Error") if stream.writable?
|
|
951
|
+
end
|
|
952
|
+
ensure
|
|
953
|
+
@pending_mutex.synchronize { @pending_streams.delete([pending.connection.handle, pending.stream_id]) }
|
|
954
|
+
@cancelled_mutex.synchronize { @cancelled_streams.delete([pending.connection.handle, pending.stream_id]) }
|
|
955
|
+
@request_registry.complete(pending.stream_id, pending.connection.handle)
|
|
956
|
+
pending.connection.remove_stream(pending.stream_id)
|
|
957
|
+
end
|
|
958
|
+
|
|
959
|
+
# Drain buffered body frames, failing the connection if the peer sends a
|
|
960
|
+
# frame type that is not allowed on a request stream.
|
|
961
|
+
def drain_pending_frames(pending)
|
|
962
|
+
drain_data_frames(pending)
|
|
963
|
+
rescue Protocol::FrameError => e
|
|
964
|
+
handle = pending.connection.handle
|
|
965
|
+
Quicsilver.logger.error("Request stream error: #{e.message} (0x#{e.error_code.to_s(16)})")
|
|
966
|
+
Quicsilver.connection_shutdown(handle, e.error_code, false) rescue nil
|
|
967
|
+
end
|
|
968
|
+
|
|
969
|
+
# Drain complete frames from the buffer, keeping any partial trailing frame
|
|
970
|
+
# for the next RECEIVE callback. DATA payloads go to the request body;
|
|
971
|
+
# anything else is an extension frame, which RFC 9114 9 says to ignore.
|
|
972
|
+
def drain_data_frames(pending)
|
|
973
|
+
buffer = pending.frame_buffer
|
|
974
|
+
|
|
975
|
+
consumed = Protocol::FrameReader.each(buffer) do |type, payload|
|
|
976
|
+
Protocol.reject_unexpected_request_frame!(type)
|
|
977
|
+
pending.body.write(payload) if type == Protocol::FRAME_DATA
|
|
978
|
+
end
|
|
979
|
+
|
|
980
|
+
pending.frame_buffer = consumed.zero? ? buffer : (buffer.byteslice(consumed..) || "".b)
|
|
981
|
+
end
|
|
982
|
+
|
|
983
|
+
def accept_webtransport(connection, connection_handle, stream_id, stream_handle, headers, early_data: false)
|
|
984
|
+
# Draft-16 §5.1 forbids concurrent sessions without session flow control.
|
|
985
|
+
manager = @webtransport.for(connection_handle)
|
|
986
|
+
if manager.routable_sessions.any?
|
|
987
|
+
manager.reject_stream(stream_id, stream_handle, error_code: Protocol::H3_REQUEST_REJECTED)
|
|
988
|
+
return
|
|
989
|
+
end
|
|
990
|
+
|
|
991
|
+
Quicsilver.logger.debug(
|
|
992
|
+
"WebTransport CONNECT stream=#{stream_id} path=#{headers[":path"].inspect} " \
|
|
993
|
+
"authority=#{headers[":authority"].inspect} headers=#{headers.inspect}"
|
|
994
|
+
)
|
|
995
|
+
|
|
996
|
+
stream = Transport::InboundStream.new(stream_id)
|
|
997
|
+
stream.stream_handle = stream_handle
|
|
998
|
+
|
|
999
|
+
session = WebTransportSession.new(
|
|
1000
|
+
connection: connection,
|
|
1001
|
+
stream: stream,
|
|
1002
|
+
headers: headers
|
|
1003
|
+
)
|
|
1004
|
+
|
|
1005
|
+
dispatch_webtransport_to_rack(connection, connection_handle, stream_id, headers, session, early_data: early_data)
|
|
1006
|
+
session if session.accepted?
|
|
1007
|
+
rescue => e
|
|
1008
|
+
Quicsilver.logger.error("WebTransport error: #{e.class} - #{e.message}")
|
|
1009
|
+
nil
|
|
1010
|
+
end
|
|
1011
|
+
|
|
1012
|
+
def dispatch_webtransport_to_rack(connection, connection_handle, stream_id, headers, session, early_data: false)
|
|
1013
|
+
request_context = connection.request_context(stream_id: stream_id)
|
|
1014
|
+
rack_context = Rack::Context.new(
|
|
1015
|
+
stream_id: stream_id,
|
|
1016
|
+
early_data: early_data,
|
|
1017
|
+
webtransport: session,
|
|
1018
|
+
metadata: request_context
|
|
1019
|
+
)
|
|
1020
|
+
|
|
1021
|
+
request, _body = @request_handler.adapter.build_request(
|
|
1022
|
+
headers,
|
|
1023
|
+
remote_address: connection.remote_address,
|
|
1024
|
+
remote_port: connection.remote_port,
|
|
1025
|
+
transport_context: request_context,
|
|
1026
|
+
rack_context: rack_context
|
|
1027
|
+
)
|
|
1028
|
+
|
|
1029
|
+
Quicsilver.logger.debug(
|
|
1030
|
+
"Dispatching WebTransport to Rack stream=#{stream_id} " \
|
|
1031
|
+
"method=#{request.method.inspect} path=#{request.path.inspect} early_data=#{early_data.inspect}"
|
|
1032
|
+
)
|
|
1033
|
+
|
|
1034
|
+
@webtransport.for(connection_handle).register(session)
|
|
1035
|
+
response = @request_handler.adapter.call(request)
|
|
1036
|
+
|
|
1037
|
+
Quicsilver.logger.debug(
|
|
1038
|
+
"WebTransport Rack response stream=#{stream_id} status=#{response.status.inspect} " \
|
|
1039
|
+
"accepted=#{session.accepted?}"
|
|
1040
|
+
)
|
|
1041
|
+
|
|
1042
|
+
if session.accepted?
|
|
1043
|
+
connection.track_client_stream(stream_id)
|
|
1044
|
+
else
|
|
1045
|
+
@webtransport.for(connection_handle).reject_session(session, response.status)
|
|
1046
|
+
end
|
|
1047
|
+
end
|
|
1048
|
+
|
|
1049
|
+
def route_wt_uni_stream(connection_handle, stream_id, stream_handle, payload, fin: false)
|
|
1050
|
+
# After Connection strips the 0x54 stream type, payload is:
|
|
1051
|
+
# [session_id varint][data...]
|
|
1052
|
+
# Reuse the same prefix parser — format is identical minus the type byte.
|
|
1053
|
+
@webtransport.for(connection_handle).route_unidirectional_stream(stream_id, stream_handle, payload, fin: fin)
|
|
1054
|
+
rescue Protocol::FrameError => e
|
|
1055
|
+
Quicsilver.logger.error("WebTransport stream error: #{e.message} (0x#{e.error_code.to_s(16)})")
|
|
1056
|
+
Quicsilver.connection_shutdown(connection_handle, e.error_code, false)
|
|
1057
|
+
nil
|
|
1058
|
+
rescue => e
|
|
1059
|
+
Quicsilver.logger.error("WebTransport uni stream error: #{e.class} - #{e.message}")
|
|
1060
|
+
raise
|
|
1061
|
+
end
|
|
1062
|
+
|
|
1063
|
+
# Accept an incoming WebTransport stream — parse prefix and route to session
|
|
1064
|
+
def accept_webtransport_stream(connection_handle, stream_id, stream_handle, payload, fin: false)
|
|
1065
|
+
@webtransport.for(connection_handle).accept_bidi_stream(stream_id, stream_handle, payload, fin: fin)
|
|
1066
|
+
rescue Protocol::FrameError => e
|
|
1067
|
+
Quicsilver.logger.error("WebTransport stream error: #{e.message} (0x#{e.error_code.to_s(16)})")
|
|
1068
|
+
Quicsilver.connection_shutdown(connection_handle, e.error_code, false)
|
|
1069
|
+
nil
|
|
1070
|
+
rescue => e
|
|
1071
|
+
Quicsilver.logger.error("WebTransport stream error: #{e.class} - #{e.message}")
|
|
1072
|
+
raise
|
|
1073
|
+
end
|
|
1074
|
+
|
|
1075
|
+
def contains_headers_frame?(data)
|
|
1076
|
+
return false if data.nil? || data.bytesize < 2
|
|
1077
|
+
data.getbyte(0) == Protocol::FRAME_HEADERS
|
|
1078
|
+
end
|
|
1079
|
+
end
|
|
1080
|
+
end
|