quicsilver 0.5.0-x86_64-linux

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. checksums.yaml +7 -0
  2. data/.github/workflows/ci.yml +49 -0
  3. data/.github/workflows/cibuildgem.yaml +117 -0
  4. data/.gitignore +21 -0
  5. data/.gitmodules +3 -0
  6. data/.ruby-version +1 -0
  7. data/CHANGELOG.md +175 -0
  8. data/Gemfile +6 -0
  9. data/Gemfile.lock +67 -0
  10. data/LICENSE +21 -0
  11. data/README.md +261 -0
  12. data/Rakefile +130 -0
  13. data/benchmarks/baseline.rb +175 -0
  14. data/benchmarks/compare.rb +398 -0
  15. data/benchmarks/components.rb +191 -0
  16. data/benchmarks/helpers.rb +162 -0
  17. data/benchmarks/latency.rb +128 -0
  18. data/benchmarks/loss.rb +193 -0
  19. data/benchmarks/mobile.rb +125 -0
  20. data/bin/console +14 -0
  21. data/bin/setup +8 -0
  22. data/docs/connection_identity.md +141 -0
  23. data/examples/README.md +58 -0
  24. data/examples/benchmark.rb +191 -0
  25. data/examples/connection_pool_demo.rb +47 -0
  26. data/examples/example_helper.rb +69 -0
  27. data/examples/falcon_middleware.rb +44 -0
  28. data/examples/feature_demo.rb +125 -0
  29. data/examples/grpc_style.rb +97 -0
  30. data/examples/latency_aware_routing.rb +382 -0
  31. data/examples/lifecycle_drain.rb +264 -0
  32. data/examples/minimal_http3_server.rb +14 -0
  33. data/examples/priorities.rb +60 -0
  34. data/examples/protocol_http_server.rb +31 -0
  35. data/examples/rack_http3_server.rb +42 -0
  36. data/examples/rails_feature_test.rb +280 -0
  37. data/examples/server_stats.rb +117 -0
  38. data/examples/simple_client_test.rb +26 -0
  39. data/examples/streaming_sse.rb +33 -0
  40. data/examples/trailers.rb +69 -0
  41. data/examples/webtransport_smoke.rb +546 -0
  42. data/examples/worker_load_balancing.rb +425 -0
  43. data/ext/quicsilver/extconf.rb +124 -0
  44. data/ext/quicsilver/include/msquic.h +2226 -0
  45. data/ext/quicsilver/include/msquic_posix.h +696 -0
  46. data/ext/quicsilver/include/quic_sal_stub.h +314 -0
  47. data/ext/quicsilver/msquic_patch.rb +68 -0
  48. data/ext/quicsilver/patches/reliable-reset.patch +723 -0
  49. data/ext/quicsilver/patches/stream-final-size.patch +47 -0
  50. data/ext/quicsilver/quicsilver.c +2741 -0
  51. data/lib/quicsilver/3.4/quicsilver.so +0 -0
  52. data/lib/quicsilver/4.0/quicsilver.so +0 -0
  53. data/lib/quicsilver/client/client.rb +708 -0
  54. data/lib/quicsilver/client/connection_pool.rb +202 -0
  55. data/lib/quicsilver/client/request.rb +190 -0
  56. data/lib/quicsilver/errors.rb +39 -0
  57. data/lib/quicsilver/libmsquic.so.2 +0 -0
  58. data/lib/quicsilver/protocol/adapter.rb +196 -0
  59. data/lib/quicsilver/protocol/capsule.rb +38 -0
  60. data/lib/quicsilver/protocol/connect_stream_decoder.rb +75 -0
  61. data/lib/quicsilver/protocol/control_stream_parser.rb +117 -0
  62. data/lib/quicsilver/protocol/datagram.rb +40 -0
  63. data/lib/quicsilver/protocol/frame_parser.rb +141 -0
  64. data/lib/quicsilver/protocol/frame_reader.rb +84 -0
  65. data/lib/quicsilver/protocol/frames.rb +454 -0
  66. data/lib/quicsilver/protocol/priority.rb +65 -0
  67. data/lib/quicsilver/protocol/qpack/decoder.rb +127 -0
  68. data/lib/quicsilver/protocol/qpack/encoder.rb +217 -0
  69. data/lib/quicsilver/protocol/qpack/header_block_decoder.rb +140 -0
  70. data/lib/quicsilver/protocol/qpack/huffman.rb +459 -0
  71. data/lib/quicsilver/protocol/request_encoder.rb +50 -0
  72. data/lib/quicsilver/protocol/request_parser.rb +289 -0
  73. data/lib/quicsilver/protocol/response_encoder.rb +107 -0
  74. data/lib/quicsilver/protocol/response_parser.rb +147 -0
  75. data/lib/quicsilver/protocol/stream_input.rb +98 -0
  76. data/lib/quicsilver/protocol/stream_output.rb +59 -0
  77. data/lib/quicsilver/protocol/unidirectional_stream.rb +12 -0
  78. data/lib/quicsilver/protocol/web_transport.rb +214 -0
  79. data/lib/quicsilver/rack/context.rb +33 -0
  80. data/lib/quicsilver/response.rb +40 -0
  81. data/lib/quicsilver/server/listener_data.rb +14 -0
  82. data/lib/quicsilver/server/rack_adapter.rb +77 -0
  83. data/lib/quicsilver/server/receive_queue.rb +106 -0
  84. data/lib/quicsilver/server/request_handler.rb +149 -0
  85. data/lib/quicsilver/server/request_registry.rb +63 -0
  86. data/lib/quicsilver/server/scheduler.rb +50 -0
  87. data/lib/quicsilver/server/schedulers/fiber_scheduler.rb +79 -0
  88. data/lib/quicsilver/server/schedulers/thread_scheduler.rb +76 -0
  89. data/lib/quicsilver/server/server.rb +1080 -0
  90. data/lib/quicsilver/server/web_transport_flow_control.rb +270 -0
  91. data/lib/quicsilver/server/web_transport_manager.rb +281 -0
  92. data/lib/quicsilver/server/web_transport_registry.rb +40 -0
  93. data/lib/quicsilver/server/web_transport_session.rb +812 -0
  94. data/lib/quicsilver/server/web_transport_stream.rb +326 -0
  95. data/lib/quicsilver/transport/configuration.rb +234 -0
  96. data/lib/quicsilver/transport/connection.rb +594 -0
  97. data/lib/quicsilver/transport/connection_stats.rb +53 -0
  98. data/lib/quicsilver/transport/event_loop.rb +74 -0
  99. data/lib/quicsilver/transport/inbound_stream.rb +53 -0
  100. data/lib/quicsilver/transport/stream.rb +43 -0
  101. data/lib/quicsilver/transport/stream_event.rb +49 -0
  102. data/lib/quicsilver/transport/stream_id.rb +26 -0
  103. data/lib/quicsilver/version.rb +3 -0
  104. data/lib/quicsilver.rb +96 -0
  105. data/lib/rackup/handler/quicsilver.rb +96 -0
  106. data/quicsilver.gemspec +66 -0
  107. metadata +362 -0
@@ -0,0 +1,202 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Quicsilver
4
+ class Client
5
+ # Thread-safe pool of connected Client instances, keyed by (host, port).
6
+ # Idle connections are reused automatically. Stale ones are evicted at checkout.
7
+ #
8
+ # Quicsilver::Client.get("example.com", 4433, "/users")
9
+ #
10
+ class ConnectionPool
11
+ attr_reader :max_size, :idle_timeout, :mode
12
+
13
+ DEFAULT_MAX_SIZE = 4
14
+ DEFAULT_IDLE_TIMEOUT = 60 # seconds
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)
23
+ @max_size = max_size
24
+ @idle_timeout = idle_timeout
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
29
+ @mutex = Mutex.new
30
+ @condition = ConditionVariable.new
31
+ end
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
+
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).
47
+ def checkout(hostname, port, **options)
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
54
+
55
+ @mutex.synchronize do
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
66
+
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]
73
+ end
74
+
75
+ # Room to create a new connection
76
+ break if entries.size < @max_size
77
+
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)
85
+ end
86
+ end
87
+
88
+ # Create outside the lock (blocking I/O)
89
+ client = Client.new(hostname, port, **options)
90
+ client.open_connection
91
+
92
+ @mutex.synchronize do
93
+ (@pools[key] ||= []) << { client: client, checked_out: true, last_used: Time.now }
94
+ @keys[client] = key
95
+ end
96
+
97
+ client
98
+ end
99
+
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.
146
+ def checkin(client)
147
+ return if @mode == :shared
148
+
149
+ @mutex.synchronize do
150
+ key = @keys[client]
151
+ entries = key && @pools[key]
152
+ return unless entries
153
+
154
+ entry = entries.find { |e| e[:client].equal?(client) }
155
+ return unless entry
156
+
157
+ if client.connected?
158
+ entry[:checked_out] = false
159
+ entry[:last_used] = Time.now
160
+ else
161
+ entries.delete(entry)
162
+ @keys.delete(client)
163
+ client.close_connection
164
+ @pools.delete(key) if entries.empty?
165
+ end
166
+
167
+ @condition.broadcast # wake threads waiting for a connection
168
+ end
169
+ end
170
+
171
+ # Close all clients.
172
+ def close
173
+ @mutex.synchronize do
174
+ @pools.each_value do |entries|
175
+ entries.each { |e| e[:client].close_connection }
176
+ end
177
+ @pools.clear
178
+ @keys.clear
179
+ end
180
+ end
181
+
182
+ # Total clients in the pool, optionally filtered by host and port across
183
+ # every option set pooled for them.
184
+ def size(host = nil, port = nil)
185
+ @mutex.synchronize do
186
+ if host && port
187
+ prefix = "#{host}:#{port}\0"
188
+ @pools.sum { |key, entries| key.start_with?(prefix) ? entries.size : 0 }
189
+ else
190
+ @pools.values.sum(&:size)
191
+ end
192
+ end
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
200
+ end
201
+ end
202
+ end
@@ -0,0 +1,190 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Quicsilver
4
+ class Client
5
+ class Request
6
+ attr_reader :stream, :status
7
+
8
+ class ResetError < Quicsilver::Error
9
+ attr_reader :error_code
10
+
11
+ def initialize(message, error_code = nil)
12
+ super(message)
13
+ @error_code = error_code
14
+ end
15
+ end
16
+
17
+ def initialize(client, stream)
18
+ @client = client
19
+ @stream = stream
20
+ @status = :pending
21
+ @queue = Queue.new
22
+ @streaming_queue = Queue.new
23
+ @mutex = Mutex.new
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
58
+ end
59
+
60
+ # Block until response arrives or timeout
61
+ # Returns response hash { status:, headers:, body: }
62
+ # Raises TimeoutError, CancelledError, or ResetError
63
+ def response(timeout: nil)
64
+ timeout ||= @client.request_timeout
65
+ return @response if @status == :completed
66
+
67
+ result = @queue.pop(timeout: timeout)
68
+
69
+ @mutex.synchronize do
70
+ case result
71
+ when nil
72
+ raise Quicsilver::TimeoutError, "Request timeout after #{timeout}s"
73
+ when Response
74
+ @status = :completed
75
+ @response = result
76
+ when Hash
77
+ # Error hash from #fail
78
+ @status = :error
79
+ raise ResetError.new(result[:message] || "Stream reset by peer", result[:error_code])
80
+ end
81
+ end
82
+
83
+ @response
84
+ end
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
+
115
+ # Cancel the request (sends RESET_STREAM + STOP_SENDING to server)
116
+ # error_code defaults to H3_REQUEST_CANCELLED (0x10c)
117
+ def cancel(error_code: Protocol::H3_REQUEST_CANCELLED)
118
+ @mutex.synchronize do
119
+ return false unless @status == :pending
120
+
121
+ @stream.abort(error_code)
122
+ @status = :cancelled
123
+ end
124
+ true
125
+ rescue => e
126
+ Quicsilver.logger.error("Failed to cancel request: #{e.message}")
127
+ false
128
+ end
129
+
130
+ def pending?
131
+ @status == :pending
132
+ end
133
+
134
+ def completed?
135
+ @status == :completed
136
+ end
137
+
138
+ def cancelled?
139
+ @status == :cancelled
140
+ end
141
+
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
169
+ def complete(response) # :nodoc:
170
+ @queue.push(response)
171
+ end
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
+
178
+ # Called by Client on stream reset from peer or connection close
179
+ def fail(error_code, message = nil) # :nodoc:
180
+ @mutex.synchronize do
181
+ return if @status == :cancelled
182
+ @status = :error
183
+ end
184
+ error = { error: true, error_code: error_code, message: message }
185
+ @queue.push(error)
186
+ @streaming_queue.push(error)
187
+ end
188
+ end
189
+ end
190
+ 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
@@ -0,0 +1,196 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "protocol/http/request"
4
+ require "protocol/http/response"
5
+ require "protocol/http/headers"
6
+ require "protocol/http/peer"
7
+ require_relative "stream_input"
8
+ require_relative "stream_output"
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
24
+ module Protocol
25
+ # Converts between QUIC/HTTP/3 frames and protocol-http Request/Response
26
+ # objects. This enables integration with Falcon and any other server
27
+ # built on protocol-http.
28
+ #
29
+ # Usage:
30
+ # adapter = Protocol::Adapter.new(app)
31
+ # request, body = adapter.build_request(parsed_headers)
32
+ # body.write(chunk) # feed body data
33
+ # body.close_write # signal end of body
34
+ # response = adapter.call(request)
35
+ # adapter.send_response(response, writer)
36
+ #
37
+ class Adapter
38
+ VERSION = "HTTP/3"
39
+
40
+ def initialize(app)
41
+ @app = app
42
+ @qpack_encoder = Quicsilver::Protocol::Qpack::Encoder.new
43
+ end
44
+
45
+ # Build a Protocol::HTTP::Request from parsed HTTP/3 headers.
46
+ #
47
+ # Returns [request, body] where body is a Protocol::StreamInput that
48
+ # the transport feeds RECEIVE chunks into.
49
+ #
50
+ # @param headers [Hash] Parsed headers from RequestParser (includes pseudo-headers).
51
+ # @return [Array(Protocol::HTTP::Request, Protocol::StreamInput)] request and body.
52
+ # Body is nil for bodyless methods (GET, HEAD, TRACE).
53
+ # Caller feeds RECEIVE data into body via write(), then close_write on FIN.
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)
59
+ method = headers[":method"]
60
+ scheme = headers[":scheme"] || "https"
61
+ authority = headers[":authority"]
62
+ path = headers[":path"]
63
+ protocol = headers[":protocol"]
64
+ content_length = headers["content-length"]&.to_i
65
+
66
+ protocol_headers = ::Protocol::HTTP::Headers.new
67
+ headers.each do |name, value|
68
+ next if name.start_with?(":")
69
+ protocol_headers.add(name, value)
70
+ end
71
+
72
+ body = unless bodyless_request?(method)
73
+ Protocol::StreamInput.new(content_length)
74
+ end
75
+
76
+ peer = if remote_address
77
+ ::Protocol::HTTP::Peer.new(Addrinfo.tcp(remote_address, remote_port))
78
+ end
79
+
80
+ request = Request.new(
81
+ scheme, authority, method, path, VERSION,
82
+ protocol_headers, body, protocol,
83
+ peer: peer,
84
+ transport_context: transport_context,
85
+ rack_context: rack_context
86
+ )
87
+
88
+ [request, body]
89
+ end
90
+
91
+ # Send a Protocol::HTTP::Response via a transport writer.
92
+ #
93
+ # Encodes the response headers as an HTTP/3 HEADERS frame, then streams
94
+ # the response body as DATA frames via Protocol::StreamOutput.
95
+ #
96
+ # @param response [Protocol::HTTP::Response] The response to send.
97
+ # @param writer [#call] Transport writer — accepts (data, fin) for sending bytes.
98
+ # @param head_request [Boolean] Whether this was a HEAD request.
99
+ # @return [void]
100
+ def send_response(response, writer, head_request: false)
101
+ status = response.status
102
+ headers = response.headers
103
+ trailers = extract_trailers(headers)
104
+ headers_hash = response_header_fields(headers)
105
+ body = response.body
106
+
107
+ if body.nil? || head_request
108
+ send_headers_only(status, headers_hash, writer, trailers: trailers)
109
+ elsif body.respond_to?(:read)
110
+ stream_response(status, headers_hash, body, writer, trailers: trailers)
111
+ else
112
+ buffer_response(status, headers_hash, body, writer, trailers: trailers)
113
+ end
114
+ end
115
+
116
+ # Call the protocol-http application with the request.
117
+ #
118
+ # @param request [Protocol::HTTP::Request]
119
+ # @return [Protocol::HTTP::Response]
120
+ def call(request)
121
+ @app.call(request)
122
+ end
123
+
124
+ private
125
+
126
+ # Methods where a body has no defined semantics (RFC 9110 §9.3.1, §9.3.2, §9.3.8).
127
+ # GET and HEAD SHOULD NOT have a body. TRACE MUST NOT.
128
+ # DELETE, OPTIONS, CONNECT can all have meaningful bodies.
129
+ BODYLESS_METHODS = %w[GET HEAD TRACE].freeze
130
+
131
+ def bodyless_request?(method)
132
+ BODYLESS_METHODS.include?(method)
133
+ end
134
+
135
+ # No body — send HEADERS with FIN
136
+ def send_headers_only(status, headers, writer, trailers: nil)
137
+ encoder = Quicsilver::Protocol::ResponseEncoder.new(status, headers, [], trailers: trailers)
138
+ writer.call(encoder.encode, true)
139
+ end
140
+
141
+ # Streaming body (protocol-http Body::Readable) — send HEADERS, then
142
+ # stream DATA frames as chunks arrive. Used by Falcon mode.
143
+ def stream_response(status, headers, body, writer, trailers: nil)
144
+ has_trailers = trailers&.any?
145
+ writer.call(build_headers_frame(status, headers), false)
146
+ Protocol::StreamOutput.new(body, &writer).stream(send_fin: !has_trailers)
147
+ writer.call(build_trailer_frame(trailers), true) if has_trailers
148
+ end
149
+
150
+ # Buffered body (Rack array or enumerable) — encode everything and send.
151
+ def buffer_response(status, headers, body, writer, trailers: nil)
152
+ parts = body.respond_to?(:each) ? body : [body.to_s]
153
+ encoder = Quicsilver::Protocol::ResponseEncoder.new(status, headers, parts, trailers: trailers)
154
+ writer.call(encoder.encode, true)
155
+ ensure
156
+ body.close if body.respond_to?(:close)
157
+ end
158
+
159
+ # Extract trailers from Protocol::HTTP::Headers if present.
160
+ # Returns a Hash or nil.
161
+ def extract_trailers(headers)
162
+ return nil unless headers.respond_to?(:trailer?) && headers.trailer?
163
+
164
+ result = {}
165
+ headers.trailer.each do |name, value|
166
+ result[name] = value
167
+ end
168
+ result
169
+ end
170
+
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
181
+ end
182
+
183
+ # Build an HTTP/3 HEADERS frame from key-value pairs
184
+ def build_headers_frame(status, headers)
185
+ pairs = [[":status", status.to_s]]
186
+ headers.each { |name, value| pairs << [name.to_s.downcase, value.to_s] }
187
+ Protocol.build_headers_frame(pairs, encoder: @qpack_encoder)
188
+ end
189
+
190
+ def build_trailer_frame(trailers)
191
+ pairs = trailers.map { |name, value| [name.to_s.downcase, value.to_s] }
192
+ Protocol.build_headers_frame(pairs, encoder: @qpack_encoder)
193
+ end
194
+ end
195
+ end
196
+ 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