quicsilver 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +9 -1
  3. data/.github/workflows/cibuildgem.yaml +117 -0
  4. data/.gitignore +8 -1
  5. data/CHANGELOG.md +82 -0
  6. data/Gemfile.lock +27 -2
  7. data/README.md +205 -29
  8. data/Rakefile +75 -3
  9. data/benchmarks/baseline.rb +175 -0
  10. data/benchmarks/compare.rb +398 -0
  11. data/benchmarks/helpers.rb +138 -64
  12. data/benchmarks/latency.rb +128 -0
  13. data/benchmarks/loss.rb +193 -0
  14. data/benchmarks/mobile.rb +125 -0
  15. data/docs/connection_identity.md +141 -0
  16. data/examples/README.md +44 -91
  17. data/examples/benchmark.rb +191 -0
  18. data/examples/connection_pool_demo.rb +47 -0
  19. data/examples/example_helper.rb +69 -0
  20. data/examples/falcon_middleware.rb +44 -0
  21. data/examples/feature_demo.rb +125 -0
  22. data/examples/grpc_style.rb +97 -0
  23. data/examples/latency_aware_routing.rb +382 -0
  24. data/examples/lifecycle_drain.rb +264 -0
  25. data/examples/minimal_http3_server.rb +6 -18
  26. data/examples/priorities.rb +60 -0
  27. data/examples/protocol_http_server.rb +31 -0
  28. data/examples/rack_http3_server.rb +8 -20
  29. data/examples/rails_feature_test.rb +280 -0
  30. data/examples/server_stats.rb +117 -0
  31. data/examples/simple_client_test.rb +5 -5
  32. data/examples/streaming_sse.rb +33 -0
  33. data/examples/trailers.rb +69 -0
  34. data/examples/webtransport_smoke.rb +546 -0
  35. data/examples/worker_load_balancing.rb +425 -0
  36. data/ext/quicsilver/extconf.rb +111 -12
  37. data/ext/quicsilver/include/msquic.h +2226 -0
  38. data/ext/quicsilver/include/msquic_posix.h +696 -0
  39. data/ext/quicsilver/include/quic_sal_stub.h +314 -0
  40. data/ext/quicsilver/msquic_patch.rb +68 -0
  41. data/ext/quicsilver/patches/reliable-reset.patch +723 -0
  42. data/ext/quicsilver/patches/stream-final-size.patch +47 -0
  43. data/ext/quicsilver/quicsilver.c +1646 -189
  44. data/lib/quicsilver/client/client.rb +546 -88
  45. data/lib/quicsilver/client/connection_pool.rb +202 -0
  46. data/lib/quicsilver/client/request.rb +106 -14
  47. data/lib/quicsilver/errors.rb +39 -0
  48. data/lib/quicsilver/libmsquic.2.dylib +0 -0
  49. data/lib/quicsilver/protocol/adapter.rb +196 -0
  50. data/lib/quicsilver/protocol/capsule.rb +38 -0
  51. data/lib/quicsilver/protocol/connect_stream_decoder.rb +75 -0
  52. data/lib/quicsilver/protocol/control_stream_parser.rb +117 -0
  53. data/lib/quicsilver/protocol/datagram.rb +40 -0
  54. data/lib/quicsilver/protocol/frame_parser.rb +141 -0
  55. data/lib/quicsilver/protocol/frame_reader.rb +84 -0
  56. data/lib/quicsilver/protocol/frames.rb +137 -10
  57. data/lib/quicsilver/protocol/priority.rb +65 -0
  58. data/lib/quicsilver/protocol/qpack/decoder.rb +4 -42
  59. data/lib/quicsilver/protocol/qpack/encoder.rb +49 -21
  60. data/lib/quicsilver/protocol/qpack/header_block_decoder.rb +16 -1
  61. data/lib/quicsilver/protocol/qpack/huffman.rb +4 -4
  62. data/lib/quicsilver/protocol/request_encoder.rb +6 -3
  63. data/lib/quicsilver/protocol/request_parser.rb +43 -141
  64. data/lib/quicsilver/protocol/response_encoder.rb +40 -5
  65. data/lib/quicsilver/protocol/response_parser.rb +26 -128
  66. data/lib/quicsilver/protocol/stream_input.rb +98 -0
  67. data/lib/quicsilver/protocol/stream_output.rb +59 -0
  68. data/lib/quicsilver/protocol/unidirectional_stream.rb +12 -0
  69. data/lib/quicsilver/protocol/web_transport.rb +214 -0
  70. data/lib/quicsilver/rack/context.rb +33 -0
  71. data/lib/quicsilver/response.rb +40 -0
  72. data/lib/quicsilver/server/rack_adapter.rb +77 -0
  73. data/lib/quicsilver/server/receive_queue.rb +106 -0
  74. data/lib/quicsilver/server/request_handler.rb +111 -48
  75. data/lib/quicsilver/server/request_registry.rb +18 -5
  76. data/lib/quicsilver/server/scheduler.rb +50 -0
  77. data/lib/quicsilver/server/schedulers/fiber_scheduler.rb +79 -0
  78. data/lib/quicsilver/server/schedulers/thread_scheduler.rb +76 -0
  79. data/lib/quicsilver/server/server.rb +848 -104
  80. data/lib/quicsilver/server/web_transport_flow_control.rb +270 -0
  81. data/lib/quicsilver/server/web_transport_manager.rb +281 -0
  82. data/lib/quicsilver/server/web_transport_registry.rb +40 -0
  83. data/lib/quicsilver/server/web_transport_session.rb +812 -0
  84. data/lib/quicsilver/server/web_transport_stream.rb +326 -0
  85. data/lib/quicsilver/transport/configuration.rb +113 -11
  86. data/lib/quicsilver/transport/connection.rb +376 -132
  87. data/lib/quicsilver/transport/connection_stats.rb +53 -0
  88. data/lib/quicsilver/transport/event_loop.rb +36 -0
  89. data/lib/quicsilver/transport/inbound_stream.rb +21 -1
  90. data/lib/quicsilver/transport/stream.rb +18 -3
  91. data/lib/quicsilver/transport/stream_event.rb +25 -2
  92. data/lib/quicsilver/transport/stream_id.rb +26 -0
  93. data/lib/quicsilver/version.rb +1 -1
  94. data/lib/quicsilver.rb +45 -11
  95. data/lib/rackup/handler/quicsilver.rb +57 -38
  96. data/quicsilver.gemspec +17 -2
  97. metadata +120 -10
  98. data/benchmarks/concurrent.rb +0 -110
  99. data/benchmarks/quicsilver_server.rb +0 -46
  100. data/benchmarks/rails.rb +0 -170
  101. data/benchmarks/throughput.rb +0 -113
  102. data/examples/setup_certs.sh +0 -57
@@ -0,0 +1,425 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Example 2: propagate one app-server load signal to a toy load balancer.
5
+ #
6
+ # bundle exec ruby examples/worker_load_balancing.rb
7
+ #
8
+ # This is the follow-up to lifecycle_drain.rb. Each worker exposes ordinary
9
+ # HTTP endpoints:
10
+ #
11
+ # /_quicsilver/ready
12
+ # /_quicsilver/stats
13
+ #
14
+ # The toy LB polls those endpoints and compares round-robin routing with a
15
+ # least-loaded policy. To make the signal visible, worker-1 is intentionally
16
+ # slower than the other workers.
17
+
18
+ require_relative "example_helper"
19
+ require "rbconfig"
20
+
21
+ $stdout.sync = true
22
+
23
+ HOST = "127.0.0.1"
24
+ WORKERS = Integer(ENV.fetch("WORKERS", "3"))
25
+ WORKER_THREADS = Integer(ENV.fetch("WORKER_THREADS", "2"))
26
+ MAX_QUEUE_SIZE = Integer(ENV.fetch("MAX_QUEUE_SIZE", (WORKER_THREADS * 4).to_s))
27
+ REQUESTS = Integer(ENV.fetch("REQUESTS", "36"))
28
+ CONCURRENCY = Integer(ENV.fetch("CONCURRENCY", "9"))
29
+ HOT_WORKER_MS = Integer(ENV.fetch("HOT_WORKER_MS", "250"))
30
+ UNREADY_PRESSURE = Float(ENV.fetch("UNREADY_PRESSURE", "0.85"))
31
+
32
+ class LoadSignal
33
+ def self.for(stats)
34
+ new(stats || {})
35
+ end
36
+
37
+ def initialize(stats)
38
+ @stats = stats || {}
39
+ end
40
+
41
+ def ready?
42
+ running? && !draining? && !queue_full? && pressure < UNREADY_PRESSURE
43
+ end
44
+
45
+ def accepting_requests?
46
+ ready?
47
+ end
48
+
49
+ def pressure(local_inflight: 0)
50
+ if @stats.empty?
51
+ 1.0
52
+ else
53
+ [request_pressure(local_inflight: local_inflight), queue_pressure].max.clamp(0.0, 1.0)
54
+ end
55
+ end
56
+
57
+ def to_h
58
+ {
59
+ "ready" => ready?,
60
+ "accepting_requests" => accepting_requests?,
61
+ "pressure" => pressure.round(3),
62
+ "reason" => reason,
63
+ "components" => {
64
+ "requests" => request_pressure.round(3),
65
+ "queue" => queue_pressure.round(3)
66
+ }
67
+ }
68
+ end
69
+
70
+ private
71
+ def running?
72
+ @stats["running"] == true
73
+ end
74
+
75
+ def draining?
76
+ @stats["shutting_down"] == true
77
+ end
78
+
79
+ def queue_full?
80
+ @stats.dig("scheduler", "full") == true
81
+ end
82
+
83
+ def reason
84
+ if @stats.empty?
85
+ "stats_unavailable"
86
+ elsif !running?
87
+ "not_running"
88
+ elsif draining?
89
+ "draining"
90
+ elsif queue_full?
91
+ "queue_full"
92
+ elsif pressure >= UNREADY_PRESSURE
93
+ "high_pressure"
94
+ else
95
+ "ok"
96
+ end
97
+ end
98
+
99
+ def request_pressure(local_inflight: 0)
100
+ active = @stats.dig("requests", "active").to_f + local_inflight.to_f
101
+ threads = @stats.dig("scheduler", "threads").to_f
102
+ ratio(active, threads)
103
+ end
104
+
105
+ def queue_pressure
106
+ pending = @stats.dig("scheduler", "pending").to_f
107
+ max = @stats.dig("scheduler", "max_queue_size").to_f
108
+ ratio(pending, max)
109
+ end
110
+
111
+ def ratio(numerator, denominator)
112
+ denominator.positive? ? numerator / denominator : 0.0
113
+ end
114
+ end
115
+
116
+ Worker = Struct.new(:id, :host, :port, :pid, :delay_ms, keyword_init: true) do
117
+ def stats
118
+ response = Quicsilver::Client.get(host, port, "/_quicsilver/stats",
119
+ unsecure: true, request_timeout: 1, connection_timeout: 500)
120
+ JSON.parse(response.body)
121
+ rescue JSON::ParserError, Quicsilver::Error, SystemCallError
122
+ {}
123
+ end
124
+
125
+ def ready?
126
+ response = Quicsilver::Client.get(host, port, "/_quicsilver/ready",
127
+ unsecure: true, request_timeout: 1, connection_timeout: 500)
128
+ response.status == 200
129
+ rescue Quicsilver::Error, SystemCallError
130
+ false
131
+ end
132
+
133
+ def get(path)
134
+ started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
135
+ response = Quicsilver::Client.get(host, port, path, unsecure: true, request_timeout: 5)
136
+ duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at) * 1000).round(1)
137
+
138
+ { worker_id: id, status: response.status, duration_ms: duration_ms }
139
+ rescue => error
140
+ duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at) * 1000).round(1)
141
+ { worker_id: id, status: nil, duration_ms: duration_ms, error: "#{error.class}: #{error.message}" }
142
+ end
143
+ end
144
+
145
+ class ToyLoadBalancer
146
+ def initialize(workers, strategy:)
147
+ @workers = workers
148
+ @strategy = strategy
149
+ @index = 0
150
+ @inflight = Hash.new(0)
151
+ @mutex = Mutex.new
152
+ end
153
+
154
+ def get(path)
155
+ worker = choose_worker
156
+ track_start(worker)
157
+ worker.get(path)
158
+ ensure
159
+ track_finish(worker) if worker
160
+ end
161
+
162
+ private
163
+ def choose_worker
164
+ case @strategy
165
+ when :round_robin
166
+ round_robin_worker
167
+ when :least_loaded
168
+ least_loaded_worker
169
+ else
170
+ raise ArgumentError, "unknown strategy: #{@strategy.inspect}"
171
+ end
172
+ end
173
+
174
+ def round_robin_worker
175
+ @mutex.synchronize do
176
+ worker = @workers[@index % @workers.size]
177
+ @index += 1
178
+ worker
179
+ end
180
+ end
181
+
182
+ def least_loaded_worker
183
+ scored = @workers.map do |worker|
184
+ signal = LoadSignal.for(worker.stats)
185
+ [worker, signal.pressure(local_inflight: inflight_for(worker))]
186
+ end
187
+ lowest = scored.map(&:last).min
188
+ candidates = scored.select { |_, score| score == lowest }.map(&:first)
189
+
190
+ @mutex.synchronize do
191
+ worker = candidates[@index % candidates.size]
192
+ @index += 1
193
+ worker
194
+ end
195
+ end
196
+
197
+ def track_start(worker)
198
+ @mutex.synchronize { @inflight[worker.id] += 1 }
199
+ end
200
+
201
+ def track_finish(worker)
202
+ @mutex.synchronize { @inflight[worker.id] -= 1 }
203
+ end
204
+
205
+ def inflight_for(worker)
206
+ @mutex.synchronize { @inflight[worker.id] }
207
+ end
208
+ end
209
+
210
+ def worker_stats(server, worker_id, port, delay_ms, control_request: false)
211
+ stats = server.stats
212
+
213
+ if control_request
214
+ active = stats.dig("requests", "active").to_i
215
+ stats["requests"] = stats["requests"].merge("active" => [active - 1, 0].max)
216
+ end
217
+
218
+ stats.merge(
219
+ "worker" => { "id" => worker_id, "pid" => Process.pid, "port" => port, "delay_ms" => delay_ms },
220
+ "load_signal" => LoadSignal.for(stats).to_h
221
+ )
222
+ end
223
+
224
+ def worker_signal(server, worker_id, port, delay_ms)
225
+ LoadSignal.for(worker_stats(server, worker_id, port, delay_ms, control_request: true))
226
+ end
227
+
228
+ def run_worker!
229
+ Quicsilver.logger = Logger.new(File::NULL)
230
+
231
+ worker_id = ENV.fetch("WORKER_ID")
232
+ port = Integer(ENV.fetch("PORT"))
233
+ delay_ms = Integer(ENV.fetch("DELAY_MS", "0"))
234
+ server = nil
235
+
236
+ app = ->(env) {
237
+ signal = worker_signal(server, worker_id, port, delay_ms)
238
+
239
+ case env["PATH_INFO"]
240
+ when "/_quicsilver/ready"
241
+ Example.json_response(signal.ready? ? 200 : 503, signal.to_h.merge("worker_id" => worker_id))
242
+ when "/_quicsilver/stats"
243
+ Example.json_response(200, worker_stats(server, worker_id, port, delay_ms, control_request: true))
244
+ when "/hello"
245
+ if signal.accepting_requests?
246
+ sleep delay_ms / 1000.0 if delay_ms.positive?
247
+ Example.text_response(200, "hello from #{worker_id}\n")
248
+ else
249
+ Example.json_response(503, "error" => "worker overloaded", "load_signal" => signal.to_h)
250
+ end
251
+ else
252
+ Example.text_response(404, "not found\n")
253
+ end
254
+ }
255
+
256
+ server = Quicsilver::Server.new(
257
+ port,
258
+ app: app,
259
+ server_configuration: EXAMPLE_TLS_CONFIG,
260
+ threads: WORKER_THREADS,
261
+ max_queue_size: MAX_QUEUE_SIZE
262
+ )
263
+
264
+ trap("TERM") do
265
+ server.stop rescue nil
266
+ exit
267
+ end
268
+
269
+ server.start
270
+ ensure
271
+ server&.stop rescue nil
272
+ end
273
+
274
+ def spawn_worker(index)
275
+ id = "worker-#{index + 1}"
276
+ delay_ms = index.zero? ? HOT_WORKER_MS : 0
277
+ port = Example.available_udp_port(HOST)
278
+ env = {
279
+ "QUICSILVER_TOY_LB_WORKER" => "1",
280
+ "WORKER_ID" => id,
281
+ "PORT" => port.to_s,
282
+ "DELAY_MS" => delay_ms.to_s,
283
+ "WORKER_THREADS" => WORKER_THREADS.to_s,
284
+ "MAX_QUEUE_SIZE" => MAX_QUEUE_SIZE.to_s,
285
+ "UNREADY_PRESSURE" => UNREADY_PRESSURE.to_s
286
+ }
287
+
288
+ pid = Process.spawn(env, RbConfig.ruby, __FILE__)
289
+ worker = Worker.new(id: id, host: HOST, port: port, pid: pid, delay_ms: delay_ms)
290
+
291
+ Example.wait_until { worker.ready? }
292
+ worker
293
+ end
294
+
295
+ def run_phase(title, strategy, workers)
296
+ Example.heading title
297
+
298
+ lb = ToyLoadBalancer.new(workers, strategy: strategy)
299
+ queue = Queue.new
300
+ REQUESTS.times { queue << "/hello" }
301
+
302
+ results = []
303
+ results_mutex = Mutex.new
304
+ max_pressure = Hash.new(0.0)
305
+ monitor_done = false
306
+
307
+ monitor = Thread.new do
308
+ until monitor_done
309
+ workers.each do |worker|
310
+ pressure = worker.stats.dig("load_signal", "pressure").to_f
311
+ max_pressure[worker.id] = [max_pressure[worker.id], pressure].max
312
+ end
313
+ sleep 0.025
314
+ end
315
+ end
316
+
317
+ started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
318
+ threads = CONCURRENCY.times.map do
319
+ Thread.new do
320
+ loop do
321
+ path = queue.pop(true) rescue nil
322
+ break unless path
323
+
324
+ result = lb.get(path)
325
+ results_mutex.synchronize { results << result }
326
+ end
327
+ end
328
+ end
329
+ threads.each(&:join)
330
+ elapsed_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at) * 1000).round(1)
331
+
332
+ monitor_done = true
333
+ monitor.join
334
+
335
+ print_phase_summary(workers, results, max_pressure, elapsed_ms)
336
+ end
337
+
338
+ def percentile(values, percentile)
339
+ return 0 if values.empty?
340
+
341
+ sorted = values.sort
342
+ index = ((percentile / 100.0) * (sorted.size - 1)).round
343
+ sorted[index]
344
+ end
345
+
346
+ def print_phase_summary(workers, results, max_pressure, elapsed_ms)
347
+ durations = results.map { |result| result[:duration_ms] }
348
+ errors = results.count { |result| result[:error] || result[:status].to_i >= 500 }
349
+
350
+ Example.detail "elapsed", "#{elapsed_ms}ms"
351
+ Example.detail "latency", "p50=#{percentile(durations, 50)}ms p95=#{percentile(durations, 95)}ms"
352
+ Example.detail "errors", errors
353
+
354
+ puts
355
+ puts format(" %-8s %6s %8s %12s %9s %9s", "worker", "delay", "requests", "max_pressure", "p50", "p95")
356
+ puts " #{'-' * 66}"
357
+
358
+ request_counts = {}
359
+
360
+ workers.each do |worker|
361
+ worker_results = results.select { |result| result[:worker_id] == worker.id }
362
+ worker_durations = worker_results.map { |result| result[:duration_ms] }
363
+ request_counts[worker.id] = worker_results.size
364
+
365
+ puts format(
366
+ " %-8s %5dms %8d %12.2f %8.1fms %8.1fms",
367
+ worker.id,
368
+ worker.delay_ms,
369
+ worker_results.size,
370
+ max_pressure[worker.id],
371
+ percentile(worker_durations, 50),
372
+ percentile(worker_durations, 95)
373
+ )
374
+ end
375
+
376
+ { p95: percentile(durations, 95), errors: errors, request_counts: request_counts }
377
+ end
378
+
379
+ if ENV["QUICSILVER_TOY_LB_WORKER"] == "1"
380
+ run_worker!
381
+ exit
382
+ end
383
+
384
+ workers = []
385
+
386
+ begin
387
+ Example.heading "Worker-aware Load Signal Demo", level: 1
388
+ Example.detail "scenario", "worker-1 is intentionally slow"
389
+ Example.detail "workers", WORKERS
390
+ Example.detail "worker threads", WORKER_THREADS
391
+ Example.detail "requests", REQUESTS
392
+ Example.detail "concurrency", CONCURRENCY
393
+ Example.detail "slow worker", "worker-1 sleeps #{HOT_WORKER_MS}ms per request"
394
+ Example.detail "signal flow", "Server#stats -> LoadSignal -> routing + readiness"
395
+
396
+ Example.heading "Starting workers"
397
+ workers = WORKERS.times.map { |index| spawn_worker(index) }
398
+ workers.each do |worker|
399
+ Example.detail worker.id, "https://#{worker.host}:#{worker.port} delay=#{worker.delay_ms}ms pid=#{worker.pid}"
400
+ end
401
+
402
+ round_robin = run_phase("1. Round robin", :round_robin, workers)
403
+
404
+ Quicsilver::Client.close_pool
405
+ sleep 0.2
406
+
407
+ least_loaded = run_phase("2. Least loaded", :least_loaded, workers)
408
+
409
+ slow_worker = workers.first.id
410
+
411
+ Example.heading "What to notice"
412
+ puts " Slow-worker requests: #{round_robin[:request_counts][slow_worker]} -> #{least_loaded[:request_counts][slow_worker]}"
413
+ puts " Fleet p95 latency: #{round_robin[:p95]}ms -> #{least_loaded[:p95]}ms"
414
+ puts " Errors: #{round_robin[:errors]} -> #{least_loaded[:errors]}"
415
+ puts
416
+ puts " This is pressure-aware routing, not outlier detection. It notices when an"
417
+ puts " endpoint is busy right now. A slow endpoint can still receive traffic when"
418
+ puts " it has spare request capacity."
419
+ ensure
420
+ Quicsilver::Client.close_pool rescue nil
421
+ workers.each do |worker|
422
+ Process.kill("TERM", worker.pid) rescue nil
423
+ Process.wait(worker.pid) rescue nil
424
+ end
425
+ end
@@ -1,25 +1,124 @@
1
+ require_relative "msquic_patch"
1
2
  require 'mkmf'
3
+ require 'fileutils'
4
+
5
+ ext_dir = File.expand_path('../../lib/quicsilver', __dir__)
6
+ gemspec_dir = File.expand_path('../..', __dir__)
7
+
8
+ # Skip compilation for installed platform gems that already contain a native
9
+ # extension. In a source checkout we must always compile for the current Ruby;
10
+ # otherwise a stale lib/quicsilver/quicsilver.bundle from another Ruby ABI can
11
+ # prevent rake-compiler from producing the extension it is about to copy.
12
+ source_checkout = File.exist?(File.expand_path("../../.git", __dir__))
13
+ precompiled_binary = File.exist?(File.join(ext_dir, "quicsilver.bundle")) ||
14
+ File.exist?(File.join(ext_dir, "quicsilver.so"))
15
+
16
+ if precompiled_binary && !source_checkout
17
+ File.write("Makefile", "install:\n\t@echo 'Using precompiled binary'\n\nall:\n\t@echo 'Using precompiled binary'\n")
18
+ exit
19
+ end
20
+
21
+ # On macOS, use Apple clang if available. Homebrew clang can't find
22
+ # system headers and produces broken binaries with MsQuic.
23
+ if RUBY_PLATFORM =~ /darwin/ && File.exist?("/usr/bin/clang")
24
+ RbConfig::CONFIG["CC"] = "/usr/bin/clang"
25
+ RbConfig::MAKEFILE_CONFIG["CC"] = "/usr/bin/clang"
26
+ end
27
+
28
+ # --- Locate libmsquic ---
29
+ # Priority:
30
+ # 1. Vendored submodule build (vendor/msquic/build/bin/Release/)
31
+ # 2. Shipped dylib in lib/quicsilver/ (checked into git, always available)
32
+ # 3. Auto-build from submodule source
2
33
 
3
- # Find MSQUIC in the submodule
4
34
  msquic_dir = File.expand_path('../../../vendor/msquic', __FILE__)
35
+ vendor_lib_dir = "#{msquic_dir}/build/bin/Release"
36
+ shipped_lib_dir = ext_dir # lib/quicsilver/ contains libmsquic.2.dylib
37
+
38
+ # A stale binary must not bypass the source patch when building from a checkout.
39
+ if File.exist?(File.join(msquic_dir, "CMakeLists.txt"))
40
+ MsquicPatch.apply!(msquic_dir)
41
+ end
42
+ needs_patched_build = File.exist?(File.join(msquic_dir, "CMakeLists.txt")) && !MsquicPatch.built?(msquic_dir)
43
+
44
+ # Find which directory has libmsquic
45
+ lib_dir = if !needs_patched_build && File.exist?(vendor_lib_dir) && Dir.glob("#{vendor_lib_dir}/libmsquic.*").any?
46
+ puts "Using vendored MsQuic from #{vendor_lib_dir}"
47
+ vendor_lib_dir
48
+ elsif !needs_patched_build && Dir.glob("#{shipped_lib_dir}/libmsquic*").any?
49
+ puts "Using shipped libmsquic from #{shipped_lib_dir}"
50
+ shipped_lib_dir
51
+ else
52
+ # Auto-build from submodule
53
+ puts "MsQuic not found, building from source..."
54
+
55
+ unless File.exist?(File.join(msquic_dir, 'CMakeLists.txt'))
56
+ Dir.chdir(gemspec_dir) do
57
+ system('git submodule update --init --recursive vendor/msquic') or
58
+ raise 'Failed to initialize MsQuic submodule'
59
+ end
60
+ end
5
61
 
6
- # Add MSQUIC include directory
7
- $CFLAGS << " -I#{msquic_dir}/src/inc"
8
- $CFLAGS << " -I#{msquic_dir}/src/inc/public"
62
+ MsquicPatch.apply!(msquic_dir)
63
+
64
+ cmake_args = ['-B build', '-DCMAKE_BUILD_TYPE=Release', '-DQUIC_TLS_LIB=quictls', '-DQUIC_LINUX_IOURING_ENABLED=OFF']
65
+ if RUBY_PLATFORM =~ /darwin/
66
+ cmake_args << '-DCMAKE_EXE_LINKER_FLAGS="-framework CoreServices"'
67
+ cmake_args << '-DCMAKE_SHARED_LINKER_FLAGS="-framework CoreServices"'
68
+ sdk_path = `xcrun --show-sdk-path 2>/dev/null`.strip
69
+ cmake_args << "-DCMAKE_OSX_SYSROOT=#{sdk_path}" unless sdk_path.empty?
70
+ end
71
+
72
+ env = { 'PATH' => "/usr/bin:#{ENV['PATH']}" }
73
+ Dir.chdir(msquic_dir) do
74
+ system(env, "cmake #{cmake_args.join(' ')}") or raise 'MsQuic cmake configure failed'
75
+ system(env, 'cmake --build build --config Release') or raise 'MsQuic build failed'
76
+ MsquicPatch.record_build!(msquic_dir)
77
+ end
78
+
79
+ vendor_lib_dir
80
+ end
81
+
82
+ # Copy MsQuic next to the Ruby extension so @loader_path/@rpath works for
83
+ # Bundler git installs as well as local checkouts. GitHub checkouts do not have
84
+ # generated dylibs checked in, so when we build MsQuic from the submodule we
85
+ # must also place the runtime library where `require "quicsilver"` expects it.
86
+ FileUtils.mkdir_p(ext_dir)
87
+ Dir.glob(File.join(lib_dir, "libmsquic*")) do |library|
88
+ target = File.join(ext_dir, File.basename(library))
89
+ next if File.identical?(library, target) rescue false
90
+
91
+ FileUtils.cp(library, target)
92
+ end
93
+
94
+ # --- Locate MsQuic headers ---
95
+ # Available from vendored submodule or shipped in ext/quicsilver/include/
96
+ msquic_inc = "#{msquic_dir}/src/inc"
97
+ msquic_inc_public = "#{msquic_dir}/src/inc/public"
98
+ shipped_inc = File.join(File.dirname(__FILE__), 'include')
99
+
100
+ if File.exist?(msquic_inc)
101
+ $CFLAGS << " -I#{msquic_inc}"
102
+ $CFLAGS << " -I#{msquic_inc_public}"
103
+ elsif File.exist?(shipped_inc)
104
+ $CFLAGS << " -I#{shipped_inc}"
105
+ end
9
106
 
10
- # Add our fixes header
11
107
  $CFLAGS << " -I#{File.expand_path('.', __FILE__)}"
12
108
 
13
- # Add MSQUIC library directory
14
- lib_dir = "#{msquic_dir}/build/bin/Release"
15
109
  $LDFLAGS << " -L#{lib_dir}"
16
110
 
17
- # Set rpath so the extension can find the library at runtime
18
- $LDFLAGS << " -Wl,-rpath,#{lib_dir}"
111
+ if RUBY_PLATFORM =~ /darwin/
112
+ $LDFLAGS << " -Wl,-rpath,@loader_path"
113
+ $LDFLAGS << " -Wl,-rpath,@loader_path/.."
114
+ else
115
+ $LDFLAGS << " -Wl,-rpath,\\$$ORIGIN"
116
+ $LDFLAGS << " -Wl,-rpath,\\$$ORIGIN/.."
117
+ end
19
118
 
20
- # Find the MSQUIC library
21
119
  unless find_library('msquic', nil, lib_dir)
22
- raise "MSQUIC library not found. Please run 'rake build_msquic' first."
120
+ raise "MSQUIC library not found in #{lib_dir}. " \
121
+ "Ensure lib/quicsilver/libmsquic.2.dylib exists or run 'rake build_msquic'."
23
122
  end
24
123
 
25
- create_makefile('quicsilver/quicsilver')
124
+ create_makefile('quicsilver/quicsilver')