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.
Files changed (107) hide show
  1. checksums.yaml +7 -0
  2. data/.github/workflows/ci.yml +49 -0
  3. data/.github/workflows/cibuildgem.yaml +117 -0
  4. data/.gitignore +21 -0
  5. data/.gitmodules +3 -0
  6. data/.ruby-version +1 -0
  7. data/CHANGELOG.md +175 -0
  8. data/Gemfile +6 -0
  9. data/Gemfile.lock +67 -0
  10. data/LICENSE +21 -0
  11. data/README.md +261 -0
  12. data/Rakefile +130 -0
  13. data/benchmarks/baseline.rb +175 -0
  14. data/benchmarks/compare.rb +398 -0
  15. data/benchmarks/components.rb +191 -0
  16. data/benchmarks/helpers.rb +162 -0
  17. data/benchmarks/latency.rb +128 -0
  18. data/benchmarks/loss.rb +193 -0
  19. data/benchmarks/mobile.rb +125 -0
  20. data/bin/console +14 -0
  21. data/bin/setup +8 -0
  22. data/docs/connection_identity.md +141 -0
  23. data/examples/README.md +58 -0
  24. data/examples/benchmark.rb +191 -0
  25. data/examples/connection_pool_demo.rb +47 -0
  26. data/examples/example_helper.rb +69 -0
  27. data/examples/falcon_middleware.rb +44 -0
  28. data/examples/feature_demo.rb +125 -0
  29. data/examples/grpc_style.rb +97 -0
  30. data/examples/latency_aware_routing.rb +382 -0
  31. data/examples/lifecycle_drain.rb +264 -0
  32. data/examples/minimal_http3_server.rb +14 -0
  33. data/examples/priorities.rb +60 -0
  34. data/examples/protocol_http_server.rb +31 -0
  35. data/examples/rack_http3_server.rb +42 -0
  36. data/examples/rails_feature_test.rb +280 -0
  37. data/examples/server_stats.rb +117 -0
  38. data/examples/simple_client_test.rb +26 -0
  39. data/examples/streaming_sse.rb +33 -0
  40. data/examples/trailers.rb +69 -0
  41. data/examples/webtransport_smoke.rb +546 -0
  42. data/examples/worker_load_balancing.rb +425 -0
  43. data/ext/quicsilver/extconf.rb +124 -0
  44. data/ext/quicsilver/include/msquic.h +2226 -0
  45. data/ext/quicsilver/include/msquic_posix.h +696 -0
  46. data/ext/quicsilver/include/quic_sal_stub.h +314 -0
  47. data/ext/quicsilver/msquic_patch.rb +68 -0
  48. data/ext/quicsilver/patches/reliable-reset.patch +723 -0
  49. data/ext/quicsilver/patches/stream-final-size.patch +47 -0
  50. data/ext/quicsilver/quicsilver.c +2741 -0
  51. data/lib/quicsilver/3.4/quicsilver.so +0 -0
  52. data/lib/quicsilver/4.0/quicsilver.so +0 -0
  53. data/lib/quicsilver/client/client.rb +708 -0
  54. data/lib/quicsilver/client/connection_pool.rb +202 -0
  55. data/lib/quicsilver/client/request.rb +190 -0
  56. data/lib/quicsilver/errors.rb +39 -0
  57. data/lib/quicsilver/libmsquic.so.2 +0 -0
  58. data/lib/quicsilver/protocol/adapter.rb +196 -0
  59. data/lib/quicsilver/protocol/capsule.rb +38 -0
  60. data/lib/quicsilver/protocol/connect_stream_decoder.rb +75 -0
  61. data/lib/quicsilver/protocol/control_stream_parser.rb +117 -0
  62. data/lib/quicsilver/protocol/datagram.rb +40 -0
  63. data/lib/quicsilver/protocol/frame_parser.rb +141 -0
  64. data/lib/quicsilver/protocol/frame_reader.rb +84 -0
  65. data/lib/quicsilver/protocol/frames.rb +454 -0
  66. data/lib/quicsilver/protocol/priority.rb +65 -0
  67. data/lib/quicsilver/protocol/qpack/decoder.rb +127 -0
  68. data/lib/quicsilver/protocol/qpack/encoder.rb +217 -0
  69. data/lib/quicsilver/protocol/qpack/header_block_decoder.rb +140 -0
  70. data/lib/quicsilver/protocol/qpack/huffman.rb +459 -0
  71. data/lib/quicsilver/protocol/request_encoder.rb +50 -0
  72. data/lib/quicsilver/protocol/request_parser.rb +289 -0
  73. data/lib/quicsilver/protocol/response_encoder.rb +107 -0
  74. data/lib/quicsilver/protocol/response_parser.rb +147 -0
  75. data/lib/quicsilver/protocol/stream_input.rb +98 -0
  76. data/lib/quicsilver/protocol/stream_output.rb +59 -0
  77. data/lib/quicsilver/protocol/unidirectional_stream.rb +12 -0
  78. data/lib/quicsilver/protocol/web_transport.rb +214 -0
  79. data/lib/quicsilver/rack/context.rb +33 -0
  80. data/lib/quicsilver/response.rb +40 -0
  81. data/lib/quicsilver/server/listener_data.rb +14 -0
  82. data/lib/quicsilver/server/rack_adapter.rb +77 -0
  83. data/lib/quicsilver/server/receive_queue.rb +106 -0
  84. data/lib/quicsilver/server/request_handler.rb +149 -0
  85. data/lib/quicsilver/server/request_registry.rb +63 -0
  86. data/lib/quicsilver/server/scheduler.rb +50 -0
  87. data/lib/quicsilver/server/schedulers/fiber_scheduler.rb +79 -0
  88. data/lib/quicsilver/server/schedulers/thread_scheduler.rb +76 -0
  89. data/lib/quicsilver/server/server.rb +1080 -0
  90. data/lib/quicsilver/server/web_transport_flow_control.rb +270 -0
  91. data/lib/quicsilver/server/web_transport_manager.rb +281 -0
  92. data/lib/quicsilver/server/web_transport_registry.rb +40 -0
  93. data/lib/quicsilver/server/web_transport_session.rb +812 -0
  94. data/lib/quicsilver/server/web_transport_stream.rb +326 -0
  95. data/lib/quicsilver/transport/configuration.rb +234 -0
  96. data/lib/quicsilver/transport/connection.rb +594 -0
  97. data/lib/quicsilver/transport/connection_stats.rb +53 -0
  98. data/lib/quicsilver/transport/event_loop.rb +74 -0
  99. data/lib/quicsilver/transport/inbound_stream.rb +53 -0
  100. data/lib/quicsilver/transport/stream.rb +43 -0
  101. data/lib/quicsilver/transport/stream_event.rb +49 -0
  102. data/lib/quicsilver/transport/stream_id.rb +26 -0
  103. data/lib/quicsilver/version.rb +3 -0
  104. data/lib/quicsilver.rb +96 -0
  105. data/lib/rackup/handler/quicsilver.rb +96 -0
  106. data/quicsilver.gemspec +66 -0
  107. 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"