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,97 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ # gRPC-style request/response over HTTP/3.
4
+ #
5
+ # gRPC is just HTTP with:
6
+ # - content-type: application/grpc (or application/grpc+json)
7
+ # - 5-byte frame prefix: [compressed(1)][length(4)][message]
8
+ # - Status in trailers: grpc-status, grpc-message
9
+ #
10
+ # This example uses JSON for simplicity. In production you'd use
11
+ # protobuf (google-protobuf gem) for smaller, faster serialization.
12
+ # Quicsilver carries the bytes — the app chooses the encoding.
13
+ #
14
+ # ruby examples/grpc_style.rb
15
+
16
+ require_relative "example_helper"
17
+ require "json"
18
+
19
+ PORT = 4433
20
+ HOST = "localhost"
21
+
22
+ # gRPC frame: [0x00][4-byte big-endian length][message]
23
+ def grpc_encode(message)
24
+ data = message.to_json
25
+ [0, data.bytesize].pack("CN") + data
26
+ end
27
+
28
+ def grpc_decode(frame)
29
+ _compressed, length = frame.unpack("CN")
30
+ JSON.parse(frame[5, length])
31
+ end
32
+
33
+ app = ->(env) {
34
+ path = env["PATH_INFO"]
35
+ body = env["rack.input"]&.read || ""
36
+
37
+ case path
38
+ when "/grpc.UserService/GetUser"
39
+ request = grpc_decode(body) rescue { "error" => "bad frame" }
40
+ user = { "id" => request["id"], "name" => "Alice", "email" => "alice@example.com" }
41
+ response_frame = grpc_encode(user)
42
+
43
+ [200,
44
+ { "content-type" => "application/grpc+json" },
45
+ [response_frame]]
46
+
47
+ when "/grpc.UserService/ListUsers"
48
+ users = [
49
+ { "id" => 1, "name" => "Alice" },
50
+ { "id" => 2, "name" => "Bob" },
51
+ { "id" => 3, "name" => "Charlie" }
52
+ ]
53
+ response_frame = grpc_encode(users)
54
+
55
+ [200,
56
+ { "content-type" => "application/grpc+json" },
57
+ [response_frame]]
58
+
59
+ else
60
+ [404, { "content-type" => "text/plain" }, ["Unknown gRPC method: #{path}"]]
61
+ end
62
+ }
63
+
64
+ server = Quicsilver::Server.new(PORT, app: app, server_configuration: EXAMPLE_TLS_CONFIG)
65
+ server_thread = Thread.new { server.start }
66
+ sleep 0.3
67
+
68
+ puts "🔌 gRPC-style over HTTP/3 (JSON, no protobuf)"
69
+ puts "=" * 50
70
+
71
+ client = Quicsilver::Client.new(HOST, PORT, unsecure: true)
72
+
73
+ # GetUser
74
+ puts "\n GetUser(id=1):"
75
+ request_frame = grpc_encode({ "id" => 1 })
76
+ response = client.post("/grpc.UserService/GetUser",
77
+ body: request_frame,
78
+ headers: { "content-type" => "application/grpc+json" })
79
+ user = grpc_decode(response.body)
80
+ puts " → #{user}"
81
+
82
+ # ListUsers
83
+ puts "\n ListUsers():"
84
+ request_frame = grpc_encode({})
85
+ response = client.post("/grpc.UserService/ListUsers",
86
+ body: request_frame,
87
+ headers: { "content-type" => "application/grpc+json" })
88
+ users = grpc_decode(response.body)
89
+ puts " → #{users}"
90
+
91
+ puts "\n gRPC is just HTTP + framing. Quicsilver carries the bytes."
92
+ puts " This example uses JSON — swap in protobuf for production."
93
+
94
+ client.disconnect
95
+ server.stop
96
+ server_thread.join(2)
97
+ puts "\n✅ Done"
@@ -0,0 +1,382 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Example 3: latency-aware routing as a separate policy from pressure.
5
+ #
6
+ # bundle exec ruby examples/latency_aware_routing.rb
7
+ #
8
+ # worker-1 is intentionally slower, but it is not necessarily busy. A purely
9
+ # pressure-aware policy might still send it traffic when its queue is empty.
10
+ # This example tracks rolling request latency per worker and uses that history
11
+ # to reduce traffic to consistently slow endpoints.
12
+
13
+ require_relative "example_helper"
14
+ require "rbconfig"
15
+
16
+ $stdout.sync = true
17
+
18
+ HOST = "127.0.0.1"
19
+ WORKERS = Integer(ENV.fetch("WORKERS", "3"))
20
+ REQUESTS = Integer(ENV.fetch("REQUESTS", "24"))
21
+ CONCURRENCY = Integer(ENV.fetch("CONCURRENCY", "6"))
22
+ SLOW_WORKER_MS = Integer(ENV.fetch("SLOW_WORKER_MS", "250"))
23
+ WINDOW_SIZE = Integer(ENV.fetch("WINDOW_SIZE", "50"))
24
+
25
+ class RollingLatency
26
+ def initialize(size: WINDOW_SIZE)
27
+ @size = size
28
+ @samples = []
29
+ @mutex = Mutex.new
30
+ end
31
+
32
+ def record(duration_ms, status)
33
+ @mutex.synchronize do
34
+ @samples << { duration_ms: duration_ms, status: status }
35
+ @samples.shift while @samples.size > @size
36
+ end
37
+ end
38
+
39
+ def to_h
40
+ samples = @mutex.synchronize { @samples.dup }
41
+ durations = samples.map { |sample| sample[:duration_ms] }
42
+ errors = samples.count { |sample| sample[:status].to_i >= 500 }
43
+
44
+ {
45
+ "count" => samples.size,
46
+ "p50_ms" => percentile(durations, 50),
47
+ "p95_ms" => percentile(durations, 95),
48
+ "error_rate" => samples.empty? ? 0.0 : (errors.to_f / samples.size).round(3)
49
+ }
50
+ end
51
+
52
+ private
53
+
54
+ def percentile(values, percentile)
55
+ return 0.0 if values.empty?
56
+
57
+ sorted = values.sort
58
+ index = ((percentile / 100.0) * (sorted.size - 1)).round
59
+ sorted[index].round(1)
60
+ end
61
+ end
62
+
63
+ class LatencySignal
64
+ def self.for(stats)
65
+ new(stats || {})
66
+ end
67
+
68
+ def initialize(stats)
69
+ @stats = stats || {}
70
+ end
71
+
72
+ def score(local_inflight: 0)
73
+ p95_ms + (error_rate * 1_000) + (local_inflight * 50)
74
+ end
75
+
76
+ def to_h
77
+ {
78
+ "p95_ms" => p95_ms,
79
+ "error_rate" => error_rate,
80
+ "score" => score.round(1),
81
+ "reason" => reason
82
+ }
83
+ end
84
+
85
+ private
86
+
87
+ def p95_ms
88
+ @stats.dig("latency", "p95_ms").to_f
89
+ end
90
+
91
+ def error_rate
92
+ @stats.dig("latency", "error_rate").to_f
93
+ end
94
+
95
+ def reason
96
+ if @stats.empty?
97
+ "stats_unavailable"
98
+ elsif error_rate.positive?
99
+ "errors"
100
+ elsif p95_ms > 200
101
+ "slow"
102
+ else
103
+ "ok"
104
+ end
105
+ end
106
+ end
107
+
108
+ Worker = Struct.new(:id, :host, :port, :pid, :delay_ms, keyword_init: true) do
109
+ def health
110
+ response = Quicsilver::Client.get(host, port, "/_quicsilver/health",
111
+ unsecure: true, request_timeout: 1, connection_timeout: 500)
112
+ JSON.parse(response.body)
113
+ rescue JSON::ParserError, Quicsilver::Error, SystemCallError
114
+ {}
115
+ end
116
+
117
+ def ready?
118
+ response = Quicsilver::Client.get(host, port, "/_quicsilver/ready",
119
+ unsecure: true, request_timeout: 1, connection_timeout: 500)
120
+ response.status == 200
121
+ rescue Quicsilver::Error, SystemCallError
122
+ false
123
+ end
124
+
125
+ def get(path)
126
+ started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
127
+ response = Quicsilver::Client.get(host, port, path, unsecure: true, request_timeout: 5)
128
+ duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at) * 1000).round(1)
129
+
130
+ { worker_id: id, status: response.status, duration_ms: duration_ms }
131
+ rescue => error
132
+ duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at) * 1000).round(1)
133
+ { worker_id: id, status: nil, duration_ms: duration_ms, error: "#{error.class}: #{error.message}" }
134
+ end
135
+ end
136
+
137
+ class ToyLoadBalancer
138
+ def initialize(workers, strategy:)
139
+ @workers = workers
140
+ @strategy = strategy
141
+ @index = 0
142
+ @inflight = Hash.new(0)
143
+ @routed = Hash.new(0)
144
+ @mutex = Mutex.new
145
+ end
146
+
147
+ def get(path)
148
+ worker = checkout_worker
149
+ worker.get(path)
150
+ ensure
151
+ checkin_worker(worker) if worker
152
+ end
153
+
154
+ private
155
+
156
+ def checkout_worker
157
+ @mutex.synchronize do
158
+ worker = choose_worker
159
+ @inflight[worker.id] += 1
160
+ @routed[worker.id] += 1
161
+ worker
162
+ end
163
+ end
164
+
165
+ def choose_worker
166
+ case @strategy
167
+ when :round_robin
168
+ round_robin_worker
169
+ when :latency_aware
170
+ latency_aware_worker
171
+ else
172
+ raise ArgumentError, "unknown strategy: #{@strategy.inspect}"
173
+ end
174
+ end
175
+
176
+ def round_robin_worker
177
+ worker = @workers[@index % @workers.size]
178
+ @index += 1
179
+ worker
180
+ end
181
+
182
+ def latency_aware_worker
183
+ # Keep a tiny probe floor. Production outlier policies usually keep probing
184
+ # reduced-weight endpoints so they can recover instead of disappearing forever.
185
+ if (unprobed = @workers.find { |worker| @routed[worker.id].zero? })
186
+ return unprobed
187
+ end
188
+
189
+ scored = @workers.map do |worker|
190
+ signal = LatencySignal.for(worker.health)
191
+ [worker, signal.score(local_inflight: @inflight[worker.id])]
192
+ end
193
+ lowest = scored.map(&:last).min
194
+ scored.find { |_, score| score == lowest }.first
195
+ end
196
+
197
+ def checkin_worker(worker)
198
+ @mutex.synchronize { @inflight[worker.id] -= 1 }
199
+ end
200
+ end
201
+
202
+ def run_worker!
203
+ Quicsilver.logger = Logger.new(File::NULL)
204
+
205
+ worker_id = ENV.fetch("WORKER_ID")
206
+ port = Integer(ENV.fetch("PORT"))
207
+ delay_ms = Integer(ENV.fetch("DELAY_MS", "0"))
208
+ latency = RollingLatency.new
209
+
210
+ app = ->(env) {
211
+ case env["PATH_INFO"]
212
+ when "/_quicsilver/ready"
213
+ Example.json_response(200, "ready" => true, "worker_id" => worker_id)
214
+ when "/_quicsilver/health"
215
+ stats = latency.to_h
216
+ Example.json_response(200, stats.merge("latency" => stats, "signal" => LatencySignal.for("latency" => stats).to_h))
217
+ when "/hello"
218
+ started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
219
+ status = 200
220
+ sleep delay_ms / 1000.0 if delay_ms.positive?
221
+ duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at) * 1000).round(1)
222
+ latency.record(duration_ms, status)
223
+ Example.text_response(status, "hello from #{worker_id}\n")
224
+ else
225
+ Example.text_response(404, "not found\n")
226
+ end
227
+ }
228
+
229
+ server = Quicsilver::Server.new(port, app: app, server_configuration: EXAMPLE_TLS_CONFIG)
230
+
231
+ trap("TERM") do
232
+ server.stop rescue nil
233
+ exit
234
+ end
235
+
236
+ server.start
237
+ ensure
238
+ server&.stop rescue nil
239
+ end
240
+
241
+ def spawn_worker(index)
242
+ id = "worker-#{index + 1}"
243
+ delay_ms = index.zero? ? SLOW_WORKER_MS : 0
244
+ port = Example.available_udp_port(HOST)
245
+ env = {
246
+ "QUICSILVER_LATENCY_WORKER" => "1",
247
+ "WORKER_ID" => id,
248
+ "PORT" => port.to_s,
249
+ "DELAY_MS" => delay_ms.to_s
250
+ }
251
+
252
+ pid = Process.spawn(env, RbConfig.ruby, __FILE__)
253
+ worker = Worker.new(id: id, host: HOST, port: port, pid: pid, delay_ms: delay_ms)
254
+
255
+ Example.wait_until { worker.ready? }
256
+ worker
257
+ end
258
+
259
+ def warm_up(workers)
260
+ Example.heading "Warm up latency history"
261
+ workers.each do |worker|
262
+ 3.times { worker.get("/hello") }
263
+ health = worker.health
264
+ Example.detail worker.id, "delay=#{worker.delay_ms}ms p95=#{health.dig('latency', 'p95_ms')}ms"
265
+ end
266
+ end
267
+
268
+ def run_phase(title, strategy, workers)
269
+ Example.heading title
270
+
271
+ lb = ToyLoadBalancer.new(workers, strategy: strategy)
272
+ queue = Queue.new
273
+ REQUESTS.times { queue << "/hello" }
274
+
275
+ results = []
276
+ results_mutex = Mutex.new
277
+
278
+ started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
279
+ threads = CONCURRENCY.times.map do
280
+ Thread.new do
281
+ loop do
282
+ path = queue.pop(true) rescue nil
283
+ break unless path
284
+
285
+ result = lb.get(path)
286
+ results_mutex.synchronize { results << result }
287
+ end
288
+ end
289
+ end
290
+ threads.each(&:join)
291
+ elapsed_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at) * 1000).round(1)
292
+
293
+ print_summary(workers, results, elapsed_ms)
294
+ end
295
+
296
+ def percentile(values, percentile)
297
+ return 0 if values.empty?
298
+
299
+ sorted = values.sort
300
+ index = ((percentile / 100.0) * (sorted.size - 1)).round
301
+ sorted[index]
302
+ end
303
+
304
+ def print_summary(workers, results, elapsed_ms)
305
+ durations = results.map { |result| result[:duration_ms] }
306
+
307
+ Example.detail "elapsed", "#{elapsed_ms}ms"
308
+ Example.detail "latency", "p50=#{percentile(durations, 50)}ms p95=#{percentile(durations, 95)}ms"
309
+
310
+ puts
311
+ puts format(" %-8s %6s %8s %9s %9s %10s", "worker", "delay", "requests", "p50", "p95", "health_p95")
312
+ puts " #{'-' * 67}"
313
+
314
+ request_counts = {}
315
+ workers.each do |worker|
316
+ worker_results = results.select { |result| result[:worker_id] == worker.id }
317
+ worker_durations = worker_results.map { |result| result[:duration_ms] }
318
+ health = worker.health
319
+ request_counts[worker.id] = worker_results.size
320
+
321
+ puts format(
322
+ " %-8s %5dms %8d %8.1fms %8.1fms %9.1fms",
323
+ worker.id,
324
+ worker.delay_ms,
325
+ worker_results.size,
326
+ percentile(worker_durations, 50),
327
+ percentile(worker_durations, 95),
328
+ health.dig("latency", "p95_ms").to_f
329
+ )
330
+ end
331
+
332
+ { p95: percentile(durations, 95), request_counts: request_counts }
333
+ end
334
+
335
+ if ENV["QUICSILVER_LATENCY_WORKER"] == "1"
336
+ run_worker!
337
+ exit
338
+ end
339
+
340
+ workers = []
341
+
342
+ begin
343
+ Example.heading "Latency-aware Routing Demo", level: 1
344
+ Example.detail "scenario", "worker-1 is consistently slow"
345
+ Example.detail "workers", WORKERS
346
+ Example.detail "requests", REQUESTS
347
+ Example.detail "concurrency", CONCURRENCY
348
+ Example.detail "slow worker", "worker-1 sleeps #{SLOW_WORKER_MS}ms per request"
349
+ Example.detail "signal flow", "rolling latency -> LatencySignal -> routing"
350
+
351
+ Example.heading "Starting workers"
352
+ workers = WORKERS.times.map { |index| spawn_worker(index) }
353
+ workers.each do |worker|
354
+ Example.detail worker.id, "https://#{worker.host}:#{worker.port} delay=#{worker.delay_ms}ms pid=#{worker.pid}"
355
+ end
356
+
357
+ warm_up(workers)
358
+
359
+ round_robin = run_phase("1. Round robin", :round_robin, workers)
360
+
361
+ Quicsilver::Client.close_pool
362
+ sleep 0.2
363
+
364
+ latency_aware = run_phase("2. Latency aware", :latency_aware, workers)
365
+
366
+ slow_worker = workers.first.id
367
+
368
+ Example.heading "What to notice"
369
+ puts " Slow-worker requests: #{round_robin[:request_counts][slow_worker]} -> #{latency_aware[:request_counts][slow_worker]}"
370
+ puts " Fleet p95 latency: #{round_robin[:p95]}ms -> #{latency_aware[:p95]}ms"
371
+ puts
372
+ puts " This is latency-aware routing. It reduces traffic to an endpoint that is"
373
+ puts " historically slow, even when that endpoint is not currently full. The one"
374
+ puts " remaining request is a probe so the endpoint has a path to recovery."
375
+ puts " Combine this with pressure-aware routing when you want both capacity and health."
376
+ ensure
377
+ Quicsilver::Client.close_pool rescue nil
378
+ workers.each do |worker|
379
+ Process.kill("TERM", worker.pid) rescue nil
380
+ Process.wait(worker.pid) rescue nil
381
+ end
382
+ end