quicsilver 0.3.0 → 0.5.0

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