quicsilver 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yml +9 -1
- data/.github/workflows/cibuildgem.yaml +29 -5
- data/.gitignore +5 -0
- data/CHANGELOG.md +50 -0
- data/Gemfile.lock +8 -1
- data/README.md +128 -15
- data/Rakefile +10 -3
- data/benchmarks/baseline.rb +175 -0
- data/benchmarks/compare.rb +398 -0
- data/benchmarks/helpers.rb +138 -64
- data/benchmarks/latency.rb +128 -0
- data/benchmarks/loss.rb +193 -0
- data/benchmarks/mobile.rb +125 -0
- data/docs/connection_identity.md +141 -0
- data/examples/benchmark.rb +80 -0
- data/examples/connection_pool_demo.rb +1 -1
- data/examples/example_helper.rb +51 -0
- data/examples/feature_demo.rb +7 -7
- data/examples/grpc_style.rb +2 -2
- data/examples/latency_aware_routing.rb +382 -0
- data/examples/lifecycle_drain.rb +264 -0
- data/examples/priorities.rb +2 -2
- data/examples/rails_feature_test.rb +92 -72
- data/examples/server_stats.rb +117 -0
- data/examples/simple_client_test.rb +3 -3
- data/examples/trailers.rb +23 -23
- data/examples/webtransport_smoke.rb +546 -0
- data/examples/worker_load_balancing.rb +425 -0
- data/ext/quicsilver/extconf.rb +100 -15
- data/ext/quicsilver/include/msquic.h +2226 -0
- data/ext/quicsilver/include/msquic_posix.h +696 -0
- data/ext/quicsilver/include/quic_sal_stub.h +314 -0
- data/ext/quicsilver/msquic_patch.rb +68 -0
- data/ext/quicsilver/patches/reliable-reset.patch +723 -0
- data/ext/quicsilver/patches/stream-final-size.patch +47 -0
- data/ext/quicsilver/quicsilver.c +1640 -222
- data/lib/quicsilver/client/client.rb +419 -60
- data/lib/quicsilver/client/connection_pool.rb +122 -26
- data/lib/quicsilver/client/request.rb +106 -14
- data/lib/quicsilver/errors.rb +39 -0
- data/lib/quicsilver/libmsquic.2.dylib +0 -0
- data/lib/quicsilver/protocol/adapter.rb +41 -21
- data/lib/quicsilver/protocol/capsule.rb +38 -0
- data/lib/quicsilver/protocol/connect_stream_decoder.rb +75 -0
- data/lib/quicsilver/protocol/control_stream_parser.rb +12 -1
- data/lib/quicsilver/protocol/datagram.rb +40 -0
- data/lib/quicsilver/protocol/frame_parser.rb +5 -6
- data/lib/quicsilver/protocol/frame_reader.rb +31 -2
- data/lib/quicsilver/protocol/frames.rb +128 -12
- data/lib/quicsilver/protocol/priority.rb +9 -0
- data/lib/quicsilver/protocol/qpack/decoder.rb +4 -42
- data/lib/quicsilver/protocol/qpack/encoder.rb +10 -20
- data/lib/quicsilver/protocol/qpack/huffman.rb +4 -4
- data/lib/quicsilver/protocol/request_encoder.rb +6 -3
- data/lib/quicsilver/protocol/request_parser.rb +16 -2
- data/lib/quicsilver/protocol/response_encoder.rb +18 -8
- data/lib/quicsilver/protocol/response_parser.rb +6 -0
- data/lib/quicsilver/protocol/stream_input.rb +3 -3
- data/lib/quicsilver/protocol/unidirectional_stream.rb +12 -0
- data/lib/quicsilver/protocol/web_transport.rb +214 -0
- data/lib/quicsilver/rack/context.rb +33 -0
- data/lib/quicsilver/response.rb +40 -0
- data/lib/quicsilver/server/rack_adapter.rb +77 -0
- data/lib/quicsilver/server/receive_queue.rb +106 -0
- data/lib/quicsilver/server/request_handler.rb +24 -13
- data/lib/quicsilver/server/request_registry.rb +18 -5
- data/lib/quicsilver/server/scheduler.rb +50 -0
- data/lib/quicsilver/server/schedulers/fiber_scheduler.rb +79 -0
- data/lib/quicsilver/server/schedulers/thread_scheduler.rb +76 -0
- data/lib/quicsilver/server/server.rb +655 -185
- data/lib/quicsilver/server/web_transport_flow_control.rb +270 -0
- data/lib/quicsilver/server/web_transport_manager.rb +281 -0
- data/lib/quicsilver/server/web_transport_registry.rb +40 -0
- data/lib/quicsilver/server/web_transport_session.rb +812 -0
- data/lib/quicsilver/server/web_transport_stream.rb +326 -0
- data/lib/quicsilver/transport/configuration.rb +103 -10
- data/lib/quicsilver/transport/connection.rb +302 -87
- data/lib/quicsilver/transport/connection_stats.rb +53 -0
- data/lib/quicsilver/transport/event_loop.rb +36 -0
- data/lib/quicsilver/transport/inbound_stream.rb +21 -1
- data/lib/quicsilver/transport/stream.rb +18 -3
- data/lib/quicsilver/transport/stream_event.rb +25 -2
- data/lib/quicsilver/transport/stream_id.rb +26 -0
- data/lib/quicsilver/version.rb +1 -1
- data/lib/quicsilver.rb +22 -11
- data/lib/rackup/handler/quicsilver.rb +57 -38
- data/quicsilver.gemspec +10 -3
- metadata +54 -8
- data/benchmarks/concurrent.rb +0 -110
- data/benchmarks/quicsilver_server.rb +0 -46
- data/benchmarks/rails.rb +0 -170
- data/benchmarks/throughput.rb +0 -113
- data/lib/quicsilver/quicsilver.bundle +0 -0
|
@@ -0,0 +1,812 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../protocol/connect_stream_decoder"
|
|
4
|
+
|
|
5
|
+
module Quicsilver
|
|
6
|
+
class Server
|
|
7
|
+
# A WebTransport session opened via Extended CONNECT (RFC 9220).
|
|
8
|
+
#
|
|
9
|
+
# WebTransport provides two messaging modes on the same session:
|
|
10
|
+
# - Datagrams: unreliable, unordered (live cursors, typing indicators)
|
|
11
|
+
# - Streams: reliable, ordered (chat messages, RPC)
|
|
12
|
+
#
|
|
13
|
+
# The Rack app owns Origin validation (draft-ietf-webtrans-http3-16 §3.2).
|
|
14
|
+
# Check HTTP_ORIGIN against trusted origins before calling accept!; return
|
|
15
|
+
# 403 to reject. Non-browser clients may omit Origin, so the app also decides
|
|
16
|
+
# whether to allow requests without it. Origin is not client authentication.
|
|
17
|
+
#
|
|
18
|
+
# Usage inside a Rack app (a browser-only endpoint):
|
|
19
|
+
# session = env["quicsilver.context"].webtransport
|
|
20
|
+
# return [403, {}, []] unless env["HTTP_ORIGIN"] == "https://app.example.com"
|
|
21
|
+
# session.accept!
|
|
22
|
+
# session.on_datagram { |data| session.send_datagram("echo: #{data}") }
|
|
23
|
+
# [200, {}, []]
|
|
24
|
+
#
|
|
25
|
+
class WebTransportSession
|
|
26
|
+
attr_reader :path, :authority, :headers, :connection, :stream_id
|
|
27
|
+
|
|
28
|
+
# WebTransport stream types (draft-ietf-webtrans-http3), matching aioquic/Chrome.
|
|
29
|
+
WT_STREAM_BIDI = Protocol::WebTransport::BIDI_STREAM_TYPE
|
|
30
|
+
WT_STREAM_UNI = Protocol::WebTransport::UNI_STREAM_TYPE
|
|
31
|
+
WT_CLOSE_SESSION = Protocol::WebTransport::CLOSE_SESSION_CAPSULE
|
|
32
|
+
WT_DRAIN_SESSION = Protocol::WebTransport::DRAIN_SESSION_CAPSULE
|
|
33
|
+
|
|
34
|
+
# Why a session ended. A clean close with no capsule is code 0 and an
|
|
35
|
+
# empty reason (draft-ietf-webtrans-http3-16 §6).
|
|
36
|
+
#
|
|
37
|
+
# `remote` distinguishes the peer hanging up from us closing, which the
|
|
38
|
+
# wire format does not carry but applications need for reconnect logic.
|
|
39
|
+
CloseInfo = Data.define(:code, :reason, :remote) do
|
|
40
|
+
def remote? = remote
|
|
41
|
+
def local? = !remote
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# WT_CLOSE_SESSION payload layout (§6):
|
|
45
|
+
#
|
|
46
|
+
# [error code: 4 bytes, network order][reason: UTF-8, ≤ 1024 bytes]
|
|
47
|
+
ERROR_CODE_BYTES = 4
|
|
48
|
+
MAX_CLOSE_MESSAGE_LENGTH = 1024
|
|
49
|
+
|
|
50
|
+
# A client may send capsules before we answer CONNECT, and we must hold
|
|
51
|
+
# them unparsed until then (§3.2). Bound that hold so an unaccepted
|
|
52
|
+
# session cannot buffer without limit; matches the pre-SETTINGS cap in
|
|
53
|
+
# WebTransportManager.
|
|
54
|
+
MAX_OPTIMISTIC_CONNECT_BYTES = 65_536
|
|
55
|
+
|
|
56
|
+
# The receive policy for every child stream. This is the only place these
|
|
57
|
+
# defaults live; WebTransportStream requires them to be passed in.
|
|
58
|
+
DEFAULT_RECEIVE_OPTIONS = {
|
|
59
|
+
receive_buffer_bytes: 1_048_576,
|
|
60
|
+
receive_buffer_chunks: 1024,
|
|
61
|
+
receive_overflow_code: 0,
|
|
62
|
+
receive_backpressure: true
|
|
63
|
+
}.freeze
|
|
64
|
+
|
|
65
|
+
# Parse a bidirectional WebTransport stream prefix:
|
|
66
|
+
# [type=0x41 varint][session_id varint][data...]
|
|
67
|
+
# Returns [session_id, remainder] or nil if malformed.
|
|
68
|
+
# A session id is the stream id of the CONNECT request that established
|
|
69
|
+
# it, so it is always a client-initiated bidirectional stream
|
|
70
|
+
# (draft-ietf-webtrans-http3-16 §4). Anything else cannot name a session.
|
|
71
|
+
def self.validate_session_id!(session_id)
|
|
72
|
+
return if session_id % 4 == 0
|
|
73
|
+
|
|
74
|
+
raise Protocol::FrameError.new("Invalid WebTransport session ID #{session_id}", error_code: Protocol::H3_ID_ERROR)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def self.parse_stream_prefix(payload)
|
|
78
|
+
type, type_len = Protocol.decode_varint_str(payload, 0)
|
|
79
|
+
return nil unless type == WT_STREAM_BIDI && type_len > 0
|
|
80
|
+
|
|
81
|
+
session_id, sid_len = Protocol.decode_varint_str(payload, type_len)
|
|
82
|
+
return nil if sid_len == 0
|
|
83
|
+
validate_session_id!(session_id)
|
|
84
|
+
|
|
85
|
+
[session_id, payload.byteslice((type_len + sid_len)..-1) || "".b]
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Route an incoming WebTransport stream to the right session.
|
|
89
|
+
def self.accept_stream(sessions, stream_id, stream_handle, payload, fin: false)
|
|
90
|
+
session_id, initial_data = parse_stream_prefix(payload)
|
|
91
|
+
return unless session_id
|
|
92
|
+
|
|
93
|
+
session = sessions[session_id]
|
|
94
|
+
return unless session
|
|
95
|
+
|
|
96
|
+
wt_stream = session.add_stream(stream_handle, stream_id)
|
|
97
|
+
fin ? wt_stream.receive_fin(initial_data) : wt_stream.receive_data(initial_data)
|
|
98
|
+
wt_stream
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# A bare FIN means a clean close; a CLOSE capsule must contain its
|
|
102
|
+
# four-byte code and a valid, bounded UTF-8 reason (draft-16 §6).
|
|
103
|
+
def self.parse_close_payload(payload)
|
|
104
|
+
raise Protocol::Capsule::ParseError, "Truncated close code" if payload.bytesize < ERROR_CODE_BYTES
|
|
105
|
+
code = payload.unpack1("N")
|
|
106
|
+
reason = payload.byteslice(ERROR_CODE_BYTES..).dup.force_encoding(Encoding::UTF_8)
|
|
107
|
+
unless reason.bytesize <= MAX_CLOSE_MESSAGE_LENGTH && reason.valid_encoding?
|
|
108
|
+
raise Protocol::Capsule::ParseError, "Invalid close reason"
|
|
109
|
+
end
|
|
110
|
+
CloseInfo.new(code: code, reason: reason, remote: true)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def self.build_close_payload(code, reason)
|
|
114
|
+
unless code.is_a?(Integer) && code.between?(0, 0xffff_ffff)
|
|
115
|
+
raise ArgumentError, "Close code must be an unsigned 32-bit integer"
|
|
116
|
+
end
|
|
117
|
+
[code].pack("N") + truncate_reason(reason).b
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Truncate only at a UTF-8 character boundary (draft-16 §6).
|
|
121
|
+
def self.truncate_reason(reason, limit = MAX_CLOSE_MESSAGE_LENGTH)
|
|
122
|
+
reason = reason.to_s
|
|
123
|
+
reason = reason.b.dup.force_encoding(Encoding::UTF_8) if reason.encoding == Encoding::ASCII_8BIT
|
|
124
|
+
reason = reason.encode(Encoding::UTF_8)
|
|
125
|
+
raise ArgumentError, "Close reason must be valid UTF-8" unless reason.valid_encoding?
|
|
126
|
+
return reason if reason.bytesize <= limit
|
|
127
|
+
|
|
128
|
+
truncated = reason.byteslice(0, limit)
|
|
129
|
+
truncated = truncated.byteslice(0, truncated.bytesize - 1) until truncated.valid_encoding?
|
|
130
|
+
truncated
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def self.parse_uni_stream_data(payload)
|
|
134
|
+
session_id, sid_len = Protocol.decode_varint_str(payload, 0)
|
|
135
|
+
return nil if sid_len == 0
|
|
136
|
+
validate_session_id!(session_id)
|
|
137
|
+
|
|
138
|
+
[session_id, payload.byteslice(sid_len..-1) || "".b]
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def initialize(connection:, stream:, headers:)
|
|
142
|
+
@connection = connection
|
|
143
|
+
@stream = stream
|
|
144
|
+
@stream_id = stream.stream_id
|
|
145
|
+
@path = headers[":path"]
|
|
146
|
+
@authority = headers[":authority"]
|
|
147
|
+
@headers = headers
|
|
148
|
+
@accepted = false
|
|
149
|
+
@open = false
|
|
150
|
+
@datagram_callback = nil
|
|
151
|
+
@drain_callback = nil
|
|
152
|
+
@stream_callback = nil
|
|
153
|
+
@uni_stream_callback = nil
|
|
154
|
+
@close_callback = nil
|
|
155
|
+
@streams = {} # stream_id => WebTransportStream
|
|
156
|
+
@starting_streams = {}
|
|
157
|
+
@streams_mutex = Mutex.new
|
|
158
|
+
@connect_buffer = "".b
|
|
159
|
+
@connect_decoder = Protocol::ConnectStreamDecoder.new
|
|
160
|
+
@connect_send_finished = false
|
|
161
|
+
@received_close = false
|
|
162
|
+
@connect_failed = false
|
|
163
|
+
@closed = false
|
|
164
|
+
@flow_control_enabled = false
|
|
165
|
+
@receive_options = DEFAULT_RECEIVE_OPTIONS
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def stream_manager=(manager)
|
|
169
|
+
@streams_mutex.synchronize do
|
|
170
|
+
@stream_manager = manager
|
|
171
|
+
@streams.each_key { |stream_id| manager.register_stream(stream_id, @stream_id) }
|
|
172
|
+
@starting_streams.each_value { |stream| manager.register_starting_stream(stream) }
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Accept the session — sends 200 HEADERS on the CONNECT stream.
|
|
177
|
+
# Call only after the app has checked Origin and authorized the request.
|
|
178
|
+
#
|
|
179
|
+
# Limits apply to each child's queued Ruby input, not native buffers or
|
|
180
|
+
# the whole connection.
|
|
181
|
+
#
|
|
182
|
+
# By default a child that fills its buffer stops being delivered until
|
|
183
|
+
# the application reads, so a slow reader keeps its stream. A reader that
|
|
184
|
+
# never drains leaves it paused indefinitely, and a paused stream holds
|
|
185
|
+
# connection credit, which can stall its siblings.
|
|
186
|
+
#
|
|
187
|
+
# With receive_backpressure: false an overflowing child is reset instead:
|
|
188
|
+
# we send STOP_SENDING with receive_overflow_code, discard its queued
|
|
189
|
+
# input, and fail its pending reads. Its write side stays open.
|
|
190
|
+
def accept!(**receive_options)
|
|
191
|
+
raise IOError, "Session closed" if @closed
|
|
192
|
+
return if @accepted
|
|
193
|
+
|
|
194
|
+
unknown = receive_options.keys - DEFAULT_RECEIVE_OPTIONS.keys
|
|
195
|
+
raise ArgumentError, "Unknown receive options: #{unknown.join(", ")}" if unknown.any?
|
|
196
|
+
|
|
197
|
+
options = DEFAULT_RECEIVE_OPTIONS.merge(receive_options)
|
|
198
|
+
ReceiveQueue.validate_limits(options[:receive_buffer_bytes], options[:receive_buffer_chunks])
|
|
199
|
+
WebTransportStream.validate_backpressure(options[:receive_backpressure],
|
|
200
|
+
options[:receive_buffer_bytes], options[:receive_buffer_chunks])
|
|
201
|
+
WebTransportStream.validate_overflow_code(options[:receive_overflow_code])
|
|
202
|
+
@receive_options = options
|
|
203
|
+
frame = Protocol.build_headers_frame([[:":status", "200"]])
|
|
204
|
+
@stream.send(frame, fin: false)
|
|
205
|
+
@accepted = true
|
|
206
|
+
@open = true
|
|
207
|
+
@flow_control_enabled = @connection.wt_flow_control_enabled?
|
|
208
|
+
# A client may send capsules optimistically before our response. We held
|
|
209
|
+
# those bytes unparsed; now that the 2xx is out they can be processed
|
|
210
|
+
# (draft-ietf-webtrans-http3-16 §3.2).
|
|
211
|
+
drain_connect_capsules
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# Send a datagram to the client (unreliable, no retransmission).
|
|
215
|
+
def send_datagram(data)
|
|
216
|
+
raise "Session not accepted" unless @accepted
|
|
217
|
+
raise "Session not open" unless accepts_datagrams?
|
|
218
|
+
|
|
219
|
+
Quicsilver.datagram_send(@connection.data, Protocol::Datagram.encode(@stream_id, data))
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# Register a callback for datagrams from the client.
|
|
223
|
+
def on_datagram(&block)
|
|
224
|
+
@datagram_callback = block
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# Register a callback for incoming streams from the client.
|
|
228
|
+
def on_stream(&block)
|
|
229
|
+
@stream_callback = block
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# Open a server-initiated bidirectional stream to the client.
|
|
233
|
+
def open_stream
|
|
234
|
+
open_outgoing_stream(unidirectional: false)
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# Register a callback for incoming unidirectional streams (client → server, read-only).
|
|
238
|
+
def on_uni_stream(&block)
|
|
239
|
+
@uni_stream_callback = block
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# Open a server-initiated unidirectional stream to the client (write-only).
|
|
243
|
+
def open_uni_stream
|
|
244
|
+
open_outgoing_stream(unidirectional: true)
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# Called when the peer raises our send credit, so an application that
|
|
248
|
+
# hit SendBlocked knows to retry. Writes are all or nothing, so there is
|
|
249
|
+
# no partial progress to resume from.
|
|
250
|
+
#
|
|
251
|
+
# session.on_writable { flush_pending }
|
|
252
|
+
def on_writable(&block)
|
|
253
|
+
@writable_callback = block
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
# Register a callback for session close.
|
|
257
|
+
def on_close(&block)
|
|
258
|
+
@close_callback = block
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
# The peer is asking us to wind this session down. Advisory: the session
|
|
262
|
+
# stays usable, and the application decides when to close (draft-16 §4.7).
|
|
263
|
+
#
|
|
264
|
+
# session.on_drain { stop_accepting_work }
|
|
265
|
+
def on_drain(&block)
|
|
266
|
+
@drain_callback = block
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
# Ask the peer to wind the session down. Does not close it.
|
|
270
|
+
def drain!
|
|
271
|
+
@stream.send(Protocol.build_frame(Protocol::FRAME_DATA, Protocol::Capsule.encode(WT_DRAIN_SESSION, "")), fin: false)
|
|
272
|
+
rescue
|
|
273
|
+
# Best-effort — connection may already be gone
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def accepted?
|
|
277
|
+
@accepted
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
def open?
|
|
281
|
+
@open && !@closed
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
def closed?
|
|
285
|
+
@closed
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
def routable?
|
|
289
|
+
!@closed
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
def accepts_new_streams?
|
|
293
|
+
open?
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
def accepts_datagrams?
|
|
297
|
+
open?
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
def reject!(status = 403, headers = {})
|
|
301
|
+
return if @accepted || @rejected
|
|
302
|
+
@rejected = true
|
|
303
|
+
|
|
304
|
+
response_headers = [[":status", status.to_i.to_s]]
|
|
305
|
+
headers.each { |name, value| response_headers << [name.to_s.downcase, value.to_s] }
|
|
306
|
+
@stream.send(Protocol.build_headers_frame(response_headers), fin: true)
|
|
307
|
+
@open = false
|
|
308
|
+
# Optimistic capsules are discarded when the session is rejected (§3.2).
|
|
309
|
+
@connect_buffer = "".b
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
# Both endpoints must have advertised a non-zero WT_INITIAL_MAX_*
|
|
313
|
+
# setting (§5.1). We advertise none, so this is false today.
|
|
314
|
+
# Resolved once when the session is accepted, not asked per chunk: the
|
|
315
|
+
# answer cannot change afterwards, and the receive path reads it for
|
|
316
|
+
# every arriving byte.
|
|
317
|
+
def flow_control_enabled?
|
|
318
|
+
@flow_control_enabled
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
# What the peer permits us to send in this session. Seeded from its
|
|
322
|
+
# SETTINGS, then raised by capsules (§5.5, §5.6).
|
|
323
|
+
def flow_control
|
|
324
|
+
@flow_control ||= WebTransportFlowControl.from_settings(peer_settings)
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
# What the peer may send us, from the limits we advertised (§5.3, §5.4).
|
|
328
|
+
def receive_limits
|
|
329
|
+
@receive_limits ||= WebTransportReceiveLimits.from_settings(@connection.local_settings)
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
# Charge an incoming child stream against the limit we advertised
|
|
333
|
+
# (§5.6.2). Returns false when the limit is exceeded, having failed the
|
|
334
|
+
# session; the caller must not deliver the stream to the application.
|
|
335
|
+
#
|
|
336
|
+
# Exceeding a limit is a session error, so it must not escape into the
|
|
337
|
+
# transport callback that routed the stream: that runs on the poll
|
|
338
|
+
# thread and would take the whole connection down with it.
|
|
339
|
+
def count_incoming_stream(direction) # :nodoc:
|
|
340
|
+
return true unless flow_control_enabled?
|
|
341
|
+
|
|
342
|
+
receive_limits.open_stream!(direction)
|
|
343
|
+
true
|
|
344
|
+
rescue Protocol::WebTransport::FlowControlError => error
|
|
345
|
+
handle_capsule_error(error)
|
|
346
|
+
false
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
# Charge received stream body bytes (§5.6.4). Called per chunk, so it
|
|
350
|
+
# stays a plain integer add when flow control is off.
|
|
351
|
+
def count_received_data(bytes) # :nodoc:
|
|
352
|
+
return unless flow_control_enabled?
|
|
353
|
+
|
|
354
|
+
receive_limits.receive_data!(bytes)
|
|
355
|
+
rescue Protocol::WebTransport::FlowControlError => error
|
|
356
|
+
handle_capsule_error(error)
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
# Charge an outgoing stream against what the peer granted. Tells the peer
|
|
360
|
+
# we were blocked before raising, so it can raise the limit (§5.6.3).
|
|
361
|
+
def count_outgoing_stream(direction) # :nodoc:
|
|
362
|
+
return unless flow_control_enabled?
|
|
363
|
+
|
|
364
|
+
flow_control.open_stream!(direction)
|
|
365
|
+
rescue Protocol::WebTransport::SendBlocked => blocked
|
|
366
|
+
send_capsule(Protocol::WebTransport.build_streams_blocked(direction, blocked.limit))
|
|
367
|
+
raise
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
# Can this session send `bytes` right now? Ask before writing to avoid
|
|
371
|
+
# handling SendBlocked; writes are all or nothing, so a write larger
|
|
372
|
+
# than the remaining credit sends nothing.
|
|
373
|
+
#
|
|
374
|
+
# Credit rises only when the peer sends a WT_MAX_DATA capsule, so an
|
|
375
|
+
# application that is blocked has to retry. A notification for that
|
|
376
|
+
# arrives with grant handling.
|
|
377
|
+
def can_send?(bytes = 1) # :nodoc:
|
|
378
|
+
return true unless flow_control_enabled?
|
|
379
|
+
|
|
380
|
+
flow_control.data_remaining >= bytes
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
# Charge outgoing stream body bytes (§5.6.4). Body only: the prefix that
|
|
384
|
+
# links a stream to this session costs nothing.
|
|
385
|
+
def count_sent_data(bytes) # :nodoc:
|
|
386
|
+
return unless flow_control_enabled?
|
|
387
|
+
|
|
388
|
+
flow_control.send_data!(bytes)
|
|
389
|
+
rescue Protocol::WebTransport::SendBlocked => blocked
|
|
390
|
+
send_capsule(Protocol::WebTransport.build_data_blocked(blocked.limit))
|
|
391
|
+
raise
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
# Capsules ride the CONNECT stream as HTTP/3 DATA, like CLOSE and DRAIN.
|
|
395
|
+
# Signalling is advisory, so a send failure must not break the session.
|
|
396
|
+
def send_capsule(capsule)
|
|
397
|
+
@stream.send(Protocol.build_frame(Protocol::FRAME_DATA, capsule), fin: false)
|
|
398
|
+
rescue StandardError => error
|
|
399
|
+
Quicsilver.logger.debug(
|
|
400
|
+
"WebTransport session #{@stream_id} could not send a flow control capsule: #{error.message}"
|
|
401
|
+
)
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
# The application read `bytes`, freeing that much of the window. Grant
|
|
405
|
+
# the peer more without waiting to be told we blocked it: "An endpoint
|
|
406
|
+
# MUST NOT wait for a WT_DATA_BLOCKED... capsule before sending a
|
|
407
|
+
# WT_MAX_DATA" (§5.6).
|
|
408
|
+
def count_consumed_data(bytes) # :nodoc:
|
|
409
|
+
return unless flow_control_enabled?
|
|
410
|
+
|
|
411
|
+
granted = receive_limits.consume_data(bytes)
|
|
412
|
+
send_capsule(Protocol::WebTransport.build_max_data(granted)) if granted
|
|
413
|
+
end
|
|
414
|
+
|
|
415
|
+
# A child closed, so the session can afford another (§5.6).
|
|
416
|
+
def count_closed_stream(direction) # :nodoc:
|
|
417
|
+
return unless flow_control_enabled?
|
|
418
|
+
|
|
419
|
+
granted = receive_limits.close_stream(direction)
|
|
420
|
+
send_capsule(Protocol::WebTransport.build_max_streams(direction, granted)) if granted
|
|
421
|
+
end
|
|
422
|
+
|
|
423
|
+
# A reset charges what the sender counted, not what reached us (§5.4).
|
|
424
|
+
def count_reset_stream(final_size, delivered) # :nodoc:
|
|
425
|
+
return unless flow_control_enabled?
|
|
426
|
+
|
|
427
|
+
receive_limits.reset_stream!(final_size, delivered)
|
|
428
|
+
rescue Protocol::WebTransport::FlowControlError => error
|
|
429
|
+
handle_capsule_error(error)
|
|
430
|
+
end
|
|
431
|
+
|
|
432
|
+
def notify_writable
|
|
433
|
+
@writable_callback&.call
|
|
434
|
+
rescue StandardError => error
|
|
435
|
+
Quicsilver.logger.error("WebTransport on_writable callback failed: #{error.message}")
|
|
436
|
+
end
|
|
437
|
+
|
|
438
|
+
def peer_settings
|
|
439
|
+
@connection.settings
|
|
440
|
+
end
|
|
441
|
+
|
|
442
|
+
# Look up a stream by ID within this session.
|
|
443
|
+
def stream(stream_id)
|
|
444
|
+
@streams[stream_id]
|
|
445
|
+
end
|
|
446
|
+
|
|
447
|
+
# Close the session with an optional error code and message.
|
|
448
|
+
# Sends a WT_CLOSE_SESSION capsule (RFC draft-ietf-webtrans-http3)
|
|
449
|
+
# on the CONNECT stream before closing.
|
|
450
|
+
def close(code: 0, reason: "")
|
|
451
|
+
return if closed?
|
|
452
|
+
# Conversion can invoke application code or raise. Do it before
|
|
453
|
+
# changing lifecycle state so invalid input leaves the session usable.
|
|
454
|
+
payload = self.class.build_close_payload(code, reason)
|
|
455
|
+
was_open = @streams_mutex.synchronize do
|
|
456
|
+
was_open = @open
|
|
457
|
+
@open = false
|
|
458
|
+
was_open
|
|
459
|
+
end
|
|
460
|
+
|
|
461
|
+
if was_open
|
|
462
|
+
capsule = Protocol::Capsule.encode(WT_CLOSE_SESSION, payload)
|
|
463
|
+
finish_connect(Protocol.build_frame(Protocol::FRAME_DATA, capsule))
|
|
464
|
+
end
|
|
465
|
+
|
|
466
|
+
notify_close(code: code, reason: reason, remote: false)
|
|
467
|
+
end
|
|
468
|
+
|
|
469
|
+
# Decode CONNECT body bytes, then apply session-close policy.
|
|
470
|
+
def receive_connect_stream_data(data, fin: false)
|
|
471
|
+
return if @connect_failed
|
|
472
|
+
if @received_close
|
|
473
|
+
if data && !data.empty?
|
|
474
|
+
return handle_capsule_error(Protocol::Capsule::ParseError.new("Stream data after close capsule"))
|
|
475
|
+
end
|
|
476
|
+
@connect_decoder.finish! if fin
|
|
477
|
+
end
|
|
478
|
+
return if closed?
|
|
479
|
+
|
|
480
|
+
begin
|
|
481
|
+
@connect_decoder.each(data) do |chunk|
|
|
482
|
+
receive_connect_data(chunk)
|
|
483
|
+
break if @connect_failed || @received_close
|
|
484
|
+
end
|
|
485
|
+
ensure
|
|
486
|
+
# Application callbacks must not bypass trailing-byte or FIN checks.
|
|
487
|
+
if @received_close && !@connect_failed
|
|
488
|
+
if @connect_decoder.buffered?
|
|
489
|
+
handle_capsule_error(Protocol::Capsule::ParseError.new("Stream data after close capsule"))
|
|
490
|
+
elsif fin
|
|
491
|
+
@connect_decoder.finish!
|
|
492
|
+
end
|
|
493
|
+
end
|
|
494
|
+
end
|
|
495
|
+
return if @connect_failed
|
|
496
|
+
@connect_decoder.finish! if fin && !@received_close
|
|
497
|
+
receive_connect_fin("") if fin
|
|
498
|
+
rescue Protocol::FrameError => error
|
|
499
|
+
@connect_failed = true
|
|
500
|
+
@connect_decoder.clear
|
|
501
|
+
begin
|
|
502
|
+
@connection.shutdown(error.error_code)
|
|
503
|
+
ensure
|
|
504
|
+
notify_close
|
|
505
|
+
end
|
|
506
|
+
end
|
|
507
|
+
|
|
508
|
+
# Capsule bytes extracted from the CONNECT HTTP message body. :nodoc:
|
|
509
|
+
def receive_connect_data(data)
|
|
510
|
+
return if @connect_failed
|
|
511
|
+
if @received_close && data && !data.empty?
|
|
512
|
+
return handle_capsule_error(Protocol::Capsule::ParseError.new("Data after close capsule"))
|
|
513
|
+
end
|
|
514
|
+
return if closed?
|
|
515
|
+
@connect_buffer << data if data && !data.empty?
|
|
516
|
+
# Hold optimistic capsules unparsed until we have sent the 2xx (§3.2).
|
|
517
|
+
unless @accepted
|
|
518
|
+
if @connect_buffer.bytesize > MAX_OPTIMISTIC_CONNECT_BYTES
|
|
519
|
+
handle_capsule_error(
|
|
520
|
+
Protocol::Capsule::ParseError.new("Too many capsule bytes before the session was accepted")
|
|
521
|
+
)
|
|
522
|
+
end
|
|
523
|
+
return
|
|
524
|
+
end
|
|
525
|
+
|
|
526
|
+
drain_connect_capsules
|
|
527
|
+
end
|
|
528
|
+
|
|
529
|
+
# Parse whatever complete capsules the CONNECT buffer holds. Only reached
|
|
530
|
+
# once the session is accepted. :nodoc:
|
|
531
|
+
def drain_connect_capsules
|
|
532
|
+
while (capsule = Protocol::Capsule.parse(@connect_buffer))
|
|
533
|
+
type, payload, @connect_buffer = capsule
|
|
534
|
+
begin
|
|
535
|
+
handle_capsule(type, payload)
|
|
536
|
+
ensure
|
|
537
|
+
if @received_close && !@connect_buffer.empty?
|
|
538
|
+
handle_capsule_error(Protocol::Capsule::ParseError.new("Data after close capsule"))
|
|
539
|
+
end
|
|
540
|
+
end
|
|
541
|
+
break if @received_close
|
|
542
|
+
end
|
|
543
|
+
rescue Protocol::Capsule::ParseError, Protocol::WebTransport::FlowControlError => error
|
|
544
|
+
handle_capsule_error(error)
|
|
545
|
+
end
|
|
546
|
+
|
|
547
|
+
# Called by Server when the CONNECT/session stream receives FIN. :nodoc:
|
|
548
|
+
def receive_connect_fin(data)
|
|
549
|
+
receive_connect_data(data)
|
|
550
|
+
# An unaccepted session never parsed these bytes, so a partial capsule
|
|
551
|
+
# is not a peer error we can report (§3.2).
|
|
552
|
+
if @accepted && !@connect_buffer.empty?
|
|
553
|
+
handle_capsule_error(Protocol::Capsule::ParseError.new("Truncated capsule"))
|
|
554
|
+
end
|
|
555
|
+
finish_connect if @accepted && !@connect_failed
|
|
556
|
+
notify_close
|
|
557
|
+
end
|
|
558
|
+
|
|
559
|
+
# Called by Server when a datagram arrives for this session.
|
|
560
|
+
def receive_datagram(data) # :nodoc:
|
|
561
|
+
@datagram_callback&.call(data)
|
|
562
|
+
end
|
|
563
|
+
|
|
564
|
+
# Called by Server when the CONNECT stream is reset/closed.
|
|
565
|
+
#
|
|
566
|
+
# Idempotent: subsequent calls are a no-op. This matters because the FIN
|
|
567
|
+
# and capsule-error paths both fan into notify_close, and a CLOSE_SESSION
|
|
568
|
+
# capsule followed by FIN would otherwise fire @close_callback twice and
|
|
569
|
+
# re-walk an already-cleared @streams map.
|
|
570
|
+
def notify_close(code: 0, reason: "", remote: true) # :nodoc:
|
|
571
|
+
streams = @streams_mutex.synchronize do
|
|
572
|
+
return if @closed
|
|
573
|
+
@closed = true
|
|
574
|
+
@open = false
|
|
575
|
+
streams = @streams.values + @starting_streams.values
|
|
576
|
+
@streams.clear
|
|
577
|
+
@starting_streams.clear
|
|
578
|
+
streams
|
|
579
|
+
end
|
|
580
|
+
|
|
581
|
+
Quicsilver.logger.debug("WebTransport session #{@stream_id} notify_close")
|
|
582
|
+
begin
|
|
583
|
+
terminate_streams(streams)
|
|
584
|
+
ensure
|
|
585
|
+
@close_callback&.call(CloseInfo.new(code: code, reason: reason, remote: remote))
|
|
586
|
+
end
|
|
587
|
+
end
|
|
588
|
+
|
|
589
|
+
# Called by Server when a new stream with our session ID arrives.
|
|
590
|
+
def add_stream(stream_handle, stream_id) # :nodoc:
|
|
591
|
+
stream = Transport::Stream.new(stream_handle)
|
|
592
|
+
wt_stream = WebTransportStream.new(
|
|
593
|
+
session: self, stream: stream, stream_id: stream_id, **@receive_options
|
|
594
|
+
)
|
|
595
|
+
return wt_stream unless count_incoming_stream(:bidi)
|
|
596
|
+
return wt_stream unless register_stream(wt_stream)
|
|
597
|
+
@stream_callback&.call(wt_stream)
|
|
598
|
+
wt_stream
|
|
599
|
+
end
|
|
600
|
+
|
|
601
|
+
# Route data to the right stream.
|
|
602
|
+
def route_stream_data(stream_id, data) # :nodoc:
|
|
603
|
+
@streams[stream_id]&.receive_data(data)
|
|
604
|
+
end
|
|
605
|
+
|
|
606
|
+
# Called by Server when an incoming uni stream arrives.
|
|
607
|
+
def add_uni_stream(stream_handle, stream_id) # :nodoc:
|
|
608
|
+
stream = Transport::Stream.new(stream_handle)
|
|
609
|
+
wt_stream = WebTransportStream.new(
|
|
610
|
+
session: self, stream: stream, stream_id: stream_id,
|
|
611
|
+
direction: :receive_only, **@receive_options
|
|
612
|
+
)
|
|
613
|
+
return wt_stream unless count_incoming_stream(:uni)
|
|
614
|
+
return wt_stream unless register_stream(wt_stream)
|
|
615
|
+
@uni_stream_callback&.call(wt_stream)
|
|
616
|
+
wt_stream
|
|
617
|
+
end
|
|
618
|
+
|
|
619
|
+
def stream_started(stream, stream_id)
|
|
620
|
+
@streams_mutex.synchronize do
|
|
621
|
+
starting = @starting_streams.delete(stream.stream_handle)
|
|
622
|
+
stream.notify_start(stream_id)
|
|
623
|
+
@stream_manager&.register_stream(stream_id, @stream_id)
|
|
624
|
+
@streams[stream_id] = stream if starting && !@closed
|
|
625
|
+
end
|
|
626
|
+
end
|
|
627
|
+
|
|
628
|
+
def remove_starting_stream(handle)
|
|
629
|
+
stream = @streams_mutex.synchronize { @starting_streams.delete(handle) }
|
|
630
|
+
stream&.notify_close
|
|
631
|
+
end
|
|
632
|
+
|
|
633
|
+
# Called when a stream within this session is reset.
|
|
634
|
+
# `error_code` is the raw HTTP/3 code from a peer RESET_STREAM, so the
|
|
635
|
+
# stream can report it to the application (§4.4). nil for an ordinary
|
|
636
|
+
# close, where there is no code to deliver.
|
|
637
|
+
def remove_stream(stream_id, error_code: nil) # :nodoc:
|
|
638
|
+
stream = @streams_mutex.synchronize { @streams.delete(stream_id) }
|
|
639
|
+
return unless stream
|
|
640
|
+
|
|
641
|
+
error_code ? stream.notify_peer_reset(error_code) : stream.notify_close
|
|
642
|
+
end
|
|
643
|
+
|
|
644
|
+
private
|
|
645
|
+
|
|
646
|
+
def open_outgoing_stream(unidirectional:)
|
|
647
|
+
raise "Session not accepted" unless @accepted
|
|
648
|
+
raise "Session not open" unless accepts_new_streams?
|
|
649
|
+
|
|
650
|
+
count_outgoing_stream(unidirectional ? :uni : :bidi)
|
|
651
|
+
stream = @connection.open_stream(unidirectional: unidirectional)
|
|
652
|
+
wt_stream = WebTransportStream.new(
|
|
653
|
+
session: self, stream: stream, stream_id: stream.stream_id,
|
|
654
|
+
direction: unidirectional ? :send_only : :bidi, **@receive_options
|
|
655
|
+
)
|
|
656
|
+
type = unidirectional ? WT_STREAM_UNI : WT_STREAM_BIDI
|
|
657
|
+
prefix = Protocol.encode_varint(type) + Protocol.encode_varint(@stream_id)
|
|
658
|
+
# Grant receive credit before the prefix goes out. Native credit
|
|
659
|
+
# enforcement is off until the first grant, so data arriving in that
|
|
660
|
+
# window would bypass the budget entirely rather than be paused.
|
|
661
|
+
wt_stream.enable_receive_backpressure
|
|
662
|
+
stream.send(prefix)
|
|
663
|
+
stream.reliable_offset = prefix.bytesize if @connection.reliable_reset_enabled?
|
|
664
|
+
# Teardown may reset registered streams, so protect the header first.
|
|
665
|
+
# Registration also rejects and aborts if the session closed meanwhile.
|
|
666
|
+
raise "Session not open" unless register_stream(wt_stream, outgoing: true)
|
|
667
|
+
|
|
668
|
+
@stream_manager&.stream_started(stream.stream_id, stream.handle)
|
|
669
|
+
wt_stream
|
|
670
|
+
rescue StandardError
|
|
671
|
+
discard_outgoing_stream(wt_stream) if wt_stream
|
|
672
|
+
raise
|
|
673
|
+
end
|
|
674
|
+
|
|
675
|
+
def discard_outgoing_stream(stream)
|
|
676
|
+
@streams_mutex.synchronize do
|
|
677
|
+
@streams.delete(stream.stream_id)
|
|
678
|
+
@starting_streams.delete(stream.stream_handle)
|
|
679
|
+
@stream_manager&.discard_starting_stream(stream.stream_handle)
|
|
680
|
+
end
|
|
681
|
+
stream.abort(Protocol::WebTransport::SESSION_GONE) if stream.open?
|
|
682
|
+
end
|
|
683
|
+
|
|
684
|
+
def register_stream(stream, outgoing: false)
|
|
685
|
+
registered = @streams_mutex.synchronize do
|
|
686
|
+
if stream.stream_id
|
|
687
|
+
@stream_manager&.register_stream(stream.stream_id, @stream_id)
|
|
688
|
+
else
|
|
689
|
+
@stream_manager&.register_starting_stream(stream)
|
|
690
|
+
end
|
|
691
|
+
next false if @closed || (outgoing && !@open)
|
|
692
|
+
|
|
693
|
+
if stream.stream_id
|
|
694
|
+
@streams[stream.stream_id] = stream
|
|
695
|
+
else
|
|
696
|
+
@starting_streams[stream.stream_handle] = stream
|
|
697
|
+
end
|
|
698
|
+
true
|
|
699
|
+
end
|
|
700
|
+
stream.abort(Protocol::WebTransport::SESSION_GONE) unless registered
|
|
701
|
+
registered
|
|
702
|
+
end
|
|
703
|
+
|
|
704
|
+
def terminate_streams(streams)
|
|
705
|
+
failure = nil
|
|
706
|
+
streams.each do |stream|
|
|
707
|
+
stream.abort(Protocol::WebTransport::SESSION_GONE)
|
|
708
|
+
rescue StandardError => error
|
|
709
|
+
failure ||= error
|
|
710
|
+
end
|
|
711
|
+
raise failure if failure
|
|
712
|
+
end
|
|
713
|
+
|
|
714
|
+
def handle_capsule(type, payload)
|
|
715
|
+
case type
|
|
716
|
+
when WT_CLOSE_SESSION
|
|
717
|
+
closed_with = self.class.parse_close_payload(payload)
|
|
718
|
+
Quicsilver.logger.debug(
|
|
719
|
+
"WebTransport session #{@stream_id} received close capsule " \
|
|
720
|
+
"code=#{closed_with.code} reason=#{closed_with.reason.inspect}"
|
|
721
|
+
)
|
|
722
|
+
# draft-16 §6 requires the recipient to close or reset CONNECT.
|
|
723
|
+
# Finish our send side without discarding any queued response bytes.
|
|
724
|
+
@received_close = true
|
|
725
|
+
if @accepted
|
|
726
|
+
finish_connect
|
|
727
|
+
else
|
|
728
|
+
abort_connect(Protocol::H3_REQUEST_REJECTED)
|
|
729
|
+
end
|
|
730
|
+
notify_close(**closed_with.to_h)
|
|
731
|
+
when *Protocol::WebTransport::PROHIBITED_CAPSULES
|
|
732
|
+
# Per-stream limits are QUIC's job over HTTP/3, so these are a
|
|
733
|
+
# session error however flow control was negotiated (draft-16 §5.4).
|
|
734
|
+
raise Protocol::WebTransport::FlowControlError,
|
|
735
|
+
"Capsule 0x#{type.to_s(16)} is prohibited over HTTP/3"
|
|
736
|
+
when *Protocol::WebTransport::FLOW_CONTROL_CAPSULES.keys
|
|
737
|
+
# "if flow control is not enabled, an endpoint MUST ignore receipt
|
|
738
|
+
# of any flow control capsules... since the peer might not have
|
|
739
|
+
# received SETTINGS at the time they were sent" (§5.1). Ignoring is
|
|
740
|
+
# deliberate here, not a side effect of the type being unknown.
|
|
741
|
+
unless flow_control_enabled?
|
|
742
|
+
Quicsilver.logger.debug(
|
|
743
|
+
"WebTransport session #{@stream_id} ignoring flow control capsule " \
|
|
744
|
+
"0x#{type.to_s(16)}: flow control was not negotiated"
|
|
745
|
+
)
|
|
746
|
+
return
|
|
747
|
+
end
|
|
748
|
+
|
|
749
|
+
# Raises FlowControlError for a limit that does not increase, or a
|
|
750
|
+
# stream count above 2^60 (§5.6.2, §5.6.4).
|
|
751
|
+
before = flow_control.data_remaining
|
|
752
|
+
flow_control.apply(Protocol::WebTransport.parse_flow_control_capsule(type, payload))
|
|
753
|
+
notify_writable if flow_control.data_remaining > before
|
|
754
|
+
when WT_DRAIN_SESSION
|
|
755
|
+
# §4.7 fixes the capsule at Length = 0, so anything in the payload
|
|
756
|
+
# is a malformed message rather than an extension we can ignore.
|
|
757
|
+
unless payload.empty?
|
|
758
|
+
raise Protocol::Capsule::ParseError, "WT_DRAIN_SESSION takes no payload"
|
|
759
|
+
end
|
|
760
|
+
|
|
761
|
+
# Advisory only. The session stays open and usable; it is up to the
|
|
762
|
+
# application to wind down (draft-16 §4.7).
|
|
763
|
+
Quicsilver.logger.debug("WebTransport session #{@stream_id} received drain capsule")
|
|
764
|
+
@drain_callback&.call
|
|
765
|
+
else
|
|
766
|
+
# Unknown capsules are ignored, matching HTTP Capsule extensibility.
|
|
767
|
+
end
|
|
768
|
+
end
|
|
769
|
+
|
|
770
|
+
# A malformed capsule is a message error; a capsule whose contents break
|
|
771
|
+
# a flow control rule closes the session with its own code (§5.6).
|
|
772
|
+
def handle_capsule_error(error)
|
|
773
|
+
return if @connect_failed
|
|
774
|
+
@connect_failed = true
|
|
775
|
+
Quicsilver.logger.debug("WebTransport session #{@stream_id} capsule error: #{error.message}")
|
|
776
|
+
@connect_buffer.clear
|
|
777
|
+
@connect_decoder.clear
|
|
778
|
+
code = error.respond_to?(:error_code) ? error.error_code : Protocol::H3_MESSAGE_ERROR
|
|
779
|
+
abort_connect(code)
|
|
780
|
+
notify_close
|
|
781
|
+
end
|
|
782
|
+
|
|
783
|
+
def abort_connect(code)
|
|
784
|
+
# FIN may already be acknowledged, making a send-only RESET a no-op.
|
|
785
|
+
# Also stop receiving so a peer cannot hold failed CONNECT streams open.
|
|
786
|
+
@stream.abort(code)
|
|
787
|
+
rescue StandardError
|
|
788
|
+
# The native stream may already have gone; still clean up locally.
|
|
789
|
+
end
|
|
790
|
+
|
|
791
|
+
def finish_connect(data = "")
|
|
792
|
+
@streams_mutex.synchronize do
|
|
793
|
+
return if @connect_send_finished
|
|
794
|
+
@connect_send_finished = true
|
|
795
|
+
end
|
|
796
|
+
|
|
797
|
+
# Successful submission preserves queued bytes and sends FIN. If native
|
|
798
|
+
# submission fails on a live stream, abort both directions as a fallback.
|
|
799
|
+
@stream.send(data, fin: true)
|
|
800
|
+
rescue StandardError => error
|
|
801
|
+
begin
|
|
802
|
+
Quicsilver.logger.warn(
|
|
803
|
+
"WebTransport session #{@stream_id} close FIN submission failed (#{error.class}); aborting CONNECT"
|
|
804
|
+
)
|
|
805
|
+
rescue StandardError
|
|
806
|
+
# A broken logger must not prevent transport or session cleanup.
|
|
807
|
+
end
|
|
808
|
+
abort_connect(Protocol::H3_INTERNAL_ERROR)
|
|
809
|
+
end
|
|
810
|
+
end
|
|
811
|
+
end
|
|
812
|
+
end
|