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.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yml +9 -1
- data/.github/workflows/cibuildgem.yaml +29 -5
- data/.gitignore +5 -0
- data/CHANGELOG.md +50 -0
- data/Gemfile.lock +8 -1
- data/README.md +128 -15
- data/Rakefile +10 -3
- data/benchmarks/baseline.rb +175 -0
- data/benchmarks/compare.rb +398 -0
- data/benchmarks/helpers.rb +138 -64
- data/benchmarks/latency.rb +128 -0
- data/benchmarks/loss.rb +193 -0
- data/benchmarks/mobile.rb +125 -0
- data/docs/connection_identity.md +141 -0
- data/examples/benchmark.rb +80 -0
- data/examples/connection_pool_demo.rb +1 -1
- data/examples/example_helper.rb +51 -0
- data/examples/feature_demo.rb +7 -7
- data/examples/grpc_style.rb +2 -2
- data/examples/latency_aware_routing.rb +382 -0
- data/examples/lifecycle_drain.rb +264 -0
- data/examples/priorities.rb +2 -2
- data/examples/rails_feature_test.rb +92 -72
- data/examples/server_stats.rb +117 -0
- data/examples/simple_client_test.rb +3 -3
- data/examples/trailers.rb +23 -23
- data/examples/webtransport_smoke.rb +546 -0
- data/examples/worker_load_balancing.rb +425 -0
- data/ext/quicsilver/extconf.rb +100 -15
- data/ext/quicsilver/include/msquic.h +2226 -0
- data/ext/quicsilver/include/msquic_posix.h +696 -0
- data/ext/quicsilver/include/quic_sal_stub.h +314 -0
- data/ext/quicsilver/msquic_patch.rb +68 -0
- data/ext/quicsilver/patches/reliable-reset.patch +723 -0
- data/ext/quicsilver/patches/stream-final-size.patch +47 -0
- data/ext/quicsilver/quicsilver.c +1640 -222
- data/lib/quicsilver/client/client.rb +419 -60
- data/lib/quicsilver/client/connection_pool.rb +122 -26
- data/lib/quicsilver/client/request.rb +106 -14
- data/lib/quicsilver/errors.rb +39 -0
- data/lib/quicsilver/libmsquic.2.dylib +0 -0
- data/lib/quicsilver/protocol/adapter.rb +41 -21
- data/lib/quicsilver/protocol/capsule.rb +38 -0
- data/lib/quicsilver/protocol/connect_stream_decoder.rb +75 -0
- data/lib/quicsilver/protocol/control_stream_parser.rb +12 -1
- data/lib/quicsilver/protocol/datagram.rb +40 -0
- data/lib/quicsilver/protocol/frame_parser.rb +5 -6
- data/lib/quicsilver/protocol/frame_reader.rb +31 -2
- data/lib/quicsilver/protocol/frames.rb +128 -12
- data/lib/quicsilver/protocol/priority.rb +9 -0
- data/lib/quicsilver/protocol/qpack/decoder.rb +4 -42
- data/lib/quicsilver/protocol/qpack/encoder.rb +10 -20
- data/lib/quicsilver/protocol/qpack/huffman.rb +4 -4
- data/lib/quicsilver/protocol/request_encoder.rb +6 -3
- data/lib/quicsilver/protocol/request_parser.rb +16 -2
- data/lib/quicsilver/protocol/response_encoder.rb +18 -8
- data/lib/quicsilver/protocol/response_parser.rb +6 -0
- data/lib/quicsilver/protocol/stream_input.rb +3 -3
- data/lib/quicsilver/protocol/unidirectional_stream.rb +12 -0
- data/lib/quicsilver/protocol/web_transport.rb +214 -0
- data/lib/quicsilver/rack/context.rb +33 -0
- data/lib/quicsilver/response.rb +40 -0
- data/lib/quicsilver/server/rack_adapter.rb +77 -0
- data/lib/quicsilver/server/receive_queue.rb +106 -0
- data/lib/quicsilver/server/request_handler.rb +24 -13
- data/lib/quicsilver/server/request_registry.rb +18 -5
- data/lib/quicsilver/server/scheduler.rb +50 -0
- data/lib/quicsilver/server/schedulers/fiber_scheduler.rb +79 -0
- data/lib/quicsilver/server/schedulers/thread_scheduler.rb +76 -0
- data/lib/quicsilver/server/server.rb +655 -185
- data/lib/quicsilver/server/web_transport_flow_control.rb +270 -0
- data/lib/quicsilver/server/web_transport_manager.rb +281 -0
- data/lib/quicsilver/server/web_transport_registry.rb +40 -0
- data/lib/quicsilver/server/web_transport_session.rb +812 -0
- data/lib/quicsilver/server/web_transport_stream.rb +326 -0
- data/lib/quicsilver/transport/configuration.rb +103 -10
- data/lib/quicsilver/transport/connection.rb +302 -87
- data/lib/quicsilver/transport/connection_stats.rb +53 -0
- data/lib/quicsilver/transport/event_loop.rb +36 -0
- data/lib/quicsilver/transport/inbound_stream.rb +21 -1
- data/lib/quicsilver/transport/stream.rb +18 -3
- data/lib/quicsilver/transport/stream_event.rb +25 -2
- data/lib/quicsilver/transport/stream_id.rb +26 -0
- data/lib/quicsilver/version.rb +1 -1
- data/lib/quicsilver.rb +22 -11
- data/lib/rackup/handler/quicsilver.rb +57 -38
- data/quicsilver.gemspec +10 -3
- metadata +54 -8
- data/benchmarks/concurrent.rb +0 -110
- data/benchmarks/quicsilver_server.rb +0 -46
- data/benchmarks/rails.rb +0 -170
- data/benchmarks/throughput.rb +0 -113
- data/lib/quicsilver/quicsilver.bundle +0 -0
|
@@ -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,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
|
|
@@ -29,14 +29,15 @@ module Quicsilver
|
|
|
29
29
|
Quicsilver.connection_shutdown(connection.handle, e.error_code, false) rescue nil
|
|
30
30
|
rescue Protocol::MessageError => e
|
|
31
31
|
Quicsilver.logger.error("Message error: #{e.message} (0x#{e.error_code.to_s(16)})")
|
|
32
|
-
|
|
32
|
+
stream.reset(e.error_code) if stream.writable?
|
|
33
33
|
rescue => e
|
|
34
34
|
Quicsilver.logger.error("Error handling request: #{e.class} - #{e.message}")
|
|
35
35
|
Quicsilver.logger.debug(e.backtrace.first(5).join("\n"))
|
|
36
36
|
connection.send_error(stream, 500, "Internal Server Error") if stream.writable?
|
|
37
37
|
ensure
|
|
38
|
-
@request_registry.complete(stream.stream_id
|
|
39
|
-
@cancelled_mutex.synchronize { @cancelled_streams.delete(stream.stream_id) }
|
|
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)
|
|
40
41
|
end
|
|
41
42
|
|
|
42
43
|
private
|
|
@@ -66,7 +67,12 @@ module Quicsilver
|
|
|
66
67
|
return
|
|
67
68
|
end
|
|
68
69
|
|
|
69
|
-
request, body = @adapter.build_request(
|
|
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
|
+
)
|
|
70
76
|
request.headers.add("quicsilver-early-data", early_data.to_s)
|
|
71
77
|
|
|
72
78
|
# Wire interim_response so apps can send 103 Early Hints.
|
|
@@ -94,7 +100,7 @@ module Quicsilver
|
|
|
94
100
|
end
|
|
95
101
|
|
|
96
102
|
def send_response(connection, stream, request, response)
|
|
97
|
-
if cancelled_stream?(stream.stream_id)
|
|
103
|
+
if cancelled_stream?(stream.stream_id, connection.handle)
|
|
98
104
|
Quicsilver.logger.debug("Skipping response for cancelled stream #{stream.stream_id}")
|
|
99
105
|
return
|
|
100
106
|
end
|
|
@@ -111,27 +117,32 @@ module Quicsilver
|
|
|
111
117
|
trailer_hash
|
|
112
118
|
end
|
|
113
119
|
|
|
114
|
-
|
|
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 = []
|
|
115
125
|
if headers.respond_to?(:header)
|
|
116
|
-
headers.header.each { |name, value| response_headers[name
|
|
126
|
+
headers.header.each { |name, value| response_headers << [ name, value ] }
|
|
117
127
|
else
|
|
118
|
-
headers&.each { |name, value| response_headers[name
|
|
128
|
+
headers&.each { |name, value| response_headers << [ name, value ] }
|
|
119
129
|
end
|
|
120
130
|
|
|
121
131
|
# Protocol-rack moves content-length from headers to body.length —
|
|
122
132
|
# re-add it so the HTTP/3 response includes the header.
|
|
123
|
-
|
|
124
|
-
response_headers["content-length"
|
|
133
|
+
unless response_headers.any? { |name, _| name == "content-length" }
|
|
134
|
+
response_headers << [ "content-length", response.body.length.to_s ] if response.body&.length
|
|
125
135
|
end
|
|
126
136
|
|
|
127
137
|
body = response.body || []
|
|
128
138
|
connection.send_response(stream, response.status, response_headers, body,
|
|
129
139
|
head_request: request.head?, trailers: trailers)
|
|
130
|
-
@request_registry.complete(stream.stream_id)
|
|
140
|
+
@request_registry.complete(stream.stream_id, connection.handle)
|
|
141
|
+
connection.remove_stream(stream.stream_id)
|
|
131
142
|
end
|
|
132
143
|
|
|
133
|
-
def cancelled_stream?(stream_id)
|
|
134
|
-
@cancelled_mutex.synchronize { @cancelled_streams.include?(stream_id) }
|
|
144
|
+
def cancelled_stream?(stream_id, connection_handle)
|
|
145
|
+
@cancelled_mutex.synchronize { @cancelled_streams.include?([connection_handle, stream_id]) }
|
|
135
146
|
end
|
|
136
147
|
end
|
|
137
148
|
end
|
|
@@ -10,8 +10,9 @@ module Quicsilver
|
|
|
10
10
|
|
|
11
11
|
def track(stream_id, connection_handle, path:, method:, started_at: Time.now)
|
|
12
12
|
@mutex.synchronize do
|
|
13
|
-
@requests[stream_id] = {
|
|
13
|
+
@requests[key_for(stream_id, connection_handle)] = {
|
|
14
14
|
connection_handle: connection_handle,
|
|
15
|
+
stream_id: stream_id,
|
|
15
16
|
path: path,
|
|
16
17
|
method: method,
|
|
17
18
|
started_at: started_at
|
|
@@ -19,8 +20,10 @@ module Quicsilver
|
|
|
19
20
|
end
|
|
20
21
|
end
|
|
21
22
|
|
|
22
|
-
def complete(stream_id)
|
|
23
|
-
@mutex.synchronize
|
|
23
|
+
def complete(stream_id, connection_handle)
|
|
24
|
+
@mutex.synchronize do
|
|
25
|
+
@requests.delete(key_for(stream_id, connection_handle))
|
|
26
|
+
end
|
|
24
27
|
end
|
|
25
28
|
|
|
26
29
|
def active_count
|
|
@@ -42,8 +45,18 @@ module Quicsilver
|
|
|
42
45
|
@mutex.synchronize { @requests.empty? }
|
|
43
46
|
end
|
|
44
47
|
|
|
45
|
-
def include?(stream_id)
|
|
46
|
-
@mutex.synchronize
|
|
48
|
+
def include?(stream_id, connection_handle)
|
|
49
|
+
@mutex.synchronize do
|
|
50
|
+
@requests.key?(key_for(stream_id, connection_handle))
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def key_for(stream_id, connection_handle)
|
|
57
|
+
raise ArgumentError, "Connection handle is required" if connection_handle.nil?
|
|
58
|
+
|
|
59
|
+
[connection_handle, stream_id]
|
|
47
60
|
end
|
|
48
61
|
end
|
|
49
62
|
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Quicsilver
|
|
4
|
+
class Server
|
|
5
|
+
# Base class for work schedulers.
|
|
6
|
+
#
|
|
7
|
+
# A scheduler controls how incoming request work units are executed —
|
|
8
|
+
# via threads, fibers, or any other concurrency model. Subclasses
|
|
9
|
+
# implement the delivery mechanism.
|
|
10
|
+
#
|
|
11
|
+
# Usage:
|
|
12
|
+
# scheduler = ThreadScheduler.new(size: 10, max_queue_size: 100, &handler)
|
|
13
|
+
# scheduler.start # start workers
|
|
14
|
+
# scheduler.enqueue(work) # schedule a work unit
|
|
15
|
+
# scheduler.drain(timeout: 5) # wait for queue to empty
|
|
16
|
+
# scheduler.stop # shut down workers
|
|
17
|
+
#
|
|
18
|
+
class Scheduler
|
|
19
|
+
# Schedule a work unit for execution.
|
|
20
|
+
def enqueue(work)
|
|
21
|
+
raise NotImplementedError
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Is the work queue at capacity?
|
|
25
|
+
def full?
|
|
26
|
+
raise NotImplementedError
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Number of pending work units.
|
|
30
|
+
def pending
|
|
31
|
+
raise NotImplementedError
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Wait for the queue to empty.
|
|
35
|
+
def drain(timeout: 5)
|
|
36
|
+
raise NotImplementedError
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Start processing work units.
|
|
40
|
+
def start
|
|
41
|
+
raise NotImplementedError
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Shut down workers.
|
|
45
|
+
def stop
|
|
46
|
+
raise NotImplementedError
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|