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.
Files changed (94) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +9 -1
  3. data/.github/workflows/cibuildgem.yaml +29 -5
  4. data/.gitignore +5 -0
  5. data/CHANGELOG.md +50 -0
  6. data/Gemfile.lock +8 -1
  7. data/README.md +128 -15
  8. data/Rakefile +10 -3
  9. data/benchmarks/baseline.rb +175 -0
  10. data/benchmarks/compare.rb +398 -0
  11. data/benchmarks/helpers.rb +138 -64
  12. data/benchmarks/latency.rb +128 -0
  13. data/benchmarks/loss.rb +193 -0
  14. data/benchmarks/mobile.rb +125 -0
  15. data/docs/connection_identity.md +141 -0
  16. data/examples/benchmark.rb +80 -0
  17. data/examples/connection_pool_demo.rb +1 -1
  18. data/examples/example_helper.rb +51 -0
  19. data/examples/feature_demo.rb +7 -7
  20. data/examples/grpc_style.rb +2 -2
  21. data/examples/latency_aware_routing.rb +382 -0
  22. data/examples/lifecycle_drain.rb +264 -0
  23. data/examples/priorities.rb +2 -2
  24. data/examples/rails_feature_test.rb +92 -72
  25. data/examples/server_stats.rb +117 -0
  26. data/examples/simple_client_test.rb +3 -3
  27. data/examples/trailers.rb +23 -23
  28. data/examples/webtransport_smoke.rb +546 -0
  29. data/examples/worker_load_balancing.rb +425 -0
  30. data/ext/quicsilver/extconf.rb +100 -15
  31. data/ext/quicsilver/include/msquic.h +2226 -0
  32. data/ext/quicsilver/include/msquic_posix.h +696 -0
  33. data/ext/quicsilver/include/quic_sal_stub.h +314 -0
  34. data/ext/quicsilver/msquic_patch.rb +68 -0
  35. data/ext/quicsilver/patches/reliable-reset.patch +723 -0
  36. data/ext/quicsilver/patches/stream-final-size.patch +47 -0
  37. data/ext/quicsilver/quicsilver.c +1640 -222
  38. data/lib/quicsilver/client/client.rb +419 -60
  39. data/lib/quicsilver/client/connection_pool.rb +122 -26
  40. data/lib/quicsilver/client/request.rb +106 -14
  41. data/lib/quicsilver/errors.rb +39 -0
  42. data/lib/quicsilver/libmsquic.2.dylib +0 -0
  43. data/lib/quicsilver/protocol/adapter.rb +41 -21
  44. data/lib/quicsilver/protocol/capsule.rb +38 -0
  45. data/lib/quicsilver/protocol/connect_stream_decoder.rb +75 -0
  46. data/lib/quicsilver/protocol/control_stream_parser.rb +12 -1
  47. data/lib/quicsilver/protocol/datagram.rb +40 -0
  48. data/lib/quicsilver/protocol/frame_parser.rb +5 -6
  49. data/lib/quicsilver/protocol/frame_reader.rb +31 -2
  50. data/lib/quicsilver/protocol/frames.rb +128 -12
  51. data/lib/quicsilver/protocol/priority.rb +9 -0
  52. data/lib/quicsilver/protocol/qpack/decoder.rb +4 -42
  53. data/lib/quicsilver/protocol/qpack/encoder.rb +10 -20
  54. data/lib/quicsilver/protocol/qpack/huffman.rb +4 -4
  55. data/lib/quicsilver/protocol/request_encoder.rb +6 -3
  56. data/lib/quicsilver/protocol/request_parser.rb +16 -2
  57. data/lib/quicsilver/protocol/response_encoder.rb +18 -8
  58. data/lib/quicsilver/protocol/response_parser.rb +6 -0
  59. data/lib/quicsilver/protocol/stream_input.rb +3 -3
  60. data/lib/quicsilver/protocol/unidirectional_stream.rb +12 -0
  61. data/lib/quicsilver/protocol/web_transport.rb +214 -0
  62. data/lib/quicsilver/rack/context.rb +33 -0
  63. data/lib/quicsilver/response.rb +40 -0
  64. data/lib/quicsilver/server/rack_adapter.rb +77 -0
  65. data/lib/quicsilver/server/receive_queue.rb +106 -0
  66. data/lib/quicsilver/server/request_handler.rb +24 -13
  67. data/lib/quicsilver/server/request_registry.rb +18 -5
  68. data/lib/quicsilver/server/scheduler.rb +50 -0
  69. data/lib/quicsilver/server/schedulers/fiber_scheduler.rb +79 -0
  70. data/lib/quicsilver/server/schedulers/thread_scheduler.rb +76 -0
  71. data/lib/quicsilver/server/server.rb +655 -185
  72. data/lib/quicsilver/server/web_transport_flow_control.rb +270 -0
  73. data/lib/quicsilver/server/web_transport_manager.rb +281 -0
  74. data/lib/quicsilver/server/web_transport_registry.rb +40 -0
  75. data/lib/quicsilver/server/web_transport_session.rb +812 -0
  76. data/lib/quicsilver/server/web_transport_stream.rb +326 -0
  77. data/lib/quicsilver/transport/configuration.rb +103 -10
  78. data/lib/quicsilver/transport/connection.rb +302 -87
  79. data/lib/quicsilver/transport/connection_stats.rb +53 -0
  80. data/lib/quicsilver/transport/event_loop.rb +36 -0
  81. data/lib/quicsilver/transport/inbound_stream.rb +21 -1
  82. data/lib/quicsilver/transport/stream.rb +18 -3
  83. data/lib/quicsilver/transport/stream_event.rb +25 -2
  84. data/lib/quicsilver/transport/stream_id.rb +26 -0
  85. data/lib/quicsilver/version.rb +1 -1
  86. data/lib/quicsilver.rb +22 -11
  87. data/lib/rackup/handler/quicsilver.rb +57 -38
  88. data/quicsilver.gemspec +10 -3
  89. metadata +54 -8
  90. data/benchmarks/concurrent.rb +0 -110
  91. data/benchmarks/quicsilver_server.rb +0 -46
  92. data/benchmarks/rails.rb +0 -170
  93. data/benchmarks/throughput.rb +0 -113
  94. 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