quicsilver 0.5.0-x86_64-linux
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 +7 -0
- data/.github/workflows/ci.yml +49 -0
- data/.github/workflows/cibuildgem.yaml +117 -0
- data/.gitignore +21 -0
- data/.gitmodules +3 -0
- data/.ruby-version +1 -0
- data/CHANGELOG.md +175 -0
- data/Gemfile +6 -0
- data/Gemfile.lock +67 -0
- data/LICENSE +21 -0
- data/README.md +261 -0
- data/Rakefile +130 -0
- data/benchmarks/baseline.rb +175 -0
- data/benchmarks/compare.rb +398 -0
- data/benchmarks/components.rb +191 -0
- data/benchmarks/helpers.rb +162 -0
- data/benchmarks/latency.rb +128 -0
- data/benchmarks/loss.rb +193 -0
- data/benchmarks/mobile.rb +125 -0
- data/bin/console +14 -0
- data/bin/setup +8 -0
- data/docs/connection_identity.md +141 -0
- data/examples/README.md +58 -0
- data/examples/benchmark.rb +191 -0
- data/examples/connection_pool_demo.rb +47 -0
- data/examples/example_helper.rb +69 -0
- data/examples/falcon_middleware.rb +44 -0
- data/examples/feature_demo.rb +125 -0
- data/examples/grpc_style.rb +97 -0
- data/examples/latency_aware_routing.rb +382 -0
- data/examples/lifecycle_drain.rb +264 -0
- data/examples/minimal_http3_server.rb +14 -0
- data/examples/priorities.rb +60 -0
- data/examples/protocol_http_server.rb +31 -0
- data/examples/rack_http3_server.rb +42 -0
- data/examples/rails_feature_test.rb +280 -0
- data/examples/server_stats.rb +117 -0
- data/examples/simple_client_test.rb +26 -0
- data/examples/streaming_sse.rb +33 -0
- data/examples/trailers.rb +69 -0
- data/examples/webtransport_smoke.rb +546 -0
- data/examples/worker_load_balancing.rb +425 -0
- data/ext/quicsilver/extconf.rb +124 -0
- 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 +2741 -0
- data/lib/quicsilver/3.4/quicsilver.so +0 -0
- data/lib/quicsilver/4.0/quicsilver.so +0 -0
- data/lib/quicsilver/client/client.rb +708 -0
- data/lib/quicsilver/client/connection_pool.rb +202 -0
- data/lib/quicsilver/client/request.rb +190 -0
- data/lib/quicsilver/errors.rb +39 -0
- data/lib/quicsilver/libmsquic.so.2 +0 -0
- data/lib/quicsilver/protocol/adapter.rb +196 -0
- 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 +117 -0
- data/lib/quicsilver/protocol/datagram.rb +40 -0
- data/lib/quicsilver/protocol/frame_parser.rb +141 -0
- data/lib/quicsilver/protocol/frame_reader.rb +84 -0
- data/lib/quicsilver/protocol/frames.rb +454 -0
- data/lib/quicsilver/protocol/priority.rb +65 -0
- data/lib/quicsilver/protocol/qpack/decoder.rb +127 -0
- data/lib/quicsilver/protocol/qpack/encoder.rb +217 -0
- data/lib/quicsilver/protocol/qpack/header_block_decoder.rb +140 -0
- data/lib/quicsilver/protocol/qpack/huffman.rb +459 -0
- data/lib/quicsilver/protocol/request_encoder.rb +50 -0
- data/lib/quicsilver/protocol/request_parser.rb +289 -0
- data/lib/quicsilver/protocol/response_encoder.rb +107 -0
- data/lib/quicsilver/protocol/response_parser.rb +147 -0
- data/lib/quicsilver/protocol/stream_input.rb +98 -0
- data/lib/quicsilver/protocol/stream_output.rb +59 -0
- 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/listener_data.rb +14 -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 +149 -0
- data/lib/quicsilver/server/request_registry.rb +63 -0
- 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 +1080 -0
- 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 +234 -0
- data/lib/quicsilver/transport/connection.rb +594 -0
- data/lib/quicsilver/transport/connection_stats.rb +53 -0
- data/lib/quicsilver/transport/event_loop.rb +74 -0
- data/lib/quicsilver/transport/inbound_stream.rb +53 -0
- data/lib/quicsilver/transport/stream.rb +43 -0
- data/lib/quicsilver/transport/stream_event.rb +49 -0
- data/lib/quicsilver/transport/stream_id.rb +26 -0
- data/lib/quicsilver/version.rb +3 -0
- data/lib/quicsilver.rb +96 -0
- data/lib/rackup/handler/quicsilver.rb +96 -0
- data/quicsilver.gemspec +66 -0
- metadata +362 -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
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
|
|
3
|
+
require_relative "example_helper"
|
|
4
|
+
|
|
5
|
+
puts "🚀 Minimal HTTP/3 Server Example"
|
|
6
|
+
puts "=" * 40
|
|
7
|
+
|
|
8
|
+
server = Quicsilver::Server.new(4433, server_configuration: EXAMPLE_TLS_CONFIG)
|
|
9
|
+
|
|
10
|
+
puts "✅ Listening on https://localhost:4433"
|
|
11
|
+
puts "⏳ Press Ctrl+C to stop."
|
|
12
|
+
puts
|
|
13
|
+
|
|
14
|
+
server.start
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
|
|
3
|
+
# HTTP/3 Extensible Priorities (RFC 9218).
|
|
4
|
+
#
|
|
5
|
+
# Browsers send priority hints: CSS is urgency 0 (highest),
|
|
6
|
+
# images are urgency 5 (low). Quicsilver parses these and tells
|
|
7
|
+
# MsQuic to send high-priority data first.
|
|
8
|
+
#
|
|
9
|
+
# ruby examples/priorities.rb
|
|
10
|
+
#
|
|
11
|
+
# Then from another terminal, the client fires CSS and image
|
|
12
|
+
# requests and shows the priority was parsed.
|
|
13
|
+
|
|
14
|
+
require_relative "example_helper"
|
|
15
|
+
|
|
16
|
+
PORT = 4433
|
|
17
|
+
HOST = "localhost"
|
|
18
|
+
|
|
19
|
+
app = ->(env) {
|
|
20
|
+
path = env["PATH_INFO"]
|
|
21
|
+
|
|
22
|
+
case path
|
|
23
|
+
when "/style.css"
|
|
24
|
+
# Browsers send: priority: u=0 (highest urgency)
|
|
25
|
+
[200, { "content-type" => "text/css" }, ["body { margin: 0; }\n" * 50]]
|
|
26
|
+
when "/image.png"
|
|
27
|
+
# Browsers send: priority: u=5 (low urgency)
|
|
28
|
+
[200, { "content-type" => "image/png" }, ["x" * 10_000]]
|
|
29
|
+
else
|
|
30
|
+
[200, { "content-type" => "text/html" }, [
|
|
31
|
+
"<link rel='stylesheet' href='/style.css'>\n<img src='/image.png'>\n"
|
|
32
|
+
]]
|
|
33
|
+
end
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
server = Quicsilver::Server.new(PORT, app: app, server_configuration: EXAMPLE_TLS_CONFIG)
|
|
37
|
+
server_thread = Thread.new { server.start }
|
|
38
|
+
sleep 0.3
|
|
39
|
+
|
|
40
|
+
puts "🎯 HTTP/3 Priorities Demo"
|
|
41
|
+
puts "=" * 50
|
|
42
|
+
|
|
43
|
+
client = Quicsilver::Client.new(HOST, PORT, unsecure: true)
|
|
44
|
+
|
|
45
|
+
# Request with priority header (what a browser would send)
|
|
46
|
+
puts "\n CSS request (high priority):"
|
|
47
|
+
response = client.get("/style.css", headers: { "priority" => "u=0, i" })
|
|
48
|
+
puts " Status: #{response.status}, Size: #{response.body.bytesize} bytes"
|
|
49
|
+
|
|
50
|
+
puts "\n Image request (low priority):"
|
|
51
|
+
response = client.get("/image.png", headers: { "priority" => "u=5" })
|
|
52
|
+
puts " Status: #{response.status}, Size: #{response.body.bytesize} bytes"
|
|
53
|
+
|
|
54
|
+
puts "\n MsQuic schedules CSS data before image data"
|
|
55
|
+
puts " when both are in flight on the same connection."
|
|
56
|
+
|
|
57
|
+
client.disconnect
|
|
58
|
+
server.stop
|
|
59
|
+
server_thread.join(2)
|
|
60
|
+
puts "\n✅ Done"
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Example: Running a Rack app with protocol-http as the internal layer
|
|
5
|
+
#
|
|
6
|
+
# quicsilver uses protocol-http Request/Response internally.
|
|
7
|
+
# Rack apps are automatically wrapped — no code changes needed.
|
|
8
|
+
#
|
|
9
|
+
# Modes:
|
|
10
|
+
# :rack (default) — Rack app, wrapped with Protocol::Rack::Adapter
|
|
11
|
+
# :falcon — native protocol-http app, no wrapping
|
|
12
|
+
|
|
13
|
+
require_relative "example_helper"
|
|
14
|
+
|
|
15
|
+
# Any standard Rack app works
|
|
16
|
+
app = ->(env) {
|
|
17
|
+
puts "#{env['REQUEST_METHOD']} #{env['PATH_INFO']} #{env['SERVER_PROTOCOL']}"
|
|
18
|
+
|
|
19
|
+
body = "Hello from Quicsilver!\n" \
|
|
20
|
+
"Method: #{env['REQUEST_METHOD']}\n" \
|
|
21
|
+
"Path: #{env['PATH_INFO']}\n" \
|
|
22
|
+
"Protocol: #{env['SERVER_PROTOCOL']}\n"
|
|
23
|
+
|
|
24
|
+
[200, { "content-type" => "text/plain" }, [body]]
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
server = Quicsilver::Server.new(4433, app: app, server_configuration: EXAMPLE_TLS_CONFIG)
|
|
28
|
+
|
|
29
|
+
puts "Starting Quicsilver on https://localhost:4433"
|
|
30
|
+
puts "Test with: curl --http3 -k https://localhost:4433/"
|
|
31
|
+
server.start
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
|
|
3
|
+
require_relative "example_helper"
|
|
4
|
+
|
|
5
|
+
puts "🚀 Rack HTTP/3 Server Example"
|
|
6
|
+
puts "=" * 40
|
|
7
|
+
|
|
8
|
+
# Define a simple Rack app
|
|
9
|
+
app = ->(env) {
|
|
10
|
+
path = env['PATH_INFO']
|
|
11
|
+
method = env['REQUEST_METHOD']
|
|
12
|
+
|
|
13
|
+
case path
|
|
14
|
+
when '/'
|
|
15
|
+
[200,
|
|
16
|
+
{'Content-Type' => 'text/plain'},
|
|
17
|
+
["Welcome to Quicsilver HTTP/3!\n"]]
|
|
18
|
+
when '/api/users'
|
|
19
|
+
[200,
|
|
20
|
+
{'Content-Type' => 'application/json'},
|
|
21
|
+
['{"users": ["alice", "bob", "charlie"]}']]
|
|
22
|
+
when '/api/status'
|
|
23
|
+
[200,
|
|
24
|
+
{'Content-Type' => 'application/json'},
|
|
25
|
+
["{\"status\": \"ok\", \"method\": \"#{method}\", \"path\": \"#{path}\"}"]]
|
|
26
|
+
else
|
|
27
|
+
[404,
|
|
28
|
+
{'Content-Type' => 'text/plain'},
|
|
29
|
+
["Not Found: #{path}\n"]]
|
|
30
|
+
end
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
server = Quicsilver::Server.new(4433, app: app, server_configuration: EXAMPLE_TLS_CONFIG)
|
|
34
|
+
|
|
35
|
+
puts "📋 Try these requests:"
|
|
36
|
+
puts " curl --http3 -k https://localhost:4433/"
|
|
37
|
+
puts " curl --http3 -k https://localhost:4433/api/users"
|
|
38
|
+
puts " curl --http3 -k https://localhost:4433/api/status"
|
|
39
|
+
puts "⏳ Press Ctrl+C to stop."
|
|
40
|
+
puts
|
|
41
|
+
|
|
42
|
+
server.start
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
|
|
3
|
+
# Tests all major quicsilver features against a running Rails app.
|
|
4
|
+
#
|
|
5
|
+
# Prerequisites:
|
|
6
|
+
# 1. Start the Rails app: cd blogz && bundle exec rackup -s quicsilver -p 4433
|
|
7
|
+
# 2. Run this script: cd quicsilver && bundle exec ruby examples/rails_feature_test.rb
|
|
8
|
+
#
|
|
9
|
+
# Tests: ping, streaming, priorities, echo, HEAD, multiplexing, connection pooling
|
|
10
|
+
|
|
11
|
+
require "bundler/setup"
|
|
12
|
+
require "quicsilver"
|
|
13
|
+
|
|
14
|
+
HOST = "localhost"
|
|
15
|
+
PORT = 4433
|
|
16
|
+
PASS = "✅"
|
|
17
|
+
FAIL = "❌"
|
|
18
|
+
|
|
19
|
+
def test(name)
|
|
20
|
+
result = yield
|
|
21
|
+
puts " #{PASS} #{name}"
|
|
22
|
+
result
|
|
23
|
+
rescue => e
|
|
24
|
+
puts " #{FAIL} #{name}: #{e.message}"
|
|
25
|
+
nil
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
puts "Quicsilver Rails Feature Test"
|
|
29
|
+
puts " Target: https://#{HOST}:#{PORT}"
|
|
30
|
+
puts "=" * 60
|
|
31
|
+
|
|
32
|
+
# === 1. Basic connectivity ===
|
|
33
|
+
puts "\n1. Basic Connectivity"
|
|
34
|
+
puts "-" * 60
|
|
35
|
+
|
|
36
|
+
test("GET /h3/ping returns JSON over HTTP/3") do
|
|
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}"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# === 2. Connection Pooling ===
|
|
45
|
+
puts "\n2. Connection Pooling"
|
|
46
|
+
puts "-" * 60
|
|
47
|
+
|
|
48
|
+
test("5 requests reuse one connection (no repeat handshakes)") do
|
|
49
|
+
Quicsilver::Client.close_pool
|
|
50
|
+
times = 5.times.map do |i|
|
|
51
|
+
t = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
52
|
+
Quicsilver::Client.get(HOST, PORT, "/h3/ping", unsecure: true)
|
|
53
|
+
elapsed = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - t) * 1000).round(1)
|
|
54
|
+
label = i == 0 ? "new connection + handshake" : "reused"
|
|
55
|
+
puts " Request #{i}: #{elapsed}ms (#{label})"
|
|
56
|
+
elapsed
|
|
57
|
+
end
|
|
58
|
+
puts " Pool: #{Quicsilver::Client.pool.size} connection(s)"
|
|
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"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# === 3. Streaming ===
|
|
66
|
+
puts "\n3. Streaming Response"
|
|
67
|
+
puts "-" * 60
|
|
68
|
+
|
|
69
|
+
test("GET /h3/stream returns chunked SSE data") do
|
|
70
|
+
response = Quicsilver::Client.get(HOST, PORT, "/h3/stream", unsecure: true)
|
|
71
|
+
raise "Expected 200" unless response.status == 200
|
|
72
|
+
chunks = response.body.scan(/data: chunk \d+/)
|
|
73
|
+
raise "Expected 5 chunks, got #{chunks.size}" unless chunks.size == 5
|
|
74
|
+
puts " Received #{chunks.size} streamed chunks"
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# === 4. POST with Body (Echo) ===
|
|
78
|
+
puts "\n4. POST with Body"
|
|
79
|
+
puts "-" * 60
|
|
80
|
+
|
|
81
|
+
test("POST /h3/echo returns the request body") do
|
|
82
|
+
body = '{"message": "Hello HTTP/3!"}'
|
|
83
|
+
response = Quicsilver::Client.post(HOST, PORT, "/h3/echo",
|
|
84
|
+
body: body,
|
|
85
|
+
headers: { "content-type" => "application/json" },
|
|
86
|
+
unsecure: true)
|
|
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]}"
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# === 5. HEAD Request ===
|
|
93
|
+
puts "\n5. HEAD Request"
|
|
94
|
+
puts "-" * 60
|
|
95
|
+
|
|
96
|
+
test("HEAD /h3/head returns headers but no body") do
|
|
97
|
+
client = Quicsilver::Client.new(HOST, PORT, unsecure: true)
|
|
98
|
+
response = client.head("/h3/head")
|
|
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}"
|
|
102
|
+
client.disconnect
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# === 6. True Multiplexing (concurrent streams, single connection) ===
|
|
106
|
+
puts "\n6. True Multiplexing (concurrent streams, single connection)"
|
|
107
|
+
puts "-" * 60
|
|
108
|
+
|
|
109
|
+
test("10 concurrent requests on one connection") do
|
|
110
|
+
client = Quicsilver::Client.new(HOST, PORT, unsecure: true)
|
|
111
|
+
client.get("/h3/ping") # warmup + handshake
|
|
112
|
+
|
|
113
|
+
t_start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
114
|
+
requests = 10.times.map { client.build_request("GET", "/h3/ping") }
|
|
115
|
+
responses = requests.map { |r| r.response(timeout: 10) }
|
|
116
|
+
total = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - t_start) * 1000).round(1)
|
|
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
|
|
122
|
+
client.disconnect
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# === 7. Priority Endpoints (CSS vs Image) ===
|
|
126
|
+
puts "\n7. Priority Endpoints"
|
|
127
|
+
puts "-" * 60
|
|
128
|
+
|
|
129
|
+
test("GET /h3/css returns CSS content") do
|
|
130
|
+
response = Quicsilver::Client.get(HOST, PORT, "/h3/css", unsecure: true)
|
|
131
|
+
raise "Expected 200" unless response.status == 200
|
|
132
|
+
puts " CSS: #{response.body.bytesize} bytes"
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
test("GET /h3/image returns large binary payload") do
|
|
136
|
+
response = Quicsilver::Client.get(HOST, PORT, "/h3/image", unsecure: true)
|
|
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"
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# === 8. Slow Response (Delay) ===
|
|
143
|
+
puts "\n8. Slow Response"
|
|
144
|
+
puts "-" * 60
|
|
145
|
+
|
|
146
|
+
test("GET /h3/slow?delay=0.2 respects delay") do
|
|
147
|
+
t = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
148
|
+
response = Quicsilver::Client.get(HOST, PORT, "/h3/slow?delay=0.2", unsecure: true)
|
|
149
|
+
elapsed = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - t) * 1000).round(1)
|
|
150
|
+
raise "Expected 200" unless response.status == 200
|
|
151
|
+
raise "Should take ~200ms, took #{elapsed}ms" unless elapsed > 150
|
|
152
|
+
puts " Delayed response in #{elapsed}ms"
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# === 9. Concurrent Connections ===
|
|
156
|
+
puts "\n9. Concurrent Connections (4 threads, 4 connections)"
|
|
157
|
+
puts "-" * 60
|
|
158
|
+
|
|
159
|
+
test("4 concurrent requests on separate connections complete in parallel") do
|
|
160
|
+
Quicsilver::Client.close_pool
|
|
161
|
+
threads = 4.times.map do |i|
|
|
162
|
+
Thread.new do
|
|
163
|
+
client = Quicsilver::Client.new(HOST, PORT, unsecure: true)
|
|
164
|
+
t = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
165
|
+
response = client.get("/h3/slow?delay=0.2")
|
|
166
|
+
elapsed = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - t) * 1000).round(1)
|
|
167
|
+
client.disconnect
|
|
168
|
+
[i, response.status, elapsed]
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
results = threads.map(&:value).sort_by(&:first)
|
|
172
|
+
results.each { |i, status, elapsed| puts " Connection #{i}: #{status} -- #{elapsed}ms" }
|
|
173
|
+
total = results.map { |_, _, e| e }.max
|
|
174
|
+
sequential = 200 * 4
|
|
175
|
+
puts " Total: #{total}ms (sequential would be ~#{sequential}ms)"
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# === 10. Large Upload ===
|
|
179
|
+
puts "\n10. Large Upload"
|
|
180
|
+
puts "-" * 60
|
|
181
|
+
|
|
182
|
+
test("POST 100KB body") do
|
|
183
|
+
big_body = "x" * 100_000
|
|
184
|
+
response = Quicsilver::Client.post(HOST, PORT, "/h3/upload",
|
|
185
|
+
body: big_body,
|
|
186
|
+
headers: { "content-type" => "application/octet-stream" },
|
|
187
|
+
unsecure: true)
|
|
188
|
+
raise "Expected 200" unless response.status == 200
|
|
189
|
+
raise "Size mismatch" unless response.body.include?('"received_bytes":100000')
|
|
190
|
+
puts " Uploaded 100KB, server received 100,000 bytes"
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# === 11. Content-Length Validation ===
|
|
194
|
+
puts "\n11. Content-Length Validation"
|
|
195
|
+
puts "-" * 60
|
|
196
|
+
|
|
197
|
+
test("POST with content-length header") do
|
|
198
|
+
body = "hello"
|
|
199
|
+
response = Quicsilver::Client.post(HOST, PORT, "/h3/upload",
|
|
200
|
+
body: body,
|
|
201
|
+
headers: { "content-type" => "text/plain" },
|
|
202
|
+
unsecure: true)
|
|
203
|
+
raise "Expected 200, got #{response.status}" unless response.status == 200
|
|
204
|
+
raise "Size mismatch" unless response.body.include?('"received_bytes":5')
|
|
205
|
+
puts " Sent 5 bytes, server received 5 bytes"
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# === 12. Handshake Cost (connection pooling benefit) ===
|
|
209
|
+
puts "\n12. Handshake Cost"
|
|
210
|
+
puts "-" * 60
|
|
211
|
+
|
|
212
|
+
test("Fresh connections pay handshake cost -- pooling avoids this") do
|
|
213
|
+
Quicsilver::Client.close_pool
|
|
214
|
+
|
|
215
|
+
# First connection -- full handshake
|
|
216
|
+
client1 = Quicsilver::Client.new(HOST, PORT, unsecure: true)
|
|
217
|
+
t1 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
218
|
+
client1.get("/h3/ping")
|
|
219
|
+
first = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - t1) * 1000).round(1)
|
|
220
|
+
client1.disconnect
|
|
221
|
+
|
|
222
|
+
# Second connection -- another full handshake
|
|
223
|
+
client2 = Quicsilver::Client.new(HOST, PORT, unsecure: true)
|
|
224
|
+
t2 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
225
|
+
client2.get("/h3/ping")
|
|
226
|
+
second = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - t2) * 1000).round(1)
|
|
227
|
+
client2.disconnect
|
|
228
|
+
|
|
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"
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# === 13. Server Health ===
|
|
242
|
+
puts "\n13. Server Health"
|
|
243
|
+
puts "-" * 60
|
|
244
|
+
|
|
245
|
+
test("Server still responding after all tests") do
|
|
246
|
+
response = Quicsilver::Client.get(HOST, PORT, "/h3/ping", unsecure: true)
|
|
247
|
+
raise "Expected 200" unless response.status == 200
|
|
248
|
+
puts " Server healthy"
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
# === 14. Rails CRUD ===
|
|
252
|
+
puts "\n14. Rails CRUD"
|
|
253
|
+
puts "-" * 60
|
|
254
|
+
|
|
255
|
+
test("GET /posts.json returns posts array") do
|
|
256
|
+
response = Quicsilver::Client.get(HOST, PORT, "/posts.json", unsecure: true)
|
|
257
|
+
raise "Expected 200" unless response.status == 200
|
|
258
|
+
puts " Posts: #{response.body[0..40]}"
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
# === 15. HTTP Methods ===
|
|
262
|
+
puts "\n15. HTTP Methods"
|
|
263
|
+
puts "-" * 60
|
|
264
|
+
|
|
265
|
+
client = Quicsilver::Client.new(HOST, PORT, unsecure: true)
|
|
266
|
+
%i[get post put patch delete head].each do |method|
|
|
267
|
+
test("#{method.to_s.upcase} /h3/ping") do
|
|
268
|
+
response = client.public_send(method, "/h3/ping")
|
|
269
|
+
body_size = response.body&.bytesize || 0
|
|
270
|
+
puts " #{response.status} (#{body_size} bytes)"
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
client.disconnect
|
|
274
|
+
|
|
275
|
+
# === Summary ===
|
|
276
|
+
puts "\n" + "=" * 60
|
|
277
|
+
puts "Feature test complete!"
|
|
278
|
+
puts " Pool: #{Quicsilver::Client.pool.size} connection(s)"
|
|
279
|
+
Quicsilver::Client.close_pool
|
|
280
|
+
puts "Done"
|