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,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Quicsilver
4
+ module Protocol
5
+ module UnidirectionalStream
6
+ CONTROL = 0x00
7
+ PUSH = 0x01
8
+ QPACK_ENCODER = 0x02
9
+ QPACK_DECODER = 0x03
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,214 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Quicsilver
4
+ module Protocol
5
+ module WebTransport
6
+ SESSION_GONE = 0x170d7b68
7
+ BUFFERED_STREAM_REJECTED = 0x3994bd84
8
+
9
+ BIDI_STREAM_TYPE = 0x41
10
+ UNI_STREAM_TYPE = 0x54
11
+ CLOSE_SESSION_CAPSULE = 0x2843
12
+
13
+ # draft-ietf-webtrans-http3-16 §4.7. Advisory: either endpoint asks the
14
+ # other to wind the session down, but the session stays usable until it
15
+ # is actually closed.
16
+ DRAIN_SESSION_CAPSULE = 0x78ae
17
+
18
+ # Session-level flow control (§5.6). These sit above QUIC's own limits:
19
+ # QUIC bounds the connection and each stream, these bound one session
20
+ # across all of its streams.
21
+ #
22
+ # MAX_* raise a limit. *_BLOCKED are advisory, saying a limit was hit;
23
+ # §5.6 forbids waiting for one before raising a limit.
24
+ MAX_DATA_CAPSULE = 0x190B4D3D
25
+ MAX_STREAMS_BIDI_CAPSULE = 0x190B4D3F
26
+ MAX_STREAMS_UNI_CAPSULE = 0x190B4D40
27
+ DATA_BLOCKED_CAPSULE = 0x190B4D41
28
+ STREAMS_BLOCKED_BIDI_CAPSULE = 0x190B4D43
29
+ STREAMS_BLOCKED_UNI_CAPSULE = 0x190B4D44
30
+
31
+ # A flow control violation closes the session, not the connection (§5.6).
32
+ FLOW_CONTROL_ERROR = 0x045d4487
33
+
34
+ # Per-stream data limits belong to the HTTP/2 binding. Over HTTP/3 every
35
+ # WebTransport stream is a real QUIC stream, so QUIC provides them
36
+ # natively and these two capsules are prohibited: receipt is a session
37
+ # error (§5.4).
38
+ #
39
+ # Defined in draft-ietf-webtrans-http2-14 §6.6 and §6.9, which the HTTP/3
40
+ # draft cites without restating the code points. They fill the two gaps
41
+ # in the 0x190B4D3D..0x190B4D44 block the §9.6 registry allocates.
42
+ #
43
+ # That draft is not in docs/specs, so these are the only values here
44
+ # without a local citation. Re-verify them when it is vendored.
45
+ MAX_STREAM_DATA_CAPSULE = 0x190B4D3E
46
+ STREAM_DATA_BLOCKED_CAPSULE = 0x190B4D42
47
+
48
+ PROHIBITED_CAPSULES = [MAX_STREAM_DATA_CAPSULE, STREAM_DATA_BLOCKED_CAPSULE].freeze
49
+
50
+ # Prohibited whether or not flow control was negotiated. §5.1 says to
51
+ # ignore "flow control capsules" when flow control is off, but §5.6
52
+ # defines that term as MAX_DATA, MAX_STREAMS, DATA_BLOCKED and
53
+ # STREAMS_BLOCKED only. These two are never legal over HTTP/3.
54
+ def self.prohibited_capsule?(type)
55
+ PROHIBITED_CAPSULES.include?(type)
56
+ end
57
+
58
+ # Stream limits cannot exceed 2^60: a larger count could not be encoded
59
+ # as a stream ID (§5.6.2, §5.6.3).
60
+ MAX_STREAMS_LIMIT = 1 << 60
61
+
62
+ # Largest value a QUIC variable-length integer can carry (RFC 9000 §16).
63
+ VARINT_MAX = (1 << 62) - 1
64
+
65
+ # Raised for a capsule whose contents break a flow control rule, as
66
+ # opposed to one we cannot parse. The session is closed with
67
+ # FLOW_CONTROL_ERROR; the connection survives.
68
+ class FlowControlError < StandardError
69
+ def error_code = FLOW_CONTROL_ERROR
70
+ end
71
+
72
+ # We wanted to send but the peer's session limit forbids it (§5.3,
73
+ # §5.4). Local and recoverable: nothing is wrong on the wire, there is
74
+ # simply no credit. Distinct from FlowControlError, which means the peer
75
+ # broke a rule and the session must close.
76
+ class SendBlocked < StandardError
77
+ attr_reader :limit, :needed
78
+
79
+ def initialize(message, limit:, needed:)
80
+ super(message)
81
+ @limit = limit
82
+ @needed = needed
83
+ end
84
+ end
85
+
86
+ # What a flow control capsule says. `limit` is cumulative for MAX_DATA
87
+ # and MAX_STREAMS, and for the BLOCKED capsules it is the limit that was
88
+ # in force when the sender was blocked.
89
+ Limit = Data.define(:kind, :direction, :limit) do
90
+ def max? = kind == :max_data || kind == :max_streams
91
+ def blocked? = !max?
92
+ end
93
+
94
+ # kind and direction for each flow control capsule type. Direction is nil
95
+ # where the capsule covers the whole session rather than one stream type.
96
+ FLOW_CONTROL_CAPSULES = {
97
+ MAX_DATA_CAPSULE => [:max_data, nil],
98
+ MAX_STREAMS_BIDI_CAPSULE => [:max_streams, :bidi],
99
+ MAX_STREAMS_UNI_CAPSULE => [:max_streams, :uni],
100
+ DATA_BLOCKED_CAPSULE => [:data_blocked, nil],
101
+ STREAMS_BLOCKED_BIDI_CAPSULE => [:streams_blocked, :bidi],
102
+ STREAMS_BLOCKED_UNI_CAPSULE => [:streams_blocked, :uni]
103
+ }.freeze
104
+
105
+ def self.flow_control_capsule?(type)
106
+ FLOW_CONTROL_CAPSULES.key?(type)
107
+ end
108
+
109
+ # Decode a flow control capsule payload: a single varint (§5.6.2-5.6.5).
110
+ #
111
+ # Raises Capsule::ParseError when the payload is not exactly one varint,
112
+ # and FlowControlError when a stream count exceeds what a stream ID can
113
+ # encode. Those are different outcomes on the wire: the first is a
114
+ # malformed message, the second a flow control violation.
115
+ def self.parse_flow_control_capsule(type, payload)
116
+ kind, direction = FLOW_CONTROL_CAPSULES.fetch(type) do
117
+ raise ArgumentError, "Not a flow control capsule: 0x#{type.to_s(16)}"
118
+ end
119
+
120
+ value, length = Protocol.decode_varint_str(payload, 0)
121
+ raise Capsule::ParseError, "Truncated #{kind} capsule" if length == 0
122
+ raise Capsule::ParseError, "Trailing bytes in #{kind} capsule" unless length == payload.bytesize
123
+
124
+ if (kind == :max_streams || kind == :streams_blocked) && value > MAX_STREAMS_LIMIT
125
+ raise FlowControlError, "#{kind} of #{value} exceeds the 2^60 stream limit"
126
+ end
127
+
128
+ Limit.new(kind: kind, direction: direction, limit: value)
129
+ end
130
+
131
+ def self.build_max_data(limit)
132
+ Capsule.encode(MAX_DATA_CAPSULE, Protocol.encode_varint(validate_limit!(limit)))
133
+ end
134
+
135
+ def self.build_data_blocked(limit)
136
+ Capsule.encode(DATA_BLOCKED_CAPSULE, Protocol.encode_varint(validate_limit!(limit)))
137
+ end
138
+
139
+ def self.build_max_streams(direction, limit)
140
+ type = (direction == :bidi) ? MAX_STREAMS_BIDI_CAPSULE : MAX_STREAMS_UNI_CAPSULE
141
+ Capsule.encode(type, Protocol.encode_varint(validate_stream_limit!(direction, limit)))
142
+ end
143
+
144
+ def self.build_streams_blocked(direction, limit)
145
+ type = (direction == :bidi) ? STREAMS_BLOCKED_BIDI_CAPSULE : STREAMS_BLOCKED_UNI_CAPSULE
146
+ Capsule.encode(type, Protocol.encode_varint(validate_stream_limit!(direction, limit)))
147
+ end
148
+
149
+ # Building a capsule the peer would have to reject is our bug, so these
150
+ # raise ArgumentError rather than a protocol error.
151
+ def self.validate_limit!(limit)
152
+ unless limit.is_a?(Integer) && limit >= 0 && limit <= VARINT_MAX
153
+ raise ArgumentError, "Limit must be a varint-encodable non-negative integer"
154
+ end
155
+
156
+ limit
157
+ end
158
+
159
+ def self.validate_stream_limit!(direction, limit)
160
+ unless direction == :bidi || direction == :uni
161
+ raise ArgumentError, "Direction must be :bidi or :uni"
162
+ end
163
+ validate_limit!(limit)
164
+ raise ArgumentError, "Stream limit #{limit} exceeds 2^60" if limit > MAX_STREAMS_LIMIT
165
+
166
+ limit
167
+ end
168
+
169
+ # Extended CONNECT :protocol values.
170
+ #
171
+ # draft-ietf-webtrans-http3-16 §3.2 requires "webtransport-h3". The bare
172
+ # "webtransport" token identifies the capsule-based HTTP/2 binding
173
+ # (§2.1.2) and was what pre-15 drafts used over HTTP/3. We retain it
174
+ # for legacy interoperability; the token alone does not identify a
175
+ # browser version or prove support for every feature of an older draft.
176
+ PROTOCOL = "webtransport-h3"
177
+ PROTOCOL_LEGACY = "webtransport"
178
+
179
+ def self.protocol?(value)
180
+ value == PROTOCOL || value == PROTOCOL_LEGACY
181
+ end
182
+
183
+ # WebTransport shares the HTTP/3 error space, so 32-bit application error
184
+ # codes are mapped into a reserved range (§4.4, §9.5). Codepoints of the
185
+ # form 0x1f * N + 0x21 are reserved by HTTP/3 §8.1 and skipped, which is
186
+ # why this is not a plain offset.
187
+ APPLICATION_ERROR_FIRST = 0x52e4a40fa8db
188
+ APPLICATION_ERROR_LAST = 0x52e5ac983162
189
+
190
+ RESERVED_STRIDE = 0x1f
191
+ RESERVED_OFFSET = 0x21
192
+
193
+ # Application code -> HTTP/3 code, stepping over reserved codepoints.
194
+ def self.application_error_to_http(code)
195
+ APPLICATION_ERROR_FIRST + code + (code / 0x1e)
196
+ end
197
+
198
+ # HTTP/3 code -> application code. nil when the code is outside the
199
+ # reserved range or lands on a reserved codepoint, neither of which a
200
+ # peer should ever send.
201
+ def self.http_to_application_error(code)
202
+ return nil unless code.between?(APPLICATION_ERROR_FIRST, APPLICATION_ERROR_LAST)
203
+ return nil if reserved_codepoint?(code)
204
+
205
+ shifted = code - APPLICATION_ERROR_FIRST
206
+ shifted - (shifted / RESERVED_STRIDE)
207
+ end
208
+
209
+ def self.reserved_codepoint?(code)
210
+ (code - RESERVED_OFFSET) % RESERVED_STRIDE == 0
211
+ end
212
+ end
213
+ end
214
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Quicsilver
4
+ module Rack
5
+ # Rack-visible Quicsilver transport context.
6
+ #
7
+ # This is the single env capability object for HTTP/3/QUIC-specific state.
8
+ # Normal Rack apps can ignore it; Quicsilver-aware apps can use it to opt into
9
+ # transport features like WebTransport.
10
+ class Context
11
+ attr_reader :stream_id, :webtransport
12
+
13
+ def initialize(stream_id: nil, early_data: false, webtransport: nil, metadata: {})
14
+ @stream_id = stream_id
15
+ @early_data = early_data
16
+ @webtransport = webtransport
17
+ @metadata = metadata || {}
18
+ end
19
+
20
+ def early_data?
21
+ !!@early_data
22
+ end
23
+
24
+ def webtransport?
25
+ !!@webtransport
26
+ end
27
+
28
+ def [](key)
29
+ @metadata[key]
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Quicsilver
4
+ # Unified HTTP/3 response object returned by the client.
5
+ #
6
+ # Used for both buffered and streaming responses:
7
+ # - Buffered: body is a String
8
+ # - Streaming: body is a StreamInput (call body.read in a loop)
9
+ #
10
+ # response = client.get("/users")
11
+ # response.status # => 200
12
+ # response.headers # => {"content-type" => "application/json"}
13
+ # response.body # => '{"users":["alice"]}'
14
+ # response.trailers # => {}
15
+ #
16
+ # # Streaming
17
+ # req = client.build_request("GET", "/stream")
18
+ # response = req.streaming_response(timeout: 10)
19
+ # response.status # => 200
20
+ # while (chunk = response.body.read)
21
+ # process(chunk)
22
+ # end
23
+ #
24
+ class Response
25
+ attr_reader :status, :headers, :body, :trailers
26
+
27
+ def initialize(status:, headers: {}, body: nil, trailers: {})
28
+ @status = status
29
+ @headers = headers
30
+ @body = body
31
+ @trailers = trailers
32
+ end
33
+
34
+ def ok? = status == 200
35
+ def success? = status >= 200 && status < 300
36
+ def redirect? = status >= 300 && status < 400
37
+ def client_error? = status >= 400 && status < 500
38
+ def server_error? = status >= 500 && status < 600
39
+ end
40
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Quicsilver
4
+ class Server
5
+ class ListenerData
6
+ attr_reader :listener_handle, :context_handle
7
+
8
+ def initialize(listener_handle, context_handle)
9
+ @listener_handle = listener_handle # The MSQUIC listener handle
10
+ @context_handle = context_handle # The C context pointer
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Quicsilver
4
+ class Server
5
+ # Subclass of Protocol::Rack's adapter that adds rack.trailers support.
6
+ #
7
+ # Protocol-rack's wrap_headers creates new Protocol::HTTP::Headers,
8
+ # losing any trailer! state. We override call to inject trailers
9
+ # from env["rack.trailers"] after wrap_headers runs.
10
+ #
11
+ # Usage in a Rack app:
12
+ # env["rack.trailers"] = { "grpc-status" => "0", "grpc-message" => "OK" }
13
+ #
14
+ class RackAdapter < ::Protocol::Rack::Adapter::Rack31
15
+ def call(request)
16
+ env = self.make_environment(request)
17
+ add_transport_context(env, request)
18
+ add_rack_context(env, request.rack_context) if request.respond_to?(:rack_context) && request.rack_context
19
+
20
+ # Wire rack.early_hints for 103 Early Hints
21
+ if request.respond_to?(:interim_response) && request.interim_response
22
+ env["rack.early_hints"] = ->(hint_headers) {
23
+ request.send_interim_response(103, ::Protocol::HTTP::Headers[hint_headers.map { |k, v| [k, v] }])
24
+ }
25
+ end
26
+
27
+ status, headers, body = @app.call(env)
28
+
29
+ headers, meta = self.wrap_headers(headers)
30
+
31
+ # Inject trailers from rack.trailers into the protocol-http headers
32
+ if (trailers = env["rack.trailers"]) && trailers.is_a?(Hash) && !trailers.empty?
33
+ headers.trailer!
34
+ trailers.each { |k, v| headers.add(k, v) }
35
+ end
36
+
37
+ ::Protocol::Rack::Response.wrap(env, status, headers, meta, body, request)
38
+ rescue => error
39
+ self.handle_error(env, status, headers, body, error)
40
+ end
41
+
42
+ private
43
+
44
+ def add_rack_context(env, rack_context)
45
+ env["quicsilver.context"] = rack_context
46
+
47
+ if rack_context.respond_to?(:webtransport) && rack_context.webtransport
48
+ env["quicsilver.webtransport"] = rack_context.webtransport
49
+ end
50
+ end
51
+
52
+ def add_transport_context(env, request)
53
+ return unless request.respond_to?(:transport_context)
54
+ return unless (context = request.transport_context)
55
+
56
+ connection = context["connection"] || {}
57
+
58
+ if connection_id = connection["connection_id"]
59
+ env["quicsilver.connection_id"] = connection_id
60
+ end
61
+
62
+ if request_id = connection["request_id"]
63
+ env["quicsilver.request_id"] = request_id
64
+ end
65
+
66
+ if stream_id = connection["stream_id"]
67
+ env["quicsilver.stream_id"] = stream_id
68
+ end
69
+
70
+ env["quicsilver.context"] ||= ::Quicsilver::Rack::Context.new(
71
+ stream_id: connection["stream_id"],
72
+ metadata: context
73
+ )
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Quicsilver
4
+ class Server
5
+ # Nonblocking admission for Body::Writable; only the consumer may wait.
6
+ class ReceiveQueue
7
+ def self.validate_limits(bytes, chunks)
8
+ [bytes, chunks].each do |limit|
9
+ raise ArgumentError, "Receive limits must be positive integers" unless limit.is_a?(Integer) && limit.positive?
10
+ end
11
+ end
12
+
13
+ # Accounting only. Every byte pushed here was sent inside a limit this
14
+ # endpoint advertised (RFC 9000 §4.1), so there is no refusing it; the
15
+ # receiver controls the rate by deciding when to advertise more (§4.2),
16
+ # which is what native receive credit does. Whether being over the limit
17
+ # means "stop granting" or "stop the stream" is the caller's policy.
18
+ def initialize(bytes:, chunks:)
19
+ self.class.validate_limits(bytes, chunks)
20
+ @byte_limit = bytes
21
+ @chunk_limit = chunks
22
+ @queue = Thread::Queue.new
23
+ @mutex = Mutex.new
24
+ @bytes = 0
25
+ @chunks = 0
26
+ @generation = 0
27
+ end
28
+
29
+ def push(data)
30
+ @mutex.synchronize do
31
+ raise ClosedQueueError if @queue.closed?
32
+
33
+ @queue.push([data, data.bytesize, @generation])
34
+ @bytes += data.bytesize
35
+ @chunks += 1
36
+ end
37
+ self
38
+ end
39
+
40
+ def over_limit?
41
+ @mutex.synchronize { @bytes > @byte_limit || @chunks > @chunk_limit }
42
+ end
43
+
44
+ def available_bytes
45
+ @mutex.synchronize { @chunks < @chunk_limit ? [@byte_limit - @bytes, 0].max : 0 }
46
+ end
47
+
48
+ def release_capacity_to(&callback)
49
+ capacity = @mutex.synchronize do
50
+ return if @queue.closed? || @release_capacity
51
+
52
+ @release_capacity = callback
53
+ [[@byte_limit - @bytes, 0].max, [@chunk_limit - @chunks, 0].max]
54
+ end
55
+ callback.call(*capacity)
56
+ end
57
+
58
+ def pop
59
+ entry = @queue.pop
60
+ return unless entry
61
+
62
+ data, bytes, generation = entry
63
+ grant_bytes = grant_chunks = 0
64
+ release_capacity = @mutex.synchronize do
65
+ # A concurrent discard already released entries from the old queue.
66
+ if generation == @generation
67
+ @bytes -= bytes
68
+ @chunks -= 1
69
+ # Grant back what was freed, capped at the capacity that is now
70
+ # actually spare. After an oversized first receive the two differ,
71
+ # and granting the full amount would admit another oversized one.
72
+ grant_bytes = [bytes, @byte_limit - @bytes].min.clamp(0..)
73
+ grant_chunks = [1, @chunk_limit - @chunks].min.clamp(0..)
74
+ @release_capacity unless @queue.closed?
75
+ end
76
+ end
77
+ release_capacity&.call(grant_bytes, grant_chunks) if grant_bytes.positive? || grant_chunks.positive?
78
+ data
79
+ end
80
+
81
+ def clear
82
+ @mutex.synchronize do
83
+ # Writable clears only when discarding input; prevent a racing push
84
+ # between its clear and close calls.
85
+ @release_capacity = nil
86
+ @queue.close
87
+ @queue.clear
88
+ @generation += 1
89
+ @bytes = @chunks = 0
90
+ end
91
+ self
92
+ end
93
+
94
+ def close
95
+ @mutex.synchronize do
96
+ @release_capacity = nil
97
+ @queue.close
98
+ end
99
+ self
100
+ end
101
+
102
+ def closed? = @queue.closed?
103
+ def empty? = @queue.empty?
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Quicsilver
4
+ class Server
5
+ class RequestHandler
6
+ SAFE_METHODS = %w[GET HEAD OPTIONS].freeze
7
+
8
+ attr_reader :adapter
9
+
10
+ def initialize(app:, configuration:, request_registry:, cancelled_streams:, cancelled_mutex:)
11
+ @configuration = configuration
12
+ @request_registry = request_registry
13
+ @cancelled_streams = cancelled_streams
14
+ @cancelled_mutex = cancelled_mutex
15
+ @adapter = Protocol::Adapter.new(app)
16
+ end
17
+
18
+ def call(connection, stream, early_data: false)
19
+ request = parse_request(connection, stream, early_data: early_data)
20
+ return unless request
21
+
22
+ response = @adapter.call(request)
23
+
24
+ send_response(connection, stream, request, response)
25
+ rescue Server::DrainTimeoutError
26
+ Quicsilver.logger.debug("Request interrupted by drain: stream #{stream.stream_id}")
27
+ rescue Protocol::FrameError => e
28
+ Quicsilver.logger.error("Frame error: #{e.message} (0x#{e.error_code.to_s(16)})")
29
+ Quicsilver.connection_shutdown(connection.handle, e.error_code, false) rescue nil
30
+ rescue Protocol::MessageError => e
31
+ Quicsilver.logger.error("Message error: #{e.message} (0x#{e.error_code.to_s(16)})")
32
+ stream.reset(e.error_code) if stream.writable?
33
+ rescue => e
34
+ Quicsilver.logger.error("Error handling request: #{e.class} - #{e.message}")
35
+ Quicsilver.logger.debug(e.backtrace.first(5).join("\n"))
36
+ connection.send_error(stream, 500, "Internal Server Error") if stream.writable?
37
+ ensure
38
+ @request_registry.complete(stream.stream_id, connection.handle)
39
+ @cancelled_mutex.synchronize { @cancelled_streams.delete([connection.handle, stream.stream_id]) }
40
+ connection.remove_stream(stream.stream_id)
41
+ end
42
+
43
+ private
44
+
45
+ def parse_request(connection, stream, early_data: false)
46
+ parser = Protocol::RequestParser.new(
47
+ stream.data,
48
+ max_body_size: @configuration.max_body_size,
49
+ max_header_size: @configuration.max_header_size,
50
+ max_header_count: @configuration.max_header_count,
51
+ max_frame_payload_size: @configuration.max_frame_payload_size
52
+ )
53
+ parser.parse
54
+ parser.validate_headers!
55
+
56
+ headers = parser.headers
57
+ unless headers && !headers.empty?
58
+ connection.send_error(stream, 400, "Bad Request") if stream.writable?
59
+ return
60
+ end
61
+
62
+ method = headers[":method"]
63
+
64
+ if @configuration.early_data_policy == :reject &&
65
+ early_data && !SAFE_METHODS.include?(method)
66
+ connection.send_error(stream, 425, "Too Early") if stream.writable?
67
+ return
68
+ end
69
+
70
+ request, body = @adapter.build_request(
71
+ headers,
72
+ remote_address: connection.remote_address,
73
+ remote_port: connection.remote_port,
74
+ transport_context: connection.request_context(stream_id: stream.stream_id)
75
+ )
76
+ request.headers.add("quicsilver-early-data", early_data.to_s)
77
+
78
+ # Wire interim_response so apps can send 103 Early Hints.
79
+ # Falcon mode: app calls request.send_interim_response(103, headers)
80
+ # Rack mode: bridged to rack.early_hints via EarlyHintsMiddleware
81
+ request.interim_response = ->(status, headers) {
82
+ connection.send_informational(stream, status, headers)
83
+ }
84
+
85
+ if body && parser.body && parser.body.size > 0
86
+ parser.body.rewind
87
+ body_data = parser.body.read
88
+ body.write(body_data) unless body_data.empty?
89
+ end
90
+ body&.close_write
91
+
92
+ connection.apply_stream_priority(stream, parser.priority)
93
+
94
+ @request_registry.track(
95
+ stream.stream_id, connection.handle,
96
+ path: headers[":path"] || "/", method: method || "GET"
97
+ )
98
+
99
+ request
100
+ end
101
+
102
+ def send_response(connection, stream, request, response)
103
+ if cancelled_stream?(stream.stream_id, connection.handle)
104
+ Quicsilver.logger.debug("Skipping response for cancelled stream #{stream.stream_id}")
105
+ return
106
+ end
107
+
108
+ raise "Stream handle not found for stream #{stream.stream_id}" unless stream.writable?
109
+
110
+ headers = response.headers
111
+
112
+ # Extract trailers before flattening (Protocol::HTTP::Headers tracks
113
+ # trailer! state that a plain Hash would lose — needed for gRPC).
114
+ trailers = if headers.respond_to?(:trailer?) && headers.trailer?
115
+ trailer_hash = {}
116
+ headers.trailer.each { |name, value| trailer_hash[name] = value }
117
+ trailer_hash
118
+ end
119
+
120
+ # Pairs, not a Hash: a field can legitimately repeat, and set-cookie MUST NOT be combined
121
+ # (RFC 9110 §5.3). Keying by name dropped every repeat but the last, which silently discarded
122
+ # the session cookie of any Rails response that set more than one — sign-in appeared to work
123
+ # and then forgot the request it had just authenticated.
124
+ response_headers = []
125
+ if headers.respond_to?(:header)
126
+ headers.header.each { |name, value| response_headers << [ name, value ] }
127
+ else
128
+ headers&.each { |name, value| response_headers << [ name, value ] }
129
+ end
130
+
131
+ # Protocol-rack moves content-length from headers to body.length —
132
+ # re-add it so the HTTP/3 response includes the header.
133
+ unless response_headers.any? { |name, _| name == "content-length" }
134
+ response_headers << [ "content-length", response.body.length.to_s ] if response.body&.length
135
+ end
136
+
137
+ body = response.body || []
138
+ connection.send_response(stream, response.status, response_headers, body,
139
+ head_request: request.head?, trailers: trailers)
140
+ @request_registry.complete(stream.stream_id, connection.handle)
141
+ connection.remove_stream(stream.stream_id)
142
+ end
143
+
144
+ def cancelled_stream?(stream_id, connection_handle)
145
+ @cancelled_mutex.synchronize { @cancelled_streams.include?([connection_handle, stream_id]) }
146
+ end
147
+ end
148
+ end
149
+ end