quicsilver 0.4.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 (94) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +9 -1
  3. data/.github/workflows/cibuildgem.yaml +29 -5
  4. data/.gitignore +5 -0
  5. data/CHANGELOG.md +50 -0
  6. data/Gemfile.lock +8 -1
  7. data/README.md +128 -15
  8. data/Rakefile +10 -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/benchmark.rb +80 -0
  17. data/examples/connection_pool_demo.rb +1 -1
  18. data/examples/example_helper.rb +51 -0
  19. data/examples/feature_demo.rb +7 -7
  20. data/examples/grpc_style.rb +2 -2
  21. data/examples/latency_aware_routing.rb +382 -0
  22. data/examples/lifecycle_drain.rb +264 -0
  23. data/examples/priorities.rb +2 -2
  24. data/examples/rails_feature_test.rb +92 -72
  25. data/examples/server_stats.rb +117 -0
  26. data/examples/simple_client_test.rb +3 -3
  27. data/examples/trailers.rb +23 -23
  28. data/examples/webtransport_smoke.rb +546 -0
  29. data/examples/worker_load_balancing.rb +425 -0
  30. data/ext/quicsilver/extconf.rb +100 -15
  31. data/ext/quicsilver/include/msquic.h +2226 -0
  32. data/ext/quicsilver/include/msquic_posix.h +696 -0
  33. data/ext/quicsilver/include/quic_sal_stub.h +314 -0
  34. data/ext/quicsilver/msquic_patch.rb +68 -0
  35. data/ext/quicsilver/patches/reliable-reset.patch +723 -0
  36. data/ext/quicsilver/patches/stream-final-size.patch +47 -0
  37. data/ext/quicsilver/quicsilver.c +1640 -222
  38. data/lib/quicsilver/client/client.rb +419 -60
  39. data/lib/quicsilver/client/connection_pool.rb +122 -26
  40. data/lib/quicsilver/client/request.rb +106 -14
  41. data/lib/quicsilver/errors.rb +39 -0
  42. data/lib/quicsilver/libmsquic.2.dylib +0 -0
  43. data/lib/quicsilver/protocol/adapter.rb +41 -21
  44. data/lib/quicsilver/protocol/capsule.rb +38 -0
  45. data/lib/quicsilver/protocol/connect_stream_decoder.rb +75 -0
  46. data/lib/quicsilver/protocol/control_stream_parser.rb +12 -1
  47. data/lib/quicsilver/protocol/datagram.rb +40 -0
  48. data/lib/quicsilver/protocol/frame_parser.rb +5 -6
  49. data/lib/quicsilver/protocol/frame_reader.rb +31 -2
  50. data/lib/quicsilver/protocol/frames.rb +128 -12
  51. data/lib/quicsilver/protocol/priority.rb +9 -0
  52. data/lib/quicsilver/protocol/qpack/decoder.rb +4 -42
  53. data/lib/quicsilver/protocol/qpack/encoder.rb +10 -20
  54. data/lib/quicsilver/protocol/qpack/huffman.rb +4 -4
  55. data/lib/quicsilver/protocol/request_encoder.rb +6 -3
  56. data/lib/quicsilver/protocol/request_parser.rb +16 -2
  57. data/lib/quicsilver/protocol/response_encoder.rb +18 -8
  58. data/lib/quicsilver/protocol/response_parser.rb +6 -0
  59. data/lib/quicsilver/protocol/stream_input.rb +3 -3
  60. data/lib/quicsilver/protocol/unidirectional_stream.rb +12 -0
  61. data/lib/quicsilver/protocol/web_transport.rb +214 -0
  62. data/lib/quicsilver/rack/context.rb +33 -0
  63. data/lib/quicsilver/response.rb +40 -0
  64. data/lib/quicsilver/server/rack_adapter.rb +77 -0
  65. data/lib/quicsilver/server/receive_queue.rb +106 -0
  66. data/lib/quicsilver/server/request_handler.rb +24 -13
  67. data/lib/quicsilver/server/request_registry.rb +18 -5
  68. data/lib/quicsilver/server/scheduler.rb +50 -0
  69. data/lib/quicsilver/server/schedulers/fiber_scheduler.rb +79 -0
  70. data/lib/quicsilver/server/schedulers/thread_scheduler.rb +76 -0
  71. data/lib/quicsilver/server/server.rb +655 -185
  72. data/lib/quicsilver/server/web_transport_flow_control.rb +270 -0
  73. data/lib/quicsilver/server/web_transport_manager.rb +281 -0
  74. data/lib/quicsilver/server/web_transport_registry.rb +40 -0
  75. data/lib/quicsilver/server/web_transport_session.rb +812 -0
  76. data/lib/quicsilver/server/web_transport_stream.rb +326 -0
  77. data/lib/quicsilver/transport/configuration.rb +103 -10
  78. data/lib/quicsilver/transport/connection.rb +302 -87
  79. data/lib/quicsilver/transport/connection_stats.rb +53 -0
  80. data/lib/quicsilver/transport/event_loop.rb +36 -0
  81. data/lib/quicsilver/transport/inbound_stream.rb +21 -1
  82. data/lib/quicsilver/transport/stream.rb +18 -3
  83. data/lib/quicsilver/transport/stream_event.rb +25 -2
  84. data/lib/quicsilver/transport/stream_id.rb +26 -0
  85. data/lib/quicsilver/version.rb +1 -1
  86. data/lib/quicsilver.rb +22 -11
  87. data/lib/rackup/handler/quicsilver.rb +57 -38
  88. data/quicsilver.gemspec +10 -3
  89. metadata +54 -8
  90. data/benchmarks/concurrent.rb +0 -110
  91. data/benchmarks/quicsilver_server.rb +0 -46
  92. data/benchmarks/rails.rb +0 -170
  93. data/benchmarks/throughput.rb +0 -113
  94. data/lib/quicsilver/quicsilver.bundle +0 -0
@@ -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."
@@ -0,0 +1,141 @@
1
+ # Connection identity and QUIC routing IDs
2
+
3
+ ## Rack request identity
4
+
5
+ Rack apps receive these env values:
6
+
7
+ - `env["quicsilver.connection_id"]`
8
+ - Opaque QUIC connection identity.
9
+ - Stable across requests/streams on the same QUIC connection.
10
+ - Currently backed by MsQuic's original destination connection ID.
11
+ - Do not parse this for load-balancing or backend routing decisions.
12
+
13
+ - `env["quicsilver.stream_id"]`
14
+ - QUIC stream id for this HTTP/3 request.
15
+ - Different requests on the same connection have different stream ids.
16
+
17
+ - `env["quicsilver.request_id"]`
18
+ - Recommended app/log/correlation identity.
19
+ - Format without fixed server id:
20
+ - `<connection_id>:<stream_id>`
21
+ - Format with fixed server id:
22
+ - `<transport_server_id>:<connection_id>:<stream_id>`
23
+ - This is the value applications and middleware should prefer.
24
+
25
+ Quicsilver does not expose separate Rack env fields for transport-only routing metadata such as `transport_server_id` or the original destination CID. Use `quicsilver.request_id` for request correlation.
26
+
27
+ ## Fixed MsQuic Server ID
28
+
29
+ `transport_server_id` configures MsQuic's fixed 4-byte Server ID.
30
+
31
+ - Purpose:
32
+ - Allows QUIC-aware load balancers to route using the server id embedded in MsQuic-generated connection IDs.
33
+ - Useful after the server has generated CIDs for an established connection.
34
+
35
+ - Configuration:
36
+
37
+ ```ruby
38
+ config = Quicsilver::Transport::Configuration.new(
39
+ cert_file,
40
+ key_file,
41
+ transport_server_id: "01020304"
42
+ )
43
+ ```
44
+
45
+ - Rules:
46
+ - Must be exactly 4 bytes encoded as 8 hex characters.
47
+ - Hex is normalized to lowercase.
48
+ - Server startup applies this before MsQuic is opened.
49
+ - This is MsQuic process-global state, so set it once per server process/pod.
50
+
51
+ - Common deployment pattern:
52
+
53
+ ```ruby
54
+ config = Quicsilver::Transport::Configuration.new(
55
+ cert_file,
56
+ key_file,
57
+ transport_server_id: ENV["QUICSILVER_TRANSPORT_SERVER_ID"]
58
+ )
59
+ ```
60
+
61
+ ```sh
62
+ QUICSILVER_TRANSPORT_SERVER_ID=01020304 bundle exec ruby server.rb
63
+ ```
64
+
65
+ When configured, Rack request ids include the server id prefix:
66
+
67
+ ```text
68
+ 01020304:7f9a23c001de88b0:4
69
+ ```
70
+
71
+ ## CIBIR ID
72
+
73
+ `cibir_id` configures MsQuic CIBIR: Connection ID Based Implicit Routing.
74
+
75
+ - Purpose:
76
+ - Provides routing bytes in the client's destination connection ID.
77
+ - Useful for routing the first packet/initial connection to the right listener or shard.
78
+ - The meaning of the bytes is application/load-balancer defined.
79
+
80
+ - Server/listener configuration:
81
+
82
+ ```ruby
83
+ config = Quicsilver::Transport::Configuration.new(
84
+ cert_file,
85
+ key_file,
86
+ cibir_id: "0a"
87
+ )
88
+ ```
89
+
90
+ - Client configuration:
91
+
92
+ ```ruby
93
+ client = Quicsilver::Client.new(
94
+ "example.com",
95
+ 4433,
96
+ transport_cibir_id: "0a"
97
+ )
98
+ ```
99
+
100
+ - Rules:
101
+ - Must be 1..6 bytes encoded as an even-length hex string.
102
+ - Hex is normalized to lowercase.
103
+ - MsQuic currently supports CIBIR at offset `0` only.
104
+ - CIBIR must be configured consistently on both ends:
105
+ - Server/listener: `cibir_id: "0a"`
106
+ - Client: `transport_cibir_id: "0a"`
107
+ - If one side uses CIBIR and the other side does not, MsQuic rejects the connection during handshake.
108
+ - If the client sends the wrong CIBIR bytes, packets may not route to the configured listener/backend.
109
+ - CIBIR is transport routing configuration, not Rack request identity.
110
+
111
+ ## How the pieces differ
112
+
113
+ - `cibir_id`
114
+ - Client-selected routing bytes.
115
+ - Used at listener/connection start time.
116
+ - Helps route initial packets before server-generated CIDs matter.
117
+
118
+ - `transport_server_id`
119
+ - Server-selected fixed 4-byte id.
120
+ - Embedded by MsQuic in server-generated CIDs.
121
+ - Helps QUIC-aware load balancers route established connections by backend/server.
122
+
123
+ - `connection_id`
124
+ - App-facing opaque connection identity.
125
+ - Stable across streams on the same QUIC connection.
126
+ - Not guaranteed to be the same CID a load balancer sees on the wire.
127
+
128
+ - `stream_id`
129
+ - Per-request QUIC stream identity.
130
+
131
+ - `request_id`
132
+ - App-facing request correlation value.
133
+ - Combines the stable connection identity and stream id.
134
+ - Includes `transport_server_id` as a prefix when configured.
135
+
136
+ ## Recommended usage
137
+
138
+ - Log `env["quicsilver.request_id"]` for application request correlation.
139
+ - Treat `env["quicsilver.connection_id"]` as opaque.
140
+ - Configure `transport_server_id` when QUIC-aware infrastructure needs backend routing by server-generated CIDs.
141
+ - Configure `cibir_id` / `transport_cibir_id` together when client-chosen destination CID bytes are part of your routing scheme.
@@ -15,6 +15,9 @@ app = ->(env) {
15
15
  case env["PATH_INFO"]
16
16
  when "/large"
17
17
  [200, { "content-type" => "application/octet-stream" }, ["x" * 50_000]]
18
+ when "/work"
19
+ sleep 0.001
20
+ [200, { "content-type" => "application/json" }, ['{"ok":true}']]
18
21
  else
19
22
  [200, { "content-type" => "application/json" }, ['{"ok":true}']]
20
23
  end
@@ -102,6 +105,83 @@ end
102
105
 
103
106
  client.disconnect
104
107
 
108
+ # Cleanup
109
+ Quicsilver::Client.close_pool
110
+ # === 5. Multi-threaded concurrency ===
111
+ puts "\nšŸ“Š Multi-threaded — concurrent clients, #{ITERATIONS} total requests"
112
+ puts "-" * 60
113
+
114
+ [1, 2, 4, 8].each do |thread_count|
115
+ requests_per_thread = ITERATIONS / thread_count
116
+ err_count = 0
117
+ mu = Mutex.new
118
+
119
+ # Connect all clients sequentially (connection setup is serialized)
120
+ clients = thread_count.times.map do
121
+ c = Quicsilver::Client.new(HOST, PORT, unsecure: true, request_timeout: 10)
122
+ c.open_connection
123
+ c
124
+ end
125
+
126
+ # Run requests in parallel
127
+ start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
128
+ threads = clients.map do |client|
129
+ Thread.new do
130
+ requests_per_thread.times do
131
+ client.get("/ping")
132
+ rescue => e
133
+ mu.synchronize { err_count += 1 }
134
+ end
135
+ end
136
+ end
137
+ threads.each(&:join)
138
+ elapsed = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start) * 1000).round(1)
139
+
140
+ clients.each(&:disconnect)
141
+
142
+ rps = (ITERATIONS / (elapsed / 1000.0)).round(0)
143
+ err = err_count > 0 ? " (#{err_count} errors)" : ""
144
+ puts " #{thread_count.to_s.rjust(3)} threads: #{elapsed.to_s.rjust(7)}ms #{rps} req/s#{err}"
145
+ sleep 0.1
146
+ end
147
+
148
+ # === 6. Multi-threaded with simulated work ===
149
+ puts "\nšŸ“Š Multi-threaded with 1ms app work, 50 total requests"
150
+ puts "-" * 60
151
+
152
+ [1, 2, 4, 8].each do |thread_count|
153
+ reqs = 50
154
+ requests_per_thread = reqs / thread_count
155
+ err_count = 0
156
+ mu = Mutex.new
157
+
158
+ clients = thread_count.times.map do
159
+ c = Quicsilver::Client.new(HOST, PORT, unsecure: true, request_timeout: 10)
160
+ c.open_connection
161
+ c
162
+ end
163
+
164
+ start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
165
+ threads = clients.map do |client|
166
+ Thread.new do
167
+ requests_per_thread.times do
168
+ client.get("/work")
169
+ rescue => e
170
+ mu.synchronize { err_count += 1 }
171
+ end
172
+ end
173
+ end
174
+ threads.each(&:join)
175
+ elapsed = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start) * 1000).round(1)
176
+
177
+ clients.each(&:disconnect)
178
+
179
+ rps = (reqs / (elapsed / 1000.0)).round(0)
180
+ err = err_count > 0 ? " (#{err_count} errors)" : ""
181
+ puts " #{thread_count.to_s.rjust(3)} threads: #{elapsed.to_s.rjust(7)}ms #{rps} req/s#{err}"
182
+ sleep 0.1
183
+ end
184
+
105
185
  # Cleanup
106
186
  Quicsilver::Client.close_pool
107
187
  server.stop
@@ -34,7 +34,7 @@ puts "-" * 50
34
34
  elapsed = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - t) * 1000).round(1)
35
35
 
36
36
  label = i == 0 ? "← new connection + QUIC handshake" : "← reused"
37
- puts " Request #{i}: #{response[:status]} — #{elapsed}ms #{label}"
37
+ puts " Request #{i}: #{response.status} — #{elapsed}ms #{label}"
38
38
  end
39
39
 
40
40
  puts "\n Pool: #{Quicsilver::Client.pool.size} connection(s) ready for reuse"