quicsilver 0.5.0-x86_64-linux

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