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,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
|
|
@@ -3,18 +3,46 @@
|
|
|
3
3
|
module Quicsilver
|
|
4
4
|
class Server
|
|
5
5
|
class RequestHandler
|
|
6
|
-
# Safe HTTP methods allowed in 0-RTT early data (RFC 9110 §9.2.1)
|
|
7
6
|
SAFE_METHODS = %w[GET HEAD OPTIONS].freeze
|
|
8
7
|
|
|
8
|
+
attr_reader :adapter
|
|
9
|
+
|
|
9
10
|
def initialize(app:, configuration:, request_registry:, cancelled_streams:, cancelled_mutex:)
|
|
10
|
-
@app = app
|
|
11
11
|
@configuration = configuration
|
|
12
12
|
@request_registry = request_registry
|
|
13
13
|
@cancelled_streams = cancelled_streams
|
|
14
14
|
@cancelled_mutex = cancelled_mutex
|
|
15
|
+
@adapter = Protocol::Adapter.new(app)
|
|
15
16
|
end
|
|
16
17
|
|
|
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)
|
|
18
46
|
parser = Protocol::RequestParser.new(
|
|
19
47
|
stream.data,
|
|
20
48
|
max_body_size: @configuration.max_body_size,
|
|
@@ -23,63 +51,98 @@ module Quicsilver
|
|
|
23
51
|
max_frame_payload_size: @configuration.max_frame_payload_size
|
|
24
52
|
)
|
|
25
53
|
parser.parse
|
|
26
|
-
parser.validate_headers!
|
|
27
|
-
env = parser.to_rack_env
|
|
54
|
+
parser.validate_headers!
|
|
28
55
|
|
|
29
|
-
|
|
30
|
-
|
|
56
|
+
headers = parser.headers
|
|
57
|
+
unless headers && !headers.empty?
|
|
58
|
+
connection.send_error(stream, 400, "Bad Request") if stream.writable?
|
|
59
|
+
return
|
|
60
|
+
end
|
|
31
61
|
|
|
32
|
-
|
|
33
|
-
if @configuration.early_data_policy == :reject &&
|
|
34
|
-
early_data && !SAFE_METHODS.include?(env["REQUEST_METHOD"])
|
|
35
|
-
connection.send_error(stream, 425, "Too Early") if stream.writable?
|
|
36
|
-
return
|
|
37
|
-
end
|
|
62
|
+
method = headers[":method"]
|
|
38
63
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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)
|
|
45
77
|
|
|
46
|
-
|
|
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
|
+
}
|
|
47
84
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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)
|
|
52
93
|
|
|
53
|
-
|
|
94
|
+
@request_registry.track(
|
|
95
|
+
stream.stream_id, connection.handle,
|
|
96
|
+
path: headers[":path"] || "/", method: method || "GET"
|
|
97
|
+
)
|
|
54
98
|
|
|
55
|
-
|
|
56
|
-
|
|
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 ] }
|
|
57
127
|
else
|
|
58
|
-
|
|
128
|
+
headers&.each { |name, value| response_headers << [ name, value ] }
|
|
59
129
|
end
|
|
60
|
-
rescue Server::DrainTimeoutError
|
|
61
|
-
Quicsilver.logger.debug("Request interrupted by drain: stream #{stream.stream_id}")
|
|
62
|
-
rescue Protocol::FrameError => e
|
|
63
|
-
# Frame errors are connection-level: signal via CONNECTION_CLOSE with H3 error code
|
|
64
|
-
Quicsilver.logger.error("Frame error: #{e.message} (0x#{e.error_code.to_s(16)})")
|
|
65
|
-
Quicsilver.connection_shutdown(connection.handle, e.error_code, false) rescue nil
|
|
66
|
-
rescue Protocol::MessageError => e
|
|
67
|
-
# Message errors are stream-level: signal via RESET_STREAM with H3 error code
|
|
68
|
-
Quicsilver.logger.error("Message error: #{e.message} (0x#{e.error_code.to_s(16)})")
|
|
69
|
-
Quicsilver.stream_reset(stream.stream_handle, e.error_code) if stream.writable?
|
|
70
|
-
rescue => e
|
|
71
|
-
Quicsilver.logger.error("Error handling request: #{e.class} - #{e.message}")
|
|
72
|
-
Quicsilver.logger.debug(e.backtrace.first(5).join("\n"))
|
|
73
|
-
connection.send_error(stream, 500, "Internal Server Error") if stream.writable?
|
|
74
|
-
ensure
|
|
75
|
-
@request_registry.complete(stream.stream_id) if @request_registry.include?(stream.stream_id)
|
|
76
|
-
@cancelled_mutex.synchronize { @cancelled_streams.delete(stream.stream_id) }
|
|
77
|
-
end
|
|
78
130
|
|
|
79
|
-
|
|
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
|
|
80
143
|
|
|
81
|
-
def cancelled_stream?(stream_id)
|
|
82
|
-
@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]) }
|
|
83
146
|
end
|
|
84
147
|
end
|
|
85
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
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../scheduler"
|
|
4
|
+
|
|
5
|
+
module Quicsilver
|
|
6
|
+
class Server
|
|
7
|
+
module Schedulers
|
|
8
|
+
# Fiber-based scheduler — uses Async::Queue for cooperative scheduling.
|
|
9
|
+
# Expects to be running inside an Async reactor (e.g. Falcon provides one).
|
|
10
|
+
# Requires the async gem.
|
|
11
|
+
class FiberScheduler < Scheduler
|
|
12
|
+
def initialize(concurrency:, max_queue_size:, &handler)
|
|
13
|
+
require "async"
|
|
14
|
+
require "async/queue"
|
|
15
|
+
require "async/barrier"
|
|
16
|
+
|
|
17
|
+
@concurrency = concurrency
|
|
18
|
+
@max_queue_size = max_queue_size
|
|
19
|
+
@handler = handler
|
|
20
|
+
@queue = Async::Queue.new
|
|
21
|
+
@running = false
|
|
22
|
+
@barrier = nil
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def enqueue(work)
|
|
26
|
+
@queue.enqueue(work)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def full?
|
|
30
|
+
@queue.size >= @max_queue_size
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def pending
|
|
34
|
+
@queue.size
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Spawn fiber workers inside the current Async reactor.
|
|
38
|
+
# Caller must already be inside Async do (e.g. Falcon).
|
|
39
|
+
def start
|
|
40
|
+
@running = true
|
|
41
|
+
@barrier = Async::Barrier.new
|
|
42
|
+
|
|
43
|
+
@concurrency.times do
|
|
44
|
+
@barrier.async do
|
|
45
|
+
while @running
|
|
46
|
+
work = @queue.dequeue
|
|
47
|
+
break if work == :shutdown
|
|
48
|
+
|
|
49
|
+
run(work)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# One unit of work must not take the worker fiber down with it; see
|
|
56
|
+
# the thread scheduler for the same reasoning.
|
|
57
|
+
private def run(work)
|
|
58
|
+
@handler.call(work)
|
|
59
|
+
rescue => e
|
|
60
|
+
Quicsilver.logger.error("Unhandled error in worker: #{e.class} - #{e.message}")
|
|
61
|
+
Quicsilver.logger.debug(e.backtrace&.first(5)&.join("\n").to_s)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def drain(timeout: 5)
|
|
65
|
+
deadline = Time.now + timeout
|
|
66
|
+
while @queue.size > 0 && Time.now < deadline
|
|
67
|
+
sleep 0.05
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def stop
|
|
72
|
+
@running = false
|
|
73
|
+
@concurrency.times { @queue.enqueue(:shutdown) }
|
|
74
|
+
@barrier&.wait
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../scheduler"
|
|
4
|
+
|
|
5
|
+
module Quicsilver
|
|
6
|
+
class Server
|
|
7
|
+
module Schedulers
|
|
8
|
+
# Thread-based scheduler — uses a thread pool with Thread::Queue.
|
|
9
|
+
class ThreadScheduler < Scheduler
|
|
10
|
+
def initialize(concurrency:, max_queue_size:, &handler)
|
|
11
|
+
@size = concurrency
|
|
12
|
+
@max_queue_size = max_queue_size
|
|
13
|
+
@handler = handler
|
|
14
|
+
@queue = Queue.new
|
|
15
|
+
@threads = []
|
|
16
|
+
@mutex = Mutex.new
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def enqueue(work)
|
|
20
|
+
@queue.push(work)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def full?
|
|
24
|
+
@queue.size >= @max_queue_size
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def pending
|
|
28
|
+
@queue.size
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def start
|
|
32
|
+
@size.times do
|
|
33
|
+
thread = Thread.new do
|
|
34
|
+
while (work = @queue.pop)
|
|
35
|
+
break if work == :shutdown
|
|
36
|
+
|
|
37
|
+
run(work)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
@mutex.synchronize { @threads << thread }
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def drain(timeout: 5)
|
|
45
|
+
deadline = Time.now + timeout
|
|
46
|
+
while @queue.size > 0 && Time.now < deadline
|
|
47
|
+
sleep 0.05
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# One unit of work must not take the worker down with it. An escaping
|
|
52
|
+
# error would end the thread, shrinking the pool for the rest of the
|
|
53
|
+
# process, and Thread#join would re-raise it in whoever stops the
|
|
54
|
+
# server. DrainTimeoutError is our own interruption, so it ends the
|
|
55
|
+
# worker as intended.
|
|
56
|
+
private def run(work)
|
|
57
|
+
@handler.call(work)
|
|
58
|
+
rescue DrainTimeoutError
|
|
59
|
+
raise
|
|
60
|
+
rescue => e
|
|
61
|
+
Quicsilver.logger.error("Unhandled error in worker: #{e.class} - #{e.message}")
|
|
62
|
+
Quicsilver.logger.debug(e.backtrace&.first(5)&.join("\n").to_s)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def stop
|
|
66
|
+
@size.times { @queue.push(:shutdown) }
|
|
67
|
+
@mutex.synchronize do
|
|
68
|
+
@threads.each { |t| t.join(2) }
|
|
69
|
+
@threads.each { |t| t.raise(DrainTimeoutError, "drain timeout") if t.alive? }
|
|
70
|
+
@threads.clear
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|