quicsilver 0.1.0 → 0.3.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 +41 -0
- data/.gitignore +3 -1
- data/CHANGELOG.md +76 -5
- data/Gemfile.lock +18 -4
- data/LICENSE +21 -0
- data/README.md +33 -53
- data/Rakefile +29 -2
- data/benchmarks/components.rb +191 -0
- data/benchmarks/concurrent.rb +110 -0
- data/benchmarks/helpers.rb +88 -0
- data/benchmarks/quicsilver_server.rb +46 -0
- data/benchmarks/rails.rb +170 -0
- data/benchmarks/throughput.rb +113 -0
- data/examples/minimal_http3_server.rb +0 -6
- data/examples/rack_http3_server.rb +0 -6
- data/examples/simple_client_test.rb +26 -0
- data/ext/quicsilver/quicsilver.c +615 -138
- data/lib/quicsilver/client/client.rb +250 -0
- data/lib/quicsilver/client/request.rb +98 -0
- data/lib/quicsilver/protocol/frames.rb +327 -0
- data/lib/quicsilver/protocol/qpack/decoder.rb +165 -0
- data/lib/quicsilver/protocol/qpack/encoder.rb +189 -0
- data/lib/quicsilver/protocol/qpack/header_block_decoder.rb +125 -0
- data/lib/quicsilver/protocol/qpack/huffman.rb +459 -0
- data/lib/quicsilver/protocol/request_encoder.rb +47 -0
- data/lib/quicsilver/protocol/request_parser.rb +387 -0
- data/lib/quicsilver/protocol/response_encoder.rb +72 -0
- data/lib/quicsilver/protocol/response_parser.rb +249 -0
- data/lib/quicsilver/server/listener_data.rb +14 -0
- data/lib/quicsilver/server/request_handler.rb +86 -0
- data/lib/quicsilver/server/request_registry.rb +50 -0
- data/lib/quicsilver/server/server.rb +336 -0
- data/lib/quicsilver/transport/configuration.rb +132 -0
- data/lib/quicsilver/transport/connection.rb +350 -0
- data/lib/quicsilver/transport/event_loop.rb +38 -0
- data/lib/quicsilver/transport/inbound_stream.rb +33 -0
- data/lib/quicsilver/transport/stream.rb +28 -0
- data/lib/quicsilver/transport/stream_event.rb +26 -0
- data/lib/quicsilver/version.rb +1 -1
- data/lib/quicsilver.rb +49 -9
- data/lib/rackup/handler/quicsilver.rb +77 -0
- data/quicsilver.gemspec +10 -3
- metadata +122 -17
- data/examples/minimal_http3_client.rb +0 -89
- data/lib/quicsilver/client.rb +0 -191
- data/lib/quicsilver/http3/request_encoder.rb +0 -112
- data/lib/quicsilver/http3/request_parser.rb +0 -158
- data/lib/quicsilver/http3/response_encoder.rb +0 -73
- data/lib/quicsilver/http3.rb +0 -68
- data/lib/quicsilver/listener_data.rb +0 -29
- data/lib/quicsilver/server.rb +0 -258
- data/lib/quicsilver/server_configuration.rb +0 -49
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# Concurrency benchmark: tests HTTP/3 multiplexing and multi-connection overhead.
|
|
3
|
+
#
|
|
4
|
+
# Self-contained:
|
|
5
|
+
# ruby benchmarks/concurrent.rb
|
|
6
|
+
#
|
|
7
|
+
# External server:
|
|
8
|
+
# HOST=127.0.0.1 PORT=4433 ruby benchmarks/concurrent.rb
|
|
9
|
+
|
|
10
|
+
require_relative "helpers"
|
|
11
|
+
require "benchmark"
|
|
12
|
+
|
|
13
|
+
MULTIPLEX_REQUESTS = ENV.fetch("MULTIPLEX_REQUESTS", "50").to_i
|
|
14
|
+
NUM_CLIENTS = ENV.fetch("NUM_CLIENTS", "20").to_i
|
|
15
|
+
REQUESTS_PER_CLIENT = ENV.fetch("REQUESTS_PER_CLIENT", "5").to_i
|
|
16
|
+
HOST = ENV["HOST"]
|
|
17
|
+
PORT = ENV["PORT"]&.to_i
|
|
18
|
+
|
|
19
|
+
def test_multiplexing(host, port)
|
|
20
|
+
Benchmarks::Helpers.print_header(
|
|
21
|
+
"Test 1: Single-connection multiplexing",
|
|
22
|
+
requests: MULTIPLEX_REQUESTS
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
request_times = []
|
|
26
|
+
mutex = Mutex.new
|
|
27
|
+
|
|
28
|
+
client = Quicsilver::Client.new(host, port, unsecure: true)
|
|
29
|
+
client.connect
|
|
30
|
+
|
|
31
|
+
elapsed = Benchmark.realtime do
|
|
32
|
+
threads = MULTIPLEX_REQUESTS.times.map do |i|
|
|
33
|
+
Thread.new do
|
|
34
|
+
start = Time.now
|
|
35
|
+
client.get("/multiplex/#{i}")
|
|
36
|
+
req_time = Time.now - start
|
|
37
|
+
mutex.synchronize { request_times << req_time }
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
threads.each(&:join)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
client.disconnect
|
|
44
|
+
|
|
45
|
+
concurrency_factor = request_times.any? ? (request_times.sum / elapsed).round(2) : 0
|
|
46
|
+
puts " Wall clock: #{(elapsed * 1000).round(2)}ms"
|
|
47
|
+
puts " Avg request time: #{(request_times.sum / request_times.size * 1000).round(2)}ms"
|
|
48
|
+
puts " Throughput: #{(MULTIPLEX_REQUESTS / elapsed).round(2)} req/s"
|
|
49
|
+
puts " Concurrency factor: #{concurrency_factor}x"
|
|
50
|
+
Benchmarks::Helpers.print_stats("Latency", request_times)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def test_concurrent_clients(host, port)
|
|
54
|
+
Benchmarks::Helpers.print_header(
|
|
55
|
+
"Test 2: Concurrent clients",
|
|
56
|
+
clients: NUM_CLIENTS,
|
|
57
|
+
requests_per_client: REQUESTS_PER_CLIENT
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
request_times = []
|
|
61
|
+
mutex = Mutex.new
|
|
62
|
+
successful = 0
|
|
63
|
+
|
|
64
|
+
elapsed = Benchmark.realtime do
|
|
65
|
+
threads = NUM_CLIENTS.times.map do |i|
|
|
66
|
+
Thread.new do
|
|
67
|
+
client = Quicsilver::Client.new(host, port, unsecure: true)
|
|
68
|
+
client.connect
|
|
69
|
+
|
|
70
|
+
REQUESTS_PER_CLIENT.times do |req|
|
|
71
|
+
start = Time.now
|
|
72
|
+
client.get("/client#{i}/request#{req}")
|
|
73
|
+
req_time = Time.now - start
|
|
74
|
+
mutex.synchronize { request_times << req_time }
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
client.disconnect
|
|
78
|
+
mutex.synchronize { successful += 1 }
|
|
79
|
+
rescue => e
|
|
80
|
+
$stderr.puts " Client #{i} error: #{e.message}"
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
threads.each(&:join)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
total_requests = request_times.size
|
|
87
|
+
concurrency_factor = request_times.any? ? (request_times.sum / elapsed).round(2) : 0
|
|
88
|
+
|
|
89
|
+
puts " Successful clients: #{successful}/#{NUM_CLIENTS}"
|
|
90
|
+
puts " Total requests: #{total_requests}"
|
|
91
|
+
puts " Wall clock: #{(elapsed * 1000).round(2)}ms"
|
|
92
|
+
puts " Avg request time: #{request_times.any? ? (request_times.sum / request_times.size * 1000).round(2) : "N/A"}ms"
|
|
93
|
+
puts " Throughput: #{(total_requests / elapsed).round(2)} req/s"
|
|
94
|
+
puts " Concurrency factor: #{concurrency_factor}x"
|
|
95
|
+
Benchmarks::Helpers.print_stats("Latency", request_times)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def run_all(host, port)
|
|
99
|
+
test_multiplexing(host, port)
|
|
100
|
+
test_concurrent_clients(host, port)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
if HOST && PORT
|
|
104
|
+
run_all(HOST, PORT)
|
|
105
|
+
else
|
|
106
|
+
puts "Booting inline server..."
|
|
107
|
+
Benchmarks::Helpers.with_server(Benchmarks::Helpers.benchmark_app) do |port|
|
|
108
|
+
run_all("127.0.0.1", port)
|
|
109
|
+
end
|
|
110
|
+
end
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
require "bundler/setup"
|
|
2
|
+
require "quicsilver"
|
|
3
|
+
require "localhost/authority"
|
|
4
|
+
|
|
5
|
+
module Benchmarks
|
|
6
|
+
module Helpers
|
|
7
|
+
# Boot a Quicsilver server on a random port, yield the port, then tear down.
|
|
8
|
+
def self.with_server(app, &block)
|
|
9
|
+
authority = Localhost::Authority.fetch
|
|
10
|
+
config = Quicsilver::Transport::Configuration.new(
|
|
11
|
+
authority.certificate_path,
|
|
12
|
+
authority.key_path
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
port = random_port
|
|
16
|
+
server = Quicsilver::Server.new(port, app: app, server_configuration: config)
|
|
17
|
+
server_thread = Thread.new { server.start }
|
|
18
|
+
sleep 0.5 # let listener settle
|
|
19
|
+
|
|
20
|
+
yield port
|
|
21
|
+
ensure
|
|
22
|
+
server&.stop rescue nil
|
|
23
|
+
server_thread&.join(2)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Compute latency stats from an array of float durations (in seconds).
|
|
27
|
+
# Returns a Hash with :avg, :min, :max, :p50, :p95, :p99 (all in ms).
|
|
28
|
+
def self.stats(times)
|
|
29
|
+
return nil if times.empty?
|
|
30
|
+
|
|
31
|
+
sorted = times.sort
|
|
32
|
+
n = sorted.size
|
|
33
|
+
{
|
|
34
|
+
count: n,
|
|
35
|
+
avg: (times.sum / n * 1000).round(2),
|
|
36
|
+
min: (sorted.first * 1000).round(2),
|
|
37
|
+
max: (sorted.last * 1000).round(2),
|
|
38
|
+
p50: (sorted[n / 2] * 1000).round(2),
|
|
39
|
+
p95: (sorted[(n * 0.95).to_i] * 1000).round(2),
|
|
40
|
+
p99: (sorted[(n * 0.99).to_i] * 1000).round(2)
|
|
41
|
+
}
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def self.print_header(title, **opts)
|
|
45
|
+
puts
|
|
46
|
+
puts "=" * 60
|
|
47
|
+
puts title
|
|
48
|
+
puts "=" * 60
|
|
49
|
+
opts.each { |k, v| puts " #{k}: #{v}" }
|
|
50
|
+
puts "-" * 60
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def self.print_stats(label, times)
|
|
54
|
+
s = stats(times)
|
|
55
|
+
unless s
|
|
56
|
+
puts " #{label}: no data"
|
|
57
|
+
return
|
|
58
|
+
end
|
|
59
|
+
puts " #{label}: #{s[:count]} reqs | avg=#{s[:avg]}ms p50=#{s[:p50]}ms p95=#{s[:p95]}ms p99=#{s[:p99]}ms (min=#{s[:min]}ms max=#{s[:max]}ms)"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def self.print_results(total_time:, total_requests:, times:, failed: 0, latency: false)
|
|
63
|
+
s = stats(times)
|
|
64
|
+
puts "-" * 60
|
|
65
|
+
puts " Total time: #{total_time.round(3)}s"
|
|
66
|
+
puts " Requests: #{total_requests} (#{failed} failed)"
|
|
67
|
+
puts " Throughput: #{(total_requests / total_time).round(2)} req/s"
|
|
68
|
+
if latency && s
|
|
69
|
+
puts " Latency: avg=#{s[:avg]}ms p50=#{s[:p50]}ms p95=#{s[:p95]}ms p99=#{s[:p99]}ms"
|
|
70
|
+
puts " min=#{s[:min]}ms max=#{s[:max]}ms"
|
|
71
|
+
end
|
|
72
|
+
puts "=" * 60
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def self.random_port
|
|
76
|
+
server = TCPServer.new("127.0.0.1", 0)
|
|
77
|
+
server.addr[1]
|
|
78
|
+
ensure
|
|
79
|
+
server&.close
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def self.benchmark_app
|
|
83
|
+
->(env) {
|
|
84
|
+
[200, { "content-type" => "text/plain" }, ["OK"]]
|
|
85
|
+
}
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
|
|
3
|
+
require "bundler/setup"
|
|
4
|
+
require "quicsilver"
|
|
5
|
+
require 'localhost/authority'
|
|
6
|
+
require 'json'
|
|
7
|
+
|
|
8
|
+
SIMPLE_APP = lambda do |env|
|
|
9
|
+
case env['PATH_INFO']
|
|
10
|
+
when '/'
|
|
11
|
+
[200, {'Content-Type' => 'text/plain'}, ['OK']]
|
|
12
|
+
when '/json'
|
|
13
|
+
body = JSON.generate({status: 'ok', timestamp: Time.now.to_i})
|
|
14
|
+
[200, {'Content-Type' => 'application/json'}, [body]]
|
|
15
|
+
when '/echo'
|
|
16
|
+
[200, {'Content-Type' => 'text/plain'}, [env['REQUEST_METHOD']]]
|
|
17
|
+
else
|
|
18
|
+
[404, {'Content-Type' => 'text/plain'}, ['Not Found']]
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
port = ENV["PORT"] || 4433
|
|
23
|
+
default_host = ENV["HOST"] || "0.0.0.0"
|
|
24
|
+
|
|
25
|
+
authority = Localhost::Authority.fetch
|
|
26
|
+
cert_file = authority.certificate_path
|
|
27
|
+
key_file = authority.key_path
|
|
28
|
+
|
|
29
|
+
config = ::Quicsilver::Transport::Configuration.new(cert_file, key_file)
|
|
30
|
+
|
|
31
|
+
server = ::Quicsilver::Server.new(
|
|
32
|
+
port.to_i,
|
|
33
|
+
address: default_host,
|
|
34
|
+
app: SIMPLE_APP,
|
|
35
|
+
server_configuration: config
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
puts "Starting Quicsilver on port #{port}..."
|
|
39
|
+
|
|
40
|
+
trap("INT") do
|
|
41
|
+
puts "\nStopping server..."
|
|
42
|
+
server.stop
|
|
43
|
+
exit
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
server.start # Blocks until shutdown
|
data/benchmarks/rails.rb
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# Rails benchmark: concurrent POST, GET, DELETE against a Rails app.
|
|
3
|
+
# Multiplexes requests within each connection (HTTP/3 streams),
|
|
4
|
+
# capped by CONCURRENCY to stay within the server's stream limit.
|
|
5
|
+
#
|
|
6
|
+
# Start blogz first:
|
|
7
|
+
# cd ../blogz && bundle exec rackup -s quicsilver -p 4433
|
|
8
|
+
#
|
|
9
|
+
# Usage:
|
|
10
|
+
# CONNECTIONS=5 ITERATIONS=100 ruby benchmarks/rails.rb
|
|
11
|
+
# CONCURRENCY=8 CONNECTIONS=3 ITERATIONS=200 ruby benchmarks/rails.rb
|
|
12
|
+
|
|
13
|
+
require "bundler/setup"
|
|
14
|
+
require "quicsilver"
|
|
15
|
+
require "json"
|
|
16
|
+
require "benchmark"
|
|
17
|
+
|
|
18
|
+
require_relative "helpers"
|
|
19
|
+
|
|
20
|
+
HOST = ENV.fetch("HOST", "127.0.0.1")
|
|
21
|
+
PORT = ENV.fetch("PORT", "4433").to_i
|
|
22
|
+
CONNECTIONS = ENV.fetch("CONNECTIONS", "5").to_i
|
|
23
|
+
ITERATIONS = ENV.fetch("ITERATIONS", "100").to_i
|
|
24
|
+
CONCURRENCY = ENV.fetch("CONCURRENCY", "8").to_i # max in-flight per connection
|
|
25
|
+
|
|
26
|
+
total_requests = CONNECTIONS * ITERATIONS
|
|
27
|
+
|
|
28
|
+
Benchmarks::Helpers.print_header(
|
|
29
|
+
"Rails Concurrent Benchmark (multiplexed)",
|
|
30
|
+
target: "#{HOST}:#{PORT}",
|
|
31
|
+
connections: CONNECTIONS,
|
|
32
|
+
"reqs/conn": ITERATIONS,
|
|
33
|
+
concurrency: "#{CONCURRENCY} streams/conn",
|
|
34
|
+
total: "#{total_requests * 3} (POST + GET + DELETE)"
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
mutex = Mutex.new
|
|
38
|
+
results = { post: [], get: [], delete: [] }
|
|
39
|
+
all_created_ids = []
|
|
40
|
+
|
|
41
|
+
# Fire N requests with at most `concurrency` in-flight at a time on a shared connection.
|
|
42
|
+
def multiplex(count, concurrency:)
|
|
43
|
+
queue = Queue.new
|
|
44
|
+
count.times { |i| queue << i }
|
|
45
|
+
|
|
46
|
+
threads = concurrency.times.map do
|
|
47
|
+
Thread.new do
|
|
48
|
+
while (i = queue.pop(true) rescue nil)
|
|
49
|
+
yield i
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
threads.each(&:join)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Phase 1: Concurrent multiplexed POSTs
|
|
57
|
+
puts "\nPhase 1: POST /posts.json (#{CONNECTIONS} conns x #{ITERATIONS}, #{CONCURRENCY} in-flight)..."
|
|
58
|
+
post_elapsed = Benchmark.realtime do
|
|
59
|
+
conn_threads = CONNECTIONS.times.map do |conn_id|
|
|
60
|
+
Thread.new do
|
|
61
|
+
client = Quicsilver::Client.new(HOST, PORT, unsecure: true)
|
|
62
|
+
client.connect
|
|
63
|
+
|
|
64
|
+
local_times = []
|
|
65
|
+
local_ids = []
|
|
66
|
+
|
|
67
|
+
multiplex(ITERATIONS, concurrency: CONCURRENCY) do |i|
|
|
68
|
+
start = Time.now
|
|
69
|
+
response = client.post(
|
|
70
|
+
"/posts.json",
|
|
71
|
+
headers: { "content-type" => "application/json" },
|
|
72
|
+
body: { post: { name: "Author #{conn_id}-#{i}", title: "Post #{conn_id}-#{i}" } }.to_json
|
|
73
|
+
)
|
|
74
|
+
elapsed = Time.now - start
|
|
75
|
+
|
|
76
|
+
if response && response[:status] == 201
|
|
77
|
+
body = JSON.parse(response[:body]) rescue {}
|
|
78
|
+
mutex.synchronize do
|
|
79
|
+
local_times << elapsed
|
|
80
|
+
local_ids << body["id"] if body["id"]
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
client.disconnect
|
|
86
|
+
mutex.synchronize do
|
|
87
|
+
results[:post].concat(local_times)
|
|
88
|
+
all_created_ids.concat(local_ids)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
conn_threads.each(&:join)
|
|
93
|
+
end
|
|
94
|
+
puts " #{results[:post].size} created in #{post_elapsed.round(2)}s (#{(results[:post].size / post_elapsed).round(1)} req/s)"
|
|
95
|
+
|
|
96
|
+
# Phase 2: Concurrent multiplexed GETs
|
|
97
|
+
puts "\nPhase 2: GET /posts.json (#{CONNECTIONS} conns x #{ITERATIONS}, #{CONCURRENCY} in-flight)..."
|
|
98
|
+
get_elapsed = Benchmark.realtime do
|
|
99
|
+
conn_threads = CONNECTIONS.times.map do
|
|
100
|
+
Thread.new do
|
|
101
|
+
client = Quicsilver::Client.new(HOST, PORT, unsecure: true)
|
|
102
|
+
client.connect
|
|
103
|
+
|
|
104
|
+
local_times = []
|
|
105
|
+
|
|
106
|
+
multiplex(ITERATIONS, concurrency: CONCURRENCY) do |_i|
|
|
107
|
+
start = Time.now
|
|
108
|
+
response = client.get("/posts.json")
|
|
109
|
+
elapsed = Time.now - start
|
|
110
|
+
|
|
111
|
+
if response && response[:status] == 200
|
|
112
|
+
mutex.synchronize { local_times << elapsed }
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
client.disconnect
|
|
117
|
+
mutex.synchronize { results[:get].concat(local_times) }
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
conn_threads.each(&:join)
|
|
121
|
+
end
|
|
122
|
+
puts " #{results[:get].size} fetched in #{get_elapsed.round(2)}s (#{(results[:get].size / get_elapsed).round(1)} req/s)"
|
|
123
|
+
|
|
124
|
+
# Phase 3: Concurrent multiplexed DELETEs
|
|
125
|
+
delete_count = all_created_ids.size
|
|
126
|
+
puts "\nPhase 3: DELETE /posts/:id (#{delete_count} across #{CONNECTIONS} conns, #{CONCURRENCY} in-flight)..."
|
|
127
|
+
delete_elapsed = Benchmark.realtime do
|
|
128
|
+
id_chunks = all_created_ids.each_slice((all_created_ids.size.to_f / CONNECTIONS).ceil).to_a
|
|
129
|
+
|
|
130
|
+
conn_threads = id_chunks.map do |ids|
|
|
131
|
+
Thread.new do
|
|
132
|
+
next if ids.empty?
|
|
133
|
+
|
|
134
|
+
client = Quicsilver::Client.new(HOST, PORT, unsecure: true)
|
|
135
|
+
client.connect
|
|
136
|
+
|
|
137
|
+
local_times = []
|
|
138
|
+
|
|
139
|
+
multiplex(ids.size, concurrency: CONCURRENCY) do |i|
|
|
140
|
+
start = Time.now
|
|
141
|
+
response = client.delete("/posts/#{ids[i]}.json")
|
|
142
|
+
elapsed = Time.now - start
|
|
143
|
+
|
|
144
|
+
if response && response[:status] == 204
|
|
145
|
+
mutex.synchronize { local_times << elapsed }
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
client.disconnect
|
|
150
|
+
mutex.synchronize { results[:delete].concat(local_times) }
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
conn_threads.each(&:join)
|
|
154
|
+
end
|
|
155
|
+
puts " #{results[:delete].size} deleted in #{delete_elapsed.round(2)}s (#{(results[:delete].size / delete_elapsed).round(1)} req/s)"
|
|
156
|
+
|
|
157
|
+
# Summary
|
|
158
|
+
total_elapsed = post_elapsed + get_elapsed + delete_elapsed
|
|
159
|
+
total_completed = results.values.sum(&:size)
|
|
160
|
+
|
|
161
|
+
puts
|
|
162
|
+
puts "=" * 70
|
|
163
|
+
puts "RESULTS"
|
|
164
|
+
puts "=" * 70
|
|
165
|
+
Benchmarks::Helpers.print_stats("POST /posts.json", results[:post])
|
|
166
|
+
Benchmarks::Helpers.print_stats("GET /posts.json", results[:get])
|
|
167
|
+
Benchmarks::Helpers.print_stats("DELETE /posts/:id ", results[:delete])
|
|
168
|
+
puts "-" * 70
|
|
169
|
+
puts " Total: #{total_completed} requests in #{total_elapsed.round(2)}s (#{(total_completed / total_elapsed).round(2)} req/s)"
|
|
170
|
+
puts "=" * 70
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# Throughput benchmark: measures req/sec and latency percentiles.
|
|
3
|
+
# Tests both sequential and concurrent (multiplexed) modes.
|
|
4
|
+
#
|
|
5
|
+
# Self-contained (boots inline server with trivial Rack app):
|
|
6
|
+
# ruby benchmarks/throughput.rb
|
|
7
|
+
#
|
|
8
|
+
# External server:
|
|
9
|
+
# HOST=127.0.0.1 PORT=4433 ruby benchmarks/throughput.rb
|
|
10
|
+
|
|
11
|
+
require_relative "helpers"
|
|
12
|
+
require "benchmark"
|
|
13
|
+
|
|
14
|
+
REQUESTS = ENV.fetch("REQUESTS", "500").to_i
|
|
15
|
+
CONNECTIONS = ENV.fetch("CONNECTIONS", "5").to_i
|
|
16
|
+
CONCURRENCY = ENV.fetch("CONCURRENCY", "8").to_i
|
|
17
|
+
HOST = ENV["HOST"]
|
|
18
|
+
PORT = ENV["PORT"]&.to_i
|
|
19
|
+
|
|
20
|
+
def run_benchmark(host, port)
|
|
21
|
+
# --- Sequential: 1 request at a time per connection ---
|
|
22
|
+
puts "\n--- Sequential (1 stream/conn, #{CONNECTIONS} conns) ---"
|
|
23
|
+
seq_times = []
|
|
24
|
+
mutex = Mutex.new
|
|
25
|
+
|
|
26
|
+
seq_elapsed = Benchmark.realtime do
|
|
27
|
+
per_conn = REQUESTS / CONNECTIONS
|
|
28
|
+
|
|
29
|
+
threads = CONNECTIONS.times.map do
|
|
30
|
+
Thread.new do
|
|
31
|
+
client = Quicsilver::Client.new(host, port, connection_timeout: 5000, request_timeout: 10)
|
|
32
|
+
client.connect
|
|
33
|
+
|
|
34
|
+
local = []
|
|
35
|
+
per_conn.times do
|
|
36
|
+
start = Time.now
|
|
37
|
+
response = client.get("/")
|
|
38
|
+
local << (Time.now - start) if response && response[:status] == 200
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
client.disconnect
|
|
42
|
+
mutex.synchronize { seq_times.concat(local) }
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
threads.each(&:join)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
Benchmarks::Helpers.print_results(
|
|
49
|
+
total_time: seq_elapsed, total_requests: REQUESTS,
|
|
50
|
+
times: seq_times, failed: REQUESTS - seq_times.size, latency: true
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
# --- Concurrent: CONCURRENCY streams per connection ---
|
|
54
|
+
puts "\n--- Concurrent (#{CONCURRENCY} streams/conn, #{CONNECTIONS} conns) ---"
|
|
55
|
+
con_times = []
|
|
56
|
+
con_failed = 0
|
|
57
|
+
|
|
58
|
+
con_elapsed = Benchmark.realtime do
|
|
59
|
+
per_conn = REQUESTS / CONNECTIONS
|
|
60
|
+
|
|
61
|
+
threads = CONNECTIONS.times.map do
|
|
62
|
+
Thread.new do
|
|
63
|
+
client = Quicsilver::Client.new(host, port, connection_timeout: 5000, request_timeout: 10)
|
|
64
|
+
client.connect
|
|
65
|
+
|
|
66
|
+
local = []
|
|
67
|
+
queue = Queue.new
|
|
68
|
+
per_conn.times { |i| queue << i }
|
|
69
|
+
|
|
70
|
+
workers = CONCURRENCY.times.map do
|
|
71
|
+
Thread.new do
|
|
72
|
+
while (queue.pop(true) rescue nil)
|
|
73
|
+
start = Time.now
|
|
74
|
+
response = client.get("/bench")
|
|
75
|
+
dur = Time.now - start
|
|
76
|
+
if response && response[:status] == 200
|
|
77
|
+
local << dur
|
|
78
|
+
else
|
|
79
|
+
mutex.synchronize { con_failed += 1 }
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
workers.each(&:join)
|
|
85
|
+
client.disconnect
|
|
86
|
+
mutex.synchronize { con_times.concat(local) }
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
threads.each(&:join)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
Benchmarks::Helpers.print_results(
|
|
93
|
+
total_time: con_elapsed, total_requests: REQUESTS,
|
|
94
|
+
times: con_times, failed: con_failed, latency: true
|
|
95
|
+
)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
Benchmarks::Helpers.print_header(
|
|
99
|
+
"Quicsilver Throughput (trivial Rack app, no DB)",
|
|
100
|
+
connections: CONNECTIONS,
|
|
101
|
+
"reqs/conn": REQUESTS / CONNECTIONS,
|
|
102
|
+
concurrency: "#{CONCURRENCY} streams/conn",
|
|
103
|
+
total: REQUESTS
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
if HOST && PORT
|
|
107
|
+
run_benchmark(HOST, PORT)
|
|
108
|
+
else
|
|
109
|
+
puts "Booting inline server..."
|
|
110
|
+
Benchmarks::Helpers.with_server(Benchmarks::Helpers.benchmark_app) do |port|
|
|
111
|
+
run_benchmark("localhost", port)
|
|
112
|
+
end
|
|
113
|
+
end
|
|
@@ -6,12 +6,6 @@ require "quicsilver"
|
|
|
6
6
|
puts "🚀 Minimal HTTP/3 Server Example"
|
|
7
7
|
puts "=" * 40
|
|
8
8
|
|
|
9
|
-
# First, set up certificates if they don't exist
|
|
10
|
-
unless File.exist?("certs/server.crt") && File.exist?("certs/server.key")
|
|
11
|
-
puts "📝 Setting up certificates..."
|
|
12
|
-
system("bash examples/setup_certs.sh")
|
|
13
|
-
end
|
|
14
|
-
|
|
15
9
|
# Create and start the server
|
|
16
10
|
server = Quicsilver::Server.new(4433)
|
|
17
11
|
|
|
@@ -6,12 +6,6 @@ require "quicsilver"
|
|
|
6
6
|
puts "🚀 Rack HTTP/3 Server Example"
|
|
7
7
|
puts "=" * 40
|
|
8
8
|
|
|
9
|
-
# First, set up certificates if they don't exist
|
|
10
|
-
unless File.exist?("certs/server.crt") && File.exist?("certs/server.key")
|
|
11
|
-
puts "📝 Setting up certificates..."
|
|
12
|
-
system("bash examples/setup_certs.sh")
|
|
13
|
-
end
|
|
14
|
-
|
|
15
9
|
# Define a simple Rack app
|
|
16
10
|
app = ->(env) {
|
|
17
11
|
path = env['PATH_INFO']
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
|
|
3
|
+
require "bundler/setup"
|
|
4
|
+
require "quicsilver"
|
|
5
|
+
|
|
6
|
+
puts "🔌 Simple HTTP/3 Client Test"
|
|
7
|
+
puts "=" * 40
|
|
8
|
+
|
|
9
|
+
begin
|
|
10
|
+
client = Quicsilver::Client.new("127.0.0.1", 4433, unsecure: true)
|
|
11
|
+
|
|
12
|
+
client.connect
|
|
13
|
+
|
|
14
|
+
response = client.get("/posts")
|
|
15
|
+
|
|
16
|
+
puts "Status: #{response[:status]}"
|
|
17
|
+
puts "Headers: #{response[:headers].inspect}"
|
|
18
|
+
puts "Body: #{response[:body]}"
|
|
19
|
+
|
|
20
|
+
rescue => e
|
|
21
|
+
puts "❌ Error: #{e.class} - #{e.message}"
|
|
22
|
+
puts e.backtrace.first(10)
|
|
23
|
+
ensure
|
|
24
|
+
client&.disconnect
|
|
25
|
+
puts "👋 Done"
|
|
26
|
+
end
|