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
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "protocol/http/body/writable"
|
|
4
|
+
require_relative "receive_queue"
|
|
5
|
+
|
|
6
|
+
module Quicsilver
|
|
7
|
+
class Server
|
|
8
|
+
# A reliable bidirectional stream within a WebTransport session.
|
|
9
|
+
#
|
|
10
|
+
# Streams are opened by either the client or server. Data is reliable
|
|
11
|
+
# and ordered — unlike datagrams, nothing is dropped.
|
|
12
|
+
#
|
|
13
|
+
# Usage:
|
|
14
|
+
# session.on_stream do |stream|
|
|
15
|
+
# Thread.new do
|
|
16
|
+
# while (data = stream.read)
|
|
17
|
+
# stream.write("echo: #{data}")
|
|
18
|
+
# end
|
|
19
|
+
# stream.close_write
|
|
20
|
+
# end
|
|
21
|
+
# end
|
|
22
|
+
#
|
|
23
|
+
# # Server-initiated:
|
|
24
|
+
# stream = session.open_stream
|
|
25
|
+
# stream.write("server push")
|
|
26
|
+
# stream.close
|
|
27
|
+
#
|
|
28
|
+
class WebTransportStream
|
|
29
|
+
class ResetError < Quicsilver::Error
|
|
30
|
+
attr_reader :http_error_code, :application_error_code
|
|
31
|
+
|
|
32
|
+
def initialize(http_error_code)
|
|
33
|
+
@http_error_code = http_error_code
|
|
34
|
+
@application_error_code = Protocol::WebTransport.http_to_application_error(http_error_code)
|
|
35
|
+
super("WebTransport stream reset (HTTP/3 code 0x#{http_error_code.to_s(16)})")
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
attr_reader :stream_id, :session
|
|
40
|
+
|
|
41
|
+
def self.validate_overflow_code(code)
|
|
42
|
+
unless code.is_a?(Integer) && code.between?(0, 0xffff_ffff)
|
|
43
|
+
raise ArgumentError, "Receive overflow code must be a 32-bit application error code"
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def self.validate_backpressure(enabled, bytes, chunks)
|
|
48
|
+
unless enabled == true || enabled == false
|
|
49
|
+
raise ArgumentError, "Receive backpressure must be true or false"
|
|
50
|
+
end
|
|
51
|
+
if enabled && [bytes, chunks].any? { |limit| limit > 0xffff_ffff_ffff_ffff }
|
|
52
|
+
raise ArgumentError, "Receive backpressure limits must fit in 64 bits"
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# The receive policy has one home: WebTransportSession#accept! defines
|
|
57
|
+
# the defaults and passes them in, so there is nothing to keep in sync.
|
|
58
|
+
def initialize(session:, stream:, stream_id:, direction: :bidi,
|
|
59
|
+
receive_buffer_bytes:, receive_buffer_chunks:, receive_overflow_code:,
|
|
60
|
+
receive_backpressure:)
|
|
61
|
+
@session = session
|
|
62
|
+
@stream = stream
|
|
63
|
+
@stream_id = stream_id
|
|
64
|
+
# Stream body bytes accepted from the peer, for session accounting.
|
|
65
|
+
@body_received = 0
|
|
66
|
+
@session_stream_closed = false
|
|
67
|
+
@direction = direction
|
|
68
|
+
@read_open = direction != :send_only
|
|
69
|
+
@write_open = direction != :receive_only
|
|
70
|
+
self.class.validate_overflow_code(receive_overflow_code)
|
|
71
|
+
@receive_overflow_error = Protocol::WebTransport.application_error_to_http(receive_overflow_code)
|
|
72
|
+
@receive_queue = ReceiveQueue.new(bytes: receive_buffer_bytes, chunks: receive_buffer_chunks)
|
|
73
|
+
self.class.validate_backpressure(receive_backpressure, receive_buffer_bytes, receive_buffer_chunks)
|
|
74
|
+
@receive_backpressure = receive_backpressure
|
|
75
|
+
@input = ::Protocol::HTTP::Body::Writable.new(queue: @receive_queue)
|
|
76
|
+
@close_callback = nil
|
|
77
|
+
@peer_reset_callback = nil
|
|
78
|
+
@peer_stop_sending_callback = nil
|
|
79
|
+
@close_notified = false
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def stream_handle
|
|
83
|
+
@stream.handle if @stream.respond_to?(:handle)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def notify_start(stream_id)
|
|
87
|
+
@stream_id = stream_id
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def replace_stream_handle(handle)
|
|
91
|
+
@stream = Transport::Stream.new(handle)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def write(data)
|
|
95
|
+
raise "Cannot write to a receive-only stream" if @direction == :receive_only
|
|
96
|
+
raise IOError, "Stream is closed for writing" unless @write_open
|
|
97
|
+
|
|
98
|
+
payload = data.to_s.b
|
|
99
|
+
# Charge the session budget before the bytes leave. Raises SendBlocked
|
|
100
|
+
# when the peer has not granted enough, having told it so (draft-16
|
|
101
|
+
# §5.6.4); nothing is sent in that case.
|
|
102
|
+
@session&.count_sent_data(payload.bytesize)
|
|
103
|
+
@stream.send(payload)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Whether `bytes` can be written now. Writes are all or nothing, so a
|
|
107
|
+
# write past the session's remaining credit raises SendBlocked and sends
|
|
108
|
+
# nothing; check here to avoid that.
|
|
109
|
+
def writable?(bytes = 1)
|
|
110
|
+
return false unless @write_open && @direction != :receive_only
|
|
111
|
+
|
|
112
|
+
@session.nil? || @session.can_send?(bytes)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Finish sending and discard unread input; use close_write to keep reading.
|
|
116
|
+
def close
|
|
117
|
+
close_write
|
|
118
|
+
ensure
|
|
119
|
+
discard_input
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# One application reader; never call from the transport event loop.
|
|
123
|
+
def read
|
|
124
|
+
raise IOError, "Cannot read from a send-only stream" if @direction == :send_only
|
|
125
|
+
|
|
126
|
+
chunk = @input.read
|
|
127
|
+
# Reading frees session window, which is what the peer is granted more
|
|
128
|
+
# against (draft-16 §5.6).
|
|
129
|
+
@session&.count_consumed_data(chunk.bytesize) if chunk
|
|
130
|
+
chunk
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def on_close(&block)
|
|
134
|
+
@close_callback = block
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# The peer reset its sending side (RESET_STREAM): our read side is now
|
|
138
|
+
# closed and `read` raises ResetError. Writing is unaffected.
|
|
139
|
+
#
|
|
140
|
+
# Yields the application error code, or nil when the peer used a code
|
|
141
|
+
# outside the WebTransport range (draft-16 §4.4 — still a reset, just no
|
|
142
|
+
# application code to report).
|
|
143
|
+
def on_peer_reset(&block)
|
|
144
|
+
@peer_reset_callback = block
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# The peer asked us to stop sending (STOP_SENDING): our write side is now
|
|
148
|
+
# closed and `write` raises IOError. Reading is unaffected.
|
|
149
|
+
#
|
|
150
|
+
# Yields the application error code on the same terms as on_peer_reset.
|
|
151
|
+
def on_peer_stop_sending(&block)
|
|
152
|
+
@peer_stop_sending_callback = block
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Reset this stream with an application error code.
|
|
156
|
+
#
|
|
157
|
+
# §4.4: application codes are 32-bit and share the HTTP/3 error space, so
|
|
158
|
+
# they are mapped into the reserved WT_APPLICATION_ERROR range.
|
|
159
|
+
def reset(error_code)
|
|
160
|
+
return unless @write_open || @read_open
|
|
161
|
+
|
|
162
|
+
abort(Protocol::WebTransport.application_error_to_http(error_code))
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def abort(http_error_code)
|
|
166
|
+
@stream.abort(http_error_code)
|
|
167
|
+
ensure
|
|
168
|
+
notify_close(error: ResetError.new(http_error_code))
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def open?
|
|
172
|
+
@read_open || @write_open
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def enable_receive_backpressure
|
|
176
|
+
return unless @receive_backpressure && @read_open
|
|
177
|
+
|
|
178
|
+
@receive_queue.release_capacity_to do |bytes, chunks|
|
|
179
|
+
unless @stream.grant_receive_credit(bytes, chunks)
|
|
180
|
+
abort(@receive_overflow_error) if @read_open
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# Called by Server when data arrives on this stream. :nodoc:
|
|
186
|
+
def receive_data(data)
|
|
187
|
+
return true unless @read_open
|
|
188
|
+
|
|
189
|
+
data ||= "".b
|
|
190
|
+
# Every byte here was sent inside a limit we advertised, so it is
|
|
191
|
+
# accepted (RFC 9000 §4.1). With backpressure on, the pause is that we
|
|
192
|
+
# stop granting native receive credit until the reader drains (§4.2);
|
|
193
|
+
# credit bounds every receive after the first grant, and the first is
|
|
194
|
+
# bounded by the initial stream window from the handshake. With
|
|
195
|
+
# backpressure off there is no pause, only the overflow policy below.
|
|
196
|
+
accepted = data.bytesize
|
|
197
|
+
if accepted.positive?
|
|
198
|
+
@input.write(data)
|
|
199
|
+
# Stream body only; the prefix that links this stream to its session
|
|
200
|
+
# is stripped before we see it and costs no credit (draft-16 §5.4).
|
|
201
|
+
@body_received += accepted
|
|
202
|
+
@session&.count_received_data(accepted)
|
|
203
|
+
end
|
|
204
|
+
return overflow! if !@receive_backpressure && @receive_queue.over_limit?
|
|
205
|
+
|
|
206
|
+
enable_receive_backpressure
|
|
207
|
+
true
|
|
208
|
+
rescue ::Protocol::HTTP::Body::Writable::Closed, ClosedQueueError, ResetError
|
|
209
|
+
# Closing can race the write after the read-open check above.
|
|
210
|
+
raise if @read_open
|
|
211
|
+
|
|
212
|
+
# The read side is already closed, so this data was not consumed and
|
|
213
|
+
# there is no read side left to close. Never report full consumption.
|
|
214
|
+
false
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# receive_backpressure: false. The reader is too far behind and the app
|
|
218
|
+
# chose not to slow the peer, so ask it to stop (RFC 9000 §3.5) and close
|
|
219
|
+
# our read side; the write side stays open so the app can still say why.
|
|
220
|
+
def overflow!
|
|
221
|
+
@stream.stop_sending(@receive_overflow_error)
|
|
222
|
+
false
|
|
223
|
+
ensure
|
|
224
|
+
close_read(ResetError.new(@receive_overflow_error))
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# A FIN only ends the read side once its data is fully consumed; under
|
|
228
|
+
# backpressure a deferred suffix is still outstanding. :nodoc:
|
|
229
|
+
def receive_fin(data)
|
|
230
|
+
notify_read_close if receive_data(data)
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# Called by Server when the peer has closed its write side. :nodoc:
|
|
234
|
+
def notify_read_close
|
|
235
|
+
return unless @read_open
|
|
236
|
+
|
|
237
|
+
@read_open = false
|
|
238
|
+
@input.close_write
|
|
239
|
+
notify_close_callback
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# RESET_STREAM ends our read side; the write side stays open. :nodoc:
|
|
243
|
+
def notify_peer_reset(http_error_code, final_size = nil)
|
|
244
|
+
error = ResetError.new(http_error_code)
|
|
245
|
+
begin
|
|
246
|
+
# The sender counted everything up to the final size, including what
|
|
247
|
+
# never reached us, so the session owes credit for the difference
|
|
248
|
+
# (§5.4).
|
|
249
|
+
@session&.count_reset_stream(final_size, @body_received)
|
|
250
|
+
close_read(error)
|
|
251
|
+
ensure
|
|
252
|
+
@peer_reset_callback&.call(error.application_error_code)
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
# STOP_SENDING ends our write side; MsQuic answers it, and we keep
|
|
257
|
+
# receiving on the read side. :nodoc:
|
|
258
|
+
def notify_peer_stop_sending(http_error_code)
|
|
259
|
+
@write_open = false
|
|
260
|
+
begin
|
|
261
|
+
notify_close_callback unless open?
|
|
262
|
+
ensure
|
|
263
|
+
@peer_stop_sending_callback&.call(Protocol::WebTransport.http_to_application_error(http_error_code))
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
# Called by Server when the stream is reset or fully closed. :nodoc:
|
|
268
|
+
#
|
|
269
|
+
# Under backpressure an errored close also sends STOP_SENDING. abort
|
|
270
|
+
# already requests a transport abort, so this is belt and braces for a
|
|
271
|
+
# stream with paused input; it is not known to be required. It cannot
|
|
272
|
+
# re-enter notify_peer_stop_sending, since our own STOP_SENDING is not
|
|
273
|
+
# delivered back to us as a peer event.
|
|
274
|
+
def notify_close(error: nil)
|
|
275
|
+
if error && @receive_backpressure && @read_open
|
|
276
|
+
@stream.stop_sending(error.http_error_code)
|
|
277
|
+
end
|
|
278
|
+
ensure
|
|
279
|
+
@read_open = false
|
|
280
|
+
@write_open = false
|
|
281
|
+
error ? @input.close(error) : @input.close_write
|
|
282
|
+
notify_close_callback
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
def close_write
|
|
286
|
+
return unless @write_open
|
|
287
|
+
|
|
288
|
+
@stream.send("".b, fin: true)
|
|
289
|
+
@write_open = false
|
|
290
|
+
notify_close_callback unless open?
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
private
|
|
294
|
+
|
|
295
|
+
# Close the read half and fail pending reads, leaving the write half as is.
|
|
296
|
+
def close_read(error)
|
|
297
|
+
@read_open = false
|
|
298
|
+
@input.close(error)
|
|
299
|
+
notify_close_callback unless open?
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
def discard_input
|
|
303
|
+
@stream.stop_sending(Protocol::WebTransport.application_error_to_http(0)) if @read_open
|
|
304
|
+
ensure
|
|
305
|
+
@read_open = false
|
|
306
|
+
@input.close
|
|
307
|
+
notify_close_callback
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
def notify_session_stream_closed
|
|
311
|
+
return if @session_stream_closed
|
|
312
|
+
|
|
313
|
+
@session_stream_closed = true
|
|
314
|
+
@session&.count_closed_stream(@direction == :bidi ? :bidi : :uni)
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
def notify_close_callback
|
|
318
|
+
return if @close_notified
|
|
319
|
+
|
|
320
|
+
@close_notified = true
|
|
321
|
+
notify_session_stream_closed
|
|
322
|
+
@close_callback&.call
|
|
323
|
+
end
|
|
324
|
+
end
|
|
325
|
+
end
|
|
326
|
+
end
|
|
@@ -3,13 +3,31 @@
|
|
|
3
3
|
module Quicsilver
|
|
4
4
|
module Transport
|
|
5
5
|
class Configuration
|
|
6
|
+
# True when max_concurrent_requests is our default rather than the
|
|
7
|
+
# application's choice. :nodoc:
|
|
8
|
+
def max_concurrent_requests_default? = @max_concurrent_requests_default
|
|
9
|
+
|
|
10
|
+
# Advertise a stream limit the server can actually serve. RFC 9114 A.1:
|
|
11
|
+
# "In contrast to HTTP/2, stream concurrency in HTTP/3 is managed by
|
|
12
|
+
# QUIC." Advertising more than we can hold means a peer opens streams we
|
|
13
|
+
# then reject with 503, which is a refusal for doing exactly what we
|
|
14
|
+
# permitted. :nodoc:
|
|
15
|
+
def limit_concurrent_requests!(capacity)
|
|
16
|
+
return unless @max_concurrent_requests_default
|
|
17
|
+
return if capacity >= @max_concurrent_requests
|
|
18
|
+
|
|
19
|
+
@max_concurrent_requests = capacity
|
|
20
|
+
end
|
|
21
|
+
|
|
6
22
|
attr_reader :cert_file, :key_file, :idle_timeout_ms, :server_resumption_level, :max_concurrent_requests,
|
|
7
23
|
:max_unidirectional_streams, :stream_receive_window, :stream_receive_buffer, :connection_flow_control_window,
|
|
8
24
|
:pacing_enabled, :send_buffering_enabled, :initial_rtt_ms, :initial_window_packets, :max_ack_delay_ms,
|
|
9
25
|
:keep_alive_interval_ms, :congestion_control_algorithm, :migration_enabled,
|
|
10
26
|
:disconnect_timeout_ms, :handshake_idle_timeout_ms,
|
|
11
27
|
:max_body_size, :max_header_size, :max_header_count, :max_frame_payload_size,
|
|
12
|
-
:early_data_policy
|
|
28
|
+
:early_data_policy,
|
|
29
|
+
:cibir_id, :transport_server_id,
|
|
30
|
+
:mode
|
|
13
31
|
|
|
14
32
|
QUIC_SERVER_RESUME_AND_ZERORTT = 1
|
|
15
33
|
QUIC_SERVER_RESUME_ONLY = 2
|
|
@@ -20,8 +38,6 @@ module Quicsilver
|
|
|
20
38
|
CONGESTION_CONTROL_CUBIC = 0
|
|
21
39
|
CONGESTION_CONTROL_BBR = 1
|
|
22
40
|
|
|
23
|
-
DEFAULT_CERT_FILE = "certificates/server.crt"
|
|
24
|
-
DEFAULT_KEY_FILE = "certificates/server.key"
|
|
25
41
|
DEFAULT_ALPN = "h3"
|
|
26
42
|
|
|
27
43
|
# Flow control defaults — cross-referenced with quiche, quic-go, lsquic, RFC 9000
|
|
@@ -37,6 +53,13 @@ module Quicsilver
|
|
|
37
53
|
DEFAULT_INITIAL_WINDOW_PACKETS = 10 # Matches RFC 9002 recommendation
|
|
38
54
|
DEFAULT_MAX_ACK_DELAY_MS = 25 # Matches RFC 9000 default
|
|
39
55
|
|
|
56
|
+
# HTTP/3 parser limits — prevent OOM from malicious clients
|
|
57
|
+
DEFAULT_MAX_BODY_SIZE = 10 * 1024 * 1024 # 10MB — matches common reverse proxy defaults
|
|
58
|
+
DEFAULT_MAX_HEADER_SIZE = 65_536 # 64KB — matches Envoy/nginx defaults
|
|
59
|
+
DEFAULT_MAX_HEADER_COUNT = 100 # Matches nginx default
|
|
60
|
+
DEFAULT_MAX_FRAME_PAYLOAD_SIZE = 1_048_576 # 1MB — single frame can't exceed this
|
|
61
|
+
|
|
62
|
+
|
|
40
63
|
# Connection management defaults
|
|
41
64
|
DEFAULT_KEEP_ALIVE_INTERVAL_MS = 0 # 0 = disabled. Set to 20000 for NAT traversal
|
|
42
65
|
DEFAULT_CONGESTION_CONTROL_ALGORITHM = CONGESTION_CONTROL_CUBIC # CUBIC (0) or BBR (1)
|
|
@@ -44,10 +67,26 @@ module Quicsilver
|
|
|
44
67
|
DEFAULT_DISCONNECT_TIMEOUT_MS = 16_000 # How long to wait for ACK before path declared dead
|
|
45
68
|
DEFAULT_HANDSHAKE_IDLE_TIMEOUT_MS = 10_000 # Handshake timeout (separate from connection idle)
|
|
46
69
|
|
|
70
|
+
# Matches MsQuic and quic-go. A handler slower than this loses its
|
|
71
|
+
# response: the lower of the two endpoints' values applies.
|
|
72
|
+
DEFAULT_IDLE_TIMEOUT_MS = 30_000
|
|
73
|
+
|
|
47
74
|
def initialize(cert_file = nil, key_file = nil, options = {})
|
|
48
|
-
@idle_timeout_ms = options.fetch(:idle_timeout_ms,
|
|
75
|
+
@idle_timeout_ms = options.fetch(:idle_timeout_ms, DEFAULT_IDLE_TIMEOUT_MS)
|
|
49
76
|
@server_resumption_level = options.fetch(:server_resumption_level, QUIC_SERVER_RESUME_AND_ZERORTT)
|
|
77
|
+
# Maximum concurrent bidirectional streams the peer can open.
|
|
78
|
+
# Matches quic-go and Chromium defaults. MsQuic ceiling: 65,535.
|
|
50
79
|
@max_concurrent_requests = options.fetch(:max_concurrent_requests, 100)
|
|
80
|
+
# Whether the application chose this, or it is our default. A server
|
|
81
|
+
# that knows its own capacity lowers the default so the limit it
|
|
82
|
+
# advertises is one it can honour; an explicit value is never touched.
|
|
83
|
+
@max_concurrent_requests_default = !options.key?(:max_concurrent_requests)
|
|
84
|
+
|
|
85
|
+
# Maximum concurrent unidirectional streams the peer can open.
|
|
86
|
+
# HTTP/3 requires 3 (control, QPACK encoder, QPACK decoder).
|
|
87
|
+
# Default of 10 provides headroom for GREASE and server push.
|
|
88
|
+
# Bump to 100 for WebTransport (opens many uni streams for
|
|
89
|
+
# server-initiated data channels) or MASQUE proxy tunnels.
|
|
51
90
|
@max_unidirectional_streams = options.fetch(:max_unidirectional_streams, 10)
|
|
52
91
|
@alpn = options.fetch(:alpn, DEFAULT_ALPN)
|
|
53
92
|
|
|
@@ -70,11 +109,13 @@ module Quicsilver
|
|
|
70
109
|
@disconnect_timeout_ms = options.fetch(:disconnect_timeout_ms, DEFAULT_DISCONNECT_TIMEOUT_MS)
|
|
71
110
|
@handshake_idle_timeout_ms = options.fetch(:handshake_idle_timeout_ms, DEFAULT_HANDSHAKE_IDLE_TIMEOUT_MS)
|
|
72
111
|
|
|
73
|
-
# HTTP/3 parser limits
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
@
|
|
77
|
-
@
|
|
112
|
+
# HTTP/3 parser limits — sensible defaults prevent OOM from malicious clients.
|
|
113
|
+
# RFC 9114 §4.2.2: SETTINGS_MAX_FIELD_SECTION_SIZE limits header block size.
|
|
114
|
+
# Override with nil to disable (not recommended in production).
|
|
115
|
+
@max_body_size = options.fetch(:max_body_size, DEFAULT_MAX_BODY_SIZE)
|
|
116
|
+
@max_header_size = options.fetch(:max_header_size, DEFAULT_MAX_HEADER_SIZE)
|
|
117
|
+
@max_header_count = options.fetch(:max_header_count, DEFAULT_MAX_HEADER_COUNT)
|
|
118
|
+
@max_frame_payload_size = options.fetch(:max_frame_payload_size, DEFAULT_MAX_FRAME_PAYLOAD_SIZE)
|
|
78
119
|
|
|
79
120
|
# 0-RTT early data policy (RFC 8470)
|
|
80
121
|
# :reject (default) — send 425 Too Early for unsafe methods on 0-RTT
|
|
@@ -84,8 +125,27 @@ module Quicsilver
|
|
|
84
125
|
raise ServerConfigurationError, "Invalid early_data_policy: #{@early_data_policy.inspect} (must be :reject or :allow)"
|
|
85
126
|
end
|
|
86
127
|
|
|
87
|
-
|
|
88
|
-
|
|
128
|
+
# MsQuic CIBIR (Connection ID Based Implicit Routing). Applications own
|
|
129
|
+
# the meaning of these bytes; Quicsilver validates and passes them to MsQuic.
|
|
130
|
+
# MsQuic currently supports CIBIR at offset 0 only, so offset is not configurable.
|
|
131
|
+
@cibir_id = normalize_cibir_id(options[:cibir_id])
|
|
132
|
+
|
|
133
|
+
# MsQuic fixed Server ID for QUIC-aware load balancers. This is
|
|
134
|
+
# process-global MsQuic state; Server applies it before opening MsQuic.
|
|
135
|
+
@transport_server_id = normalize_transport_server_id(options[:transport_server_id])
|
|
136
|
+
|
|
137
|
+
# Application interface mode:
|
|
138
|
+
# :rack (default) — app is a Rack app, auto-wrapped with Protocol::Rack::Adapter
|
|
139
|
+
# :falcon — app is a native protocol-http app, used directly
|
|
140
|
+
@mode = options.fetch(:mode, :rack)
|
|
141
|
+
unless %i[rack falcon].include?(@mode)
|
|
142
|
+
raise ServerConfigurationError, "Invalid mode: #{@mode.inspect} (must be :rack or :falcon)"
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
validate_certificate_paths!(cert_file, key_file)
|
|
146
|
+
|
|
147
|
+
@cert_file = cert_file
|
|
148
|
+
@key_file = key_file
|
|
89
149
|
|
|
90
150
|
unless File.exist?(@cert_file)
|
|
91
151
|
raise ServerConfigurationError, "Certificate file not found: #{@cert_file}"
|
|
@@ -103,6 +163,10 @@ module Quicsilver
|
|
|
103
163
|
@alpn
|
|
104
164
|
end
|
|
105
165
|
|
|
166
|
+
def cibir_bytes
|
|
167
|
+
[@cibir_id].pack("H*") if @cibir_id
|
|
168
|
+
end
|
|
169
|
+
|
|
106
170
|
def to_h
|
|
107
171
|
{
|
|
108
172
|
cert_file: @cert_file,
|
|
@@ -127,6 +191,44 @@ module Quicsilver
|
|
|
127
191
|
handshake_idle_timeout_ms: @handshake_idle_timeout_ms
|
|
128
192
|
}
|
|
129
193
|
end
|
|
194
|
+
|
|
195
|
+
private
|
|
196
|
+
HEX = /\A[[:xdigit:]]+\z/
|
|
197
|
+
TRANSPORT_SERVER_ID = /\A[[:xdigit:]]{8}\z/
|
|
198
|
+
|
|
199
|
+
def normalize_cibir_id(value)
|
|
200
|
+
return if value.nil?
|
|
201
|
+
|
|
202
|
+
cibir_id = value.to_s
|
|
203
|
+
if cibir_id.empty? || cibir_id.bytesize.odd? || cibir_id.bytesize > 12 || !cibir_id.match?(HEX)
|
|
204
|
+
raise ServerConfigurationError, "cibir_id must be 1..6 bytes encoded as an even-length hex string"
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
cibir_id.downcase
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def normalize_transport_server_id(value)
|
|
211
|
+
return if value.nil?
|
|
212
|
+
|
|
213
|
+
unless value.is_a?(String) && value.match?(TRANSPORT_SERVER_ID)
|
|
214
|
+
raise ServerConfigurationError, "transport_server_id must be exactly 4 bytes encoded as an 8-character hex string"
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
value.downcase
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def validate_certificate_paths!(cert_file, key_file)
|
|
221
|
+
cert_file_missing = cert_file.to_s.empty?
|
|
222
|
+
key_file_missing = key_file.to_s.empty?
|
|
223
|
+
|
|
224
|
+
if cert_file_missing && key_file_missing
|
|
225
|
+
raise ServerConfigurationError, "cert_file and key_file are required"
|
|
226
|
+
elsif cert_file_missing
|
|
227
|
+
raise ServerConfigurationError, "cert_file is required when key_file is provided"
|
|
228
|
+
elsif key_file_missing
|
|
229
|
+
raise ServerConfigurationError, "key_file is required when cert_file is provided"
|
|
230
|
+
end
|
|
231
|
+
end
|
|
130
232
|
end
|
|
131
233
|
end
|
|
132
234
|
end
|