quicsilver 0.2.0 → 0.4.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.
Files changed (81) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +4 -5
  3. data/.github/workflows/cibuildgem.yaml +93 -0
  4. data/.gitignore +3 -1
  5. data/CHANGELOG.md +81 -0
  6. data/Gemfile.lock +26 -4
  7. data/README.md +95 -31
  8. data/Rakefile +95 -3
  9. data/benchmarks/components.rb +191 -0
  10. data/benchmarks/concurrent.rb +110 -0
  11. data/benchmarks/helpers.rb +88 -0
  12. data/benchmarks/quicsilver_server.rb +1 -1
  13. data/benchmarks/rails.rb +170 -0
  14. data/benchmarks/throughput.rb +113 -0
  15. data/examples/README.md +44 -91
  16. data/examples/benchmark.rb +111 -0
  17. data/examples/connection_pool_demo.rb +47 -0
  18. data/examples/example_helper.rb +18 -0
  19. data/examples/falcon_middleware.rb +44 -0
  20. data/examples/feature_demo.rb +125 -0
  21. data/examples/grpc_style.rb +97 -0
  22. data/examples/minimal_http3_server.rb +6 -18
  23. data/examples/priorities.rb +60 -0
  24. data/examples/protocol_http_server.rb +31 -0
  25. data/examples/rack_http3_server.rb +8 -20
  26. data/examples/rails_feature_test.rb +260 -0
  27. data/examples/simple_client_test.rb +2 -2
  28. data/examples/streaming_sse.rb +33 -0
  29. data/examples/trailers.rb +69 -0
  30. data/ext/quicsilver/extconf.rb +14 -0
  31. data/ext/quicsilver/quicsilver.c +568 -181
  32. data/lib/quicsilver/client/client.rb +349 -0
  33. data/lib/quicsilver/client/connection_pool.rb +106 -0
  34. data/lib/quicsilver/client/request.rb +98 -0
  35. data/lib/quicsilver/libmsquic.2.dylib +0 -0
  36. data/lib/quicsilver/protocol/adapter.rb +176 -0
  37. data/lib/quicsilver/protocol/control_stream_parser.rb +106 -0
  38. data/lib/quicsilver/protocol/frame_parser.rb +142 -0
  39. data/lib/quicsilver/protocol/frame_reader.rb +55 -0
  40. data/lib/quicsilver/{http3.rb → protocol/frames.rb} +146 -30
  41. data/lib/quicsilver/protocol/priority.rb +56 -0
  42. data/lib/quicsilver/protocol/qpack/decoder.rb +165 -0
  43. data/lib/quicsilver/protocol/qpack/encoder.rb +227 -0
  44. data/lib/quicsilver/protocol/qpack/header_block_decoder.rb +140 -0
  45. data/lib/quicsilver/protocol/qpack/huffman.rb +459 -0
  46. data/lib/quicsilver/protocol/request_encoder.rb +47 -0
  47. data/lib/quicsilver/protocol/request_parser.rb +275 -0
  48. data/lib/quicsilver/protocol/response_encoder.rb +97 -0
  49. data/lib/quicsilver/protocol/response_parser.rb +141 -0
  50. data/lib/quicsilver/protocol/stream_input.rb +98 -0
  51. data/lib/quicsilver/protocol/stream_output.rb +59 -0
  52. data/lib/quicsilver/quicsilver.bundle +0 -0
  53. data/lib/quicsilver/server/listener_data.rb +14 -0
  54. data/lib/quicsilver/server/request_handler.rb +138 -0
  55. data/lib/quicsilver/server/request_registry.rb +50 -0
  56. data/lib/quicsilver/server/server.rb +610 -0
  57. data/lib/quicsilver/transport/configuration.rb +141 -0
  58. data/lib/quicsilver/transport/connection.rb +379 -0
  59. data/lib/quicsilver/transport/event_loop.rb +38 -0
  60. data/lib/quicsilver/transport/inbound_stream.rb +33 -0
  61. data/lib/quicsilver/transport/stream.rb +28 -0
  62. data/lib/quicsilver/transport/stream_event.rb +26 -0
  63. data/lib/quicsilver/version.rb +1 -1
  64. data/lib/quicsilver.rb +55 -14
  65. data/lib/rackup/handler/quicsilver.rb +1 -2
  66. data/quicsilver.gemspec +13 -3
  67. metadata +125 -21
  68. data/benchmarks/benchmark.rb +0 -68
  69. data/examples/setup_certs.sh +0 -57
  70. data/lib/quicsilver/client.rb +0 -261
  71. data/lib/quicsilver/connection.rb +0 -42
  72. data/lib/quicsilver/event_loop.rb +0 -38
  73. data/lib/quicsilver/http3/request_encoder.rb +0 -133
  74. data/lib/quicsilver/http3/request_parser.rb +0 -176
  75. data/lib/quicsilver/http3/response_encoder.rb +0 -186
  76. data/lib/quicsilver/http3/response_parser.rb +0 -160
  77. data/lib/quicsilver/listener_data.rb +0 -29
  78. data/lib/quicsilver/quic_stream.rb +0 -36
  79. data/lib/quicsilver/request_registry.rb +0 -48
  80. data/lib/quicsilver/server.rb +0 -355
  81. data/lib/quicsilver/server_configuration.rb +0 -78
@@ -0,0 +1,191 @@
1
+ #!/usr/bin/env ruby
2
+ # Component micro-benchmarks using benchmark-ips.
3
+ # No server needed — pure in-process measurements.
4
+ #
5
+ # Usage: ruby benchmarks/components.rb
6
+
7
+ require "bundler/setup"
8
+ require "quicsilver"
9
+ require "benchmark/ips"
10
+
11
+ # --- Varint ---
12
+
13
+ puts "=" * 60
14
+ puts "Varint encode/decode"
15
+ puts "=" * 60
16
+
17
+ small = 6
18
+ medium = 1_000
19
+ large = 1_000_000
20
+
21
+ encoded_small = Quicsilver::Protocol.encode_varint(small)
22
+ encoded_medium = Quicsilver::Protocol.encode_varint(medium)
23
+ encoded_large = Quicsilver::Protocol.encode_varint(large)
24
+
25
+ Benchmark.ips do |x|
26
+ x.config(warmup: 1, time: 3)
27
+
28
+ x.report("encode small (#{small})") { Quicsilver::Protocol.encode_varint(small) }
29
+ x.report("encode medium (#{medium})") { Quicsilver::Protocol.encode_varint(medium) }
30
+ x.report("encode large (#{large})") { Quicsilver::Protocol.encode_varint(large) }
31
+ x.report("decode small") { Quicsilver::Protocol.decode_varint(encoded_small.bytes, 0) }
32
+ x.report("decode medium") { Quicsilver::Protocol.decode_varint(encoded_medium.bytes, 0) }
33
+ x.report("decode large") { Quicsilver::Protocol.decode_varint(encoded_large.bytes, 0) }
34
+
35
+ x.compare!
36
+ end
37
+
38
+ # --- Huffman ---
39
+
40
+ puts
41
+ puts "=" * 60
42
+ puts "Huffman encode/decode"
43
+ puts "=" * 60
44
+
45
+ huffman_inputs = [
46
+ "www.example.com",
47
+ "application/json",
48
+ "text/html; charset=utf-8",
49
+ "GET",
50
+ "/api/v1/users?page=1&limit=50"
51
+ ]
52
+
53
+ encoded_huffman = huffman_inputs.map { |s| Quicsilver::Protocol::Qpack::Huffman.encode(s) }
54
+
55
+ Benchmark.ips do |x|
56
+ x.config(warmup: 1, time: 3)
57
+
58
+ huffman_inputs.each do |input|
59
+ x.report("encode #{input[0..20]}") { Quicsilver::Protocol::Qpack::Huffman.encode(input) }
60
+ end
61
+ encoded_huffman.each_with_index do |enc, i|
62
+ x.report("decode #{huffman_inputs[i][0..20]}") { Quicsilver::Protocol::Qpack::Huffman.decode(enc) }
63
+ end
64
+
65
+ x.compare!
66
+ end
67
+
68
+ # --- QPACK Encoder ---
69
+
70
+ puts
71
+ puts "=" * 60
72
+ puts "QPACK Encoder"
73
+ puts "=" * 60
74
+
75
+ headers = [
76
+ [":method", "GET"],
77
+ [":path", "/api/v1/users"],
78
+ [":scheme", "https"],
79
+ [":authority", "example.com"],
80
+ ["accept", "application/json"],
81
+ ["user-agent", "quicsilver-bench/1.0"],
82
+ ["accept-encoding", "gzip, deflate"]
83
+ ]
84
+
85
+ encoder_huffman = Quicsilver::Protocol::Qpack::Encoder.new(huffman: true)
86
+ encoder_raw = Quicsilver::Protocol::Qpack::Encoder.new(huffman: false)
87
+
88
+ Benchmark.ips do |x|
89
+ x.config(warmup: 1, time: 3)
90
+
91
+ x.report("encode (huffman on)") { encoder_huffman.encode(headers) }
92
+ x.report("encode (huffman off)") { encoder_raw.encode(headers) }
93
+
94
+ x.compare!
95
+ end
96
+
97
+ # --- QPACK Decoder ---
98
+
99
+ puts
100
+ puts "=" * 60
101
+ puts "QPACK Decoder (string decoding)"
102
+ puts "=" * 60
103
+
104
+ # Build payloads for decode_qpack_string
105
+ huffman_payload = Quicsilver::Protocol::Qpack::Huffman.encode("application/json")
106
+ huffman_bytes = [0x80 | huffman_payload.bytesize] + huffman_payload.bytes # Huffman flag set
107
+
108
+ raw_string = "application/json"
109
+ raw_bytes = [raw_string.bytesize] + raw_string.bytes # No Huffman flag
110
+
111
+ # Include the decoder module in a throwaway object
112
+ decoder = Object.new
113
+ decoder.extend(Quicsilver::Protocol::Qpack::Decoder)
114
+
115
+ Benchmark.ips do |x|
116
+ x.config(warmup: 1, time: 3)
117
+
118
+ x.report("decode huffman string") { decoder.decode_qpack_string(huffman_bytes, 0) }
119
+ x.report("decode raw string") { decoder.decode_qpack_string(raw_bytes, 0) }
120
+
121
+ x.compare!
122
+ end
123
+
124
+ # --- Request Parser ---
125
+
126
+ puts
127
+ puts "=" * 60
128
+ puts "Request Parser"
129
+ puts "=" * 60
130
+
131
+ # Build a realistic GET request frame
132
+ request_encoder = Quicsilver::Protocol::Qpack::Encoder.new(huffman: true)
133
+ request_headers_payload = request_encoder.encode([
134
+ [":method", "GET"],
135
+ [":path", "/api/v1/users?page=1"],
136
+ [":scheme", "https"],
137
+ [":authority", "example.com"],
138
+ ["accept", "application/json"],
139
+ ["user-agent", "quicsilver-bench/1.0"]
140
+ ])
141
+
142
+ # HEADERS frame: type=0x01, varint length, payload
143
+ request_frame = Quicsilver::Protocol.encode_varint(0x01) +
144
+ Quicsilver::Protocol.encode_varint(request_headers_payload.bytesize) +
145
+ request_headers_payload
146
+
147
+ Benchmark.ips do |x|
148
+ x.config(warmup: 1, time: 3)
149
+
150
+ x.report("parse GET request") do
151
+ parser = Quicsilver::Protocol::RequestParser.new(request_frame)
152
+ parser.parse
153
+ end
154
+
155
+ x.compare!
156
+ end
157
+
158
+ # --- Response Parser ---
159
+
160
+ puts
161
+ puts "=" * 60
162
+ puts "Response Parser"
163
+ puts "=" * 60
164
+
165
+ # Build a realistic 200 response with body
166
+ response_encoder = Quicsilver::Protocol::Qpack::Encoder.new(huffman: true)
167
+ response_headers_payload = response_encoder.encode([
168
+ [":status", "200"],
169
+ ["content-type", "application/json"],
170
+ ["server", "quicsilver"]
171
+ ])
172
+
173
+ body = '{"users":[{"id":1,"name":"Alice"},{"id":2,"name":"Bob"}]}'
174
+
175
+ response_frame = Quicsilver::Protocol.encode_varint(0x01) +
176
+ Quicsilver::Protocol.encode_varint(response_headers_payload.bytesize) +
177
+ response_headers_payload +
178
+ Quicsilver::Protocol.encode_varint(0x00) +
179
+ Quicsilver::Protocol.encode_varint(body.bytesize) +
180
+ body
181
+
182
+ Benchmark.ips do |x|
183
+ x.config(warmup: 1, time: 3)
184
+
185
+ x.report("parse 200 response + body") do
186
+ parser = Quicsilver::Protocol::ResponseParser.new(response_frame)
187
+ parser.parse
188
+ end
189
+
190
+ x.compare!
191
+ end
@@ -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.open_connection
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.open_connection
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
@@ -26,7 +26,7 @@ authority = Localhost::Authority.fetch
26
26
  cert_file = authority.certificate_path
27
27
  key_file = authority.key_path
28
28
 
29
- config = ::Quicsilver::ServerConfiguration.new(cert_file, key_file)
29
+ config = ::Quicsilver::Transport::Configuration.new(cert_file, key_file)
30
30
 
31
31
  server = ::Quicsilver::Server.new(
32
32
  port.to_i,
@@ -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.open_connection
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.open_connection
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.open_connection
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.open_connection
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.open_connection
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