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
@@ -8,43 +8,80 @@ module Quicsilver
8
8
  # Quicsilver::Client.get("example.com", 4433, "/users")
9
9
  #
10
10
  class ConnectionPool
11
- attr_reader :max_size, :idle_timeout
11
+ attr_reader :max_size, :idle_timeout, :mode
12
12
 
13
13
  DEFAULT_MAX_SIZE = 4
14
14
  DEFAULT_IDLE_TIMEOUT = 60 # seconds
15
-
16
- def initialize(max_size: DEFAULT_MAX_SIZE, idle_timeout: DEFAULT_IDLE_TIMEOUT)
15
+ DEFAULT_CHECKOUT_TIMEOUT = 5 # seconds
16
+
17
+ # @param mode [:exclusive, :shared] Pool strategy.
18
+ # :shared (default) — one connection per host, all threads share it via
19
+ # QUIC stream multiplexing. 5x faster, one TLS handshake per host.
20
+ # :exclusive — one connection per checkout, like ActiveRecord. Use for
21
+ # maintenance tasks that need isolation or servers with low stream limits.
22
+ def initialize(max_size: DEFAULT_MAX_SIZE, idle_timeout: DEFAULT_IDLE_TIMEOUT, checkout_timeout: DEFAULT_CHECKOUT_TIMEOUT, mode: :shared)
17
23
  @max_size = max_size
18
24
  @idle_timeout = idle_timeout
19
- @pools = {} # "host:port" => [{ client:, checked_out: }]
25
+ @checkout_timeout = checkout_timeout
26
+ @mode = mode
27
+ @pools = {} # connection key => [{ client:, checked_out: }]
28
+ @keys = {}.compare_by_identity # client => the key it was pooled under
20
29
  @mutex = Mutex.new
30
+ @condition = ConditionVariable.new
21
31
  end
22
32
 
33
+ # Options that change the connection itself, so a pooled connection built
34
+ # with one set must never be handed to a caller asking for another.
35
+ # unsecure is the important one: it disables certificate validation.
36
+ CONNECTION_OPTIONS = %i[
37
+ unsecure
38
+ datagram_receive_enabled
39
+ reliable_reset_enabled
40
+ transport_cibir_id
41
+ idle_timeout_ms
42
+ ].freeze
43
+
23
44
  # Check out a connected Client. Reuses an idle one or creates a new one.
45
+ # In :exclusive mode, blocks with timeout if pool is full.
46
+ # In :shared mode, returns the shared connection (all threads use one connection).
24
47
  def checkout(hostname, port, **options)
25
- key = "#{hostname}:#{port}"
48
+ @mode == :shared ? checkout_shared(hostname, port, **options) : checkout_exclusive(hostname, port, **options)
49
+ end
50
+
51
+ private def checkout_exclusive(hostname, port, **options)
52
+ key = connection_key(hostname, port, options)
53
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + @checkout_timeout
26
54
 
27
55
  @mutex.synchronize do
28
- entries = @pools[key] ||= []
56
+ loop do
57
+ entries = @pools[key] ||= []
58
+
59
+ # Evict dead/stale/draining
60
+ entries.reject! do |e|
61
+ if !e[:checked_out] && (!e[:client].connected? || e[:client].draining? || e[:last_used] < Time.now - @idle_timeout)
62
+ e[:client].close_connection
63
+ true
64
+ end
65
+ end
29
66
 
30
- # Evict dead/stale/draining
31
- entries.reject! do |e|
32
- if !e[:checked_out] && (!e[:client].connected? || e[:client].draining? || e[:last_used] < Time.now - @idle_timeout)
33
- e[:client].close_connection
34
- true
67
+ # Reuse an idle client (skip draining ones)
68
+ idle = entries.find { |e| !e[:checked_out] && e[:client].connected? && !e[:client].draining? }
69
+ if idle
70
+ idle[:checked_out] = true
71
+ idle[:last_used] = Time.now
72
+ return idle[:client]
35
73
  end
36
- end
37
74
 
38
- # Reuse an idle client (skip draining ones)
39
- idle = entries.find { |e| !e[:checked_out] && e[:client].connected? && !e[:client].draining? }
40
- if idle
41
- idle[:checked_out] = true
42
- idle[:last_used] = Time.now
43
- return idle[:client]
44
- end
75
+ # Room to create a new connection
76
+ break if entries.size < @max_size
45
77
 
46
- if entries.size >= @max_size
47
- raise ConnectionError, "Connection pool full for #{key} (max: #{@max_size})"
78
+ # Pool full — wait for a checkin
79
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
80
+ if remaining <= 0
81
+ raise ConnectionError,
82
+ "Connection pool full for #{hostname}:#{port} (waited #{@checkout_timeout}s, max: #{@max_size})"
83
+ end
84
+ @condition.wait(@mutex, remaining)
48
85
  end
49
86
  end
50
87
 
@@ -54,17 +91,64 @@ module Quicsilver
54
91
 
55
92
  @mutex.synchronize do
56
93
  (@pools[key] ||= []) << { client: client, checked_out: true, last_used: Time.now }
94
+ @keys[client] = key
57
95
  end
58
96
 
59
97
  client
60
98
  end
61
99
 
62
- # Return a Client to the pool.
100
+ # Shared mode: one connection per host, all threads share it.
101
+ # Each thread opens its own QUIC stream on the shared connection.
102
+ # No checkout/checkin semantics — the connection is never "owned".
103
+ private def checkout_shared(hostname, port, **options)
104
+ key = connection_key(hostname, port, options)
105
+
106
+ @mutex.synchronize do
107
+ entry = @pools[key]&.first
108
+ if entry && entry[:client].connected? && !entry[:client].draining?
109
+ entry[:last_used] = Time.now
110
+ return entry[:client]
111
+ end
112
+ # Remove stale entry
113
+ @pools.delete(key) if entry
114
+ end
115
+
116
+ # Create outside the lock (blocking I/O)
117
+ client = Client.new(hostname, port, **options)
118
+ client.open_connection
119
+
120
+ @mutex.synchronize do
121
+ # Double-check — another thread may have created one while we were connecting
122
+ existing = @pools[key]&.first
123
+ if existing && existing[:client].connected? && !existing[:client].draining?
124
+ client.close_connection
125
+ existing[:last_used] = Time.now
126
+ return existing[:client]
127
+ end
128
+ @pools[key] = [{ client: client, checked_out: false, last_used: Time.now }]
129
+ end
130
+
131
+ client
132
+ end
133
+
134
+ # Block-based checkout — auto-checkin on completion or error.
135
+ #
136
+ # pool.with("example.com", 443) { |client| client.get("/") }
137
+ #
138
+ def with(hostname, port, **options)
139
+ client = checkout(hostname, port, **options)
140
+ yield client
141
+ ensure
142
+ checkin(client) if client
143
+ end
144
+
145
+ # Return a Client to the pool. No-op in shared mode.
63
146
  def checkin(client)
64
- key = "#{client.hostname}:#{client.port}"
147
+ return if @mode == :shared
65
148
 
66
149
  @mutex.synchronize do
67
- entries = @pools[key]
150
+ key = @keys[client]
151
+ entries = key && @pools[key]
68
152
  return unless entries
69
153
 
70
154
  entry = entries.find { |e| e[:client].equal?(client) }
@@ -75,9 +159,12 @@ module Quicsilver
75
159
  entry[:last_used] = Time.now
76
160
  else
77
161
  entries.delete(entry)
162
+ @keys.delete(client)
78
163
  client.close_connection
79
164
  @pools.delete(key) if entries.empty?
80
165
  end
166
+
167
+ @condition.broadcast # wake threads waiting for a connection
81
168
  end
82
169
  end
83
170
 
@@ -88,19 +175,28 @@ module Quicsilver
88
175
  entries.each { |e| e[:client].close_connection }
89
176
  end
90
177
  @pools.clear
178
+ @keys.clear
91
179
  end
92
180
  end
93
181
 
94
- # Total clients in the pool, optionally filtered by host:port.
182
+ # Total clients in the pool, optionally filtered by host and port across
183
+ # every option set pooled for them.
95
184
  def size(host = nil, port = nil)
96
185
  @mutex.synchronize do
97
186
  if host && port
98
- (@pools["#{host}:#{port}"] || []).size
187
+ prefix = "#{host}:#{port}\0"
188
+ @pools.sum { |key, entries| key.start_with?(prefix) ? entries.size : 0 }
99
189
  else
100
190
  @pools.values.sum(&:size)
101
191
  end
102
192
  end
103
193
  end
194
+
195
+ private def connection_key(hostname, port, options)
196
+ defaults = Client::DEFAULT_CONNECTION_OPTIONS
197
+ identity = CONNECTION_OPTIONS.map { |name| options.fetch(name, defaults[name]).inspect }
198
+ "#{hostname}:#{port}\0#{identity.join("\0")}"
199
+ end
104
200
  end
105
201
  end
106
202
  end
@@ -5,9 +5,7 @@ module Quicsilver
5
5
  class Request
6
6
  attr_reader :stream, :status
7
7
 
8
- CancelledError = Class.new(StandardError)
9
-
10
- class ResetError < StandardError
8
+ class ResetError < Quicsilver::Error
11
9
  attr_reader :error_code
12
10
 
13
11
  def initialize(message, error_code = nil)
@@ -21,8 +19,42 @@ module Quicsilver
21
19
  @stream = stream
22
20
  @status = :pending
23
21
  @queue = Queue.new
22
+ @streaming_queue = Queue.new
24
23
  @mutex = Mutex.new
25
24
  @response = nil
25
+ @streaming_response = nil
26
+ @streaming_requested = false
27
+ end
28
+
29
+ # Whether the caller has opted into streaming via streaming_response.
30
+ def streaming_requested?
31
+ @streaming_requested
32
+ end
33
+
34
+ # Block until streaming response headers arrive.
35
+ # Returns a StreamingResponse with status, headers, and a readable body.
36
+ # Body data arrives incrementally — call body.read in a loop.
37
+ #
38
+ # streaming = request.streaming_response(timeout: 10)
39
+ # streaming.status # => 200
40
+ # streaming.headers # => {"content-type" => "video/mp4"}
41
+ # while (chunk = streaming.body.read)
42
+ # file.write(chunk)
43
+ # end
44
+ #
45
+ def streaming_response(timeout: nil)
46
+ timeout ||= @client.request_timeout
47
+ @streaming_requested = true
48
+ return @streaming_response if @streaming_response
49
+
50
+ result = @streaming_queue.pop(timeout: timeout)
51
+ raise Quicsilver::TimeoutError, "Streaming response timeout after #{timeout}s" if result.nil?
52
+
53
+ if result.is_a?(Hash) && result[:error]
54
+ raise ResetError.new(result[:message] || "Stream reset", result[:error_code])
55
+ end
56
+
57
+ @streaming_response = result
26
58
  end
27
59
 
28
60
  # Block until response arrives or timeout
@@ -38,28 +70,55 @@ module Quicsilver
38
70
  case result
39
71
  when nil
40
72
  raise Quicsilver::TimeoutError, "Request timeout after #{timeout}s"
73
+ when Response
74
+ @status = :completed
75
+ @response = result
41
76
  when Hash
42
- if result[:error]
43
- @status = :error
44
- raise ResetError.new(result[:message] || "Stream reset by peer", result[:error_code])
45
- else
46
- @status = :completed
47
- @response = result
48
- end
77
+ # Error hash from #fail
78
+ @status = :error
79
+ raise ResetError.new(result[:message] || "Stream reset by peer", result[:error_code])
49
80
  end
50
81
  end
51
82
 
52
83
  @response
53
84
  end
54
85
 
86
+ # Stream request body in chunks. Only valid when build_request was
87
+ # called with body: :stream.
88
+ #
89
+ # req = client.build_request('POST', '/upload', body: :stream)
90
+ # req.stream_body do |writer|
91
+ # File.open('big.mp4') do |f|
92
+ # while (chunk = f.read(16_384))
93
+ # writer.write(chunk)
94
+ # end
95
+ # end
96
+ # end
97
+ # req.response # wait for server response
98
+ #
99
+ def stream_body
100
+ writer = BodyWriter.new(@stream)
101
+ yield writer
102
+ writer.finish
103
+ end
104
+
105
+ # Reprioritise this stream (RFC 9218). Sends PRIORITY_UPDATE on the
106
+ # control stream. Can be called while the response is in flight.
107
+ #
108
+ # req = client.build_request("GET", "/video", priority: Priority.new(urgency: 2))
109
+ # req.update_priority(Priority.new(urgency: 6)) # deprioritise
110
+ #
111
+ def update_priority(priority)
112
+ @client.send_priority_update(@stream.stream_id, priority)
113
+ end
114
+
55
115
  # Cancel the request (sends RESET_STREAM + STOP_SENDING to server)
56
116
  # error_code defaults to H3_REQUEST_CANCELLED (0x10c)
57
117
  def cancel(error_code: Protocol::H3_REQUEST_CANCELLED)
58
118
  @mutex.synchronize do
59
119
  return false unless @status == :pending
60
120
 
61
- @stream.reset(error_code)
62
- @stream.stop_sending(error_code)
121
+ @stream.abort(error_code)
63
122
  @status = :cancelled
64
123
  end
65
124
  true
@@ -80,18 +139,51 @@ module Quicsilver
80
139
  @status == :cancelled
81
140
  end
82
141
 
83
- # Called by Client when response arrives
142
+ # Writes request body DATA frames to a QUIC stream.
143
+ class BodyWriter
144
+ def initialize(stream)
145
+ @stream = stream
146
+ @finished = false
147
+ end
148
+
149
+ # Write a chunk as an HTTP/3 DATA frame.
150
+ def write(chunk)
151
+ raise "Body already finished" if @finished
152
+ payload = chunk.b
153
+ data = Protocol.encode_varint(Protocol::FRAME_DATA) +
154
+ Protocol.encode_varint(payload.bytesize) +
155
+ payload
156
+ @stream.send(data, fin: false)
157
+ end
158
+
159
+ # Send FIN to close the request body. Called automatically
160
+ # at the end of stream_body.
161
+ def finish
162
+ return if @finished
163
+ @finished = true
164
+ @stream.send("".b, fin: true)
165
+ end
166
+ end
167
+
168
+ # Called by Client when buffered response arrives
84
169
  def complete(response) # :nodoc:
85
170
  @queue.push(response)
86
171
  end
87
172
 
173
+ # Called by Client when streaming headers are parsed
174
+ def deliver_streaming(streaming_response) # :nodoc:
175
+ @streaming_queue.push(streaming_response)
176
+ end
177
+
88
178
  # Called by Client on stream reset from peer or connection close
89
179
  def fail(error_code, message = nil) # :nodoc:
90
180
  @mutex.synchronize do
91
181
  return if @status == :cancelled
92
182
  @status = :error
93
183
  end
94
- @queue.push({ error: true, error_code: error_code, message: message })
184
+ error = { error: true, error_code: error_code, message: message }
185
+ @queue.push(error)
186
+ @streaming_queue.push(error)
95
187
  end
96
188
  end
97
189
  end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Quicsilver
4
+ class Error < StandardError; end
5
+
6
+ # Transport errors from MsQuic. The C extension raises RuntimeError
7
+ # which Ruby call sites wrap into typed TransportError subclasses.
8
+ # Carries the MsQuic QUIC_STATUS code parsed from the error message.
9
+ #
10
+ # rescue Quicsilver::TransportError => e
11
+ # e.message # => "StreamOpen failed, 0x1!"
12
+ # e.status # => 1 (QUIC_STATUS_INVALID_STATE / EPERM)
13
+ #
14
+ class TransportError < Error
15
+ attr_reader :status
16
+
17
+ def initialize(message = nil, status: 0)
18
+ @status = status
19
+ super(message)
20
+ end
21
+
22
+ # Parse MsQuic hex status from C extension error messages.
23
+ # "StreamOpen failed, 0x1!" => 0x1 (EPERM / INVALID_STATE)
24
+ def self.parse_status(message)
25
+ message&.match(/0x([0-9a-fA-F]+)/) { |m| m[1].to_i(16) } || 0
26
+ end
27
+ end
28
+
29
+ class ServerIsRunningError < Error; end
30
+ class ServerConfigurationError < Error; end
31
+ class ServerListenerError < Error; end
32
+ class ServerError < Error; end
33
+ class ConnectionError < Error; end
34
+ class TimeoutError < Error; end
35
+ class GoAwayError < Error; end
36
+ class StreamFailedToOpenError < TransportError; end
37
+ class CancelledError < Error; end
38
+
39
+ end
Binary file
@@ -3,10 +3,24 @@
3
3
  require "protocol/http/request"
4
4
  require "protocol/http/response"
5
5
  require "protocol/http/headers"
6
+ require "protocol/http/peer"
6
7
  require_relative "stream_input"
7
8
  require_relative "stream_output"
8
9
 
9
10
  module Quicsilver
11
+ module Protocol
12
+ # HTTP/3 request with peer address from the QUIC connection.
13
+ class Request < ::Protocol::HTTP::Request
14
+ attr_reader :peer, :transport_context, :rack_context
15
+
16
+ def initialize(*args, peer: nil, transport_context: nil, rack_context: nil, **kwargs)
17
+ super(*args, **kwargs)
18
+ @peer = peer
19
+ @transport_context = transport_context
20
+ @rack_context = rack_context
21
+ end
22
+ end
23
+ end
10
24
  module Protocol
11
25
  # Converts between QUIC/HTTP/3 frames and protocol-http Request/Response
12
26
  # objects. This enables integration with Falcon and any other server
@@ -37,7 +51,11 @@ module Quicsilver
37
51
  # @return [Array(Protocol::HTTP::Request, Protocol::StreamInput)] request and body.
38
52
  # Body is nil for bodyless methods (GET, HEAD, TRACE).
39
53
  # Caller feeds RECEIVE data into body via write(), then close_write on FIN.
40
- def build_request(headers)
54
+ # @param headers [Hash] Parsed headers from RequestParser (includes pseudo-headers).
55
+ # @param remote_address [String, nil] Peer IP from the QUIC connection (e.g. "127.0.0.1").
56
+ # @param remote_port [Integer] Peer port from the QUIC connection.
57
+ # @param transport_context [Hash, nil] Quicsilver transport metadata for request debugging/log tags.
58
+ def build_request(headers, remote_address: nil, remote_port: 0, transport_context: nil, rack_context: nil)
41
59
  method = headers[":method"]
42
60
  scheme = headers[":scheme"] || "https"
43
61
  authority = headers[":authority"]
@@ -55,9 +73,16 @@ module Quicsilver
55
73
  Protocol::StreamInput.new(content_length)
56
74
  end
57
75
 
58
- request = ::Protocol::HTTP::Request.new(
76
+ peer = if remote_address
77
+ ::Protocol::HTTP::Peer.new(Addrinfo.tcp(remote_address, remote_port))
78
+ end
79
+
80
+ request = Request.new(
59
81
  scheme, authority, method, path, VERSION,
60
- protocol_headers, body, protocol
82
+ protocol_headers, body, protocol,
83
+ peer: peer,
84
+ transport_context: transport_context,
85
+ rack_context: rack_context
61
86
  )
62
87
 
63
88
  [request, body]
@@ -76,7 +101,7 @@ module Quicsilver
76
101
  status = response.status
77
102
  headers = response.headers
78
103
  trailers = extract_trailers(headers)
79
- headers_hash = response_headers_hash(headers)
104
+ headers_hash = response_header_fields(headers)
80
105
  body = response.body
81
106
 
82
107
  if body.nil? || head_request
@@ -143,33 +168,28 @@ module Quicsilver
143
168
  result
144
169
  end
145
170
 
146
- # Convert Protocol::HTTP::Headers to a plain Hash for ResponseEncoder.
147
- # Only includes headers, not trailers.
148
- def response_headers_hash(headers)
149
- return {} unless headers
150
-
151
- headers.header.to_h
171
+ # Response header name/value pairs for ResponseEncoder. Only headers, not trailers.
172
+ #
173
+ # Pairs rather than a Hash because a field can legitimately repeat, and set-cookie MUST NOT be
174
+ # combined (RFC 9110 §5.3). Hashing drops every repeat but the last, which silently discarded
175
+ # the session cookie of any Rails response that set more than one — the app still worked, it
176
+ # just never stayed signed in.
177
+ def response_header_fields(headers)
178
+ return [] unless headers
179
+
180
+ headers.header.each.to_a
152
181
  end
153
182
 
154
183
  # Build an HTTP/3 HEADERS frame from key-value pairs
155
- def build_qpack_frame(pairs)
156
- encoded = @qpack_encoder.encode(pairs)
157
- Quicsilver::Protocol.encode_varint(Quicsilver::Protocol::FRAME_HEADERS) +
158
- Quicsilver::Protocol.encode_varint(encoded.bytesize) +
159
- encoded
160
- end
161
-
162
- # Build a response HEADERS frame (with :status pseudo-header)
163
184
  def build_headers_frame(status, headers)
164
185
  pairs = [[":status", status.to_s]]
165
186
  headers.each { |name, value| pairs << [name.to_s.downcase, value.to_s] }
166
- build_qpack_frame(pairs)
187
+ Protocol.build_headers_frame(pairs, encoder: @qpack_encoder)
167
188
  end
168
189
 
169
- # Build a trailer HEADERS frame
170
190
  def build_trailer_frame(trailers)
171
191
  pairs = trailers.map { |name, value| [name.to_s.downcase, value.to_s] }
172
- build_qpack_frame(pairs)
192
+ Protocol.build_headers_frame(pairs, encoder: @qpack_encoder)
173
193
  end
174
194
  end
175
195
  end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Quicsilver
4
+ module Protocol
5
+ module Capsule
6
+ DATAGRAM = 0x00
7
+ MAX_PAYLOAD_SIZE = 1_048_576
8
+
9
+ ParseError = Class.new(StandardError)
10
+ PayloadTooLarge = Class.new(ParseError)
11
+
12
+ class << self
13
+ def encode(type, payload)
14
+ payload = payload.to_s.b
15
+ Protocol.encode_varint(type) + Protocol.encode_varint(payload.bytesize) + payload
16
+ end
17
+
18
+ def parse(buffer, max_payload_size: MAX_PAYLOAD_SIZE)
19
+ type, type_length = Protocol.decode_varint_str(buffer, 0)
20
+ return unless type_length > 0
21
+
22
+ length, length_length = Protocol.decode_varint_str(buffer, type_length)
23
+ return unless length_length > 0
24
+
25
+ raise PayloadTooLarge, "Capsule payload too large" if length > max_payload_size
26
+
27
+ header_length = type_length + length_length
28
+ return if buffer.bytesize < header_length + length
29
+
30
+ payload = buffer.byteslice(header_length, length) || "".b
31
+ remainder = buffer.byteslice(header_length + length..-1) || "".b
32
+
33
+ [type, payload, remainder]
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "frame_parser"
4
+
5
+ module Quicsilver
6
+ module Protocol
7
+ # Incrementally yields CONNECT DATA payloads without waiting for whole
8
+ # frames. Unknown frame payloads are skipped as bytes arrive.
9
+ class ConnectStreamDecoder
10
+ def initialize
11
+ @buffer = "".b
12
+ @frame_type = nil
13
+ @frame_remaining = nil
14
+ end
15
+
16
+ # A caller may break after a terminal capsule. Unprocessed bytes remain
17
+ # available to buffered? so it can reject data following that capsule.
18
+ def each(data)
19
+ @buffer << data if data
20
+ until @buffer.empty?
21
+ break unless @frame_remaining || read_frame_header
22
+
23
+ chunk = consume_payload
24
+ yield chunk if @frame_type == FRAME_DATA
25
+ end
26
+ end
27
+
28
+ def buffered?
29
+ !@buffer.empty?
30
+ end
31
+
32
+ def finish!
33
+ if buffered? || @frame_remaining
34
+ raise FrameError.new("Truncated HTTP/3 frame", error_code: H3_FRAME_ERROR)
35
+ end
36
+ end
37
+
38
+ def clear
39
+ @buffer.clear
40
+ @frame_remaining = nil
41
+ end
42
+
43
+ private
44
+
45
+ def read_frame_header
46
+ type, type_length = Protocol.decode_varint_str(@buffer, 0)
47
+ return false if type_length == 0
48
+ length, length_length = Protocol.decode_varint_str(@buffer, type_length)
49
+ return false if length_length == 0
50
+
51
+ # RFC 9114 §4.4 forbids known non-DATA frames after CONNECT,
52
+ # including HEADERS trailers; these are connection errors.
53
+ if CONTROL_ONLY_FRAME_SET.key?(type) ||
54
+ HTTP2_RESERVED_FRAME_SET.key?(type) ||
55
+ [FRAME_HEADERS, FRAME_PUSH_PROMISE, FRAME_PRIORITY_UPDATE].include?(type)
56
+ raise FrameError, "Frame not allowed on CONNECT"
57
+ end
58
+
59
+ @frame_type = type
60
+ @frame_remaining = length
61
+ @buffer = @buffer.byteslice(type_length + length_length..) || "".b
62
+ true
63
+ end
64
+
65
+ def consume_payload
66
+ count = [@frame_remaining, @buffer.bytesize].min
67
+ chunk = @buffer.byteslice(0, count)
68
+ @buffer = @buffer.byteslice(count..) || "".b
69
+ @frame_remaining -= count
70
+ @frame_remaining = nil if @frame_remaining.zero?
71
+ chunk
72
+ end
73
+ end
74
+ end
75
+ end