quicsilver 0.4.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.
Files changed (94) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +9 -1
  3. data/.github/workflows/cibuildgem.yaml +29 -5
  4. data/.gitignore +5 -0
  5. data/CHANGELOG.md +50 -0
  6. data/Gemfile.lock +8 -1
  7. data/README.md +128 -15
  8. data/Rakefile +10 -3
  9. data/benchmarks/baseline.rb +175 -0
  10. data/benchmarks/compare.rb +398 -0
  11. data/benchmarks/helpers.rb +138 -64
  12. data/benchmarks/latency.rb +128 -0
  13. data/benchmarks/loss.rb +193 -0
  14. data/benchmarks/mobile.rb +125 -0
  15. data/docs/connection_identity.md +141 -0
  16. data/examples/benchmark.rb +80 -0
  17. data/examples/connection_pool_demo.rb +1 -1
  18. data/examples/example_helper.rb +51 -0
  19. data/examples/feature_demo.rb +7 -7
  20. data/examples/grpc_style.rb +2 -2
  21. data/examples/latency_aware_routing.rb +382 -0
  22. data/examples/lifecycle_drain.rb +264 -0
  23. data/examples/priorities.rb +2 -2
  24. data/examples/rails_feature_test.rb +92 -72
  25. data/examples/server_stats.rb +117 -0
  26. data/examples/simple_client_test.rb +3 -3
  27. data/examples/trailers.rb +23 -23
  28. data/examples/webtransport_smoke.rb +546 -0
  29. data/examples/worker_load_balancing.rb +425 -0
  30. data/ext/quicsilver/extconf.rb +100 -15
  31. data/ext/quicsilver/include/msquic.h +2226 -0
  32. data/ext/quicsilver/include/msquic_posix.h +696 -0
  33. data/ext/quicsilver/include/quic_sal_stub.h +314 -0
  34. data/ext/quicsilver/msquic_patch.rb +68 -0
  35. data/ext/quicsilver/patches/reliable-reset.patch +723 -0
  36. data/ext/quicsilver/patches/stream-final-size.patch +47 -0
  37. data/ext/quicsilver/quicsilver.c +1640 -222
  38. data/lib/quicsilver/client/client.rb +419 -60
  39. data/lib/quicsilver/client/connection_pool.rb +122 -26
  40. data/lib/quicsilver/client/request.rb +106 -14
  41. data/lib/quicsilver/errors.rb +39 -0
  42. data/lib/quicsilver/libmsquic.2.dylib +0 -0
  43. data/lib/quicsilver/protocol/adapter.rb +41 -21
  44. data/lib/quicsilver/protocol/capsule.rb +38 -0
  45. data/lib/quicsilver/protocol/connect_stream_decoder.rb +75 -0
  46. data/lib/quicsilver/protocol/control_stream_parser.rb +12 -1
  47. data/lib/quicsilver/protocol/datagram.rb +40 -0
  48. data/lib/quicsilver/protocol/frame_parser.rb +5 -6
  49. data/lib/quicsilver/protocol/frame_reader.rb +31 -2
  50. data/lib/quicsilver/protocol/frames.rb +128 -12
  51. data/lib/quicsilver/protocol/priority.rb +9 -0
  52. data/lib/quicsilver/protocol/qpack/decoder.rb +4 -42
  53. data/lib/quicsilver/protocol/qpack/encoder.rb +10 -20
  54. data/lib/quicsilver/protocol/qpack/huffman.rb +4 -4
  55. data/lib/quicsilver/protocol/request_encoder.rb +6 -3
  56. data/lib/quicsilver/protocol/request_parser.rb +16 -2
  57. data/lib/quicsilver/protocol/response_encoder.rb +18 -8
  58. data/lib/quicsilver/protocol/response_parser.rb +6 -0
  59. data/lib/quicsilver/protocol/stream_input.rb +3 -3
  60. data/lib/quicsilver/protocol/unidirectional_stream.rb +12 -0
  61. data/lib/quicsilver/protocol/web_transport.rb +214 -0
  62. data/lib/quicsilver/rack/context.rb +33 -0
  63. data/lib/quicsilver/response.rb +40 -0
  64. data/lib/quicsilver/server/rack_adapter.rb +77 -0
  65. data/lib/quicsilver/server/receive_queue.rb +106 -0
  66. data/lib/quicsilver/server/request_handler.rb +24 -13
  67. data/lib/quicsilver/server/request_registry.rb +18 -5
  68. data/lib/quicsilver/server/scheduler.rb +50 -0
  69. data/lib/quicsilver/server/schedulers/fiber_scheduler.rb +79 -0
  70. data/lib/quicsilver/server/schedulers/thread_scheduler.rb +76 -0
  71. data/lib/quicsilver/server/server.rb +655 -185
  72. data/lib/quicsilver/server/web_transport_flow_control.rb +270 -0
  73. data/lib/quicsilver/server/web_transport_manager.rb +281 -0
  74. data/lib/quicsilver/server/web_transport_registry.rb +40 -0
  75. data/lib/quicsilver/server/web_transport_session.rb +812 -0
  76. data/lib/quicsilver/server/web_transport_stream.rb +326 -0
  77. data/lib/quicsilver/transport/configuration.rb +103 -10
  78. data/lib/quicsilver/transport/connection.rb +302 -87
  79. data/lib/quicsilver/transport/connection_stats.rb +53 -0
  80. data/lib/quicsilver/transport/event_loop.rb +36 -0
  81. data/lib/quicsilver/transport/inbound_stream.rb +21 -1
  82. data/lib/quicsilver/transport/stream.rb +18 -3
  83. data/lib/quicsilver/transport/stream_event.rb +25 -2
  84. data/lib/quicsilver/transport/stream_id.rb +26 -0
  85. data/lib/quicsilver/version.rb +1 -1
  86. data/lib/quicsilver.rb +22 -11
  87. data/lib/rackup/handler/quicsilver.rb +57 -38
  88. data/quicsilver.gemspec +10 -3
  89. metadata +54 -8
  90. data/benchmarks/concurrent.rb +0 -110
  91. data/benchmarks/quicsilver_server.rb +0 -46
  92. data/benchmarks/rails.rb +0 -170
  93. data/benchmarks/throughput.rb +0 -113
  94. data/lib/quicsilver/quicsilver.bundle +0 -0
@@ -0,0 +1,264 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Example 1: graceful drain as an HTTP/3 lifecycle control loop.
5
+ #
6
+ # bundle exec ruby examples/lifecycle_drain.rb
7
+ #
8
+ # This keeps the policy deliberately small and in one file:
9
+ #
10
+ # Quicsilver::Server#stats -> LifecycleSignal -> readiness / drain / safe-to-exit
11
+ #
12
+ # A real quicsilver-health/quicsilver-lifecycle package could extract the
13
+ # LifecycleSignal, but the core idea should stay this boring:
14
+ #
15
+ # 1. fail readiness
16
+ # 2. send HTTP/3 GOAWAY via Server#shutdown
17
+ # 3. refuse/avoid new work
18
+ # 4. finish in-flight streams
19
+ # 5. exit when active work reaches zero or the deadline expires
20
+
21
+ require_relative "example_helper"
22
+
23
+ $stdout.sync = true
24
+
25
+ HOST = ENV.fetch("HOST", "127.0.0.1")
26
+ PORT = Integer(ENV.fetch("PORT", "0"))
27
+ SLOW_REQUESTS = Integer(ENV.fetch("SLOW_REQUESTS", "3"))
28
+ SLOW_MS = Integer(ENV.fetch("SLOW_MS", "2_000"))
29
+ SHUTDOWN_TIMEOUT = Integer(ENV.fetch("SHUTDOWN_TIMEOUT", "10"))
30
+
31
+ class LifecycleSignal
32
+ def self.for(server)
33
+ new(server&.stats || {})
34
+ end
35
+
36
+ def initialize(stats)
37
+ @stats = stats || {}
38
+ end
39
+
40
+ def ready?
41
+ running? && !draining? && !queue_full?
42
+ end
43
+
44
+ def accepting_requests?
45
+ ready?
46
+ end
47
+
48
+ def safe_to_exit?
49
+ draining? && active_requests.zero? && pending_requests.zero?
50
+ end
51
+
52
+ def state
53
+ if !running?
54
+ "stopped"
55
+ elsif draining?
56
+ safe_to_exit? ? "drained" : "draining"
57
+ elsif queue_full?
58
+ "overloaded"
59
+ else
60
+ "ready"
61
+ end
62
+ end
63
+
64
+ def to_h
65
+ {
66
+ "state" => state,
67
+ "ready" => ready?,
68
+ "accepting_requests" => accepting_requests?,
69
+ "safe_to_exit" => safe_to_exit?,
70
+ "active_connections" => active_connections,
71
+ "active_requests" => active_requests,
72
+ "pending_requests" => pending_requests,
73
+ "queue_max" => @stats.dig("scheduler", "max_queue_size"),
74
+ "goaway_or_drain" => draining?
75
+ }
76
+ end
77
+
78
+ private
79
+ def running?
80
+ @stats["running"] == true
81
+ end
82
+
83
+ def draining?
84
+ @stats["shutting_down"] == true
85
+ end
86
+
87
+ def queue_full?
88
+ @stats.dig("scheduler", "full") == true
89
+ end
90
+
91
+ def active_connections
92
+ @stats.dig("connections", "active").to_i
93
+ end
94
+
95
+ def active_requests
96
+ @stats.dig("requests", "active").to_i
97
+ end
98
+
99
+ def pending_requests
100
+ if running? || draining?
101
+ @stats.dig("scheduler", "pending").to_i
102
+ else
103
+ 0
104
+ end
105
+ end
106
+ end
107
+
108
+ def banner(title)
109
+ Example.heading(title, level: 1)
110
+ end
111
+
112
+ def section(number, title)
113
+ Example.heading("#{number}. #{title}")
114
+ end
115
+
116
+ def yes_no(value)
117
+ value ? "yes" : "no"
118
+ end
119
+
120
+ def lifecycle_header
121
+ Example.say
122
+ Example.say format(
123
+ " %-22s %-10s %-5s %-7s %-5s %5s %6s %5s",
124
+ "sample", "state", "ready", "accept", "safe", "conns", "active", "queue"
125
+ )
126
+ Example.say " #{'-' * 75}"
127
+ end
128
+
129
+ def lifecycle_row(label, server)
130
+ data = LifecycleSignal.for(server).to_h
131
+
132
+ Example.say format(
133
+ " %-22s %-10s %-5s %-7s %-5s %5d %6d %5d",
134
+ label,
135
+ data["state"],
136
+ yes_no(data["ready"]),
137
+ yes_no(data["accepting_requests"]),
138
+ yes_no(data["safe_to_exit"]),
139
+ data["active_connections"],
140
+ data["active_requests"],
141
+ data["pending_requests"]
142
+ )
143
+ end
144
+
145
+ def elapsed_label(started_at)
146
+ elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at
147
+ "t+#{format('%.1f', elapsed)}s"
148
+ end
149
+
150
+ def attempt_new_request(client)
151
+ response = client.get("/hello", timeout: 2)
152
+ "#{response.status} #{response.body.inspect}"
153
+ rescue => error
154
+ "#{error.class}: #{error.message}"
155
+ end
156
+
157
+ Quicsilver.logger = Logger.new(File::NULL)
158
+
159
+ port = PORT.zero? ? Example.available_udp_port(HOST) : PORT
160
+ server = nil
161
+
162
+ app = ->(env) {
163
+ signal = LifecycleSignal.for(server)
164
+
165
+ case env["PATH_INFO"]
166
+ when "/_quicsilver/ready"
167
+ Example.json_response(signal.ready? ? 200 : 503, signal.to_h)
168
+ when "/_quicsilver/lifecycle"
169
+ Example.json_response(200, signal.to_h)
170
+ when %r{\A/slow/(\d+)}
171
+ request_id = Regexp.last_match(1)
172
+ sleep SLOW_MS / 1000.0
173
+ Example.text_response(200, "slow request #{request_id} completed while #{signal.state}\n")
174
+ when "/hello"
175
+ if signal.accepting_requests?
176
+ Example.text_response(200, "hello before drain\n")
177
+ else
178
+ Example.json_response(503, "error" => "server draining", "lifecycle" => signal.to_h)
179
+ end
180
+ else
181
+ Example.text_response(404, "not found\n")
182
+ end
183
+ }
184
+
185
+ server = Quicsilver::Server.new(
186
+ port,
187
+ app: app,
188
+ server_configuration: EXAMPLE_TLS_CONFIG,
189
+ threads: SLOW_REQUESTS + 2
190
+ )
191
+ server_thread = Thread.new { server.start }
192
+ client = Quicsilver::Client.new(HOST, port, unsecure: true)
193
+ shutdown_thread = nil
194
+ monitor = nil
195
+ slow_threads = []
196
+ monitor_done = false
197
+
198
+ begin
199
+ Example.wait_until { server.running? }
200
+
201
+ banner "HTTP/3 Lifecycle Drain Demo"
202
+ Example.detail "server", "https://#{HOST}:#{port}"
203
+ Example.detail "slow streams", "#{SLOW_REQUESTS} × #{SLOW_MS}ms"
204
+ Example.detail "shutdown timeout", "#{SHUTDOWN_TIMEOUT}s"
205
+ Example.detail "control loop", "readiness=false -> GOAWAY/drain -> active=0 -> safe exit"
206
+
207
+ section 1, "Warm up the connection"
208
+ Example.detail "GET /hello", attempt_new_request(client)
209
+ lifecycle_header
210
+ lifecycle_row "before drain", server
211
+
212
+ section 2, "Start long-running streams"
213
+ slow_threads = SLOW_REQUESTS.times.map do |index|
214
+ Thread.new do
215
+ started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
216
+ response = client.get("/slow/#{index + 1}", timeout: SHUTDOWN_TIMEOUT + 5)
217
+ elapsed_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at) * 1000).round(1)
218
+ Example.say format(" stream #%-2d completed status=%d elapsed=%7.1fms", index + 1, response.status, elapsed_ms)
219
+ rescue => error
220
+ Example.say format(" stream #%-2d failed %s: %s", index + 1, error.class, error.message)
221
+ end
222
+ end
223
+
224
+ Example.wait_until { server.stats.dig("requests", "active") >= SLOW_REQUESTS }
225
+ lifecycle_header
226
+ lifecycle_row "streams in flight", server
227
+
228
+ section 3, "Begin drain"
229
+ Example.detail "action", "Server#shutdown(timeout: #{SHUTDOWN_TIMEOUT})"
230
+ shutdown_thread = Thread.new { server.shutdown(timeout: SHUTDOWN_TIMEOUT) }
231
+ Example.wait_until { server.shutting_down }
232
+ sleep 0.05 # give GOAWAY/readiness state a moment to propagate, but keep streams in flight
233
+
234
+ Example.detail "readiness", "not ready"
235
+ Example.detail "routing", "new work should move to other endpoints"
236
+ Example.detail "in-flight", "existing streams continue until complete"
237
+ lifecycle_header
238
+ lifecycle_row "after GOAWAY", server
239
+
240
+ monitor_started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
241
+ monitor = Thread.new do
242
+ until monitor_done
243
+ lifecycle_row elapsed_label(monitor_started_at), server
244
+ sleep 0.5
245
+ end
246
+ end
247
+
248
+ slow_threads.each(&:join)
249
+ shutdown_thread.join
250
+ monitor_done = true
251
+ monitor.join
252
+
253
+ section 4, "Shutdown complete"
254
+ lifecycle_header
255
+ lifecycle_row "after shutdown", server
256
+ Example.detail "takeaway", "routing changes immediately; in-flight HTTP/3 streams drain cleanly"
257
+ ensure
258
+ monitor_done = true
259
+ monitor&.join(1)
260
+ client&.disconnect rescue nil
261
+ Quicsilver::Client.close_pool rescue nil
262
+ server&.stop if server&.running? rescue nil
263
+ server_thread&.join(2)
264
+ end
@@ -45,11 +45,11 @@ client = Quicsilver::Client.new(HOST, PORT, unsecure: true)
45
45
  # Request with priority header (what a browser would send)
46
46
  puts "\n CSS request (high priority):"
47
47
  response = client.get("/style.css", headers: { "priority" => "u=0, i" })
48
- puts " Status: #{response[:status]}, Size: #{response[:body].bytesize} bytes"
48
+ puts " Status: #{response.status}, Size: #{response.body.bytesize} bytes"
49
49
 
50
50
  puts "\n Image request (low priority):"
51
51
  response = client.get("/image.png", headers: { "priority" => "u=5" })
52
- puts " Status: #{response[:status]}, Size: #{response[:body].bytesize} bytes"
52
+ puts " Status: #{response.status}, Size: #{response.body.bytesize} bytes"
53
53
 
54
54
  puts "\n MsQuic schedules CSS data before image data"
55
55
  puts " when both are in flight on the same connection."
@@ -25,51 +25,57 @@ rescue => e
25
25
  nil
26
26
  end
27
27
 
28
- puts "🚀 Quicsilver Rails Feature Test"
28
+ puts "Quicsilver Rails Feature Test"
29
29
  puts " Target: https://#{HOST}:#{PORT}"
30
30
  puts "=" * 60
31
31
 
32
32
  # === 1. Basic connectivity ===
33
- puts "\n1️⃣ Basic Connectivity"
33
+ puts "\n1. Basic Connectivity"
34
34
  puts "-" * 60
35
35
 
36
36
  test("GET /h3/ping returns JSON over HTTP/3") do
37
37
  response = Quicsilver::Client.get(HOST, PORT, "/h3/ping", unsecure: true)
38
- raise "Expected 200, got #{response[:status]}" unless response[:status] == 200
39
- raise "Expected JSON" unless response[:body].include?('"status":"ok"')
40
- raise "Expected HTTP/3" unless response[:body].include?("HTTP/3")
41
- puts " Response: #{response[:body]}"
38
+ raise "Expected 200, got #{response.status}" unless response.status == 200
39
+ raise "Expected JSON" unless response.body.include?('"status":"ok"')
40
+ raise "Expected HTTP/3" unless response.body.include?("HTTP/3")
41
+ puts " Response: #{response.body}"
42
42
  end
43
43
 
44
44
  # === 2. Connection Pooling ===
45
- puts "\n2️⃣ Connection Pooling"
45
+ puts "\n2. Connection Pooling"
46
46
  puts "-" * 60
47
47
 
48
- test("Multiple requests reuse the same connection") do
49
- 5.times do |i|
48
+ test("5 requests reuse one connection (no repeat handshakes)") do
49
+ Quicsilver::Client.close_pool
50
+ times = 5.times.map do |i|
50
51
  t = Process.clock_gettime(Process::CLOCK_MONOTONIC)
51
52
  Quicsilver::Client.get(HOST, PORT, "/h3/ping", unsecure: true)
52
53
  elapsed = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - t) * 1000).round(1)
53
- puts " Request #{i}: #{elapsed}ms (#{i == 0 ? 'new connection' : 'same connection'})"
54
+ label = i == 0 ? "new connection + handshake" : "reused"
55
+ puts " Request #{i}: #{elapsed}ms (#{label})"
56
+ elapsed
54
57
  end
55
58
  puts " Pool: #{Quicsilver::Client.pool.size} connection(s)"
56
59
  raise "Pool should have 1 connection" unless Quicsilver::Client.pool.size == 1
60
+ # First request includes handshake, subsequent should be faster on average
61
+ avg_reused = times[1..].sum / times[1..].size
62
+ puts " First request: #{times[0]}ms, avg reused: #{avg_reused.round(1)}ms"
57
63
  end
58
64
 
59
65
  # === 3. Streaming ===
60
- puts "\n3️⃣ Streaming Response"
66
+ puts "\n3. Streaming Response"
61
67
  puts "-" * 60
62
68
 
63
69
  test("GET /h3/stream returns chunked SSE data") do
64
70
  response = Quicsilver::Client.get(HOST, PORT, "/h3/stream", unsecure: true)
65
- raise "Expected 200" unless response[:status] == 200
66
- chunks = response[:body].scan(/data: chunk \d+/)
71
+ raise "Expected 200" unless response.status == 200
72
+ chunks = response.body.scan(/data: chunk \d+/)
67
73
  raise "Expected 5 chunks, got #{chunks.size}" unless chunks.size == 5
68
74
  puts " Received #{chunks.size} streamed chunks"
69
75
  end
70
76
 
71
77
  # === 4. POST with Body (Echo) ===
72
- puts "\n4️⃣ POST with Body"
78
+ puts "\n4. POST with Body"
73
79
  puts "-" * 60
74
80
 
75
81
  test("POST /h3/echo returns the request body") do
@@ -78,90 +84,99 @@ test("POST /h3/echo returns the request body") do
78
84
  body: body,
79
85
  headers: { "content-type" => "application/json" },
80
86
  unsecure: true)
81
- raise "Expected 200" unless response[:status] == 200
82
- raise "Body not echoed" unless response[:body].include?("Hello HTTP/3!")
83
- puts " Echoed: #{response[:body][0..80]}"
87
+ raise "Expected 200" unless response.status == 200
88
+ raise "Body not echoed" unless response.body.include?("Hello HTTP/3!")
89
+ puts " Echoed: #{response.body[0..80]}"
84
90
  end
85
91
 
86
92
  # === 5. HEAD Request ===
87
- puts "\n5️⃣ HEAD Request"
93
+ puts "\n5. HEAD Request"
88
94
  puts "-" * 60
89
95
 
90
96
  test("HEAD /h3/head returns headers but no body") do
91
97
  client = Quicsilver::Client.new(HOST, PORT, unsecure: true)
92
98
  response = client.head("/h3/head")
93
- raise "Expected 200" unless response[:status] == 200
94
- raise "HEAD should have empty body" unless response[:body].nil? || response[:body].empty?
95
- puts " Status: #{response[:status]}, body size: #{response[:body]&.bytesize || 0}"
99
+ raise "Expected 200" unless response.status == 200
100
+ raise "HEAD should have empty body" unless response.body.nil? || response.body.empty?
101
+ puts " Status: #{response.status}, body size: #{response.body&.bytesize || 0}"
96
102
  client.disconnect
97
103
  end
98
104
 
99
- # === 6. Multiplexing (Sequential on single connection) ===
100
- puts "\n6️⃣ Multiplexing (10 requests, single connection)"
105
+ # === 6. True Multiplexing (concurrent streams, single connection) ===
106
+ puts "\n6. True Multiplexing (concurrent streams, single connection)"
101
107
  puts "-" * 60
102
108
 
103
- test("10 sequential requests on one connection") do
109
+ test("10 concurrent requests on one connection") do
104
110
  client = Quicsilver::Client.new(HOST, PORT, unsecure: true)
111
+ client.get("/h3/ping") # warmup + handshake
112
+
105
113
  t_start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
106
- 10.times { client.get("/h3/ping") }
114
+ requests = 10.times.map { client.build_request("GET", "/h3/ping") }
115
+ responses = requests.map { |r| r.response(timeout: 10) }
107
116
  total = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - t_start) * 1000).round(1)
108
- puts " 10 requests in #{total}ms (single connection)"
117
+
118
+ ok = responses.count { |r| r.status == 200 }
119
+ rps = (10 / (total / 1000.0)).round(0)
120
+ puts " 10 concurrent requests in #{total}ms (#{ok}/10 ok, #{rps} req/s)"
121
+ raise "Not all 200" unless ok == 10
109
122
  client.disconnect
110
123
  end
111
124
 
112
125
  # === 7. Priority Endpoints (CSS vs Image) ===
113
- puts "\n7️⃣ Priority Endpoints"
126
+ puts "\n7. Priority Endpoints"
114
127
  puts "-" * 60
115
128
 
116
129
  test("GET /h3/css returns CSS content") do
117
130
  response = Quicsilver::Client.get(HOST, PORT, "/h3/css", unsecure: true)
118
- raise "Expected 200" unless response[:status] == 200
119
- puts " CSS: #{response[:body].bytesize} bytes"
131
+ raise "Expected 200" unless response.status == 200
132
+ puts " CSS: #{response.body.bytesize} bytes"
120
133
  end
121
134
 
122
135
  test("GET /h3/image returns large binary payload") do
123
136
  response = Quicsilver::Client.get(HOST, PORT, "/h3/image", unsecure: true)
124
- raise "Expected 200" unless response[:status] == 200
125
- raise "Expected 50KB" unless response[:body].bytesize == 50_000
126
- puts " Image: #{response[:body].bytesize} bytes"
137
+ raise "Expected 200" unless response.status == 200
138
+ raise "Expected 50KB" unless response.body.bytesize == 50_000
139
+ puts " Image: #{response.body.bytesize} bytes"
127
140
  end
128
141
 
129
142
  # === 8. Slow Response (Delay) ===
130
- puts "\n8️⃣ Slow Response"
143
+ puts "\n8. Slow Response"
131
144
  puts "-" * 60
132
145
 
133
146
  test("GET /h3/slow?delay=0.2 respects delay") do
134
147
  t = Process.clock_gettime(Process::CLOCK_MONOTONIC)
135
148
  response = Quicsilver::Client.get(HOST, PORT, "/h3/slow?delay=0.2", unsecure: true)
136
149
  elapsed = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - t) * 1000).round(1)
137
- raise "Expected 200" unless response[:status] == 200
150
+ raise "Expected 200" unless response.status == 200
138
151
  raise "Should take ~200ms, took #{elapsed}ms" unless elapsed > 150
139
152
  puts " Delayed response in #{elapsed}ms"
140
153
  end
141
154
 
142
- # === 9. Concurrent Multiplexing ===
143
- puts "\n9️⃣ Concurrent Multiplexing"
155
+ # === 9. Concurrent Connections ===
156
+ puts "\n9. Concurrent Connections (4 threads, 4 connections)"
144
157
  puts "-" * 60
145
158
 
146
- test("4 concurrent requests on separate connections") do
159
+ test("4 concurrent requests on separate connections complete in parallel") do
160
+ Quicsilver::Client.close_pool
147
161
  threads = 4.times.map do |i|
148
162
  Thread.new do
163
+ client = Quicsilver::Client.new(HOST, PORT, unsecure: true)
149
164
  t = Process.clock_gettime(Process::CLOCK_MONOTONIC)
150
- response = Quicsilver::Client.get(HOST, PORT, "/h3/slow?delay=0.2", unsecure: true)
165
+ response = client.get("/h3/slow?delay=0.2")
151
166
  elapsed = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - t) * 1000).round(1)
152
- [i, response[:status], elapsed]
167
+ client.disconnect
168
+ [i, response.status, elapsed]
153
169
  end
154
170
  end
155
171
  results = threads.map(&:value).sort_by(&:first)
156
- results.each { |i, status, elapsed| puts " Stream #{i}: #{status} — #{elapsed}ms" }
172
+ results.each { |i, status, elapsed| puts " Connection #{i}: #{status} -- #{elapsed}ms" }
157
173
  total = results.map { |_, _, e| e }.max
158
- # All 4 should complete in roughly the same time (~200ms) not 4x (800ms)
159
- raise "Expected concurrent, took #{total}ms" if total > 600
160
- puts " All 4 concurrent in #{total}ms (not #{200 * 4}ms sequential)"
174
+ sequential = 200 * 4
175
+ puts " Total: #{total}ms (sequential would be ~#{sequential}ms)"
161
176
  end
162
177
 
163
178
  # === 10. Large Upload ===
164
- puts "\n🔟 Large Upload"
179
+ puts "\n10. Large Upload"
165
180
  puts "-" * 60
166
181
 
167
182
  test("POST 100KB body") do
@@ -170,13 +185,13 @@ test("POST 100KB body") do
170
185
  body: big_body,
171
186
  headers: { "content-type" => "application/octet-stream" },
172
187
  unsecure: true)
173
- raise "Expected 200" unless response[:status] == 200
174
- raise "Size mismatch" unless response[:body].include?('"received_bytes":100000')
188
+ raise "Expected 200" unless response.status == 200
189
+ raise "Size mismatch" unless response.body.include?('"received_bytes":100000')
175
190
  puts " Uploaded 100KB, server received 100,000 bytes"
176
191
  end
177
192
 
178
193
  # === 11. Content-Length Validation ===
179
- puts "\n1️⃣1️⃣ Content-Length Validation"
194
+ puts "\n11. Content-Length Validation"
180
195
  puts "-" * 60
181
196
 
182
197
  test("POST with content-length header") do
@@ -185,76 +200,81 @@ test("POST with content-length header") do
185
200
  body: body,
186
201
  headers: { "content-type" => "text/plain" },
187
202
  unsecure: true)
188
- raise "Expected 200, got #{response[:status]}" unless response[:status] == 200
189
- raise "Size mismatch" unless response[:body].include?('"received_bytes":5')
203
+ raise "Expected 200, got #{response.status}" unless response.status == 200
204
+ raise "Size mismatch" unless response.body.include?('"received_bytes":5')
190
205
  puts " Sent 5 bytes, server received 5 bytes"
191
206
  end
192
207
 
193
- # === 12. 0-RTT Reconnection ===
194
- puts "\n1️⃣2️⃣ 0-RTT Reconnection"
208
+ # === 12. Handshake Cost (connection pooling benefit) ===
209
+ puts "\n12. Handshake Cost"
195
210
  puts "-" * 60
196
211
 
197
- test("Two separate connections show handshake cost") do
212
+ test("Fresh connections pay handshake cost -- pooling avoids this") do
198
213
  Quicsilver::Client.close_pool
199
214
 
200
- # First connection — full handshake
215
+ # First connection -- full handshake
201
216
  client1 = Quicsilver::Client.new(HOST, PORT, unsecure: true)
202
217
  t1 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
203
218
  client1.get("/h3/ping")
204
219
  first = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - t1) * 1000).round(1)
205
220
  client1.disconnect
206
221
 
207
- sleep 0.1
208
-
209
- # Second connection — new handshake (0-RTT depends on MsQuic session cache)
222
+ # Second connection -- another full handshake
210
223
  client2 = Quicsilver::Client.new(HOST, PORT, unsecure: true)
211
224
  t2 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
212
225
  client2.get("/h3/ping")
213
226
  second = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - t2) * 1000).round(1)
214
227
  client2.disconnect
215
228
 
216
- puts " First connection: #{first}ms"
217
- puts " Second connection: #{second}ms"
218
- puts " Pooling saves: ~#{first}ms per request by avoiding handshakes"
229
+ # Pooled -- reuses existing connection, no handshake
230
+ Quicsilver::Client.close_pool
231
+ Quicsilver::Client.get(HOST, PORT, "/h3/ping", unsecure: true) # establish
232
+ t3 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
233
+ Quicsilver::Client.get(HOST, PORT, "/h3/ping", unsecure: true)
234
+ pooled = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - t3) * 1000).round(1)
235
+
236
+ puts " Fresh connection 1: #{first}ms"
237
+ puts " Fresh connection 2: #{second}ms"
238
+ puts " Pooled (no handshake): #{pooled}ms"
219
239
  end
220
240
 
221
- # === 13. GOAWAY Graceful Shutdown ===
222
- puts "\n1️⃣3️⃣ GOAWAY (server keeps serving during test)"
241
+ # === 13. Server Health ===
242
+ puts "\n13. Server Health"
223
243
  puts "-" * 60
224
244
 
225
- test("Server responds after many requests (no GOAWAY yet)") do
245
+ test("Server still responding after all tests") do
226
246
  response = Quicsilver::Client.get(HOST, PORT, "/h3/ping", unsecure: true)
227
- raise "Expected 200" unless response[:status] == 200
228
- puts " Server still healthy after all tests"
247
+ raise "Expected 200" unless response.status == 200
248
+ puts " Server healthy"
229
249
  end
230
250
 
231
251
  # === 14. Rails CRUD ===
232
- puts "\n1️⃣4️⃣ Rails CRUD"
252
+ puts "\n14. Rails CRUD"
233
253
  puts "-" * 60
234
254
 
235
255
  test("GET /posts.json returns posts array") do
236
256
  response = Quicsilver::Client.get(HOST, PORT, "/posts.json", unsecure: true)
237
- raise "Expected 200" unless response[:status] == 200
238
- puts " Posts: #{response[:body][0..40]}"
257
+ raise "Expected 200" unless response.status == 200
258
+ puts " Posts: #{response.body[0..40]}"
239
259
  end
240
260
 
241
261
  # === 15. HTTP Methods ===
242
- puts "\n1️⃣5️⃣ HTTP Methods"
262
+ puts "\n15. HTTP Methods"
243
263
  puts "-" * 60
244
264
 
245
265
  client = Quicsilver::Client.new(HOST, PORT, unsecure: true)
246
266
  %i[get post put patch delete head].each do |method|
247
267
  test("#{method.to_s.upcase} /h3/ping") do
248
268
  response = client.public_send(method, "/h3/ping")
249
- body_size = response[:body]&.bytesize || 0
250
- puts " #{response[:status]} (#{body_size} bytes)"
269
+ body_size = response.body&.bytesize || 0
270
+ puts " #{response.status} (#{body_size} bytes)"
251
271
  end
252
272
  end
253
273
  client.disconnect
254
274
 
255
275
  # === Summary ===
256
276
  puts "\n" + "=" * 60
257
- puts "🏁 Feature test complete!"
277
+ puts "Feature test complete!"
258
278
  puts " Pool: #{Quicsilver::Client.pool.size} connection(s)"
259
279
  Quicsilver::Client.close_pool
260
- puts "👋 Done"
280
+ puts "Done"