quicsilver 0.3.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 +117 -0
- data/.gitignore +8 -1
- data/CHANGELOG.md +82 -0
- data/Gemfile.lock +27 -2
- data/README.md +205 -29
- data/Rakefile +75 -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/README.md +44 -91
- 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 +6 -18
- data/examples/priorities.rb +60 -0
- data/examples/protocol_http_server.rb +31 -0
- data/examples/rack_http3_server.rb +8 -20
- data/examples/rails_feature_test.rb +280 -0
- data/examples/server_stats.rb +117 -0
- data/examples/simple_client_test.rb +5 -5
- 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 +111 -12
- 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 +1646 -189
- data/lib/quicsilver/client/client.rb +546 -88
- data/lib/quicsilver/client/connection_pool.rb +202 -0
- 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 +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 +137 -10
- data/lib/quicsilver/protocol/priority.rb +65 -0
- data/lib/quicsilver/protocol/qpack/decoder.rb +4 -42
- data/lib/quicsilver/protocol/qpack/encoder.rb +49 -21
- data/lib/quicsilver/protocol/qpack/header_block_decoder.rb +16 -1
- 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 +43 -141
- data/lib/quicsilver/protocol/response_encoder.rb +40 -5
- data/lib/quicsilver/protocol/response_parser.rb +26 -128
- 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/rack_adapter.rb +77 -0
- data/lib/quicsilver/server/receive_queue.rb +106 -0
- data/lib/quicsilver/server/request_handler.rb +111 -48
- 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 +848 -104
- 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 +113 -11
- data/lib/quicsilver/transport/connection.rb +376 -132
- 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 +45 -11
- data/lib/rackup/handler/quicsilver.rb +57 -38
- data/quicsilver.gemspec +17 -2
- metadata +120 -10
- 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/examples/setup_certs.sh +0 -57
|
@@ -1,9 +1,18 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
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
|
+
|
|
3
9
|
module Quicsilver
|
|
4
10
|
class Server
|
|
5
|
-
attr_reader :address, :port, :server_configuration, :running, :connections, :request_registry, :shutting_down, :max_queue_size, :max_connections
|
|
11
|
+
attr_reader :address, :port, :server_configuration, :running, :connections, :request_registry, :shutting_down, :max_queue_size, :max_connections, :scheduler
|
|
6
12
|
|
|
13
|
+
DEFAULT_THREAD_POOL_SIZE = 5
|
|
14
|
+
DEFAULT_QUEUE_MULTIPLIER = 4
|
|
15
|
+
DEFAULT_MAX_CONNECTIONS = 100
|
|
7
16
|
STREAM_EVENT_RECEIVE = "RECEIVE"
|
|
8
17
|
STREAM_EVENT_RECEIVE_FIN = "RECEIVE_FIN"
|
|
9
18
|
STREAM_EVENT_CONNECTION_ESTABLISHED = "CONNECTION_ESTABLISHED"
|
|
@@ -11,9 +20,34 @@ module Quicsilver
|
|
|
11
20
|
STREAM_EVENT_CONNECTION_CLOSED = "CONNECTION_CLOSED"
|
|
12
21
|
STREAM_EVENT_STREAM_RESET = "STREAM_RESET"
|
|
13
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
|
|
14
44
|
|
|
15
|
-
|
|
16
|
-
|
|
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
|
|
17
51
|
|
|
18
52
|
class << self
|
|
19
53
|
attr_accessor :instance
|
|
@@ -24,11 +58,23 @@ module Quicsilver
|
|
|
24
58
|
end
|
|
25
59
|
end
|
|
26
60
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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)
|
|
32
78
|
@port = port
|
|
33
79
|
@address = address
|
|
34
80
|
@app = app || default_rack_app
|
|
@@ -39,17 +85,30 @@ module Quicsilver
|
|
|
39
85
|
@config_handle = nil
|
|
40
86
|
@connections = {}
|
|
41
87
|
@request_registry = RequestRegistry.new
|
|
42
|
-
@handler_threads = []
|
|
43
|
-
@handler_mutex = Mutex.new
|
|
44
88
|
@thread_pool_size = threads
|
|
45
89
|
@max_queue_size = max_queue_size || threads * DEFAULT_QUEUE_MULTIPLIER
|
|
46
|
-
@
|
|
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)
|
|
47
96
|
@max_connections = max_connections
|
|
48
97
|
@cancelled_streams = Set.new
|
|
49
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)
|
|
50
109
|
|
|
51
110
|
@request_handler = RequestHandler.new(
|
|
52
|
-
app:
|
|
111
|
+
app: protocol_app,
|
|
53
112
|
configuration: @server_configuration,
|
|
54
113
|
request_registry: @request_registry,
|
|
55
114
|
cancelled_streams: @cancelled_streams,
|
|
@@ -59,28 +118,85 @@ module Quicsilver
|
|
|
59
118
|
self.class.instance = self
|
|
60
119
|
end
|
|
61
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
|
+
|
|
62
184
|
def start
|
|
63
185
|
raise ServerIsRunningError, "Server is already running" if @running
|
|
64
186
|
|
|
187
|
+
configure_transport_server_id
|
|
65
188
|
Quicsilver.open_connection
|
|
66
189
|
@config_handle = Quicsilver.create_server_configuration(@server_configuration.to_h)
|
|
67
190
|
raise ServerConfigurationError, "Failed to create server configuration" unless @config_handle
|
|
68
191
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
unless Quicsilver.start_listener(@listener_data.listener_handle, @address, @port, @server_configuration.alpn)
|
|
74
|
-
Quicsilver.close_configuration(@config_handle)
|
|
75
|
-
@config_handle = nil
|
|
76
|
-
cleanup_failed_server
|
|
77
|
-
raise ServerListenerError, "Failed to start listener on #{@address}:#{@port}"
|
|
78
|
-
end
|
|
192
|
+
create_listener
|
|
193
|
+
configure_listener
|
|
194
|
+
start_listener
|
|
79
195
|
|
|
80
196
|
@running = true
|
|
81
197
|
|
|
82
198
|
setup_signal_handlers
|
|
83
|
-
|
|
199
|
+
@scheduler.start
|
|
84
200
|
Quicsilver.event_loop.start
|
|
85
201
|
Quicsilver.event_loop.join # Block until shutdown
|
|
86
202
|
rescue ServerConfigurationError, ServerListenerError => e
|
|
@@ -131,23 +247,63 @@ module Quicsilver
|
|
|
131
247
|
@running
|
|
132
248
|
end
|
|
133
249
|
|
|
134
|
-
def
|
|
135
|
-
@
|
|
250
|
+
def draining?
|
|
251
|
+
@shutting_down
|
|
136
252
|
end
|
|
137
253
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
254
|
+
def ready?
|
|
255
|
+
@running && !draining? && !@scheduler.full?
|
|
256
|
+
end
|
|
141
257
|
|
|
142
|
-
|
|
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
|
|
143
293
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
|
148
301
|
|
|
149
|
-
|
|
150
|
-
|
|
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
|
|
151
307
|
end
|
|
152
308
|
|
|
153
309
|
# Graceful shutdown: send GOAWAY, drain requests, then stop
|
|
@@ -164,12 +320,20 @@ module Quicsilver
|
|
|
164
320
|
# Phase 2: Drain in-flight requests
|
|
165
321
|
drain(timeout: timeout)
|
|
166
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
|
+
|
|
167
330
|
# Grace period: let pending responses reach clients
|
|
168
|
-
sleep 0.5
|
|
331
|
+
sleep [0.5, timeout * 0.1].min
|
|
169
332
|
|
|
170
333
|
# Log any requests that didn't complete
|
|
171
334
|
unless @request_registry.empty?
|
|
172
|
-
@request_registry.active_requests.each do |
|
|
335
|
+
@request_registry.active_requests.each do |request_id, req|
|
|
336
|
+
stream_id = req[:stream_id] || request_id
|
|
173
337
|
elapsed = Time.now - req[:started_at]
|
|
174
338
|
Quicsilver.logger.warn("Force-closing request: #{req[:method]} #{req[:path]} (stream: #{stream_id}, elapsed: #{elapsed.round(2)}s)")
|
|
175
339
|
end
|
|
@@ -177,7 +341,7 @@ module Quicsilver
|
|
|
177
341
|
|
|
178
342
|
# Phase 3: Shutdown connections
|
|
179
343
|
@connections.each_value(&:shutdown)
|
|
180
|
-
sleep 0.1
|
|
344
|
+
sleep [0.1, timeout * 0.05].min
|
|
181
345
|
|
|
182
346
|
# Phase 4: Hard stop
|
|
183
347
|
stop
|
|
@@ -197,55 +361,32 @@ module Quicsilver
|
|
|
197
361
|
return
|
|
198
362
|
end
|
|
199
363
|
|
|
200
|
-
|
|
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!
|
|
201
374
|
@connections[connection_handle] = connection
|
|
202
375
|
connection.setup_http3_streams
|
|
203
|
-
|
|
376
|
+
@connection_callback&.call(connection)
|
|
204
377
|
when STREAM_EVENT_CONNECTION_CLOSED
|
|
205
|
-
|
|
206
|
-
Quicsilver.close_server_connection(connection_handle)
|
|
207
|
-
|
|
378
|
+
handle_connection_closed(connection_handle)
|
|
208
379
|
when STREAM_EVENT_SEND_COMPLETE
|
|
209
380
|
# Buffer cleanup handled in C extension
|
|
210
|
-
|
|
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)
|
|
211
384
|
when STREAM_EVENT_RECEIVE
|
|
212
385
|
return unless (connection = @connections[connection_handle])
|
|
213
|
-
|
|
214
|
-
# Unidirectional streams (control, QPACK) must be processed incrementally —
|
|
215
|
-
# they never send FIN, so waiting for RECEIVE_FIN would mean never parsing.
|
|
216
|
-
if (stream_id & 0x02) != 0 # unidirectional
|
|
217
|
-
begin
|
|
218
|
-
connection.receive_unidirectional_data(stream_id, data)
|
|
219
|
-
rescue Protocol::FrameError => e
|
|
220
|
-
Quicsilver.logger.error("Control stream error: #{e.message} (0x#{e.error_code.to_s(16)})")
|
|
221
|
-
Quicsilver.connection_shutdown(connection_handle, e.error_code, false) rescue nil
|
|
222
|
-
end
|
|
223
|
-
else
|
|
224
|
-
connection.buffer_data(stream_id, data)
|
|
225
|
-
end
|
|
226
|
-
|
|
386
|
+
handle_receive(connection, connection_handle, stream_id, data, early_data: early_data)
|
|
227
387
|
when STREAM_EVENT_RECEIVE_FIN
|
|
228
388
|
return unless (connection = @connections[connection_handle])
|
|
229
|
-
|
|
230
|
-
event = Transport::StreamEvent.new(data, "RECEIVE_FIN")
|
|
231
|
-
|
|
232
|
-
full_data = connection.complete_stream(stream_id, event.data)
|
|
233
|
-
stream = Transport::InboundStream.new(stream_id)
|
|
234
|
-
stream.stream_handle = event.handle
|
|
235
|
-
stream.append_data(full_data)
|
|
236
|
-
|
|
237
|
-
if stream.bidirectional?
|
|
238
|
-
connection.track_client_stream(stream_id)
|
|
239
|
-
dispatch_request(connection, stream, early_data: early_data)
|
|
240
|
-
else
|
|
241
|
-
begin
|
|
242
|
-
connection.handle_unidirectional_stream(stream)
|
|
243
|
-
rescue Protocol::FrameError => e
|
|
244
|
-
Quicsilver.logger.error("Control stream error: #{e.message} (0x#{e.error_code.to_s(16)})")
|
|
245
|
-
Quicsilver.connection_shutdown(connection_handle, e.error_code, false) rescue nil
|
|
246
|
-
end
|
|
247
|
-
end
|
|
248
|
-
|
|
389
|
+
handle_receive_fin(connection, connection_handle, stream_id, data, early_data: early_data)
|
|
249
390
|
when STREAM_EVENT_STREAM_RESET
|
|
250
391
|
return unless (connection = @connections[connection_handle])
|
|
251
392
|
event = Transport::StreamEvent.new(data, "STREAM_RESET")
|
|
@@ -255,23 +396,166 @@ module Quicsilver
|
|
|
255
396
|
if connection.critical_stream?(stream_id)
|
|
256
397
|
Quicsilver.logger.error("Critical stream #{stream_id} reset by peer")
|
|
257
398
|
Quicsilver.connection_shutdown(connection_handle, Protocol::H3_CLOSED_CRITICAL_STREAM, false) rescue nil
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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)
|
|
261
406
|
end
|
|
262
|
-
|
|
263
407
|
when STREAM_EVENT_STOP_SENDING
|
|
264
|
-
return unless @connections[connection_handle]
|
|
408
|
+
return unless (connection = @connections[connection_handle])
|
|
265
409
|
event = Transport::StreamEvent.new(data, "STOP_SENDING")
|
|
266
410
|
Quicsilver.logger.debug("Stream #{stream_id} stop sending requested with error code: 0x#{event.error_code.to_s(16)}")
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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
|
|
270
452
|
end
|
|
271
453
|
end
|
|
272
454
|
|
|
273
455
|
private
|
|
274
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
|
+
|
|
275
559
|
def setup_signal_handlers
|
|
276
560
|
%w[INT TERM].each do |signal|
|
|
277
561
|
trap(signal) { Thread.new { shutdown } }
|
|
@@ -301,36 +585,496 @@ module Quicsilver
|
|
|
301
585
|
|
|
302
586
|
attr_reader :work_queue
|
|
303
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
|
+
|
|
304
707
|
def dispatch_request(connection, stream, early_data: false)
|
|
305
|
-
if @
|
|
708
|
+
if @scheduler.full?
|
|
306
709
|
Quicsilver.logger.warn("Work queue full (#{@max_queue_size}), rejecting request")
|
|
307
710
|
connection.send_error(stream, 503, "Service Unavailable") if stream.writable?
|
|
308
711
|
else
|
|
309
|
-
|
|
712
|
+
enqueue_work([connection, stream, early_data])
|
|
310
713
|
end
|
|
311
714
|
end
|
|
312
715
|
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
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)
|
|
321
773
|
end
|
|
322
|
-
|
|
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)
|
|
323
833
|
end
|
|
324
834
|
end
|
|
325
835
|
|
|
326
|
-
def
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
@
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
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
|
|
333
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
|
|
334
1078
|
end
|
|
335
1079
|
end
|
|
336
1080
|
end
|