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.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yml +9 -1
- data/.github/workflows/cibuildgem.yaml +29 -5
- data/.gitignore +5 -0
- data/CHANGELOG.md +50 -0
- data/Gemfile.lock +8 -1
- data/README.md +128 -15
- data/Rakefile +10 -3
- data/benchmarks/baseline.rb +175 -0
- data/benchmarks/compare.rb +398 -0
- data/benchmarks/helpers.rb +138 -64
- data/benchmarks/latency.rb +128 -0
- data/benchmarks/loss.rb +193 -0
- data/benchmarks/mobile.rb +125 -0
- data/docs/connection_identity.md +141 -0
- data/examples/benchmark.rb +80 -0
- data/examples/connection_pool_demo.rb +1 -1
- data/examples/example_helper.rb +51 -0
- data/examples/feature_demo.rb +7 -7
- data/examples/grpc_style.rb +2 -2
- data/examples/latency_aware_routing.rb +382 -0
- data/examples/lifecycle_drain.rb +264 -0
- data/examples/priorities.rb +2 -2
- data/examples/rails_feature_test.rb +92 -72
- data/examples/server_stats.rb +117 -0
- data/examples/simple_client_test.rb +3 -3
- data/examples/trailers.rb +23 -23
- data/examples/webtransport_smoke.rb +546 -0
- data/examples/worker_load_balancing.rb +425 -0
- data/ext/quicsilver/extconf.rb +100 -15
- data/ext/quicsilver/include/msquic.h +2226 -0
- data/ext/quicsilver/include/msquic_posix.h +696 -0
- data/ext/quicsilver/include/quic_sal_stub.h +314 -0
- data/ext/quicsilver/msquic_patch.rb +68 -0
- data/ext/quicsilver/patches/reliable-reset.patch +723 -0
- data/ext/quicsilver/patches/stream-final-size.patch +47 -0
- data/ext/quicsilver/quicsilver.c +1640 -222
- data/lib/quicsilver/client/client.rb +419 -60
- data/lib/quicsilver/client/connection_pool.rb +122 -26
- data/lib/quicsilver/client/request.rb +106 -14
- data/lib/quicsilver/errors.rb +39 -0
- data/lib/quicsilver/libmsquic.2.dylib +0 -0
- data/lib/quicsilver/protocol/adapter.rb +41 -21
- data/lib/quicsilver/protocol/capsule.rb +38 -0
- data/lib/quicsilver/protocol/connect_stream_decoder.rb +75 -0
- data/lib/quicsilver/protocol/control_stream_parser.rb +12 -1
- data/lib/quicsilver/protocol/datagram.rb +40 -0
- data/lib/quicsilver/protocol/frame_parser.rb +5 -6
- data/lib/quicsilver/protocol/frame_reader.rb +31 -2
- data/lib/quicsilver/protocol/frames.rb +128 -12
- data/lib/quicsilver/protocol/priority.rb +9 -0
- data/lib/quicsilver/protocol/qpack/decoder.rb +4 -42
- data/lib/quicsilver/protocol/qpack/encoder.rb +10 -20
- data/lib/quicsilver/protocol/qpack/huffman.rb +4 -4
- data/lib/quicsilver/protocol/request_encoder.rb +6 -3
- data/lib/quicsilver/protocol/request_parser.rb +16 -2
- data/lib/quicsilver/protocol/response_encoder.rb +18 -8
- data/lib/quicsilver/protocol/response_parser.rb +6 -0
- data/lib/quicsilver/protocol/stream_input.rb +3 -3
- data/lib/quicsilver/protocol/unidirectional_stream.rb +12 -0
- data/lib/quicsilver/protocol/web_transport.rb +214 -0
- data/lib/quicsilver/rack/context.rb +33 -0
- data/lib/quicsilver/response.rb +40 -0
- data/lib/quicsilver/server/rack_adapter.rb +77 -0
- data/lib/quicsilver/server/receive_queue.rb +106 -0
- data/lib/quicsilver/server/request_handler.rb +24 -13
- data/lib/quicsilver/server/request_registry.rb +18 -5
- data/lib/quicsilver/server/scheduler.rb +50 -0
- data/lib/quicsilver/server/schedulers/fiber_scheduler.rb +79 -0
- data/lib/quicsilver/server/schedulers/thread_scheduler.rb +76 -0
- data/lib/quicsilver/server/server.rb +655 -185
- data/lib/quicsilver/server/web_transport_flow_control.rb +270 -0
- data/lib/quicsilver/server/web_transport_manager.rb +281 -0
- data/lib/quicsilver/server/web_transport_registry.rb +40 -0
- data/lib/quicsilver/server/web_transport_session.rb +812 -0
- data/lib/quicsilver/server/web_transport_stream.rb +326 -0
- data/lib/quicsilver/transport/configuration.rb +103 -10
- data/lib/quicsilver/transport/connection.rb +302 -87
- data/lib/quicsilver/transport/connection_stats.rb +53 -0
- data/lib/quicsilver/transport/event_loop.rb +36 -0
- data/lib/quicsilver/transport/inbound_stream.rb +21 -1
- data/lib/quicsilver/transport/stream.rb +18 -3
- data/lib/quicsilver/transport/stream_event.rb +25 -2
- data/lib/quicsilver/transport/stream_id.rb +26 -0
- data/lib/quicsilver/version.rb +1 -1
- data/lib/quicsilver.rb +22 -11
- data/lib/rackup/handler/quicsilver.rb +57 -38
- data/quicsilver.gemspec +10 -3
- metadata +54 -8
- data/benchmarks/concurrent.rb +0 -110
- data/benchmarks/quicsilver_server.rb +0 -46
- data/benchmarks/rails.rb +0 -170
- data/benchmarks/throughput.rb +0 -113
- data/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
|
data/examples/priorities.rb
CHANGED
|
@@ -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
|
|
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
|
|
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 "
|
|
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
|
|
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
|
|
39
|
-
raise "Expected JSON" unless response
|
|
40
|
-
raise "Expected HTTP/3" unless response
|
|
41
|
-
puts " Response: #{response
|
|
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
|
|
45
|
+
puts "\n2. Connection Pooling"
|
|
46
46
|
puts "-" * 60
|
|
47
47
|
|
|
48
|
-
test("
|
|
49
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
66
|
-
chunks = response
|
|
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
|
|
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
|
|
82
|
-
raise "Body not echoed" unless response
|
|
83
|
-
puts " Echoed: #{response
|
|
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
|
|
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
|
|
94
|
-
raise "HEAD should have empty body" unless response
|
|
95
|
-
puts " Status: #{response
|
|
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 (
|
|
100
|
-
puts "\n6
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
|
119
|
-
puts " CSS: #{response
|
|
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
|
|
125
|
-
raise "Expected 50KB" unless response
|
|
126
|
-
puts " Image: #{response
|
|
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
|
|
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
|
|
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
|
|
143
|
-
puts "\n9
|
|
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 =
|
|
165
|
+
response = client.get("/h3/slow?delay=0.2")
|
|
151
166
|
elapsed = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - t) * 1000).round(1)
|
|
152
|
-
|
|
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 "
|
|
172
|
+
results.each { |i, status, elapsed| puts " Connection #{i}: #{status} -- #{elapsed}ms" }
|
|
157
173
|
total = results.map { |_, _, e| e }.max
|
|
158
|
-
|
|
159
|
-
|
|
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 "\
|
|
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
|
|
174
|
-
raise "Size mismatch" unless response
|
|
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 "\
|
|
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
|
|
189
|
-
raise "Size mismatch" unless response
|
|
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.
|
|
194
|
-
puts "\
|
|
208
|
+
# === 12. Handshake Cost (connection pooling benefit) ===
|
|
209
|
+
puts "\n12. Handshake Cost"
|
|
195
210
|
puts "-" * 60
|
|
196
211
|
|
|
197
|
-
test("
|
|
212
|
+
test("Fresh connections pay handshake cost -- pooling avoids this") do
|
|
198
213
|
Quicsilver::Client.close_pool
|
|
199
214
|
|
|
200
|
-
# First connection
|
|
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
|
-
|
|
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
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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.
|
|
222
|
-
puts "\
|
|
241
|
+
# === 13. Server Health ===
|
|
242
|
+
puts "\n13. Server Health"
|
|
223
243
|
puts "-" * 60
|
|
224
244
|
|
|
225
|
-
test("Server
|
|
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
|
|
228
|
-
puts " Server
|
|
247
|
+
raise "Expected 200" unless response.status == 200
|
|
248
|
+
puts " Server healthy"
|
|
229
249
|
end
|
|
230
250
|
|
|
231
251
|
# === 14. Rails CRUD ===
|
|
232
|
-
puts "\
|
|
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
|
|
238
|
-
puts " Posts: #{response
|
|
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 "\
|
|
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
|
|
250
|
-
puts " #{response
|
|
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 "
|
|
277
|
+
puts "Feature test complete!"
|
|
258
278
|
puts " Pool: #{Quicsilver::Client.pool.size} connection(s)"
|
|
259
279
|
Quicsilver::Client.close_pool
|
|
260
|
-
puts "
|
|
280
|
+
puts "Done"
|