quicsilver 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +9 -1
  3. data/.github/workflows/cibuildgem.yaml +29 -5
  4. data/.gitignore +5 -0
  5. data/CHANGELOG.md +50 -0
  6. data/Gemfile.lock +8 -1
  7. data/README.md +128 -15
  8. data/Rakefile +10 -3
  9. data/benchmarks/baseline.rb +175 -0
  10. data/benchmarks/compare.rb +398 -0
  11. data/benchmarks/helpers.rb +138 -64
  12. data/benchmarks/latency.rb +128 -0
  13. data/benchmarks/loss.rb +193 -0
  14. data/benchmarks/mobile.rb +125 -0
  15. data/docs/connection_identity.md +141 -0
  16. data/examples/benchmark.rb +80 -0
  17. data/examples/connection_pool_demo.rb +1 -1
  18. data/examples/example_helper.rb +51 -0
  19. data/examples/feature_demo.rb +7 -7
  20. data/examples/grpc_style.rb +2 -2
  21. data/examples/latency_aware_routing.rb +382 -0
  22. data/examples/lifecycle_drain.rb +264 -0
  23. data/examples/priorities.rb +2 -2
  24. data/examples/rails_feature_test.rb +92 -72
  25. data/examples/server_stats.rb +117 -0
  26. data/examples/simple_client_test.rb +3 -3
  27. data/examples/trailers.rb +23 -23
  28. data/examples/webtransport_smoke.rb +546 -0
  29. data/examples/worker_load_balancing.rb +425 -0
  30. data/ext/quicsilver/extconf.rb +100 -15
  31. data/ext/quicsilver/include/msquic.h +2226 -0
  32. data/ext/quicsilver/include/msquic_posix.h +696 -0
  33. data/ext/quicsilver/include/quic_sal_stub.h +314 -0
  34. data/ext/quicsilver/msquic_patch.rb +68 -0
  35. data/ext/quicsilver/patches/reliable-reset.patch +723 -0
  36. data/ext/quicsilver/patches/stream-final-size.patch +47 -0
  37. data/ext/quicsilver/quicsilver.c +1640 -222
  38. data/lib/quicsilver/client/client.rb +419 -60
  39. data/lib/quicsilver/client/connection_pool.rb +122 -26
  40. data/lib/quicsilver/client/request.rb +106 -14
  41. data/lib/quicsilver/errors.rb +39 -0
  42. data/lib/quicsilver/libmsquic.2.dylib +0 -0
  43. data/lib/quicsilver/protocol/adapter.rb +41 -21
  44. data/lib/quicsilver/protocol/capsule.rb +38 -0
  45. data/lib/quicsilver/protocol/connect_stream_decoder.rb +75 -0
  46. data/lib/quicsilver/protocol/control_stream_parser.rb +12 -1
  47. data/lib/quicsilver/protocol/datagram.rb +40 -0
  48. data/lib/quicsilver/protocol/frame_parser.rb +5 -6
  49. data/lib/quicsilver/protocol/frame_reader.rb +31 -2
  50. data/lib/quicsilver/protocol/frames.rb +128 -12
  51. data/lib/quicsilver/protocol/priority.rb +9 -0
  52. data/lib/quicsilver/protocol/qpack/decoder.rb +4 -42
  53. data/lib/quicsilver/protocol/qpack/encoder.rb +10 -20
  54. data/lib/quicsilver/protocol/qpack/huffman.rb +4 -4
  55. data/lib/quicsilver/protocol/request_encoder.rb +6 -3
  56. data/lib/quicsilver/protocol/request_parser.rb +16 -2
  57. data/lib/quicsilver/protocol/response_encoder.rb +18 -8
  58. data/lib/quicsilver/protocol/response_parser.rb +6 -0
  59. data/lib/quicsilver/protocol/stream_input.rb +3 -3
  60. data/lib/quicsilver/protocol/unidirectional_stream.rb +12 -0
  61. data/lib/quicsilver/protocol/web_transport.rb +214 -0
  62. data/lib/quicsilver/rack/context.rb +33 -0
  63. data/lib/quicsilver/response.rb +40 -0
  64. data/lib/quicsilver/server/rack_adapter.rb +77 -0
  65. data/lib/quicsilver/server/receive_queue.rb +106 -0
  66. data/lib/quicsilver/server/request_handler.rb +24 -13
  67. data/lib/quicsilver/server/request_registry.rb +18 -5
  68. data/lib/quicsilver/server/scheduler.rb +50 -0
  69. data/lib/quicsilver/server/schedulers/fiber_scheduler.rb +79 -0
  70. data/lib/quicsilver/server/schedulers/thread_scheduler.rb +76 -0
  71. data/lib/quicsilver/server/server.rb +655 -185
  72. data/lib/quicsilver/server/web_transport_flow_control.rb +270 -0
  73. data/lib/quicsilver/server/web_transport_manager.rb +281 -0
  74. data/lib/quicsilver/server/web_transport_registry.rb +40 -0
  75. data/lib/quicsilver/server/web_transport_session.rb +812 -0
  76. data/lib/quicsilver/server/web_transport_stream.rb +326 -0
  77. data/lib/quicsilver/transport/configuration.rb +103 -10
  78. data/lib/quicsilver/transport/connection.rb +302 -87
  79. data/lib/quicsilver/transport/connection_stats.rb +53 -0
  80. data/lib/quicsilver/transport/event_loop.rb +36 -0
  81. data/lib/quicsilver/transport/inbound_stream.rb +21 -1
  82. data/lib/quicsilver/transport/stream.rb +18 -3
  83. data/lib/quicsilver/transport/stream_event.rb +25 -2
  84. data/lib/quicsilver/transport/stream_id.rb +26 -0
  85. data/lib/quicsilver/version.rb +1 -1
  86. data/lib/quicsilver.rb +22 -11
  87. data/lib/rackup/handler/quicsilver.rb +57 -38
  88. data/quicsilver.gemspec +10 -3
  89. metadata +54 -8
  90. data/benchmarks/concurrent.rb +0 -110
  91. data/benchmarks/quicsilver_server.rb +0 -46
  92. data/benchmarks/rails.rb +0 -170
  93. data/benchmarks/throughput.rb +0 -113
  94. data/lib/quicsilver/quicsilver.bundle +0 -0
@@ -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,12 @@ 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"
14
26
 
15
- ServerStopError = Class.new(StandardError)
16
- DrainTimeoutError = Class.new(StandardError)
27
+ ServerStopError = Class.new(Quicsilver::Error)
28
+ DrainTimeoutError = Class.new(Quicsilver::Error)
17
29
 
18
30
  # Tracks an in-flight streaming request between RECEIVE and RECEIVE_FIN.
19
31
  # The stream handle arrives at RECEIVE_FIN; the worker thread waits for it.
@@ -46,11 +58,23 @@ module Quicsilver
46
58
  end
47
59
  end
48
60
 
49
- DEFAULT_THREAD_POOL_SIZE = 5
50
- DEFAULT_QUEUE_MULTIPLIER = 4
51
- DEFAULT_MAX_CONNECTIONS = 100
52
-
53
- 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)
54
78
  @port = port
55
79
  @address = address
56
80
  @app = app || default_rack_app
@@ -61,16 +85,25 @@ module Quicsilver
61
85
  @config_handle = nil
62
86
  @connections = {}
63
87
  @request_registry = RequestRegistry.new
64
- @handler_threads = []
65
- @handler_mutex = Mutex.new
66
88
  @thread_pool_size = threads
67
89
  @max_queue_size = max_queue_size || threads * DEFAULT_QUEUE_MULTIPLIER
68
- @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)
69
96
  @max_connections = max_connections
70
97
  @cancelled_streams = Set.new
71
98
  @cancelled_mutex = Mutex.new
72
- @pending_streams = {} # stream_id => PendingStream (for streaming dispatch)
99
+ @pending_streams = {}
73
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
74
107
 
75
108
  protocol_app = wrap_app(@app, @server_configuration.mode)
76
109
 
@@ -85,28 +118,85 @@ module Quicsilver
85
118
  self.class.instance = self
86
119
  end
87
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
+
88
184
  def start
89
185
  raise ServerIsRunningError, "Server is already running" if @running
90
186
 
187
+ configure_transport_server_id
91
188
  Quicsilver.open_connection
92
189
  @config_handle = Quicsilver.create_server_configuration(@server_configuration.to_h)
93
190
  raise ServerConfigurationError, "Failed to create server configuration" unless @config_handle
94
191
 
95
- result = Quicsilver.create_listener(@config_handle)
96
- @listener_data = ListenerData.new(result[0], result[1])
97
- raise ServerListenerError, "Failed to create listener #{@address}:#{@port}" unless @listener_data
98
-
99
- unless Quicsilver.start_listener(@listener_data.listener_handle, @address, @port, @server_configuration.alpn)
100
- Quicsilver.close_configuration(@config_handle)
101
- @config_handle = nil
102
- cleanup_failed_server
103
- raise ServerListenerError, "Failed to start listener on #{@address}:#{@port}"
104
- end
192
+ create_listener
193
+ configure_listener
194
+ start_listener
105
195
 
106
196
  @running = true
107
197
 
108
198
  setup_signal_handlers
109
- start_worker_pool
199
+ @scheduler.start
110
200
  Quicsilver.event_loop.start
111
201
  Quicsilver.event_loop.join # Block until shutdown
112
202
  rescue ServerConfigurationError, ServerListenerError => e
@@ -157,23 +247,63 @@ module Quicsilver
157
247
  @running
158
248
  end
159
249
 
160
- def cancelled_stream?(stream_id)
161
- @cancelled_mutex.synchronize { @cancelled_streams.include?(stream_id) }
250
+ def draining?
251
+ @shutting_down
162
252
  end
163
253
 
164
- # Wait for work queue to drain, then shut down the pool
165
- def drain(timeout: 5)
166
- Quicsilver.logger.debug("Draining work queue (#{@work_queue.size} pending)")
254
+ def ready?
255
+ @running && !draining? && !@scheduler.full?
256
+ end
167
257
 
168
- 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
169
293
 
170
- # Wait for work queue to empty
171
- while @work_queue.size > 0 && Time.now < deadline
172
- sleep 0.05
173
- 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
174
301
 
175
- # Signal workers to exit
176
- 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
177
307
  end
178
308
 
179
309
  # Graceful shutdown: send GOAWAY, drain requests, then stop
@@ -202,7 +332,8 @@ module Quicsilver
202
332
 
203
333
  # Log any requests that didn't complete
204
334
  unless @request_registry.empty?
205
- @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
206
337
  elapsed = Time.now - req[:started_at]
207
338
  Quicsilver.logger.warn("Force-closing request: #{req[:method]} #{req[:path]} (stream: #{stream_id}, elapsed: #{elapsed.round(2)}s)")
208
339
  end
@@ -230,17 +361,26 @@ module Quicsilver
230
361
  return
231
362
  end
232
363
 
233
- connection = Transport::Connection.new(connection_handle, connection_data,
234
- max_header_size: @server_configuration.max_header_size)
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!
235
374
  @connections[connection_handle] = connection
236
375
  connection.setup_http3_streams
237
-
376
+ @connection_callback&.call(connection)
238
377
  when STREAM_EVENT_CONNECTION_CLOSED
239
- @connections.delete(connection_handle)&.streams&.clear
240
- Quicsilver.close_server_connection(connection_handle)
241
-
378
+ handle_connection_closed(connection_handle)
242
379
  when STREAM_EVENT_SEND_COMPLETE
243
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)
244
384
  when STREAM_EVENT_RECEIVE
245
385
  return unless (connection = @connections[connection_handle])
246
386
  handle_receive(connection, connection_handle, stream_id, data, early_data: early_data)
@@ -256,48 +396,166 @@ module Quicsilver
256
396
  if connection.critical_stream?(stream_id)
257
397
  Quicsilver.logger.error("Critical stream #{stream_id} reset by peer")
258
398
  Quicsilver.connection_shutdown(connection_handle, Protocol::H3_CLOSED_CRITICAL_STREAM, false) rescue nil
259
- else
260
- @cancelled_mutex.synchronize { @cancelled_streams.add(stream_id) }
261
- pending = @pending_mutex.synchronize { @pending_streams.delete(stream_id) }
262
- pending&.body&.close(RuntimeError.new("Stream #{stream_id} reset by peer"))
263
- @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)
264
406
  end
265
407
  when STREAM_EVENT_STOP_SENDING
266
- return unless @connections[connection_handle]
408
+ return unless (connection = @connections[connection_handle])
267
409
  event = Transport::StreamEvent.new(data, "STOP_SENDING")
268
410
  Quicsilver.logger.debug("Stream #{stream_id} stop sending requested with error code: 0x#{event.error_code.to_s(16)}")
269
- @cancelled_mutex.synchronize { @cancelled_streams.add(stream_id) }
270
- Quicsilver.stream_reset(event.handle, Protocol::H3_REQUEST_CANCELLED)
271
- @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
272
452
  end
273
453
  end
274
454
 
275
455
  private
276
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
+
277
549
  # Wrap the user's app for the configured mode.
278
550
  # Rack mode: inject rack.early_hints support, then wrap with protocol-rack.
279
551
  # Falcon mode: pass through as-is (native protocol-http app).
280
552
  def wrap_app(app, mode)
281
553
  case mode
282
554
  when :falcon then app
283
- else ::Protocol::Rack::Adapter.new(with_early_hints(app))
555
+ else Server::RackAdapter.new(app)
284
556
  end
285
557
  end
286
558
 
287
- # Bridges protocol-http's interim_response to Rack's rack.early_hints.
288
- # In a Rails controller: send_early_hints("link" => '</style.css>; rel=preload')
289
- def with_early_hints(app)
290
- ->(env) {
291
- request = env["protocol.http.request"]
292
- if request&.respond_to?(:interim_response) && request.interim_response
293
- env["rack.early_hints"] = ->(headers) {
294
- request.send_interim_response(103, ::Protocol::HTTP::Headers[headers.map { |k, v| [k, v] }])
295
- }
296
- end
297
- app.call(env)
298
- }
299
- end
300
-
301
559
  def setup_signal_handlers
302
560
  %w[INT TERM].each do |signal|
303
561
  trap(signal) { Thread.new { shutdown } }
@@ -327,41 +585,76 @@ module Quicsilver
327
585
 
328
586
  attr_reader :work_queue
329
587
 
588
+ # RECEIVE data is always [stream_handle(8)][payload...] from C.
589
+ HANDLE_SIZE = 8
590
+
330
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
+
331
596
  # Unidirectional streams (control, QPACK) must be processed incrementally —
332
597
  # they never send FIN, so waiting for RECEIVE_FIN would mean never parsing.
333
- if (stream_id & 0x02) != 0 # unidirectional
598
+ if Transport::StreamId.unidirectional?(stream_id)
334
599
  begin
335
- connection.receive_unidirectional_data(stream_id, data)
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
336
607
  rescue Protocol::FrameError => e
337
608
  Quicsilver.logger.error("Control stream error: #{e.message} (0x#{e.error_code.to_s(16)})")
338
609
  Quicsilver.connection_shutdown(connection_handle, e.error_code, false) rescue nil
339
610
  end
340
611
  else
341
- handle_bidi_receive(connection, connection_handle, stream_id, data, early_data: early_data)
612
+ handle_bidi_receive(connection, connection_handle, stream_id, stream_handle, payload, early_data: early_data)
342
613
  end
343
614
  end
344
615
 
345
- def handle_bidi_receive(connection, connection_handle, stream_id, data, early_data: false)
346
- pending = @pending_mutex.synchronize { @pending_streams[stream_id] }
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
347
623
  if pending
348
- # Subsequent RECEIVE — append to frame buffer and extract complete DATA payloads.
349
- # MsQuic splits data at arbitrary boundaries, so frames may span callbacks.
350
- pending.frame_buffer << data
351
- drain_data_frames(pending)
352
- elsif contains_headers_frame?(data)
353
- dispatch_streaming(connection, connection_handle, stream_id, data, early_data: early_data)
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)
354
631
  else
355
- connection.buffer_data(stream_id, data)
632
+ connection.buffer_data(stream_id, payload)
356
633
  end
357
634
  end
358
635
 
359
636
  def handle_receive_fin(connection, connection_handle, stream_id, data, early_data: false)
637
+ return if cancelled_stream?(stream_id, connection_handle)
638
+
360
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
361
649
 
362
- pending = @pending_mutex.synchronize { @pending_streams[stream_id] }
650
+ pending = @pending_mutex.synchronize { @pending_streams[[connection_handle, stream_id]] }
363
651
  if pending
364
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)
365
658
  else
366
659
  complete_buffered_request(connection, connection_handle, stream_id, event, early_data: early_data)
367
660
  end
@@ -370,7 +663,7 @@ module Quicsilver
370
663
  def complete_streaming_request(pending, event)
371
664
  if event.data && !event.data.empty?
372
665
  pending.frame_buffer << event.data
373
- drain_data_frames(pending)
666
+ drain_pending_frames(pending)
374
667
  end
375
668
  pending.body.close_write
376
669
  pending.complete(event.handle)
@@ -384,10 +677,14 @@ module Quicsilver
384
677
 
385
678
  if stream.bidirectional?
386
679
  connection.track_client_stream(stream_id)
387
- dispatch_request(connection, stream, early_data: early_data)
680
+ dispatch_streaming(connection, connection_handle, stream_id, full_data,
681
+ stream_handle: event.handle, early_data: early_data, completed_stream: stream)
388
682
  else
389
683
  begin
390
- connection.handle_unidirectional_stream(stream)
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
391
688
  rescue Protocol::FrameError => e
392
689
  Quicsilver.logger.error("Control stream error: #{e.message} (0x#{e.error_code.to_s(16)})")
393
690
  Quicsilver.connection_shutdown(connection_handle, e.error_code, false) rescue nil
@@ -395,36 +692,69 @@ module Quicsilver
395
692
  end
396
693
  end
397
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
+
398
707
  def dispatch_request(connection, stream, early_data: false)
399
- if @work_queue.size >= @max_queue_size
708
+ if @scheduler.full?
400
709
  Quicsilver.logger.warn("Work queue full (#{@max_queue_size}), rejecting request")
401
710
  connection.send_error(stream, 503, "Service Unavailable") if stream.writable?
402
711
  else
403
- @work_queue.push([connection, stream, early_data])
712
+ enqueue_work([connection, stream, early_data])
404
713
  end
405
714
  end
406
715
 
407
- def start_worker_pool
408
- @thread_pool_size.times do
409
- thread = Thread.new do
410
- while (work = @work_queue.pop)
411
- break if work == :shutdown
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
412
727
 
413
- if work.is_a?(Array) && work[0] == :streaming
414
- handle_streaming_request(work[1])
415
- else
416
- connection, stream, early_data = work
417
- @request_handler.call(connection, stream, early_data: early_data)
418
- end
419
- end
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)
420
751
  end
421
- @handler_mutex.synchronize { @handler_threads << thread }
422
752
  end
423
753
  end
424
754
 
425
- # Streaming dispatch: parse headers from first RECEIVE, dispatch immediately.
426
- # Body data arrives via subsequent RECEIVE events into StreamInput.
427
- def dispatch_streaming(connection, connection_handle, stream_id, data, early_data: false)
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)
428
758
  parser = Protocol::RequestParser.new(
429
759
  data,
430
760
  max_header_size: @server_configuration.max_header_size,
@@ -435,28 +765,119 @@ module Quicsilver
435
765
  parser.validate_headers!
436
766
 
437
767
  headers = parser.headers
438
- return if headers.empty?
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
439
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
440
853
  method = headers[":method"]
441
854
 
442
- if @server_configuration.early_data_policy == :reject &&
443
- early_data && !RequestHandler::SAFE_METHODS.include?(method)
444
- Quicsilver.logger.debug("Rejected 0-RTT #{method} on stream #{stream_id} (no stream handle to send 425)")
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")
445
867
  return
446
868
  end
447
869
 
448
- request, body = @request_handler.adapter.build_request(headers)
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
+ )
449
876
  request.headers.add("quicsilver-early-data", early_data.to_s)
450
877
 
451
- # Feed body data from the first RECEIVE.
452
- # The parser consumed complete frames (HEADERS + any complete DATA frames).
453
- if body
454
- # Complete DATA frames the parser extracted
455
- if parser.body && parser.body.size > 0
456
- parser.body.rewind
457
- body_data = parser.body.read
458
- body.write(body_data) unless body_data.empty?
459
- end
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)
460
881
  end
461
882
 
462
883
  pending = PendingStream.new(
@@ -471,28 +892,15 @@ module Quicsilver
471
892
  remainder = data.byteslice(parser.bytes_consumed..-1)
472
893
  if remainder && remainder.bytesize > 0
473
894
  pending.frame_buffer << remainder
474
- drain_data_frames(pending)
895
+ drain_pending_frames(pending)
475
896
  end
476
- @pending_mutex.synchronize { @pending_streams[stream_id] = pending }
897
+ @pending_mutex.synchronize { @pending_streams[[connection_handle, stream_id]] = pending }
477
898
 
478
899
  connection.track_client_stream(stream_id)
479
900
  @request_registry.track(stream_id, connection_handle,
480
901
  path: headers[":path"] || "/", method: method || "GET")
481
902
 
482
- if @work_queue.size >= @max_queue_size
483
- Quicsilver.logger.warn("Work queue full (#{@max_queue_size}), rejecting request")
484
- body&.close
485
- @pending_mutex.synchronize { @pending_streams.delete(stream_id) }
486
- else
487
- @work_queue.push([:streaming, pending])
488
- end
489
- rescue Protocol::FrameError => e
490
- Quicsilver.logger.error("Frame error: #{e.message}")
491
- Quicsilver.connection_shutdown(connection_handle, e.error_code, false) rescue nil
492
- rescue Protocol::MessageError => e
493
- Quicsilver.logger.error("Message error on stream #{stream_id}: #{e.message}")
494
- rescue => e
495
- Quicsilver.logger.error("Error in streaming dispatch: #{e.class} - #{e.message}")
903
+ @scheduler.enqueue([:streaming, pending])
496
904
  end
497
905
 
498
906
  def handle_streaming_request(pending)
@@ -505,7 +913,7 @@ module Quicsilver
505
913
  return
506
914
  end
507
915
 
508
- return if cancelled_stream?(pending.stream_id)
916
+ return if cancelled_stream?(pending.stream_id, pending.connection.handle)
509
917
 
510
918
  headers = response.headers
511
919
 
@@ -515,15 +923,16 @@ module Quicsilver
515
923
  trailer_hash
516
924
  end
517
925
 
518
- response_headers = {}
926
+ # Pairs rather than a Hash, for the set-cookie reason in RequestHandler#send_response.
927
+ response_headers = []
519
928
  if headers.respond_to?(:header)
520
- headers.header.each { |name, value| response_headers[name] = value }
929
+ headers.header.each { |name, value| response_headers << [ name, value ] }
521
930
  else
522
- headers&.each { |name, value| response_headers[name] = value }
931
+ headers&.each { |name, value| response_headers << [ name, value ] }
523
932
  end
524
933
 
525
- if !response_headers.key?("content-length") && response.body&.length
526
- response_headers["content-length"] = response.body.length.to_s
934
+ unless response_headers.any? { |name, _| name == "content-length" }
935
+ response_headers << [ "content-length", response.body.length.to_s ] if response.body&.length
527
936
  end
528
937
 
529
938
  stream = Transport::InboundStream.new(pending.stream_id)
@@ -532,7 +941,7 @@ module Quicsilver
532
941
  pending.connection.apply_stream_priority(stream, pending.priority)
533
942
  pending.connection.send_response(stream, response.status, response_headers, response.body,
534
943
  head_request: pending.request.method == "HEAD", trailers: trailers)
535
- @request_registry.complete(pending.stream_id)
944
+ @request_registry.complete(pending.stream_id, pending.connection.handle)
536
945
  rescue => e
537
946
  Quicsilver.logger.error("Streaming request error: #{e.class} - #{e.message}")
538
947
  if pending.stream_handle
@@ -541,70 +950,131 @@ module Quicsilver
541
950
  pending.connection.send_error(stream, 500, "Internal Server Error") if stream.writable?
542
951
  end
543
952
  ensure
544
- @pending_mutex.synchronize { @pending_streams.delete(pending.stream_id) }
545
- @request_registry.complete(pending.stream_id) if @request_registry.include?(pending.stream_id)
546
- @cancelled_mutex.synchronize { @cancelled_streams.delete(pending.stream_id) }
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)
547
957
  end
548
958
 
549
- # Incrementally extract complete DATA frame payloads from the frame buffer.
550
- # Handles MsQuic splitting frames across RECEIVE callbacks — partial frames
551
- # remain in the buffer until the next callback completes them.
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.
552
972
  def drain_data_frames(pending)
553
- buf = pending.frame_buffer
973
+ buffer = pending.frame_buffer
554
974
 
555
- while buf.bytesize >= 2
556
- type_byte = buf.getbyte(0)
557
- if type_byte < 0x40
558
- type = type_byte
559
- type_len = 1
560
- else
561
- type, type_len = Protocol.decode_varint_str(buf, 0)
562
- break if type_len == 0
563
- end
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
564
979
 
565
- len_byte = buf.getbyte(type_len)
566
- break unless len_byte
567
- if len_byte < 0x40
568
- length = len_byte
569
- length_len = 1
570
- else
571
- length, length_len = Protocol.decode_varint_str(buf, type_len)
572
- break if length_len == 0
573
- end
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
574
990
 
575
- header_len = type_len + length_len
576
- total = header_len + length
991
+ Quicsilver.logger.debug(
992
+ "WebTransport CONNECT stream=#{stream_id} path=#{headers[":path"].inspect} " \
993
+ "authority=#{headers[":authority"].inspect} headers=#{headers.inspect}"
994
+ )
577
995
 
578
- # Incomplete frame — wait for more data
579
- break if buf.bytesize < total
996
+ stream = Transport::InboundStream.new(stream_id)
997
+ stream.stream_handle = stream_handle
580
998
 
581
- if type == Protocol::FRAME_DATA
582
- pending.body.write(buf.byteslice(header_len, length))
583
- end
584
- # Skip non-DATA frames (e.g. unknown extension frames)
999
+ session = WebTransportSession.new(
1000
+ connection: connection,
1001
+ stream: stream,
1002
+ headers: headers
1003
+ )
585
1004
 
586
- buf = buf.byteslice(total..-1) || "".b
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)
587
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
588
1062
 
589
- pending.frame_buffer = buf
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
590
1073
  end
591
1074
 
592
- # Heuristic: check if raw data starts with an HTTP/3 HEADERS frame (type 0x01).
593
- # QUIC typically delivers complete frames, but if this misidentifies data,
594
- # the parser will fail safely in dispatch_streaming's rescue handlers.
595
1075
  def contains_headers_frame?(data)
596
1076
  return false if data.nil? || data.bytesize < 2
597
1077
  data.getbyte(0) == Protocol::FRAME_HEADERS
598
1078
  end
599
-
600
- def stop_worker_pool
601
- @thread_pool_size.times { @work_queue.push(:shutdown) }
602
- @handler_mutex.synchronize do
603
- @handler_threads.each { |t| t.join(2) }
604
- # Raise into any stuck workers
605
- @handler_threads.each { |t| t.raise(DrainTimeoutError, "drain timeout") if t.alive? }
606
- @handler_threads.clear
607
- end
608
- end
609
1079
  end
610
1080
  end