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
@@ -1,88 +1,162 @@
1
- require "bundler/setup"
2
- require "quicsilver"
3
- require "localhost/authority"
1
+ # frozen_string_literal: true
2
+
3
+ require "logger"
4
+ require "socket"
4
5
 
5
6
  module Benchmarks
6
- module Helpers
7
- # Boot a Quicsilver server on a random port, yield the port, then tear down.
8
- def self.with_server(app, &block)
9
- authority = Localhost::Authority.fetch
10
- config = Quicsilver::Transport::Configuration.new(
11
- authority.certificate_path,
12
- authority.key_path
13
- )
7
+ PATHS = {
8
+ "tiny" => "/",
9
+ "hello" => "/hello",
10
+ "small" => "/small",
11
+ "big" => "/big",
12
+ "sleep" => "/sleep",
13
+ "upload" => "/upload",
14
+ }.freeze
15
+
16
+ # Body a client POSTs to /upload. The server reads it to the end and answers
17
+ # OK, so this measures the receive path: native delivery, the copy into
18
+ # Ruby, and rack.input.
19
+ UPLOAD_BODY = ("u" * 1024 * 1024).b.freeze
20
+
21
+ TINY_RESPONSE = "OK"
22
+ HELLO_RESPONSE = "Hello World\n"
23
+ SMALL_RESPONSE = ("Hello World\n" * 10) * 10
24
+ BIG_RESPONSE = ("Hello World\n" * 1000) * 1000
25
+
26
+ Result = Struct.new(:requests, :connections, :streams, :times, :failed, :elapsed, keyword_init: true) do
27
+ def rps
28
+ times.length / elapsed
29
+ end
30
+ end
31
+
32
+ class << self
33
+ attr_accessor :sleep_seconds
34
+
35
+ def path_for(workload)
36
+ PATHS.fetch(workload) { abort "unknown WORKLOAD=#{workload.inspect}; use #{PATHS.keys.join(", ")}" }
37
+ end
14
38
 
15
- port = random_port
16
- server = Quicsilver::Server.new(port, app: app, server_configuration: config)
17
- server_thread = Thread.new { server.start }
18
- sleep 0.5 # let listener settle
39
+ def rails_app(sleep_seconds: 0.1, secret_key_base: "benchmark")
40
+ self.sleep_seconds = sleep_seconds
41
+ require "rails"
42
+ require "action_controller/railtie"
19
43
 
20
- yield port
44
+ initialize_rails_app(secret_key_base)
45
+ end
46
+
47
+ def random_port
48
+ server = TCPServer.new("127.0.0.1", 0)
49
+ server.addr[1]
21
50
  ensure
22
- server&.stop rescue nil
23
- server_thread&.join(2)
51
+ server&.close
52
+ end
53
+
54
+ def now
55
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
56
+ end
57
+
58
+ def distribute(total, buckets)
59
+ base, extra = total.divmod(buckets)
60
+ Array.new(buckets) { |index| base + (index < extra ? 1 : 0) }
24
61
  end
25
62
 
26
- # Compute latency stats from an array of float durations (in seconds).
27
- # Returns a Hash with :avg, :min, :max, :p50, :p95, :p99 (all in ms).
28
- def self.stats(times)
29
- return nil if times.empty?
63
+ def stats(times)
64
+ return empty_stats if times.empty?
30
65
 
31
66
  sorted = times.sort
32
67
  n = sorted.size
33
68
  {
34
69
  count: n,
35
- avg: (times.sum / n * 1000).round(2),
36
- min: (sorted.first * 1000).round(2),
37
- max: (sorted.last * 1000).round(2),
38
- p50: (sorted[n / 2] * 1000).round(2),
39
- p95: (sorted[(n * 0.95).to_i] * 1000).round(2),
40
- p99: (sorted[(n * 0.99).to_i] * 1000).round(2)
70
+ avg: (times.sum / n * 1000).round(2),
71
+ min: (sorted.first * 1000).round(2),
72
+ max: (sorted.last * 1000).round(2),
73
+ p50: percentile(sorted, 0.50),
74
+ p95: percentile(sorted, 0.95),
75
+ p99: percentile(sorted, 0.99),
41
76
  }
42
77
  end
43
78
 
44
- def self.print_header(title, **opts)
45
- puts
46
- puts "=" * 60
47
- puts title
48
- puts "=" * 60
49
- opts.each { |k, v| puts " #{k}: #{v}" }
50
- puts "-" * 60
79
+ def print_result(title, result, path:, workers:)
80
+ s = stats(result.times)
81
+
82
+ puts "\n#{title}"
83
+ puts "#{result.requests} requests, #{result.connections} connection(s), #{result.streams} stream(s) per connection, path #{path.inspect}"
84
+ puts "workers: #{workers}"
85
+ puts "-" * 76
86
+ puts format("%9s %8s %8s %8s %8s %7s", "Req/s", "p50", "p95", "p99", "max", "Failed")
87
+ puts "-" * 76
88
+ puts format(
89
+ "%9.0f %7.2fms %7.2fms %7.2fms %7.2fms %7d",
90
+ result.rps,
91
+ s[:p50],
92
+ s[:p95],
93
+ s[:p99],
94
+ s[:max],
95
+ result.failed,
96
+ )
51
97
  end
52
98
 
53
- def self.print_stats(label, times)
54
- s = stats(times)
55
- unless s
56
- puts " #{label}: no data"
57
- return
99
+ private
100
+ def empty_stats
101
+ { count: 0, avg: 0.0, min: 0.0, max: 0.0, p50: 0.0, p95: 0.0, p99: 0.0 }
58
102
  end
59
- puts " #{label}: #{s[:count]} reqs | avg=#{s[:avg]}ms p50=#{s[:p50]}ms p95=#{s[:p95]}ms p99=#{s[:p99]}ms (min=#{s[:min]}ms max=#{s[:max]}ms)"
60
- end
61
103
 
62
- def self.print_results(total_time:, total_requests:, times:, failed: 0, latency: false)
63
- s = stats(times)
64
- puts "-" * 60
65
- puts " Total time: #{total_time.round(3)}s"
66
- puts " Requests: #{total_requests} (#{failed} failed)"
67
- puts " Throughput: #{(total_requests / total_time).round(2)} req/s"
68
- if latency && s
69
- puts " Latency: avg=#{s[:avg]}ms p50=#{s[:p50]}ms p95=#{s[:p95]}ms p99=#{s[:p99]}ms"
70
- puts " min=#{s[:min]}ms max=#{s[:max]}ms"
104
+ def percentile(sorted_times, fraction)
105
+ index = (sorted_times.length * fraction).floor.clamp(0, sorted_times.length - 1)
106
+ (sorted_times[index] * 1000).round(2)
71
107
  end
72
- puts "=" * 60
73
- end
74
108
 
75
- def self.random_port
76
- server = TCPServer.new("127.0.0.1", 0)
77
- server.addr[1]
78
- ensure
79
- server&.close
80
- end
109
+ def initialize_rails_app(secret_key_base)
110
+ return Application if const_defined?(:Application, false)
81
111
 
82
- def self.benchmark_app
83
- ->(env) {
84
- [200, { "content-type" => "text/plain" }, ["OK"]]
85
- }
86
- end
112
+ controller = Class.new(ActionController::Base) do
113
+ def index
114
+ render plain: Benchmarks::TINY_RESPONSE
115
+ end
116
+
117
+ def hello
118
+ render plain: Benchmarks::HELLO_RESPONSE
119
+ end
120
+
121
+ def small
122
+ render plain: Benchmarks::SMALL_RESPONSE
123
+ end
124
+
125
+ def big
126
+ render plain: Benchmarks::BIG_RESPONSE
127
+ end
128
+
129
+ def sleep
130
+ Kernel.sleep(Benchmarks.sleep_seconds)
131
+ render plain: Benchmarks::TINY_RESPONSE
132
+ end
133
+ end
134
+ const_set(:BenchmarksController, controller)
135
+
136
+ app = Class.new(Rails::Application) do
137
+ config.root = __dir__
138
+ config.consider_all_requests_local = true
139
+ config.secret_key_base = secret_key_base
140
+ config.eager_load = false
141
+ config.enable_reloading = false
142
+ config.reload_classes_only_on_change = false
143
+ config.hosts.clear
144
+ config.logger = Logger.new(File::NULL)
145
+ config.public_file_server.enabled = false
146
+ config.active_support.to_time_preserves_timezone = :zone
147
+
148
+ routes.append do
149
+ root to: "benchmarks/benchmarks#index"
150
+ get "/hello", to: "benchmarks/benchmarks#hello"
151
+ get "/small", to: "benchmarks/benchmarks#small"
152
+ get "/big", to: "benchmarks/benchmarks#big"
153
+ get "/sleep", to: "benchmarks/benchmarks#sleep"
154
+ end
155
+ end
156
+ const_set(:Application, app)
157
+ app.initialize!
158
+
159
+ app
160
+ end
87
161
  end
88
162
  end
@@ -0,0 +1,128 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Quicsilver, Falcon and Puma over a link with a real round trip.
5
+ #
6
+ # This is the comparison loopback cannot give you. On localhost every protocol
7
+ # looks the same because there is no round trip to spend, and the benchmark
8
+ # measures Ruby rather than HTTP. Add a delay and the protocols start to differ
9
+ # for the reasons they were designed to.
10
+ #
11
+ # Loss is in the same currency on both sides: one in N packets costs the
12
+ # affected stream one round trip. On UDP that is a dropped datagram. On TCP the
13
+ # bytes cannot be dropped, so the stream stalls for a round trip instead, which
14
+ # is what the receiving kernel does while it waits for the retransmission. Same
15
+ # probability, same cost, and what is left over is the protocol's own
16
+ # behaviour. See Impair::Tcp for what that does and does not reproduce.
17
+ #
18
+ # ruby benchmarks/latency.rb
19
+ # DELAY_MS=5,25,50 REQUESTS=200 CONCURRENCY=20 ruby benchmarks/latency.rb
20
+ #
21
+ # DELAY_MS is one-way, so the round trip is twice it. Every server runs through
22
+ # a relay, including at 0ms, so the relay is in the baseline rather than a
23
+ # handicap applied to one side.
24
+
25
+ ENV["RUNS"] ||= "1"
26
+ require_relative "compare"
27
+ require "impair"
28
+
29
+ DELAYS_MS = ENV.fetch("DELAY_MS", "0,25").split(",").map { |value| Float(value) }
30
+ LOSS = Integer(ENV.fetch("LOSS", "0"))
31
+ REPEATS = Integer(ENV.fetch("REPEATS", "3"))
32
+ SEED = Integer(ENV.fetch("SEED", "1234"))
33
+
34
+ def median_of(values)
35
+ sorted = values.sort
36
+ sorted[sorted.length / 2]
37
+ end
38
+
39
+ def with_udp_relay(target_port, delay_ms)
40
+ proxy = Impair::Udp.new(
41
+ host: "::1", target_host: "::1", target_port: target_port,
42
+ delay: delay_ms / 1000.0, loss: LOSS, seed: SEED
43
+ ).start
44
+ yield proxy.port
45
+ ensure
46
+ proxy&.stop
47
+ end
48
+
49
+ # The TCP stall per lost segment is one round trip, and Impair derives that as
50
+ # 2 * delay (Impair::Config#rtt) rather than taking it separately: two knobs
51
+ # for one quantity is how the two arms drift apart. This passed rtt: as well,
52
+ # which Config now rejects outright, so this benchmark raised ArgumentError on
53
+ # every run. Dropping it preserves the behaviour, since the value passed was
54
+ # exactly 2 * delay.
55
+ def with_tcp_relay(target_port, delay_ms, host: "127.0.0.1")
56
+ proxy = Impair::Tcp.new(
57
+ host: host, target_host: host, target_port: target_port,
58
+ delay: delay_ms / 1000.0, loss: LOSS, seed: SEED
59
+ ).start
60
+ yield proxy.port
61
+ ensure
62
+ proxy&.stop
63
+ end
64
+
65
+ def measure_with_delay(name, delay_ms)
66
+ port = free_port
67
+ case name
68
+ when "quicsilver"
69
+ with_quicsilver(port) do
70
+ with_udp_relay(port, delay_ms) { |front| measure_quicsilver(front) }
71
+ end
72
+ when "falcon"
73
+ with_falcon(port) do
74
+ with_tcp_relay(port, delay_ms) { |front| measure_async("falcon", "HTTP/2", front) }
75
+ end
76
+ when "puma"
77
+ with_puma(port) do
78
+ with_tcp_relay(port, delay_ms) { |front| measure_async("puma", "HTTP/1.1", front, host: "127.0.0.1") }
79
+ end
80
+ end
81
+ end
82
+
83
+ puts "Quicsilver, Falcon and Puma over a delayed link"
84
+ puts "#{REQUESTS} requests, #{CONCURRENCY} in flight, #{REPEATS} repeats, all over TLS"
85
+ puts "delay is one-way in milliseconds, so the round trip is twice it"
86
+ puts "every server runs through an impairment relay, including at 0ms"
87
+ puts "loss 1-in-#{LOSS} packets, costing the affected stream one round trip" if LOSS.positive?
88
+ puts
89
+
90
+ rows = []
91
+
92
+ DELAYS_MS.each do |delay_ms|
93
+ %w[quicsilver falcon puma].each do |name|
94
+ attempts = REPEATS.times.filter_map do
95
+ result = measure_with_delay(name, delay_ms)
96
+ next unless result
97
+
98
+ stats = Benchmarks.stats(result.times)
99
+ {rps: result.rps, p50: stats[:p50], p95: stats[:p95], p99: stats[:p99], failed: result.failed}
100
+ rescue StandardError => error
101
+ warn "#{name} at #{delay_ms}ms failed: #{error.class}: #{error.message}"
102
+ warn error.backtrace.first(3).join("\n") if ENV["DEBUG"]
103
+ nil
104
+ end
105
+ next if attempts.empty?
106
+
107
+ rows << [delay_ms, name, attempts]
108
+ end
109
+ end
110
+
111
+ protocols = {"quicsilver" => "HTTP/3", "falcon" => "HTTP/2", "puma" => "HTTP/1.1"}
112
+
113
+ puts format("%-8s %-12s %-9s %9s %9s %9s %9s %8s",
114
+ "delay", "server", "protocol", "Req/s", "p50", "p95", "p99", "Failed")
115
+ puts "-" * 84
116
+ rows.each do |(delay_ms, name, attempts)|
117
+ puts format("%6.0fms %-12s %-9s %9.0f %7.2fms %7.2fms %7.2fms %8d",
118
+ delay_ms, name, protocols.fetch(name),
119
+ median_of(attempts.map { |a| a[:rps] }),
120
+ median_of(attempts.map { |a| a[:p50] }),
121
+ median_of(attempts.map { |a| a[:p95] }),
122
+ median_of(attempts.map { |a| a[:p99] }),
123
+ attempts.sum { |a| a[:failed] })
124
+ end
125
+
126
+ puts
127
+ puts "HTTP/1.1 opens a connection per concurrent request, so it pays the"
128
+ puts "handshake more often as delay rises. HTTP/2 and HTTP/3 multiplex over one."
@@ -0,0 +1,193 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Head-of-line blocking, measured on a damaged link.
5
+ #
6
+ # HTTP/2 multiplexes streams over one TCP connection, and TCP owes the
7
+ # application one ordered byte stream, so a lost packet stalls every stream
8
+ # sharing that connection. HTTP/3 scopes ordering to the stream, so a lost
9
+ # packet stalls only the streams whose data it carried. RFC 9114 1.1 puts it
10
+ # as: "because the parallel nature of HTTP/2's multiplexing is not visible to
11
+ # TCP's loss recovery mechanisms, a lost or reordered packet causes all active
12
+ # transactions to experience a stall regardless of whether that transaction was
13
+ # directly impacted by the lost packet."
14
+ #
15
+ # That is the central claim of HTTP/3, and loopback cannot show it, because
16
+ # loopback never drops a packet. So the packets are dropped in userspace:
17
+ #
18
+ # client ──▶ Impair::Udp (drops 1 in N) ──▶ Quicsilver HTTP/3
19
+ # client ──▶ Impair::Tcp (stalls + halves cwnd 1 in N) ──▶ Falcon HTTP/2
20
+ # client ──▶ Impair::Tcp (stalls + halves cwnd 1 in N) ──▶ Puma HTTP/1.1
21
+ #
22
+ # The three relays take the same link config. The TCP relay cannot drop bytes,
23
+ # so it charges what a drop costs instead: a one-RTT stall of the connection
24
+ # and a halved sender window for every segment the dice say was lost, an RTO
25
+ # and window collapse for a burst. Same loss probability per packet, same
26
+ # sender reaction; see Impair::Tcp for what that does and does not reproduce.
27
+ #
28
+ # Kernel shaping was tried first and abandoned. macOS dummynet does not shape
29
+ # the loopback interface: pings through a 3% pipe reported 0.0% loss, so the
30
+ # experiment was reporting "loss changes nothing" while dropping nothing. The
31
+ # relay counts what it drops, and a row is only reported when the relay
32
+ # confirms it saw every packet and the measured loss is close to the request.
33
+ #
34
+ # ruby benchmarks/loss.rb
35
+ # LOSS=0,50,20 DELAY_MS=15 BURST=5 REQUESTS=1500 ruby benchmarks/loss.rb
36
+ # SERVERS=quicsilver,puma ruby benchmarks/loss.rb
37
+ #
38
+ # LOSS is 1-in-N, the way MsQuic's emulated-performance runs express it, with 0
39
+ # meaning no loss. 10 is a brutal 10%; real networks are 1-in-100 or worse.
40
+ #
41
+ # With REPLAY=1 the HTTP/3 arm records its loss decisions and the TCP arms
42
+ # replay them, so all three see the identical pattern rather than the same
43
+ # seed. Whatever difference remains is the protocol.
44
+
45
+ ENV["RUNS"] ||= "1"
46
+ # Every arm binds and dials one literal so the resolver order of "localhost"
47
+ # cannot pick a family the relay is not listening on. Puma only binds v4.
48
+ ENV["QUIC_ADDRESS"] ||= "127.0.0.1"
49
+ require_relative "compare"
50
+ require "impair"
51
+
52
+ HOST = "127.0.0.1"
53
+ SERVERS = ENV.fetch("SERVERS", "quicsilver,falcon,puma").split(",")
54
+ LOSS_RATES = ENV.fetch("LOSS", "0,100,50,20,10").split(",").map { |value| Integer(value) }
55
+ REORDER = Integer(ENV.fetch("REORDER", "0"))
56
+ # Mean length of a loss burst. Real links lose packets in runs, not
57
+ # independently, and i.i.d. loss understates HTTP/3: a burst confined to one
58
+ # stream's packets stalls that stream, where TCP would stall all of them. The
59
+ # overall rate stays 1-in-LOSS; only the clustering changes. 0 is i.i.d.
60
+ BURST = Integer(ENV.fetch("BURST", "0"))
61
+ # One-way delay in milliseconds, so RTT is twice this. Loss without a round
62
+ # trip understates itself: loopback retransmits in microseconds, and the cost
63
+ # of a retransmission is one RTT. This is the axis that makes loss hurt.
64
+ DELAY_MS = Float(ENV.fetch("DELAY_MS", "0"))
65
+ SEED = Integer(ENV.fetch("SEED", "1234"))
66
+ REPLAY = ENV["REPLAY"] == "1"
67
+ # Repeats per row, because this server's own throughput varies enough between
68
+ # runs to swallow the effect being measured. A single run once showed higher
69
+ # throughput at 9% loss than at none, which is not a finding about loss.
70
+ REPEATS = Integer(ENV.fetch("REPEATS", "3"))
71
+
72
+ PROTOCOLS = {"quicsilver" => "HTTP/3", "falcon" => "HTTP/2", "puma" => "HTTP/1.1"}.freeze
73
+
74
+ def link_for(loss)
75
+ {
76
+ loss: loss, reorder: REORDER, delay: DELAY_MS / 1000.0, seed: SEED,
77
+ # burst is a clustering of loss, so it is meaningless without any, and
78
+ # Impair rejects the pair rather than silently ignoring it.
79
+ **(BURST.positive? && loss.positive? ? {burst: BURST} : {}),
80
+ }
81
+ end
82
+
83
+ # Runs +name+ behind the matching relay and yields the relay's port. Returns
84
+ # [result, relay] with the relay stopped.
85
+ def with_impaired(name, loss, replay: nil)
86
+ relay_class = (name == "quicsilver") ? Impair::Udp : Impair::Tcp
87
+ port = free_port
88
+ server = case name
89
+ when "quicsilver" then method(:with_quicsilver)
90
+ when "falcon" then method(:with_falcon)
91
+ when "puma" then method(:with_puma)
92
+ end
93
+
94
+ server.call(port) do
95
+ relay = relay_class.start(
96
+ host: HOST, target_host: HOST, target_port: port,
97
+ trace: REPLAY && replay.nil?, replay: replay, **link_for(loss)
98
+ )
99
+ begin
100
+ result = case name
101
+ when "quicsilver" then measure_quicsilver(relay.port)
102
+ else measure_async(name, PROTOCOLS.fetch(name), relay.port, host: HOST)
103
+ end
104
+ [result, relay]
105
+ ensure
106
+ relay.stop
107
+ end
108
+ end
109
+ end
110
+
111
+ def median_of(values)
112
+ sorted = values.sort
113
+ sorted[sorted.length / 2]
114
+ end
115
+
116
+ puts "Head-of-line blocking under induced packet loss"
117
+ puts "#{REQUESTS} requests, #{CONCURRENCY} in flight, #{REPEATS} repeats, through a userspace impairment relay"
118
+ puts "connections: " + CONNECTIONS.map { |proto, n| "#{proto} on #{n}" }.join(", ")
119
+ puts "loss is 1-in-N applied in both directions, seed #{SEED}"
120
+ puts "bursts averaging #{BURST} packets, overall rate unchanged" if BURST.positive?
121
+ puts "one-way delay #{DELAY_MS}ms, so about #{(DELAY_MS * 2).round}ms round trip" if DELAY_MS.positive?
122
+ puts "TCP arms replay the HTTP/3 arm's loss decisions" if REPLAY
123
+ puts
124
+
125
+ rows = []
126
+
127
+ LOSS_RATES.each do |loss|
128
+ SERVERS.each do |name|
129
+ attempts = REPEATS.times.filter_map do |repeat|
130
+ # With REPLAY, the HTTP/3 run of this row and repeat is the script.
131
+ replay = REPLAY && name != "quicsilver" ? rows.dig(-1, :traces, repeat) : nil
132
+ result, relay = with_impaired(name, loss, replay: replay)
133
+ counts = relay.counts
134
+
135
+ # Two things the relay must confirm before the row counts. That it saw
136
+ # every packet -- kernel drops before the relay are invisible to
137
+ # loss_rate -- and that the loss it applied is close to what was asked.
138
+ # The relay cannot know how many packets the client sent, so the first
139
+ # check is on its own queue only; the second catches the rest.
140
+ if counts.overrun.positive?
141
+ warn format(" %s 1-in-%d: relay overran its queue %d times, discarding", name, loss, counts.overrun)
142
+ next
143
+ end
144
+ expected = loss.zero? ? 0.0 : 1.0 / loss
145
+ if loss.positive? && counts.loss_rate < expected / 2
146
+ warn format(" %s 1-in-%d: relay dropped only %.2f%%, discarding", name, loss, counts.loss_rate * 100)
147
+ next
148
+ end
149
+
150
+ stats = Benchmarks.stats(result.times)
151
+ {rps: result.rps, p50: stats[:p50], p95: stats[:p95], p99: stats[:p99],
152
+ failed: result.failed, measured: counts.loss_rate,
153
+ longest_burst: counts.longest_burst, trace: relay.trace}
154
+ rescue StandardError => error
155
+ warn "#{name} 1-in-#{loss} failed: #{error.class}: #{error.message}"
156
+ warn error.backtrace.first(3).join("\n") if ENV["DEBUG"]
157
+ nil
158
+ end
159
+ next if attempts.empty?
160
+
161
+ rows << {loss: loss, name: name, attempts: attempts, traces: attempts.map { |a| a[:trace] }}
162
+ end
163
+ end
164
+
165
+ puts format("%-9s %-10s %-8s %8s %5s %7s %9s %9s %9s %6s %13s",
166
+ "loss", "server", "protocol", "measured", "burst", "Req/s", "p50", "p95", "p99", "Failed", "Req/s range")
167
+ puts "-" * 104
168
+ last_loss = nil
169
+ rows.each do |row|
170
+ puts if last_loss && row[:loss] != last_loss
171
+ last_loss = row[:loss]
172
+ attempts = row[:attempts]
173
+ rates = attempts.map { |a| a[:rps] }
174
+ puts format("%-9s %-10s %-8s %7.2f%% %5d %7.0f %7.2fms %7.2fms %7.2fms %6d %13s",
175
+ row[:loss].zero? ? "none" : "1-in-#{row[:loss]}",
176
+ row[:name], PROTOCOLS.fetch(row[:name]),
177
+ median_of(attempts.map { |a| a[:measured] }) * 100,
178
+ attempts.map { |a| a[:longest_burst] }.max,
179
+ median_of(rates),
180
+ median_of(attempts.map { |a| a[:p50] }),
181
+ median_of(attempts.map { |a| a[:p95] }),
182
+ median_of(attempts.map { |a| a[:p99] }),
183
+ attempts.sum { |a| a[:failed] },
184
+ (rates.length > 1 ? format("%.0f-%.0f", rates.min, rates.max) : "single"))
185
+ end
186
+
187
+ puts
188
+ puts "The relay is in the path for every row, including the first, so the"
189
+ puts "no-loss row is the baseline rather than a direct connection."
190
+ puts "Loss is applied to both directions, so a request and its response each"
191
+ puts "face the dice. On TCP a lost segment stalls the whole connection one RTT"
192
+ puts "and halves the sender's window (RFC 5681); a burst waits out the RTO and"
193
+ puts "collapses it. No SACK, so the TCP rows are still conservative in TCP's favour."
@@ -0,0 +1,125 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # A phone's network, for a few seconds at a time.
5
+ #
6
+ # The link's RTT swings, loss comes in bursts, and every couple of seconds
7
+ # the NAT forgets the client's mapping. QUIC was built for the last one:
8
+ # the client's source port changes, the server validates the new path, and
9
+ # the connection carries on (RFC 9000 §9). A TCP connection is identified by
10
+ # that port, so when it changes the connection is gone and every stream on
11
+ # it dies with it; the client starts over with a new handshake.
12
+ #
13
+ # client ──▶ Impair::Udp (rebind every 2s) ──▶ Quicsilver HTTP/3
14
+ # client ──▶ Impair::Tcp (reset every 2s) ──▶ Falcon HTTP/2
15
+ # client ──▶ Impair::Tcp (reset every 2s) ──▶ Puma HTTP/1.1
16
+ #
17
+ # rebind and reset are the same event seen by the two transports, which is
18
+ # why the schedule is one Scenario driving all three arms. RTT and loss ride
19
+ # along so the rebind lands on a busy link rather than a quiet one.
20
+ #
21
+ # ruby benchmarks/mobile.rb
22
+ # REBIND_EVERY=1 REQUESTS=2000 ruby benchmarks/mobile.rb
23
+ #
24
+ # Failed counts requests that got no 200, which for TCP includes every
25
+ # request in flight when the reset landed. That is the finding, not noise.
26
+
27
+ ENV["RUNS"] ||= "1"
28
+ ENV["QUIC_ADDRESS"] ||= "127.0.0.1"
29
+ require_relative "compare"
30
+ require "impair"
31
+
32
+ HOST = "127.0.0.1"
33
+ SERVERS = ENV.fetch("SERVERS", "quicsilver,falcon,puma").split(",")
34
+ REBIND_EVERY = Float(ENV.fetch("REBIND_EVERY", "2"))
35
+ DELAY_MS = Float(ENV.fetch("DELAY_MS", "20"))
36
+ LOSS = Integer(ENV.fetch("LOSS", "50"))
37
+ BURST = Integer(ENV.fetch("BURST", "5"))
38
+ REPEATS = Integer(ENV.fetch("REPEATS", "3"))
39
+ SEED = Integer(ENV.fetch("SEED", "1234"))
40
+ PROTOCOLS = {"quicsilver" => "HTTP/3", "falcon" => "HTTP/2", "puma" => "HTTP/1.1"}.freeze
41
+
42
+ # The one schedule. The RTT swings ±10ms around the base on a 6s period;
43
+ # the NAT rebinds on the interval. A TCP relay has no rebind, so it resets:
44
+ # that is what a rebind does to TCP.
45
+ SCENARIO = Impair::Scenario.new do
46
+ every(0.1) { |relay, t| relay.update(delay: DELAY_MS / 1000.0 + wave(t, amplitude: 0.010, period: 6)) }
47
+ every(REBIND_EVERY) do |relay, t|
48
+ next if t.zero? # the first tick is t=0; nothing to rebind yet
49
+
50
+ relay.respond_to?(:rebind) ? relay.rebind : relay.reset
51
+ end
52
+ end
53
+
54
+ def link
55
+ {loss: LOSS, burst: BURST, delay: DELAY_MS / 1000.0, seed: SEED}
56
+ end
57
+
58
+ def with_impaired(name)
59
+ relay_class = (name == "quicsilver") ? Impair::Udp : Impair::Tcp
60
+ port = free_port
61
+ server = case name
62
+ when "quicsilver" then method(:with_quicsilver)
63
+ when "falcon" then method(:with_falcon)
64
+ when "puma" then method(:with_puma)
65
+ end
66
+ server.call(port) do
67
+ relay = relay_class.new(host: HOST, target_host: HOST, target_port: port, **link).start
68
+ relay.run(SCENARIO)
69
+ begin
70
+ result = yield relay.port
71
+ [result, relay.stop]
72
+ ensure
73
+ relay.stop
74
+ end
75
+ end
76
+ end
77
+
78
+ def median_of(values)
79
+ sorted = values.sort
80
+ sorted[sorted.length / 2]
81
+ end
82
+
83
+ puts "A mobile link: RTT swinging, bursty loss, NAT rebinding every #{REBIND_EVERY}s"
84
+ puts "#{REQUESTS} requests, #{CONCURRENCY} in flight, #{REPEATS} repeats"
85
+ puts "connections: " + CONNECTIONS.map { |proto, n| "#{proto} on #{n}" }.join(", ")
86
+ puts format("base RTT %dms ±20ms, loss 1-in-%d in bursts of ~%d, seed %d", DELAY_MS * 2, LOSS, BURST, SEED)
87
+ puts "QUIC rebinds; TCP has no such thing, so it is reset on the same schedule"
88
+ puts "client retries: #{RETRIES}"
89
+ puts
90
+
91
+ rows = SERVERS.filter_map do |name|
92
+ attempts = REPEATS.times.filter_map do
93
+ result, counts = with_impaired(name) do |port|
94
+ (name == "quicsilver") ? measure_quicsilver(port) : measure_async(name, PROTOCOLS[name], port, host: HOST)
95
+ end
96
+ stats = Benchmarks.stats(result.times)
97
+ {rps: result.rps, p50: stats[:p50], p95: stats[:p95], p99: stats[:p99], failed: result.failed,
98
+ completed: result.times.size, events: counts.scenario_events, measured: counts.loss_rate}
99
+ rescue StandardError => error
100
+ warn "#{name} failed: #{error.class}: #{error.message}"
101
+ warn error.backtrace.first(3).join("\n") if ENV["DEBUG"]
102
+ nil
103
+ end
104
+ next if attempts.empty?
105
+
106
+ [name, attempts]
107
+ end
108
+
109
+ puts format("%-10s %-8s %8s %7s %9s %9s %9s %9s %7s", "server", "protocol", "measured", "Req/s", "p50", "p95", "p99", "completed", "Failed")
110
+ puts "-" * 86
111
+ rows.each do |(name, attempts)|
112
+ puts format("%-10s %-8s %7.2f%% %7.0f %7.2fms %7.2fms %7.2fms %9d %7d",
113
+ name, PROTOCOLS[name],
114
+ median_of(attempts.map { |a| a[:measured] }) * 100,
115
+ median_of(attempts.map { |a| a[:rps] }),
116
+ median_of(attempts.map { |a| a[:p50] }),
117
+ median_of(attempts.map { |a| a[:p95] }),
118
+ median_of(attempts.map { |a| a[:p99] }),
119
+ attempts.sum { |a| a[:completed] },
120
+ attempts.sum { |a| a[:failed] })
121
+ end
122
+ puts
123
+ puts "Failed is requests that got no 200, with RETRIES=#{RETRIES}. At 0 that is every"
124
+ puts "TCP request in flight when the reset landed; a browser would retry them,"
125
+ puts "so RETRIES=3 shows the user's view and 0 shows the transport's."