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
data/Rakefile CHANGED
@@ -1,11 +1,35 @@
1
+ require "bundler/setup"
1
2
  require "bundler/gem_tasks"
2
3
  require "rake/testtask"
3
4
  require "rake/extensiontask"
5
+ require_relative "ext/quicsilver/msquic_patch"
4
6
 
5
7
  Rake::ExtensionTask.new('quicsilver') do |ext|
6
8
  ext.lib_dir = 'lib/quicsilver'
7
9
  end
8
10
 
11
+ # Keep `rake compile` useful in source checkouts, but let cibuildgem own
12
+ # extension compilation after the workflow has built and bundled MsQuic.
13
+ task :compile => [:build_msquic, :bundle_msquic] unless ENV["CIBUILDGEM"]
14
+
15
+ # Copy MsQuic dylib next to the compiled extension for gem packaging
16
+ task :bundle_msquic do
17
+ lib_dir = 'lib/quicsilver'
18
+ if RUBY_PLATFORM =~ /darwin/
19
+ dylib = 'vendor/msquic/build/bin/Release/libmsquic.2.dylib'
20
+ if File.exist?(dylib)
21
+ cp dylib, "#{lib_dir}/libmsquic.2.dylib"
22
+ # Update rpath so the bundle finds the dylib in the same directory
23
+ sh "install_name_tool -add_rpath @loader_path #{lib_dir}/quicsilver.bundle 2>/dev/null || true"
24
+ end
25
+ elsif RUBY_PLATFORM =~ /linux/
26
+ so = 'vendor/msquic/build/bin/Release/libmsquic.so.2'
27
+ cp so, "#{lib_dir}/libmsquic.so.2" if File.exist?(so)
28
+ end
29
+ end
30
+
31
+ task :build => :bundle_msquic
32
+
9
33
  task :setup do
10
34
  # Initialize git submodule if it doesn't exist
11
35
  unless File.exist?('vendor/msquic')
@@ -15,13 +39,20 @@ task :setup do
15
39
  end
16
40
 
17
41
  task :build_msquic => :setup do
18
- cmake_args = ['-B build', '-DCMAKE_BUILD_TYPE=Release']
42
+ MsquicPatch.apply!(File.expand_path("vendor/msquic", __dir__))
43
+ cmake_args = ['-B build', '-DCMAKE_BUILD_TYPE=Release', '-DQUIC_TLS_LIB=quictls', '-DQUIC_LINUX_IOURING_ENABLED=OFF']
19
44
  if RUBY_PLATFORM =~ /darwin/
20
45
  cmake_args << '-DCMAKE_EXE_LINKER_FLAGS="-framework CoreServices"'
21
46
  cmake_args << '-DCMAKE_SHARED_LINKER_FLAGS="-framework CoreServices"'
47
+ # Ensure QuicTLS uses Xcode SDK, not Homebrew OpenSSL
48
+ sdk_path = `xcrun --show-sdk-path 2>/dev/null`.strip
49
+ cmake_args << "-DCMAKE_OSX_SYSROOT=#{sdk_path}" unless sdk_path.empty?
22
50
  end
23
- sh "cd vendor/msquic && cmake #{cmake_args.join(' ')}"
24
- sh 'cd vendor/msquic && cmake --build build --config Release'
51
+ # Override PATH so QuicTLS openssldir detection finds system openssl, not Homebrew
52
+ env = { 'PATH' => "/usr/bin:#{ENV['PATH']}" }
53
+ sh env, "cd vendor/msquic && cmake #{cmake_args.join(' ')}"
54
+ sh env, 'cd vendor/msquic && cmake --build build --config Release'
55
+ MsquicPatch.record_build!(File.expand_path("vendor/msquic", __dir__))
25
56
  end
26
57
 
27
58
  task :build => [:build_msquic, :compile]
@@ -29,9 +60,50 @@ task :build => [:build_msquic, :compile]
29
60
  Rake::TestTask.new(:test) do |t|
30
61
  t.libs << "test"
31
62
  t.libs << "lib"
63
+ t.ruby_opts << "-W0"
32
64
  t.test_files = FileList["test/**/*_test.rb"]
33
65
  end
34
66
 
67
+ Rake::TestTask.new(:test_unit) do |t|
68
+ t.libs << "test"
69
+ t.libs << "lib"
70
+ t.ruby_opts << "-W0"
71
+ t.test_files = FileList["test/**/*_test.rb"].reject { |f|
72
+ f.include?("integration") || f.include?("stream_control") ||
73
+ f =~ /quicsilver_test|event_loop_test/
74
+ }
75
+ end
76
+
77
+ Rake::TestTask.new(:test_integration) do |t|
78
+ t.libs << "test"
79
+ t.libs << "lib"
80
+ t.ruby_opts << "-W0"
81
+ t.test_files = FileList[
82
+ "test/stream_control_integration_test.rb",
83
+ "test/integration/**/*_test.rb",
84
+ "test/quicsilver_test.rb",
85
+ "test/event_loop_test.rb"
86
+ ]
87
+ end
88
+
89
+ desc "Run unit and integration tests in parallel"
90
+ task :test_parallel do
91
+ threads = []
92
+ results = {}
93
+
94
+ threads << Thread.new {
95
+ results[:unit] = system("bundle exec rake test_unit 2>&1 > /dev/null")
96
+ }
97
+ threads << Thread.new {
98
+ results[:integration] = system("bundle exec rake test_integration 2>&1 > /dev/null")
99
+ }
100
+
101
+ threads.each(&:join)
102
+ unless results.values.all?
103
+ abort "Tests failed: #{results.inspect}"
104
+ end
105
+ end
106
+
35
107
  namespace :benchmark do
36
108
  desc "Run throughput benchmark"
37
109
  task :throughput do
@@ -0,0 +1,175 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Quicsilver baseline benchmark with a single-file Rails app.
5
+ #
6
+ # HTTP/3 is always TLS, so the client connects by the name on the certificate
7
+ # and the server binds an address that name resolves to. Dialling 127.0.0.1
8
+ # against a certificate issued for localhost fails verification, and on macOS
9
+ # localhost resolves to ::1 first, so binding 127.0.0.1 is unreachable. Run
10
+ # `bake localhost:install` once so the localhost CA is trusted.
11
+ #
12
+ # Read Failed before Req/s. A server that sheds reports better latency than one
13
+ # that serves, because rejecting is quicker than answering.
14
+ #
15
+ # STREAMS is the only knob that loads the server. With one connection and one
16
+ # stream nothing is ever concurrent, so Req/s is just 1000 / p50.
17
+ #
18
+ # REQUESTS=2000 latency, one request at a time
19
+ # STREAMS=10 REQUESTS=2000 concurrent, inside capacity
20
+ # STREAMS=50 REQUESTS=5000 concurrent, above the advertised
21
+ # stream limit: QUIC makes the
22
+ # extra streams wait
23
+ # WORKERS=50 STREAMS=50 REQUESTS=5000 capacity matched to the load
24
+ # WORKLOAD=big REQUESTS=50 large bodies
25
+ # WORKLOAD=sleep SLEEP_SECONDS=0.1 REQUESTS=100 slow handler
26
+ #
27
+ # Capacity is WORKERS threads plus a queue of WORKERS * 4, and that total is
28
+ # what the server advertises as its stream limit. Ten requests is a warm-up,
29
+ # not a result: two runs of ten differed by 48% here.
30
+ #
31
+ # HOST name to connect to, must match the certificate (default localhost)
32
+ # ADDRESS address to bind (default ::1, what localhost resolves to first)
33
+
34
+ require 'bundler/inline'
35
+
36
+ gemfile do
37
+ source 'https://rubygems.org'
38
+ gem "rails"
39
+ gem "localhost"
40
+ gem "falcon"
41
+ gem "puma"
42
+ gem "protocol-http"
43
+ end
44
+
45
+ $LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
46
+
47
+ require "benchmark"
48
+ require "localhost/authority"
49
+ require "quicsilver"
50
+ require_relative "helpers"
51
+
52
+ REQUESTS = Integer(ENV.fetch("REQUESTS", "10"))
53
+ CONNECTIONS = Integer(ENV.fetch("CONNECTIONS", "1"))
54
+ STREAMS = Integer(ENV.fetch("STREAMS", "1"))
55
+ WORKERS = Integer(ENV.fetch("WORKERS", "5"))
56
+ WORKLOAD = ENV.fetch("WORKLOAD", "tiny")
57
+ SLEEP_SECONDS = Float(ENV.fetch("SLEEP_SECONDS", "0.1"))
58
+ HOST = ENV.fetch("HOST", "localhost")
59
+ PORT = Integer(ENV.fetch("PORT", Benchmarks.random_port.to_s))
60
+ PATH = Benchmarks.path_for(WORKLOAD)
61
+ APP = Benchmarks.rails_app(sleep_seconds: SLEEP_SECONDS, secret_key_base: "quicsilver-benchmark")
62
+
63
+ def start_server
64
+ authority = Localhost::Authority.fetch
65
+ config = Quicsilver::Transport::Configuration.new(authority.certificate_path, authority.key_path)
66
+ server = Quicsilver::Server.new(
67
+ PORT,
68
+ address: ENV.fetch("ADDRESS", "::1"),
69
+ app: APP,
70
+ server_configuration: config,
71
+ threads: WORKERS,
72
+ )
73
+
74
+ thread = Thread.new { server.start }
75
+ thread.abort_on_exception = true
76
+ wait_for_server(thread)
77
+ [server, thread]
78
+ end
79
+
80
+ def wait_for_server(thread)
81
+ deadline = Time.now + 10
82
+
83
+ while Time.now < deadline
84
+ abort "server exited while booting" unless thread.alive?
85
+
86
+ begin
87
+ client = Quicsilver::Client.new(HOST, PORT, connection_timeout: 500, request_timeout: 1)
88
+ client.open_connection
89
+ response = client.get("/")
90
+ return if response&.status == 200
91
+ warn "readiness check returned HTTP #{response&.status}: #{response&.body&.byteslice(0, 120).inspect}" if ENV["DEBUG"]
92
+ rescue StandardError => error
93
+ warn "readiness check failed: #{error.class}: #{error.message}" if ENV["DEBUG"]
94
+ sleep 0.1
95
+ ensure
96
+ client&.disconnect rescue nil
97
+ end
98
+ end
99
+
100
+ abort "server failed to boot: quicsilver://127.0.0.1:#{PORT}"
101
+ end
102
+
103
+ def stop_server(server, thread)
104
+ server&.stop rescue nil
105
+ thread&.join(5)
106
+ end
107
+
108
+ def start_request(connection, path)
109
+ connection.get(path) { |request| request }
110
+ end
111
+
112
+ def finish_request(request)
113
+ request.response
114
+ end
115
+
116
+ def request(connection, path)
117
+ finish_request(start_request(connection, path))
118
+ end
119
+
120
+ def open_connections
121
+ Array.new(CONNECTIONS) do
122
+ Quicsilver::Client.new(HOST, PORT, connection_timeout: 5000, request_timeout: 10).tap(&:open_connection)
123
+ end
124
+ end
125
+
126
+ def measure
127
+ connections = open_connections
128
+ # Untimed request per connection to warm Rails and establish steady connection state.
129
+ connections.each { |connection| request(connection, PATH) }
130
+
131
+ times = []
132
+ failed = 0
133
+
134
+ elapsed = Benchmark.realtime do
135
+ remaining = REQUESTS
136
+
137
+ while remaining.positive?
138
+ batch = []
139
+
140
+ connections.each do |connection|
141
+ STREAMS.times do
142
+ break if batch.length >= remaining
143
+
144
+ batch << [Benchmarks.now, start_request(connection, PATH)]
145
+ end
146
+ end
147
+
148
+ batch.each do |started_at, pending_request|
149
+ finish_request(pending_request)&.status == 200 ? times << (Benchmarks.now - started_at) : failed += 1
150
+ rescue StandardError
151
+ failed += 1
152
+ end
153
+
154
+ remaining -= batch.length
155
+ end
156
+ end
157
+
158
+ Benchmarks::Result.new(
159
+ requests: REQUESTS,
160
+ connections: CONNECTIONS,
161
+ streams: STREAMS,
162
+ times: times,
163
+ failed: failed,
164
+ elapsed: elapsed,
165
+ )
166
+ ensure
167
+ connections&.each { |connection| connection.disconnect rescue nil }
168
+ end
169
+
170
+ begin
171
+ server, thread = start_server
172
+ Benchmarks.print_result("Quicsilver Rails baseline", measure, path: PATH, workers: WORKERS)
173
+ ensure
174
+ stop_server(server, thread)
175
+ end
@@ -0,0 +1,398 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Quicsilver vs Puma vs Falcon, same Rack app, default configuration.
5
+ #
6
+ # This is the local-development number and nothing more. Every advantage HTTP/3
7
+ # has needs a condition localhost does not provide: 0-RTT needs a round trip to
8
+ # save, and no head-of-line blocking needs a lost or reordered packet. Loopback
9
+ # has neither. Expect HTTP/1.1 to win here, and read the caveats printed with
10
+ # the results before quoting any of it.
11
+ #
12
+ # ruby benchmarks/compare.rb
13
+ # REQUESTS=2000 CONCURRENCY=10 ruby benchmarks/compare.rb
14
+ # SERVERS=quicsilver,falcon ruby benchmarks/compare.rb
15
+ #
16
+ # Known unexplained variance: Quicsilver's throughput has measured anywhere
17
+ # from 2700 to 8900 req/s for the same code, same machine, isolated processes,
18
+ # while Falcon and Puma stayed within a few percent across the same shells. Do
19
+ # not quote a single figure from here until that is understood. Falcon and Puma
20
+ # comparisons are stable; the HTTP/3 number is not yet.
21
+ #
22
+ # CONCURRENCY is requests in flight, which each protocol reaches differently:
23
+ # HTTP/3 and HTTP/2 open that many streams on one connection, HTTP/1.1 opens
24
+ # that many connections, because it has no streams. That is the comparison, not
25
+ # a flaw in it.
26
+ #
27
+ # Defaults are what each server ships with, not matched capacity. Quicsilver
28
+ # advertises a stream limit of threads + queue and makes extra streams wait;
29
+ # Puma queues in the kernel backlog, default 1024; Falcon spawns a fiber per
30
+ # request. Those are three different answers to overload, and at high
31
+ # concurrency they matter more than the protocol.
32
+
33
+ require "bundler/inline"
34
+
35
+ gemfile do
36
+ source "https://rubygems.org"
37
+ gem "rails"
38
+ gem "localhost"
39
+ gem "falcon"
40
+ gem "puma"
41
+ gem "protocol-http"
42
+ gem "async-http"
43
+ # The impairment relay used by latency.rb and loss.rb. Benchmark-only: it is
44
+ # not a dependency of the library or of the app, so it lives here rather than
45
+ # in the Gemfile.
46
+ # IMPAIR_PATH points at a checkout, for running the benchmark against a
47
+ # relay change before it is pushed.
48
+ if (impair_path = ENV["IMPAIR_PATH"])
49
+ gem "impair", path: impair_path
50
+ else
51
+ gem "impair", github: "hahmed/impair"
52
+ end
53
+ end
54
+
55
+ $LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
56
+
57
+ require "async"
58
+ require "async/http"
59
+ require "async/http/endpoint"
60
+ require "falcon"
61
+ require "falcon/endpoint"
62
+ require "localhost/authority"
63
+ require "puma"
64
+ require "puma/configuration"
65
+ require "puma/launcher"
66
+ require "quicsilver"
67
+ require_relative "helpers"
68
+
69
+ REQUESTS = Integer(ENV.fetch("REQUESTS", "2000"))
70
+ CONCURRENCY = Integer(ENV.fetch("CONCURRENCY", "10"))
71
+ # How many transport connections each arm spreads its CONCURRENCY requests
72
+ # over. Browsers open six TCP connections to an origin and one QUIC
73
+ # connection, so the defaults are the browser shape: HTTP/1.1 on 6, HTTP/2
74
+ # and HTTP/3 on 1. Setting CONNECTIONS applies the same count to every arm,
75
+ # which is the other fair comparison.
76
+ RETRIES = Integer(ENV.fetch("RETRIES", "0"))
77
+ CONNECTIONS = {
78
+ "HTTP/1.1" => Integer(ENV.fetch("CONNECTIONS", ENV.fetch("H1_CONNECTIONS", "6"))),
79
+ "HTTP/2" => Integer(ENV.fetch("CONNECTIONS", ENV.fetch("H2_CONNECTIONS", "1"))),
80
+ "HTTP/3" => Integer(ENV.fetch("CONNECTIONS", ENV.fetch("H3_CONNECTIONS", "1"))),
81
+ }.freeze
82
+ WORKLOAD = ENV.fetch("WORKLOAD", "tiny")
83
+ PATH = Benchmarks.path_for(WORKLOAD)
84
+ SERVERS = ENV.fetch("SERVERS", "quicsilver,falcon,puma").split(",").map(&:strip)
85
+ RUNS = Integer(ENV.fetch("RUNS", "3"))
86
+ AUTHORITY = Localhost::Authority.fetch
87
+
88
+ # One plain Rack app. No framework, so the app contributes as little as
89
+ # possible: a framework would be measured three times and tell us nothing about
90
+ # the servers.
91
+ BODIES = {
92
+ "/" => Benchmarks::TINY_RESPONSE,
93
+ "/hello" => Benchmarks::HELLO_RESPONSE,
94
+ "/small" => Benchmarks::SMALL_RESPONSE,
95
+ "/big" => Benchmarks::BIG_RESPONSE
96
+ }.freeze
97
+
98
+ APP = lambda do |env|
99
+ body = BODIES.fetch(env["PATH_INFO"], Benchmarks::TINY_RESPONSE)
100
+ [200, {"content-type" => "text/plain", "content-length" => body.bytesize.to_s}, [body]]
101
+ end
102
+
103
+ Result = Struct.new(:server, :protocol, :times, :failed, :elapsed, keyword_init: true) do
104
+ def rps = times.length / elapsed
105
+ end
106
+
107
+ def free_port
108
+ socket = UDPSocket.new
109
+ socket.bind("127.0.0.1", 0)
110
+ socket.addr[1]
111
+ ensure
112
+ socket&.close
113
+ end
114
+
115
+ # === Quicsilver, HTTP/3 ===
116
+
117
+ # "localhost" resolves to ["::1", "127.0.0.1"] on macOS and the other order
118
+ # elsewhere, so a server bound to one family and a client dialling the name is
119
+ # a coin toss. Bind and dial the same literal. ::1 by default because the
120
+ # client prefers it; loss.rb overrides both so Puma, which binds v4 only, is
121
+ # on the same path as the other two.
122
+ QUIC_ADDRESS = ENV.fetch("QUIC_ADDRESS", "::1")
123
+
124
+ def with_quicsilver(port)
125
+ config = Quicsilver::Transport::Configuration.new(
126
+ AUTHORITY.certificate_path, AUTHORITY.key_path
127
+ )
128
+ server = Quicsilver::Server.new(port, address: QUIC_ADDRESS, app: APP, server_configuration: config)
129
+ Quicsilver::Server.instance = server
130
+ thread = Thread.new { server.start }
131
+ sleep 0.05 until server.running?
132
+ yield
133
+ ensure
134
+ server&.stop
135
+ thread&.join(5)
136
+ end
137
+
138
+ def measure_quicsilver(port)
139
+ times = []
140
+ failed = 0
141
+ mutex = Mutex.new
142
+ # CONNECTIONS["HTTP/3"] QUIC connections, each carrying a share of the
143
+ # CONCURRENCY streams. Dialling a literal means the certificate's
144
+ # "localhost" no longer matches the name, so the name check goes off. The
145
+ # TCP arms already run VERIFY_NONE in client_context, so this matches them
146
+ # rather than conceding anything.
147
+ clients = Array.new(CONNECTIONS["HTTP/3"]) do
148
+ Quicsilver::Client.new(QUIC_ADDRESS, port, request_timeout: 30, unsecure: true).tap { |c| c.get(PATH) }
149
+ end
150
+
151
+ started = Benchmarks.now
152
+ Benchmarks.distribute(REQUESTS, CONCURRENCY).each_with_index.map do |count, index|
153
+ client = clients[index % clients.length]
154
+ Thread.new do
155
+ count.times do
156
+ at = Benchmarks.now
157
+ response = client.get(PATH)
158
+ elapsed = Benchmarks.now - at
159
+ mutex.synchronize do
160
+ response&.status == 200 ? times << elapsed : failed += 1
161
+ end
162
+ rescue StandardError
163
+ mutex.synchronize { failed += 1 }
164
+ end
165
+ end
166
+ end.each(&:join)
167
+ elapsed = Benchmarks.now - started
168
+
169
+ clients.each(&:disconnect)
170
+ Result.new(server: "quicsilver", protocol: "HTTP/3", times: times, failed: failed, elapsed: elapsed)
171
+ end
172
+
173
+ # === Falcon, HTTP/2, and Puma, HTTP/1.1 ===
174
+ #
175
+ # Both over TLS with the same certificate, so the comparison is not encrypted
176
+ # against cleartext. async-http negotiates h2 with Falcon and http/1.1 with
177
+ # Puma via ALPN, so the protocol difference comes from the server.
178
+
179
+ def with_falcon(port)
180
+ # Falcon::Endpoint, not Async::HTTP::Endpoint with the bare localhost
181
+ # context: the bare context has no alpn_select_cb, so the server never
182
+ # agreed to h2 even when offered it, and every "HTTP/2" row before this
183
+ # was HTTP/1.1. Falcon's own endpoint installs the callback.
184
+ endpoint = Falcon::Endpoint.parse("https://localhost:#{port}")
185
+ container = nil
186
+ thread = Thread.new do
187
+ Async do
188
+ server = Falcon::Server.new(Falcon::Server.middleware(APP), endpoint)
189
+ container = server
190
+ server.run.wait
191
+ end
192
+ end
193
+ sleep 0.3
194
+ yield
195
+ ensure
196
+ thread&.kill
197
+ thread&.join(2)
198
+ end
199
+
200
+ def with_puma(port)
201
+ config = Puma::Configuration.new do |c|
202
+ c.ssl_bind "127.0.0.1", port, {
203
+ cert: AUTHORITY.certificate_path, key: AUTHORITY.key_path, verify_mode: "none"
204
+ }
205
+ c.app APP
206
+ c.log_requests false
207
+ c.silence_single_worker_warning
208
+ end
209
+ launcher = Puma::Launcher.new(config, events: Puma::Events.new)
210
+ thread = Thread.new { launcher.run }
211
+ sleep 0.6
212
+ yield
213
+ ensure
214
+ launcher&.stop
215
+ thread&.join(3)
216
+ end
217
+
218
+ def measure_async(name, protocol, port, host: "localhost")
219
+ times = []
220
+ failed = 0
221
+ elapsed = nil
222
+
223
+ Async do
224
+ wire = wire_protocol_for(protocol)
225
+ endpoint = Async::HTTP::Endpoint.parse("https://#{host}:#{port}#{PATH}",
226
+ protocol: wire, ssl_context: client_context(wire))
227
+ # Each Async::HTTP::Client is a connection pool, and the pool grows to
228
+ # meet demand: an HTTP/1.1 client with two requests in flight opens two
229
+ # connections, and "six connections" would quietly become ten. limit: 1
230
+ # makes a client one connection, so CONNECTIONS is what it says. HTTP/2
231
+ # multiplexes its share of the streams over that one.
232
+ # retries: 0, because async-http retries idempotent requests three times
233
+ # by default, and a benchmark that silently re-sends a request after the
234
+ # connection died reports the death as latency rather than as a failure.
235
+ # A browser does retry, so RETRIES=3 is the realistic setting; 0 is the
236
+ # one that shows what the transport did.
237
+ clients = Array.new(CONNECTIONS[protocol]) { Async::HTTP::Client.new(endpoint, limit: 1, retries: RETRIES) }
238
+ clients.first.get(PATH).finish
239
+ assert_negotiated!(clients.first, protocol)
240
+
241
+ started = Benchmarks.now
242
+ Benchmarks.distribute(REQUESTS, CONCURRENCY).each_with_index.map do |count, index|
243
+ client = clients[index % clients.length]
244
+ Async do
245
+ count.times do
246
+ at = Benchmarks.now
247
+ response = client.get(PATH)
248
+ response.finish
249
+ took = Benchmarks.now - at
250
+ response.status == 200 ? times << took : failed += 1
251
+ rescue StandardError
252
+ failed += 1
253
+ end
254
+ end
255
+ end.each(&:wait)
256
+ elapsed = Benchmarks.now - started
257
+ clients.each(&:close)
258
+ end
259
+
260
+ Result.new(server: name, protocol: protocol, times: times, failed: failed, elapsed: elapsed)
261
+ end
262
+
263
+ # A client context has to do two things, and the previous one did only the
264
+ # first. VERIFY_NONE, because the localhost certificate is self-signed and
265
+ # async-http's own default only skips verification when the host is spelled
266
+ # "localhost", not 127.0.0.1. And ALPN: passing any ssl_context replaces
267
+ # async-http's default, which is where alpn_protocols lived, so a context
268
+ # without it never offers h2 and the server falls back to HTTP/1.1. The falcon
269
+ # arm ran that way for every table before this was noticed, labelled HTTP/2
270
+ # the whole time.
271
+ def client_context(wire)
272
+ context = OpenSSL::SSL::SSLContext.new
273
+ context.verify_mode = OpenSSL::SSL::VERIFY_NONE
274
+ context.alpn_protocols = wire.names
275
+ context
276
+ end
277
+
278
+ # Pin the wire protocol to the arm's label rather than letting ALPN pick, so
279
+ # a server that only offers one cannot be measured under the other's name.
280
+ def wire_protocol_for(protocol)
281
+ case protocol
282
+ when "HTTP/2" then Async::HTTP::Protocol::HTTP2
283
+ when "HTTP/1.1" then Async::HTTP::Protocol::HTTP11
284
+ else raise ArgumentError, "unknown protocol #{protocol.inspect}"
285
+ end
286
+ end
287
+
288
+ # The protocol column is a measurement, not a label. Looks at the connection
289
+ # the warm-up request actually used and refuses to continue under the wrong
290
+ # name. Reaches into the pool because async-http has no public accessor for
291
+ # it; if that breaks on an upgrade, this is the one place to fix.
292
+ def assert_negotiated!(client, protocol)
293
+ pool = client.instance_variable_get(:@pool)
294
+ connection = pool.instance_variable_get(:@resources).keys.first
295
+ expected = (protocol == "HTTP/2") ? "HTTP2" : "HTTP1"
296
+ actual = connection.class.name[/HTTP\d/]
297
+ return if actual == expected
298
+
299
+ raise "#{protocol} arm negotiated #{connection.class}: ALPN did not select the protocol being measured"
300
+ end
301
+
302
+ # === Run ===
303
+
304
+ # Loadable as a harness: loss.rb reuses the server lifecycle and the
305
+ # measurement functions above.
306
+ # Each measurement runs in its own child process. Sharing one process makes
307
+ # the results depend on which server ran first: measured 8000 req/s for
308
+ # Quicsilver alone and 2000 for the same code after an earlier run in the same
309
+ # process, while Falcon and Puma were unaffected. A benchmark that can be
310
+ # contaminated by a previous measurement is not measuring the server.
311
+ #
312
+ # Spawned rather than forked, because macOS refuses to fork once threads have
313
+ # touched the Objective-C runtime.
314
+ def measure(name)
315
+ port = free_port
316
+ case name
317
+ when "quicsilver" then with_quicsilver(port) { measure_quicsilver(port) }
318
+ when "falcon" then with_falcon(port) { measure_async("falcon", "HTTP/2", port) }
319
+ when "puma" then with_puma(port) { measure_async("puma", "HTTP/1.1", port, host: "127.0.0.1") }
320
+ else warn "unknown server #{name.inspect}"
321
+ end
322
+ end
323
+
324
+ # IO.popen merges into the current environment rather than replacing it, and
325
+ # Bundler.original_env only reports what the shell already had. Variables
326
+ # Bundler *added* — BUNDLE_GEMFILE above all — would survive the merge, so map
327
+ # those to nil, which removes them.
328
+ CHILD_ENV = begin
329
+ original = defined?(Bundler) ? Bundler.original_env.to_h : {}
330
+ added = ENV.keys - original.keys
331
+ original.merge(added.to_h { |key| [key, nil] }).freeze
332
+ end
333
+
334
+ def run_child(name)
335
+ output = IO.popen(
336
+ # The child re-runs this file and re-evaluates the inline gemfile, which
337
+ # cannot activate falcon or puma while it inherits the parent's bundler
338
+ # environment: GEM_HOME, GEM_PATH and RUBYOPT all point at the repo's own
339
+ # gemspec-only Gemfile. Bundler.original_env is what the shell had before
340
+ # setup, so the child resolves the inline gemfile the way a bare ruby would.
341
+ CHILD_ENV.merge(
342
+ "BENCH_ONE" => name, "REQUESTS" => REQUESTS.to_s, "CONCURRENCY" => CONCURRENCY.to_s,
343
+ "WORKLOAD" => WORKLOAD, "RUNS" => "1"
344
+ ),
345
+ [RbConfig.ruby, __FILE__], err: (ENV["DEBUG"] ? :out : File::NULL), &:read
346
+ )
347
+ warn output if ENV["DEBUG"] && !output.include?("RESULT\t")
348
+ line = output.lines.find { |l| l.start_with?("RESULT\t") }
349
+ return nil unless line
350
+
351
+ _, server, protocol, rps, p50, p95, p99, failed = line.chomp.split("\t")
352
+ {server: server, protocol: protocol, rps: rps.to_f, p50: p50.to_f,
353
+ p95: p95.to_f, p99: p99.to_f, failed: failed.to_i}
354
+ end
355
+
356
+ # Child mode: measure one server and print a parseable line.
357
+ if (only = ENV["BENCH_ONE"])
358
+ result = measure(only)
359
+ if result
360
+ s = Benchmarks.stats(result.times)
361
+ puts format("RESULT\t%s\t%s\t%f\t%f\t%f\t%f\t%d",
362
+ result.server, result.protocol, result.rps, s[:p50], s[:p95], s[:p99], result.failed)
363
+ end
364
+ exit 0
365
+ end
366
+
367
+ if __FILE__ == $PROGRAM_NAME
368
+
369
+ puts "Quicsilver vs Puma vs Falcon — local development, default configuration"
370
+ puts "#{REQUESTS} requests, #{CONCURRENCY} in flight, path #{PATH.inspect}, all over TLS"
371
+ puts
372
+
373
+
374
+ results = []
375
+
376
+ SERVERS.each do |name|
377
+ runs = RUNS.times.filter_map { run_child(name) }
378
+ if runs.empty?
379
+ warn "#{name}: no runs completed"
380
+ next
381
+ end
382
+
383
+ median = runs.sort_by { |r| r[:rps] }[runs.length / 2]
384
+ results << [median, runs.map { |r| r[:rps] }]
385
+ end
386
+
387
+ puts format("%-12s %-9s %9s %9s %9s %9s %8s %16s",
388
+ "server", "protocol", "Req/s", "p50", "p95", "p99", "Failed", "Req/s range")
389
+ puts "-" * 94
390
+ results.each do |(result, rates)|
391
+ puts format(
392
+ "%-12s %-9s %9.0f %7.2fms %7.2fms %7.2fms %8d %16s",
393
+ result[:server], result[:protocol], result[:rps], result[:p50], result[:p95], result[:p99],
394
+ result[:failed],
395
+ (rates.length > 1 ? format("%.0f-%.0f", rates.min, rates.max) : "single run")
396
+ )
397
+ end
398
+ end