quicsilver 0.3.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 +117 -0
- data/.gitignore +8 -1
- data/CHANGELOG.md +82 -0
- data/Gemfile.lock +27 -2
- data/README.md +205 -29
- data/Rakefile +75 -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/README.md +44 -91
- data/examples/benchmark.rb +191 -0
- data/examples/connection_pool_demo.rb +47 -0
- data/examples/example_helper.rb +69 -0
- data/examples/falcon_middleware.rb +44 -0
- data/examples/feature_demo.rb +125 -0
- data/examples/grpc_style.rb +97 -0
- data/examples/latency_aware_routing.rb +382 -0
- data/examples/lifecycle_drain.rb +264 -0
- data/examples/minimal_http3_server.rb +6 -18
- data/examples/priorities.rb +60 -0
- data/examples/protocol_http_server.rb +31 -0
- data/examples/rack_http3_server.rb +8 -20
- data/examples/rails_feature_test.rb +280 -0
- data/examples/server_stats.rb +117 -0
- data/examples/simple_client_test.rb +5 -5
- data/examples/streaming_sse.rb +33 -0
- data/examples/trailers.rb +69 -0
- data/examples/webtransport_smoke.rb +546 -0
- data/examples/worker_load_balancing.rb +425 -0
- data/ext/quicsilver/extconf.rb +111 -12
- 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 +1646 -189
- data/lib/quicsilver/client/client.rb +546 -88
- data/lib/quicsilver/client/connection_pool.rb +202 -0
- 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 +196 -0
- 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 +117 -0
- data/lib/quicsilver/protocol/datagram.rb +40 -0
- data/lib/quicsilver/protocol/frame_parser.rb +141 -0
- data/lib/quicsilver/protocol/frame_reader.rb +84 -0
- data/lib/quicsilver/protocol/frames.rb +137 -10
- data/lib/quicsilver/protocol/priority.rb +65 -0
- data/lib/quicsilver/protocol/qpack/decoder.rb +4 -42
- data/lib/quicsilver/protocol/qpack/encoder.rb +49 -21
- data/lib/quicsilver/protocol/qpack/header_block_decoder.rb +16 -1
- 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 +43 -141
- data/lib/quicsilver/protocol/response_encoder.rb +40 -5
- data/lib/quicsilver/protocol/response_parser.rb +26 -128
- data/lib/quicsilver/protocol/stream_input.rb +98 -0
- data/lib/quicsilver/protocol/stream_output.rb +59 -0
- 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 +111 -48
- 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 +848 -104
- 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 +113 -11
- data/lib/quicsilver/transport/connection.rb +376 -132
- 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 +45 -11
- data/lib/rackup/handler/quicsilver.rb +57 -38
- data/quicsilver.gemspec +17 -2
- metadata +120 -10
- 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/examples/setup_certs.sh +0 -57
|
@@ -1,39 +1,34 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
require_relative "qpack/header_block_decoder"
|
|
3
|
+
require_relative "frame_parser"
|
|
5
4
|
|
|
6
5
|
module Quicsilver
|
|
7
6
|
module Protocol
|
|
8
|
-
class ResponseParser
|
|
9
|
-
attr_reader :
|
|
7
|
+
class ResponseParser < FrameParser
|
|
8
|
+
attr_reader :status
|
|
10
9
|
|
|
11
|
-
|
|
12
|
-
|
|
10
|
+
# Validate response semantics per RFC 9114 §4.3.2.
|
|
11
|
+
# Call after parse to check :status is present.
|
|
12
|
+
def validate_response!
|
|
13
|
+
raise Protocol::MessageError, "Response missing required :status pseudo-header" unless @status
|
|
13
14
|
end
|
|
14
15
|
|
|
15
|
-
DEFAULT_DECODER = Qpack::HeaderBlockDecoder.default
|
|
16
|
-
|
|
17
16
|
def initialize(data, **opts)
|
|
17
|
+
decoder = opts.delete(:decoder) || DEFAULT_DECODER
|
|
18
|
+
super(decoder: decoder, max_body_size: opts[:max_body_size],
|
|
19
|
+
max_header_size: opts[:max_header_size])
|
|
18
20
|
@data = data
|
|
19
|
-
|
|
20
|
-
@decoder = DEFAULT_DECODER
|
|
21
|
-
@use_parse_cache = true
|
|
22
|
-
else
|
|
23
|
-
@decoder = opts[:decoder] || DEFAULT_DECODER
|
|
24
|
-
@max_body_size = opts[:max_body_size]
|
|
25
|
-
@max_header_size = opts[:max_header_size]
|
|
26
|
-
@use_parse_cache = @decoder.equal?(DEFAULT_DECODER) && !@max_body_size && !@max_header_size
|
|
27
|
-
end
|
|
21
|
+
@use_parse_cache = @decoder.equal?(DEFAULT_DECODER) && !@max_body_size && !@max_header_size
|
|
28
22
|
end
|
|
29
23
|
|
|
30
24
|
# Reset parser with new data for object reuse (avoids allocation overhead)
|
|
31
25
|
def reset(data)
|
|
32
26
|
@data = data
|
|
33
27
|
@status = nil
|
|
34
|
-
@headers =
|
|
28
|
+
@headers = {}
|
|
29
|
+
@trailers = {}
|
|
35
30
|
@frames = nil
|
|
36
|
-
@
|
|
31
|
+
@body = nil
|
|
37
32
|
@cached_body_str = nil
|
|
38
33
|
end
|
|
39
34
|
|
|
@@ -55,35 +50,22 @@ module Quicsilver
|
|
|
55
50
|
@headers = cached[1]
|
|
56
51
|
@frames = cached[2]
|
|
57
52
|
@cached_body_str = cached[3]
|
|
53
|
+
@trailers = cached[4] || {}
|
|
58
54
|
@last_data = data
|
|
59
55
|
return
|
|
60
56
|
end
|
|
61
57
|
end
|
|
62
58
|
@status = nil
|
|
63
|
-
@headers =
|
|
59
|
+
@headers = {}
|
|
60
|
+
@trailers = {}
|
|
64
61
|
@frames = nil
|
|
65
|
-
@
|
|
62
|
+
@body = nil
|
|
66
63
|
@cached_body_str = nil
|
|
67
64
|
parse!
|
|
68
65
|
cache_result if @use_parse_cache
|
|
69
66
|
end
|
|
70
67
|
|
|
71
|
-
|
|
72
|
-
if @body_io
|
|
73
|
-
@body_io.rewind
|
|
74
|
-
@body_io
|
|
75
|
-
elsif @cached_body_str
|
|
76
|
-
@body_io = StringIO.new(@cached_body_str)
|
|
77
|
-
@body_io.set_encoding(Encoding::ASCII_8BIT)
|
|
78
|
-
@body_io
|
|
79
|
-
else
|
|
80
|
-
EMPTY_BODY
|
|
81
|
-
end
|
|
82
|
-
end
|
|
83
|
-
|
|
84
|
-
EMPTY_BODY = StringIO.new("".b).tap { |io| io.set_encoding(Encoding::ASCII_8BIT) }
|
|
85
|
-
|
|
86
|
-
# Class-level parse result cache: data → [status, headers, frames, body_str]
|
|
68
|
+
# Class-level parse result cache: data → [status, headers, frames, body_str, trailers]
|
|
87
69
|
PARSE_CACHE = {}
|
|
88
70
|
PARSE_CACHE_MAX = 128
|
|
89
71
|
PARSE_OID_CACHE = {}
|
|
@@ -98,6 +80,7 @@ module Quicsilver
|
|
|
98
80
|
@headers = cached[1]
|
|
99
81
|
@frames = cached[2]
|
|
100
82
|
@cached_body_str = cached[3]
|
|
83
|
+
@trailers = cached[4] || {}
|
|
101
84
|
return
|
|
102
85
|
end
|
|
103
86
|
end
|
|
@@ -108,10 +91,10 @@ module Quicsilver
|
|
|
108
91
|
|
|
109
92
|
private def cache_result
|
|
110
93
|
if PARSE_CACHE.size < PARSE_CACHE_MAX && @data.bytesize <= 1024
|
|
111
|
-
body_str = if @
|
|
112
|
-
@
|
|
113
|
-
s = @
|
|
114
|
-
@
|
|
94
|
+
body_str = if @body
|
|
95
|
+
@body.rewind
|
|
96
|
+
s = @body.read
|
|
97
|
+
@body.rewind
|
|
115
98
|
s
|
|
116
99
|
end
|
|
117
100
|
key = @data.frozen? ? @data : @data.dup.freeze
|
|
@@ -119,82 +102,14 @@ module Quicsilver
|
|
|
119
102
|
@status,
|
|
120
103
|
@headers.dup.freeze,
|
|
121
104
|
(@frames || []).freeze,
|
|
122
|
-
body_str&.freeze
|
|
105
|
+
body_str&.freeze,
|
|
106
|
+
(@trailers.empty? ? nil : @trailers.dup.freeze)
|
|
123
107
|
].freeze
|
|
124
108
|
end
|
|
125
109
|
end
|
|
126
110
|
|
|
127
111
|
private
|
|
128
112
|
|
|
129
|
-
# Frame types forbidden on request streams — O(1) lookup
|
|
130
|
-
CONTROL_ONLY_SET = Protocol::CONTROL_ONLY_FRAMES.each_with_object({}) { |f, h| h[f] = true }.freeze
|
|
131
|
-
|
|
132
|
-
def parse!
|
|
133
|
-
@headers = {}
|
|
134
|
-
@status = nil
|
|
135
|
-
@body_io = nil
|
|
136
|
-
@frames = nil
|
|
137
|
-
buffer = @data
|
|
138
|
-
offset = 0
|
|
139
|
-
headers_received = false
|
|
140
|
-
buf_size = buffer.bytesize
|
|
141
|
-
|
|
142
|
-
while offset < buf_size
|
|
143
|
-
break if buf_size - offset < 2
|
|
144
|
-
|
|
145
|
-
# Inline single-byte varint fast path (covers frame types 0x00-0x3F)
|
|
146
|
-
type_byte = buffer.getbyte(offset)
|
|
147
|
-
if type_byte < 0x40
|
|
148
|
-
type = type_byte
|
|
149
|
-
type_len = 1
|
|
150
|
-
else
|
|
151
|
-
type, type_len = Protocol.decode_varint_str(buffer, offset)
|
|
152
|
-
end
|
|
153
|
-
|
|
154
|
-
len_byte = buffer.getbyte(offset + type_len)
|
|
155
|
-
if len_byte < 0x40
|
|
156
|
-
length = len_byte
|
|
157
|
-
length_len = 1
|
|
158
|
-
else
|
|
159
|
-
length, length_len = Protocol.decode_varint_str(buffer, offset + type_len)
|
|
160
|
-
break if length_len == 0
|
|
161
|
-
end
|
|
162
|
-
break if type_len == 0
|
|
163
|
-
|
|
164
|
-
header_len = type_len + length_len
|
|
165
|
-
|
|
166
|
-
break if buf_size < offset + header_len + length
|
|
167
|
-
|
|
168
|
-
payload = buffer.byteslice(offset + header_len, length)
|
|
169
|
-
(@frames ||= []) << { type: type, length: length, payload: payload }
|
|
170
|
-
|
|
171
|
-
if CONTROL_ONLY_SET.key?(type)
|
|
172
|
-
raise Protocol::FrameError, "Frame type 0x#{type.to_s(16)} not allowed on request streams"
|
|
173
|
-
end
|
|
174
|
-
|
|
175
|
-
case type
|
|
176
|
-
when 0x01 # HEADERS
|
|
177
|
-
if @max_header_size && length > @max_header_size
|
|
178
|
-
raise Protocol::MessageError, "Header block #{length} exceeds limit #{@max_header_size}"
|
|
179
|
-
end
|
|
180
|
-
parse_headers(payload)
|
|
181
|
-
headers_received = true
|
|
182
|
-
when 0x00 # DATA
|
|
183
|
-
raise Protocol::FrameError, "DATA frame before HEADERS" unless headers_received
|
|
184
|
-
unless @body_io
|
|
185
|
-
@body_io = StringIO.new
|
|
186
|
-
@body_io.set_encoding(Encoding::ASCII_8BIT)
|
|
187
|
-
end
|
|
188
|
-
@body_io.write(payload)
|
|
189
|
-
if @max_body_size && @body_io.size > @max_body_size
|
|
190
|
-
raise Protocol::MessageError, "Body size #{@body_io.size} exceeds limit #{@max_body_size}"
|
|
191
|
-
end
|
|
192
|
-
end
|
|
193
|
-
|
|
194
|
-
offset += header_len + length
|
|
195
|
-
end
|
|
196
|
-
end
|
|
197
|
-
|
|
198
113
|
# Cache for validated response header results
|
|
199
114
|
HEADERS_CACHE = {}
|
|
200
115
|
HEADERS_CACHE_MAX = 256
|
|
@@ -227,23 +142,6 @@ module Quicsilver
|
|
|
227
142
|
HEADERS_CACHE[key] = { status: @status, headers: @headers.dup.freeze }.freeze
|
|
228
143
|
end
|
|
229
144
|
end
|
|
230
|
-
|
|
231
|
-
# RFC 9110 §5.3: Combine duplicate header values.
|
|
232
|
-
# - set-cookie: join with "\n" (Rack convention, MUST NOT combine with comma)
|
|
233
|
-
# - cookie: join with "; " (RFC 9114 §4.2.1)
|
|
234
|
-
# - all others: join with ", "
|
|
235
|
-
def store_header(name, value)
|
|
236
|
-
if @headers.key?(name)
|
|
237
|
-
separator = case name
|
|
238
|
-
when "set-cookie" then "\n"
|
|
239
|
-
when "cookie" then "; "
|
|
240
|
-
else ", "
|
|
241
|
-
end
|
|
242
|
-
@headers[name] = "#{@headers[name]}#{separator}#{value}"
|
|
243
|
-
else
|
|
244
|
-
@headers[name] = value
|
|
245
|
-
end
|
|
246
|
-
end
|
|
247
145
|
end
|
|
248
146
|
end
|
|
249
147
|
end
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "protocol/http/body/writable"
|
|
4
|
+
|
|
5
|
+
module Quicsilver
|
|
6
|
+
module Protocol
|
|
7
|
+
# A streaming request body backed by Protocol::HTTP::Body::Writable.
|
|
8
|
+
#
|
|
9
|
+
# QUIC RECEIVE events push chunks via {write}, while the application
|
|
10
|
+
# reads them via {read}. This enables concurrent streaming — the app
|
|
11
|
+
# can start processing before the full body arrives.
|
|
12
|
+
#
|
|
13
|
+
# Follows the protocol-http Body::Writable contract:
|
|
14
|
+
# - write(chunk) — called by the QUIC transport on RECEIVE events
|
|
15
|
+
# - close_write — called on RECEIVE_FIN to signal end of body
|
|
16
|
+
# - read — called by the application (blocks until data available)
|
|
17
|
+
# - close — called to abort (e.g., stream reset)
|
|
18
|
+
#
|
|
19
|
+
# Optional features:
|
|
20
|
+
# - Back-pressure via Thread::SizedQueue (bounded buffer)
|
|
21
|
+
# - Read timeout for slow client protection
|
|
22
|
+
#
|
|
23
|
+
class StreamInput < ::Protocol::HTTP::Body::Writable
|
|
24
|
+
class ReadTimeout < Quicsilver::Error; end
|
|
25
|
+
|
|
26
|
+
# @param length [Integer, nil] The content-length if known from headers.
|
|
27
|
+
# @param queue_size [Integer, nil] Maximum buffered chunks for back-pressure.
|
|
28
|
+
# nil (default) = unbounded. When bounded, write blocks if queue is full.
|
|
29
|
+
# A bounded queue must not block the MsQuic poll thread.
|
|
30
|
+
# @param read_timeout [Numeric, nil] Seconds to wait for data before raising
|
|
31
|
+
# ReadTimeout. nil (default) = wait forever.
|
|
32
|
+
def initialize(length = nil, queue_size: nil, read_timeout: nil)
|
|
33
|
+
queue = if queue_size
|
|
34
|
+
Thread::SizedQueue.new(queue_size)
|
|
35
|
+
else
|
|
36
|
+
Thread::Queue.new
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
super(length, queue: queue)
|
|
40
|
+
@read_timeout = read_timeout
|
|
41
|
+
@bytes_written = 0
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# @attribute [Numeric, nil] Read timeout in seconds.
|
|
45
|
+
attr_reader :read_timeout
|
|
46
|
+
|
|
47
|
+
# Track bytes written for content-length validation.
|
|
48
|
+
def write(chunk)
|
|
49
|
+
@bytes_written += chunk.bytesize
|
|
50
|
+
super
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Signal that no more data will be written.
|
|
54
|
+
# Validates content-length if declared (RFC 9114 §4.1.2) — raises
|
|
55
|
+
# MessageError if total bytes written don't match.
|
|
56
|
+
def close_write(error = nil)
|
|
57
|
+
if @length && @bytes_written != @length
|
|
58
|
+
raise Protocol::MessageError, "Content-length mismatch: header=#{@length}, body=#{@bytes_written}"
|
|
59
|
+
end
|
|
60
|
+
super
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Read the next available chunk, with optional timeout.
|
|
64
|
+
#
|
|
65
|
+
# @returns [String | Nil] The next chunk, or nil if the body is finished.
|
|
66
|
+
# @raises [ReadTimeout] If no data arrives within the timeout.
|
|
67
|
+
# @raises [Exception] If the body was closed due to an error.
|
|
68
|
+
def read
|
|
69
|
+
if @read_timeout
|
|
70
|
+
read_with_timeout
|
|
71
|
+
else
|
|
72
|
+
super
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
private
|
|
77
|
+
|
|
78
|
+
def read_with_timeout
|
|
79
|
+
raise @error if @error
|
|
80
|
+
|
|
81
|
+
# Thread::Queue#pop(timeout:) blocks efficiently (no busy-wait).
|
|
82
|
+
# Returns nil on timeout OR on closed empty queue — disambiguate below.
|
|
83
|
+
chunk = @queue.pop(timeout: @read_timeout)
|
|
84
|
+
|
|
85
|
+
raise @error if @error
|
|
86
|
+
|
|
87
|
+
# nil means either timeout or body finished (queue closed + empty).
|
|
88
|
+
# If queue is closed, the body is done — return nil (EOF).
|
|
89
|
+
# If queue is still open, we timed out waiting for data.
|
|
90
|
+
if chunk.nil? && !@queue.closed?
|
|
91
|
+
raise ReadTimeout, "No data received within #{@read_timeout}s"
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
chunk
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
module Quicsilver
|
|
5
|
+
module Protocol
|
|
6
|
+
# Reads from a Protocol::HTTP::Body::Readable response body and writes
|
|
7
|
+
# HTTP/3 DATA frames to the transport.
|
|
8
|
+
#
|
|
9
|
+
# Transport-agnostic: takes a writer block/callable that handles the actual
|
|
10
|
+
# sending. Works with msquic, kernel QUIC sockets, or any transport that
|
|
11
|
+
# can send bytes with a FIN flag.
|
|
12
|
+
#
|
|
13
|
+
class StreamOutput
|
|
14
|
+
# @param body [Protocol::HTTP::Body::Readable] The response body to stream.
|
|
15
|
+
# @param writer [#call] A callable that accepts (data, fin) — sends bytes
|
|
16
|
+
# to the transport. `fin: true` signals end of stream.
|
|
17
|
+
def initialize(body, &writer)
|
|
18
|
+
@body = body
|
|
19
|
+
@writer = writer
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Stream all chunks from the response body as HTTP/3 DATA frames.
|
|
23
|
+
#
|
|
24
|
+
# Each chunk is wrapped in an HTTP/3 DATA frame (type 0x00) and sent
|
|
25
|
+
# via the writer. The final chunk is sent with fin=true.
|
|
26
|
+
#
|
|
27
|
+
# @param send_fin [Boolean] Whether to send FIN after the last chunk.
|
|
28
|
+
# Set to false when trailers will follow.
|
|
29
|
+
# @return [void]
|
|
30
|
+
def stream(send_fin: true)
|
|
31
|
+
last_chunk = nil
|
|
32
|
+
|
|
33
|
+
while (chunk = @body.read)
|
|
34
|
+
if last_chunk
|
|
35
|
+
@writer.call(build_data_frame(last_chunk), false)
|
|
36
|
+
end
|
|
37
|
+
last_chunk = chunk
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
if last_chunk
|
|
41
|
+
@writer.call(build_data_frame(last_chunk), send_fin)
|
|
42
|
+
elsif send_fin
|
|
43
|
+
@writer.call("".b, true)
|
|
44
|
+
end
|
|
45
|
+
ensure
|
|
46
|
+
@body.close if @body.respond_to?(:close)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def build_data_frame(payload)
|
|
52
|
+
payload = payload.b
|
|
53
|
+
Quicsilver::Protocol.encode_varint(Quicsilver::Protocol::FRAME_DATA) +
|
|
54
|
+
Quicsilver::Protocol.encode_varint(payload.bytesize) +
|
|
55
|
+
payload
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
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
|