quicsilver 0.2.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.
Files changed (52) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +3 -4
  3. data/CHANGELOG.md +49 -0
  4. data/Gemfile.lock +8 -4
  5. data/README.md +7 -6
  6. data/Rakefile +29 -2
  7. data/benchmarks/components.rb +191 -0
  8. data/benchmarks/concurrent.rb +110 -0
  9. data/benchmarks/helpers.rb +88 -0
  10. data/benchmarks/quicsilver_server.rb +1 -1
  11. data/benchmarks/rails.rb +170 -0
  12. data/benchmarks/throughput.rb +113 -0
  13. data/ext/quicsilver/quicsilver.c +529 -181
  14. data/lib/quicsilver/client/client.rb +250 -0
  15. data/lib/quicsilver/client/request.rb +98 -0
  16. data/lib/quicsilver/{http3.rb → protocol/frames.rb} +133 -28
  17. data/lib/quicsilver/protocol/qpack/decoder.rb +165 -0
  18. data/lib/quicsilver/protocol/qpack/encoder.rb +189 -0
  19. data/lib/quicsilver/protocol/qpack/header_block_decoder.rb +125 -0
  20. data/lib/quicsilver/protocol/qpack/huffman.rb +459 -0
  21. data/lib/quicsilver/protocol/request_encoder.rb +47 -0
  22. data/lib/quicsilver/protocol/request_parser.rb +387 -0
  23. data/lib/quicsilver/protocol/response_encoder.rb +72 -0
  24. data/lib/quicsilver/protocol/response_parser.rb +249 -0
  25. data/lib/quicsilver/server/listener_data.rb +14 -0
  26. data/lib/quicsilver/server/request_handler.rb +86 -0
  27. data/lib/quicsilver/server/request_registry.rb +50 -0
  28. data/lib/quicsilver/server/server.rb +336 -0
  29. data/lib/quicsilver/transport/configuration.rb +132 -0
  30. data/lib/quicsilver/transport/connection.rb +350 -0
  31. data/lib/quicsilver/transport/event_loop.rb +38 -0
  32. data/lib/quicsilver/transport/inbound_stream.rb +33 -0
  33. data/lib/quicsilver/transport/stream.rb +28 -0
  34. data/lib/quicsilver/transport/stream_event.rb +26 -0
  35. data/lib/quicsilver/version.rb +1 -1
  36. data/lib/quicsilver.rb +31 -13
  37. data/lib/rackup/handler/quicsilver.rb +1 -2
  38. data/quicsilver.gemspec +3 -1
  39. metadata +58 -18
  40. data/benchmarks/benchmark.rb +0 -68
  41. data/lib/quicsilver/client.rb +0 -261
  42. data/lib/quicsilver/connection.rb +0 -42
  43. data/lib/quicsilver/event_loop.rb +0 -38
  44. data/lib/quicsilver/http3/request_encoder.rb +0 -133
  45. data/lib/quicsilver/http3/request_parser.rb +0 -176
  46. data/lib/quicsilver/http3/response_encoder.rb +0 -186
  47. data/lib/quicsilver/http3/response_parser.rb +0 -160
  48. data/lib/quicsilver/listener_data.rb +0 -29
  49. data/lib/quicsilver/quic_stream.rb +0 -36
  50. data/lib/quicsilver/request_registry.rb +0 -48
  51. data/lib/quicsilver/server.rb +0 -355
  52. data/lib/quicsilver/server_configuration.rb +0 -78
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: eb40bd31dbb1c684ae68ec9b8a5f9fd0caee6972d360dc2be67984e473fdd15a
4
- data.tar.gz: 2f46305042b7253d9fedc0018947404d6f27b045aef43be7a03ec32d3fe0bb8d
3
+ metadata.gz: 36065332b2efe43090097b945882282ba770a6b6b5a54d16d7229bf8977dfb6e
4
+ data.tar.gz: 22f94375fdaa43b465b6ff9b866985ca7dd06732ea5e0cbf98a94c7c92eb97df
5
5
  SHA512:
6
- metadata.gz: 48810a1f5a78021b027cb416bca181388b23889456e61c4c05702bd228f15da52026c25b2eedf375473054133300c62efd88c486367ddb987490676c8575a302
7
- data.tar.gz: 7ebe3bdb83efaf1854655f6553e324ee4c425c4b2d202e76cb2c2adce0bfd814e567003fb2b315cb6b373acf8cfed8fb7f08dffa3859e4e103e3480d4877d8c6
6
+ metadata.gz: edcac1b797654202cf22ef471e3de5cd23a8409f0ded8d940652cb6194d14974f0c08f7118af1f76e1895dca0598ae646764687d0fabb0555317e67a3587f690
7
+ data.tar.gz: a1521e2a828ba6bd394939a5c7b1034d1951f92329444f1adee5135db96df9703128b731c673c10c6309d104f238aa95589a9c9cfb581c6e050bc5ef6a261c8f
@@ -26,11 +26,10 @@ jobs:
26
26
 
27
27
  steps:
28
28
  - uses: actions/checkout@v4
29
+ with:
30
+ submodules: recursive
29
31
  - name: Set up Ruby
30
- # To automatically get bug fixes and new Ruby versions for ruby/setup-ruby,
31
- # change this to (see https://github.com/ruby/setup-ruby#versioning):
32
- # uses: ruby/setup-ruby@v1
33
- uses: ruby/setup-ruby@55283cc23133118229fd3f97f9336ee23a179fcf # v1.146.0
32
+ uses: ruby/setup-ruby@v1
34
33
  with:
35
34
  ruby-version: ${{ matrix.ruby-version }}
36
35
  bundler-cache: true # runs 'bundle install' and caches installed gems automatically
data/CHANGELOG.md CHANGED
@@ -5,6 +5,55 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.3.0] - 2026-03-23
9
+
10
+ ### Added
11
+ - QPACK Huffman coding with 8-bit decode table and encode/decode caching
12
+ - 0-RTT replay protection for unsafe HTTP methods
13
+ - Bounded backpressure support
14
+ - Buffer size limits to prevent memory exhaustion (configurable `max_body_size`, `max_header_size`, `max_header_count`, `max_frame_payload_size`)
15
+ - Content-length validation
16
+ - Multi-value header support for duplicate header fields
17
+ - Headers validation: reject connection-specific headers, require `:authority` or `host` for http/https schemes
18
+ - Incremental unidirectional stream processing with critical stream protection
19
+ - QPACK encoder and decoder stream instruction validation
20
+ - Spec-correct error signaling with error codes on `FrameError` and `MessageError`
21
+ - Suppress response body for HEAD requests per RFC 9114 §4.1
22
+ - Allow `te: trailers` header in requests per RFC 9114 §4.2
23
+ - Custom ALPN support (no longer hardcoded to `h3`)
24
+ - `Stream` and `StreamEvent` abstractions to encapsulate C extension details
25
+ - Dual-stack (IPv4/IPv6) listener support — fixes TLS handshake failures on macOS
26
+ - Client `PUT` method
27
+ - Integration test suite for curl HTTP/3
28
+
29
+ ### Fixed
30
+ - Memory leaks: free `StreamContext` on `SHUTDOWN_COMPLETE`, free `ConnectionContext` on `CONNECTION_SHUTDOWN_COMPLETE`, close `EventQ`/`ExecContext`/`WakeFd` on shutdown
31
+ - Double-free and handle leaks in C extension
32
+ - `dispatch_to_ruby` safety with `rb_protect`; client use-after-free fix
33
+ - Infinite loop on truncated varint in request/response parsers
34
+ - Frame ordering: `DATA` before `HEADERS` now raises `FrameError`
35
+ - `STOP_SENDING` / `STREAM_RESET` compliance — server properly cancels streams and resets send side
36
+ - Control stream validation: reject duplicate settings, forbidden frame types, and reserved HTTP/2 types
37
+ - QPACK static table index 57/58 casing (`includeSubDomains`)
38
+ - Stale stream handle guard in cancel and C extension
39
+ - Replaced `Thread.kill` with `Thread.raise(DrainTimeoutError)` for clean drain
40
+ - Binary encoding for `buffer_data` and empty FIN handling
41
+ - Linux/GitHub CI: use epoll instead of kqueue on non-Darwin platforms
42
+ - Circular require warning
43
+
44
+ ### Changed
45
+ - Reorganized gem structure: `protocol/`, `server/`, `transport/` directories
46
+ - Server owns the 0-RTT policy
47
+ - QPACK encoder uses O(1) static table lookup with multi-level caching
48
+ - QPACK decoder uses string-based decoding with result caching
49
+ - HTTP/3 parsers optimized with parse-level caching and lazy allocation
50
+ - Varint encoding/decoding optimized with precomputed tables
51
+ - HTTP/3 encoders handle framing only; QPACK handles field encoding (cleaner separation)
52
+ - MsQuic custom execution mode with configurable worker pool and throughput settings
53
+
54
+ ### Limitations
55
+ - Client does not reuse connections
56
+
8
57
  ## [0.2.0] - 2025-12-17
9
58
 
10
59
  ### Added
data/Gemfile.lock CHANGED
@@ -1,22 +1,25 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- quicsilver (0.1.0)
4
+ quicsilver (0.2.0)
5
5
  localhost (~> 1.6)
6
+ logger
6
7
  rack (~> 3.0)
7
8
  rackup (~> 2.0)
8
9
 
9
10
  GEM
10
11
  remote: https://rubygems.org/
11
12
  specs:
13
+ benchmark-ips (2.14.0)
12
14
  localhost (1.6.0)
13
- minitest (5.25.5)
15
+ logger (1.7.0)
16
+ minitest (5.27.0)
14
17
  minitest-focus (1.4.0)
15
18
  minitest (>= 4, < 6)
16
19
  rack (3.2.4)
17
20
  rackup (2.2.1)
18
21
  rack (>= 3)
19
- rake (10.5.0)
22
+ rake (13.3.1)
20
23
  rake-compiler (1.3.0)
21
24
  rake
22
25
  rake-compiler-dock (1.9.1)
@@ -26,11 +29,12 @@ PLATFORMS
26
29
  ruby
27
30
 
28
31
  DEPENDENCIES
32
+ benchmark-ips (~> 2.12)
29
33
  bundler (~> 2.0)
30
34
  minitest (~> 5.0)
31
35
  minitest-focus (~> 1.3)
32
36
  quicsilver!
33
- rake (~> 10.0)
37
+ rake (~> 13.0)
34
38
  rake-compiler (~> 1.2)
35
39
  rake-compiler-dock (~> 1.3)
36
40
 
data/README.md CHANGED
@@ -60,14 +60,15 @@ rackup -s quicsilver -p 4433
60
60
  ## Configuration
61
61
 
62
62
  ```ruby
63
+ config = Quicsilver::ServerConfiguration.new("/path/to/cert.pem", "/path/to/key.pem",
64
+ idle_timeout_ms: 10_000, # Connection idle timeout (ms)
65
+ max_concurrent_requests: 100 # Max concurrent requests per connection
66
+ )
67
+
63
68
  server = Quicsilver::Server.new(4433,
64
69
  app: app,
65
70
  address: "0.0.0.0",
66
- idle_timeout: 10_000, # Connection idle timeout (ms)
67
- initial_window_size: 65536, # Flow control window
68
- max_streams_bidi: 100, # Max concurrent requests
69
- cert_path: "/path/to/cert.pem", # TLS certificate
70
- key_path: "/path/to/key.pem" # TLS private key
71
+ server_configuration: config
71
72
  )
72
73
  ```
73
74
 
@@ -75,7 +76,7 @@ server = Quicsilver::Server.new(4433,
75
76
 
76
77
  ```bash
77
78
  rake compile # Build C extension
78
- rake test # Run tests (122 passing)
79
+ rake test # Run tests
79
80
  rake clean # Clean build artifacts
80
81
  ```
81
82
 
data/Rakefile CHANGED
@@ -15,8 +15,12 @@ task :setup do
15
15
  end
16
16
 
17
17
  task :build_msquic => :setup do
18
- # Build MSQUIC using CMake with proper macOS framework linking
19
- sh 'cd vendor/msquic && cmake -B build -DCMAKE_BUILD_TYPE=Release -DCMAKE_EXE_LINKER_FLAGS="-framework CoreServices" -DCMAKE_SHARED_LINKER_FLAGS="-framework CoreServices"'
18
+ cmake_args = ['-B build', '-DCMAKE_BUILD_TYPE=Release']
19
+ if RUBY_PLATFORM =~ /darwin/
20
+ cmake_args << '-DCMAKE_EXE_LINKER_FLAGS="-framework CoreServices"'
21
+ cmake_args << '-DCMAKE_SHARED_LINKER_FLAGS="-framework CoreServices"'
22
+ end
23
+ sh "cd vendor/msquic && cmake #{cmake_args.join(' ')}"
20
24
  sh 'cd vendor/msquic && cmake --build build --config Release'
21
25
  end
22
26
 
@@ -28,4 +32,27 @@ Rake::TestTask.new(:test) do |t|
28
32
  t.test_files = FileList["test/**/*_test.rb"]
29
33
  end
30
34
 
35
+ namespace :benchmark do
36
+ desc "Run throughput benchmark"
37
+ task :throughput do
38
+ ruby "benchmarks/throughput.rb"
39
+ end
40
+
41
+ desc "Run concurrency benchmark"
42
+ task :concurrent do
43
+ ruby "benchmarks/concurrent.rb"
44
+ end
45
+
46
+ desc "Run component micro-benchmarks"
47
+ task :components do
48
+ ruby "benchmarks/components.rb"
49
+ end
50
+
51
+ desc "Run all benchmarks"
52
+ task :all => [:components, :throughput, :concurrent]
53
+ end
54
+
55
+ desc "Run all benchmarks"
56
+ task :benchmark => "benchmark:all"
57
+
31
58
  task :default => :test
@@ -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.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
@@ -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,